Mapae docs

2. Payment flows

Mapae maintains two regression-testable paths in parallel.

EIP-3009 direct payment

agent → resource request
      ← 402 Payment Required (amount · recipient · asset · EIP-712 domain)
      → check the limit, sign an EIP-3009 authorization (off-chain)
facilitator → verify the signature → broadcast the settlement transaction to GIWA
      ← resource + receipt

The payer pays no gas. It is the facilitator's relayer signer that broadcasts the transaction, and because the authorization pins from, to, and value under the signature, the relayer holds no authority beyond that of a broadcaster.

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)

In this document, permission and delegation refer to the same signed artifact — the difference is ERC-7715 versus ERC-7710 terminology. 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.

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 /delegated/deliverable/inv-002
    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 + resource (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 inv-002 (retry)
    Seller->>Fac: /verify → simulate
    Fac->>DM: simulate redeemDelegations
    DM-->>Fac: revert ERC20PeriodTransferEnforcer:transfer-amount-exceeded
    Fac-->>Seller: isValid = false
    Seller-->>Agent: 403 — 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 impossibleDelegationManager 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 per-IP rate limit, the daily gas budget (BOOTSTRAP_DAILY_WEI), and the sponsor balance kept deliberately small. The sponsor holds no delegation authority, so it cannot reach payer funds, caps, or settlement. Verification is bun run test:e2e:bootstrap — 15 cases on a GIWA fork (kill switch, approval mismatch, shared-relayer refusal, foreign signer, high-s, deployment, late binding, gas accounting, faucet, idempotency, concurrency, rate limit, budget exhaustion, chain-failure leak guard), 15/15.

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.

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 403, 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.

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 the payment refused with PAYMENT_REJECTED 403 and /health reporting ok=false with the reason DelegationManager is not operationally active.

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   # 15 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.