Mapae docs

4. Security considerations

Facilitator trust boundary

The facilitator holds the relayer key, receives the signed Payment-Signature, and is itself the redeemer the leaf pins. In other words, it sits in a position where every identity check passes. The trust boundary is therefore defined not by whether the facilitator is trusted, but by the maximum damage when it is fully compromised.

The crux is the signature scope of redeemDelegations. The permission context is signed, but the execution is not_executionCallDatas is supplied as calldata by the caller at redemption time (DelegationManager.sol:126-133). A compromised facilitator can submit an arbitrary execution alongside a valid leaf, and the only thing standing in the way is the set of caveats attached to that leaf. The wrong-redeemer case does not cover this threat — what that case proves is that a third party cannot redeem, and the facilitator is not a third party.

Attempt by a compromised facilitator Refusing enforcer On-chain revert
Pay its own address instead of the vendor AllowedCalldataEnforcer invalid-calldata
Inflate the amount (even within the period cap) ERC20TransferAmountEnforcer allowance-exceeded
Turn a one-shot payment into a standing allowance (approve drain) ERC20TransferAmountEnforcer invalid-method
Redirect the call to another contract ERC20TransferAmountEnforcer invalid-contract
Attach native value ValueLteEnforcer value-too-high
Target the payer account itself (entering the self branch) ERC20TransferAmountEnforcer invalid-contract
Redeem the same leaf again ERC20TransferAmountEnforcer allowance-exceeded
Redeem after expiry TimestampEnforcer expired-delegation
Accumulate beyond the period cap ERC20PeriodTransferEnforcer transfer-amount-exceeded

The self-target case is the least obvious. Because execution happens through IDeleGatorCore(root.delegator).executeFromExecutor (DelegationManager.sol:252-253), an execution whose target is the payer account makes the account call itself, and msg.sender == address(this) — the self branch of onlyEntryPointOrSelf — holds (DeleGatorCore.sol:106-109). Through this branch lie withdrawDeposit(:356), enableDelegation(:373 — undoes a revocation), and _authorizeUpgrade(:526 — swaps the implementation). DeleGatorCore has nothing that blocks a self call; the only thing standing in that spot is the caveat. The case uses withdrawDeposit(address,uint256) as its payload because that calldata is exactly 68 bytes, so it passes the length gate of ERC20TransferAmountEnforcer (:87) and is then caught by the contract check (:92) — making clear that it is not blocked by size by accident.

The approve case is constructed to pass every address check — the pinned vendor address goes into the spender slot. The only thing that refuses it is the selector check, and it does refuse.

What remains available to a compromised facilitator is limited to the following.

Theft of funds, redirection, and exceeding the cap are impossible; what remains is availability and ordering. This is the rationale for a structure that entrusts the relayer with gas but not with funds.

The nine rows in the table above are cases that negative-path-suite.ts executes, and the six tampering cases carry a control — same leaf, same redeemer, an execution with only the tampering removed settles normally. Without the controls, the six refusals could also come from reasons unrelated to the tampering (an exhausted period, a stale account). All cases pass on both a disposable chain and a GIWA fork.

The same public host as the facilitator also routes the onboarding sponsor under the /bootstrap path — a separate process, a separate key. The request body is {permissionContext} and nothing else, the owner is recovered from the signature, and CREATE2(owner) must match the permission's delegator, so the caller cannot nominate an address we would pay to deploy. Responses emit only a closed refusal enum. Even if the sponsor key is compromised, all it yields is wasted gas up to the balance — holding no delegation authority, it cannot reach payer funds, caps, or settlement. If the sponsor coincides with the relayer or the deployer, the service refuses to boot: sharing a key that answers unauthenticated requests with the settlement key lets griefing spread into a settlement outage.

Attack vectors and countermeasures

