Skip to content

feat(payments): adopt native boring-tx state machine#107

Open
whoabuddy wants to merge 10 commits intomainfrom
feat/boring-tx-state-machine
Open

feat(payments): adopt native boring-tx state machine#107
whoabuddy wants to merge 10 commits intomainfrom
feat/boring-tx-state-machine

Conversation

@whoabuddy
Copy link
Copy Markdown
Contributor

Summary

  • Adopts the relay's native boring-tx state machine, eliminating the compat shim that inferred payment state from relay error strings.
  • Adds PaymentPollingDO — a Durable Object that registers every in-flight payment, polls checkStatusUrl with exponential backoff, and surfaces a /payment-status/:paymentId route for agents.
  • Emits structured payment.* events (payment.initiated, payment.poll, payment.finalized) with canonical paymentId, status, and terminalReason fields — replacing the old compat_shim_used: true log pattern.
  • Adds retryable, retryAfter, and nextSteps fields to payment error responses so agents know exactly what action to take next.
  • Routes all payment types through @aibtc/tx-schemas enums 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_held classification via string-matching) and #106 (conflicting_nonce retry logic). Both of those approaches are now handled natively by the relay's boring-tx state machine: terminalReason values map directly to TERMINAL_REASON_TO_CATEGORY and TERMINAL_REASON_CATEGORY_HANDLING from @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 the X402_RELAY service binding is configured, replace the fetch(checkStatusUrl) call in PaymentPollingDO with env.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 clean
  • npm run deploy:dry-run — Cloudflare Worker build succeeds
  • Unit tests covering boring-tx lifecycle (payment.initiatedpayment.pollpayment.finalized) pass without live relay

Deployment-gated note: The test:full payment-polling lifecycle test asserts the X-PAYMENT-ID response header, which is only present after this branch is deployed to the staging environment (x402.aibtc.dev). Do not chase a failing X-PAYMENT-ID assertion 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 the wrangler.jsonc migration entry.

whoabuddy and others added 9 commits April 23, 2026 00:08
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>
Copilot AI review requested due to automatic review settings April 23, 2026 08:04
@cloudflare-workers-and-pages
Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
❌ Deployment failed
View logs
x402-api-staging 9442043 Apr 23 2026, 08:04 AM

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented Apr 23, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
❌ Deployment failed
View logs
x402-api-production 8db0f7b Apr 23 2026, 08:06 AM

Record PR title, body, issue comment text, and all URLs for the
boring-tx state machine PR (#107) and related issue comments on
#94, #106, and #87.

Co-Authored-By: Claude <noreply@anthropic.com>
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/middleware/x402.ts
Comment on lines +155 to +159
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 };
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread src/middleware/x402.ts
Comment on lines +574 to +578
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",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment on lines +188 to +191
} catch {
// Transient network error — reschedule and return cached state
await this._reschedule(pollCount);
return this._readSnapshot(paymentId);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor

@arc0btc arc0btc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  • PaymentPollingDO design is solid: alarm-based backoff (5s/15s/60s), 10-min timeout guard, idempotent track() via ON CONFLICT DO NOTHING. The _fetchStatus() seam for issue #87 RPC swap is well-marked.
  • computeDerivedHints() extracted to payment-hints.ts for testability without CF runtime dependency — good isolation.
  • classifyPaymentError() now correctly checks canonical status field 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 and wrangler.jsonc migration 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_STATUSES duplication between PaymentPollingDO.ts and payment-hints.ts is 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.ts removed 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.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 free GET /payment-status/:paymentId route to surface cached payment state.
  • Refactor x402 middleware to mint a paymentId, emit updated payment.* lifecycle events, and attach structured retry hints (retryable, retryAfter, nextSteps) in error responses.
  • Upgrade @aibtc/tx-schemas to ^1.0.0 and 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" },
});

Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_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.

Suggested change
if (response.status === 404) {
return {
status: "not_found",
terminalReason: "not_found",
};
}

Copilot uses AI. Check for mistakes.
Comment thread src/middleware/x402.ts
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 };
}

Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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,
};
}

Copilot uses AI. Check for mistakes.
Comment thread src/middleware/x402.ts
Comment on lines +625 to +626
computeDerivedHints(canonicalStatus ?? "failed", terminalReason) ??
hintsFromClassifiedCode(classified);
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
computeDerivedHints(canonicalStatus ?? "failed", terminalReason) ??
hintsFromClassifiedCode(classified);
(canonicalStatus !== undefined
? computeDerivedHints(canonicalStatus, terminalReason)
: null) ?? hintsFromClassifiedCode(classified);

Copilot uses AI. Check for mistakes.
Comment thread src/middleware/x402.ts
Comment on lines 540 to 548
return c.json(
{
error: classified.message,
code: classified.code,
retryable: exceptionHints.retryable,
nextSteps: exceptionHints.nextSteps,
...(exceptionHints.retryAfter !== undefined ? { retryAfter: exceptionHints.retryAfter } : {}),
asset,
network: networkV2,
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread tests/_shared_utils.ts
Comment on lines +482 to +484
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);
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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);

Copilot uses AI. Check for mistakes.
Comment thread src/middleware/x402.ts
Comment on lines +171 to 173
const resultError = errorReason?.toLowerCase() || "";
const combined = `${errorStr} ${resultError}`;

Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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 };
}

Copilot uses AI. Check for mistakes.
Comment thread src/middleware/x402.ts
Comment on lines +7 to +12
* 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)
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +670 to +679
\`\`\`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"
}
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/middleware/x402.ts
Comment on lines 629 to 643
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,
},
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants