Mapae docs
Contents · Search
Tech (English) · 2. Payment flows

2. Payment flows

Mapae's payment path is the ERC-7710 delegated payment. EIP-3009 direct payment, the first regression path, has lost its apps (a seller and an agent) and kept only its primitives.

EIP-3009 direct payment — what remains

MockUSDC in contracts/ implements transferWithAuthorization; packages/shared holds the authorization's types, its EIP-712 domain and the settlement error model (SettlementError); facilitator/ is the x402-rs container configuration. The two apps that issued and paid this path were removed when the hosted shop arrived — the delegated path closes the same 402 → sign → settle loop under narrower authority.

One property was kept as the delegated path's starting point: the authorization pins from, to, and value under the signature, so the relayer that broadcasts it holds no authority beyond that of a broadcaster. The payer pays no gas.

ERC-7710 delegated payment

account owner wallet → HybridDeleGator owner account
            (if the account does not exist yet: pre-deployment signature → account-bootstrap deploys with sponsor gas)
            → erc20PeriodTransfer parent delegation
agent       → receives 402
            → signs a payment-specific leaf with amount/payTo/facilitator pinned
seller      → ERC-7710 facilitator /verify → /settle
facilitator → DelegationManager.redeemDelegations
            → mUSDC.transfer(payTo, amount)

A Mapae permission is a signed delegation chain in the MetaMask format. ERC-7710 defines the redeemDelegations redemption interface; ERC-7715 defines the wallet permission-request API, wallet_requestExecutionPermissions. They are separate specifications, both currently Draft. Mapae uses direct owner EIP-712 signing, not the ERC-7715 API. Payments are executed by the facilitator EOA; ERC-4337 EntryPoint v0.7 is used for revocation. EIP-7702 account conversion and ERC-6492 counterfactual verification are not product paths. The parent caveat enforces the 60-second period cap and the expiry window (30 minutes by default, extended via PERMISSION_TTL_SECONDS for the demo) on-chain. The vendor profile also pins the recipient position in the ERC-20 transfer calldata. In manager-to-child re-delegation, the child's individual cap and the manager's aggregate cap apply simultaneously.

A parent delegation carries four caveats: the zero native-value ceiling (ValueLteEnforcer) and the period cap (ERC20PeriodTransferEnforcer) come from the scope; the validity window (TimestampEnforcer) and the lifetime total (ERC20TransferAmountEnforcer) come from the policy. A fixed-vendor policy adds the recipient pin (AllowedCalldataEnforcer), making five. The period cap is a rate, not a budget: it refills every period, so a 60s/30min grant can move thirty times its period cap. The lifetime total does not refill, because ERC20TransferAmountEnforcer keeps one never-decreasing slot per delegation hash (spentMap(delegationManager, delegationHash)). The per-payment cap is not on the parent — the leaf is already bound to the offer amount on chain.

The offer's extra. An ERC-7710 offer's extra always carries two fields, plus two more when the facilitator advertises them. Always present: assetTransferMethod: "erc7710" (exact-EVM's official transfer method) and paymentFlow: "upfront". The latter is the declaration §6.1 of the specification requires: any flow other than the default authorization MUST be declared, and Mapae hands over the resource only after /verify and /settle have both succeeded. Without the declaration a client reading the specification assumes authorization — served first, settled after — and computes the wrong moment of delivery. The two conditional fields are facilitatorAddresses (the trust gate — the agent refuses any offer that does not overlap its own allowlist) and delegationManager (GIWA's manager is in no registry, so the in-band advertisement is the only channel a third-party integrator has); the middleware copies both verbatim from /supported. Every offer this repo builds declares the flow, down to the /supported kind.

