fix(gasless): reject unresolvable token pairs instead of throwing - #3758
fix(gasless): reject unresolvable token pairs instead of throwing#3758droplet-rl wants to merge 3 commits into
Conversation
isAllowedGaslessPair resolved both tokens to an L1 equivalent via a helper that asserts when a token has no hub-chain address. Tokens listed only on a remote chain (a chain's wrapped native asset, for example) have no such address, so the assert fired. The call was unguarded, so the throw escaped processDepositMessage. Since forEachAsync is Promise.all, that rejected the whole poll tick. The message never reached a terminal state, so it recurred every tick, and the fill lock taken immediately before the throw was never released, wedging later deposits from the same authorizer on that chain. Resolve through a new non-throwing variant and treat an unresolvable token as a rejected pair. Separately, wrap the per-message state machine in try/catch/finally so that any unexpected throw marks only that message ERROR and always releases its fill lock, rather than failing the batch. Refs BOU-54. Co-Authored-By: Claude <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
Two follow-ups to the error-isolation block, from a self-review pass: The `finally` released the fill lock unconditionally. A message that throws while spinning in the lock-wait branch does not hold the lock, so releasing it there would hand the lock to a second message while the holder's fill is still in flight - the same overlap the lock exists to prevent. Guard the delete on ownership; every normal exit path holds the lock, so releases are unaffected. The catch also set ERROR before logging, so the alert always recorded `state: ERROR` rather than the state the message actually died in. Widen the local `log` helper to accept "error" and log before the transition, which both captures the failing state and picks up the authorizer/nonce/token context the direct `logger.error` call was dropping - authorizer being the field that identifies which fill lock was involved. Co-Authored-By: Claude <noreply@anthropic.com>
|
Codex finished while I was looking at this: completed with no findings on I used the pass to self-review the block that comment was triggered on instead, and found two things wrong with my own 1. The if (this.fillLock[fillKey] === depositKey) {
delete this.fillLock[fillKey];
}Every normal exit path holds the lock at exit, so intended releases are unchanged — including the 2. The error log recorded the wrong state. The catch called Verification: One caveat worth flagging rather than hiding: I did not add a test for the ownership guard. Exercising it needs a throw from inside the lock-wait branch, which is only No doc change — per Since this was the one automated round: I think the change is complete as it stands, but the lock-ownership reasoning above is the part I'd most want a human to sanity-check. If you'd rather iterate further, assigning me to the PR raises the round budget. |
| log("warn", `Skipping expired deposit destined for ${origin}.`); | ||
| setState(MessageState.ERROR); | ||
| } | ||
| try { |
There was a problem hiding this comment.
Not keen on scoping this try statement so broadly. Narrow it to where it's actually needed.
There was a problem hiding this comment.
Agreed — narrowed it to a wrapper around the single processDepositMessage call at the forEachAsync site (9241b3ff).
That is where the concern actually lives: forEachAsync is Promise.all, so the hazard is one message's rejection taking the whole batch — a property of the batch boundary, not of any individual state transition. Wrapping the state machine to express it was the wrong altitude.
const processDepositMessageSafely = async (depositMessage: AnyGaslessDepositMessage) => {
const depositKey = this._getDepositKeyFromMessage(depositMessage);
const fillKey = this._getFillLockKeyFromMessage(depositMessage);
try {
await processDepositMessage(depositMessage);
} catch (error) { /* mark only this message ERROR */ }
finally { if (this.fillLock[fillKey] === depositKey) { delete this.fillLock[fillKey]; } }
};The state machine is now byte-identical to master — no try, no re-indentation, and the success-path delete this.fillLock[fillKey] stays exactly where it was. The finally is purely the error-path net (a no-op on the success path, since the lock is already released), still guarded on ownership so a throw from the lock-wait branch can't release a lock another message holds.
Diff on this file drops from 369 lines to 42, all additive. Also extracted _getFillLockKeyFromMessage so the wrapper and the state machine derive the fill key one way rather than two.
Worth noting the two concerns are now cleanly separated, which the tests show: the actual bug is fixed at source by the non-throwing resolver, and with the wrapper temporarily bypassed the token-map test still passes while only the isolation test fails. So the wrapper is a generic net, not load-bearing for this bug.
Review feedback: the try wrapped the whole ~200-line deposit state machine. Narrow it to a wrapper around the single processDepositMessage call at the forEachAsync site. That is where the concern actually lives - forEachAsync is Promise.all, so the hazard is one message's rejection taking the batch, which is a property of the batch boundary rather than of any state transition. The state machine is now byte-identical to master again: no try, no re-indentation, and the success-path lock release stays exactly where it was. The wrapper's finally is purely the error-path net, still guarded on lock ownership. Extract _getFillLockKeyFromMessage so the wrapper and the state machine derive the fill key one way instead of two. Diff on this file drops from 369 lines to ~45, all additive. Co-Authored-By: Claude <noreply@anthropic.com>
|
Thanks — good call, that What changed: the isolation moved to a wrapper around the single Consequences:
Verified: 85 passing / 0 failing across I also checked the wrapper is load-bearing rather than decorative: with it temporarily bypassed, the isolation test fails (52 passing, 1 failing) while the token-map test still passes. That's the separation I'd want — the actual bug is fixed at source by the non-throwing resolver, and the wrapper is only a generic net for unexpected throws. One thing worth flagging: my first commit on this pass accidentally staged two unrelated pre-existing working-tree deletions ( |
Problem
isAllowedGaslessPairresolves both tokens to an L1 equivalent viagetInventoryEquivalentL1TokenAddress, which asserts when a token has no hub-chain address. Tokens listed only on a remote chain — a chain's wrapped native asset, for example — have no such address, so the assert fires.The call is unguarded, so the throw escapes
processDepositMessage.forEachAsyncisPromise.all, so that rejects the whole poll tick (evaluateApiSignatures failed; batch skipped this tick). Two follow-on effects:A deposit naming a token the pinned
TOKEN_SYMBOLS_MAPdoesn't know is ordinary user input, not an error condition — it should be rejected, not thrown.Change
tryGetInventoryEquivalentL1TokenAddress, a non-throwing variant of the existing resolver.isAllowedGaslessPairuses it and treats an unresolvable token as a rejected pair. Valid pairs behave exactly as before.try/catch/finally: an unexpected throw now marks only that messageERROR, andfinallyalways releases the fill lock. The catch still logs aterror, so genuinely unexpected failures keep alerting — they just no longer take the batch or the lock down with them.Most of the diff in
GaslessRelayer.tsis re-indentation from thetry. Review withgit diff -w.Tests
Five added; three fail without this change:
isAllowedGaslessPair: rejects rather than throws for a token with no hub-chain equivalent, and for one absent from the map; still accepts a genuine same-L1 pair.ERRORrather than throwing.ERRORand releases its lock, while a second message sharing its authorizer still completes toFILLED.Refs BOU-54.