Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,16 @@ same cache entry as the filler outputs, so it is replaced and expires with them.
## The call

`buildApprovalAndFillCalldata` appends
`debitOrder(budgetId, cap, token, approved, fee)` (`SOLVER_ACCOUNT_ABI`) as the last call of the
bid's ERC-7821 batch, after the funding calls, the approvals and `fillOrder`. Its target is the
solver account itself.
`debitOrder(budgetId, cap, token, approved, fee)` (`SOLVER_ACCOUNT_ABI`) to the bid's ERC-7821
batch, after the funding calls, the approvals and `fillOrder`. It is the last call for the budget
token, and only the trailing zero-resets of other approved tokens (see
`2026-10-17-gateway-approval-is-exact.md`) follow it. Its target is the solver account itself.

- `approved` is exactly what the batch approves to the gateway for `token`.
- `fee` is `fillOptions.relayerFee` when the order is cross-chain and the fee token is `token`.
Otherwise it is zero. The batch sends no native value for the dispatch, so the gateway takes the
fee in the fee token. The approval for the fee token also covers the estimated gas cost, which
the gateway does not pull and which is not counted as paid out.
fee in the fee token. The approval for the fee token covers the outputs and this fee only, so
`approved` less `fee` is what the fill paid out.
- The account counts `approved`, less the allowance the gateway has left, less `fee`, and then
clears the allowance.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# The gateway approval is exactly what the fill pays

`buildApprovalAndFillCalldata` approves the IntentGateway for what the fill pays and nothing else:

- each ERC20 output, summed per token;
- on a cross-chain fill, `fillOptions.relayerFee` (the dispatch fee) added to the destination fee
token. An output in the fee token and the fee share one approval. A same-chain fill dispatches
nothing and approves no fee token. Native outputs (`ADDRESS_ZERO`) need no approval.

The fill's gas is not in the approval. The paymaster pays it through its Permit2 pull, and the
gateway pulls only the dispatch fee in the fee token. The cached `totalCostInSourceFeeToken` is in
the source chain's fee-token decimals and is used only by the profitability gates.

`fillOptions.relayerFee` is the only source of the dispatch fee: the approval, the `fillOrder`
calldata and `debitOrder`'s `fee` all read it. `approvalAmounts` builds the per-token amounts, and
both the batch and the call gas allowance use it.

Every approval is set `approve(gateway, 0)`, then `approve(gateway, amount)`. The batch ends with
`approve(gateway, 0)` for each token it approved, after `fillOrder` and after `debitOrder`, so no
allowance to the upgradeable gateway proxy stands once the batch completes. `debitOrder` reads the
allowance that is left, so the resets come after it. It also clears the budget token's allowance
itself, so a batch that settles a budget leaves that token out of the trailing resets.

The bundler estimates a lone `fillOrder` (`estimateGasFillPost` passes `prependCalls: undefined`),
so neither the funding prepends nor the approvals are in its figure. `FUNDING_GAS_PER_CALL` covers
the prepends, and `callGasLimitFor` adds `APPROVAL_GAS_PER_TOKEN` (45,000, for the three `approve`
calls a token costs) for each distinct non-zero ERC20 the batch approves: the non-native output tokens,
plus the fee token on a cross-chain fill unless an output already pays it.

## Allowances left by earlier releases are revoked at boot

A solver that ran the old code can still hold about 1.09e16 units of a destination fee token
approved to the gateway proxy on a chain, and only its next fill in that token there would clear it.
After delegation is set up, and only when the filler has a signer, `revokeStaleGatewayAllowances`
(`src/services/gateway-allowance.ts`, called from `bootFiller` once the `TokenSender` exists) reads
`allowance(solver, gateway)` for the gateway's fee token and the configured USDC and USDT on every
configured chain that is not watch-only. Each non-zero allowance gets one `approve(gateway, 0)` through
`TokenSender.submitCalls`, one call per token. `submitCalls` tries a sponsored op whenever a paymaster
and bundler are configured, and sends natively only when the bundler refuses it; a single call goes
straight to the token, so that fallback works from a wallet that is not delegated yet.

The revoke covers the gateway fee token and the configured USDC and USDT, where the large leftover
allowance was. Other output tokens a pre-fix clamped fill paid may hold a small leftover allowance,
and they are not swept.

The sponsored revoke runs on its own EntryPoint nonce key (`submitCalls`' optional
`{ nonceKey }`; every other caller keeps key 0). Boot also starts `bridgeTopUp.restore()` and, about
30s later, the first vault sweep on key 0, and two ops on one key read the same `getNonce`, so the
bundler would reject or replace one of them. The key is a fixed hash of
`simplex.gateway-allowance-revoke`, shifted to fit the key's 192 bits. A bid's key is a hash of its
commitment, session key and calldata, so the two do not meet. One chain's tokens are revoked one
after another, so they share the key without competing.

