diff --git a/sdk/packages/simplex/docs/ai/changelog/2026-09-29-limit-order-budget-on-chain.md b/sdk/packages/simplex/docs/ai/changelog/2026-09-29-limit-order-budget-on-chain.md index ae7ebd4ac..335c2998e 100644 --- a/sdk/packages/simplex/docs/ai/changelog/2026-09-29-limit-order-budget-on-chain.md +++ b/sdk/packages/simplex/docs/ai/changelog/2026-09-29-limit-order-budget-on-chain.md @@ -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. diff --git a/sdk/packages/simplex/docs/ai/changelog/2026-10-17-gateway-approval-is-exact.md b/sdk/packages/simplex/docs/ai/changelog/2026-10-17-gateway-approval-is-exact.md new file mode 100644 index 000000000..486d01809 --- /dev/null +++ b/sdk/packages/simplex/docs/ai/changelog/2026-10-17-gateway-approval-is-exact.md @@ -0,0 +1,73 @@ +# 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 sweep also covers the gateways a chain has since left. The over-approval has been in the code +since 2026-03-11, and moving to a new gateway does not clear what was granted to the old one. So for +each chain it reads the same tokens against every address in `RETIRED_GATEWAY_ADDRESSES` and revokes +a non-zero allowance the same way, with the same nonce key. An address equal to the chain's current +gateway is skipped, and a chain with no retired address does no extra reads. The list is per chain +ID, taken from the history of the SDK's `chain.ts`: + +- Mainnets 1, 10, 56, 100, 137, 8453 and 42161: `0x2d61…`, `0x09a7…` and `0x16F9…`, before the move + to `0xAe04…` (#996). +- Polkadot Hub mainnet (420420419): `0x16F9…`. +- BSC Chapel (97) and Polygon Amoy (80002): `0xFbF5…` and `0xE13f…`, before `0x6CF4…`. + +A later gateway move has to add the address it leaves behind to that list. + +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 revokes (its tokens, and +its current and retired gateways) go one after another on that key. That keeps them apart only +while each op confirms: if one is left unconfirmed, the next op on the key collides with it and +fails, and the next boot retries it. + +It is best-effort and runs in the background: each revocation is logged (with the `gateway` and +whether it is `retired`), 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, gateway and chain, once. diff --git a/sdk/packages/simplex/src/core/boot.ts b/sdk/packages/simplex/src/core/boot.ts index e5f4b5a74..762cae525 100644 --- a/sdk/packages/simplex/src/core/boot.ts +++ b/sdk/packages/simplex/src/core/boot.ts @@ -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" @@ -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" diff --git a/sdk/packages/simplex/src/services/ContractInteractionService.ts b/sdk/packages/simplex/src/services/ContractInteractionService.ts index 719137493..c965c1d53 100644 --- a/sdk/packages/simplex/src/services/ContractInteractionService.ts +++ b/sdk/packages/simplex/src/services/ContractInteractionService.ts @@ -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 { + const amounts = new Map() + 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 @@ -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; 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 } } /** @@ -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!) @@ -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, ) @@ -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 @@ -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 { const chain = order.destination const intentGatewayV2Address = this.configService.getIntentGatewayAddress(chain) - // Aggregate required amounts per ERC20 token - const perTokenRequired = new Map() - 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, @@ -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, @@ -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) } } diff --git a/sdk/packages/simplex/src/services/TokenSender.ts b/sdk/packages/simplex/src/services/TokenSender.ts index 164b65e69..e9b4f793c 100644 --- a/sdk/packages/simplex/src/services/TokenSender.ts +++ b/sdk/packages/simplex/src/services/TokenSender.ts @@ -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), diff --git a/sdk/packages/simplex/src/services/gateway-allowance.ts b/sdk/packages/simplex/src/services/gateway-allowance.ts new file mode 100644 index 000000000..7f1e1cea0 --- /dev/null +++ b/sdk/packages/simplex/src/services/gateway-allowance.ts @@ -0,0 +1,198 @@ +import { encodeFunctionData, keccak256, toHex } from "viem" +import type { ERC7821Call, HexString } from "@hyperbridge/sdk" +import { ERC20_ABI } from "@/config/abis/ERC20" +import type { ChainClientManager } from "@/services/ChainClientManager" +import type { ContractInteractionService } from "@/services/ContractInteractionService" +import type { FillerConfigService } from "@/services/FillerConfigService" +import type { Logger } from "@/services/Logger" +import type { TokenSender } from "@/services/TokenSender" + +/** IntentGateway addresses a chain no longer points at, by chain ID. */ +export type RetiredGateways = Readonly> + +/** + * Mainnet IntentGateway addresses the SDK config has since replaced. The over-approval this sweep + * undoes dates from 2026-03-11 (9149fc527), so every gateway the filler pointed at from then on + * may still hold an allowance. Each address was the filler's gateway on the chains below, and the + * dates are those of the SDK config commits that set it: + * + * - `0x2d61…` from 2b0e7fb12 (2026-03-05; Optimism and Gnosis from 2caeb37d6, 2026-03-12) to + * 898e9e95a (2026-05-13, #825). + * - `0x09a7…` from 898e9e95a to bf74af936 (2026-05-29, #933). + * - `0x16F9…` from bf74af936 to f7e085486 (2026-06-25, #996), which moved mainnet to `0xAe04…`. + * Polkadot Hub took it from 76514f7e4 (2026-05-27) until the same move. + */ +const RETIRED_MAINNET_GATEWAYS: readonly HexString[] = [ + "0x2d61624A17f361020679FaA16fbB566C344AaF4B", + "0x09a74AA91eb532C21862d4C6222753e4fC7e22dB", + "0x16F9E57f735bBfF9f6c4E5276330f9c437d0e9E0", +] + +/** + * Testnet gateways the SDK config has since replaced: BSC Chapel and Polygon Amoy used + * `0xFbF5…` from 9149fc527 (2026-03-11), `0xE13f…` from 713a589ba (2026-06-05, #950), and + * `0x6CF4…` from 57982b94d (2026-06-30, #1009). + */ +const RETIRED_TESTNET_GATEWAYS: readonly HexString[] = [ + "0xFbF50B2b32768127603cC9eF4b871574b881b8eD", + "0xE13fB34CAe12505ae51BaC8C405AF8EB27AC8058", +] + +/** + * Retired gateways per chain, from the history of `sdk/src/configs/chain.ts`. A chain that has + * kept its gateway, or never had a retired one, is absent. A new gateway move adds the address it + * leaves behind here, since an allowance to it outlives the config entry. + */ +export const RETIRED_GATEWAY_ADDRESSES: RetiredGateways = { + 1: RETIRED_MAINNET_GATEWAYS, + 10: RETIRED_MAINNET_GATEWAYS, + 56: RETIRED_MAINNET_GATEWAYS, + 100: RETIRED_MAINNET_GATEWAYS, + 137: RETIRED_MAINNET_GATEWAYS, + 8453: RETIRED_MAINNET_GATEWAYS, + 42161: RETIRED_MAINNET_GATEWAYS, + 420420419: ["0x16F9E57f735bBfF9f6c4E5276330f9c437d0e9E0"], + 97: RETIRED_TESTNET_GATEWAYS, + 80002: RETIRED_TESTNET_GATEWAYS, +} + +export interface StaleGatewayAllowanceDeps { + clientManager: Pick + configService: Pick< + FillerConfigService, + "getConfiguredChainIds" | "getIntentGatewayAddress" | "getUsdcAsset" | "getUsdtAsset" + > + contractService: Pick + tokenSender: Pick + solver: HexString + /** Chains the filler only observes: it never sends from them, so it does not here. */ + watchOnly?: Record + /** Defaults to {@link RETIRED_GATEWAY_ADDRESSES}. */ + retiredGateways?: RetiredGateways + logger: Pick +} + +/** + * The EntryPoint nonce key the revoke ops use. The revoke runs at boot, when the other key-0 + * senders do too (`bridgeTopUp.restore()`, then the first vault sweep about 30s later); two ops on + * one key read the same `getNonce`, so the bundler would reject or replace one and the revoke would + * fall back to a native send or time out. A lane of its own never competes with them. + * + * The EntryPoint's key is a uint192, so the 256-bit hash is shifted down to fit. It cannot meet a + * bid's key: those are `bidNonceKey(commitment, session, callData)`, a keccak over 84 bytes of + * order data, and `SolverAccount` only checks the key of a bid op (one with a session signature), + * which this plain-signature op is not. A collision would need a second preimage of 192 bits. + */ +const REVOKE_NONCE_KEY = BigInt(keccak256(toHex("simplex.gateway-allowance-revoke"))) >> 64n + +/** Missing assets come back from the config service as the literal "0x". */ +function isConfigured(address: HexString): boolean { + return !!address && address !== "0x" && address !== "0x0000000000000000000000000000000000000000" +} + +/** + * Revokes the IntentGateway allowance an older release left standing on the solver wallet. + * + * Fills used to approve the destination fee token for a figure in the source chain's decimals + * (about 1.09e16 units of a 6-decimal USDC) and never reset it. A fill resets the allowance of + * the tokens it touches, so the leftover would otherwise stand on a chain until the next fill in + * that token there, and the gateway is an upgradeable proxy. This clears it at boot instead. + * + * The same code ran against the gateways the chain used before its current one, and moving to a + * new gateway does not clear what was granted to the old one. So the sweep covers the current + * gateway and every address in `retiredGateways` for the chain, other than one equal to the + * current gateway. A chain with no retired address costs no extra reads. + * + * 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, and sends `approve(gateway, 0)` for each + * that is not zero, one call per submission: `submitCalls` tries a sponsored op whenever a paymaster + * and bundler are configured and sends natively only when the bundler refuses it, and a single call + * goes straight to the token, so that fallback works from a wallet that is not delegated yet. The + * sponsored op uses {@link REVOKE_NONCE_KEY}. Idempotent, since a revoked + * allowance reads zero on the next boot. Best-effort: a failure is logged and the rest carry on, + * so a flaky endpoint never blocks the filler from starting. Never rejects. + */ +export async function revokeStaleGatewayAllowances(deps: StaleGatewayAllowanceDeps): Promise { + const { configService, logger } = deps + const chainIds = configService.getConfiguredChainIds().filter((chainId) => deps.watchOnly?.[chainId] !== true) + const retiredGateways = deps.retiredGateways ?? RETIRED_GATEWAY_ADDRESSES + + // Chains run side by side, but one chain's revokes go one after another, over its tokens and + // gateways alike: two native transactions from the same wallet would race for a nonce, and so + // would two ops on the one revoke nonce key. Going in turn only keeps them apart while each op + // confirms. If one is left unconfirmed, the next op on that key collides with it and fails, and + // the next boot retries it. Another chain has its own EntryPoint and wallet nonce, so it never + // shares them. + await Promise.all( + chainIds.map(async (chainId) => { + const chain = `EVM-${chainId}` + try { + await revokeOnChain(deps, chain, retiredGateways[chainId] ?? []) + } catch (err) { + logger.warn({ chain, err }, "Could not check the gateway allowance, leaving it as it is") + } + }), + ) +} + +async function revokeOnChain( + deps: StaleGatewayAllowanceDeps, + chain: string, + retired: readonly HexString[], +): Promise { + const { clientManager, configService, contractService, tokenSender, solver, logger } = deps + const client = clientManager.getPublicClient(chain) + const gateway = configService.getIntentGatewayAddress(chain) + // Distinct from the current gateway and from each other; the current one is swept first. + const gateways = new Map([ + [gateway.toLowerCase(), { address: gateway, retired: false }], + ]) + for (const address of retired) { + if (!gateways.has(address.toLowerCase())) gateways.set(address.toLowerCase(), { address, retired: true }) + } + + const tokens = new Map() + const add = (token: HexString) => { + if (isConfigured(token)) tokens.set(token.toLowerCase(), token) + } + try { + add((await contractService.getFeeTokenWithDecimals(chain)).address) + } catch (err) { + // USDC and USDT are still worth checking without it. + logger.warn({ chain, err }, "Could not read the gateway's fee token, checking USDC and USDT only") + } + add(configService.getUsdcAsset(chain)) + add(configService.getUsdtAsset(chain)) + + for (const { address: spender, retired: isRetired } of gateways.values()) { + for (const token of tokens.values()) { + try { + const allowance = (await client.readContract({ + address: token, + abi: ERC20_ABI, + functionName: "allowance", + args: [solver, spender], + })) as bigint + if (allowance === 0n) continue + + const revoke: ERC7821Call = { + target: token, + value: 0n, + data: encodeFunctionData({ abi: ERC20_ABI, functionName: "approve", args: [spender, 0n] }), + } + const { txHash, sponsored } = await tokenSender.submitCalls(chain, [revoke], { + nonceKey: REVOKE_NONCE_KEY, + }) + logger.info( + { chain, token, gateway: spender, retired: isRetired, allowance: allowance.toString(), txHash, sponsored }, + "Revoked a stale IntentGateway allowance", + ) + } catch (err) { + logger.warn( + { chain, token, gateway: spender, retired: isRetired, err }, + "Could not revoke a stale IntentGateway allowance", + ) + } + } + } +} diff --git a/sdk/packages/simplex/src/tests/gateway-allowance.test.ts b/sdk/packages/simplex/src/tests/gateway-allowance.test.ts new file mode 100644 index 000000000..d39b90f0c --- /dev/null +++ b/sdk/packages/simplex/src/tests/gateway-allowance.test.ts @@ -0,0 +1,417 @@ +import { describe, expect, it, vi } from "vitest" +import { decodeFunctionData, keccak256, toHex } from "viem" +import type { ERC7821Call, HexString } from "@hyperbridge/sdk" +import { ERC20_ABI } from "@/config/abis/ERC20" +import { chainConfigs } from "@hyperbridge/sdk" +import { + RETIRED_GATEWAY_ADDRESSES, + revokeStaleGatewayAllowances, + type RetiredGateways, + type StaleGatewayAllowanceDeps, +} from "@/services/gateway-allowance" + +/** + * Fills used to approve the gateway for the destination fee token in the wrong decimals and never + * reset it, so a solver that ran that code can hold a standing allowance on a chain no fill has + * touched since. Boot clears it with one `approve(gateway, 0)` per token, and does nothing once + * it has. + */ + +const SOLVER = "0x5b2c3e25243634732eE94525Ae98aEc404c82506" as HexString +const GATEWAY = "0x1111111111111111111111111111111111111111" as HexString +const RETIRED = "0x5555555555555555555555555555555555555555" as HexString +const RETIRED_TOO = "0x6666666666666666666666666666666666666666" as HexString +const FEE_TOKEN = "0x2222222222222222222222222222222222222222" as HexString +const USDC = "0x3333333333333333333333333333333333333333" as HexString +const USDT = "0x4444444444444444444444444444444444444444" as HexString +const STALE = 10_900_000_000_000_000n +/** The revoke's own EntryPoint nonce lane: a uint192 key, so it never shares key 0 with boot-time senders. */ +const REVOKE_NONCE_KEY = BigInt(keccak256(toHex("simplex.gateway-allowance-revoke"))) >> 64n + +function build( + opts: { + /** Allowance by lowercased token address; anything absent reads zero. */ + allowances?: Record + /** Allowance by lowercased token address to a retired gateway, keyed by lowercased gateway. */ + retiredAllowances?: Record> + retiredGateways?: RetiredGateways + chainIds?: number[] + watchOnly?: Record + usdt?: HexString + feeToken?: () => Promise<{ address: HexString; decimals: number }> + submitCalls?: (chain: string, calls: ERC7821Call[], opts?: { nonceKey?: bigint }) => Promise<{ txHash: HexString; sponsored: boolean }> + readContract?: (call: { address: HexString; args: [HexString, HexString] }) => Promise + } = {}, +) { + const allowances = opts.allowances ?? {} + const readContract = vi.fn( + opts.readContract ?? + (async ({ address, args }: { address: HexString; args: [HexString, HexString] }) => { + const retired = opts.retiredAllowances?.[args[1].toLowerCase()] + if (retired) return retired[address.toLowerCase()] ?? 0n + return args[1] === GATEWAY ? (allowances[address.toLowerCase()] ?? 0n) : 0n + }), + ) + const submitCalls = vi.fn(opts.submitCalls ?? (async () => ({ txHash: "0xrevoke" as HexString, sponsored: true }))) + const logger = { info: vi.fn(), warn: vi.fn() } + const deps: StaleGatewayAllowanceDeps = { + clientManager: { getPublicClient: () => ({ readContract }) } as never, + configService: { + getConfiguredChainIds: () => opts.chainIds ?? [8453], + getIntentGatewayAddress: () => GATEWAY, + getUsdcAsset: () => USDC, + getUsdtAsset: () => opts.usdt ?? USDT, + }, + contractService: { + getFeeTokenWithDecimals: opts.feeToken ?? (async () => ({ address: FEE_TOKEN, decimals: 6 })), + }, + tokenSender: { submitCalls }, + solver: SOLVER, + watchOnly: opts.watchOnly, + // No chain here has a retired gateway unless a test says so; the real list is checked on its own. + retiredGateways: opts.retiredGateways ?? {}, + logger, + } + return { deps, readContract, submitCalls, logger } +} + +describe("revokeStaleGatewayAllowances", () => { + it("sends one approve(gateway, 0) for a token whose allowance is still standing", async () => { + const { deps, submitCalls, logger } = build({ allowances: { [FEE_TOKEN.toLowerCase()]: STALE } }) + + await revokeStaleGatewayAllowances(deps) + + expect(submitCalls).toHaveBeenCalledOnce() + const [chain, calls, opts] = submitCalls.mock.calls[0] + expect(chain).toBe("EVM-8453") + // One call per submission, so the native fallback goes straight to the token and works + // from a wallet that is not delegated yet. + expect(calls).toHaveLength(1) + expect(opts).toEqual({ nonceKey: REVOKE_NONCE_KEY }) + expect(calls[0].target).toBe(FEE_TOKEN) + expect(calls[0].value).toBe(0n) + expect(decodeFunctionData({ abi: ERC20_ABI, data: calls[0].data })).toEqual({ + functionName: "approve", + args: [GATEWAY, 0n], + }) + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ chain: "EVM-8453", token: FEE_TOKEN, allowance: STALE.toString() }), + "Revoked a stale IntentGateway allowance", + ) + }) + + it("sends every revoke on the one nonce key, which fits the EntryPoint's uint192", async () => { + const { deps, submitCalls } = build({ + allowances: { [FEE_TOKEN.toLowerCase()]: STALE, [USDT.toLowerCase()]: 1n }, + }) + + await revokeStaleGatewayAllowances(deps) + + expect(submitCalls.mock.calls.map(([, , opts]) => opts?.nonceKey)).toEqual([REVOKE_NONCE_KEY, REVOKE_NONCE_KEY]) + expect(REVOKE_NONCE_KEY).toBeGreaterThan(0n) + expect(REVOKE_NONCE_KEY).toBeLessThan(1n << 192n) + }) + + it("reads the allowance the solver granted the gateway on the fee token, USDC and USDT", async () => { + const { deps, readContract } = build() + + await revokeStaleGatewayAllowances(deps) + + expect(readContract.mock.calls.map(([call]) => call)).toEqual( + [FEE_TOKEN, USDC, USDT].map((address) => + expect.objectContaining({ address, functionName: "allowance", args: [SOLVER, GATEWAY] }), + ), + ) + }) + + it("revokes each token separately, and only the ones that still stand", async () => { + const { deps, submitCalls } = build({ + allowances: { [FEE_TOKEN.toLowerCase()]: STALE, [USDT.toLowerCase()]: 1n }, + }) + + await revokeStaleGatewayAllowances(deps) + + expect(submitCalls.mock.calls.map(([, calls]) => calls.map((call) => call.target))).toEqual([[FEE_TOKEN], [USDT]]) + }) + + it("does nothing once every allowance reads zero, as on the boot after a revoke", async () => { + const { deps, submitCalls, logger } = build() + + await revokeStaleGatewayAllowances(deps) + + expect(submitCalls).not.toHaveBeenCalled() + expect(logger.warn).not.toHaveBeenCalled() + }) + + it("reads a token once when the fee token is also USDC", async () => { + const { deps, readContract, submitCalls } = build({ + allowances: { [USDC.toLowerCase()]: STALE }, + feeToken: async () => ({ address: USDC, decimals: 6 }), + }) + + await revokeStaleGatewayAllowances(deps) + + expect(readContract).toHaveBeenCalledTimes(2) + expect(submitCalls).toHaveBeenCalledOnce() + }) + + it("skips a USDT that the chain does not configure", async () => { + const { deps, readContract } = build({ usdt: "0x" }) + + await revokeStaleGatewayAllowances(deps) + + expect(readContract.mock.calls.map(([call]) => call.address)).toEqual([FEE_TOKEN, USDC]) + }) + + it("leaves a watch-only chain alone and still clears the others", async () => { + const { deps, readContract, submitCalls } = build({ + chainIds: [8453, 56], + watchOnly: { 8453: true }, + allowances: { [FEE_TOKEN.toLowerCase()]: STALE }, + }) + + await revokeStaleGatewayAllowances(deps) + + expect(readContract).toHaveBeenCalledTimes(3) + expect(submitCalls.mock.calls.map(([chain]) => chain)).toEqual(["EVM-56"]) + }) + + it("does nothing when every chain is watch-only", async () => { + const { deps, readContract, submitCalls } = build({ + chainIds: [8453, 56], + watchOnly: { 8453: true, 56: true }, + allowances: { [FEE_TOKEN.toLowerCase()]: STALE }, + }) + + await revokeStaleGatewayAllowances(deps) + + expect(readContract).not.toHaveBeenCalled() + expect(submitCalls).not.toHaveBeenCalled() + }) + + it("warns and carries on to the next token when a revoke fails", async () => { + const { deps, submitCalls, logger } = build({ + allowances: { [FEE_TOKEN.toLowerCase()]: STALE, [USDC.toLowerCase()]: STALE }, + submitCalls: vi + .fn() + .mockRejectedValueOnce(new Error("insufficient funds")) + .mockResolvedValueOnce({ txHash: "0xok", sponsored: false }), + }) + + await expect(revokeStaleGatewayAllowances(deps)).resolves.toBeUndefined() + + expect(submitCalls).toHaveBeenCalledTimes(2) + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ token: FEE_TOKEN }), + "Could not revoke a stale IntentGateway allowance", + ) + expect(logger.info).toHaveBeenCalledOnce() + }) + + it("warns and carries on to the next chain when a chain's allowances cannot be read", async () => { + const { deps, submitCalls, logger } = build({ + chainIds: [8453, 56], + readContract: async () => { + throw new Error("rpc down") + }, + }) + deps.configService.getIntentGatewayAddress = (chain: string) => { + if (chain === "EVM-8453") throw new Error("no gateway") + return GATEWAY + } + + await expect(revokeStaleGatewayAllowances(deps)).resolves.toBeUndefined() + + expect(submitCalls).not.toHaveBeenCalled() + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ chain: "EVM-8453" }), + "Could not check the gateway allowance, leaving it as it is", + ) + // EVM-56 was still reached: each token's failed read is its own warning. + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ chain: "EVM-56", token: FEE_TOKEN }), + "Could not revoke a stale IntentGateway allowance", + ) + }) + + it("still checks USDC and USDT when the fee token cannot be read", async () => { + const { deps, readContract, logger } = build({ + feeToken: async () => { + throw new Error("host unreachable") + }, + }) + + await revokeStaleGatewayAllowances(deps) + + expect(readContract.mock.calls.map(([call]) => call.address)).toEqual([USDC, USDT]) + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ chain: "EVM-8453" }), + "Could not read the gateway's fee token, checking USDC and USDT only", + ) + }) +}) + +describe("retired gateways", () => { + const retiredOn8453 = { 8453: [RETIRED] } + + it("revokes a standing allowance to a retired gateway", async () => { + const { deps, submitCalls, logger } = build({ + retiredGateways: retiredOn8453, + retiredAllowances: { [RETIRED.toLowerCase()]: { [USDC.toLowerCase()]: STALE } }, + }) + + await revokeStaleGatewayAllowances(deps) + + expect(submitCalls).toHaveBeenCalledOnce() + const [chain, calls, opts] = submitCalls.mock.calls[0] + expect(chain).toBe("EVM-8453") + expect(opts).toEqual({ nonceKey: REVOKE_NONCE_KEY }) + expect(calls).toHaveLength(1) + expect(calls[0].target).toBe(USDC) + expect(decodeFunctionData({ abi: ERC20_ABI, data: calls[0].data })).toEqual({ + functionName: "approve", + args: [RETIRED, 0n], + }) + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ chain: "EVM-8453", token: USDC, gateway: RETIRED, retired: true }), + "Revoked a stale IntentGateway allowance", + ) + }) + + it("reads the same fee token, USDC and USDT for the retired gateway", async () => { + const { deps, readContract } = build({ retiredGateways: retiredOn8453 }) + + await revokeStaleGatewayAllowances(deps) + + expect(readContract.mock.calls.map(([call]) => [call.address, call.args[1]])).toEqual([ + [FEE_TOKEN, GATEWAY], + [USDC, GATEWAY], + [USDT, GATEWAY], + [FEE_TOKEN, RETIRED], + [USDC, RETIRED], + [USDT, RETIRED], + ]) + }) + + it("sends nothing when every retired allowance reads zero", async () => { + const { deps, submitCalls, logger } = build({ retiredGateways: retiredOn8453 }) + + await revokeStaleGatewayAllowances(deps) + + expect(submitCalls).not.toHaveBeenCalled() + expect(logger.warn).not.toHaveBeenCalled() + }) + + it("revokes the current and the retired gateway one after another on the one nonce key", async () => { + let inFlight = 0 + let overlap = false + const { deps, submitCalls } = build({ + retiredGateways: { 8453: [RETIRED, RETIRED_TOO] }, + allowances: { [FEE_TOKEN.toLowerCase()]: STALE }, + retiredAllowances: { + [RETIRED.toLowerCase()]: { [USDT.toLowerCase()]: 1n }, + [RETIRED_TOO.toLowerCase()]: { [USDC.toLowerCase()]: 1n }, + }, + submitCalls: async () => { + overlap ||= ++inFlight > 1 + await new Promise((resolve) => setTimeout(resolve, 1)) + inFlight-- + return { txHash: "0xrevoke" as HexString, sponsored: true } + }, + }) + + await revokeStaleGatewayAllowances(deps) + + expect(overlap).toBe(false) + expect( + submitCalls.mock.calls.map(([, calls, opts]) => [ + calls[0].target, + decodeFunctionData({ abi: ERC20_ABI, data: calls[0].data }).args?.[0], + opts?.nonceKey, + ]), + ).toEqual([ + [FEE_TOKEN, GATEWAY, REVOKE_NONCE_KEY], + [USDT, RETIRED, REVOKE_NONCE_KEY], + [USDC, RETIRED_TOO, REVOKE_NONCE_KEY], + ]) + }) + + it("skips a retired address that is the current gateway, whatever its case", async () => { + const { deps, readContract } = build({ + retiredGateways: { 8453: [GATEWAY.toUpperCase().replace("0X", "0x") as HexString, RETIRED, RETIRED] }, + }) + + await revokeStaleGatewayAllowances(deps) + + const spenders = readContract.mock.calls.map(([call]) => call.args[1]) + expect(spenders.filter((spender) => spender === GATEWAY)).toHaveLength(3) + expect(spenders.filter((spender) => spender === RETIRED)).toHaveLength(3) + expect(spenders).toHaveLength(6) + }) + + it("reads nothing extra on a chain with no retired gateway", async () => { + const { deps, readContract } = build({ chainIds: [56], retiredGateways: retiredOn8453 }) + + await revokeStaleGatewayAllowances(deps) + + expect(readContract.mock.calls.map(([call]) => [call.address, call.args[1]])).toEqual( + [FEE_TOKEN, USDC, USDT].map((token) => [token, GATEWAY]), + ) + }) + + it("leaves a watch-only chain's retired gateways alone too", async () => { + const { deps, readContract } = build({ + watchOnly: { 8453: true }, + retiredGateways: retiredOn8453, + }) + + await revokeStaleGatewayAllowances(deps) + + expect(readContract).not.toHaveBeenCalled() + }) + + it("warns and carries on to the next retired gateway when one revoke fails", async () => { + const { deps, submitCalls, logger } = build({ + retiredGateways: { 8453: [RETIRED, RETIRED_TOO] }, + retiredAllowances: { + [RETIRED.toLowerCase()]: { [USDC.toLowerCase()]: 1n }, + [RETIRED_TOO.toLowerCase()]: { [USDC.toLowerCase()]: 1n }, + }, + submitCalls: vi + .fn() + .mockRejectedValueOnce(new Error("insufficient funds")) + .mockResolvedValueOnce({ txHash: "0xok", sponsored: false }), + }) + + await expect(revokeStaleGatewayAllowances(deps)).resolves.toBeUndefined() + + expect(submitCalls).toHaveBeenCalledTimes(2) + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ gateway: RETIRED, retired: true }), + "Could not revoke a stale IntentGateway allowance", + ) + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ gateway: RETIRED_TOO, retired: true }), + "Revoked a stale IntentGateway allowance", + ) + }) +}) + +describe("RETIRED_GATEWAY_ADDRESSES", () => { + it("holds only addresses the chain no longer uses, each once", () => { + for (const [chainId, retired] of Object.entries(RETIRED_GATEWAY_ADDRESSES)) { + const current = chainConfigs[Number(chainId)]?.addresses.IntentGateway + expect(current, `chain ${chainId} has a gateway configured`).toBeTruthy() + const lowered = retired.map((address) => address.toLowerCase()) + expect(lowered, `chain ${chainId}`).not.toContain(String(current).toLowerCase()) + expect(new Set(lowered).size).toBe(lowered.length) + } + }) + + it("covers the gateway mainnet left in #996", () => { + const previous = "0x16F9E57f735bBfF9f6c4E5276330f9c437d0e9E0".toLowerCase() + for (const chainId of [1, 10, 56, 100, 137, 8453, 42161, 420420419]) { + expect(RETIRED_GATEWAY_ADDRESSES[chainId]?.map((address) => address.toLowerCase())).toContain(previous) + } + }) +}) diff --git a/sdk/packages/simplex/src/tests/services/bid-approvals.test.ts b/sdk/packages/simplex/src/tests/services/bid-approvals.test.ts index cb97765dd..95092654d 100644 --- a/sdk/packages/simplex/src/tests/services/bid-approvals.test.ts +++ b/sdk/packages/simplex/src/tests/services/bid-approvals.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest" -import { bytes20ToBytes32, type FillOptions, type HexString, type Order } from "@hyperbridge/sdk" +import { bytes20ToBytes32, type FillOptions, type HexString, type Order, type PackedUserOperation } from "@hyperbridge/sdk" import { decodeAbiParameters, decodeFunctionData, erc20Abi, parseAbi } from "viem" +import { budgetIdFor, type LimitOrderBudget } from "@/orderbook/amounts" import { ContractInteractionService } from "@/services/ContractInteractionService" import { privateKeySigner } from "@/services/wallet" @@ -11,9 +12,15 @@ import { privateKeySigner } from "@/services/wallet" * they execute one after another. An approval skipped because the allowance covered * the bid when it was signed is spent by a sibling that fills first, and the bid then * reverts with `ERC20InsufficientAllowance`. So every bid sets its own allowance. + * + * The gateway is approved for exactly what the fill pays, the outputs plus the dispatch + * fee in the fee token. The fill's gas is paid through the paymaster, and the allowance + * goes back to zero at the end of the batch. */ const SOLVER_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" as HexString +const SOLVER = privateKeySigner(SOLVER_KEY).address as HexString +const ENTRY_POINT = "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108" as HexString const GATEWAY = "0x6CF42FA9BecbC5b6a26884964956b113530f7cFA" as HexString const USDC = "0x1111111111111111111111111111111111111111" as HexString const CNGN = "0x2222222222222222222222222222222222222222" as HexString @@ -30,6 +37,29 @@ function service(): ContractInteractionService { return contract } +/** A service that prepares bids over a chain that answers every read with zero, its fee token USDC at `feeDecimals`. */ +function bidService(feeDecimals: number): ContractInteractionService { + const client = { + chain: { blockTime: 2000 }, + getBlockNumber: async () => 100n, + readContract: async () => 0n, + } + const contract = new ContractInteractionService( + // biome-ignore lint/suspicious/noExplicitAny: only the client reads on this path are answered + { getPublicClient: () => client, getWalletClient: () => ({}) } as any, + // biome-ignore lint/suspicious/noExplicitAny: only the settings read on this path are answered + { + loggers: undefined, + getIntentGatewayAddress: () => GATEWAY, + getSimplexPaymasterAddress: () => undefined, + getBidValiditySeconds: () => 60, + } as any, + privateKeySigner(SOLVER_KEY), + ) + contract.cacheService.setFeeTokenWithDecimals("EVM-97", USDC, feeDecimals) + return contract +} + const order: Order = { id: "0xorder", user: bytes20ToBytes32(USDC), @@ -55,14 +85,24 @@ const options: FillOptions = { /** The calls in the ERC-7821 batch, with each approval decoded to its spender and amount. */ async function calls(contract: ContractInteractionService) { - const callData = await contract.buildApprovalAndFillCalldata(order, outputs, options, 0n) + const callData = await contract.buildApprovalAndFillCalldata(order, outputs, options) + return decodeBatch(callData) +} + +/** The raw calls in the ERC-7821 batch. */ +function rawBatch(callData: HexString) { const { args } = decodeFunctionData({ abi: EXECUTE, data: callData }) const [batch] = decodeAbiParameters( [{ type: "tuple[]", components: [{ name: "target", type: "address" }, { name: "value", type: "uint256" }, { name: "data", type: "bytes" }] }], args[1], ) - return batch.map((call) => { + return batch +} + +function decodeBatch(callData: HexString) { + return rawBatch(callData).map((call) => { if (call.target.toLowerCase() === GATEWAY.toLowerCase()) return { fill: true } + if (call.target.toLowerCase() === SOLVER.toLowerCase()) return { debit: true } const approval = decodeFunctionData({ abi: erc20Abi, data: call.data }) return { token: call.target.toLowerCase(), fn: approval.functionName, args: approval.args } }) @@ -76,6 +116,174 @@ describe("the approvals a bid signs", () => { { token: CNGN.toLowerCase(), fn: "approve", args: [GATEWAY, 0n] }, { token: CNGN.toLowerCase(), fn: "approve", args: [GATEWAY, 39_750n] }, { fill: true }, + { token: CNGN.toLowerCase(), fn: "approve", args: [GATEWAY, 0n] }, ]) }) + + it("approves a same-chain fill's outputs alone, and zeroes them at the end", async () => { + // Nothing is dispatched, so the gateway never pulls the fee token. + const contract = bidService(6) + const batch = decodeBatch( + await contract.buildApprovalAndFillCalldata(order, outputs, { ...options, relayerFee: 120n }), + ) + + expect(batch).toEqual([ + { token: CNGN.toLowerCase(), fn: "approve", args: [GATEWAY, 0n] }, + { token: CNGN.toLowerCase(), fn: "approve", args: [GATEWAY, 39_750n] }, + { fill: true }, + { token: CNGN.toLowerCase(), fn: "approve", args: [GATEWAY, 0n] }, + ]) + }) + + describe("with several legs", () => { + const ZERO = "0x0000000000000000000000000000000000000000" as HexString + const legsOrder = (assets: { token: HexString; amount: bigint }[]): Order => ({ + ...order, + inputs: assets.map(() => ({ token: bytes20ToBytes32(USDC), amount: 50n })), + output: { ...order.output, assets: assets.map((a) => ({ token: bytes20ToBytes32(a.token), amount: a.amount })) }, + }) + const quote = (assets: { token: HexString; amount: bigint }[]) => ({ + ...options, + outputs: assets.map((a) => ({ token: bytes20ToBytes32(a.token), amount: a.amount })), + inputs: assets.map(() => ({ token: bytes20ToBytes32(USDC), amount: 25n })), + }) + + it("gives a native output no approval and no reset, and sends its amount with the fill", async () => { + const assets = [ + { token: ZERO, amount: 1_000n }, + { token: CNGN, amount: 39_750n }, + ] + const fillOptions = quote(assets) + const callData = await service().buildApprovalAndFillCalldata(legsOrder(assets), fillOptions.outputs, fillOptions) + const batch = rawBatch(callData) + + expect(decodeBatch(callData)).toEqual([ + { token: CNGN.toLowerCase(), fn: "approve", args: [GATEWAY, 0n] }, + { token: CNGN.toLowerCase(), fn: "approve", args: [GATEWAY, 39_750n] }, + { fill: true }, + { token: CNGN.toLowerCase(), fn: "approve", args: [GATEWAY, 0n] }, + ]) + expect(batch.map((call) => call.value)).toEqual([0n, 0n, 1_000n, 0n]) + expect(batch.some((call) => call.target.toLowerCase() === ZERO)).toBe(false) + }) + + it("sums two legs paying one ERC20 into one approval and one reset", async () => { + const assets = [ + { token: CNGN, amount: 20_000n }, + { token: CNGN, amount: 19_750n }, + ] + const fillOptions = quote(assets) + const callData = await service().buildApprovalAndFillCalldata(legsOrder(assets), fillOptions.outputs, fillOptions) + + expect(decodeBatch(callData)).toEqual([ + { token: CNGN.toLowerCase(), fn: "approve", args: [GATEWAY, 0n] }, + { token: CNGN.toLowerCase(), fn: "approve", args: [GATEWAY, 39_750n] }, + { fill: true }, + { token: CNGN.toLowerCase(), fn: "approve", args: [GATEWAY, 0n] }, + ]) + }) + }) + + describe("on a cross-chain fill", () => { + const crossChain: Order = { ...order, id: "0xcross", source: "EVM-84532" } + /** The order, asking for `token` out. */ + const asking = (token: HexString): Order => ({ + ...crossChain, + output: { ...order.output, assets: [{ token: bytes20ToBytes32(token), amount: 78_000n }] }, + }) + const DISPATCH_FEE = 36_480n + // Gas priced in the source chain's 18-decimal fee token: 0.010901 USDC. + const GAS_IN_SOURCE_FEE_TOKEN = 10_901_000_000_000_000n + const budget: LimitOrderBudget = { budgetId: budgetIdFor("limit-order-1"), cap: 1_000_000n, token: USDC } + + /** What `prepareBidUserOp` signs for the order, from a gas estimate priced in the source fee token. */ + async function signedBatch(paid: { token: HexString; amount: bigint }[], limitOrder?: LimitOrderBudget) { + const contract = bidService(6) + let signed: { callData: HexString } | undefined + const helper = { + estimateBidPreVerificationGas: async () => 50_000n, + prepareSubmitBid: async (params: { callData: HexString; nonce: bigint }): Promise => { + signed = params + return { + sender: SOLVER, + nonce: params.nonce, + initCode: "0x", + callData: params.callData, + accountGasLimits: `0x${"00".repeat(32)}`, + preVerificationGas: 0n, + gasFees: `0x${"00".repeat(32)}`, + paymasterAndData: "0x", + signature: "0x", + } + }, + } + // biome-ignore lint/suspicious/noExplicitAny: stands in for the gateway helper that would sign + ;(contract as any).getIntentGateway = async () => helper + + const asked = asking(paid[0].token) + const id = asked.id as string + const filled = paid.map(({ token, amount }) => ({ token: bytes20ToBytes32(token), amount })) + contract.cacheService.setGasEstimate( + id, + GAS_IN_SOURCE_FEE_TOKEN, + 0n, + DISPATCH_FEE, + 1_000_000n, + 100_000n, + 50_000n, + 1n, + 1n, + 1n, + ) + contract.cacheService.setFillerOutputs(id, filled, [{ token: bytes20ToBytes32(USDC), amount: 25n }], limitOrder) + await contract.prepareBidUserOp(asked, ENTRY_POINT, SOLVER) + return decodeBatch((signed as { callData: HexString }).callData) + } + + const approve = (token: HexString, amount: bigint) => ({ + token: token.toLowerCase(), + fn: "approve", + args: [GATEWAY, amount], + }) + + it("approves the outputs and the dispatch fee, with no gas in it", async () => { + // The destination fee token has 6 decimals and the source's 18. Gas read in the + // source's decimals once went into this approval as billions of USDC. + expect(await signedBatch([{ token: USDC, amount: 39_750n }])).toEqual([ + approve(USDC, 0n), + approve(USDC, 39_750n + DISPATCH_FEE), + { fill: true }, + approve(USDC, 0n), + ]) + }) + + it("approves the dispatch fee alone in a fee token that is no output", async () => { + expect(await signedBatch([{ token: CNGN, amount: 39_750n }])).toEqual([ + approve(CNGN, 0n), + approve(CNGN, 39_750n), + approve(USDC, 0n), + approve(USDC, DISPATCH_FEE), + { fill: true }, + approve(CNGN, 0n), + approve(USDC, 0n), + ]) + }) + + it("zero-resets the other tokens after the budget call, which clears the budget token itself", async () => { + const batch = await signedBatch([{ token: CNGN, amount: 39_750n }], { ...budget, token: CNGN }) + const fill = batch.findIndex((call) => "fill" in call) + const debit = batch.findIndex((call) => "debit" in call) + + expect(fill).toBeGreaterThan(-1) + expect(debit).toBeGreaterThan(fill) + // `debitOrder` does `forceApprove(gateway, 0)` on the budget token (CNGN). + expect(batch.slice(debit + 1)).toEqual([approve(USDC, 0n)]) + }) + + it("zero-resets every token when no budget is settled", async () => { + const batch = await signedBatch([{ token: CNGN, amount: 39_750n }]) + + expect(batch.slice(-2)).toEqual([approve(CNGN, 0n), approve(USDC, 0n)]) + }) + }) }) diff --git a/sdk/packages/simplex/src/tests/services/bid-budget.test.ts b/sdk/packages/simplex/src/tests/services/bid-budget.test.ts index 6bd96cb54..fb3929f66 100644 --- a/sdk/packages/simplex/src/tests/services/bid-budget.test.ts +++ b/sdk/packages/simplex/src/tests/services/bid-budget.test.ts @@ -18,7 +18,8 @@ import { ContractInteractionService } from "@/services/ContractInteractionServic import { privateKeySigner } from "@/services/wallet" /** - * The `debitOrder` call a bid priced by a limit order ends with. + * The `debitOrder` call a bid priced by a limit order carries, after its fill and ahead + * of the zero-resets that end every batch. * * The account works out what a fill paid from what the gateway left of the * allowance: `used = approved - allowance - fee`. So `approved` has to be exactly @@ -44,6 +45,8 @@ const RELAYER_FEE = 120n const BASE_CALL_GAS = 1_000_000n const FUNDING_GAS_PER_CALL = 400_000n const DEBIT_ORDER_GAS = 60_000n +const APPROVAL_GAS_PER_TOKEN = 45_000n +const OTHER_TOKEN = "0xfedcbafedcbafedcbafedcbafedcbafedcbafedc" as HexString const budget: LimitOrderBudget = { budgetId: budgetIdFor("limit-order-1"), cap: 78_000n, token: CNGN } @@ -103,32 +106,43 @@ function batchOf(callData: HexString): ERC7821Call[] { return batch } -/** The arguments of the batch's last call, which has to be the account settling the budget. */ +/** The index of the call to the account settling the budget, which is the one call that account receives. */ +function debitIndex(batch: ERC7821Call[]): number { + return batch.findIndex((call) => call.target.toLowerCase() === SOLVER.toLowerCase()) +} + +/** The arguments of the batch's call to the account settling the budget, which follows the fill. */ function settled(batch: ERC7821Call[]) { - const last = batch[batch.length - 1] - expect(last.target.toLowerCase()).toBe(SOLVER.toLowerCase()) - expect(last.value).toBe(0n) - expect(last.data.slice(0, 10)).toBe(DEBIT_ORDER) - const { functionName, args } = decodeFunctionData({ abi: SOLVER_ACCOUNT_ABI, data: last.data }) - if (functionName !== "debitOrder") throw new Error(`the batch ends with ${functionName}`) + const index = debitIndex(batch) + expect(index).toBeGreaterThan(-1) + const call = batch[index] + expect(call.value).toBe(0n) + expect(call.data.slice(0, 10)).toBe(DEBIT_ORDER) + const { functionName, args } = decodeFunctionData({ abi: SOLVER_ACCOUNT_ABI, data: call.data }) + if (functionName !== "debitOrder") throw new Error(`the batch settles with ${functionName}`) const [budgetId, cap, token, approved, fee] = args return { budgetId, cap, token: token.toLowerCase(), approved, fee } } -/** What the batch approves to the gateway in `token`, from its last approval of it. */ +/** What the batch approves to the gateway in `token`, from its last non-zero approval of it. */ function approvedIn(batch: ERC7821Call[], token: HexString): bigint | undefined { - const approvals = batch + const amounts = batch .filter((call) => call.target.toLowerCase() === token.toLowerCase()) .map((call) => decodeFunctionData({ abi: erc20Abi, data: call.data })) - const last = approvals[approvals.length - 1] - return last?.functionName === "approve" ? last.args[1] : undefined + .flatMap((call) => (call.functionName === "approve" && call.args[1] > 0n ? [call.args[1]] : [])) + return amounts[amounts.length - 1] } /** * Drives `prepareBidUserOp` over a gateway helper that signs nothing and hands back * what it was asked to sign. */ -async function prepareBid(contract: ContractInteractionService, order: Order, cached?: LimitOrderBudget) { +async function prepareBid( + contract: ContractInteractionService, + order: Order, + cached?: LimitOrderBudget, + paid?: { token: HexString; amount: bigint }[], +) { let signed: any const helper = { estimateBidPreVerificationGas: async () => 50_000n, @@ -149,16 +163,30 @@ async function prepareBid(contract: ContractInteractionService, order: Order, ca } ;(contract as any).getIntentGateway = async () => helper + // With `paid`, the order asks for those legs and the bid quotes them, one input leg for each. + const legs = paid?.map(({ token, amount }) => ({ token: bytes20ToBytes32(token), amount })) + const bidOrder: Order = legs + ? { + ...order, + inputs: legs.map(() => ({ token: bytes20ToBytes32(USDC), amount: 50n })), + output: { ...order.output, assets: legs }, + } + : order const id = order.id as string contract.cacheService.setGasEstimate(id, FILL_GAS, 0n, RELAYER_FEE, BASE_CALL_GAS, 100_000n, 50_000n, 1n, 1n, 1n) - contract.cacheService.setFillerOutputs(id, outputs, inputs, cached) - await contract.prepareBidUserOp(order, ENTRY_POINT, SOLVER) + contract.cacheService.setFillerOutputs( + id, + legs ?? outputs, + legs ? legs.map(() => ({ token: bytes20ToBytes32(USDC), amount: 25n })) : inputs, + cached, + ) + await contract.prepareBidUserOp(bidOrder, ENTRY_POINT, SOLVER) return signed as { callData: HexString; callGasLimit: bigint; fillOptions: FillOptions } } describe("the budget call a bid ends with", () => { it("settles a same-chain bid for what it approved, with no fee", async () => { - const callData = await service().buildApprovalAndFillCalldata(sameChain, outputs, optionsWith(0n), 0n, { + const callData = await service().buildApprovalAndFillCalldata(sameChain, outputs, optionsWith(0n), { budget, solverAccount: SOLVER, }) @@ -177,13 +205,12 @@ describe("the budget call a bid ends with", () => { crossChain, outputs, optionsWith(RELAYER_FEE), - FILL_GAS + RELAYER_FEE, { budget, solverAccount: SOLVER }, ) const batch = batchOf(callData) const { approved, fee } = settled(batch) - expect(approved).toBe(PAYOUT + FILL_GAS + RELAYER_FEE) + expect(approved).toBe(PAYOUT + RELAYER_FEE) expect(approved).toBe(approvedIn(batch, CNGN)) expect(fee).toBe(RELAYER_FEE) @@ -200,12 +227,11 @@ describe("the budget call a bid ends with", () => { crossChain, outputs, { ...optionsWith(RELAYER_FEE), nativeDispatchFee: 1n }, - FILL_GAS + RELAYER_FEE, { budget, solverAccount: SOLVER }, ) const { approved, fee } = settled(batchOf(callData)) - expect(approved).toBe(PAYOUT + FILL_GAS + RELAYER_FEE) + expect(approved).toBe(PAYOUT + RELAYER_FEE) expect(fee).toBe(RELAYER_FEE) // The batch carries no native value to pay the dispatch with, so the gateway @@ -219,19 +245,18 @@ describe("the budget call a bid ends with", () => { crossChain, outputs, optionsWith(RELAYER_FEE), - FILL_GAS + RELAYER_FEE, { budget, solverAccount: SOLVER }, ) const batch = batchOf(callData) expect(settled(batch)).toMatchObject({ approved: PAYOUT, fee: 0n }) expect(approvedIn(batch, CNGN)).toBe(PAYOUT) - expect(approvedIn(batch, USDC)).toBe(FILL_GAS + RELAYER_FEE) + expect(approvedIn(batch, USDC)).toBe(RELAYER_FEE) }) it("matches the budget's token whatever case it is written in", async () => { const shouted = `0x${CNGN.slice(2).toUpperCase()}` as HexString - const callData = await service().buildApprovalAndFillCalldata(sameChain, outputs, optionsWith(0n), 0n, { + const callData = await service().buildApprovalAndFillCalldata(sameChain, outputs, optionsWith(0n), { budget: { ...budget, token: shouted }, solverAccount: SOLVER, }) @@ -244,15 +269,17 @@ describe("the budget call a bid ends with", () => { const funding: ERC7821Call = { target: "0x0000000000000000000000000000000000000001", value: 0n, data: "0x01" } contract.cacheService.setFundingPrepends(sameChain.id as string, [funding]) - const callData = await contract.buildApprovalAndFillCalldata(sameChain, outputs, optionsWith(0n), 0n, { + const callData = await contract.buildApprovalAndFillCalldata(sameChain, outputs, optionsWith(0n), { budget, solverAccount: SOLVER, }) const batch = batchOf(callData) + // The budget token's allowance is cleared by `debitOrder`, so no reset follows it. expect(batch).toHaveLength(5) expect(batch[0]).toEqual(funding) expect(batch[3].target.toLowerCase()).toBe(GATEWAY.toLowerCase()) + expect(debitIndex(batch)).toBe(4) expect(settled(batch).approved).toBe(PAYOUT) }) }) @@ -271,23 +298,27 @@ describe("a bid no budget applies to", () => { value: 0n, data: encodeFillOrder(transformOrderForContract(sameChain) as any, optionsWith(0n)), }, + approve(0n), ]) - it("signs the approvals and the fill, and nothing else", async () => { - expect(await service().buildApprovalAndFillCalldata(sameChain, outputs, optionsWith(0n), 0n)).toBe(unbudgeted) + it("signs the approvals, the fill and the zero-resets, and nothing else", async () => { + expect(await service().buildApprovalAndFillCalldata(sameChain, outputs, optionsWith(0n))).toBe(unbudgeted) }) - it("is what a budgeted bid signs ahead of its budget call", async () => { - const callData = await service().buildApprovalAndFillCalldata(sameChain, outputs, optionsWith(0n), 0n, { + it("is what a budgeted bid signs ahead of its budget call, which clears the budget token itself", async () => { + const callData = await service().buildApprovalAndFillCalldata(sameChain, outputs, optionsWith(0n), { budget, solverAccount: SOLVER, }) - expect(batchOf(callData).slice(0, -1)).toEqual(batchOf(unbudgeted)) + const batch = batchOf(callData) + + expect(debitIndex(batch)).toBe(batch.length - 1) + expect(batch.slice(0, -1)).toEqual(batchOf(unbudgeted).slice(0, -1)) }) it("includes one whose budget is in the native token", async () => { - const callData = await service().buildApprovalAndFillCalldata(sameChain, outputs, optionsWith(0n), 0n, { + const callData = await service().buildApprovalAndFillCalldata(sameChain, outputs, optionsWith(0n), { budget: { ...budget, token: ZERO }, solverAccount: SOLVER, }) @@ -315,7 +346,7 @@ describe("preparing a bid", () => { expect(bid.fillOptions.relayerFee).toBe(RELAYER_FEE) expect(fee).toBe(RELAYER_FEE) - expect(approved).toBe(PAYOUT + FILL_GAS + RELAYER_FEE) + expect(approved).toBe(PAYOUT + RELAYER_FEE) }) it("signs no budget call when no limit order priced the bid", async () => { @@ -323,7 +354,7 @@ describe("preparing a bid", () => { const bid = await prepareBid(contract, sameChain) expect(bid.callData).toBe( - await contract.buildApprovalAndFillCalldata(sameChain, outputs, bid.fillOptions, FILL_GAS + RELAYER_FEE), + await contract.buildApprovalAndFillCalldata(sameChain, outputs, bid.fillOptions), ) expect(batchOf(bid.callData).map((call) => call.target.toLowerCase())).not.toContain(SOLVER.toLowerCase()) }) @@ -335,16 +366,55 @@ describe("preparing a bid", () => { }) it("allows call gas for the budget call only when the bid carries it", async () => { + // One approved token in every case: the output, CNGN. const contract = service() const funding: ERC7821Call = { target: "0x0000000000000000000000000000000000000001", value: 0n, data: "0x01" } - expect((await prepareBid(contract, sameChain)).callGasLimit).toBe(BASE_CALL_GAS) - expect((await prepareBid(contract, sameChain, budget)).callGasLimit).toBe(BASE_CALL_GAS + DEBIT_ORDER_GAS) - expect((await prepareBid(contract, sameChain, { ...budget, token: ZERO })).callGasLimit).toBe(BASE_CALL_GAS) + const base = BASE_CALL_GAS + APPROVAL_GAS_PER_TOKEN + + expect((await prepareBid(contract, sameChain)).callGasLimit).toBe(base) + expect((await prepareBid(contract, sameChain, budget)).callGasLimit).toBe(base + DEBIT_ORDER_GAS) + expect((await prepareBid(contract, sameChain, { ...budget, token: ZERO })).callGasLimit).toBe(base) contract.cacheService.setFundingPrepends(sameChain.id as string, [funding]) expect((await prepareBid(contract, sameChain, budget)).callGasLimit).toBe( - BASE_CALL_GAS + FUNDING_GAS_PER_CALL + DEBIT_ORDER_GAS, + base + FUNDING_GAS_PER_CALL + DEBIT_ORDER_GAS, ) }) + + describe("allows call gas for each token it approves", () => { + const leg = (token: HexString, amount: bigint) => ({ token, amount }) + + it("for one token, however many legs pay it", async () => { + const one = await prepareBid(service(), sameChain, undefined, [leg(CNGN, PAYOUT)]) + const two = await prepareBid(service(), sameChain, undefined, [leg(CNGN, 1_000n), leg(CNGN, 2_000n)]) + + expect(one.callGasLimit).toBe(BASE_CALL_GAS + APPROVAL_GAS_PER_TOKEN) + expect(two.callGasLimit).toBe(BASE_CALL_GAS + APPROVAL_GAS_PER_TOKEN) + }) + + it("for two tokens", async () => { + const bid = await prepareBid(service(), sameChain, undefined, [leg(CNGN, PAYOUT), leg(OTHER_TOKEN, 5n)]) + + expect(bid.callGasLimit).toBe(BASE_CALL_GAS + 2n * APPROVAL_GAS_PER_TOKEN) + }) + + it("for none when the only output is native", async () => { + const bid = await prepareBid(service(), sameChain, undefined, [leg(ZERO, 5n)]) + + expect(bid.callGasLimit).toBe(BASE_CALL_GAS) + }) + + it("for a cross-chain fill's fee token that no output pays", async () => { + const bid = await prepareBid(service(USDC), crossChain, undefined, [leg(CNGN, PAYOUT)]) + + expect(bid.callGasLimit).toBe(BASE_CALL_GAS + 2n * APPROVAL_GAS_PER_TOKEN) + }) + + it("once for a cross-chain fill's fee token that an output pays", async () => { + const bid = await prepareBid(service(CNGN), crossChain, undefined, [leg(CNGN, PAYOUT)]) + + expect(bid.callGasLimit).toBe(BASE_CALL_GAS + APPROVAL_GAS_PER_TOKEN) + }) + }) }) diff --git a/sdk/packages/simplex/src/tests/services/bid-call-gas.test.ts b/sdk/packages/simplex/src/tests/services/bid-call-gas.test.ts index 15910202f..be2f5ce59 100644 --- a/sdk/packages/simplex/src/tests/services/bid-call-gas.test.ts +++ b/sdk/packages/simplex/src/tests/services/bid-call-gas.test.ts @@ -16,6 +16,7 @@ import { privateKeySigner } from "@/services/wallet" const SOLVER_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" as HexString const BASE_CALL_GAS = 1_000_000n const FUNDING_GAS_PER_CALL = 400_000n +const APPROVAL_GAS_PER_TOKEN = 45_000n function service(): ContractInteractionService { // biome-ignore lint/suspicious/noExplicitAny: nothing on this path reads a chain or config @@ -25,9 +26,9 @@ function service(): ContractInteractionService { const call = (data: HexString): ERC7821Call => ({ target: "0x0000000000000000000000000000000000000001", value: 0n, data }) const order = { id: "0xorder" } as Order -function callGasFor(contract: ContractInteractionService): bigint { +function callGasFor(contract: ContractInteractionService, approvedTokens = 0): bigint { // biome-ignore lint/suspicious/noExplicitAny: the allowance is what is under test - return (contract as any).callGasLimitFor(order, BASE_CALL_GAS) + return (contract as any).callGasLimitFor(order, BASE_CALL_GAS, approvedTokens) } describe("the call gas a bid signs", () => { @@ -37,6 +38,17 @@ describe("the call gas a bid signs", () => { expect(callGasFor(contract)).toBe(BASE_CALL_GAS) }) + it("adds an allowance for each token the batch approves", () => { + const contract = service() + contract.cacheService.clearFundingPrepends(order.id!) + + expect(callGasFor(contract, 1)).toBe(BASE_CALL_GAS + APPROVAL_GAS_PER_TOKEN) + expect(callGasFor(contract, 3)).toBe(BASE_CALL_GAS + 3n * APPROVAL_GAS_PER_TOKEN) + + contract.cacheService.setFundingPrepends(order.id!, [call("0x01")]) + expect(callGasFor(contract, 2)).toBe(BASE_CALL_GAS + FUNDING_GAS_PER_CALL + 2n * APPROVAL_GAS_PER_TOKEN) + }) + it("follows the funding calls of the bid being signed", () => { const contract = service() diff --git a/sdk/packages/simplex/src/tests/token-sender.test.ts b/sdk/packages/simplex/src/tests/token-sender.test.ts index 722bc1704..485ad3ca2 100644 --- a/sdk/packages/simplex/src/tests/token-sender.test.ts +++ b/sdk/packages/simplex/src/tests/token-sender.test.ts @@ -329,6 +329,22 @@ describe("TokenSender sponsored batches", () => { expect(sponsoredBatch(trySendSponsored).map((call) => call.target)).toEqual([TOKEN]) }) + it("sends the sponsored op on the nonce key the caller names", async () => { + const { sender, trySendSponsored } = makeSponsoredSender() + + await sender.submitCalls("EVM-137", [transfer], { nonceKey: 7n }) + + expect(trySendSponsored.mock.calls[0][0].nonceKey).toBe(7n) + }) + + it("leaves the nonce key unset for a caller that names none, so the op uses key 0", async () => { + const { sender, trySendSponsored } = makeSponsoredSender() + + await sender.submitCalls("EVM-137", [transfer]) + + expect(trySendSponsored.mock.calls[0][0].nonceKey).toBeUndefined() + }) + it("sends the batch unchanged when the approval check fails", async () => { vi.mocked(resolvePendingPermit2Approval).mockRejectedValueOnce(new Error("429 Too Many Requests")) const { sender, trySendSponsored } = makeSponsoredSender()