Repository navigation
feat(bridge): bridge LPT between Ethereum and Arbitrum - #850
Conversation
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>
There was a problem hiding this comment.
🔵 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>
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>
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>
There was a problem hiding this comment.
🔵 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
There was a problem hiding this comment.
🔵 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
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>
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>
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>
| staleTime: 60_000, | ||
| queryFn: async () => { | ||
| const [g1, g2] = await Promise.all([ | ||
| l1!.getGasPrice(), |
There was a problem hiding this comment.
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() { |
There was a problem hiding this comment.
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 }), |
There was a problem hiding this comment.
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.
| }): DepositParams { | ||
| const minGas = 100_000n; | ||
| const maxGas = (gas * 13n) / 10n > minGas ? (gas * 13n) / 10n : minGas; | ||
| const gasPriceBid = l2GasPrice * 3n; |
There was a problem hiding this comment.
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?
| "scripts": { | ||
| "dev": "next dev", | ||
| "build": "next build", | ||
| "build": "node scripts/bridge-withdrawals.mjs --soft && next build", |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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). |
There was a problem hiding this comment.
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>

Bridge LPT between Ethereum and Arbitrum right from the explorer. Stacked on #828, so this diff is just the bridge.
What users get
Screenshots
Starting a bridge from the account page, where the LPT in the wallet is split by network:
Transfers in progress, on the account page:
Safety
Testing
🤖 Generated with Claude Code