Repository navigation
[simplex]: approve the gateway for exactly what a fill pays, and clear the allowance after it - #1423
Conversation
Wizdave97
left a comment
There was a problem hiding this comment.
Verdict: approve with minor comments.
I traced every pull from the solver through IntentsBase._payLeg, ExtrinsicIntents._post, HyperApp.dispatchWithFeeToken and EvmHost.dispatch. The exact approval can't come in short:
- Fee pull. On a cross-chain fill the gateway pulls
request.fee, which isfillOptions.relayerFeein the destination fee token's decimals. There's no per-byte fee, and the host pulls from the gateway, not from the solver. - Per-leg pull.
paid = max(credited, ceil(offered·released/quoted)), which is never more thanoutputs[i].amount. That holds by hand and in a 230k-case randomised check. debitOrder. Itsusedcalculation is unchanged, and it ends withforceApprove(_intentGateway, 0), so skipping the budget token's reset is correct.- USDT. The existing
[0n, required]loop resets the allowance before setting it, so USDT-style tokens are fine. - Gas. 45k per token covers a cold
approve(0), the 0→x set and the warm reset. - Tests. They drive the real
prepareBidUserOpbatch and fail on the old code.
-
Allowances left by fills before this PR aren't cleared (ContractInteractionService.ts:1219).
- A solver that ran the old code may still have about 1.09e16 units of destination USDC approved to the gateway proxy. Only its next fill in that token on that chain clears it, through the leading
approve(0). - If the wallet stops filling that token or destination, the allowance stays indefinitely. That is the exposure this PR sets out to remove.
- Suggestion: at startup, read
allowance(solver, gateway)for the configured fee token, USDC and USDT on each chain, and zero any that aren't zero. Failing that, tell operators in the release notes to revoke these allowances.
- A solver that ran the old code may still have about 1.09e16 units of destination USDC approved to the gateway proxy. Only its next fill in that token on that chain clears it, through the leading
-
A comment and the changelog note misstate what the bundler estimate covers.
- ContractInteractionService.ts:59 and line 24 of the changelog note say it covers "the prepends and a lone
fillOrder". - But
estimateGasFillPostpassesprependCalls: undefined(:369). So the estimate covers a lonefillOrder, andFUNDING_GAS_PER_CALLcovers the prepends.
- ContractInteractionService.ts:59 and line 24 of the changelog note say it covers "the prepends and a lone
Notes:
- #1418's
assertUnsponsoredBidFundedreads the same localcallGasLimit, so it picks up the added per-token gas. The two PRs are compatible. - The only conflicts with #1411 and #1418 are mechanical, in
bid-budget.test.ts.
CI: the failing live job is unrelated. It runs SDK tests only. getRequestBscAmoy.test.ts timed out after self-delivery failed on eth_getProof with "missing trie node": the Chapel RPC had already discarded the state for that block.
- Revoke gateway allowances that fills before this change left standing. At boot, after delegation setup, with a signer, and on chains that are not watch-only, read allowance(solver, gateway) for the gateway's fee token plus the configured USDC and USDT, and send approve(gateway, 0) through TokenSender.submitCalls, one call per token. Best-effort and idempotent: each revoke is logged, a failure is a warning that does not block boot, and a revoked allowance reads zero on the next boot. Costs one small transaction per affected token and chain, once. - Correct the comment and the note: the bundler estimate covers a lone fillOrder (estimateGasFillPost passes no prepends); FUNDING_GAS_PER_CALL covers the prepends. - Revise the changelog note to match.
Send the boot-time gateway allowance revoke on its own EntryPoint nonce key so it cannot collide with the key-0 senders that run at boot, via an optional nonceKey on TokenSender.submitCalls. Correct the note's description of the sponsored/native path and its scope.
…r the allowance after it
- Revoke gateway allowances that fills before this change left standing. At boot, after delegation setup, with a signer, and on chains that are not watch-only, read allowance(solver, gateway) for the gateway's fee token plus the configured USDC and USDT, and send approve(gateway, 0) through TokenSender.submitCalls, one call per token. Best-effort and idempotent: each revoke is logged, a failure is a warning that does not block boot, and a revoked allowance reads zero on the next boot. Costs one small transaction per affected token and chain, once. - Correct the comment and the note: the bundler estimate covers a lone fillOrder (estimateGasFillPost passes no prepends); FUNDING_GAS_PER_CALL covers the prepends. - Revise the changelog note to match.
Send the boot-time gateway allowance revoke on its own EntryPoint nonce key so it cannot collide with the key-0 senders that run at boot, via an optional nonceKey on TokenSender.submitCalls. Correct the note's description of the sponsored/native path and its scope.
aa22560 to
e3935d6
Compare
Wizdave97
left a comment
There was a problem hiding this comment.
Approved. The comment and the changelog note now describe the bundler estimate correctly.
The boot-time revoke is safe:
- It is best-effort and runs after delegation.
- It is skipped for watch-only and signerless runs.
- It can't race a fill, because every bid resets the allowance in its own batch.
Non-blocking follow-ups:
- Old gateway address isn't swept. The revoke only checks the current gateway (gateway-allowance.ts:83).
- Mainnet moved from
0x16F9E57f735bBfF9f6c4E5276330f9c437d0e9E0to0xAe041F7B0CB581876832830baeB6a2Aa2a3C9716in #996. - The over-approval code predates that move, so allowances on the old address are never cleared.
- Fix: add the earlier addresses to the sweep, or add a revoke instruction to the release notes.
- Mainnet moved from
- PR description. It doesn't mention the boot revoke. Since it sends transactions from operators' wallets at startup, the description should say so, along with its scope and tests.
- Comment overstates. gateway-allowance.ts:66-68 says one chain's tokens share the nonce key "without competing". That only holds when each op confirms.
A cross-chain fill's batch approved the IntentGateway for far more than it pulls, and left the allowance standing afterwards. On mainnet, one fill from BSC into a 6-decimal USDC destination approved 10,901,000,000,036,480 USDC units (about 10.9 billion USDC); the gateway pulled 36,480 units, the dispatch fee. No funds moved, but every cross-chain fill left a huge standing allowance to an upgradeable proxy.
Why:
prepareBidUserOppassedtotalCostInSourceFeeToken + relayerFeeas the fee-token approval.GasEstimator.ts:434-436). BSC's USDC has 18 decimals, so 0.010901 USDC of gas became 10,901,000,000,000,000 and was added to a 6-decimal destination amount.Fix (
src/services/ContractInteractionService.ts):fillOptions.relayerFeein the fee token on a cross-chain fill.fillOptionsthe fill encodes, so the approval, the fill anddebitOrder'sfeecan't disagree.fillOrderanddebitOrder, every approved token getsapprove(gateway, 0), so nothing stands once the fill completes. The budget token is skipped, becausedebitOrderalready clears it.callGasLimitFornow addsAPPROVAL_GAS_PER_TOKEN(45k) per approved token. It is counted by the same helper that builds the approvals.Boot revoke of allowances older releases left (
src/services/gateway-allowance.ts, called frombootFiller;TokenSender.submitCallstakes an optional{ nonceKey })This sends transactions from the operator's wallet at startup, so its scope is deliberately narrow.
Why: the old approval stood on the gateway until a later fill in the same token on that chain reset it, which may never happen. Moving to a new gateway does not clear what was granted to the old one either.
What it does:
revokeStaleGatewayAllowancesreadsallowance(solver, gateway)and, for each non-zero one, sendsapprove(gateway, 0), one call per submission.RETIRED_GATEWAY_ADDRESSES, minus any equal to the current one. The list is per chain ID, from the history of the SDKchain.ts:0x2d61624A17f361020679FaA16fbB566C344AaF4B,0x09a74AA91eb532C21862d4C6222753e4fC7e22dBand0x16F9E57f735bBfF9f6c4E5276330f9c437d0e9E0, before the move to0xAe041F7B0CB581876832830baeB6a2Aa2a3C9716([sdk,indexer,simplex]: update mainnet IntentGateway and Solver addresses #996);0x16F9E57f735bBfF9f6c4E5276330f9c437d0e9E0;0xFbF50B2b32768127603cC9eF4b871574b881b8eDand0xE13fB34CAe12505ae51BaC8C405AF8EB27AC8058, before0x6CF42FA9BecbC5b6a26884964956b113530f7cFA.When it does not run: never on a chain configured watch-only, and not at all when the filler has no signer. It starts after delegation is set up and does not delay or fail boot.
How it sends:
submitCallstries a sponsored op when a paymaster and bundler are configured, and falls back to a native transaction from the wallet only when the bundler refuses it. A single call goes straight to the token, so the fallback works from a wallet that is not delegated yet. The sponsored op uses its own EntryPoint nonce key (a fixed hash ofsimplex.gateway-allowance-revoke, shifted to fit 192 bits), because boot also runs other key-0 senders; every othersubmitCallscaller keeps key 0.Best-effort: a failed read or revoke is logged as a warning and the rest carry on. One chain's revokes go one after another on the one key, which keeps them apart only while each op confirms; if one is left unconfirmed the next on that key fails and the next boot retries it.
Idempotent: a revoked allowance reads zero on the next boot, so nothing is sent. It costs one small transaction per affected token, gateway and chain, once.
Tests:
src/tests/gateway-allowance.test.tscoverssrc/tests/token-sender.test.tschecks thatnonceKeyreaches the sponsored op and is unset otherwise.I traced this through
IntentsBase._payLeg,ExtrinsicIntents,HyperAppandEvmHost: the gateway never pulls more than outputs plusrelayerFee, so an exact approval can't make a fill revert.debitOrder'sused = approved − allowance − feeis unchanged, because the gas term cancelled out in it anyway.Validation
vitest --watch=falseongateway-allowance,bid-approvals,bid-budget,bid-call-gasandtoken-sender: 5 files, 70 passed. They include:fillOrder/debitOrder, and the budget token's is skipped;gateway-allowance.test.ts: 22 tests).pnpm --filter="@hyperbridge/simplex":lintexit 0 (warnings only);test:data146 passed;test:unit261 passed;test:filler59 passed, 5 skipped.npx tsc --noEmit -p .: 14 errors, the ones already onmain; none in files this PR changes.Not covered: no mainnet run, so the retired addresses come from the SDK config history and were not read on chain. The audit had this as c30, refuted as "no money at stake". That holds only while the gateway proxy is fully trusted.