The reading side accepts the default flow in three shapes. Two flows are payable — this rail's upfront and the specification's default authorization — and both an absent declaration and an explicit null mean that default (the reference schemas in @x402/core 2.27.0 fold a null optional field into absence with .nullish()). authorization has the seller serve first and settle after, so the seller carries the settlement risk itself and nothing changes about what a one-shot leaf our agent signs can lose. Splitting payment from refusal on whether the field was written would make the test "did you spell it out?" rather than "which flow is it?", and would kill only the sellers who declared the same flow honestly. Absence is also what a real counterparty produces: the supportedKind flow in @metamask/x402 1.0.0 copies only facilitatorAddresses out of /supported's extra, so an offer built that way declares no flow at all (a measurement the conformance test pins). escrow, by contrast, is a different flow — a later claim — that this client's result cannot describe, so it is refused together with any unknown value.

The facilitator applies the same rule. This rail settles in place — /settle follows /verify and redeems there — so there is no code that could perform escrow, and such an offer is refused as invalid_payment_requirements even when the offer and its echo agree. Were the protection on the client alone, we would settle on the spot a payment the seller declared as a later claim. paymentFlow is also part of the echo comparison: our seller's offers always carry the value, so a payload that drops or rewrites the field accepted terms other than the ones offered, and is invalid_payload.

Current reference-stack integration. The runtime uses Smart Accounts Kit 2.0.0 and Delegation ABIs 2.0.0. The deployed Framework 1.3.0 composition ID, original package versions and integrities remain deployment provenance; installed bytecode is checked against that composition separately. Updating the SDK does not redeploy contracts or require existing grants to be signed again.

The reference integration guide and executable example cover the full HTTP path with @metamask/x402 1.0.0 and @x402/core / @x402/evm 2.27.0. The server explicitly declares erc7710 / upfront in paymentFlows; the client opts into the GIWA asset with an atomic per-payment cap in spendControls.allowedAssets. The reference upfront server calls /settle directly, without a separate /verify; Mapae independently validates and simulates at that endpoint. A pending hash is retried once with the identical payload, without signing a new leaf. This example does not replace the hosted seller middleware.

The 402's extensions is an envelope. In the specification extensions is a map from extension name to {info, schema} — info is what the extension itself declares, schema a JSON Schema describing the shape a client echoes back in its payload. The hosted shop publishes one entry, mapae, and puts the seller and the manifest URL under its info. There is no schema: nothing there asks the client to echo anything. The payment payload carries a slot of the same name. The specification has a client echo only the extensions it actually used, and the first producer of that slot here is payment-identifier, below. The reference client is looser than that rule — mergeExtensions in @x402/core 2.27.0 returns the seller's whole map when the client adds nothing of its own, so a reference-stack payer paying the hosted shop echoes the mapae entry back unused. The facilitator's validator compares the fields it names instead of enumerating the object's keys, so that entry is ignored rather than refused. /supported's identically named extensions is a different thing: the list of extensions the facilitator supports, which today is empty.

payment-identifier is an idempotency key, not authentication. Give the paywall a binding store and the 402 advertises this extension; the client then puts a fresh id (pay_ plus 16 random bytes in hex) in its payload envelope, one per payment. Before settling, the seller binds that id to the request fingerprint — scheme, network, asset, amount, payTo, resource URL and method folded with sha256 — and to the payment intent. The resource URL includes the origin and the query: fold the path alone and a header paid at /report?ticker=A is served the stored receipt at ?ticker=B, one settlement for two resources. It is also where the spec says to scope the key by tenant, merchant or route. The reason any of this is needed is that a retry signs a new leaf: the intent differs, so neither the facilitator's intent journal nor the shop's orders table can see the two attempts as one payment, and the identifier is the only name that spans them. That name is not signed, though — a man in the middle can rewrite it — so it is an idempotency hint and never authentication, and every safety decision is still made alongside the signature-derived intent. A seller that supplies no binding neither advertises the extension nor reads an id that arrives: promise only what you can keep.

There are four verdicts. An unseen id settles the usual way. The same request and the same leaf under an id that already settled is answered from the stored payer and hash without settling again (replayed on the success receipt says this call did no settling). A different fingerprint is 409 payment_identifier_conflict: nothing was charged for this request, so a fresh id is how to pay. The same fingerprint with a different leaf, under an id that already settled, is 409 payment_identifier_settled, and that one carries the stored payer and hash — it is where a buyer whose first attempt ended settlement_pending arrives after signing again, and "nothing was charged" would be the sentence that makes them pay twice. The buyer's client reads the first word as PAYMENT_REJECTED and the second as SETTLEMENT_UNKNOWN for that reason.

