Skip to content

feat(bridge): bridge LPT between Ethereum and Arbitrum - #850

Merged
adamsoffer merged 236 commits into
nextfrom
arbitrum-bridge
Nov 11, 2026
Merged

adamsoffer merged 236 commits into
nextfrom
arbitrum-bridge

Conversation

@adamsoffer

@adamsoffer adamsoffer commented Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator

Bridge LPT between Ethereum and Arbitrum right from the explorer. Stacked on #828, so this diff is just the bridge.

What users get

  • Bridge LPT either way from one dialog
  • Claim withdrawals on Ethereum after Arbitrum's 7-day wait
  • Transfers in progress shown on account pages, with Claim and Retry buttons
  • Safes supported
  • Bridge from the account page, the connected wallet's menu or ⌘K
  • The delegate dialog points to the bridge when a wallet has LPT on Ethereum

Screenshots

Bridging Safe, choosing where it lands Safe, same Safe on both networks
Bridge dialog A Safe choosing where its LPT lands A Safe bridging to itself

Starting a bridge from the account page, where the LPT in the wallet is split by network:

The in-wallet chip's menu with LPT on Arbitrum and Ethereum

Transfers in progress, on the account page:

Pending transfers on an account page

Safety

  • Approvals are always for the exact amount
  • Claims are double-checked in the browser before signing
  • Signing stops if Arbitrum's bridge contracts change
  • A weekly check (once this is on the default branch) checks the bridge contracts, deposit fees and a simulated claim on mainnet, and opens an issue if something changes

Testing

  • Lint, typecheck, 251 tests, mainnet health check
  • Real mainnet claim, withdrawals and deposit
  • Safe flow

🤖 Generated with Claude Code

adamsoffer and others added 30 commits September 29, 2026 02:56
Deletes the pages-router app: Stitches styles, Apollo and its codegen,
and the hand-written components, hooks and layouts. Repository tooling
(CI, templates, husky, license, contributing docs) stays. The contract
ABIs are kept and move to lib/abis.