It is best-effort and runs in the background: each revocation is logged, a failure is a warning, and
neither delays nor fails boot. It is idempotent, since a revoked allowance reads zero on the next boot.
It costs one small transaction per affected token and chain, once.
16 changes: 16 additions & 0 deletions sdk/packages/simplex/src/core/boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { VaultConfig, FundingVenue } from "@/funding/types"
import { VaultFundingPlanner } from "@/funding/vault/VaultFundingPlanner"
import { VaultLiquidityState } from "@/funding/vault/VaultLiquidityState"
import { TokenSender } from "@/services/TokenSender"
import { revokeStaleGatewayAllowances } from "@/services/gateway-allowance"
import { BridgeTopUp } from "@/services/BridgeTopUp"
import { deriveSubstrateKeyPair } from "@/services/substrate-key"
import { formatChainKey, parseChainKey } from "@/config/interpolated-curve"
Expand Down Expand Up @@ -622,6 +623,21 @@ export async function bootFiller(config: FillerTomlConfig, options: BootOptions)
userOpSender,
configService,
)
// Fills before the exact-approval release left the gateway approved for the destination fee
// token. A fill clears that only for the token and chain it touches, so clear what is still
// standing now, once delegation is set up. A signerless observer holds nothing to revoke.
// Fire-and-forget: it is housekeeping that retries on the next boot, and it never rejects.
if (options.signer) {
void revokeStaleGatewayAllowances({
clientManager: chainClientManager,
configService,
contractService,
tokenSender,
solver: runtimeSigner.address as HexString,
watchOnly: watchOnlyConfig,
logger: moduleLogger(options.loggers, "gateway-allowance"),
})
}
// BRIDGE trades only on mainnet BSC, so a testnet filler has nothing to top up with.
const bridgeTopUp =
config.simplex.substratePrivateKey && network === "mainnet"
Expand Down
136 changes: 105 additions & 31 deletions sdk/packages/simplex/src/services/ContractInteractionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,43 @@ const FUNDING_GAS_PER_CALL = 400_000n
*/
const DEBIT_ORDER_GAS = 60_000n

/**
* Call gas allowed per ERC20 the batch approves to the gateway. The bundler's estimate is of a
* lone `fillOrder` (`estimateGasFillPost` passes no prepends; `FUNDING_GAS_PER_CALL` covers
* those), so the approvals are not in it. Each token costs three
* `approve` calls: the reset ahead of the set (about 5-10k, a write of zero over zero or over a
* leftover), the set from zero (about 22k for the write and 2k for the cold slot), and the reset
* after the fill (about 5k). A call into the token, through a proxy for the stablecoins, adds a
* few thousand to each. Together about 40k, rounded up.
*/
const APPROVAL_GAS_PER_TOKEN = 45_000n

/**
* What the batch approves to the gateway, per ERC20 (lowercased address): each output's amount,
* summed over the legs paying the same token, plus the dispatch fee in the fee token. Native
* outputs need no approval, and a token owed nothing is left out. `feeToken` is undefined for a
* same-chain fill, which dispatches nothing.
*
* The batch's approvals and the call gas allowed for them both come from this, so they agree.
*/
function approvalAmounts(outputs: TokenInfo[], feeToken: string | undefined, dispatchFee: bigint): Map<string, bigint> {
const amounts = new Map<string, bigint>()
for (const output of outputs) {
const addr = bytes32ToBytes20(output.token)
if (addr === ADDRESS_ZERO) continue
const key = addr.toLowerCase()
amounts.set(key, (amounts.get(key) ?? 0n) + output.amount)
}
if (feeToken) {
const key = feeToken.toLowerCase()
amounts.set(key, (amounts.get(key) ?? 0n) + dispatchFee)
}
for (const [key, amount] of amounts) {
if (amount === 0n) amounts.delete(key)
}
return amounts
}

