Skip to content

spec(auth-capture/evm): require trace-level simulation + gas cap for smart contract captureAuthorizer#60

Open
A1igator wants to merge 3 commits into
mainfrom
A1igator/auth-capture-contract-operator-hardening
Open

spec(auth-capture/evm): require trace-level simulation + gas cap for smart contract captureAuthorizer#60
A1igator wants to merge 3 commits into
mainfrom
A1igator/auth-capture-contract-operator-hardening

Conversation

@A1igator
Copy link
Copy Markdown
Contributor

Summary

Adds a "Smart Contract captureAuthorizer" section to scheme_auth_capture_evm.md that lays out concrete facilitator requirements when extra.captureAuthorizer is a smart contract (e.g. an arbiter / multisig / passthrough wrapper).

Today the spec leaves the contract path under-specified: a facilitator can simulateContract(escrow.authorize), see a non-revert, and broadcast. That's insufficient because:

  1. Revert-only simulation does not prove the wrapper actually forwards into AuthCaptureEscrow with the signed PaymentInfo.
  2. Revert-only simulation does not prove the asset deltas match the merchant's signed split (minFeeBps / maxFeeBps, allowed recipients).
  3. There is no upper bound on the gas a wrapper can burn, so a hostile or buggy wrapper can drain the facilitator's submitter EOA.

The spec PR makes the contract path's required behavior explicit:

  • Trace-level simulation (eth_simulateV1 or equivalent), not just revert-only.
  • Escrow event verification: the trace MUST contain the expected AuthCaptureEscrow event emitted by AUTH_CAPTURE_ESCROW_ADDRESS, with paymentInfoHash matching the payer-agnostic hash from verification step 12.
  • Asset-delta verification: reconstruct ERC-20 Transfer deltas for requirements.asset and bound them to {payer, receiver, feeReceiver, escrow}, with the split implied by some feeBps ∈ [minFeeBps, maxFeeBps].
  • Gas cap on both simulation and broadcast. Recommended 400,000 gas, ~2x the worst-case legitimate charge path through the audited escrow with the ERC-3009 collector (measured at ~181k on Base via the upstream gas benchmark in base/commerce-payments).

Also adds operational hardening guidance (gas-only hot wallet with no token balances or approvals, reject value > 0, gas cap applies pre-broadcast not just in simulation), and four new error codes (capture_authorizer_escrow_call_missing, capture_authorizer_payment_info_mismatch, capture_authorizer_asset_divergence, capture_authorizer_gas_exceeded).

Why now

This is preparatory work before bringing the same hardening to the upstream @x402/evm auth-capture mechanism (x402-foundation/x402 PR 2308). Settling the spec here first means the TS implementation can land referencing a published normative source instead of inventing the contract.

Scope discipline

This PR deliberately does NOT touch:

  • Server-initiated operations / payload type discriminator.
  • serverAuthorization and extra.serverAuthorizationRequired.
  • paymentInfo traveling in the payload (vs being reconstructed).

Those are landing through a separate upstream effort and are still in flux; locking them into our spec now would be premature.

Test plan

  • Spec-only change; no code touched.
  • pnpm build runs as part of the pre-commit hook and passed.
  • Follow-up TS implementation will live in packages/mechanisms/evm/src/auth-capture/facilitator/scheme.ts.

A1igator and others added 2 commits May 22, 2026 01:24
…smart contract captureAuthorizer

Adds a "Smart Contract captureAuthorizer" section to the EVM scheme
spec that distinguishes the EOA path from the contract path and lays
out the additional facilitator checks required on the contract path:

- Trace-level simulation (eth_simulateV1 or equivalent), not just
  revert-only.
- Escrow event verification: the trace MUST contain the expected
  AuthCaptureEscrow event emitted by AUTH_CAPTURE_ESCROW_ADDRESS, with
  paymentInfoHash matching the payer-agnostic hash from verification
  step 12.
- Asset-delta verification: reconstruct ERC-20 Transfer deltas for
  requirements.asset and bound them to {payer, receiver, feeReceiver,
  escrow}, with a fee split within [minFeeBps, maxFeeBps].