The hosted shop keeps two tables because it asks two questions: orders answers "what was sold" once money has moved, and payment_identifiers answers "is this the same payment" from the first attempt onwards. A row there is written before the facilitator is asked anything, so attempts nobody verified claim one too — which is why it carries the same kind of prune the ledger's refusals do: unsettled rows only, bounded by both an age and a count. Settled rows are the replay guard and are never touched. The table took the store schema to version 7, and this build refuses an older file rather than migrating it: an operator moves the existing sqlite file aside and re-seeds, which recreates sellers, items, orders and tickets.

The sequence below shows three paths for one and the same delegation — a normal settlement, an over-cap refusal, and an expiry refusal. What decides a refusal is the on-chain caveat, not a backend.

sequenceDiagram
    autonumber
    actor Owner as Owner wallet
    participant SA as HybridDeleGator<br/>smart account (payer)
    participant Agent as delegated-agent<br/>session key
    participant Seller as delegated-seller
    participant Fac as facilitator<br/>(relayer, pays gas)
    participant DM as DelegationManager<br/>+ caveat enforcers
    participant USDC as MockUSDC

    Note over Owner,SA: once, up front — root delegation signed offline<br/>a pre-deployment signature is also valid ('Sponsored onboarding' below)
    Owner->>SA: eth_signTypedData_v4 → ERC-1271 0x1626ba7e
    Note right of SA: 3 mUSDC / 60s cap · expiry window · permission.json

    rect rgb(232,245,233)
    Note over Agent,USDC: ① normal path — cumulative 2.5 ≤ 3.0
    Agent->>Seller: GET /s/demo-cafe/croissant
    Seller-->>Agent: 402 (amount 2.5, erc7710)
    Agent->>Agent: sign payment-specific leaf (session key)
    Agent->>Seller: Payment-Signature (leaf context)
    Seller->>Fac: /verify → simulate redeemDelegations
    Fac-->>Seller: isValid
    Seller->>Fac: /settle
    Fac->>DM: redeemDelegations (relayer pays gas)
    DM->>USDC: transfer(payTo, 2.5)
    DM-->>Fac: OK
    Fac-->>Seller: tx 0x71d71442…
    Seller-->>Agent: 200 + ticket (payer gas 0)
    end

    rect rgb(255,235,235)
    Note over Agent,DM: ② over cap — retry in the same period, cumulative 5.0 > 3.0
    Agent->>Seller: GET /s/demo-cafe/croissant (retry)
    Seller->>Fac: /verify → simulate
    Fac->>DM: simulate redeemDelegations
    DM-->>Fac: revert ERC20PeriodTransferEnforcer:transfer-amount-exceeded
    Fac-->>Seller: isValid = false
    Seller-->>Agent: 402 + offer re-issued — no settlement, funds untouched
    end

    rect rgb(255,244,229)
    Note over Agent,DM: ③ expiry — after the validity window has passed
    Fac->>DM: simulate redeemDelegations
    DM-->>Fac: revert TimestampEnforcer:expired-delegation
    Fac-->>Seller: isValid = false
    end

Settlement evidence — GIWA Sepolia (2026-07-24 ~ 2026-08-04)

Evidence levels are stated separately. Mined is a transaction that entered a GIWA block and opens in the explorer; simulated is an eth_call against GIWA's current state — the verdict is handed down by the deployed enforcer bytecode reading the real period counter, but nothing entered a block.

