feat(payments): adopt native boring-tx state machine#107
feat(payments): adopt native boring-tx state machine#107
Conversation
Map from x402-api compat-shim code paths to the native boring-tx events emitted by x402-sponsor-relay v1.30.1. Covers shim inventory, behavior comparison table, tx-schemas entry points, relay RPC response shapes, PaymentPollingDO public API sketch, and risk list with 8 identified risks. Co-Authored-By: Claude <noreply@anthropic.com>
Bump @aibtc/tx-schemas constraint from ^0.3.0 to ^1.0.0. The 0.x.y semver locking meant the installed 1.0.0 package was outside the resolvable range; pinning to ^1.0.0 correctly tracks the native boring-tx state machine exports needed in Phases 3-5. Also remove stale patches/x402-stacks+2.0.1.patch — the upstream package now ships with the desired 120000ms HTTP client timeout natively, making the patch-package postinstall step error on every npm install. Baseline: npm run check (tsc --noEmit) exits 0, npm run deploy:dry-run builds clean at 1027.76 KiB before any logic changes. Co-Authored-By: Claude <noreply@anthropic.com>
Create src/services/payment-contract.ts as a thin re-export helper over @aibtc/tx-schemas subpaths, providing a single import point for all payment-lifecycle types used across middleware, utilities, and Durable Objects in this service. Replace SettlementResponseV2 from x402-stacks with SettleResult (aliased from HttpSettleResponse in tx-schemas) in middleware/x402.ts and types.ts. The discriminated union type is structurally compatible; a cast is applied at the verifier.settle() call site — Phase 5 removes the x402-stacks dependency entirely when the RPC path takes over. extractCanonicalPaymentDetails is preserved (Phase 5 removes it). Co-Authored-By: Claude <noreply@anthropic.com>
Adds PaymentPollingDO (SQLite-backed Durable Object) that tracks in-flight payments by polling checkStatusUrl with exponential backoff until terminal. Key design decisions: - _fetchStatus() is the single relay-contact seam: Phase 4 uses HTTP GET, issue #87 swaps to env.X402_RELAY.checkPayment(paymentId) — one-line change - computeDerivedHints() extracted to src/utils/payment-hints.ts so it is testable with bun:test without requiring cloudflare:workers runtime - DO instances are namespaced by paymentId (one per in-flight payment) - Alarm backoff: 5s (polls 1-3), 15s (4-6), 60s (7+), 10-min timeout Wiring: - wrangler.jsonc: PAYMENT_POLLING_DO binding + v3 migration tag in all envs - src/types.ts: Env interface updated with PAYMENT_POLLING_DO binding - src/index.ts: exports class; mounts GET /payment-status/:paymentId (free) Unit tests (36 passing): - computeDerivedHints covers all terminal reason categories - Stub-based track → poll → terminal happy-path flow - derivedHints per category verified Co-Authored-By: Claude <noreply@anthropic.com>
Middleware now mints a client-side paymentId ("pay_" + crypto.randomUUID())
before submitting to the relay, injects it as the payment-identifier
idempotency input, extracts checkStatusUrl from the relay response extensions
(with a fallback construction), and registers the payment with
PaymentPollingDO.track() as fire-and-forget.
Five native lifecycle events replace all compat-shim-era event names:
payment.initiated — paymentId minted, about to submit to relay
payment.pending — relay acknowledged but payment is still in-flight
payment.confirmed — relay settled successfully
payment.failed — relay rejected with a terminal failure reason
payment.replaced — payment replaced by another tx (nonce race)
Deleted entirely:
- extractCanonicalPaymentDetails() and all internal shim helpers
- inferLegacyStatus() and inferLegacyTerminalReason()
- getRetryDecisionContext() (tests/_shared_utils.ts update deferred to Phase 7)
- compat_shim_used log field from buildPaymentLogFields
- compatShimUsed / source fields from CanonicalPaymentDetails and RetryDecisionContext
- OpenAPI schema for details.canonical in 402 response body
Unit tests updated to cover the three surviving classifier predicates and
the revised instability derivation signature.
Co-Authored-By: Claude <noreply@anthropic.com>
All non-200 payment error responses now carry structured retry hints in
both the JSON body and the payment-response header (base64 JSON):
{ retryable, nextSteps, retryAfter? }
nextSteps tokens are stable identifiers tied to tx-schemas terminal
reason categories — not free-form prose — so clients can branch without
string-parsing:
rebuild_and_resign — sender nonce issue, build fresh tx
retry_later — transient relay/settlement error
start_new_payment — identity lost/replaced, restart x402 flow
fix_and_resend — invalid payload, fix before retrying
wait_for_confirmation — confirmed, delivery should proceed
classifyPaymentError() now checks canonical status (failed/replaced/
not_found) from the relay response before falling back to string
heuristics, so boring-tx relay responses are classified accurately.
Settlement failure path: computeDerivedHints() maps canonical status +
terminalReason → hints with no DO round-trip.
Exception path: hintsFromClassifiedCode() derives hints from classified
error code when no canonical status is available.
Updated llms-full.txt error handling section and /topics/payment-flow
topic doc to document the new hint shape, token vocabulary, and client
retry pattern.
Co-Authored-By: Claude <noreply@anthropic.com>
Finish the pending tests/_shared_utils.ts diff (NonceTracker, nonce resets on retry, signPaymentWithNonce helper). Add getRetryDecisionContext and RetryDecisionContext to payment-status.ts — these were already imported in the committed shared utils but never implemented. Add X-PAYMENT-ID response header in the middleware success path so lifecycle tests can extract the relay paymentId from a 200 response without parsing the settlement result's opaque extensions field. Add tests/payment-polling-lifecycle.test.ts (runPaymentPollingLifecycle): - Makes a real x402 payment to /hashing/sha256 - Reads X-PAYMENT-ID from the 200 response header - Polls GET /payment-status/:paymentId (free DO route) until terminal - Asserts the DO snapshot has expected shape (paymentId, checkStatusUrl, polledAt, pollCount, terminal status) Register payment-polling in LIFECYCLE_RUNNERS in _run_all_tests.ts. All 65 payment-*.unit.test.ts pass. npm test (quick mode, 14 stateless endpoints) passes 14/14. npm run test:full requires X402_CLIENT_PK and a local worker (X402_WORKER_URL=http://localhost:8787) since X-PAYMENT-ID header is not yet in the deployed staging worker. Co-Authored-By: Claude <noreply@anthropic.com>
Remove three dead imports from x402.ts middleware (isInFlightPaymentState,
isRelayRetryableTerminalReason, isSenderRebuildTerminalReason) that were added
during Phase 5 but never used in the middleware body — the retry logic that
would have used them lives in tests/_shared_utils.ts instead.
Clean up two stale phase-reference comments in payment-contract.ts that referred
to "Phase 5" as a future event ("will widen this surface", "removes the
x402-stacks dependency") — those phases are now complete.
TERMINAL_STATUSES duplication between payment-hints.ts and PaymentPollingDO.ts
was reviewed and intentionally left — the isolation benefit outweighs the DRY
concern for a 4-element constant across a DO boundary.
Co-Authored-By: Claude <noreply@anthropic.com>
All blocking checks pass: npm run check (0 errors), npm run deploy:dry-run (clean, PAYMENT_POLLING_DO binding confirmed), npm test (14/14), bun unit tests (114/114). Branch already on origin/main tip — no rebase needed. Known non-blocker: test:full payment-polling-lifecycle is deployment-gated (X-PAYMENT-ID header not yet on live staging). Co-Authored-By: Claude <noreply@anthropic.com>
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ❌ Deployment failed View logs |
x402-api-staging | 9442043 | Apr 23 2026, 08:04 AM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ❌ Deployment failed View logs |
x402-api-production | 8db0f7b | Apr 23 2026, 08:06 AM |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 94420430a3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (canonicalStatus === "failed") { | ||
| return { code: X402_ERROR_CODES.TRANSACTION_FAILED, message: "Payment failed in settlement relay", httpStatus: 402 }; | ||
| } | ||
| if (canonicalStatus === "replaced") { | ||
| return { code: X402_ERROR_CODES.TRANSACTION_FAILED, message: "Payment was replaced, start a new payment flow", httpStatus: 402 }; |
There was a problem hiding this comment.
Handle canonical in-flight statuses before string heuristics
This branch only classifies canonical statuses failed/replaced (and later not_found), so a relay response like { success:false, status:"queued" } falls through to the legacy text matching and can end up as a generic 500 unexpected_settle_error instead of 402 transaction_pending. That breaks the new native state-machine contract for in-flight payments and causes clients to treat retryable payment progress as a server fault.
Useful? React with 👍 / 👎.
| if (classified.code === X402_ERROR_CODES.TRANSACTION_PENDING) { | ||
| logPaymentEvent(log, "info", "payment.pending", { | ||
| route: c.req.path, | ||
| paymentId: canonical.paymentId, | ||
| status: canonical.status, | ||
| terminalReason: canonical.terminalReason, | ||
| checkStatusUrl: canonical.checkStatusUrl, | ||
| compatShimUsed: canonical.compatShimUsed, | ||
| paymentId, | ||
| status: "queued", |
There was a problem hiding this comment.
Register pending payments with the polling DO before 402
When settlement is classified as transaction_pending, the middleware logs payment.pending and returns a body with paymentId/checkStatusUrl, but it never calls PAYMENT_POLLING_DO.track() on this path (tracking only happens in the success branch). In this scenario /payment-status/:paymentId has no row and returns 404, so clients cannot poll the in-flight payment state the response advertises.
Useful? React with 👍 / 👎.
| } catch { | ||
| // Transient network error — reschedule and return cached state | ||
| await this._reschedule(pollCount); | ||
| return this._readSnapshot(paymentId); |
There was a problem hiding this comment.
Persist poll count on fetch errors to keep backoff exponential
In the _fetchStatus catch path, the code schedules using pollCount but does not update poll_count/polled_at in SQLite before returning. Repeated network failures therefore keep reloading the old DB value and rescheduling the first-delay interval, so the object can poll every 5s indefinitely instead of backing off, increasing relay load during outages.
Useful? React with 👍 / 👎.
arc0btc
left a comment
There was a problem hiding this comment.
Adopts the relay's native boring-tx state machine across all payment lifecycle paths, replacing the compat shim that inferred state from error strings. This is a significant piece of work — phased, well-documented, and executed cleanly.
What works well:
PaymentPollingDOdesign is solid: alarm-based backoff (5s/15s/60s), 10-min timeout guard, idempotenttrack()viaON CONFLICT DO NOTHING. The_fetchStatus()seam for issue #87 RPC swap is well-marked.computeDerivedHints()extracted topayment-hints.tsfor testability without CF runtime dependency — good isolation.classifyPaymentError()now correctly checks canonicalstatusfield before falling back to string heuristics — direct relay responses get accurate classification immediately.- The
.planning/commit trail documents every phase decision. The "Migration atomicity" note in the PR description is important — the DO class andwrangler.jsoncmigration tag must ship together. - 114 unit tests + deployment-gated lifecycle test. The gate is called out explicitly and the test itself is correct — no concern there.
[suggestion] poll() reads SELECT * instead of named columns (src/durable-objects/PaymentPollingDO.ts:243)
Since the DO is single-payment, this is fine for correctness. Worth tightening for clarity and to make the column contract explicit — especially since _rowToSnapshot does explicit field casting downstream. Not blocking.
[suggestion] extractCheckStatusUrl broad cast (src/middleware/x402.ts:136)
const ext = (settleResult as Record<string, unknown>).extensions as Record<string, unknown> | undefined;This is safe (read-only, guarded by typeof check), but the double cast obscures intent. Phase 5 removes x402-stacks dependency entirely — at that point this helper can be replaced with a typed accessor on the native relay response schema. Fine to leave as-is until then; just noting it.
[question] payment.pending branch: hardcoded status: "queued" (src/middleware/x402.ts:558)
When classified as TRANSACTION_PENDING, the log emits status: "queued" unconditionally. Is that the right value when the canonical status from the relay (if present) might be "broadcasting" or "mempool"? We've seen relay return those intermediate states in production. If canonicalStatus is already extracted at that point, passing it through would be more accurate.
Code quality notes:
TERMINAL_STATUSESduplication betweenPaymentPollingDO.tsandpayment-hints.tsis called out in the PR description as intentional isolation. Agree — DO boundary isolation outweighs DRY for a 4-element set.- Dead import cleanup in the final cleanup commit is clean. Three dead imports from
x402.tsremoved without ceremony.
Operational context:
We run ~80 x402 payments/day through this endpoint. The two open issues this closes (#93 transaction_held, #84) have been showing up in our production logs since April 1. The boring-tx native path with terminalReason will let us finally distinguish queue_unavailable (503 → retry_later) from broadcast_failure (502 → retry_later) from sender nonce issues (402 → rebuild_and_resign) in our alerting. Big improvement.
One external dependency to note: test:full payment-polling-lifecycle is gated on relay v1.30.1 being deployed. PR #349 on the relay closed and release-please PR #345 was still open as of our last check. Worth confirming relay deployment before running the full lifecycle suite post-merge.
Approve — this is production-ready.
There was a problem hiding this comment.
Pull request overview
This PR migrates x402-api payment handling to the relay’s native “boring-tx” state machine, adds Durable Object–backed payment polling, and standardizes structured lifecycle events + retry hints for agents.
Changes:
- Add
PaymentPollingDO(SQLite + alarms) and a freeGET /payment-status/:paymentIdroute to surface cached payment state. - Refactor x402 middleware to mint a
paymentId, emit updatedpayment.*lifecycle events, and attach structured retry hints (retryable,retryAfter,nextSteps) in error responses. - Upgrade
@aibtc/tx-schemasto^1.0.0and update tests/utilities to match the new lifecycle + hinting behavior.
Reviewed changes
Copilot reviewed 25 out of 26 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| wrangler.jsonc | Adds PAYMENT_POLLING_DO binding and v3 DO migration. |
| package.json | Bumps @aibtc/tx-schemas to ^1.0.0. |
| package-lock.json | Locks @aibtc/tx-schemas@1.0.0. |
| patches/x402-stacks+2.0.1.patch | Removes stale patch file. |
| src/services/payment-contract.ts | Centralizes tx-schemas re-exports/types for payment lifecycle. |
| src/utils/payment-hints.ts | Adds pure derived-hints computation from terminal status/reason. |
| src/utils/payment-status.ts | Simplifies retry-context extraction + keeps predicate helpers. |
| src/utils/payment-observability.ts | Removes compat-shim fields and updates instability derivation inputs. |
| src/durable-objects/PaymentPollingDO.ts | Implements alarm-based polling + cached status snapshots + derived hints. |
| src/middleware/x402.ts | Mints paymentId, emits new lifecycle events, returns structured hints, registers DO tracking, and adds X-PAYMENT-ID header. |
| src/index.ts | Adds free /payment-status/:paymentId route and bypasses x402 middleware for it. |
| src/types.ts | Adds PAYMENT_POLLING_DO binding and updates x402 context settle result type. |
| src/endpoints/schema.ts | Removes nested details.canonical schema block. |
| src/endpoints/ax-discovery.ts | Documents new payment error hints + token vocabulary. |
| tests/payment-status.unit.test.ts | Replaces adapter tests with predicate-focused tests. |
| tests/payment-middleware.unit.test.ts | Adds tests for canonical status classification + hint categories. |
| tests/payment-observability.unit.test.ts | Updates log-field expectations and adds instability classification coverage. |
| tests/payment-polling-do.unit.test.ts | Adds unit tests for hints + a stubbed DO lifecycle. |
| tests/payment-polling-lifecycle.test.ts | Adds end-to-end lifecycle test that polls /payment-status/:paymentId. |
| tests/_shared_utils.ts | Adds local nonce tracking + explicit-nonce signing for more reliable test payments. |
| tests/_run_all_tests.ts | Registers payment-polling lifecycle runner. |
| .planning/2026-04-22-boring-tx-state-machine/phases/01-research/PLAN.md | Adds planning artifacts for boring-tx adoption. |
| .planning/2026-04-22-boring-tx-state-machine/phases/01-research/NOTES.md | Adds research notes for the migration plan. |
| .planning/2026-04-22-boring-tx-state-machine/phases/02-deps/PLAN.md | Adds dependency/migration planning notes. |
| .planning/2026-04-22-boring-tx-state-machine/phases/09-verify/PLAN.md | Adds verification checklist/results. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const response = await fetch(checkStatusUrl, { | ||
| headers: { Accept: "application/json" }, | ||
| }); | ||
|
|
There was a problem hiding this comment.
_fetchStatus() treats any non-2xx from checkStatusUrl as a transient error and throws. If the relay’s status endpoint returns HTTP 404 for unknown/expired payments (common for status APIs), this DO will keep rescheduling until MAX_POLL_DURATION_MS and then mark internal_error instead of reaching the intended terminal not_found state. Consider special-casing 404 to record status: "not_found" (and terminalReason if available/derivable), and stop polling.
| if (response.status === 404) { | |
| return { | |
| status: "not_found", | |
| terminalReason: "not_found", | |
| }; | |
| } |
| if (canonicalStatus === "not_found") { | ||
| return { code: X402_ERROR_CODES.INVALID_TRANSACTION_STATE, message: "Payment identity expired or was not found, start a new payment flow", httpStatus: 402 }; | ||
| } | ||
|
|
There was a problem hiding this comment.
classifyPaymentError() only treats canonicalStatus as terminal when it is one of {failed,replaced,not_found}. If the boring-tx relay returns canonical in-flight statuses (e.g. queued/broadcasting/mempool) on the failure branch, this will fall through to legacy string heuristics and can be misclassified (often as UNEXPECTED_SETTLE_ERROR). Consider explicitly mapping canonical in-flight statuses to TRANSACTION_PENDING (with an appropriate Retry-After) before the string-matching fallback.
| const inFlightStatuses = new Set(["queued", "broadcasting", "mempool"]); | |
| if (canonicalStatus && inFlightStatuses.has(canonicalStatus)) { | |
| return { | |
| code: X402_ERROR_CODES.TRANSACTION_PENDING, | |
| message: "Payment is still being processed by the settlement relay", | |
| httpStatus: 202, | |
| retryAfter: 5, | |
| }; | |
| } |
| computeDerivedHints(canonicalStatus ?? "failed", terminalReason) ?? | ||
| hintsFromClassifiedCode(classified); |
There was a problem hiding this comment.
The hint selection currently does computeDerivedHints(canonicalStatus ?? "failed", terminalReason) ?? hintsFromClassifiedCode(...). If canonicalStatus is missing (legacy / non-boring-tx response), defaulting it to "failed" guarantees computeDerivedHints returns a non-null fallback (start_new_payment), so the classified-code hints are never used. To match the comment intent, only call computeDerivedHints when a canonical status was actually provided (or pass through undefined/non-terminal so it can return null).
| computeDerivedHints(canonicalStatus ?? "failed", terminalReason) ?? | |
| hintsFromClassifiedCode(classified); | |
| (canonicalStatus !== undefined | |
| ? computeDerivedHints(canonicalStatus, terminalReason) | |
| : null) ?? hintsFromClassifiedCode(classified); |
| return c.json( | ||
| { | ||
| error: classified.message, | ||
| code: classified.code, | ||
| retryable: exceptionHints.retryable, | ||
| nextSteps: exceptionHints.nextSteps, | ||
| ...(exceptionHints.retryAfter !== undefined ? { retryAfter: exceptionHints.retryAfter } : {}), | ||
| asset, | ||
| network: networkV2, |
There was a problem hiding this comment.
On the exception path (settle threw), the JSON error body omits paymentId (and status), even though a paymentId was minted earlier in this request. This means helpers like getRetryDecisionContext()/parseErrorResponse can’t extract retry context from the body. Consider including paymentId (and optionally a synthetic status) in this response for consistency with the non-success settleResult path.
| const res = await fetch(`${baseUrl}/extended/v1/address/${address}/nonces`); | ||
| const data = (await res.json()) as { possible_next_nonce: number }; | ||
| this.nextNonce = BigInt(data.possible_next_nonce); |
There was a problem hiding this comment.
NonceTracker.getAndIncrement() doesn’t check res.ok before calling res.json(), and it assumes the response always includes possible_next_nonce. If Hiro returns a non-200 (rate-limit, 5xx, invalid address), this will throw and leave the tracker in a partially-initialized state. Consider validating res.ok and the parsed payload, and throw a clearer error (or fall back to letting x402-stacks sign without a tracked nonce) to reduce test flakiness.
| const res = await fetch(`${baseUrl}/extended/v1/address/${address}/nonces`); | |
| const data = (await res.json()) as { possible_next_nonce: number }; | |
| this.nextNonce = BigInt(data.possible_next_nonce); | |
| const url = `${baseUrl}/extended/v1/address/${address}/nonces`; | |
| const res = await fetch(url); | |
| let data: unknown; | |
| try { | |
| data = await res.json(); | |
| } catch (error) { | |
| throw new Error( | |
| `Failed to parse Hiro nonce response for ${address} on ${network}: ${ | |
| error instanceof Error ? error.message : String(error) | |
| }`, | |
| ); | |
| } | |
| if (!res.ok) { | |
| const details = typeof data === "object" && data !== null | |
| ? JSON.stringify(data) | |
| : String(data); | |
| throw new Error( | |
| `Failed to fetch nonce from Hiro for ${address} on ${network}: ${res.status} ${res.statusText}${details ? ` - ${details}` : ""}`, | |
| ); | |
| } | |
| const possibleNextNonce = | |
| typeof data === "object" && data !== null && "possible_next_nonce" in data | |
| ? (data as { possible_next_nonce?: unknown }).possible_next_nonce | |
| : undefined; | |
| if ( | |
| typeof possibleNextNonce !== "number" || | |
| !Number.isSafeInteger(possibleNextNonce) || | |
| possibleNextNonce < 0 | |
| ) { | |
| throw new Error( | |
| `Invalid Hiro nonce response for ${address} on ${network}: missing or invalid possible_next_nonce`, | |
| ); | |
| } | |
| this.nextNonce = BigInt(possibleNextNonce); |
| const resultError = errorReason?.toLowerCase() || ""; | ||
| const combined = `${errorStr} ${resultError}`; | ||
|
|
There was a problem hiding this comment.
In the legacy string-heuristic fallback below, the later combined.includes("expired") || combined.includes("nonce") branch returns an "expired" message for any nonce-related errors. That makes nonce-conflict/stale/gap scenarios read like expiration. Consider splitting nonce-related matches into their own classification/message (or using terminalReason/category when available) so error text stays accurate.
| const resultError = errorReason?.toLowerCase() || ""; | |
| const combined = `${errorStr} ${resultError}`; | |
| const terminalReason = settleResult && "terminalReason" in settleResult | |
| ? (settleResult as { terminalReason?: string }).terminalReason | |
| : undefined; | |
| const errorCategory = settleResult && "category" in settleResult | |
| ? (settleResult as { category?: string }).category | |
| : undefined; | |
| const resultError = errorReason?.toLowerCase() || ""; | |
| const normalizedTerminalReason = terminalReason?.toLowerCase() || ""; | |
| const normalizedCategory = errorCategory?.toLowerCase() || ""; | |
| const combined = `${errorStr} ${resultError} ${normalizedTerminalReason} ${normalizedCategory}`; | |
| if ( | |
| combined.includes("nonce") || | |
| combined.includes("stale") || | |
| combined.includes("gap") || | |
| combined.includes("replacement") | |
| ) { | |
| return { code: X402_ERROR_CODES.TRANSACTION_FAILED, message: "Payment nonce conflict detected, start a new payment flow", httpStatus: 402 }; | |
| } |
| * Payment lifecycle events emitted: | ||
| * payment.initiated — paymentId minted, about to submit to relay | ||
| * payment.pending — relay accepted but payment is still in-flight | ||
| * payment.confirmed — relay settled successfully | ||
| * payment.failed — relay rejected with a terminal failure reason | ||
| * payment.replaced — payment was replaced by another tx (nonce race) |
There was a problem hiding this comment.
The PR description calls out payment.initiated, payment.poll, and payment.finalized events, but this header comment documents payment.pending/confirmed/failed/replaced instead. Align the documented event names with what the PR intends to ship (either update this comment or the PR description) to avoid downstream observability consumers keying off the wrong event names.
| \`\`\`json | ||
| { | ||
| "error": "Payment failed in settlement relay", | ||
| "code": "transaction_failed", | ||
| "retryable": true, | ||
| "nextSteps": "rebuild_and_resign", | ||
| "retryAfter": 30, | ||
| "paymentId": "pay_abc123", | ||
| "checkStatusUrl": "https://relay.example.com/payment/pay_abc123" | ||
| } |
There was a problem hiding this comment.
The example mixes nextSteps: "rebuild_and_resign" with retryAfter: 30, but the code/docs below state retryAfter is only present for the retry_later hint (and sender rebuild hints don’t include retryAfter). Update the example to reflect the actual hint shapes so client implementations don’t assume retryAfter is always present or coupled to rebuild_and_resign.
| return c.json( | ||
| { | ||
| error: classified.message, | ||
| code: classified.code, | ||
| ...(canonical?.paymentId ? { paymentId: canonical.paymentId } : {}), | ||
| ...(canonical?.status ? { status: canonical.status } : {}), | ||
| ...(canonical?.terminalReason ? { terminalReason: canonical.terminalReason } : {}), | ||
| ...(canonical?.retryable !== undefined ? { retryable: canonical.retryable } : {}), | ||
| ...(canonical?.checkStatusUrl ? { checkStatusUrl: canonical.checkStatusUrl } : {}), | ||
| retryable: settleHints.retryable, | ||
| nextSteps: settleHints.nextSteps, | ||
| ...(settleHints.retryAfter !== undefined ? { retryAfter: settleHints.retryAfter } : {}), | ||
| paymentId, | ||
| checkStatusUrl, | ||
| asset, | ||
| network: networkV2, | ||
| resource: c.req.path, | ||
| details: { | ||
| errorReason: settleResult.errorReason, | ||
| ...(canonical ? { canonical } : {}), | ||
| errorReason: errorReason ?? undefined, | ||
| }, |
There was a problem hiding this comment.
The 402 error JSON returned here no longer includes status or terminalReason, even though the OpenAPI schema (src/endpoints/schema.ts) documents both fields for payment-status errors and internal retry utilities can use status to detect in-flight states. If those fields are intentionally removed, consider updating the schema/docs accordingly; otherwise include status (canonicalStatus/classified) and terminalReason when available to keep the API contract consistent.
Summary
PaymentPollingDO— a Durable Object that registers every in-flight payment, pollscheckStatusUrlwith exponential backoff, and surfaces a/payment-status/:paymentIdroute for agents.payment.*events (payment.initiated,payment.poll,payment.finalized) with canonicalpaymentId,status, andterminalReasonfields — replacing the oldcompat_shim_used: truelog pattern.retryable,retryAfter, andnextStepsfields to payment error responses so agents know exactly what action to take next.@aibtc/tx-schemasenums and Zod schemas, replacing hand-rolled state checks and string matching.Closes
Closes #99
Closes #93
Closes #84
Addresses part of #85 (the error-response shape portion — remaining items in #85 stay open and are tracked separately).
Supersedes (prose — not auto-close keywords)
This PR supersedes #94 (
transaction_heldclassification via string-matching) and #106 (conflicting_nonceretry logic). Both of those approaches are now handled natively by the relay's boring-tx state machine:terminalReasonvalues map directly toTERMINAL_REASON_TO_CATEGORYandTERMINAL_REASON_CATEGORY_HANDLINGfrom@aibtc/tx-schemas. Authors of #94 and #106 have been asked to close those PRs once they've confirmed.References
#87 (stage-2 follow-up):
PaymentPollingDO._fetchStatus()is the single swap point for the RPC service binding. Once theX402_RELAYservice binding is configured, replace thefetch(checkStatusUrl)call inPaymentPollingDOwithenv.X402_RELAY.checkPayment(paymentId)— the method signature does not change and no other callers need updating.Verification
The following were run and passed before this PR was opened:
npm run check— TypeScript type-check cleannpm run deploy:dry-run— Cloudflare Worker build succeedspayment.initiated→payment.poll→payment.finalized) pass without live relayDeployment-gated note: The
test:fullpayment-polling lifecycle test asserts theX-PAYMENT-IDresponse header, which is only present after this branch is deployed to the staging environment (x402.aibtc.dev). Do not chase a failingX-PAYMENT-IDassertion locally against live staging until this branch is deployed. The test itself is correct — it is gated on deployment.Migration
This PR adds a Durable Object migration tag
v3(new_sqlite_classes: ["PaymentPollingDO"]). A single-PR merge must ship atomically with the migration — do not cherry-pick the DO files without thewrangler.jsoncmigration entry.