04-settlement-and-idempotency
Settlement and idempotency
How Agent Play verifies x402 payments, commits world state atomically, and handles failures without double-selling or lost funds.
See also: Payment catalog · Observability · Security
Core rule
Never mutate world state (sold item, lease row, talk session) until facilitator verify succeeds.
Never verify the same idempotency key twice for two different world commits.
Internal wallet today uses Redis WATCH/MULTI on wallet + item. x402 adds a payment intent layer before the same commit path.
Payment intent lifecycle
stateDiagram-v2
[*] --> quoted: preparePayment / 402 issued
quoted --> verified: facilitator verify OK
verified --> committed: Redis EXEC sold+audit
committed --> settled: facilitator settle + tx sig
settled --> [*]
quoted --> expired: TTL
verified --> failed: EXEC abort
failed --> quoted: retry new quote
committed --> settle_pending: async settle
settle_pending --> settled
settle_pending --> reconcile: ops job
Redis keys
| Key | Purpose | TTL |
|---|---|---|
agent-play:{hostId}:payment-intent:{id} |
Intent JSON (status, resource, amounts) | 24h after terminal state |
agent-play:{hostId}:idempotency:{key} |
Maps key → intent id + result | 7d |
agent-play:{hostId}:node:{nodeId}:settlement |
Wallet link profile | none (until revoked) |
Intent record (schema)
type PaymentIntent = {
id: string;
idempotencyKey: string;
status: "quoted" | "verified" | "committed" | "settled" | "failed" | "expired";
resource: string;
sku: string;
payerNodeId: string;
payeeAddress: string;
amountMicro: string;
network: string;
createdAt: string;
verifiedAt?: string;
committedAt?: string;
txSignature?: string;
facilitatorRef?: string;
worldOp: { op: string; payload: Record<string, unknown> };
};
Commit flow (amenity purchase)
sequenceDiagram
participant RPC as purchase handler
participant PG as PaymentGate
participant F as Facilitator
participant SS as SessionStore
participant R as Redis
RPC->>PG: requirePayment(proof, intentId)
PG->>F: POST /verify
F-->>PG: OK
PG->>R: SET intent status=verified
PG->>SS: executePurchaseAfterSettlement(intentId)
Note over SS: WATCH item hash + idempotency key
SS->>SS: mark sold, LPUSH purchase w/ settlement
SS-->>PG: ok
PG->>F: POST /settle
F-->>PG: txSignature
PG->>R: intent status=settled
PG-->>RPC: 200 response
Branching on payments mode
AGENT_PLAY_PAYMENTS_MODE |
Behavior |
|---|---|
internal |
Legacy balanceUsd debit only |
dual |
x402 required; internal debit disabled for new purchases |
x402 |
402 without proof; no PlayerWallet reads |
Implementation: single executePurchase entry with strategy pattern in redis-session-store.ts.
Idempotency
Key composition (example):
purchase:{payerNodeId}:{resource}:{quoteVersion}
Client retry: same X-PAYMENT + same Idempotency-Key header → return cached 200 with original txSignature.
Server:
// Pseudocode
const existing = await redis.get(idempotencyKey);
if (existing?.status === "committed") {
return existing.response;
}
Prevents double-sell when client retries after timeout.
Ordering: verify → commit → settle
| Order | Pros | Cons |
|---|---|---|
| Verify → commit → settle (recommended v1) | No sold item without verified payment | Rare settle failure after commit needs reconciliation |
| Verify → settle → commit | Stronger fund guarantee | Slower UX; chain latency blocks gameplay |
Reconciliation job (see 10 — Observability): intents in committed + no txSignature after N minutes → alert + manual/auto settle retry.
Extended purchase record
When paymentsMode is x402 or dual, append:
settlement: {
network: "solana:devnet" | "solana:mainnet-beta";
asset: "USDC";
amountMicro: string;
payerAddress: string;
payeeAddress: string;
txSignature: string;
x402Resource: string;
facilitatorRef?: string;
idempotencyKey: string;
}
priceUsd retained for display and legacy export.
Failure modes
| Scenario | User-visible | Server action |
|---|---|---|
| Facilitator down on verify | 503 + Retry-After |
No commit |
| Verify OK, EXEC fails | 500 | Intent failed; item stays available |
| Commit OK, settle fails | 200 + settlementPending: true |
Reconciliation job |
| Duplicate purchase race | 409 ITEM_ALREADY_SOLD |
One winner only |
| Expired quote | 402 new quote | New intent id |
Facilitator down policy: fail closed for new purchases; in-flight talk sessions grace one tick then end with message.
Talk session settlement
Talk ticks generate high-frequency intents. Mitigations:
- Batch ticks — one verify per 10s tick (default).
- Session voucher — prepaid SKU; ticks debit voucher in Redis only (05 — Agent payouts).
- Idempotency per tick seq — resource id includes monotonic
seqfrom talk session state.
Production checklist
-
WATCH/MULTIcovers item + idempotency key (same as today’s purchase tests) - Zero commits with
status != verified(assert in tests + metric) - Stuck intent sweeper cron documented in 07 — Platform ops
- Idempotency TTL ≥ max client retry window
- Load test: 50 concurrent purchases, 1 item → 1 success