This commit doesn't build on its own; the next ones put the new app in
place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dh52qRhBrfkLKKAAGqPxYN
New package.json and lockfile, TypeScript, ESLint, Jest and PostCSS
config, and the Dockerfile. Old URLs such as /accounts/:a/delegating
and /transcoders redirect to their new pages. RainbowKit's Base Account wallet lazily pulls
in optional @x402/* modules, so Turbopack aliases them to an empty stub.
AGENTS.md and CLAUDE.md hold the Next.js notes for coding agents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dh52qRhBrfkLKKAAGqPxYN
Theme tokens for light, dark and system, the brand mark, buttons,
dialogs, tooltips, badges, sortable headers, page scaffolding and the
time-series chart, plus number and token formatting. Icons and the web
manifest are refreshed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dh52qRhBrfkLKKAAGqPxYN
A plain GraphQL client over The Graph's gateway (Studio URL without a
key), cursor paging that handles events sharing a timestamp, active-set
status from activation rounds, and queries for the network, orchestrators,
gateways, votes and accounts, wrapped in TanStack Query hooks.

lib/portfolio/compute.ts rebuilds stake, rewards and fees per round from
the cumulative reward and fee factors, so bonding and moving stake never
count as earnings. Orchestrator stats include realised APR, the yield at
today's cut and reward-call reliability. Tests cover the maths, paging,
activity status and search.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dh52qRhBrfkLKKAAGqPxYN
The portfolio is a list of addresses kept in the browser; connecting a
wallet adds its address, and any address or ENS name can be added and
renamed. Acting for an address that isn't connected is one "Connect
<name>" step, and the action resumes once that account connects.

One staking dialog handles delegate, stake more, move, unstake, restake
and withdrawals, including approval. Inside the Safe app, approve and bond
go out as one batch (EIP-5792), queued transactions show as sent to the
Safe, and a Safe's queued Livepeer actions are decoded locally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dh52qRhBrfkLKKAAGqPxYN
A vertical sidebar with navigation, the wallet menu, theme switcher and
a round clock that celebrates when a new round starts; a top bar with a
drawer on phones; ⌘K search across orchestrators (by ENS name or
address), gateways, addresses, pages and theme; and error and not-found
pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dh52qRhBrfkLKKAAGqPxYN
Treasury proposals and LIP polls with quorum and quota thresholds, vote
tallies and voting. Creating a poll lists LIPs that are "Proposed" and
have no poll yet; the server pins the official LIP text to IPFS before
calling PollCreator.createPoll. A treasury proposal takes a markdown
description, a receiver (address or ENS) and an LPT amount checked
against the treasury and your voting power. Open votes feed the
portfolio's Needs attention.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dh52qRhBrfkLKKAAGqPxYN
The home view charts stake, rewards, fees and network share across your
addresses, with realised APR, projected earnings and pending
withdrawals. Delegations adapt to the width they get, withdrawing fees
says what it will withdraw, and unstaked LPT gets a prompt that opens
the delegate flow pre-filled. Needs attention flags missed reward calls,
cut changes, inactive orchestrators, ready withdrawals, Safe actions and
open votes.

The earnings CSV has one row per wallet per round, valued in USD at the
hour of the reward call from Coinbase's hourly candles, served through a
cached route.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dh52qRhBrfkLKKAAGqPxYN
The list defaults to what a delegator joining now can expect, rather
than stake, so new stake isn't steered to whoever already has the most:
rewards over the last 30 rounds (lowered to today's cut when it was
raised, and flagged) plus 90 days of fees at today's fee share, valued at
today's ETH/LPT price, leaving out fees an orchestrator paid itself.
Newcomers get their own group and near-ties go to the smaller
orchestrator. Profiles show yield, stake and fee charts, the last 90
reward calls, cut history and delegators.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dh52qRhBrfkLKKAAGqPxYN
Fees paid, deposit and reserve, deposit runway, and payouts by
orchestrator, flagging a gateway that pays its own orchestrator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dh52qRhBrfkLKKAAGqPxYN
A round card with reward calls, LPT minted and fees; KPI tiles with
30-day sparklines you can scrub; history charts; and a paginated,
filterable activity feed searchable by address, ENS or transaction hash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dh52qRhBrfkLKKAAGqPxYN
Orchestrator, gateway, poll and proposal pages draw their own share card
and title, e.g. "titan-node · 32% APR · 30/30 reward calls"; the rest of
the site uses a static image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dh52qRhBrfkLKKAAGqPxYN
scripts/mock serves the queries from generated fixtures, with a
screenshot script for visual review. The README and .env.example cover
the new setup: the Graph API key, optional deployment pin, WalletConnect
and Pinata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dh52qRhBrfkLKKAAGqPxYN
Replaces the Conventional Commits guidance with an area-prefix style:
the area of the app, then a short imperative summary. PR titles follow
it too, since PRs are squash-merged and the title becomes the commit
on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings over the previous explorer's Vercel Web Analytics funnel (#809),
with the same event names, so the two versions can be compared event
for event in one Vercel project, split by hostname.

In v2 the Delegating tab on your own account is the portfolio with a
connected wallet, and switching orchestrator counts as a redelegation,
like "Move Delegated Stake" did. The unstaked LPT prompt and the
onboarding links into choosing an orchestrator are the plan's Earn
entry point, which the old explorer had no UI for. There's still no
separate review step, so delegation_review_opened stays unimplemented.

Events tracked on mount could run before <Analytics /> set up its queue
and be dropped, so the helper sets up the queue itself, with the URL
redaction registered ahead of any event. Safe proposals aren't tracked
as transactions, since they have no on-chain hash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both orchestrator queries wait for the protocol, and a query that's
waiting reports isLoading: false. The pages read that as loaded and
empty, so the server HTML, and the client until the protocol arrived,
showed "No orchestrators match" on the list and "Not an orchestrator"
on every profile, and hydration failed on the list. isPending covers
the wait. A failed protocol read now shows as the page's error, rather
than leaving the skeleton up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The onboarding card ranked on realised APR alone, broke ties toward
more stake and counted 29 of 30 reward calls as reliable, so its top
five were the biggest of the tied orchestrators rather than the list's.
It now reuses rankByYield, expected yield with fees and ties to the
smaller orchestrator, with 30 of 30 calls, matching "Reliable only".
The median realised APR beside it uses the same set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It was a card of its own between the stake chart and the figures,
weighing as much as the portfolio itself even for a little LPT. It's
now a short "N LPT unstaked" with a small outline Delegate button in
the Delegation(s) header, above the rows it would change, like the
Withdraw button on unclaimed fees. Several wallets get a menu, and a
wallet with no orchestrator gets "Choose orchestrator".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Each wallet's share of the portfolio is written out ("70% of total")
  instead of an unlabelled bar, which read like progress and looked
  broken for a small wallet.
- The subtitle says "3 wallets", or "2 delegations · 3 wallets" when
  some have none, rather than "3 across 3 wallets".
- Every account has a second line: its address under a label or name,
  "Connected", or "Not connected", so rows line up and show status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
At 260px, a 900px-tall screen showed only the Delegations heading. At
200px its first row and the unstaked LPT prompt are in view before any
scrolling, and the chart still reads as a trend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each item put both addresses on one line, "wallet → orchestrator", so
the orchestrator was cut off twice. The wallet is now the main line,
with "to <orchestrator>" muted under it and the amount on the right.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"Reliable only" put a full-width sentence between the controls and the
table, repeating what the info tooltip beside it already explains. The
toggle now carries the counts, "All active 100" and "Reliable only 89",
counted over the whole set rather than the search.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The chart stopped at a year. All goes back to the move to Arbitrum in
February 2022, about 1,700 days, read newest first in pages of 1,000
since that's the subgraph's limit. The KPIs above share the request.
Over that span weekly fee bars turn into hairlines, so All groups fees
by calendar month, dropping a partial first month like a partial week
and keeping this month as month to date.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
transferBond moves delegated stake between wallets without unbonding,
so moving into cold storage or a Safe, or out of a wallet at risk, no
longer means a week's unlock and lost rewards. It's offered from a
position's menu, and the Delegation card, when the portfolio has another
wallet to move to; addresses outside the portfolio are left for later,
since a mistyped one would lose the stake.

What the contract does, shown before signing:
- A receiver with no stake takes the sender's orchestrator and starts
  earning next round.
- A receiver already delegated keeps its orchestrator, and the moved
  stake joins it there, so a different one gets a warning.
- A receiver whose active orchestrator hasn't called reward this round
  is refused (ILLEGAL_CLAIM_EARNINGS) so it doesn't miss the round.
  The transfer is simulated first, and that case explains itself
  instead of failing in the wallet.

transferHints places the sender's orchestrator after the unbond and the
receiver's after both, as the contract updates the list in that order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Renamed from "move" to "Transfer stake": with any address allowed it
  reads like a wallet's transfer, and "stake" keeps it apart from
  sending LPT.
- The dialog shows From and To, like switching orchestrator does. To is
  a combobox, as in a wallet's send screen: the portfolio's wallets,
  each with where its stake is, or any address or ENS name, chosen as
  soon as it resolves.
- An address outside the portfolio has to be vouched for ("I control
  …") before Transfer is enabled, since stake sent to a wrong address
  can't be recovered. The sending wallet itself is refused.
- Transfers use the Send icon, in the activity feed too, which used the
  same icon as switching orchestrator.
- The address-or-ENS field logic is shared with Add address.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every account page lit up Portfolio, which read as "this address is
yours" for anyone's page opened from the feed or search, and on your
own wallets left no visible way back to the whole portfolio. Account
pages now highlight nothing, so Portfolio stays a link back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"✓ In your portfolio" read as a status but removed the wallet on click,
with no confirmation and losing its label. It now opens a menu with
Rename… and Remove from portfolio, the way the sidebar's wallet menu
does, and removing offers Undo, which restores the label. The heading
shows the label too, so a rename there shows up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
For a saved wallet, a small pencil after the heading opens the rename
dialog, the usual cue for an editable name; for an unnamed wallet it
invites naming it rather than leaving a raw address. Always visible,
since hover doesn't exist on touch. Rename stays in the menu too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The Delegation card's footer is one row wherever the card is narrow,
  on a phone or beside the side panels: Delegate more, Switch, and a
  menu for Transfer stake and Undelegate. It switches on the card's
  width, since at 1280px the full row ran past the edge.
- "About +N LPT per round" wraps as a sentence instead of splitting
  into columns.
- An orchestrator's terms wrap whole, so no line starts with a dot.
- An account's caption no longer repeats "In your portfolio" from the
  button beside it.
- Portfolio wallet cards put cut and fee share under the orchestrator
  they describe, not at the bottom.
- Orchestrator cards label the reward calls meter.
- Activity rows put the time under the sentence on phones, and the
  sideways-scrolling filter tabs no longer show a scrollbar. The global
  scrollbar style moved into the base layer so a utility can override
  it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Stale data: /api/subgraph-health compares the subgraph's indexed block
  with Arbitrum's chain head (v1's check), shared through a minute of
  caching. Over ~15 min behind, "Live" becomes "Delayed" and a banner
  says figures may be out of date; a halted subgraph still answers.
- paginate throws at its cap instead of returning a partial list that
  reads as complete. The default cap is 100,000; gateway payouts and
  self-paid fees opt into partial results, as they already handled.
- waitForIndexed aborts each probe at the remaining deadline, so a
  request that never answers can't hold the "updating" state forever.
- Poll pinning is cached per LIP and LIPs-repo commit. The same text
  gives the same IPFS hash, so repeat requests can't drain the Pinata
  quota.
- The treasury amount accepts one decimal point and at most 18
  decimals, so the amount shown is the amount encoded.
- The LIP picker uses native radios, for arrow keys and tab order.
- Dockerfile passes NEXT_PUBLIC_SUBGRAPH_DEPLOYMENT, which replaced
  NEXT_PUBLIC_SUBGRAPH_ID. The mock server keeps request data out of a
  format string, and its README no longer says it isn't committed.
- Poll eligibility keeps pendingStake(address, 0), with a note why: the
  round is ignored, and PollCreator.createPoll passes 0 itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Human review is needed to assess withdrawal availability during discovery failures and deposit recovery risks from caching unfinalized outcomes and pruning tickets whose timeouts may have been extended.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

Bridging everything prefilled the balance rounded down to four places,
leaving dust behind. It now fills in the exact amount, as Max and the
delegate dialog do, and the From card rounds down like the amount's own
label, so the two agree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Human assessment is needed for cross-component risks involving unfinalized receipt caching, batched-deposit tracking, transient claim failures, and narrow-screen layout.

No open issues 🎉

Review effort: Balanced

A deposit Arbitrum couldn't finish is retried from the explorer, not
Arbitrum's tool: the API now returns each deposit's ticket, and a retry
dialog redeems it on Arbitrum, then waits for the deposit to arrive.
Without a known ticket, the button still links to Arbitrum's tool.

When bridged LPT lands, a toast says so on any page, with Delegate for
the connected wallet: the whole amount to its orchestrator, or the list
to choose one.

A withdrawal can be saved to the calendar for its claim date, from the
success screen or Pending, and the dialog says before signing that it
has to be claimed after 7 days.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Successful retries can be misreported as stalled, and transaction-hash-only tracking can miss arrival events and balance refreshes for batched deposits.

2 open issues

Review effort: Balanced

Comment thread components/bridge/retry-dialog.tsx Resolved
Comment thread lib/bridge/pending.ts Resolved
The reminder was only an .ics file, which Google Calendar takes only by
importing it in settings. It's now a menu: Google Calendar opens its
new-event page filled in, and Apple Calendar or Outlook gets the file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One transaction can carry several deposits, but arrivals were tracked by
transaction alone, so one landing from a batch could hide the next. They
are now tracked by transaction and message number, a transaction sent
from here still counting for every deposit in it (landedSince, tested).

The retry dialog judged success by the deposit leaving the pending list,
which needed it seen there first: a list first loaded after the deposit
landed showed a working retry as stalled. It now reads the deposit's own
status, arrived included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The retry dialog can falsely report a stall after successful delivery, and batched deposits sharing a transaction can miss arrival notifications and Delegate actions.

No open issues 🎉

2 resolved since last review

Review effort: Balanced

Copilot AI balanced review requested due to automatic review settings November 4, 2026 03:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Human assessment is needed because transaction-replacement handling and pre-finality caching can falsely mark bridge transfers complete, while polling gaps can leave withdrawals undiscovered.

No open issues 🎉

Review effort: Balanced

ECWireless and others added 3 commits November 10, 2026 08:46
Preview deployments sit behind Vercel Deployment Protection, so every
Lighthouse run was auditing the Vercel login page instead of the app.

A puppeteerScript now sets Vercel's bypass cookie with the Protection
Bypass for Automation secret before the audits. The secret is never in
the Lighthouse settings, so it stays out of the reports uploaded to
public storage. The job fails if the secret is missing or the cookie
doesn't get through.

puppeteer-core is added to tools/lhci at the version Lighthouse already
locks, so the lockfile gains no new packages.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Copies main's CodeQL workflow (#833). It runs on pull requests into
main and next, which next's code scanning rule requires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in the Lighthouse fix (#865) and the CodeQL workflow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Terminal states are cached permanently from unfinalized Arbitrum reads, so a reorg can leave a stale arrived result hiding a still-retryable deposit and its expiry warning.

1 open issue

Review effort: Balanced

Comment thread lib/bridge/deposits-server.ts Resolved
adamsoffer and others added 2 commits November 10, 2026 11:07
A deposit's arrived, failed or lapsed state was remembered for good the
moment it was read, from blocks Arbitrum's sequencer could still roll
back. A rolled-back retry would then show as arrived, hiding the retry
the deposit still needed and its deadline. Now it's kept only once its
block is safe (posted to Ethereum, about ten minutes), and read again
until then. A gone ticket and the search for its redeems are read at the
same block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pull requests stacked on another branch, like this one on explorer-v2,
weren't scanned until they moved onto next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

A cancelled or replaced claim can be falsely reported as successful, marking a still-unclaimed withdrawal as claimed and hiding it for ten minutes.

1 open issue
1 resolved since last review

Review effort: Balanced

Comment thread components/bridge/claim-dialog.tsx Resolved
A wallet can replace a pending transaction at the same nonce. The receipt
wait follows it, so a cancellation, itself a successful transaction, was
reported as a successful claim: the withdrawal was marked claimed and
hidden while it still wasn't. A send or approval read the same way. Now
only a fee bump counts as the same transaction (as analytics already
does); anything else says the wallet cancelled or replaced it, and
nothing is marked done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@ECWireless ECWireless left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔥🔥🔥

staleTime: 60_000,
queryFn: async () => {
const [g1, g2] = await Promise.all([
l1!.getGasPrice(),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fee estimates are pretty far off on Ethereum. In my testing the dialog showed ~$0.07 and the transaction cost ~$0.70.

getGasPrice() is basically just the base fee right now (~0.04 gwei, no tip), but wallets add a priority fee on top. MetaMask's default is 2 gwei, so the tip ends up being almost the whole cost. The last few LPT deposits on mainnet each paid ~$0.72 in gas with a 2 gwei tip.

Same thing in claim-dialog.tsx (L101). It also feeds the "not enough ETH" checks (shortForDeposit here, short in the claim dialog), so a wallet with ~$0.10 of ETH passes the check and then the wallet asks for more. Could we price in a realistic priority fee (p90 from eth_feeHistory, or a 1–2 gwei floor), or show the estimate as an upper bound?

}

/** At most one scan in flight, and none more often than every 30s. */
async function refresh() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If scanNew() throws (rate limit, timeout), withdrawalsFor and claimArgs both throw too, even though known is still good up to scannedTo. That includes claiming a withdrawal that's already in the snapshot, which doesn't need the scan at all. scannedAt also only updates on success, so while the RPC is struggling every request reruns the full scan.

