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.
Sponsored onboarding (account bootstrap)
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:
- Runtime loading is lazy and caches only success. An env or network failure at boot returns a reason in the tool result instead of killing the process, and fixing the environment recovers it without a restart.
- stdout is the JSON-RPC channel. All logging goes to stderr.
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.