Path Result Evidence level Evidence
Framework deployment 38-unit + 2-step ownership + owner smart account mined manager 0xF2F782Fa…F40C, owner account 0xA4e4d00E…DDF382
Normal settlement (inv-001, 1 mUSDC) success, payer gas 0 mined tx 0xe897fe55…a97d, block 31555419
Normal settlement (inv-002, 2.5 mUSDC) success mined tx 0x71d71442…6ce4, block 31558282
Period cap exceeded (cumulative 5.0 > 3.0) refused, funds untouched simulated revert ERC20PeriodTransferEnforcer:transfer-amount-exceeded
Expiry (validity window passed) refused simulated revert TimestampEnforcer:expired-delegation
Sponsored onboarding — account deployment CREATE2 deploy from the owner recovered out of a pre-deployment signature, new user gas 0 mined account 0x15286FE9…3301, tx 0xed21ac71…9902
Sponsored onboarding — mUSDC float 3 mUSDC minted mined tx 0x9d14588b…baa0
Post-hoc acceptance of a pre-deployment signature (late binding) live isValidSignature = 0x1626ba7e simulated account 0x15286FE9…3301

That the two refusals have no transaction hash is a consequence of the design. The facilitator's /verify filters first with simulate.redeemDelegations, so no gas is spent on a transaction destined to revert. The same 2.5 mUSDC payment settles while balance remains in the period and is refused once the cumulative total crosses the cap — the limit is state enforced by the deployed enforcer, not a promise made by application code.

A new user signs the root delegation against a payer smart account that does not exist yet, and apps/account-bootstrap deploys that account with sponsor gas. Nobody needs to hold GIWA ETH to create a delegation.

Two measurements decided the design. First, deploying at settlement time is impossible — DelegationManager runs the signature loop before any execution, a codeless delegator falls into the EOA branch, and ECDSA.recover returns the owner rather than the account, so it ends in InvalidEOASignature. There is no ERC-6492 anywhere in the Framework. Second, late binding holds — a signature made against a codeless account passes ERC-1271 after deployment, because HybridDeleGator compares against owner() and the owner is baked into the CREATE2 initcode. The 0x1626ba7e in the table above is the value with which the live chain answered that fact.

The request body is {permissionContext} and nothing else. The owner is recovered from the signature; the account is CREATE2(recovered owner) and must match the delegator the permission names. Accepting an owner or salt from the caller would let anyone nominate an address for us to pay to deploy — in this structure, the caller has to solve a fixed point that cannot be solved without the key. The signature is also checked offline for canonical form (low-s, v ∈ {27,28}). viem accepts signatures that OZ ECDSA reverts on, so without this check we would pay to deploy accounts whose every grant reverts forever.

Per-account idempotency is identity, not a budget — keypairs are free offline, so the real bounds on a griefing run are the faucet window (one top-up per account per 24 hours), the daily gas budget (BOOTSTRAP_DAILY_WEI), and the sponsor balance kept deliberately small. The hourly per-IP cap (BOOTSTRAP_RATE_PER_HOUR, default 30, IPv6 counted per /64) is a speed bump on top of those: IPs are shared and keys are free, so it cannot stop a griefer, but without any cap one machine could drain the day's budget from one address in under an hour by sending fresh keypairs. The faucet tops any account below 1000 tUSDC (testnet, not real money) up to that target (packages/delegation/src/faucet-policy.ts). The sponsor holds no delegation authority, so it cannot reach payer funds, caps, or settlement. Verification is bun run test:e2e:bootstrap — 16 cases on a GIWA fork (kill switch, approval mismatch, shared-relayer refusal, foreign signer, high-s, deployment, late binding, gas accounting, faucet top-up to target, idempotency, concurrency, faucet 24-hour window, budget exhaustion, chain-failure leak guard, hourly per-IP cap with the header-less exemption), 16/16.

Agent automation (MCP)

The payment loop converges on a single payForDelegatedResource in packages/delegation/src/payment-client.ts, and the CLI agent and the MCP server share the same implementation. Two copies of an implementation drift apart.

apps/agent-mcp exposes two tools.

tool Role
mapae_pay_for_resource receive 402 → sign a leaf within the caveat → retry the request → resource
mapae_status session key, endpoints, deployment verification state (never returns keys or the permission context)

The procedure for registering the server in an MCP client, and the environment variables, are in the MCP connection guide.

This path has run to completion on GIWA Sepolia. One MCP tool call settled a payment with no human intervention, and in transaction 0x533c…9964c (block 31634935) the payer is −1 mUSDC, the vendor +1 mUSDC, and the payer's ETH spend is 0. The evidence level for this path is mined on GIWA, not a local fork. The same transaction is also §3's timeout case — the on-chain settlement succeeded, and the reporting path's timeout budgets were redesigned afterwards.