Vector Countermeasure
Signature replay EIP-3009 nonce consumption (authorizationState), verified by tests
Smart-account signatures OZ SignatureChecker — supports EOA and EIP-1271 alike. Bare ecrecover fails silently on 4337 accounts
Authorization front-running An observer can submit transferWithAuthorization first, but funds move only to the signed to — an ordering issue, not theft. Use receiveWithAuthorization where logic depends on the fact of receipt
Validity window validAfter/validBefore enforced. The L2 sequencer's timestamp manipulation margin (seconds) is negligible against the validity window (minutes to hours)
Relayer authority Amount and recipient are fixed in the signature and cannot be changed
Signature exposure in logs Facilitator error logs never record the signature or the full payload — only chain, asset, amount, address, and nonce metadata
Facilitator attack surface The API is exposed only on loopback/private networks; the container image is pinned by digest with read-only, cap-drop, and no-new-privileges applied
Redirect hijacking Payment requests from the agent and seller refuse HTTP redirects, so the authorization in the payment headers (Payment-Signature/X-PAYMENT) never travels to another origin
Malicious DelegationManager Single-manager allowlist from the GIWA deployment artifacts; canonical EntryPoint and required enforcer addresses verified
Permission context exposure Excluded from Git, size-limited, never printed in logs or error detail
Forged payer receipts The canonical payer is derived from the last/root delegator in the permissionContext; a mismatched wire claim is refused
verify→settle race Re-simulation immediately before settle
Duplicate settle Deduplicated by a paymentIntentId over the canonical payment terms and the context bytes; the broadcast tx hash is stored before the receipt
Gas DoS via complex delegations Estimate first, then refuse anything above the configured gas cap
Unauthorized relayer The intersection of the leaf's RedeemerEnforcer and the 402's facilitatorAddresses is enforced
Onboarding griefing (repeated deploy requests) Per-IP rate limit + daily gas budget + a small dedicated sponsor wallet — exhaustion stops only that day's onboarding and never touches settlement or funds
Nominating the deploy target address The request body is {permissionContext} only — the owner is recovered from the signature, and the account is CREATE2(owner), which must match the delegator
Non-canonical signatures (high-s, v ∉ {27,28}) Deploy only after an offline canonical-form check — viem accepts them but OZ ECDSA reverts, so without the check we would pay to deploy an account whose every grant reverts
Vulnerable dependencies bun audit runs in the gate. Every finding is either fixed or accepted with a re-measurable proof attached

Acceptance criteria for dependency advisories

A finding reported by bun audit is either fixed or explicitly accepted with a rationale attached. One is accepted today — the Windows path traversal (moderate) in @hono/node-server <2.0.5. The MCP server uses only the stdio transport, and the adapter in question is something the SDK pulls in for its streamable HTTP transport, so it is not part of the bundle (0 references to hono across the 974 modules of the entrypoint bundle). No compatible update closes it — the fix landed in 2.0.5, and the SDK (declares ^1.19.9; the final 1.x is 1.19.15) does not include it.

The basis for an acceptance is code, not prose. Each accepted item in scripts/check-advisories.ts carries a prove function that re-measures its own claim on every run, and there are three failure directions — a new finding that is not accepted, an acceptance whose proof has broken, and an acceptance that is no longer reported (an unused exception outlives its rationale). To rule out a detector that always returns zero, the gate trusts the real entrypoint's zero only after it has found the references in a control that imports the transport on purpose (measured 3 versus 0). A run that cannot reach the registry is distinguished from zero findings — in that case it prints that the comparison was skipped, and the prove functions still run offline as-is.

Logging and credentials

viem embeds the full transport URL in its error messages, and on an RPC endpoint that carries its API key in the path, the URL itself is a credential. redactUrls in packages/shared reduces any URL that reaches a log to scheme://host, and the check:logging gate refuses, repository-wide, any code where a raw error reaches a console.* argument. Signed payloads and permission contexts are bearer authorizations and are never printed in logs or error detail.