- Gas cap on both simulation and broadcast. Recommended cap: 400k gas,
  ~2x the worst-case legitimate charge path through the audited escrow
  with the ERC-3009 collector (measured at ~181k on Base).

Also adds operational hardening guidance (gas-only hot wallet, no
native value, cap applies pre-broadcast not just in simulation),
updates the verification + settlement steps to point at the new
section, and adds four new error codes covering the new failure
modes.

This captures concrete requirements behind the existing prose-level
"facilitators MAY accept smart contract captureAuthorizers"
allowance, so a compliant facilitator cannot reduce contract-path
safety to simulateContract().revertOrOk.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ionale

The cap paragraph was citing "~181k measured on Base via the upstream
gas benchmark," which is an implementation-side observation rather than
a protocol fact. Keeps the concrete 400k recommendation and the rest of
the rationale (covers both assetTransferMethod values with thin-wrapper
headroom; bounds facilitator gas exposure) but removes the specific
measurement reference so the spec reads as a generic EVM binding rather
than something derived from one stack's profiling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vraspar
Copy link
Copy Markdown
Contributor

vraspar commented May 23, 2026

Reviewed the spec end-to-end against the deployed escrow (AuthCaptureEscrow.sol's getHash, event signatures, _validateFee semantics) and the companion impl. Structurally sound, three things I'd push on before this is final.

1. Gas cap is a DoS bound, not a correctness bound — make this explicit

The spec implies the 400k cap helps prevent the wrapper from misbehaving. It doesn't. EIP-150's 63/64 rule means a cap on the outer call doesn't bound the inner escrow call — a wrapper can OOG the escrow call internally and still return success. The reason this is safe today is the event-presence check: if escrow ran out of gas, no PaymentAuthorized / PaymentCharged is in the trace, and the check fails with capture_authorizer_escrow_call_missing.

Worth a sentence in the "Required checks" section saying explicitly:

The gas cap bounds the facilitator's economic exposure to a misbehaving wrapper. Correctness — that the wrapper actually reaches escrow with the signed PaymentInfo — is established by the escrow event check (2), not the gas cap (4).

This matters because someone implementing this from the spec might assume "gas cap + revert-only sim" is enough and skip the event check, which would silently re-open the original attack.

2. Promote the gas-only hot wallet to MUST on the contract path

Under "Operational hardening" the three items are SHOULD. The "gas-only wallet, no token approvals" one is the only mitigation that survives a check bypass — if the trace checks all pass but the wrapper does something unexpected at broadcast time (state-dependent on `block.number`, `block.timestamp`, oracle reads, etc.), funds aren't extractable as long as the submitter EOA holds no balances or approvals. The other two SHOULDs are tripwires; this one is the wall.

This is the same philosophy 0x's Settler uses — security from architecture, not from verifying the callee.

3. `eth_simulateV1` cross-client behavior isn't uniform

As of late 2025, observable divergence: Geth issues 30950 + 32852 (log timestamps, ethclient bindings), Nethermind 8412 (`validation:true` + multi-tx), Reth 8281 + 13421. viem's `simulateCalls` wraps whichever client the RPC runs.

Two facilitators running different providers can see different gating on the same broadcast. The impl already restates checks in terms of decoded events (good). Spec should make that explicit:

Implementations MUST express checks in terms of decoded `Transfer` and escrow events rather than raw trace structure. Cross-client equivalence of trace layout is not assumed.

4. Headroom on 400k is genuinely tight

110% over the measured ~181k worst-case covers a passthrough or simple multisig threshold check. A 3-of-5 multisig that recovers + verifies signatures on-chain, or anything that does an oracle read, eats 150k+ on its own and won't fit. The "MAY raise per-authorizer" provision handles this — but spec might want to flag that the default cap is calibrated for thin wrappers and that arbiter-class authorizers will routinely need raised caps.

5. Out of scope but worth flagging: structural alternative

The spec is the right interim defense given the escrow can't be changed. The structurally correct long-term fix is moving the smart-contract authorization off the wrapper-call path entirely — e.g. an EIP-1271 attestation from the smart contract, with the facilitator submitting directly to escrow as the operator. No wrapper call, no trace simulation, no gas DoS surface. Not for this PR, but worth noting in the doc as a future direction so this isn't read as the terminal design.

What's solid

  • Event/hash anchoring against `AUTH_CAPTURE_ESCROW_ADDRESS` with `paymentInfoHash` matching `getHash` is the right primitive — wrapper can't fake the escrow event from a different contract.
  • Error code namespace is clean and listed in the table.
  • Scope discipline section (deferring serverAuthorization etc.) is the right call.

Four changes addressing review feedback:

1. Clarify gas cap is DoS bound, not correctness. EIP-150's 63/64 rule
   means the outer cap doesn't strictly bound the inner escrow call;
   a wrapper can pre-burn gas so escrow OOGs internally and still
   return success. The escrow event check (2) is what catches that
   case — make it explicit so implementers don't drop the event check
   thinking the gas cap suffices.

2. Promote gas-only hot wallet from SHOULD to MUST (with "MUST
   partition" wording so paymaster-style submitters that hold
   non-asset balances are still allowed). This is the architectural
   defense that survives a check bypass; the trace checks are
   tripwires, the wallet partition is the wall.

3. Add MUST language for expressing checks (2) and (3) in terms of
   decoded Transfer and escrow events rather than raw trace
   structure. eth_simulateV1 layout is not uniform across execution
   clients; decoded events stay portable.

4. Raise default cap from 400_000 to 3_000_000 gas. Covers the
   audited escrow path plus on-chain logic up to and including modern
   zk verifier circuits (Groth16, PLONK, Halo2, most STARK
   constructions). The previous 400k cap forced almost any
   non-trivial captureAuthorizer (multi-oracle, zk-anchored) to
   negotiate higher caps off-chain, but there is no protocol
   mechanism for that — drop the "MAY raise per-authorizer" line so
   the spec stops endorsing a mechanism it doesn't define.

Per-attack DoS exposure at 3M gas remains modest (~$0.10 on Base,
~$0.32 on L1 at 30 gwei) and is intentionally backstopped by the
gas-only wallet partition (operational hardening), not the cap
itself.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@A1igator
Copy link
Copy Markdown
Contributor Author

Thanks, agreed on most. Pushed 0719569 addressing 1-3 plus a recalibration on 4.

1. Gas cap is a DoS bound, not correctness. Agreed and added explicit language under the "Required checks" section calling out EIP-150's 63/64 rule and naming the event check as the correctness primitive. Someone implementing "gas cap + revert-only sim" and dropping (2) would silently reopen the original attack — worth being noisy about.

2. Gas-only hot wallet → MUST. Promoted, with "MUST partition" wording rather than "MUST hold zero" so paymaster-style submitters (need non-asset balances for gas sponsoring) aren't precluded. Architectural defense vs. tripwires framing made explicit.

3. eth_simulateV1 cross-client divergence. Added MUST language requiring checks (2)/(3) be expressed in terms of decoded events, not raw trace structure. Impl PR (#61) already does this; spec language locks it in for other implementers.

4. Cap headroom. Recalibrated and went further: raised the cap to 3,000,000 gas. After tighter math on realistic arbiter-class wrappers — 3-of-5 ECDSA multisig (~30k), refund arbiter with Chainlink read (~50k), composite condition trees (~30k each) — 400k actually has ~50-70% headroom for typical cases, not the 30% I initially worried about. But moving to 3M means the cap also covers on-chain zk verifiers (Groth16 ~300k, PLONK ~500k, Halo2 ~1M, most Plonky2/STARK constructions). Per-attack DoS exposure at 3M is ~$0.10 on Base / ~$0.32 on L1 at 30 gwei — trivially absorbable with rate limiting, especially given (2) above said the cap is just a blast-radius bound anyway.

Also dropped the "MAY raise per-authorizer" line. As you noted there's no protocol mechanism for discovering which authorizers a facilitator supports at higher caps — it would have to be off-chain coordination, and the spec was endorsing a "mechanism" it didn't actually define. At 3M the escape hatch is theoretical for all realistic deployments; if a facilitator wants to support truly pathological wrappers (recursive STARK aggregators), that's their call but the spec doesn't need to bless it.

5. EIP-1271 attestation as long-term direction. Skipping. This spec is scoped to the deployed AuthCaptureEscrow contract specifically; structural alternatives that would require a different escrow design are out of scope here even as a forward-looking note. Worth its own design discussion later.

Companion impl fixup landing on #61 shortly (cap value + matching test threshold).

@vraspar
Copy link
Copy Markdown
Contributor

vraspar commented May 24, 2026

Round-2 follow-up. Items 1, 2, 3 from the original review landed cleanly. Two spec-impl divergences introduced in the round-2 rewording need fixing before this is final, plus three smaller items.

1. (blocking) Spec says "escrow" in the asset-delta allowed set; impl correctly uses the operator's TokenStore

scheme_auth_capture_evm.md:205:

No requirements.asset transfer to any address outside {payer, receiver, feeReceiver, escrow}.

AuthCaptureEscrow routes all token movement through getTokenStore(operator) — a per-operator CREATE2 clone (contract :371), not the escrow address itself. The companion impl gets this right: verifyAssetDeltas builds the allowed set as {payer, receiver, feeReceiver, tokenStore} (scheme.ts:972 authorize, :1004 charge), where tokenStore comes from a fresh escrow.getTokenStore(operator) read at :641-649.

A second implementer following the spec literally would whitelist the escrow address and reject every honest contract-path trace. Fix: replace "escrow" with "the operator's TokenStore (escrow.getTokenStore(operator))" at :205 and in the capture_authorizer_asset_divergence error-row at :261.

2. (blocking) Spec doesn't cover the feeReceiver == address(0) delegation case

paymentInfo.feeReceiver = 0x0 is allowed on-chain (per the feeReceiver param docstring on AuthCaptureEscrow.charge at :191 and capture at :255: "should match the paymentInfo.feeReceiver unless that is 0 in which case it can be any address"). It delegates fee-recipient choice to the captureAuthorizer at charge time. The asset-delta rule doesn't say what the allowed set looks like in this case — a spec-strict implementer would put address(0) in the whitelist and exclude the real recipient.

The impl handles this correctly: verifyEscrowEvent surfaces the actual feeReceiver from the PaymentCharged event (scheme.ts:881-886), and verifyAssetDeltas substitutes it into the whitelist at :1003. Add one sentence to the spec:

When paymentInfo.feeReceiver == address(0), the allowed-set slot for feeReceiver is substituted with the feeReceiver field surfaced in the PaymentAuthorized / PaymentCharged event, which MUST be non-zero.

3. (medium) "Most STARK constructions" claim is wrong

scheme_auth_capture_evm.md:213:

covers a direct call to AuthCaptureEscrow.authorize or .charge … plus on-chain logic up to and including modern zk verifier circuits (Groth16, PLONK, Halo2, most STARK constructions)

Ballpark: Groth16 ~250k, PLONK ~500k, Halo2 typically 800k–1.5M, STARK verifiers routinely exceed 3M (RISC Zero, StarkWare SHARP on-chain verifiers commonly 1.5–5M+). Compact STARK variants like Plonky2/3 are cheaper but not "most." Trim to:

plus modest on-chain logic. zk-heavy authorizers (Halo2 in worst-case configurations, STARK verifiers) will need a per-deployment cap raise.

4. (low) "Partitioned hot wallet" wording is under-defined

:218: "no token approvals to any external contract for that asset" — "external contract" is undefined (must include the canonical AuthCaptureEscrow and token collectors, else the partition is meaningless). And the condition is per-asset but the wallet is one process — no guidance on whether USDC + EURC need separate wallets. Tighten to:

MUST submit from an address that, for requirements.asset specifically, holds zero balance and has zero non-revoked allowance to any contract.

5. (low) Cap escape valve dropped without a fallback

Round 1 had "MAY raise per-authorizer." Round 2 dropped it on "at 3M nobody needs more." If that premise breaks, the spec offers no escape. One sentence at :213 saying facilitators MAY refuse settle with capture_authorizer_gas_exceeded rather than implying a fixed protocol ceiling.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants