feat(auth-capture/facilitator): trace-level simulation + gas cap on contract-path captureAuthorizer#61
Conversation
…ontract-path captureAuthorizer 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) <noreply@anthropic.com>
|
Pulled the branch and read the implementation against the deployed Three correctness bugs to fix before merge. Bug 1 (high) —
|
…as cap
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) <noreply@anthropic.com>
|
Thanks for the deep read. Pushed Bug 1 —
|
|
Round-2 follow-up. All three round-1 bugs are correctly resolved with load-bearing tests — 1. (tests) Magic-number gas literals will silently desync from production on the next cap tune
Round-2's cap raise from 400k → 3M only worked because these were hand-updated alongside the constant. The test file does not import import { CAPTURE_AUTHORIZER_GAS_LIMIT } from "../../../src/auth-capture/facilitator/scheme";
expect(call.calls[0].gas).toBe(CAPTURE_AUTHORIZER_GAS_LIMIT);
// Over-cap fixture:
{ gasUsed: CAPTURE_AUTHORIZER_GAS_LIMIT + 1_000_000n }The 2. (convention)
|
Summary
Implementation of the contract-path captureAuthorizer hardening that the spec PR (#60) added. Built side-by-side with that PR so we can verify the spec is actually implementable as written.
When
extra.captureAuthorizeris a smart contract, the facilitator now:eth_simulateV1(viemsimulateCalls) instead of a revert-onlyreadContract, withtraceTransfers: trueand the gas cap applied to the call.PaymentAuthorized/PaymentChargedevent emitted byAUTH_CAPTURE_ESCROW_ADDRESSwith the on-chainpaymentInfoHash. Added a newcomputeOnchainPaymentInfoHashhelper that mirrors the contract'sgetHash(the existingcomputePayerAgnosticPaymentInfoHashproduces the wire nonce — different hash).Transferdeltas forrequirements.assetand asserts the signed PaymentInfo:amountauthorize: receiver / feeReceiver untouched; the residual nets to +amountcharge: receiver + feeReceiver splitamountwith the impliedfeeBps ∈ [minFeeBps, maxFeeBps]; nothing else nets non-zerowriteContract, so a wrapper that simulates within budget but spikes at execution time still can't drain the facilitator. Exposed asCAPTURE_AUTHORIZER_GAS_LIMIT = 400_000n.New error reasons
ErrCaptureAuthorizerEscrowCallMissingcapture_authorizer_escrow_call_missingErrCaptureAuthorizerPaymentInfoMismatchcapture_authorizer_payment_info_mismatchErrCaptureAuthorizerAssetDivergencecapture_authorizer_asset_divergenceErrCaptureAuthorizerGasExceededcapture_authorizer_gas_exceededAll four are listed in the spec's Error Codes section.
EOA path
Unchanged. Revert-only
readContractsimulation against the audited escrow. No gas override onwriteContract. The new code only kicks in whengetCode(captureAuthorizer)returns non-empty bytecode.Signer feature-detection
simulateCallsisn't declared onFacilitatorEvmSignerin@x402/evm, so we feature-detect at use. A signer that doesn't expose it getssimulation_failedon the contract path — we cannot satisfy the spec's MUST without it.Test plan
pnpm build— all targets compile (ESM + CJS + DTS).pnpm lint— clean.pnpm format— clean.pnpm test— 150 passing (was 141; +9 new contract-path tests).New tests cover, end-to-end against the public
verify/settlesurface:capture_authorizer_escrow_call_missing.paymentInfoHash→capture_authorizer_payment_info_mismatch.charge→capture_authorizer_asset_divergence.[minFeeBps, maxFeeBps]→capture_authorizer_asset_divergence.gasUsed > 400_000n) →capture_authorizer_gas_exceeded.simulateCalls→simulation_failed.gas: 400_000npassed towriteContracton contract path only (EOA path leaves it unset for eth_estimateGas).Links
Companion spec PR: #60.