/** What a bid's batch needs to settle its limit order's budget. */
export interface BudgetSettlement {
budget: LimitOrderBudget
Expand Down Expand Up @@ -390,12 +427,33 @@ export class ContractInteractionService {
* allowance for its modifyLiquidities + take. They differ between the bids on one
* order, since each draws on its own limit order and so on its own funding, which
* is why this is worked out per bid rather than baked into the cached estimate.
* The estimate is of a lone fillOrder, so the budget call is not in it either.
* The estimate is of a lone fillOrder, so the budget call is not in it either, nor are
* the approvals: each token the batch approves adds `APPROVAL_GAS_PER_TOKEN`.
*/
private callGasLimitFor(order: Order, baseCallGasLimit: bigint, budget?: LimitOrderBudget | null): bigint {
private callGasLimitFor(
order: Order,
baseCallGasLimit: bigint,
approvedTokens: number,
budget?: LimitOrderBudget | null,
): bigint {
const funding = order.id ? this.cacheService.getFundingPrepends(order.id) : null
const fundingGas = FUNDING_GAS_PER_CALL * BigInt(funding?.calls?.length ?? 0)
return baseCallGasLimit + fundingGas + (settles(budget) ? DEBIT_ORDER_GAS : 0n)
const approvalGas = APPROVAL_GAS_PER_TOKEN * BigInt(approvedTokens)
return baseCallGasLimit + fundingGas + approvalGas + (settles(budget) ? DEBIT_ORDER_GAS : 0n)
}

/**
* What the batch approves to the gateway for `order`, per token, and the fee token's key
* when the fill dispatches. The fee token is read from the destination host.
*/
private async approvalsFor(
order: Order,
outputs: TokenInfo[],
dispatchFee: bigint,
): Promise<{ amounts: Map<string, bigint>; feeKey?: string }> {
if (order.source === order.destination) return { amounts: approvalAmounts(outputs, undefined, dispatchFee) }
const feeKey = (await this.getFeeTokenWithDecimals(order.destination)).address.toLowerCase()
return { amounts: approvalAmounts(outputs, feeKey, dispatchFee), feeKey }
}

/**
Expand Down Expand Up @@ -798,9 +856,6 @@ export class ContractInteractionService {
// outputs are the read that fails, and the bid is dropped rather than sent unbudgeted.
const budget = this.cacheService.getBidBudget(order.id!)

// The funding calls are this bid's own, set by the caller for each bid on the order.
const callGasLimit = this.callGasLimitFor(order, cachedEstimate.callGasLimit, budget)

// Use cached filler outputs (calculated based on bps) for competitive bidding
const cachedFillerOutputs = this.cacheService.getFillerOutputs(order.id!)
const cachedFillerInputs = this.cacheService.getFillerInputs(order.id!)
Expand All @@ -827,13 +882,18 @@ export class ContractInteractionService {
inputs: cachedFillerInputs,
}

// dispatchWithFeeToken pulls relayerFee in fee token from the solver.
const dispatchFeeTokenAmount = fillOptions.relayerFee
// The funding calls are this bid's own, set by the caller for each bid on the order. The
// approvals are counted from the same amounts the batch below is built from.
const { amounts: approvals } = await this.approvalsFor(order, cachedFillerOutputs, fillOptions.relayerFee)
const callGasLimit = this.callGasLimitFor(order, cachedEstimate.callGasLimit, approvals.size, budget)

// dispatchWithFeeToken pulls relayerFee in the fee token from the solver. That is all the
// gateway takes in it: the fill's gas is paid through the paymaster's Permit2 pull, and
// the cached gas estimate is in the source chain's fee-token decimals besides.
const callData = await this.buildApprovalAndFillCalldata(
order,
cachedFillerOutputs,
fillOptions,
cachedEstimate.totalCostInSourceFeeToken + dispatchFeeTokenAmount,
budget ? { budget, solverAccount: solverAccountAddress } : undefined,
)

Expand Down Expand Up @@ -1110,6 +1170,13 @@ export class ContractInteractionService {
* Builds ERC-7821 batch calldata that approves exactly what this bid can pay
* before the fillOrder call, all within a single UserOp payload.
*
* The gateway is approved for exactly what this fill pays: the outputs in each
* ERC20, plus the dispatch fee (`dispatchFee`) in the fee token on a cross-chain
* fill. Nothing else goes in an approval: the fill's gas is paid through the
* paymaster's Permit2 pull. The batch ends by setting every allowance it set back
* to zero, so none stands once it completes. The gateway is an upgradeable proxy
* and a standing allowance is exposure to a future implementation.
*
* The approvals are unconditional. One solver can hold several bids on one
* order, at different levels of its book, and they execute one after another:
* an approval skipped because the allowance covered this bid when it was signed
Expand All @@ -1125,40 +1192,31 @@ export class ContractInteractionService {
* A bid priced by a limit order ends with `debitOrder`, which takes the budget's id
* as its `orderId`, works out what the fill paid from what is left of the allowance
* and reverts the whole batch if the order's payouts would pass its size. It has to
* come last, after the gateway has drawn on the allowance.
* come after the gateway has drawn on the allowance, and the zero-resets after it,
* since it reads the allowance that is left. It clears the budget token's allowance
* itself, so the resets skip that token.
*/
public async buildApprovalAndFillCalldata(
order: Order,
fillerOutputs: TokenInfo[],
fillOptions: FillOptions,
requiredFeeTokenAmount: bigint,
settlement?: BudgetSettlement,
): Promise<HexString> {
const chain = order.destination
const intentGatewayV2Address = this.configService.getIntentGatewayAddress(chain)

// Aggregate required amounts per ERC20 token
const perTokenRequired = new Map<string, bigint>()
for (const output of fillerOutputs) {
const addr = bytes32ToBytes20(output.token)
if (addr === ADDRESS_ZERO) continue
const key = addr.toLowerCase()
perTokenRequired.set(key, (perTokenRequired.get(key) ?? 0n) + output.amount)
}

let feeKey: string | undefined
if (order.source !== order.destination) {
const feeToken = await this.getFeeTokenWithDecimals(chain)
feeKey = feeToken.address.toLowerCase()
perTokenRequired.set(feeKey, (perTokenRequired.get(feeKey) ?? 0n) + requiredFeeTokenAmount)
}
// The outputs per ERC20 token, plus the fill's own dispatch fee in the fee token
const { amounts: perTokenRequired, feeKey } = await this.approvalsFor(
order,
fillerOutputs,
fillOptions.relayerFee,
)

const fundingPrepends = order.id ? this.cacheService.getFundingPrepends(order.id) : null
const prependCalls = fundingPrepends?.calls ?? []

const calls: ERC7821Call[] = [...prependCalls]
for (const [tokenAddress, required] of perTokenRequired) {
if (required === 0n) continue
for (const amount of [0n, required]) {
calls.push({
target: tokenAddress as HexString,
Expand Down Expand Up @@ -1192,10 +1250,9 @@ export class ContractInteractionService {
`Bid on order ${order.id} approves nothing in ${budget.token}, the token budget ${budget.budgetId} is measured in`,
)
}
// Of what was folded into the approval for the fee token, the gateway draws only
// the dispatch fee; the rest stays in the allowance. It draws it whatever
// `nativeDispatchFee` says: the batch sends no native value for the dispatch,
// so the gateway takes the fee in the fee token.
// Of the fee token's approval, the gateway draws the dispatch fee on top of the
// outputs. It draws it whatever `nativeDispatchFee` says: the batch sends no
// native value for the dispatch, so the gateway takes the fee in the fee token.
const fee = feeKey === budgetToken ? fillOptions.relayerFee : 0n
calls.push({
target: solverAccount,
Expand All @@ -1208,6 +1265,23 @@ export class ContractInteractionService {
})
}

// Last of all, after `fillOrder` and after `debitOrder` (which reads the allowance
// that is left): nothing stands once the batch completes. `debitOrder` clears the
// budget token's allowance itself, so that token is left out when the batch settles.
const clearedByBudget = settlement && settles(settlement.budget) ? settlement.budget.token.toLowerCase() : undefined
for (const tokenAddress of perTokenRequired.keys()) {
if (tokenAddress === clearedByBudget) continue
calls.push({
target: tokenAddress as HexString,
value: 0n,
data: encodeFunctionData({
abi: ERC20_ABI,
functionName: "approve",
args: [intentGatewayV2Address, 0n],
}) as HexString,
})
}

return encodeERC7821ExecuteBatch(calls)
}
}
11 changes: 10 additions & 1 deletion sdk/packages/simplex/src/services/TokenSender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,14 +196,23 @@ export class TokenSender {
/**
* Executes a batch from the filler wallet: sponsored UserOp first, native tx
* when no sponsor is available. Shared by every operator-initiated outbound flow.
*
* `nonceKey` puts the sponsored op on its own EntryPoint nonce lane (default 0). Two ops on one
* key read the same `getNonce`, so a caller that can run alongside another key-0 sender passes
* its own. It has no effect on the native fallback.
*/
async submitCalls(chain: string, calls: ERC7821Call[]): Promise<{ txHash: HexString; sponsored: boolean }> {
async submitCalls(
chain: string,
calls: ERC7821Call[],
opts?: { nonceKey?: bigint },
): Promise<{ txHash: HexString; sponsored: boolean }> {
if (this.userOpSender?.canSponsor(chain)) {
const bootstrap = await this.permit2Bootstrap(chain)
const batch = bootstrap ? [bootstrap, ...calls] : calls
const result = await this.userOpSender.trySendSponsored({
chain,
callData: encodeERC7821ExecuteBatch(batch),
nonceKey: opts?.nonceKey,
gas: {
verificationGasLimit: 60_000n,
callGasLimit: 450_000n * BigInt(batch.length),
Expand Down
Loading
Loading