Failures are returned as reasons. The core returns a discriminated result instead of throwing, and points at the cause with SELLER_OFFER_INVALID, FACILITATOR_UNTRUSTED, MANAGER_MISMATCH, LIMIT_EXCEEDED, PERMISSION_INACTIVE, SIGNING_FAILED, PAYMENT_REJECTED, and the like.

Offer selection and the receipt. Every entry of the 402's accepts is walked in the seller's order, and the first that is exact ERC-7710 on GIWA, overlaps a trusted facilitator and does not contradict the verified DelegationManager is paid — a seller that lists EIP-3009 first for wallets and ERC-7710 second for delegated agents is paid as-is. When none qualifies, the reason is the first candidate's that was on the ERC-7710 rail at all; when no candidate was, it is SELLER_OFFER_INVALID. The 2xx Payment-Response header is read only when it is a receipt of this payment — success: true, the same network, the payer the leaf was signed for — and then transaction is the header's value ("", as the spec writes it, meaning no hash); a missing or self-contradicting header falls back to the body's receipt.transaction. No seller string from the header reaches the result. The resource is parsed only when its content-type is JSON and returned as text otherwise, with the bearer values redacted either way. Of the content type only the media type comes back — the parameters are dropped, because Content-Type is seller text that travels as far as MCP tool output and a bearer value parked in a parameter must not ride along.

On-chain pre-flight. Before signing, the agent reads the enforcer's own accounting directly and filters out payments that cannot succeed. The chain enforces the cap either way, so the purpose of this step is not safety but accuracy of the reason — instead of going all the way to the seller and receiving a 402, it states the cause, as in payment of 2500000 exceeds 2000000 left in this period. A side effect is that no leaf is signed for a payment that cannot succeed (a leaf is a bearer authorization).

The pre-flight verdict (judgePreflight) is factored out as a pure function, with chain reads injected as callbacks. Status lookup runs readDelegationStatus over every link of the parent permission — looking only at the root misses the narrower cap of a re-delegated child. Two verdict rules are pinned by tests: an inactive reason takes precedence over the cap (reporting a permission that cannot spend any amount as LIMIT_EXCEEDED sends the operator adjusting the cap, which is not the cause), and the cap is the chain's minimum, not the root's value. The minimum is not taken across links alone — for each link the smaller of the period allowance and the lifetime-total allowance is taken first (tightestRemaining), and the cap is the minimum of those. The refusal says which one bound: a period allowance reopens if you wait, a lifetime total does not, and a new grant has to be signed.

Three layers of spending limit. Two of them are on chain: the period cap (ERC20PeriodTransferEnforcer) sets the rate, and the lifetime total (ERC20TransferAmountEnforcer) sets the budget this delegation can ever move. One period cell can hold several days' worth, and the total does not stop that budget from leaving in a single session — every individual payment inside is legitimate, so nothing on chain stops one mistaken resource path from spending it. So the agent runtime lays three limits on top, from env (createAgentSpendGate): a per-call cap AGENT_MAX_PAYMENT_MUSDC, a session total AGENT_SESSION_BUDGET_MUSDC, and a recipient allowlist AGENT_ALLOWED_PAY_TO. Unset means that limit does not exist, and the code substitutes no default of its own — leave all three empty and the only caps are the two on-chain caveats, which is the original behaviour. The final limit is the chain either way.

Enforcement happens in exactly one place, immediately before the leaf is signed: inside the gate that wraps the provider, where the verdict and the reservation sit in the same synchronous block with no await between them — which is why concurrent calls share one budget rather than each getting it. Split the verdict from the accounting into two calls and that gap opens, and the session cap weakens in proportion to the number of concurrent calls (a budget of 1.0 with five simultaneous calls signed all five: 5.0 tUSDC measured). The gate wraps the provider so that a new call path cannot forget the enforcement — as a separate method to call, a path that never called it would still compile.

