fix(finalizer): tolerate unrecognised tokens in Arbitrum withdrawal discovery - #3706
fix(finalizer): tolerate unrecognised tokens in Arbitrum withdrawal discovery#3706droplet-rl wants to merge 9 commits into
Conversation
…iscovery arbStackFinalizer resolved each discovered withdrawal's token to an L2 address before it could build any finalization, so a token missing from the constants map aborted the entire run for that chain rather than affecting just that one withdrawal. The token only labels the finalization -- the Outbox proof is built from the L2 message alone -- so an unrecognised token is now warned about and finalized instead of throwing. The same case is handled at the getTokenInfo() call that formats the log line, which would otherwise have thrown a few lines later. Also drops the try/catch that was meant to skip unknown tokens: it wrapped only object construction, so the catch could never run. zkSync.ts already guards this case; arbStack was the gap. Co-Authored-By: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 69060d51d2
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
Replied inline. One comment, no code change: |
The logger already stringifies BigNumbers, but it does so via Object.fromEntries(), which collapses an enclosing array into an object keyed "0", "1", ... Record why the .toString() is load-bearing so it doesn't get tidied away. Co-Authored-By: Claude <noreply@anthropic.com>
|
Verified — you were right, my earlier answer was wrong. BigNumbers do log fine on their own; The different codepath you suspected is real though: that helper rebuilds via Kept it, added a comment recording why (0f6d9a9). Happy to drop it if you'd rather have the object form. |
…ubset used arbStack discovers withdrawals from the canonical gateways and ArbSys, not from the SpokePool, so there is no TokensBridged event and no relayer refund leaf behind them. Coercing them into the full type meant inventing a leafId of 0. Introduce ArbitrumWithdrawal = Pick<TokensBridged, "amountToReturn" | "l2TokenAddress" | "txnRef"> -- the only three fields the path reads: txnRef to fetch the receipt the Outbox proof is rebuilt from and to key getUniqueLogIndex, the other two to label the log line. Excess-property checking then makes the fictional leafId, and the unread chainId/txnIndex, compile errors. arbStack is the only finalizer where this is possible: opStack, zkSync and linea concat their synthetic events onto spokePoolClient.getTokensBridged(), so they stay pinned to the full type. Co-Authored-By: Claude <noreply@anthropic.com>
withdrawalEvents feeds straight into multicallArbitrumFinalizations, whose parameter type already pins the shape, so the local annotation adds nothing that the call site doesn't already enforce. Co-Authored-By: Claude <noreply@anthropic.com>
|
Not required — removed in 1ff6c2e. Note I fast-forwarded onto your master merge + c5b3ac8 first, so this sits on top of the current branch head. |
Pull l1Token/_to/_amount off e.args once at the top of the callback rather than reaching through e.args at each use. Hoisting the destructure also retires the separate EvmAddress binding, so the L2 fallback collapses to EvmAddress.from(l2Token ?? l1Token). Co-Authored-By: Claude <noreply@anthropic.com>
|
Applied in 248d608. One adjustment: as written the destructure sits inside the Also re-verified the |
|
@droplet babysit. Resolve any open comments. |
getL2TokenAddresses() defaults l1ChainId to mainnet and returns the first
TOKEN_SYMBOLS_MAP entry matching the L1 address, regardless of whether that
entry covers the target chain. Two recognised tokens therefore fell into the
new unknown-token path and were logged as UNKNOWN/18 decimals:
- On a Sepolia hub, every ERC20 withdrawal missed (USDC would have been
reported 10^12 out). arbStackFinalizer is wired up for ARBITRUM_SEPOLIA.
- ETH and WETH share an L1 address and ETH sorts first, but ETH has no
entry for chain 4663, so Robinhood WETH withdrawals missed too.
getRemoteTokenForL1Token() takes the hub chain explicitly and requires an
entry on the remote chain, so both resolve. Genuinely unrecognised tokens
still return undefined and keep the fallback.
Also shrink the .toString() comment to one line, per review.
Co-Authored-By: Claude <noreply@anthropic.com>
|
All 4 open threads addressed and resolved. One turned out to be a real bug. Codex P2 was right, both halves. Sepolia is live ( Comment shrunk to one line, as asked. L2 address via contract: queryable ( Annotation thread was already handled in 1ff6c2e. tsc/eslint/prettier clean, finalizer tests pass, CI green on the prior head. Note the last commit isn't pushed until checkpoint, so CI hasn't seen the token fix yet. |
Problem
arbStackFinalizerdiscovers withdrawals by querying the canonical Arbitrum gateways, then resolves each event's L1 token to an L2 address before it can build any finalization:getL2TokenAddressesreturnsundefinedfor any token absent fromTOKEN_SYMBOLS_MAP, so the assert throws inside the.map()— beforemulticallArbitrumFinalizationsis reached. The finalizer returns nothing, andfinalize()catches it per-chain, so every Arbitrum → Ethereum withdrawal in that run is skipped rather than just the one unrecognised token. Because the input is recomputed each loop, the condition persists until the event ages out of the lookback window.The
try/catcha few lines below, whosecatchlogs"Skipping ERC20 withdrawal event for unknown token", was intended to cover this — but it wraps only object construction and anArray.push, neither of which can throw, so that path was unreachable.Why finalize rather than skip
The token is not an input to the finalization.
finalizeArbitrumbuilds the OutboxexecuteTransactioncalldata entirely from the Arbitrum SDK message (getOutboxProof()plusnitroWriter.event);getUniqueLogIndexkeys ontxnRef. The only real consumer isgetTokenInfoinmulticallArbitrumFinalizations, which suppliessymbol/decimalsfor the log line.So an unrecognised token is a labelling gap, not a reason to withhold a withdrawal — and skipping would drop a legitimate withdrawal whenever a real token is missing from
constants(removed or renamed while a withdrawal sits mid-challenge-period). These withdrawals are already destined for an address we finalize for; the gateway queries filter onto ∈ {HubPool, SpokePool, AtomicDepositor, userAddresses}.Changes
logger.warnper run listing the unrecognised withdrawals (l1Token,to,amount,txnRef), so these stay visible.getTokenInfoat the log-line site is wrapped indescribeToken(), degrading toUNKNOWN/18. This was a second throw site — fixing only the assert would have relocated the failure, not removed it.try/catch.zkSync.tsalready carries the equivalent guard ("a single unknown token would otherwise abort finalization for every withdrawal on this chain");arbStackwas the gap. Polygon and Linea source their events from the SpokePool and sender-filtered queries respectively, so neither is affected.Testing
yarn build,eslintandprettierpass. No automated regression test — exercising the discovery path needs Arbitrum SDK receipts, Redis and block-time mocking. Happy to add one by extracting the event →TokensBridgedmapping into an exported pure helper (the waybuildFinalizationBatchesis exported for its test) if reviewers want it.Note for reviewers
The warn is deliberately not routed to
notificationPath: "across-error". Reasoning in the code comment; easy to flip if you disagree.Credit
Reported by Jagdish, who also offered to help test the fix. Thanks!
🤖 Generated with Claude Code