Skip to content

[simplex]: approve the gateway for exactly what a fill pays, and clear the allowance after it - #1423

Merged
seunlanlege merged 4 commits into
mainfrom
fix/simplex-fee-token-approval
Oct 20, 2026
Merged

seunlanlege merged 4 commits into
mainfrom
fix/simplex-fee-token-approval

Conversation

@seunlanlege

@seunlanlege seunlanlege commented Oct 17, 2026 •

Copy link
Copy Markdown
Member

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:

  • prepareBidUserOp passed totalCostInSourceFeeToken + relayerFee as the fee-token approval.
  • The first term is the fill-gas estimate, which the sdk converts into the source chain's fee-token decimals (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.
  • Gas doesn't belong in this approval at all: the paymaster pays it through Permit2, and the gateway only pulls the dispatch fee.
  • Nothing cleared the allowance after the batch.

Fix (src/services/ContractInteractionService.ts):

  • Exact approval. The gateway is approved for exactly what the fill pays: each ERC-20 output, plus fillOptions.relayerFee in the fee token on a cross-chain fill.
    • The builder reads the fee from the same fillOptions the fill encodes, so the approval, the fill and debitOrder's fee can't disagree.
    • The approval contains no gas term.
  • Allowance cleared. After fillOrder and debitOrder, every approved token gets approve(gateway, 0), so nothing stands once the fill completes. The budget token is skipped, because debitOrder already clears it.
  • Call gas covers the approvals. The bundler estimate never simulated them, so callGasLimitFor now adds APPROVAL_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 from bootFiller; TokenSender.submitCalls takes 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: revokeStaleGatewayAllowances reads allowance(solver, gateway) and, for each non-zero one, sends approve(gateway, 0), one call per submission.

    • Tokens: the gateway's fee token and the configured USDC and USDT, per chain. Other output tokens are not swept.
    • Gateways: the chain's current IntentGateway and every address listed for it in RETIRED_GATEWAY_ADDRESSES, minus any equal to the current one. The list is per chain ID, from the history of the SDK chain.ts:
      • mainnets 1, 10, 56, 100, 137, 8453 and 42161: 0x2d61624A17f361020679FaA16fbB566C344AaF4B, 0x09a74AA91eb532C21862d4C6222753e4fC7e22dB and 0x16F9E57f735bBfF9f6c4E5276330f9c437d0e9E0, before the move to 0xAe041F7B0CB581876832830baeB6a2Aa2a3C9716 ([sdk,indexer,simplex]: update mainnet IntentGateway and Solver addresses #996);
      • Polkadot Hub mainnet (420420419): 0x16F9E57f735bBfF9f6c4E5276330f9c437d0e9E0;
      • BSC Chapel (97) and Polygon Amoy (80002): 0xFbF50B2b32768127603cC9eF4b871574b881b8eD and 0xE13fB34CAe12505ae51BaC8C405AF8EB27AC8058, before 0x6CF42FA9BecbC5b6a26884964956b113530f7cFA.
      • A chain with no retired address does no extra reads. A later gateway move has to add the address it leaves behind to the list.
  • 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: submitCalls tries 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 of simplex.gateway-allowance-revoke, shifted to fit 192 bits), because boot also runs other key-0 senders; every other submitCalls caller 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.ts covers

    • a revoke only for a non-zero allowance, each on its own call and the one nonce key;
    • USDC and USDT reads, a fee token that is also USDC, and an unconfigured USDT;
    • watch-only chains left alone;
    • a failed revoke, a failed chain read and an unreadable fee token not stopping the rest;
    • a non-zero retired allowance revoked, zero sending nothing, a retired address equal to the current gateway skipped, a chain with no retired address reading nothing extra, and one chain's current and retired revokes never overlapping.

    src/tests/token-sender.test.ts checks that nonceKey reaches the sponsored op and is unset otherwise.

I traced this through IntentsBase._payLeg, ExtrinsicIntents, HyperApp and EvmHost: the gateway never pulls more than outputs plus relayerFee, so an exact approval can't make a fill revert. debitOrder's used = approved − allowance − fee is unchanged, because the gas term cancelled out in it anyway.

Validation

  • vitest --watch=false on gateway-allowance, bid-approvals, bid-budget, bid-call-gas and token-sender: 5 files, 70 passed. They include:
    • a reproduction of the mainnet case (18-decimal source fee token, 6-decimal destination, the same gas estimate): the approval is exactly outputs + fee, and the test fails on the old code;
    • an output in the fee token approved once;
    • a native output gets no approval;
    • two legs in one token get one summed approval;
    • the resets are ordered after fillOrder/debitOrder, and the budget token's is skipped;
    • the call-gas term for 1, 2 and 3 tokens;
    • the boot revoke cases listed above (gateway-allowance.test.ts: 22 tests).
  • pnpm --filter="@hyperbridge/simplex": lint exit 0 (warnings only); test:data 146 passed; test:unit 261 passed; test:filler 59 passed, 5 skipped.
  • npx tsc --noEmit -p .: 14 errors, the ones already on main; 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.

@Wizdave97 Wizdave97 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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 is fillOptions.relayerFee in 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 than outputs[i].amount. That holds by hand and in a 230k-case randomised check.
  • debitOrder. Its used calculation is unchanged, and it ends with forceApprove(_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 prepareBidUserOp batch and fail on the old code.
  1. 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.
  2. A comment and the changelog note misstate what the bundler estimate covers.

Notes:

  • #1418's assertUnsponsoredBidFunded reads the same local callGasLimit, 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.

seunlanlege added a commit that referenced this pull request Oct 19, 2026
- 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.
seunlanlege added a commit that referenced this pull request Oct 19, 2026
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.
- 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.
@seunlanlege
seunlanlege force-pushed the fix/simplex-fee-token-approval branch from aa22560 to e3935d6 Compare October 19, 2026 15:08

@Wizdave97 Wizdave97 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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 0x16F9E57f735bBfF9f6c4E5276330f9c437d0e9E0 to 0xAe041F7B0CB581876832830baeB6a2Aa2a3C9716 in #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.
  • 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.

@seunlanlege
seunlanlege merged commit d1fa52d into main Oct 20, 2026
1 of 2 checks passed
@seunlanlege
seunlanlege deleted the fix/simplex-fee-token-approval branch October 20, 2026 12:07
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.

2 participants