What the total counts is signatures, not charges. A signed leaf is a bearer authorization, so the facilitator can redeem it even when the seller never delivered the resource — counting only successful settlements would revive the budget on every failed round trip, and a cap that grows back is not a cap. There is exactly one rollback: when the signing itself threw. A leaf that does not exist cannot be redeemed.

A refusal has two names. The pre-flight the payment loop asks before signing (judge) changes no state, and its refusal becomes the legible code SPEND_POLICY_REFUSED. The reservation is not made there because pre-flight and signing are not guaranteed to be a pair: the executor in apps/payment-scheduler checks its own schedule conditions inside its provider and can refuse there, and had the pre-flight reserved, every such refusal would strand a slice of the budget for good. So a payment that passed pre-flight and then lost the budget to another call is caught at the signing point and reported as SIGNING_FAILED, with detail naming which limit it was — the enforcement is not late, the reporting is one step removed. A session is the lifetime of the runtime instance, that is of the MCP server process; a budget that must outlive a restart is the one apps/payment-scheduler keeps in its DB. The per-variable verdicts and the operational rules are in the MCP connection guide §3.1.

Two runtime behaviours:

Studio (wallet module)

Both screens read their data directly from chain.

Screen Source
Delegation and limits ERC20PeriodTransferEnforcer.getAvailableAmount (remaining period balance), caveat terms (cap, validity window), DelegationManager.disabledDelegations (revocation state)
Receipts TransferredInPeriod events

A settlement that consumes the cap always leaves this event, so the receipts need no separate ledger. The remaining balance is not self-aggregated off-chain because that would become a second truth, able to drift from the side that actually enforces.

The validity-window interpretation reflects what a 0 value means to the TimestampEnforcer — the enforcer checks each half of the window only when it is > 0, so a 0 in the terms means unbounded, not 1970.

The receipt query window. The query takes fromBlock as a required argument. GIWA refuses eth_getLogs beyond 100,000 blocks, so an unbounded default would either fail or return a truncated history as if it were complete. The default window is 50,000 blocks, and with GIWA producing roughly 1 block per second (measured over the 31634888→31634935 span) that is less than a day. So the screen header and the empty-list message both show the time at which the window opens, and that time is read from chain as the timestamp of the fromBlock block, not derived from an assumed block time. If the node cannot serve that block (pruned), the message falls back to a block-count notation and the screen stays up. When fromBlock === 0 it is labelled "full history". The window is fixed at 50,000 blocks; Studio does not paginate, and the panel says so.

The boundary of revocation. DeleGatorCore.disableDelegation is onlyEntryPointOrSelf, so the owner EOA cannot call it directly — it must be an EntryPoint UserOperation. The suite exercises both branches — the self branch proves the outcome via impersonation (after revocation disabledDelegations is true, and the same payment is refused with PERMISSION_INACTIVE), and the EntryPoint branch sends a UserOperation signed with the real owner key through handleOps. That UserOperation's callData is buildRevocationCall(...).data verbatim, not wrapped in execute() — wrapping would make it an EntryPoint → execute → self call, folding back into the self branch already covered. Each dependency carries a control.