I checked this with mocked clients: with getLogs failing, the list and the claim both fail, and three requests trigger three rescans. Could refresh() catch, log, and serve what's known with a short backoff? deposits-server.ts has the same pattern.


export const l2 = createPublicClient({
chain: arbitrum,
transport: http(ARB_PUBLIC_RPC, { timeout: 20_000, retryCount: 2 }),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the only server client in the repo that doesn't go through fallback(RPC_URLS[...]). The wide log scans need the public endpoint, but the point reads (receipts, getTimeout, getBlock, constructOutboxProof) could try the configured RPCs first. Right now one rate-limited endpoint can take down withdrawals, claims and deposit status.

Comment thread lib/bridge/deposit.ts
}): DepositParams {
const minGas = 100_000n;
const maxGas = (gas * 13n) / 10n > minGas ? (gas * 13n) / 10n : minGas;
const gasPriceBid = l2GasPrice * 3n;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment above says this matches Arbitrum's SDK, but the SDK uses 6x here (DEFAULT_GAS_PRICE_PERCENT_INCREASE = 500 in ParentToChildMessageGasEstimator). The extra is refunded and costs about a cent, and a lower bid makes a failed auto-redeem more likely, especially for Safe proposals that get executed well after they're priced. Worth matching?

Comment thread package.json
"scripts": {
"dev": "next dev",
"build": "next build",
"build": "node scripts/bridge-withdrawals.mjs --soft && next build",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Running this before every build rewrites data/bridge-withdrawals.json, so a local pnpm build leaves a dirty tree (~200 line diff when I ran it). Could it only run for production/Vercel builds?

} catch (e) {
if (!noTicket(e)) throw e;
}
return settle(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: head is always ahead of the safe block, so at <= safe in settle never passes and lapsed or manually redeemed tickets never get cached. Every request reruns getTimeout and the log search for them. They're rare, so not a big deal, but reading at the safe block would let them settle.

Read the failing check. Usually either:
- Something the LPT bridge relies on changed on Ethereum or Arbitrum
(an upgrade, a new gateway, or fees). Review the change, then
update lib/bridge (and the pins in lib/bridge/health.live.ts).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the pins live in lib/bridge/guards.ts (PINS) now, not health.live.ts.

next's files match the explorer-v2 commit this branch already has,
so this keeps every file as it is and only clears the conflict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview — 45798328 Deployed Nov 11, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants