This document is the phase 1 coordination packet for the 2026-04-09-boring-payment-state-machine quest.
It freezes the canonical contract that downstream repos should consume before relay and service migrations continue.
Companion document:
- see
docs/workers-baseline.mdfor the default Cloudflare Worker transport split, repo baseline, observability policy, and product-convention expectations that should accompany this contract
The shared public state machine is:
requires_payment -> queued -> broadcasting -> mempool -> confirmed | failed | replaced | not_found
Notes:
submittedis allowed only as an internal relay observability step.- RPC and HTTP adapters must collapse any internal
submittedstep intoqueuedbefore returning caller-facing data. confirmedis the only default deliverable state.
paymentIdis relay-owned.payment-identifieris a client-supplied idempotency input only. It is not a public polling identity and must never be surfaced or reconstructed as canonicalpaymentId.- Duplicate submission of the same already-known payment artifact should reuse the same
paymentIduntil that payment reaches a terminal state. - Accepted duplicate submit responses should return the current caller-facing in-flight status for that reused
paymentId:queued,broadcasting, ormempoolas applicable. - Internal and external polling should both treat
paymentIdas the stable handle for in-flight and terminal lookup. checkStatusUrlis an additive canonical poll hint for the same relay-owned lifecycle.- If a consumer has neither relay
paymentIdnor canonicalcheckStatusUrl, it must fail closed rather than inventing a polling identity.
The relay implementation has to keep one canonical paymentId attached across this bridge:
The row order below is normative. It is the frozen transition sequence for the relay lifecycle bridge, not a display-only convention.
| Relay step | Caller-facing state projection | Contract |
|---|---|---|
| sender-hand accepted | queued |
relay accepted sender-hand ownership for this paymentId |
| queued for sponsor dispatch | queued |
the same paymentId is now queued under sponsor dispatch ownership |
| sponsor broadcasted | broadcasting, then mempool when observed |
chain visibility changes, but the canonical identity does not |
| confirmed | confirmed |
canonical delivery success |
| replaced | replaced |
old paymentId is terminal because another tx won the nonce |
| terminal failed | failed |
old paymentId is terminal failed with a normalized terminalReason |
Polling and status responses should expose:
- canonical
status - relay-owned
paymentId checkStatusUrlwhen the transport knows the canonical poll endpoint- normalized
terminalReasonwhen the outcome is terminal and known - transport-local
errorCodeonly as an adapter detail, not as the semantic source of truth
Normalized terminal reasons let landing-page, agent-news, x402-api, skills, and aibtc-mcp-server stop inventing their own retry buckets from raw relay error strings.
Sender-owned responsibilities:
- sender nonce correctness
- rebuild after sender nonce stale or gap
- sender-wallet recovery actions
Relay-owned responsibilities:
- payment identity lifecycle
- sponsor ordering and sponsor nonce recovery
- in-flight payment transitions
- terminal settlement truth
| Terminal reason category | Recovery owner | Expected client action |
|---|---|---|
validation |
sender | stop and fix the request or signed transaction |
sender |
sender | rebuild and re-sign a new payment |
relay |
relay | use bounded retry on the same paymentId only when the adapter marks it retryable |
settlement |
relay | treat broadcast or settlement failures as relay-owned recovery on the same paymentId unless sender repair is explicitly required |
replacement |
caller | stop polling the old paymentId; decide the next action explicitly |
identity |
caller | the old identity is gone; restart the higher-level flow with a new payment and never invent a replacement paymentId |
RpcSubmitPaymentAccepted.statusshould return canonical caller-facing in-flight states:queuedfor fresh acceptance, andqueued,broadcasting, ormempoolwhen duplicate reuse surfaces the active state of the reusedpaymentId.queued_with_warningremains allowed only as a temporary compatibility shim while warning-aware callers migrate.RpcCheckPaymentResult.statusandHttpPaymentStatusResponse.statusmust never returnsubmitted.RpcCheckPaymentResultandHttpPaymentStatusResponsemay both surfacecheckStatusUrlas an additive canonical poll hint.terminalReasonis additive and should be emitted wherever relay adapters already know the normalized terminal classification.
Downstream repos should import these constants from @aibtc/tx-schemas rather than hardcoding values:
CANONICAL_POLLING_IDENTITY_FIELDS— the only valid fields for polling identity lookupRELAY_LIFECYCLE_BRIDGE— frozen ordered transition sequence from acceptance to terminalityTERMINAL_REASON_CATEGORY_HANDLING— recovery owner and expected client action by terminal reason categoryCanonicalDomainBoundary— domain boundary constants including payment identity, polling identity, and recovery boundaries
tx-schemas: freeze the public contract and publish additive fields.x402-sponsor-relay: collapse caller-facingsubmittedintoqueued, emit normalizedterminalReason, and preserve duplicate reuse bypaymentId.landing-page,agent-news,x402-api: consume shared RPC/HTTP schemas and delete copied contract types.skills,aibtc-mcp-server: converge on one external client retry and recovery matrix driven by shared statuses and terminal reasons.
| Scenario | Canonical status | terminalReason | Service delivery default | Client action |
|---|---|---|---|---|
| Fresh submission accepted | queued |
wait or poll by paymentId |
no rebuild | |
| Duplicate same submission | queued, broadcasting, or mempool |
do not create a second receipt | reuse same paymentId / same tx |
|
| Relay broadcasting | broadcasting |
do not deliver by default | poll | |
| Seen in mempool | mempool |
do not deliver by default unless route exception is documented | poll | |
| Confirmed on-chain | confirmed |
deliver | success | |
| Sender nonce stale | failed |
sender_nonce_stale |
do not deliver | rebuild transaction |
| Sender nonce gap before canonical acceptance | failed |
sender_nonce_gap |
do not deliver | rebuild or submit missing nonce |
| Accepted payment blocked on sender nonce gap | queued |
do not deliver | keep polling the same paymentId; use hold or wedge diagnostics to decide whether to submit missing nonces or escalate |
|
| Invalid transaction | failed |
invalid_transaction |
do not deliver | stop |
| Sponsor/relay internal failure | failed |
queue_unavailable, sponsor_failure, or internal_error |
do not deliver | bounded retry only if adapter marks retryable |
| Broadcast failure | failed |
broadcast_failure or chain_abort |
do not deliver | treat as failed; rebuild only if caller owns sender recovery |
| Nonce replacement | replaced |
nonce_replacement or superseded |
do not deliver | stop polling old paymentId; decide next client action explicitly |
| Missing or expired identity | not_found |
expired or unknown_payment_identity |
do not deliver | old identity is gone; restart the higher-level flow with a new payment and never retry the old paymentId |