Control What it proves Actual result
revocation-userop the normal path success — UserOperationEvent.success == true, disabledDelegations true
revocation-userop-unfunded the deposit is the real gate FailedOp(0,AA21 didn't pay prefund)
revocation-userop-wrong-signer the account verifies owner() FailedOp(0,AA24 signature error)
revocation-userop-tampered-field the signed entryPoint field is in force FailedOp(0,AA24 signature error)
revocation-submitter a JSON wire submission passes the validator and revokes success — the validated struct matches the signed struct in all 9 fields
revocation-submitter-foreign-sender a foreign account's revocation is refused before any chain read sender is not the account this submitter serves

The submission endpoint (apps/revocation-submitter). Anyone can call handleOps and the relayer fronts the gas, so a service that forwards whatever it is handed becomes a general-purpose UserOperation relay running on someone else's funds. validateRevocationSubmission narrows it to one operation on one account — a sender allowlist, the root's delegator == sender, initCode and paymasterAndData forced empty, ceilings on the 4 gas fields, and byte equality of callData against a re-encode. The last check is not a decode because a decode passes bytes appended at the end.

The signature is deliberately not verified offline. The account is a HybridDeleGator and validates through ERC-1271, so an offline ecrecover can silently disagree with the account. The authority on the signature is the AA24 the EntryPoint returns in pre-broadcast simulation.

judgeSubmissionReadiness returns, as distinct reasons, the refusals that can be judged from chain state — prefund_short (the payer holds ETH 0 by design, so the deposit is the only funding source, and this is the most common state), fee_below_basefee (the EntryPoint reimburses at min(maxFeePerGas, baseFee+priority) while the relayer's transaction cannot be included below baseFee, so sending it anyway succeeds while only the operator loses), base_fee_unreadable (the base fee could not be read — a case for retry, not re-signing, which is why its reason is kept separate from the previous one), relayer_unfunded.

Success is judged by checking UserOperationEvent.success directly, not the receipt status. The EntryPoint absorbs an inner call's revert into UserOperationRevertReason and lets the transaction itself succeed (EntryPoint.sol:340-353), so by the receipt alone a reverted disableDelegation still reads as success.

Service boot verification (bun run test:e2e:revoke). Unit tests and the negative-path suite cover the validator and the on-chain enforcement, but the boot of the process itself — env parsing, reading the deployment artifacts, the relayer cross-check at boot, /health, single-flight, simulate→broadcast — is round-tripped by a separate e2e that actually starts the service on a GIWA fork. The suite counts its own cases and prints PASS — N cases (ABC…).

Two designs in this suite are non-obvious. First, replay defence splits into two cases. The first line of defence against re-sending the same body is the deposit gate, and in that state the nonce has never been executed. So the suite refills the deposit to remove the gate, re-sends the identical body, and confirms that the one remaining line of defence — the EntryPoint nonce — cuts it off with AA25 invalid account nonce. Second, the success case verifies the relayer's balance sheet. On GIWA the well-known Anvil development addresses carry an EIP-7702 designator whose target is a sweeper that transfers away any incoming balance in full. EntryPoint._compensate pays the beneficiary with call{value:…}, so using such an address as the beneficiary empties the relayer in a single handleOps (fork measurement: 1 ETH → 0.00024 ETH, transaction cost 0.00017 ETH). The suite enforces at startup that the beneficiary address has no code.

The browser leg also checks the responses directly. The browser client (local dev :5173) and the submitter (:8082) are different origins and the request carries content-type: application/json, so the browser sends a preflight first — if the preflight fails, the POST never goes out. The suite checks each case: that an allowed origin's preflight gets 204, that an unknown origin gets 403, and that a request with no Origin (a server-side call) works as-is.

Studio's revoke button (apps/web/src/dapp/RevokeButton.tsx). Connect the wallet → check against owner() → read the nonce → build → signTypedData → POST to the submission endpoint. Three design decisions: (1) the connected wallet is checked against the account's owner() before signing (HybridDeleGator.sol:233) — a signature from another wallet surfaces as AA24 at the EntryPoint, indistinguishable from a nonce or gas problem. (2) The nonce is read at click time and the operation is built in one pass — if the value is re-read between building and signing, the digest goes stale and the result is again AA24. That is why buildRevocationUserOperation is a pure function. (3) The wire body is produced by buildRevocationSubmissionBody from the same module the submission endpoint uses for validation — a round-trip test pins byte-level reproduction so the encoder and the decoder cannot diverge.

Each reason the button locks shows its own message — revocation endpoint not configured, already revoked, wallet not connected, chain mismatch, not the owner. A short deposit is not a lock reason — on the public path the sponsor tops up the deposit at revoke time, and that is the reason sponsored mode exists. An owner mismatch is reported first — the wallet is the only element the person in front of the screen can change.

The unverified stretch: whether the wallet extension renders the signature-request struct (9 fields) legibly for a human can only be confirmed with a real wallet open. It remains the one stretch automation cannot cover.

The funding state of self-funded (pinned-mode) revocation — the EntryPoint deposit, the per-revocation requirement (revocationPrefund(DEFAULT_REVOCATION_GAS)), and the shortfall — is answered by the submission endpoint's /health. The former D6 console displayed these values on screen at all times (even at 0 — as long as gaslessness is the central claim, a row that appears only when the value is not 0 is a row that cannot confirm the invariant holds), and that principle carries over into Studio's status display.

How the kill switch is funded. Payments never pass through the EntryPoint — the relayer calls redeemDelegations directly, so the payer's zero-ETH invariant holds for payments. Revocation alone cannot avoid the EntryPoint, and the EntryPoint collects gas not from the account's native balance but from the deposit (StakeManager.deposits). DeleGatorCore._payPrefund (:559-566) absorbs a failed transfer, so with no deposit it is the EntryPoint, not the account, that refuses with AA21 — not AA23. EntryPoint.depositTo(address) is public payable with no access control, so the relayer can fill another account's deposit, and the payer's native balance stays at 0 while it does. But withdrawTo reads deposits[msg.sender], so this is a one-way cost the relayer cannot claw back. The procedure for completing the revocation path locally is in the revocation runbook.

The Framework kill switch. Where revocation severs one delegation, DelegationManager.pause() stops the entire framework (onlyOwner — an ordinary EOA transaction that needs no deposit). The defence is two layers: the facilitator's verifyFrameworkOperationalState checks paused on every request and refuses before settling, and on-chain the whenNotPaused on redeemDelegations (DelegationManager.sol:132) reverts even a bypass of that gate. The suite confirms that executing pause() on a fork with an impersonated owner has /health report ok=false, frameworkError=framework_paused and frameworkPaused=true, and has the payment turned away as not-ready rather than judged (/verify 503 facilitator_not_ready), so the agent receives SELLER_UNAVAILABLE — nothing charged, retry later. /settle gives the same answer: when the RPC dies in the pre-broadcast stage (simulation, gas estimate, fee query) it answers 200 facilitator_not_ready and writes no ledger row — nothing was judged and nothing charged. A failure after the broadcast stays settlement_pending, with its hash.

Reproduction

bun run check                      # full-stack regression, no keys or network
cd apps/delegation-lab
bun run test:negative              # caveat cases — the default target is a disposable chain
SUITE_TARGET=fork bun run test:negative   # the same cases on a GIWA fork
bun run test:e2e:mcp               # full payment run → over-cap pre-flight refusal → pause → revocation
bun run test:e2e:revoke            # actually starts the submission endpoint and round-trips it
SUITE_FORK_BLOCK=<recent block> bun run test:e2e:bootstrap   # 16 onboarding-service cases
bun run preflight:giwa             # read-only GO/NO-GO against GIWA head state

test:negative's default target is a disposable chain. The GIWA fork target must be run separately with SUITE_TARGET=fork; no single line runs both targets. All four suites count their own cases and print the count alongside the pass verdict (N/N cases passed, PASS — N cases (ABC…), GO — N개 조건 전부 충족).

The execution requirements differ per command. bun run check and test:negative run from a clean clone with no keys, no network, and no deployment artifacts — test:negative deploys the 38-unit Framework itself onto a disposable Anvil and tests against it. test:e2e:mcp, by contrast, requires a root permission artifact signed by the owner, so it does not run from a bare clone without the wallet that owns the deployed account. test:e2e:bootstrap deploys a fresh account onto a GIWA fork and therefore reads state no cache has ever held — a recent block must be passed as SUITE_FORK_BLOCK (GIWA prunes old state).

test:e2e:mcp refuses to start unless every child process is pinned to a loopback RPC, and after finishing it re-reads the real GIWA relayer nonce to confirm that nothing was broadcast.

Fork-source credentials are never exposed in argv. The private GIWA endpoint carries its API key in the URL path, so the whole URL is a credential, and argv is visible via ps. anvil --fork-url has no environment-variable alias, so apps/delegation-lab/fork-source-proxy.ts holds the key in memory and hands anvil a keyless http://127.0.0.1:<ephemeral port>. All four places that spawn a fork use this path.