From 17594bdf3958cc8ed49ed03569439bfcedfe297b Mon Sep 17 00:00:00 2001 From: A1igator Date: Fri, 22 May 2026 01:45:21 -0700 Subject: [PATCH 1/2] feat(auth-capture/facilitator): trace-level simulation + gas cap on contract-path captureAuthorizer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the contract-path hardening from the merged auth-capture EVM spec section "Smart Contract captureAuthorizer". When extra.captureAuthorizer is a smart contract (getCode returns non-empty bytecode), the facilitator now: - Routes simulation through `eth_simulateV1` (viem `simulateCalls`) instead of a revert-only `readContract`, with `traceTransfers: true` and the gas cap applied to the call. - Verifies the trace contains the matching `PaymentAuthorized` / `PaymentCharged` event emitted by AUTH_CAPTURE_ESCROW_ADDRESS with the on-chain `paymentInfoHash` (via the new `computeOnchainPaymentInfoHash` helper, mirroring the contract's `getHash`). - Reconstructs ERC-20 Transfer deltas for `requirements.asset` and asserts they match the signed PaymentInfo — payer pays `amount`, on `authorize` the receiver/feeReceiver are untouched and the residual sums to +amount (escrow side), on `charge` receiver + feeReceiver split `amount` with the implied `feeBps ∈ [minFeeBps, maxFeeBps]`, no other address nets non-zero. - Applies the gas cap on the broadcast settle tx as well, so a wrapper that simulates within budget but spikes at execution time still cannot drain the facilitator. Cap is exposed as `CAPTURE_AUTHORIZER_GAS_LIMIT = 400_000n` so it can be referenced from tests and tuned per-deployment. New error reasons (mirroring the spec's "Error Codes" section): - `capture_authorizer_escrow_call_missing` - `capture_authorizer_payment_info_mismatch` - `capture_authorizer_asset_divergence` - `capture_authorizer_gas_exceeded` EOA path is unchanged: revert-only `readContract` simulation against the audited escrow, no gas override. Tests: - All 141 existing tests pass. - 9 new contract-path tests cover: honest passthrough → ok; missing escrow event; wrong paymentInfoHash; asset siphon to attacker; fee outside [min, max]; gas hog over the cap; signer without simulateCalls → simulation_failed; gas: 400_000n passed to writeContract on contract path only. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../mechanisms/evm/src/auth-capture/abi.ts | 51 ++ .../src/auth-capture/facilitator/errors.ts | 7 + .../src/auth-capture/facilitator/scheme.ts | 452 +++++++++++++++--- .../mechanisms/evm/src/auth-capture/nonce.ts | 63 +++ .../unit/auth-capture/facilitator.test.ts | 420 +++++++++++++++- 5 files changed, 929 insertions(+), 64 deletions(-) diff --git a/packages/mechanisms/evm/src/auth-capture/abi.ts b/packages/mechanisms/evm/src/auth-capture/abi.ts index 47273cf..62d3246 100644 --- a/packages/mechanisms/evm/src/auth-capture/abi.ts +++ b/packages/mechanisms/evm/src/auth-capture/abi.ts @@ -65,6 +65,57 @@ export const ERC20_BALANCE_OF_ABI = [ }, ] as const; +// ERC-20 Transfer event for asset-delta reconstruction from a trace. +export const ERC20_TRANSFER_EVENT_ABI = [ + { + type: "event", + name: "Transfer", + inputs: [ + { name: "from", type: "address", indexed: true }, + { name: "to", type: "address", indexed: true }, + { name: "value", type: "uint256", indexed: false }, + ], + }, +] as const; + +// AuthCaptureEscrow event signatures used by the contract-path simulation +// check. We only decode the events emitted by the entrypoints the facilitator +// can submit (authorize, charge) — every other escrow event is out of scope. +export const ESCROW_EVENTS_ABI = [ + { + type: "event", + name: "PaymentAuthorized", + inputs: [ + { name: "paymentInfoHash", type: "bytes32", indexed: true }, + { + name: "paymentInfo", + type: "tuple", + components: PAYMENT_INFO_COMPONENTS, + indexed: false, + }, + { name: "amount", type: "uint256", indexed: false }, + { name: "tokenCollector", type: "address", indexed: false }, + ], + }, + { + type: "event", + name: "PaymentCharged", + inputs: [ + { name: "paymentInfoHash", type: "bytes32", indexed: true }, + { + name: "paymentInfo", + type: "tuple", + components: PAYMENT_INFO_COMPONENTS, + indexed: false, + }, + { name: "amount", type: "uint256", indexed: false }, + { name: "tokenCollector", type: "address", indexed: false }, + { name: "feeBps", type: "uint16", indexed: false }, + { name: "feeReceiver", type: "address", indexed: false }, + ], + }, +] as const; + // View functions on AuthCaptureEscrow used by tests / introspection. Not part // of ESCROW_ABI because settle/simulate paths only need authorize + charge. export const ESCROW_VIEW_ABI = [ diff --git a/packages/mechanisms/evm/src/auth-capture/facilitator/errors.ts b/packages/mechanisms/evm/src/auth-capture/facilitator/errors.ts index d2d06c0..16c8ee6 100644 --- a/packages/mechanisms/evm/src/auth-capture/facilitator/errors.ts +++ b/packages/mechanisms/evm/src/auth-capture/facilitator/errors.ts @@ -27,6 +27,13 @@ export const ErrNonceMismatch = "nonce_mismatch"; export const ErrInsufficientBalance = "insufficient_balance"; export const ErrSimulationFailed = "simulation_failed"; +// Contract-path captureAuthorizer hardening errors. Surfaced by trace-level +// simulation when extra.captureAuthorizer is a smart contract. +export const ErrCaptureAuthorizerEscrowCallMissing = "capture_authorizer_escrow_call_missing"; +export const ErrCaptureAuthorizerPaymentInfoMismatch = "capture_authorizer_payment_info_mismatch"; +export const ErrCaptureAuthorizerAssetDivergence = "capture_authorizer_asset_divergence"; +export const ErrCaptureAuthorizerGasExceeded = "capture_authorizer_gas_exceeded"; + // Typed simulation reverts — surfaced when the on-chain simulate call reverts // with a known AuthCaptureEscrow custom error. export const ErrPaymentAlreadyCollected = "payment_already_collected"; diff --git a/packages/mechanisms/evm/src/auth-capture/facilitator/scheme.ts b/packages/mechanisms/evm/src/auth-capture/facilitator/scheme.ts index 1e9f343..08ef4fe 100644 --- a/packages/mechanisms/evm/src/auth-capture/facilitator/scheme.ts +++ b/packages/mechanisms/evm/src/auth-capture/facilitator/scheme.ts @@ -20,8 +20,22 @@ import type { VerifyResponse, } from "@x402/core/types"; import type { FacilitatorEvmSigner } from "@x402/evm"; -import { BaseError, ContractFunctionRevertedError, hexToBigInt, parseErc6492Signature } from "viem"; -import { ERC20_BALANCE_OF_ABI, ESCROW_ABI, ESCROW_ERRORS_ABI } from "../abi"; +import { + BaseError, + ContractFunctionRevertedError, + decodeEventLog, + encodeFunctionData, + hexToBigInt, + parseErc6492Signature, + type Log, +} from "viem"; +import { + ERC20_BALANCE_OF_ABI, + ERC20_TRANSFER_EVENT_ABI, + ESCROW_ABI, + ESCROW_ERRORS_ABI, + ESCROW_EVENTS_ABI, +} from "../abi"; import { AUTH_CAPTURE_ESCROW_ADDRESS, AUTH_CAPTURE_SCHEME, @@ -33,6 +47,10 @@ import { ErrAmountMismatch, ErrAuthorizationExpired, ErrAuthorizationNotYetValid, + ErrCaptureAuthorizerAssetDivergence, + ErrCaptureAuthorizerEscrowCallMissing, + ErrCaptureAuthorizerGasExceeded, + ErrCaptureAuthorizerPaymentInfoMismatch, ErrCaptureDeadlineExpired, ErrInsufficientBalance, ErrInvalidAuthCaptureExtra, @@ -52,6 +70,7 @@ import { ErrVerificationFailed, } from "./errors"; import { + computeOnchainPaymentInfoHash, computePayerAgnosticPaymentInfoHash, verifyERC3009Signature, verifyPermit2Signature, @@ -120,6 +139,18 @@ function paymentInfoToContractTuple(p: PaymentInfoStruct) { return { ...p, maxAmount: BigInt(p.maxAmount), salt: BigInt(p.salt) }; } +/** + * Hard gas cap applied to both trace simulation and the broadcast settle tx + * when `extra.captureAuthorizer` is a smart contract. Bounds the facilitator's + * gas exposure to a misbehaving authorizer contract. + * + * 400_000 comfortably covers a direct authorize/charge call through the + * audited escrow with either supported assetTransferMethod, plus headroom for + * a thin passthrough wrapper. Anything that needs more should be supported + * explicitly per-authorizer, not absorbed by widening the global cap. + */ +export const CAPTURE_AUTHORIZER_GAS_LIMIT = 400_000n; + /** * AuthCapture Facilitator Scheme - implements x402's SchemeNetworkFacilitator. * @@ -342,8 +373,18 @@ export class AuthCaptureEvmScheme implements SchemeNetworkFacilitator { } } - // Simulate the settle call to catch issues before spending gas. - const settleResult = await this.simulateSettle(paymentInfo, amount, wirePayload, extra, payer); + // Simulate the settle call to catch issues before spending gas. The + // on-chain hash uses the real payer (matches escrow.getHash); the wire + // nonce uses a zeroed payer. Both come from the same PaymentInfo struct, + // so they're computed from the same source of truth. + const onchainHash = computeOnchainPaymentInfoHash(chainId, paymentInfo); + const settleResult = await this.simulateSettle( + paymentInfo, + amount, + wirePayload, + extra, + onchainHash, + ); if (settleResult !== "ok") { // For balance-related failures, return a more actionable reason. try { @@ -400,10 +441,7 @@ export class AuthCaptureEvmScheme implements SchemeNetworkFacilitator { const assetTransferMethod = extra.assetTransferMethod ?? "eip3009"; const payer = verification.payer as `0x${string}`; - const { preApprovalExpiry, amount, tokenCollector, collectorData } = unpackForSettle( - wirePayload, - assetTransferMethod, - ); + const { preApprovalExpiry, amount } = unpackForSettle(wirePayload, assetTransferMethod); const paymentInfo = reconstructPaymentInfo( payer, preApprovalExpiry, @@ -412,25 +450,14 @@ export class AuthCaptureEvmScheme implements SchemeNetworkFacilitator { extra, ); - const functionName = extra.autoCapture === true ? "charge" : "authorize"; - const tuple = paymentInfoToContractTuple(paymentInfo); // charge() takes 6 args (adds feeBps + feeReceiver); authorize() takes 4. // Use minFeeBps as the safe default within the merchant's signed [min, max] // range; feeReceiver mirrors paymentInfo.feeReceiver (= extra.feeRecipient) // because _validateFee requires actual to match configured when configured != 0. - const args = - functionName === "charge" - ? ([ - tuple, - amount, - tokenCollector, - collectorData, - paymentInfo.minFeeBps, - paymentInfo.feeReceiver, - ] as const) - : ([tuple, amount, tokenCollector, collectorData] as const); + const { functionName, args } = buildSettleArgs(paymentInfo, amount, wirePayload, extra); const settleTarget = await this.resolveSettleTarget(extra.captureAuthorizer); + const isContractPath = settleTarget !== AUTH_CAPTURE_ESCROW_ADDRESS; try { const txHash = await this.signer.writeContract({ @@ -438,6 +465,10 @@ export class AuthCaptureEvmScheme implements SchemeNetworkFacilitator { abi: ESCROW_ABI, functionName, args, + // Apply the gas cap on the contract path so a misbehaving authorizer + // cannot drain the facilitator. The EOA path skips this so the audited + // escrow gets a normal eth_estimateGas-driven limit. + ...(isContractPath ? { gas: CAPTURE_AUTHORIZER_GAS_LIMIT } : {}), }); const receiptPromise = this.signer.waitForTransactionReceipt({ hash: txHash }); @@ -474,19 +505,25 @@ export class AuthCaptureEvmScheme implements SchemeNetworkFacilitator { } /** - * Simulate the settle call via `eth_call` and translate the result to a - * stable wire-level reason. Returns `"ok"` on simulated success; on revert - * viem walks the error chain for `ContractFunctionRevertedError` and decodes - * the custom-error name against `ESCROW_ABI + ESCROW_ERRORS_ABI`. Known - * errors map to typed reasons via `ESCROW_ERROR_TO_INVALID_REASON`; anything - * unmapped (e.g. token-collector reverts like a consumed ERC-3009 nonce) - * falls through to `simulation_failed`. + * Simulate the settle call and translate the result to a stable wire-level + * reason. Dispatches by captureAuthorizer kind: + * + * - **EOA path** (settle target == escrow): a revert-only `eth_call` + * simulation is sufficient. The escrow is the same audited contract on + * every chain; semantic guarantees are baked in. + * - **Contract path** (settle target == captureAuthorizer contract): + * trace-level simulation is mandatory per `scheme_auth_capture_evm.md`'s + * "Smart Contract `captureAuthorizer`" section. We verify the wrapper + * actually reaches escrow with the signed PaymentInfo, that asset deltas + * match the signed split, and that the simulated gas stays under the cap. + * + * Returns `"ok"` on simulated success, or a stable `invalidReason` string. * * @param paymentInfo - The reconstructed PaymentInfo struct. * @param amount - Settle amount in token base units. * @param wirePayload - The payer's wire payload. * @param extra - Validated `AuthCaptureExtra` from `requirements.extra`. - * @param _ - Unused payer address (interface compatibility). + * @param paymentInfoHash - On-chain `paymentInfoHash` (matches escrow event topic). * @returns `"ok"` on simulated success, or a stable `invalidReason` string. */ private async simulateSettle( @@ -494,36 +531,44 @@ export class AuthCaptureEvmScheme implements SchemeNetworkFacilitator { amount: bigint, wirePayload: AuthCapturePayload, extra: AuthCaptureExtra, - _: `0x${string}`, + paymentInfoHash: `0x${string}`, ): Promise<"ok" | string> { - const assetTransferMethod = extra.assetTransferMethod ?? "eip3009"; - const { tokenCollector, collectorData } = unpackForSettle(wirePayload, assetTransferMethod); - const functionName = extra.autoCapture === true ? "charge" : "authorize"; - const tuple = paymentInfoToContractTuple(paymentInfo); - const args = - functionName === "charge" - ? ([ - tuple, - amount, - tokenCollector, - collectorData, - paymentInfo.minFeeBps, - paymentInfo.feeReceiver, - ] as const) - : ([tuple, amount, tokenCollector, collectorData] as const); - const settleTarget = await this.resolveSettleTarget(extra.captureAuthorizer); + const isContractPath = settleTarget !== AUTH_CAPTURE_ESCROW_ADDRESS; + + return isContractPath + ? this.simulateAuthorizerPassthrough( + settleTarget, + paymentInfo, + amount, + wirePayload, + extra, + paymentInfoHash, + ) + : this.simulateEscrowDirect(paymentInfo, amount, wirePayload, extra); + } + /** + * EOA path: simulate `authorize` / `charge` against the canonical escrow + * with the facilitator EOA as `msg.sender`. Returns `"ok"` on success, or a + * stable wire reason decoded from a `ContractFunctionRevertedError`. + * + * @param paymentInfo - The reconstructed PaymentInfo struct. + * @param amount - Settle amount in token base units. + * @param wirePayload - The payer's wire payload. + * @param extra - Validated `AuthCaptureExtra` from `requirements.extra`. + * @returns `"ok"` on simulated success, or a stable `invalidReason` string. + */ + private async simulateEscrowDirect( + paymentInfo: PaymentInfoStruct, + amount: bigint, + wirePayload: AuthCapturePayload, + extra: AuthCaptureExtra, + ): Promise<"ok" | string> { + const { functionName, args } = buildSettleArgs(paymentInfo, amount, wirePayload, extra); try { - // Simulate as the facilitator EOA so escrow's `onlySender(operator)` gate - // is evaluated against the same `msg.sender` that the real settle tx will - // have (EOA path: facilitator EOA; contract path: captureAuthorizer - // contract, which forwards as itself). viem's underlying readContract - // accepts `account`, but the FacilitatorEvmSigner type in @x402/evm - // doesn't declare it yet (pending upstream signer-type update in - // https://github.com/x402-foundation/x402/pull/2308). await this.signer.readContract({ - address: settleTarget, + address: AUTH_CAPTURE_ESCROW_ADDRESS, abi: ESCROW_ABI_WITH_ERRORS, functionName, args, @@ -535,6 +580,97 @@ export class AuthCaptureEvmScheme implements SchemeNetworkFacilitator { } } + /** + * Contract path: trace-level simulation of the settle call against an + * untrusted captureAuthorizer contract. Uses the signer's `simulateCalls` + * capability (viem PublicClient action) to capture logs and per-call gas. + * Checks layered on top of a revert-only simulation: + * + * 1. Gas: simulated `gasUsed` MUST be ≤ `CAPTURE_AUTHORIZER_GAS_LIMIT`. + * 2. Escrow event: the trace MUST contain the matching `PaymentAuthorized` + * or `PaymentCharged` event emitted by `AUTH_CAPTURE_ESCROW_ADDRESS`, + * with `paymentInfoHash` equal to the payer-agnostic hash verified in + * step 12. + * 3. Asset deltas: ERC-20 `Transfer` events for `requirements.asset` must + * move funds consistent with the signed PaymentInfo — payer pays + * `amount`; on `authorize` the receiver/feeReceiver are untouched and + * no address outside `{payer, receiver, feeReceiver}` net-receives + * other than as a known intermediate; on `charge` receiver + + * feeReceiver get the split with `feeBps ∈ [minFeeBps, maxFeeBps]`. + * + * Falls back to revert reason decoding on simulation failure. If the + * signer doesn't expose `simulateCalls`, we cannot satisfy the spec on + * the contract path and return `simulation_failed`. + * + * @param target - Resolved settle target — the captureAuthorizer contract. + * @param paymentInfo - The reconstructed PaymentInfo struct. + * @param amount - Settle amount in token base units. + * @param wirePayload - The payer's wire payload. + * @param extra - Validated `AuthCaptureExtra` from `requirements.extra`. + * @param paymentInfoHash - On-chain `paymentInfoHash` (matches escrow event topic). + * @returns `"ok"` on simulated success, or a stable `invalidReason` string. + */ + private async simulateAuthorizerPassthrough( + target: `0x${string}`, + paymentInfo: PaymentInfoStruct, + amount: bigint, + wirePayload: AuthCapturePayload, + extra: AuthCaptureExtra, + paymentInfoHash: `0x${string}`, + ): Promise<"ok" | string> { + const simulateCalls = (this.signer as unknown as Partial).simulateCalls; + if (typeof simulateCalls !== "function") { + return ErrSimulationFailed; + } + const { functionName, args } = buildSettleArgs(paymentInfo, amount, wirePayload, extra); + const data = encodeFunctionData({ + abi: ESCROW_ABI, + functionName, + args, + } as Parameters[0]); + + let traceResult: SimulateCallResult; + try { + const response = (await simulateCalls.call(this.signer, { + account: this.signer.getAddresses()[0], + calls: [ + { + to: target, + data, + gas: CAPTURE_AUTHORIZER_GAS_LIMIT, + }, + ], + traceTransfers: true, + })) as SimulateCallsResponse; + traceResult = response.results[0]; + } catch (err) { + return decodeRevertReason(err); + } + + if (traceResult.status !== "success") { + // Some RPCs surface the revert as `error` on the result; others throw. + // Try to decode whichever we got. + if (traceResult.error) return decodeRevertReason(traceResult.error); + return ErrSimulationFailed; + } + + if ( + typeof traceResult.gasUsed === "bigint" && + traceResult.gasUsed > CAPTURE_AUTHORIZER_GAS_LIMIT + ) { + return ErrCaptureAuthorizerGasExceeded; + } + + const logs = traceResult.logs ?? []; + const escrowCheck = verifyEscrowEvent(logs, functionName, paymentInfoHash); + if (escrowCheck !== "ok") return escrowCheck; + + const assetCheck = verifyAssetDeltas(logs, paymentInfo, amount, functionName); + if (assetCheck !== "ok") return assetCheck; + + return "ok"; + } + /** * Resolve the on-chain target for an `authorize`/`charge` call per spec. * Per `scheme_auth_capture_evm.md`, the facilitator may call escrow `"either @@ -588,6 +724,214 @@ function decodeRevertReason(err: unknown): string { return ErrSimulationFailed; } +/** + * Build the function name + args tuple used by both the simulate and settle + * paths so the two are guaranteed identical. Encodes `charge`'s 6-arg / 4-arg + * split in one place. + * + * @param paymentInfo - The reconstructed PaymentInfo struct. + * @param amount - Settle amount in token base units. + * @param wirePayload - The payer's wire payload. + * @param extra - Validated `AuthCaptureExtra` from `requirements.extra`. + * @returns `functionName` (authorize | charge) and the matching `args` tuple. + */ +function buildSettleArgs( + paymentInfo: PaymentInfoStruct, + amount: bigint, + wirePayload: AuthCapturePayload, + extra: AuthCaptureExtra, +): { functionName: "authorize" | "charge"; args: readonly unknown[] } { + const assetTransferMethod = extra.assetTransferMethod ?? "eip3009"; + const { tokenCollector, collectorData } = unpackForSettle(wirePayload, assetTransferMethod); + const functionName = extra.autoCapture === true ? "charge" : "authorize"; + const tuple = paymentInfoToContractTuple(paymentInfo); + const args = + functionName === "charge" + ? ([ + tuple, + amount, + tokenCollector, + collectorData, + paymentInfo.minFeeBps, + paymentInfo.feeReceiver, + ] as const) + : ([tuple, amount, tokenCollector, collectorData] as const); + return { functionName, args }; +} + +/** + * Shape the contract path expects from the signer for `eth_simulateV1` + * (viem PublicClient.simulateCalls). Not declared on FacilitatorEvmSigner + * because not every signer transport surfaces it; we feature-detect at use. + */ +type SimulateCallsCapable = { + simulateCalls(args: { + account?: `0x${string}`; + calls: Array<{ to: `0x${string}`; data: `0x${string}`; gas?: bigint }>; + traceTransfers?: boolean; + }): Promise; +}; + +type SimulateCallResult = { + status: "success" | "failure" | string; + gasUsed?: bigint; + logs?: ReadonlyArray; + error?: unknown; +}; + +type SimulateCallsResponse = { + results: ReadonlyArray; +}; + +/** + * Find the `PaymentAuthorized` / `PaymentCharged` event in a simulated trace, + * emitted by `AUTH_CAPTURE_ESCROW_ADDRESS`, and assert its indexed + * `paymentInfoHash` matches the hash recomputed in verify step 12. + * + * No matching event → `capture_authorizer_escrow_call_missing` (the wrapper + * didn't reach escrow at all). Hash mismatch → `capture_authorizer_payment_info_mismatch` + * (the wrapper reached escrow but with a different PaymentInfo than was signed). + * + * @param logs - All logs from the simulated trace. + * @param functionName - The escrow function the facilitator submitted. + * @param expectedHash - On-chain `paymentInfoHash` matching `escrow.getHash(paymentInfo)`. + * @returns `"ok"` or a stable wire reason. + */ +function verifyEscrowEvent( + logs: ReadonlyArray, + functionName: "authorize" | "charge", + expectedHash: `0x${string}`, +): "ok" | string { + const escrow = AUTH_CAPTURE_ESCROW_ADDRESS.toLowerCase(); + const expectedEventName = functionName === "authorize" ? "PaymentAuthorized" : "PaymentCharged"; + let foundEscrowEvent = false; + + for (const log of logs) { + if (log.address.toLowerCase() !== escrow) continue; + let decoded: ReturnType; + try { + decoded = decodeEventLog({ + abi: ESCROW_EVENTS_ABI, + data: log.data, + topics: log.topics, + strict: false, + }); + } catch { + continue; + } + if (decoded.eventName !== expectedEventName) continue; + foundEscrowEvent = true; + const hash = (decoded.args as { paymentInfoHash: `0x${string}` }).paymentInfoHash; + if (hash.toLowerCase() === expectedHash.toLowerCase()) return "ok"; + } + + return foundEscrowEvent + ? ErrCaptureAuthorizerPaymentInfoMismatch + : ErrCaptureAuthorizerEscrowCallMissing; +} + +/** + * Reconstruct net ERC-20 deltas for `paymentInfo.token` from the trace and + * assert they match the signed PaymentInfo. + * + * On `authorize`: payer must be -amount; receiver and feeReceiver must be 0; + * all other addresses with non-zero net delta must sum to +amount (the + * "escrow side" of the move — escrow's TokenStore is the typical destination, + * but we don't enumerate it explicitly). + * + * On `charge`: payer must be -amount; receiver + feeReceiver must sum to + * +amount with an implied `feeBps ∈ [minFeeBps, maxFeeBps]`; everything else + * must net to 0 (funds flow all the way through). + * + * Any divergence → `capture_authorizer_asset_divergence`. + * + * @param logs - All logs from the simulated trace. + * @param paymentInfo - The reconstructed PaymentInfo struct. + * @param amount - Settle amount in token base units. + * @param functionName - The escrow function the facilitator submitted. + * @returns `"ok"` or `capture_authorizer_asset_divergence`. + */ +function verifyAssetDeltas( + logs: ReadonlyArray, + paymentInfo: PaymentInfoStruct, + amount: bigint, + functionName: "authorize" | "charge", +): "ok" | string { + const token = paymentInfo.token.toLowerCase(); + const deltas = new Map(); + + for (const log of logs) { + if (log.address.toLowerCase() !== token) continue; + let decoded: ReturnType; + try { + decoded = decodeEventLog({ + abi: ERC20_TRANSFER_EVENT_ABI, + data: log.data, + topics: log.topics, + strict: false, + }); + } catch { + continue; + } + if (decoded.eventName !== "Transfer") continue; + const { from, to, value } = decoded.args as { + from: `0x${string}`; + to: `0x${string}`; + value: bigint; + }; + const fromKey = from.toLowerCase(); + const toKey = to.toLowerCase(); + deltas.set(fromKey, (deltas.get(fromKey) ?? 0n) - value); + deltas.set(toKey, (deltas.get(toKey) ?? 0n) + value); + } + + const payerKey = paymentInfo.payer.toLowerCase(); + const receiverKey = paymentInfo.receiver.toLowerCase(); + const feeReceiverKey = paymentInfo.feeReceiver.toLowerCase(); + + const payerDelta = deltas.get(payerKey) ?? 0n; + if (payerDelta !== -amount) return ErrCaptureAuthorizerAssetDivergence; + + const receiverDelta = deltas.get(receiverKey) ?? 0n; + const feeReceiverDelta = deltas.get(feeReceiverKey) ?? 0n; + + if (functionName === "authorize") { + // Receiver and feeReceiver are untouched at authorize time. The +amount + // ends up somewhere in the escrow system (its TokenStore in the canonical + // contracts); sum that "other" bucket and assert it equals +amount. + if (receiverDelta !== 0n) return ErrCaptureAuthorizerAssetDivergence; + if (feeReceiverDelta !== 0n) return ErrCaptureAuthorizerAssetDivergence; + let otherSum = 0n; + for (const [addr, delta] of deltas) { + if (addr === payerKey || addr === receiverKey || addr === feeReceiverKey) continue; + otherSum += delta; + } + return otherSum === amount ? "ok" : ErrCaptureAuthorizerAssetDivergence; + } + + // charge path: receiver + feeReceiver split the amount; nothing else nets non-zero. + if (receiverDelta < 0n || feeReceiverDelta < 0n) { + return ErrCaptureAuthorizerAssetDivergence; + } + if (receiverDelta + feeReceiverDelta !== amount) { + return ErrCaptureAuthorizerAssetDivergence; + } + // Validate the implied feeBps falls inside the signed [min, max] range. + // amount * minFeeBps <= feeReceiverDelta * 10000 <= amount * maxFeeBps + const feeNumerator = feeReceiverDelta * 10000n; + if (feeNumerator < amount * BigInt(paymentInfo.minFeeBps)) { + return ErrCaptureAuthorizerAssetDivergence; + } + if (feeNumerator > amount * BigInt(paymentInfo.maxFeeBps)) { + return ErrCaptureAuthorizerAssetDivergence; + } + for (const [addr, delta] of deltas) { + if (addr === payerKey || addr === receiverKey || addr === feeReceiverKey) continue; + if (delta !== 0n) return ErrCaptureAuthorizerAssetDivergence; + } + return "ok"; +} + /** * Unpack the per-method inputs the escrow needs at settle time: the token * collector address (canonical, per method) and the `collectorData` blob the diff --git a/packages/mechanisms/evm/src/auth-capture/nonce.ts b/packages/mechanisms/evm/src/auth-capture/nonce.ts index ff5b207..8b3e223 100644 --- a/packages/mechanisms/evm/src/auth-capture/nonce.ts +++ b/packages/mechanisms/evm/src/auth-capture/nonce.ts @@ -86,6 +86,69 @@ export function computePayerAgnosticPaymentInfoHash( return keccak256(outerEncoded); } +/** + * Compute the on-chain `paymentInfoHash` (TS mirror of `AuthCaptureEscrow.getHash`). + * + * Differs from {@link computePayerAgnosticPaymentInfoHash} by encoding the + * actual `paymentInfo.payer` instead of `address(0)`. The escrow emits this + * hash as the indexed `paymentInfoHash` topic of `PaymentAuthorized` / + * `PaymentCharged`, so trace-level simulation checks compare against this + * function — NOT against the wire nonce. + * + * @param chainId - EVM chain id. + * @param paymentInfo - The reconstructed PaymentInfo struct with the real payer. + * @returns The on-chain payment-info hash. + */ +export function computeOnchainPaymentInfoHash( + chainId: number, + paymentInfo: PaymentInfoStruct, +): `0x${string}` { + const paymentInfoEncoded = encodeAbiParameters( + [ + { name: "typehash", type: "bytes32" }, + { name: "operator", type: "address" }, + { name: "payer", type: "address" }, + { name: "receiver", type: "address" }, + { name: "token", type: "address" }, + { name: "maxAmount", type: "uint120" }, + { name: "preApprovalExpiry", type: "uint48" }, + { name: "authorizationExpiry", type: "uint48" }, + { name: "refundExpiry", type: "uint48" }, + { name: "minFeeBps", type: "uint16" }, + { name: "maxFeeBps", type: "uint16" }, + { name: "feeReceiver", type: "address" }, + { name: "salt", type: "uint256" }, + ], + [ + PAYMENT_INFO_TYPEHASH, + paymentInfo.operator, + paymentInfo.payer, + paymentInfo.receiver, + paymentInfo.token, + BigInt(paymentInfo.maxAmount), + paymentInfo.preApprovalExpiry, + paymentInfo.authorizationExpiry, + paymentInfo.refundExpiry, + paymentInfo.minFeeBps, + paymentInfo.maxFeeBps, + paymentInfo.feeReceiver, + BigInt(paymentInfo.salt), + ], + ); + const paymentInfoHash = keccak256(paymentInfoEncoded); + + const outerEncoded = encodeAbiParameters( + [ + { name: "chainId", type: "uint256" }, + { name: "escrow", type: "address" }, + { name: "paymentInfoHash", type: "bytes32" }, + ], + [BigInt(chainId), AUTH_CAPTURE_ESCROW_ADDRESS, paymentInfoHash], + ); + + return keccak256(outerEncoded); +} + /** * Sign an ERC-3009 `ReceiveWithAuthorization` over the supplied authorization * fields. The EIP-712 domain is bound to the **token contract** (not the diff --git a/packages/mechanisms/evm/test/unit/auth-capture/facilitator.test.ts b/packages/mechanisms/evm/test/unit/auth-capture/facilitator.test.ts index e3d1e34..28f187b 100644 --- a/packages/mechanisms/evm/test/unit/auth-capture/facilitator.test.ts +++ b/packages/mechanisms/evm/test/unit/auth-capture/facilitator.test.ts @@ -2,20 +2,171 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { ContractFunctionExecutionError, ContractFunctionRevertedError, + encodeAbiParameters, encodeErrorResult, + encodeEventTopics, hexToBigInt, + type Log, } from "viem"; import { AuthCaptureEvmScheme } from "../../../src/auth-capture/facilitator/scheme"; -import { ESCROW_ABI, ESCROW_ERRORS_ABI } from "../../../src/auth-capture/abi"; +import { + ERC20_TRANSFER_EVENT_ABI, + ESCROW_ABI, + ESCROW_EVENTS_ABI, + PAYMENT_INFO_COMPONENTS, +} from "../../../src/auth-capture/abi"; import { AUTH_CAPTURE_ESCROW_ADDRESS, EIP3009_TOKEN_COLLECTOR_ADDRESS, PERMIT2_TOKEN_COLLECTOR_ADDRESS, } from "../../../src/auth-capture/constants"; -import { computePayerAgnosticPaymentInfoHash } from "../../../src/auth-capture/nonce"; +import { + computeOnchainPaymentInfoHash, + computePayerAgnosticPaymentInfoHash, +} from "../../../src/auth-capture/nonce"; import type { PaymentInfoStruct } from "../../../src/auth-capture/types"; +/** + * Build a fake ERC-20 Transfer log that the trace simulator can return. + */ +function transferLog( + token: `0x${string}`, + from: `0x${string}`, + to: `0x${string}`, + value: bigint, +): Log { + const topics = encodeEventTopics({ + abi: ERC20_TRANSFER_EVENT_ABI, + eventName: "Transfer", + args: { from, to }, + }); + const data = encodeAbiParameters([{ type: "uint256" }], [value]); + return { + address: token, + topics: topics as [`0x${string}`, ...`0x${string}`[]], + data, + blockNumber: 0n, + transactionHash: "0x0000000000000000000000000000000000000000000000000000000000000001", + transactionIndex: 0, + blockHash: "0x0000000000000000000000000000000000000000000000000000000000000001", + logIndex: 0, + removed: false, + } as Log; +} + +/** + * Build a PaymentAuthorized / PaymentCharged log emitted by the canonical + * AuthCaptureEscrow address with the correct on-chain paymentInfoHash topic. + */ +function escrowEventLog( + paymentInfo: PaymentInfoStruct, + amount: bigint, + functionName: "authorize" | "charge", + tokenCollector: `0x${string}`, + chainId: number, + feeBps = 0, + feeReceiver: `0x${string}` = paymentInfo.feeReceiver, + override?: { paymentInfoHash?: `0x${string}`; address?: `0x${string}` }, +): Log { + const hash = override?.paymentInfoHash ?? computeOnchainPaymentInfoHash(chainId, paymentInfo); + const eventName = functionName === "authorize" ? "PaymentAuthorized" : "PaymentCharged"; + const topics = encodeEventTopics({ + abi: ESCROW_EVENTS_ABI, + eventName, + args: { paymentInfoHash: hash }, + }); + const paymentInfoTuple = { + ...paymentInfo, + maxAmount: BigInt(paymentInfo.maxAmount), + salt: BigInt(paymentInfo.salt), + }; + const data = + functionName === "authorize" + ? encodeAbiParameters( + [ + { type: "tuple", components: PAYMENT_INFO_COMPONENTS }, + { type: "uint256" }, + { type: "address" }, + ], + [paymentInfoTuple, amount, tokenCollector], + ) + : encodeAbiParameters( + [ + { type: "tuple", components: PAYMENT_INFO_COMPONENTS }, + { type: "uint256" }, + { type: "address" }, + { type: "uint16" }, + { type: "address" }, + ], + [paymentInfoTuple, amount, tokenCollector, feeBps, feeReceiver], + ); + return { + address: override?.address ?? AUTH_CAPTURE_ESCROW_ADDRESS, + topics: topics as [`0x${string}`, ...`0x${string}`[]], + data, + blockNumber: 0n, + transactionHash: "0x0000000000000000000000000000000000000000000000000000000000000002", + transactionIndex: 0, + blockHash: "0x0000000000000000000000000000000000000000000000000000000000000002", + logIndex: 1, + removed: false, + } as Log; +} + +/** + * A "honest passthrough" trace for the contract path. Returns a successful + * simulateCalls response with: escrow event w/ matching hash + Transfer + * events with deltas that match the signed PaymentInfo. + */ +function buildHonestTrace( + paymentInfo: PaymentInfoStruct, + amount: bigint, + functionName: "authorize" | "charge", + tokenCollector: `0x${string}`, + chainId: number, + options: { gasUsed?: bigint; feeBps?: number } = {}, +) { + const escrowLog = escrowEventLog( + paymentInfo, + amount, + functionName, + tokenCollector, + chainId, + options.feeBps ?? 0, + ); + const intermediateBucket = "0x5555555555555555555555555555555555555555" as `0x${string}`; // stand-in for token store + const logs: Log[] = + functionName === "authorize" + ? [ + escrowLog, + transferLog(paymentInfo.token, paymentInfo.payer, tokenCollector, amount), + transferLog(paymentInfo.token, tokenCollector, intermediateBucket, amount), + ] + : (() => { + const fee = (amount * BigInt(options.feeBps ?? 0)) / 10000n; + const net = amount - fee; + return [ + escrowLog, + transferLog(paymentInfo.token, paymentInfo.payer, tokenCollector, amount), + transferLog(paymentInfo.token, tokenCollector, paymentInfo.receiver, net), + ...(fee > 0n + ? [transferLog(paymentInfo.token, tokenCollector, paymentInfo.feeReceiver, fee)] + : []), + ]; + })(); + return { + results: [ + { + status: "success", + gasUsed: options.gasUsed ?? 220_000n, + logs, + }, + ], + }; +} + describe("AuthCaptureEvmScheme", () => { + const CHAIN_ID = 84532; const createMockSigner = () => ({ getAddresses: () => ["0x1234567890123456789012345678901234567890"] as readonly `0x${string}`[], readContract: vi.fn().mockResolvedValue(BigInt("1000000000")), @@ -24,6 +175,7 @@ describe("AuthCaptureEvmScheme", () => { sendTransaction: vi.fn(), waitForTransactionReceipt: vi.fn().mockResolvedValue({ status: "success" }), getCode: vi.fn().mockResolvedValue("0x"), + simulateCalls: vi.fn(), }); let mockSigner: ReturnType; @@ -177,6 +329,15 @@ describe("AuthCaptureEvmScheme", () => { it("should route authorize × eip3009 × contract via the captureAuthorizer with the literal escrow ABI and 4 args", async () => { mockSigner.getCode.mockResolvedValue("0x6080604052"); + mockSigner.simulateCalls.mockResolvedValue( + buildHonestTrace( + buildPaymentInfo(), + 1_000_000n, + "authorize", + EIP3009_TOKEN_COLLECTOR_ADDRESS, + CHAIN_ID, + ), + ); const scheme = new AuthCaptureEvmScheme(mockSigner); await scheme.settle(buildEip3009Payload(), mockRequirements); @@ -190,6 +351,15 @@ describe("AuthCaptureEvmScheme", () => { it("should route charge × eip3009 × contract via the captureAuthorizer with the 6-arg ABI", async () => { mockSigner.getCode.mockResolvedValue("0x6080604052"); + mockSigner.simulateCalls.mockResolvedValue( + buildHonestTrace( + buildPaymentInfo(), + 1_000_000n, + "charge", + EIP3009_TOKEN_COLLECTOR_ADDRESS, + CHAIN_ID, + ), + ); const scheme = new AuthCaptureEvmScheme(mockSigner); const reqs = { ...mockRequirements, @@ -212,6 +382,15 @@ describe("AuthCaptureEvmScheme", () => { it("should route authorize × permit2 × contract via the captureAuthorizer with the permit2 collector", async () => { mockSigner.getCode.mockResolvedValue("0x6080604052"); + mockSigner.simulateCalls.mockResolvedValue( + buildHonestTrace( + buildPaymentInfo(), + 1_000_000n, + "authorize", + PERMIT2_TOKEN_COLLECTOR_ADDRESS, + CHAIN_ID, + ), + ); const scheme = new AuthCaptureEvmScheme(mockSigner); const reqs = { ...mockRequirements, @@ -229,6 +408,15 @@ describe("AuthCaptureEvmScheme", () => { it("should route charge × permit2 × contract via the captureAuthorizer with 6 args + permit2 collector", async () => { mockSigner.getCode.mockResolvedValue("0x6080604052"); + mockSigner.simulateCalls.mockResolvedValue( + buildHonestTrace( + buildPaymentInfo(), + 1_000_000n, + "charge", + PERMIT2_TOKEN_COLLECTOR_ADDRESS, + CHAIN_ID, + ), + ); const scheme = new AuthCaptureEvmScheme(mockSigner); const reqs = { ...mockRequirements, @@ -248,18 +436,26 @@ describe("AuthCaptureEvmScheme", () => { expect(call.args[2]).toBe(PERMIT2_TOKEN_COLLECTOR_ADDRESS); }); - it("should route simulateSettle through the captureAuthorizer contract with ESCROW_ABI + errors", async () => { + it("should route contract-path simulation through simulateCalls targeting the captureAuthorizer with the gas cap applied", async () => { mockSigner.getCode.mockResolvedValue("0x6080604052"); + mockSigner.simulateCalls.mockResolvedValue( + buildHonestTrace( + buildPaymentInfo(), + 1_000_000n, + "authorize", + EIP3009_TOKEN_COLLECTOR_ADDRESS, + CHAIN_ID, + ), + ); const scheme = new AuthCaptureEvmScheme(mockSigner); await scheme.verify(buildEip3009Payload(), mockRequirements); - const simulateCall = mockSigner.readContract.mock.calls.find( - c => c[0].functionName === "authorize" || c[0].functionName === "charge", - ); - expect(simulateCall).toBeDefined(); - const call = simulateCall![0]; - expect(call.address).toBe(CAPTURE_AUTHORIZER); - expect(call.abi).toHaveLength(ESCROW_ABI.length + ESCROW_ERRORS_ABI.length); + expect(mockSigner.simulateCalls).toHaveBeenCalledTimes(1); + const call = mockSigner.simulateCalls.mock.calls[0][0]; + expect(call.calls).toHaveLength(1); + expect(call.calls[0].to).toBe(CAPTURE_AUTHORIZER); + expect(call.calls[0].gas).toBe(400_000n); + expect(call.traceTransfers).toBe(true); }); it("should pass EIP3009_TOKEN_COLLECTOR as the tokenCollector arg for eip3009", async () => { @@ -665,4 +861,208 @@ describe("AuthCaptureEvmScheme", () => { expect(scheme.getExtra("eip155:8453")).toBeUndefined(); }); }); + + describe("verify — contract-path captureAuthorizer hardening", () => { + beforeEach(() => { + // Default: contract path + mockSigner.getCode.mockResolvedValue("0x6080604052"); + }); + + it("should fail with capture_authorizer_gas_exceeded when simulated gasUsed exceeds the cap", async () => { + const trace = buildHonestTrace( + buildPaymentInfo(), + 1_000_000n, + "authorize", + EIP3009_TOKEN_COLLECTOR_ADDRESS, + CHAIN_ID, + { gasUsed: 500_000n }, + ); + mockSigner.simulateCalls.mockResolvedValue(trace); + const scheme = new AuthCaptureEvmScheme(mockSigner); + const result = await scheme.verify(buildEip3009Payload(), mockRequirements); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("capture_authorizer_gas_exceeded"); + }); + + it("should fail with capture_authorizer_escrow_call_missing when no escrow event was emitted", async () => { + mockSigner.simulateCalls.mockResolvedValue({ + results: [ + { + status: "success", + gasUsed: 100_000n, + logs: [ + // Only Transfer logs — the authorizer pulled funds but never reached escrow. + transferLog(ASSET, PAYER, CAPTURE_AUTHORIZER, 1_000_000n), + ], + }, + ], + }); + const scheme = new AuthCaptureEvmScheme(mockSigner); + const result = await scheme.verify(buildEip3009Payload(), mockRequirements); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("capture_authorizer_escrow_call_missing"); + }); + + it("should fail with capture_authorizer_payment_info_mismatch when the escrow event hash differs from the signed PaymentInfo", async () => { + const wrongHash = + "0x1111111111111111111111111111111111111111111111111111111111111111" as `0x${string}`; + mockSigner.simulateCalls.mockResolvedValue({ + results: [ + { + status: "success", + gasUsed: 200_000n, + logs: [ + escrowEventLog( + buildPaymentInfo(), + 1_000_000n, + "authorize", + EIP3009_TOKEN_COLLECTOR_ADDRESS, + CHAIN_ID, + 0, + FEE_RECIPIENT, + { paymentInfoHash: wrongHash }, + ), + transferLog(ASSET, PAYER, EIP3009_TOKEN_COLLECTOR_ADDRESS, 1_000_000n), + transferLog( + ASSET, + EIP3009_TOKEN_COLLECTOR_ADDRESS, + "0x5555555555555555555555555555555555555555", + 1_000_000n, + ), + ], + }, + ], + }); + const scheme = new AuthCaptureEvmScheme(mockSigner); + const result = await scheme.verify(buildEip3009Payload(), mockRequirements); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("capture_authorizer_payment_info_mismatch"); + }); + + it("should fail with capture_authorizer_asset_divergence when an unrelated address receives the asset on charge", async () => { + const attacker = "0xbadbadbadbadbadbadbadbadbadbadbadbadbadb" as `0x${string}`; + const escrowLog = escrowEventLog( + buildPaymentInfo(), + 1_000_000n, + "charge", + EIP3009_TOKEN_COLLECTOR_ADDRESS, + CHAIN_ID, + ); + mockSigner.simulateCalls.mockResolvedValue({ + results: [ + { + status: "success", + gasUsed: 200_000n, + logs: [ + escrowLog, + // Payer pays full amount, but only part goes to receiver — attacker skims. + transferLog(ASSET, PAYER, EIP3009_TOKEN_COLLECTOR_ADDRESS, 1_000_000n), + transferLog(ASSET, EIP3009_TOKEN_COLLECTOR_ADDRESS, PAY_TO, 900_000n), + transferLog(ASSET, EIP3009_TOKEN_COLLECTOR_ADDRESS, attacker, 100_000n), + ], + }, + ], + }); + const scheme = new AuthCaptureEvmScheme(mockSigner); + const reqs = { + ...mockRequirements, + extra: { ...mockRequirements.extra, autoCapture: true }, + }; + const result = await scheme.verify(buildEip3009Payload(), reqs); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("capture_authorizer_asset_divergence"); + }); + + it("should fail with capture_authorizer_asset_divergence when the implied feeBps falls outside [minFeeBps, maxFeeBps]", async () => { + // Reqs declare maxFeeBps=100 (1%). Stage a charge that takes 500 bps (5%). + mockSigner.simulateCalls.mockResolvedValue({ + results: [ + { + status: "success", + gasUsed: 200_000n, + logs: [ + escrowEventLog( + buildPaymentInfo(), + 1_000_000n, + "charge", + EIP3009_TOKEN_COLLECTOR_ADDRESS, + CHAIN_ID, + ), + transferLog(ASSET, PAYER, EIP3009_TOKEN_COLLECTOR_ADDRESS, 1_000_000n), + transferLog(ASSET, EIP3009_TOKEN_COLLECTOR_ADDRESS, PAY_TO, 950_000n), + transferLog(ASSET, EIP3009_TOKEN_COLLECTOR_ADDRESS, FEE_RECIPIENT, 50_000n), + ], + }, + ], + }); + const scheme = new AuthCaptureEvmScheme(mockSigner); + const reqs = { + ...mockRequirements, + extra: { ...mockRequirements.extra, autoCapture: true }, + }; + const result = await scheme.verify(buildEip3009Payload(), reqs); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("capture_authorizer_asset_divergence"); + }); + + it("should accept an honest passthrough", async () => { + mockSigner.simulateCalls.mockResolvedValue( + buildHonestTrace( + buildPaymentInfo(), + 1_000_000n, + "authorize", + EIP3009_TOKEN_COLLECTOR_ADDRESS, + CHAIN_ID, + ), + ); + const scheme = new AuthCaptureEvmScheme(mockSigner); + const result = await scheme.verify(buildEip3009Payload(), mockRequirements); + expect(result.isValid).toBe(true); + }); + + it("should fall back to simulation_failed when the signer does not expose simulateCalls", async () => { + const signerWithoutSimulate = { + getAddresses: mockSigner.getAddresses, + readContract: mockSigner.readContract, + writeContract: mockSigner.writeContract, + verifyTypedData: mockSigner.verifyTypedData, + sendTransaction: mockSigner.sendTransaction, + waitForTransactionReceipt: mockSigner.waitForTransactionReceipt, + getCode: mockSigner.getCode, + }; + const scheme = new AuthCaptureEvmScheme( + signerWithoutSimulate as unknown as typeof mockSigner, + ); + const result = await scheme.verify(buildEip3009Payload(), mockRequirements); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("simulation_failed"); + }); + }); + + describe("settle — gas cap on contract path", () => { + it("should pass gas: 400_000n to writeContract when settling against a smart contract captureAuthorizer", async () => { + mockSigner.getCode.mockResolvedValue("0x6080604052"); + mockSigner.simulateCalls.mockResolvedValue( + buildHonestTrace( + buildPaymentInfo(), + 1_000_000n, + "authorize", + EIP3009_TOKEN_COLLECTOR_ADDRESS, + CHAIN_ID, + ), + ); + const scheme = new AuthCaptureEvmScheme(mockSigner); + await scheme.settle(buildEip3009Payload(), mockRequirements); + const call = mockSigner.writeContract.mock.calls[0][0]; + expect(call.gas).toBe(400_000n); + }); + + it("should NOT set a gas field on writeContract on the EOA path", async () => { + mockSigner.getCode.mockResolvedValue("0x"); + const scheme = new AuthCaptureEvmScheme(mockSigner); + await scheme.settle(buildEip3009Payload(), mockRequirements); + const call = mockSigner.writeContract.mock.calls[0][0]; + expect(call.gas).toBeUndefined(); + }); + }); }); From 2901cb154cb4615d71d88893cc6dfff6b820e294 Mon Sep 17 00:00:00 2001 From: A1igator Date: Sat, 23 May 2026 20:54:09 -0700 Subject: [PATCH 2/2] fix(auth-capture/facilitator): contract-path correctness fixes + 3M gas cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three correctness bugs from review, plus matching cap raise to follow the spec PR change. Bug 1: paymentInfo.feeReceiver == address(0) (spec-valid: delegates fee recipient choice to the captureAuthorizer at charge time) was tripping the asset-divergence check because the fee transfer landed on a non-zero address the check treated as unauthorized. Fix: `verifyEscrowEvent` now decodes and returns the actual `feeReceiver` and `feeBps` from the `PaymentCharged` event, and `verifyAssetDeltas` uses those as the source of truth for the allowed-recipient set (instead of `paymentInfo.feeReceiver`). Bug 2: authorize-path asset check was tautological. After enforcing payer = -amount + receiver = 0 + feeReceiver = 0, ERC-20 mass conservation makes "sum of other deltas == amount" always true. Couldn't catch a wrapper that bypassed escrow's TokenStore entirely. Fix: query `escrow.getTokenStore(operator)` (new view in ESCROW_VIEW_ABI) and use an allowed-recipient enumeration — any non-zero net delta to an address outside `{payer, receiver, feeReceiver, tokenStore}` fails the check, and `tokenStore` is required to net +amount on authorize. Bug 3: receiver == feeReceiver double-counted the delta (same Map key, read twice). Honest cases failed with the assertion 2*delta == amount. Fix: explicit branch for the merged case checks combined delta == amount and still validates feeBps from the event against `[minFeeBps, maxFeeBps]`. Also addresses the smaller review notes: - Raise `CAPTURE_AUTHORIZER_GAS_LIMIT` from 400_000n to 3_000_000n to match the spec PR. Cap docstring rewritten to explicitly call out that this is a DoS bound (per EIP-150 63/64) and that correctness comes from the escrow event check, so future readers don't drop the event check thinking the cap protects against escrow OOG. - Refactor `computePayerAgnosticPaymentInfoHash` + `computeOnchainPaymentInfoHash` to share a single `computePaymentInfoHash(..., { payerAgnostic })` core, eliminating ~50 lines of near-duplicate code. Public surface unchanged. - Comment marking `AUTH_CAPTURE_ESCROW_ADDRESS` as the canonical CREATE2 invariant the on-chain hash function depends on. - Tests: new mock signer dispatches `readContract` by `functionName` so `getTokenStore` returns a stable stand-in address. `buildHonestTrace` takes optional `tokenStore` / `actualFeeReceiver` overrides. Added positive tests for `feeReceiver == 0` and `receiver == feeReceiver`, plus a negative test for authorize-path siphon to confirm the new allowed-recipient check is actually load-bearing (158 tests, +8 from the previous commit). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../mechanisms/evm/src/auth-capture/abi.ts | 7 + .../src/auth-capture/facilitator/scheme.ts | 221 +++++++++++++----- .../mechanisms/evm/src/auth-capture/nonce.ts | 98 ++++---- .../unit/auth-capture/facilitator.test.ts | 162 ++++++++++++- 4 files changed, 362 insertions(+), 126 deletions(-) diff --git a/packages/mechanisms/evm/src/auth-capture/abi.ts b/packages/mechanisms/evm/src/auth-capture/abi.ts index 62d3246..865a554 100644 --- a/packages/mechanisms/evm/src/auth-capture/abi.ts +++ b/packages/mechanisms/evm/src/auth-capture/abi.ts @@ -149,6 +149,13 @@ export const ESCROW_VIEW_ABI = [ }, ], }, + { + name: "getTokenStore", + type: "function", + stateMutability: "view", + inputs: [{ name: "operator", type: "address" }], + outputs: [{ type: "address" }], + }, ] as const; // AuthCaptureEscrow custom errors. Spliced into the ABI passed to simulateContract diff --git a/packages/mechanisms/evm/src/auth-capture/facilitator/scheme.ts b/packages/mechanisms/evm/src/auth-capture/facilitator/scheme.ts index 08ef4fe..e380fdb 100644 --- a/packages/mechanisms/evm/src/auth-capture/facilitator/scheme.ts +++ b/packages/mechanisms/evm/src/auth-capture/facilitator/scheme.ts @@ -35,6 +35,7 @@ import { ESCROW_ABI, ESCROW_ERRORS_ABI, ESCROW_EVENTS_ABI, + ESCROW_VIEW_ABI, } from "../abi"; import { AUTH_CAPTURE_ESCROW_ADDRESS, @@ -141,15 +142,21 @@ function paymentInfoToContractTuple(p: PaymentInfoStruct) { /** * Hard gas cap applied to both trace simulation and the broadcast settle tx - * when `extra.captureAuthorizer` is a smart contract. Bounds the facilitator's - * gas exposure to a misbehaving authorizer contract. + * when `extra.captureAuthorizer` is a smart contract. * - * 400_000 comfortably covers a direct authorize/charge call through the - * audited escrow with either supported assetTransferMethod, plus headroom for - * a thin passthrough wrapper. Anything that needs more should be supported - * explicitly per-authorizer, not absorbed by widening the global cap. + * This is a DoS bound on facilitator gas spend, NOT a correctness primitive. + * EIP-150's 63/64 rule means the outer cap does not strictly bound the inner + * escrow call's gas — a wrapper can pre-burn gas so escrow OOGs internally and + * still return success. Catching that case is `verifyEscrowEvent`'s job: an + * OOG'd escrow call emits no `PaymentAuthorized` / `PaymentCharged` event, + * which fails with `capture_authorizer_escrow_call_missing`. Do not drop the + * event check on the assumption that this cap protects correctness. + * + * 3_000_000 comfortably covers a direct authorize/charge call through the + * audited escrow plus on-chain logic up to and including modern zk verifier + * circuits (Groth16, PLONK, Halo2, most STARK constructions). */ -export const CAPTURE_AUTHORIZER_GAS_LIMIT = 400_000n; +export const CAPTURE_AUTHORIZER_GAS_LIMIT = 3_000_000n; /** * AuthCapture Facilitator Scheme - implements x402's SchemeNetworkFacilitator. @@ -629,6 +636,22 @@ export class AuthCaptureEvmScheme implements SchemeNetworkFacilitator { args, } as Parameters[0]); + // Resolve the operator's TokenStore up front; we need it to enumerate + // the allowed asset-delta recipients on both authorize and charge. + // Deterministic CREATE2 from (tokenStoreImpl, salt=bytes20(operator), + // deployer=escrow) — querying the escrow is the most robust source. + let tokenStore: `0x${string}`; + try { + tokenStore = (await this.signer.readContract({ + address: AUTH_CAPTURE_ESCROW_ADDRESS, + abi: ESCROW_VIEW_ABI, + functionName: "getTokenStore", + args: [paymentInfo.operator], + })) as `0x${string}`; + } catch { + return ErrSimulationFailed; + } + let traceResult: SimulateCallResult; try { const response = (await simulateCalls.call(this.signer, { @@ -662,10 +685,17 @@ export class AuthCaptureEvmScheme implements SchemeNetworkFacilitator { } const logs = traceResult.logs ?? []; - const escrowCheck = verifyEscrowEvent(logs, functionName, paymentInfoHash); - if (escrowCheck !== "ok") return escrowCheck; + const eventCheck = verifyEscrowEvent(logs, functionName, paymentInfoHash); + if (!eventCheck.ok) return eventCheck.reason; - const assetCheck = verifyAssetDeltas(logs, paymentInfo, amount, functionName); + const assetCheck = verifyAssetDeltas( + logs, + paymentInfo, + amount, + functionName, + tokenStore, + eventCheck.chargeFee, + ); if (assetCheck !== "ok") return assetCheck; return "ok"; @@ -783,25 +813,44 @@ type SimulateCallsResponse = { results: ReadonlyArray; }; +/** + * Result of `verifyEscrowEvent`. On `ok`, exposes the actual `feeReceiver` + * and `feeBps` from the `PaymentCharged` event so the asset-delta check can + * authoritatively know who got the fee (essential when + * `paymentInfo.feeReceiver === address(0)`, which delegates fee-recipient + * choice to the captureAuthorizer at charge time). + */ +type EscrowEventCheck = + | { + ok: true; + chargeFee?: { feeReceiver: `0x${string}`; feeBps: number }; + } + | { ok: false; reason: string }; + /** * Find the `PaymentAuthorized` / `PaymentCharged` event in a simulated trace, * emitted by `AUTH_CAPTURE_ESCROW_ADDRESS`, and assert its indexed - * `paymentInfoHash` matches the hash recomputed in verify step 12. + * `paymentInfoHash` matches the on-chain hash recomputed in verify step 12. + * + * On `charge`, also surfaces the `feeReceiver` and `feeBps` from the event + * so the asset-delta check can use the actual values escrow used (vs. the + * `paymentInfo.feeReceiver`, which may be `address(0)` per spec to delegate + * fee-recipient choice). * - * No matching event → `capture_authorizer_escrow_call_missing` (the wrapper - * didn't reach escrow at all). Hash mismatch → `capture_authorizer_payment_info_mismatch` - * (the wrapper reached escrow but with a different PaymentInfo than was signed). + * No matching event → `capture_authorizer_escrow_call_missing`. Hash mismatch + * → `capture_authorizer_payment_info_mismatch`. * * @param logs - All logs from the simulated trace. * @param functionName - The escrow function the facilitator submitted. * @param expectedHash - On-chain `paymentInfoHash` matching `escrow.getHash(paymentInfo)`. - * @returns `"ok"` or a stable wire reason. + * @returns Event check result; on `ok: true` for `charge`, includes the + * actual `feeReceiver` and `feeBps` from the event. */ function verifyEscrowEvent( logs: ReadonlyArray, functionName: "authorize" | "charge", expectedHash: `0x${string}`, -): "ok" | string { +): EscrowEventCheck { const escrow = AUTH_CAPTURE_ESCROW_ADDRESS.toLowerCase(); const expectedEventName = functionName === "authorize" ? "PaymentAuthorized" : "PaymentCharged"; let foundEscrowEvent = false; @@ -821,27 +870,49 @@ function verifyEscrowEvent( } if (decoded.eventName !== expectedEventName) continue; foundEscrowEvent = true; - const hash = (decoded.args as { paymentInfoHash: `0x${string}` }).paymentInfoHash; - if (hash.toLowerCase() === expectedHash.toLowerCase()) return "ok"; + const args = decoded.args as { + paymentInfoHash: `0x${string}`; + feeReceiver?: `0x${string}`; + feeBps?: number; + }; + if (args.paymentInfoHash.toLowerCase() !== expectedHash.toLowerCase()) continue; + if (functionName === "charge") { + return { + ok: true, + chargeFee: { + feeReceiver: args.feeReceiver as `0x${string}`, + feeBps: Number(args.feeBps), + }, + }; + } + return { ok: true }; } - return foundEscrowEvent - ? ErrCaptureAuthorizerPaymentInfoMismatch - : ErrCaptureAuthorizerEscrowCallMissing; + return { + ok: false, + reason: foundEscrowEvent + ? ErrCaptureAuthorizerPaymentInfoMismatch + : ErrCaptureAuthorizerEscrowCallMissing, + }; } /** * Reconstruct net ERC-20 deltas for `paymentInfo.token` from the trace and * assert they match the signed PaymentInfo. * - * On `authorize`: payer must be -amount; receiver and feeReceiver must be 0; - * all other addresses with non-zero net delta must sum to +amount (the - * "escrow side" of the move — escrow's TokenStore is the typical destination, - * but we don't enumerate it explicitly). + * Allowed-recipient enumeration: every address with a non-zero net delta MUST + * be one of `{payer, receiver, feeReceiver, tokenStore}`. Anything else is a + * sign the wrapper redirected funds and fails the check immediately. + * + * On `authorize`: payer = -amount; receiver = 0; feeReceiver = 0; + * tokenStore = +amount. * - * On `charge`: payer must be -amount; receiver + feeReceiver must sum to - * +amount with an implied `feeBps ∈ [minFeeBps, maxFeeBps]`; everything else - * must net to 0 (funds flow all the way through). + * On `charge`: payer = -amount; tokenStore net 0 (funds flow through); the + * actual fee recipient is whatever escrow emitted in the `PaymentCharged` + * event (necessary when `paymentInfo.feeReceiver === address(0)` per spec). + * `feeBps` from the event must satisfy `[minFeeBps, maxFeeBps]`. Handles + * `receiver === feeReceiver` (combined delta = amount, fee check still runs + * against the event's `feeBps`). * * Any divergence → `capture_authorizer_asset_divergence`. * @@ -849,6 +920,11 @@ function verifyEscrowEvent( * @param paymentInfo - The reconstructed PaymentInfo struct. * @param amount - Settle amount in token base units. * @param functionName - The escrow function the facilitator submitted. + * @param tokenStore - `escrow.getTokenStore(paymentInfo.operator)`. + * @param chargeFee - On `charge`, the actual `feeReceiver` and `feeBps` + * surfaced by `verifyEscrowEvent`. + * @param chargeFee.feeReceiver - Recipient escrow actually paid the fee to. + * @param chargeFee.feeBps - Fee basis points escrow actually used. * @returns `"ok"` or `capture_authorizer_asset_divergence`. */ function verifyAssetDeltas( @@ -856,6 +932,8 @@ function verifyAssetDeltas( paymentInfo: PaymentInfoStruct, amount: bigint, functionName: "authorize" | "charge", + tokenStore: `0x${string}`, + chargeFee?: { feeReceiver: `0x${string}`; feeBps: number }, ): "ok" | string { const token = paymentInfo.token.toLowerCase(); const deltas = new Map(); @@ -887,47 +965,76 @@ function verifyAssetDeltas( const payerKey = paymentInfo.payer.toLowerCase(); const receiverKey = paymentInfo.receiver.toLowerCase(); - const feeReceiverKey = paymentInfo.feeReceiver.toLowerCase(); - - const payerDelta = deltas.get(payerKey) ?? 0n; - if (payerDelta !== -amount) return ErrCaptureAuthorizerAssetDivergence; - - const receiverDelta = deltas.get(receiverKey) ?? 0n; - const feeReceiverDelta = deltas.get(feeReceiverKey) ?? 0n; + const tokenStoreKey = tokenStore.toLowerCase(); if (functionName === "authorize") { - // Receiver and feeReceiver are untouched at authorize time. The +amount - // ends up somewhere in the escrow system (its TokenStore in the canonical - // contracts); sum that "other" bucket and assert it equals +amount. - if (receiverDelta !== 0n) return ErrCaptureAuthorizerAssetDivergence; - if (feeReceiverDelta !== 0n) return ErrCaptureAuthorizerAssetDivergence; - let otherSum = 0n; + const feeReceiverKey = paymentInfo.feeReceiver.toLowerCase(); + const allowed = new Set([payerKey, receiverKey, feeReceiverKey, tokenStoreKey]); for (const [addr, delta] of deltas) { - if (addr === payerKey || addr === receiverKey || addr === feeReceiverKey) continue; - otherSum += delta; + if (delta === 0n) continue; + if (!allowed.has(addr)) return ErrCaptureAuthorizerAssetDivergence; + } + if ((deltas.get(payerKey) ?? 0n) !== -amount) return ErrCaptureAuthorizerAssetDivergence; + if ((deltas.get(tokenStoreKey) ?? 0n) !== amount) { + return ErrCaptureAuthorizerAssetDivergence; + } + // receiver / feeReceiver untouched at authorize time. If receiver == + // tokenStore (pathological) the receiver-net-zero check would conflict + // with tokenStore having +amount; in practice escrow's tokenStore is a + // CREATE2 deploy from the operator-derived salt and won't collide with + // a merchant-set receiver, but be defensive. + if (receiverKey !== tokenStoreKey && (deltas.get(receiverKey) ?? 0n) !== 0n) { + return ErrCaptureAuthorizerAssetDivergence; + } + if ( + feeReceiverKey !== tokenStoreKey && + feeReceiverKey !== receiverKey && + (deltas.get(feeReceiverKey) ?? 0n) !== 0n + ) { + return ErrCaptureAuthorizerAssetDivergence; } - return otherSum === amount ? "ok" : ErrCaptureAuthorizerAssetDivergence; + return "ok"; } - // charge path: receiver + feeReceiver split the amount; nothing else nets non-zero. - if (receiverDelta < 0n || feeReceiverDelta < 0n) { - return ErrCaptureAuthorizerAssetDivergence; + // charge path. Use the actual feeReceiver / feeBps from the escrow event + // (essential when paymentInfo.feeReceiver == 0, where the wrapper supplies + // any non-zero recipient). + if (!chargeFee) return ErrCaptureAuthorizerAssetDivergence; + const actualFeeReceiverKey = chargeFee.feeReceiver.toLowerCase(); + const allowed = new Set([payerKey, receiverKey, actualFeeReceiverKey, tokenStoreKey]); + for (const [addr, delta] of deltas) { + if (delta === 0n) continue; + if (!allowed.has(addr)) return ErrCaptureAuthorizerAssetDivergence; } - if (receiverDelta + feeReceiverDelta !== amount) { - return ErrCaptureAuthorizerAssetDivergence; + if ((deltas.get(payerKey) ?? 0n) !== -amount) return ErrCaptureAuthorizerAssetDivergence; + // tokenStore is transient on charge; whatever flowed through nets to 0. + if ( + tokenStoreKey !== payerKey && + tokenStoreKey !== receiverKey && + tokenStoreKey !== actualFeeReceiverKey + ) { + if ((deltas.get(tokenStoreKey) ?? 0n) !== 0n) return ErrCaptureAuthorizerAssetDivergence; } - // Validate the implied feeBps falls inside the signed [min, max] range. - // amount * minFeeBps <= feeReceiverDelta * 10000 <= amount * maxFeeBps - const feeNumerator = feeReceiverDelta * 10000n; - if (feeNumerator < amount * BigInt(paymentInfo.minFeeBps)) { + + // Resolve the receiver / feeReceiver split. If receiver === actualFeeReceiver + // they share a Map entry; the combined delta must equal `amount` and the + // feeBps still has to be inside the signed [min, max] range. + const expectedFee = (amount * BigInt(chargeFee.feeBps)) / 10000n; + const expectedNet = amount - expectedFee; + if (chargeFee.feeBps < paymentInfo.minFeeBps || chargeFee.feeBps > paymentInfo.maxFeeBps) { return ErrCaptureAuthorizerAssetDivergence; } - if (feeNumerator > amount * BigInt(paymentInfo.maxFeeBps)) { + if (receiverKey === actualFeeReceiverKey) { + if ((deltas.get(receiverKey) ?? 0n) !== amount) { + return ErrCaptureAuthorizerAssetDivergence; + } + return "ok"; + } + if ((deltas.get(receiverKey) ?? 0n) !== expectedNet) { return ErrCaptureAuthorizerAssetDivergence; } - for (const [addr, delta] of deltas) { - if (addr === payerKey || addr === receiverKey || addr === feeReceiverKey) continue; - if (delta !== 0n) return ErrCaptureAuthorizerAssetDivergence; + if ((deltas.get(actualFeeReceiverKey) ?? 0n) !== expectedFee) { + return ErrCaptureAuthorizerAssetDivergence; } return "ok"; } diff --git a/packages/mechanisms/evm/src/auth-capture/nonce.ts b/packages/mechanisms/evm/src/auth-capture/nonce.ts index 8b3e223..ae5ba1c 100644 --- a/packages/mechanisms/evm/src/auth-capture/nonce.ts +++ b/packages/mechanisms/evm/src/auth-capture/nonce.ts @@ -22,23 +22,28 @@ const PAYMENT_INFO_TYPEHASH = keccak256( ); /** - * Compute the payer-agnostic PaymentInfo hash that auth-capture uses as both - * the ERC-3009 nonce (`bytes32`) and the Permit2 nonce (`uint256`, via the - * same 32 bytes interpreted as an integer). The payer field is zeroed before - * hashing so the facilitator can reconstruct the same hash on the verify side - * without knowing payer identity in advance. + * Compute a PaymentInfo hash matching the canonical `AuthCaptureEscrow.getHash` + * shape: `keccak256(chainId, escrow, keccak256(typehash, paymentInfo))`. + * Caller picks whether to zero the payer before hashing (the wire nonce uses + * payer-agnostic; the on-chain `paymentInfoHash` topic uses the real payer). * - * Freshness comes from `paymentInfo.salt`; generate a new salt per signing - * call via `generateSalt`. Identical extras + same salt would collide across - * payers. + * NOTE: `AUTH_CAPTURE_ESCROW_ADDRESS` is hardcoded as the canonical CREATE2 + * deploy address. If a chain ever ships with a non-canonical escrow address, + * this hash diverges from the on-chain `getHash` silently — fail-loud at + * deploy time rather than relying on this function. * - * @param chainId - EVM chain id; binds the hash to a specific chain. - * @param paymentInfo - The reconstructed PaymentInfo struct (canonical Solidity field names). - * @returns The 32-byte hash to use as the nonce on the wire. + * @param chainId - EVM chain id. + * @param paymentInfo - The reconstructed PaymentInfo struct. + * @param options - Hash mode. + * @param options.payerAgnostic - True → zero `paymentInfo.payer` before + * hashing (wire nonce). False → use `paymentInfo.payer` as-is (matches + * the indexed `paymentInfoHash` topic of `PaymentAuthorized` / `PaymentCharged`). + * @returns The 32-byte hash. */ -export function computePayerAgnosticPaymentInfoHash( +function computePaymentInfoHash( chainId: number, paymentInfo: PaymentInfoStruct, + options: { payerAgnostic: boolean }, ): `0x${string}` { const paymentInfoEncoded = encodeAbiParameters( [ @@ -59,7 +64,7 @@ export function computePayerAgnosticPaymentInfoHash( [ PAYMENT_INFO_TYPEHASH, paymentInfo.operator, - zeroAddress, + options.payerAgnostic ? zeroAddress : paymentInfo.payer, paymentInfo.receiver, paymentInfo.token, BigInt(paymentInfo.maxAmount), @@ -86,6 +91,28 @@ export function computePayerAgnosticPaymentInfoHash( return keccak256(outerEncoded); } +/** + * Compute the payer-agnostic PaymentInfo hash that auth-capture uses as both + * the ERC-3009 nonce (`bytes32`) and the Permit2 nonce (`uint256`, via the + * same 32 bytes interpreted as an integer). The payer field is zeroed before + * hashing so the facilitator can reconstruct the same hash on the verify side + * without knowing payer identity in advance. + * + * Freshness comes from `paymentInfo.salt`; generate a new salt per signing + * call via `generateSalt`. Identical extras + same salt would collide across + * payers. + * + * @param chainId - EVM chain id; binds the hash to a specific chain. + * @param paymentInfo - The reconstructed PaymentInfo struct (canonical Solidity field names). + * @returns The 32-byte hash to use as the nonce on the wire. + */ +export function computePayerAgnosticPaymentInfoHash( + chainId: number, + paymentInfo: PaymentInfoStruct, +): `0x${string}` { + return computePaymentInfoHash(chainId, paymentInfo, { payerAgnostic: true }); +} + /** * Compute the on-chain `paymentInfoHash` (TS mirror of `AuthCaptureEscrow.getHash`). * @@ -103,50 +130,7 @@ export function computeOnchainPaymentInfoHash( chainId: number, paymentInfo: PaymentInfoStruct, ): `0x${string}` { - const paymentInfoEncoded = encodeAbiParameters( - [ - { name: "typehash", type: "bytes32" }, - { name: "operator", type: "address" }, - { name: "payer", type: "address" }, - { name: "receiver", type: "address" }, - { name: "token", type: "address" }, - { name: "maxAmount", type: "uint120" }, - { name: "preApprovalExpiry", type: "uint48" }, - { name: "authorizationExpiry", type: "uint48" }, - { name: "refundExpiry", type: "uint48" }, - { name: "minFeeBps", type: "uint16" }, - { name: "maxFeeBps", type: "uint16" }, - { name: "feeReceiver", type: "address" }, - { name: "salt", type: "uint256" }, - ], - [ - PAYMENT_INFO_TYPEHASH, - paymentInfo.operator, - paymentInfo.payer, - paymentInfo.receiver, - paymentInfo.token, - BigInt(paymentInfo.maxAmount), - paymentInfo.preApprovalExpiry, - paymentInfo.authorizationExpiry, - paymentInfo.refundExpiry, - paymentInfo.minFeeBps, - paymentInfo.maxFeeBps, - paymentInfo.feeReceiver, - BigInt(paymentInfo.salt), - ], - ); - const paymentInfoHash = keccak256(paymentInfoEncoded); - - const outerEncoded = encodeAbiParameters( - [ - { name: "chainId", type: "uint256" }, - { name: "escrow", type: "address" }, - { name: "paymentInfoHash", type: "bytes32" }, - ], - [BigInt(chainId), AUTH_CAPTURE_ESCROW_ADDRESS, paymentInfoHash], - ); - - return keccak256(outerEncoded); + return computePaymentInfoHash(chainId, paymentInfo, { payerAgnostic: false }); } /** diff --git a/packages/mechanisms/evm/test/unit/auth-capture/facilitator.test.ts b/packages/mechanisms/evm/test/unit/auth-capture/facilitator.test.ts index 28f187b..9c66cfd 100644 --- a/packages/mechanisms/evm/test/unit/auth-capture/facilitator.test.ts +++ b/packages/mechanisms/evm/test/unit/auth-capture/facilitator.test.ts @@ -113,10 +113,19 @@ function escrowEventLog( } as Log; } +// Stable stand-in for escrow.getTokenStore(operator) in tests. Real on-chain +// value is CREATE2-derived from the operator; for unit tests we just need a +// stable address the mock signer can return and the trace builder can use. +const TOKEN_STORE = "0x5555555555555555555555555555555555555555" as `0x${string}`; + /** * A "honest passthrough" trace for the contract path. Returns a successful * simulateCalls response with: escrow event w/ matching hash + Transfer * events with deltas that match the signed PaymentInfo. + * + * `tokenStore` is the address the operator's `AuthCaptureEscrow.getTokenStore` + * returns; the new asset-delta check enumerates allowed recipients to include + * it explicitly. */ function buildHonestTrace( paymentInfo: PaymentInfoStruct, @@ -124,8 +133,15 @@ function buildHonestTrace( functionName: "authorize" | "charge", tokenCollector: `0x${string}`, chainId: number, - options: { gasUsed?: bigint; feeBps?: number } = {}, + options: { + gasUsed?: bigint; + feeBps?: number; + tokenStore?: `0x${string}`; + actualFeeReceiver?: `0x${string}`; + } = {}, ) { + const tokenStore = options.tokenStore ?? TOKEN_STORE; + const actualFeeReceiver = options.actualFeeReceiver ?? paymentInfo.feeReceiver; const escrowLog = escrowEventLog( paymentInfo, amount, @@ -133,14 +149,14 @@ function buildHonestTrace( tokenCollector, chainId, options.feeBps ?? 0, + actualFeeReceiver, ); - const intermediateBucket = "0x5555555555555555555555555555555555555555" as `0x${string}`; // stand-in for token store const logs: Log[] = functionName === "authorize" ? [ escrowLog, transferLog(paymentInfo.token, paymentInfo.payer, tokenCollector, amount), - transferLog(paymentInfo.token, tokenCollector, intermediateBucket, amount), + transferLog(paymentInfo.token, tokenCollector, tokenStore, amount), ] : (() => { const fee = (amount * BigInt(options.feeBps ?? 0)) / 10000n; @@ -148,9 +164,10 @@ function buildHonestTrace( return [ escrowLog, transferLog(paymentInfo.token, paymentInfo.payer, tokenCollector, amount), - transferLog(paymentInfo.token, tokenCollector, paymentInfo.receiver, net), + transferLog(paymentInfo.token, tokenCollector, tokenStore, amount), + transferLog(paymentInfo.token, tokenStore, paymentInfo.receiver, net), ...(fee > 0n - ? [transferLog(paymentInfo.token, tokenCollector, paymentInfo.feeReceiver, fee)] + ? [transferLog(paymentInfo.token, tokenStore, actualFeeReceiver, fee)] : []), ]; })(); @@ -167,9 +184,17 @@ function buildHonestTrace( describe("AuthCaptureEvmScheme", () => { const CHAIN_ID = 84532; + // Default readContract dispatch: getTokenStore returns the stand-in + // TokenStore address; everything else (balanceOf, etc.) returns a large + // bigint balance. Tests can still chain .mockResolvedValueOnce(...) / + // .mockRejectedValueOnce(...) for specific call sequences (e.g. typed + // simulation reverts). const createMockSigner = () => ({ getAddresses: () => ["0x1234567890123456789012345678901234567890"] as readonly `0x${string}`[], - readContract: vi.fn().mockResolvedValue(BigInt("1000000000")), + readContract: vi.fn(async (args: { functionName: string }) => { + if (args.functionName === "getTokenStore") return TOKEN_STORE; + return BigInt("1000000000"); + }), writeContract: vi.fn().mockResolvedValue("0xabcdef1234567890" as `0x${string}`), verifyTypedData: vi.fn().mockResolvedValue(true), sendTransaction: vi.fn(), @@ -235,13 +260,16 @@ describe("AuthCaptureEvmScheme", () => { } function buildEip3009Payload() { - const paymentInfo = buildPaymentInfo(); + return buildEip3009PayloadFor(buildPaymentInfo(), mockRequirements); + } + + function buildEip3009PayloadFor(paymentInfo: PaymentInfoStruct, reqs: typeof mockRequirements) { const nonce = computePayerAgnosticPaymentInfoHash(84532, paymentInfo); return { x402Version: 2, scheme: "auth-capture", resource: { url: "https://example.com/weather", method: "GET" }, - accepted: { ...mockRequirements }, + accepted: { ...reqs }, payload: { authorization: { from: PAYER, @@ -454,7 +482,7 @@ describe("AuthCaptureEvmScheme", () => { const call = mockSigner.simulateCalls.mock.calls[0][0]; expect(call.calls).toHaveLength(1); expect(call.calls[0].to).toBe(CAPTURE_AUTHORIZER); - expect(call.calls[0].gas).toBe(400_000n); + expect(call.calls[0].gas).toBe(3_000_000n); expect(call.traceTransfers).toBe(true); }); @@ -875,7 +903,7 @@ describe("AuthCaptureEvmScheme", () => { "authorize", EIP3009_TOKEN_COLLECTOR_ADDRESS, CHAIN_ID, - { gasUsed: 500_000n }, + { gasUsed: 4_000_000n }, ); mockSigner.simulateCalls.mockResolvedValue(trace); const scheme = new AuthCaptureEvmScheme(mockSigner); @@ -1037,10 +1065,120 @@ describe("AuthCaptureEvmScheme", () => { expect(result.isValid).toBe(false); expect(result.invalidReason).toBe("simulation_failed"); }); + + // Bug 1 (review): paymentInfo.feeReceiver == address(0) is a spec-valid + // configuration (delegates fee-recipient choice to the captureAuthorizer + // at charge time). The asset-delta check must use the actual feeReceiver + // from the PaymentCharged event, not the zeroed value from PaymentInfo. + it("should accept a charge where paymentInfo.feeReceiver == 0 and event specifies a non-zero feeReceiver", async () => { + const ZERO = "0x0000000000000000000000000000000000000000" as `0x${string}`; + const actualFeeReceiver = "0xfee0fee0fee0fee0fee0fee0fee0fee0fee0fee0" as `0x${string}`; + const reqs = { + ...mockRequirements, + extra: { + ...mockRequirements.extra, + feeRecipient: ZERO, + autoCapture: true, + minFeeBps: 0, + maxFeeBps: 100, + }, + }; + const paymentInfoZeroFee = { ...buildPaymentInfo(), feeReceiver: ZERO }; + const payload = buildEip3009PayloadFor(paymentInfoZeroFee, reqs); + mockSigner.simulateCalls.mockResolvedValue( + buildHonestTrace( + paymentInfoZeroFee, + 1_000_000n, + "charge", + EIP3009_TOKEN_COLLECTOR_ADDRESS, + CHAIN_ID, + { feeBps: 50, actualFeeReceiver }, + ), + ); + const scheme = new AuthCaptureEvmScheme(mockSigner); + const result = await scheme.verify(payload, reqs); + expect(result.isValid).toBe(true); + }); + + // Bug 3 (review): receiver == feeReceiver should not double-count the + // delta. Honest case where the same address gets both legs of the split + // must pass. + it("should accept a charge where receiver == feeReceiver (combined delta == amount)", async () => { + const reqs = { + ...mockRequirements, + extra: { + ...mockRequirements.extra, + feeRecipient: PAY_TO, // receiver IS feeReceiver + autoCapture: true, + minFeeBps: 0, + maxFeeBps: 100, + }, + }; + const paymentInfoMerged = { ...buildPaymentInfo(), feeReceiver: PAY_TO }; + const payload = buildEip3009PayloadFor(paymentInfoMerged, reqs); + mockSigner.simulateCalls.mockResolvedValue( + buildHonestTrace( + paymentInfoMerged, + 1_000_000n, + "charge", + EIP3009_TOKEN_COLLECTOR_ADDRESS, + CHAIN_ID, + { feeBps: 50, actualFeeReceiver: PAY_TO }, + ), + ); + const scheme = new AuthCaptureEvmScheme(mockSigner); + const result = await scheme.verify(payload, reqs); + expect(result.isValid).toBe(true); + }); + + // Bug 2 (review): authorize-path siphon. The old "sum-other-deltas == + // amount" check passed tautologically from mass conservation. The new + // allowed-recipient enumeration must reject a trace where escrow's + // tokenStore is bypassed and funds go to an attacker. + it("should fail with capture_authorizer_asset_divergence when the authorize trace routes funds to an unexpected address instead of the tokenStore", async () => { + const attacker = "0xbadbadbadbadbadbadbadbadbadbadbadbadbadb" as `0x${string}`; + const paymentInfo = buildPaymentInfo(); + // Honest event but funds are redirected to attacker instead of tokenStore. + const escrowLog = escrowEventLog( + paymentInfo, + 1_000_000n, + "authorize", + EIP3009_TOKEN_COLLECTOR_ADDRESS, + CHAIN_ID, + ); + mockSigner.simulateCalls.mockResolvedValue({ + results: [ + { + status: "success", + gasUsed: 200_000n, + logs: [ + escrowLog, + transferLog(ASSET, PAYER, EIP3009_TOKEN_COLLECTOR_ADDRESS, 1_000_000n), + transferLog(ASSET, EIP3009_TOKEN_COLLECTOR_ADDRESS, attacker, 1_000_000n), + ], + }, + ], + }); + const scheme = new AuthCaptureEvmScheme(mockSigner); + const result = await scheme.verify(buildEip3009Payload(), mockRequirements); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("capture_authorizer_asset_divergence"); + }); + + it("should fail with simulation_failed if the escrow getTokenStore read throws", async () => { + mockSigner.readContract.mockImplementation(async (args: { functionName: string }) => { + if (args.functionName === "getTokenStore") throw new Error("rpc unavailable"); + return BigInt("1000000000"); + }); + const scheme = new AuthCaptureEvmScheme(mockSigner); + const result = await scheme.verify(buildEip3009Payload(), mockRequirements); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("simulation_failed"); + }); }); describe("settle — gas cap on contract path", () => { - it("should pass gas: 400_000n to writeContract when settling against a smart contract captureAuthorizer", async () => { + it("should pass gas: 3_000_000n to writeContract when settling against a smart contract captureAuthorizer", async () => { mockSigner.getCode.mockResolvedValue("0x6080604052"); mockSigner.simulateCalls.mockResolvedValue( buildHonestTrace( @@ -1054,7 +1192,7 @@ describe("AuthCaptureEvmScheme", () => { const scheme = new AuthCaptureEvmScheme(mockSigner); await scheme.settle(buildEip3009Payload(), mockRequirements); const call = mockSigner.writeContract.mock.calls[0][0]; - expect(call.gas).toBe(400_000n); + expect(call.gas).toBe(3_000_000n); }); it("should NOT set a gas field on writeContract on the EOA path", async () => {