3. Error model
A discriminated union that assigns a tag to every failure mode on the settlement
path (packages/shared/src/errors.ts).
Blockchain code has a wide error surface — RPC timeouts, rate limits, reverts,
nonce contention, signature verification failures, relayer gas exhaustion.
Collapsing these into a single catch destroys the information recovery needs.
Each tag distinguishes:
- Retryable (
RpcUnavailable,RpcRateLimited) — retry after backoff - Operational failure (
RelayerOutOfGasand the like) — 503, not the caller's fault, alert-worthy - Caller error (
InvalidSignature,DomainMismatch,MalformedPayload) — 4xx, the cause is returned
Why DomainMismatch is its own tag: an EIP-712 domain mismatch is the most
common failure in an x402 integration, and if it goes out as a generic 500 the
cause cannot be pinpointed.
The two paths have different response policies (deliberately). It is the
EIP-3009 direct payment path (apps/seller) that carries the tag union verbatim
in the response body. The ERC-7710 delegated path behaves differently.
Direct payment (apps/seller) |
Delegated payment (apps/delegated-seller, apps/facilitator-erc7710) |
|
|---|---|---|
| External response | SettlementError._tag + describe() cause |
Opaque reasons such as delegation_rejected / settlement_unknown |
| Status code | httpStatusFor() |
402 / 400 / 403 / 422 / 504 |
| Client branching | Tag | DelegatedPaymentFailureCode (the agent's own classification) |
The delegated path is opaque because of the threat model. Returning detailed
failure reasons would let an attacker probe the caveat boundaries — remaining
allowance, expiry status, re-delegation structure — from responses alone. The
cause goes to the server log. redactForLog keeps the revert reason
(ERC20PeriodTransferEnforcer:transfer-amount-exceeded) and removes the
bearer-length hex viem embeds in the error (the signed permission context),
leaving only its size. The operator sees the cause; the caller does not.
How absent state is judged
One rule applies across the guard code: a value that could not be read, or does not exist, placed in a judgment position must produce a refusal or a distinct reason — never satisfaction.
PERMISSION_EMPTY. The correct ABI encoding of an emptyDelegation[]is a 130-character string that passes the hex-shape guard, anddecodeDelegationsturns it back into[]. In that state every pre-flight check would pass for lack of anything to compare against, so it is refused under its own tag.PERMISSION_INACTIVEis not reused because the two tags direct different actions — the former means regenerate the artifact, the latter means check the chain for revocation or expiry. The guard sits in both places: boot validation (loadDelegatedAgentRuntime) and the judgment function (judgePreflight).- Absent period caveat. If the link carries no
ERC20PeriodTransferEnforcercaveat, the remaining balance staysundefined. Two consumers judge that state differently because they ask different questions.
| Question | tightest === undefined |
|
|---|---|---|
judgePreflight (runtime) |
Will the chain refuse this payment? | Pass — without a cap it is not refused |
giwa-preflight (human gate) |
Does the configuration match the intent? | Fail — there is no value to check against |
The computation (tightestPeriodRemaining) is shared in
packages/delegation; each side keeps its own judgment, and the two are
linked by cross-reference comments.
- Absent input to the fee judgment.
judgeSubmissionReadinessrefuses the state where the base fee could not be read asbase_fee_unreadable. The reason is kept separate fromfee_below_basefeebecause the two direct opposite actions —fee_below_basefeetells the owner to re-sign with a higher fee,base_fee_unreadablesays retry the chain read.
Standing gates
bun run check mechanically verifies the claims of documentation and
configuration alongside the code. Every gate runs without keys or network, and
gives the same result on a clean clone.
| Gate | What it verifies |
|---|---|
check:docs |
That every bun run and make command in the documentation actually exists, that every relative link resolves, and that every address matches the deployment artifacts and the canonical token source |
check:gitbook |
That the GitBook chapters, SUMMARY, and configuration match, byte for byte, what is derived from the canonical source (docs/tech-notes.md) |
check:logging |
That no raw error reaches a console.* argument in apps/, packages/, or scripts/ — viem embeds the transport URL in error messages, so this blocks the path by which an RPC URL with a key in its path leaks into logs |
check:storage |
That no browser-storage write anywhere in apps/web/src happens outside the one sanctioned module (lib/grant-store.ts) — that module's projection is an allowlist which omits the agent session key, and a second write elsewhere would inherit neither the allowlist nor the test that pins it. Reads and removals are allowed everywhere |
check:advisories |
That every bun audit finding is either fixed or an acceptance carrying a prove function that is re-measured on every run |
check:counts |
That the test counts the repository README states match what bun and forge actually collect — it checks agreement with the suites, not agreement among the numbers in the documentation |
The semantic distinction between 422 and 504
Among the opaque reasons, these two must be distinguished. settlement_failed
(422) means "the payer was not charged"; settlement_unknown (504) means
"whether the payer was charged could not be established." The former is an
answer that invites a retry, and retrying an unconfirmed state becomes a double
payment.
The distinction is grounded in a real incident. GIWA 0x533c5cb2…9964c
(block 31634935) actually transferred 1.00 mUSDC from the payer, yet the caller
received PAYMENT_REJECTED — the receipt-wait timeout had been configured
longer than the seller's HTTP timeout. The timeout budgets were then redesigned
to grow toward the outer layers (25 → 35 → 45 → 50 s), and a payment whose
outcome is unresolved is returned as SETTLEMENT_UNKNOWN.
The judgment is isolated in a pure function, decideSettlement()
(packages/delegation/src/x402.ts), and the criterion string
(SETTLEMENT_UNCONFIRMED) and the response type are taken from the same module
by producer and consumer alike. The decision ladder leans toward unknown.
| Observation | Result | Reason |
|---|---|---|
| No response received (connection refused, non-2xx, not JSON, timeout) | unknown 504 |
"The request never arrived" and "the response was lost after broadcast" cannot be told apart |
errorReason === SETTLEMENT_UNCONFIRMED |
unknown 504 (+hash) |
Without the hash the caller has no way to verify |
success !== true |
failed 422 |
Explicit refusal — no funds moved |
success === true, payer mismatch |
unknown 504 |
A broadcast was claimed but the identity did not line up, and the balance was not confirmed |
success === true, payer match |
settled 200 |
This path can be forced on a fork — shrinking the facilitator's receipt wait to 1ms exercises the unconfirmed-after-broadcast branch.
cd apps/delegation-lab && SETTLEMENT_RECEIPT_TIMEOUT_MS=1 bun run test:e2e:mcp
The run does not only check status codes; it reads the enforcer events directly from the fork and cross-checks whether funds actually moved.
Effect migration plan
The current implementation is a discriminated union, with the _tag
discriminator kept isomorphic to Effect's
Data.TaggedError.
| Stage | State |
|---|---|
| Now | Discriminated union + explicit branching. Every failure mode enumerated at the type level |
| Next | Migrate the settlement path to Effect<A, SettlementError, R> — typed error channel, Schedule-based retry/backoff, resource-safe RPC connections |
It was not adopted during the MVP window because partial adoption is hard. Effect propagates through the entire call chain, so adopting it before the core payment loop was proven would have been an execution risk. Fixing the shape of the error model first turns the migration into a mechanical substitution rather than a rewrite.