Skip to content

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

Open
seunlanlege wants to merge 3 commits into
mainfrom
fix/simplex-fee-token-approval
Open

seunlanlege wants to merge 3 commits into
mainfrom
fix/simplex-fee-token-approval

Conversation

@seunlanlege

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.

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

  • bid-approvals, bid-budget and bid-call-gas: 35 tests. 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.
  • CI test commands, locally: simplex lint exit 0; test:data 146, test:unit 261 and test:filler 59 passed.
  • Full offline suite: 1430 passed. The only failures are the 10 live/credential tests.
  • tsc shows only the 14 errors already on main.

Not covered: no mainnet run. 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

This branch has not been deployed

No deployments
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