feat(news): derive paymentIdentifier from txHex for V2 RPC idempotency#626
Conversation
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ❌ Deployment failed View logs |
agent-news | 158a20f | Apr 23 2026, 08:47 AM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
agent-news | 26ed125 | Apr 23 2026, 08:51 AM |
closes #624) Bring agent-news RPC path to canonical x402 V2 idempotency parity: - Add derivePaymentIdentifier(...parts) to src/lib/helpers.ts using Web Crypto subtle.digest("SHA-256") — produces pay_<28-hex-chars>, satisfying PaymentIdentifierSchema [a-zA-Z0-9_-]{16,128}. - Derive identifier from txHex inside verifyPayment() and pass as third arg to X402_RELAY.submitPayment(txHex, settle, paymentIdentifier). Same tx resubmitted on retry → same identifier → relay returns cached paymentId without double-counting the nonce. - Update RelayRPC interface in src/lib/types.ts with optional third param. - Map PAYMENT_IDENTIFIER_CONFLICT to unknown_payment_identity terminal reason (non-retryable 402) in RPC_ERROR_TO_TERMINAL_REASON. - Bump @aibtc/tx-schemas to ^1.1.0 which ships RpcSubmitPaymentRequestSchema with optional paymentIdentifier field and PAYMENT_IDENTIFIER_CONFLICT code. - Tests: derivePaymentIdentifier determinism, charset, idempotency; submitPayment receives pay_<hex> as third arg; PAYMENT_IDENTIFIER_CONFLICT mapped to 402 non-retryable with no Retry-After header. Depends-on: aibtcdev/tx-schemas#28, aibtcdev/x402-sponsor-relay#355 Co-Authored-By: Claude <noreply@anthropic.com>
158a20f to
c49f174
Compare
arc0btc
left a comment
There was a problem hiding this comment.
Brings the agent-news RPC path to V2 idempotency parity — same pattern already live in the relay and tx-schemas, and this is the correct client-side half.
What works well:
derivePaymentIdentifieris clean: pure SHA-256 ontxHex, variadic for future flexibility, and thepay_<28-hex>format satisfiesPaymentIdentifierSchemaexactly.- Using
txHexas the sole input at the call site is the right choice — same pre-signed tx on retry → same identifier → relay returns cachedpaymentIdwithout consuming a new nonce. - Test coverage is thorough: determinism, format, single-arg production use case, and the new error code path all covered. 60 existing tests still pass.
- Mapping
PAYMENT_IDENTIFIER_CONFLICTas non-retryable (noRetry-Afterheader) is correct — this is a client-side protocol error, not a transient relay failure.
[question] Terminal reason label (src/services/x402.ts, RPC_ERROR_TO_TERMINAL_REASON map)
The value "unknown_payment_identity" reads as "we don't know who this payment is from" rather than "idempotency key conflict." This affects internal tracking/logging, not the HTTP response, but an ambiguous terminal reason makes post-hoc debugging harder. Something like "identifier_conflict" would be clearer. Is PaymentTerminalReason in tx-schemas 1.1.0 constrained to specific values, or is it open? If constrained, TypeScript would have caught a mismatch, so I assume it compiled — but worth confirming the label is intentional.
Code quality notes:
- The
|" separator inparts.join("|")" is safe for hex and address inputs, but worth a brief comment that|was chosen deliberately as a separator that won't appear in hex strings, in case the multi-arg API gets extended later. - Minor: the variadic
...partsAPI is flexible but the sole production call site uses a single arg. No YAGNI issue since the tests exercise multi-arg and the helper is general-purpose.
Operational note: We process x402 payments daily and the double-submission risk on network timeout is real — we've seen retry storms hit the relay when RPC calls don't get an ack. This fix eliminates that class of nonce double-spend at the source. The major version bump to @aibtc/tx-schemas (0.5.2 → 1.1.0) is expected given tx-schemas#29.
npm install on Node 24 / npm 11 pruned these optional peer deps from the lock file. CI runs on Node 22 / npm 10 which enforces them. Restore the entries verbatim from main to keep npm ci working on Node 22. Co-Authored-By: Claude <noreply@anthropic.com>
|
Preview deployed: https://agent-news-staging.hosting-962.workers.dev This preview uses sample data — beats, signals, and streaks are seeded automatically. |
There was a problem hiding this comment.
Pull request overview
Adds deterministic idempotency support to the x402 RPC payment submission path by deriving and forwarding a paymentIdentifier, and handling the new relay conflict error code.
Changes:
- Derive a deterministic
paymentIdentifier(SHA-256-based,pay_<28 hex>) and pass it as the 3rd arg toX402_RELAY.submitPayment(...). - Map
PAYMENT_IDENTIFIER_CONFLICTto a non-retryable 402 response (noRetry-After). - Bump
@aibtc/tx-schemasto^1.1.0and add/adjust tests for identifier derivation and error mapping.
Reviewed changes
Copilot reviewed 7 out of 8 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/services/x402.ts | Derives and forwards paymentIdentifier on RPC submit; maps new conflict code. |
| src/lib/types.ts | Updates RelayRPC.submitPayment signature/docs to include optional paymentIdentifier. |
| src/lib/helpers.ts | Adds derivePaymentIdentifier() helper using Web Crypto SHA-256. |
| src/tests/x402-rpc.test.ts | Verifies identifier is passed/deterministic and conflict handling behavior. |
| src/tests/x402-error-mapping.test.ts | Asserts PAYMENT_IDENTIFIER_CONFLICT → 402 non-retryable (no headers). |
| src/tests/helpers.test.ts | Adds unit tests for derivePaymentIdentifier() format/determinism/constraints. |
| package.json | Bumps @aibtc/tx-schemas to ^1.1.0. |
| package-lock.json | Locks @aibtc/tx-schemas@1.1.0 and updates dependency tree accordingly. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| * (e.g. beatId, senderAddress, nonce or txHex) | ||
| */ | ||
| export async function derivePaymentIdentifier(...parts: string[]): Promise<string> { | ||
| const input = parts.join("|"); |
There was a problem hiding this comment.
derivePaymentIdentifier() builds the hash input using parts.join("|"). This encoding is ambiguous if any part can contain "|" (or if empty strings are present), allowing different part arrays to collide to the same input string (e.g., ["a|b","c"] vs ["a","b|c"]). Since this function is intended to produce a unique idempotency key from components, consider switching to an unambiguous encoding (e.g., JSON.stringify(parts) or length-prefixed concatenation) and rejecting empty parts/empty input to avoid accidental constant identifiers.
| const input = parts.join("|"); | |
| if (parts.length === 0) { | |
| throw new Error("derivePaymentIdentifier requires at least one non-empty part"); | |
| } | |
| if (parts.some((part) => part.length === 0)) { | |
| throw new Error("derivePaymentIdentifier parts must be non-empty strings"); | |
| } | |
| const input = JSON.stringify(parts); |
| // Derive a deterministic idempotency key from the transaction hex. | ||
| // Same tx resubmitted on retry → same identifier → relay returns cached paymentId. | ||
| const paymentIdentifier = await derivePaymentIdentifier(txHex); | ||
|
|
||
| // Step 1: Submit the payment to the relay queue. | ||
| let submitResult: SubmitPaymentResult; | ||
| try { | ||
| console.log("[x402] using RPC path via X402_RELAY service binding"); | ||
| submitResult = parseSubmitPaymentResult(await env.X402_RELAY.submitPayment(txHex, settle)); | ||
| submitResult = parseSubmitPaymentResult(await env.X402_RELAY.submitPayment(txHex, settle, paymentIdentifier)); |
There was a problem hiding this comment.
verifyPayment() awaits derivePaymentIdentifier(txHex) outside of the submitPayment try/catch. If Web Crypto isn't available or digest() rejects, verifyPayment will throw and bypass the structured {valid:false, relayError:true|false} result handling. Consider wrapping identifier derivation in its own try/catch (and either proceed without a paymentIdentifier or return a relayError) so payment verification never fails with an unhandled exception.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 158a20f6d5
ℹ️ 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".
| * (e.g. beatId, senderAddress, nonce or txHex) | ||
| */ | ||
| export async function derivePaymentIdentifier(...parts: string[]): Promise<string> { | ||
| const input = parts.join("|"); |
There was a problem hiding this comment.
Canonicalize tx hex before deriving paymentIdentifier
derivePaymentIdentifier hashes the raw input string, but transaction hex is case-insensitive; the same signed transaction can legitimately appear with different letter casing across retries. In that case, this code generates different paymentIdentifier values for equivalent transactions, which breaks the new idempotency guarantee and can allow duplicate relay submissions instead of returning the cached paymentId.
Useful? React with 👍 / 👎.
Summary
paymentIdentifierfrom the transaction hex using Web Crypto SHA-256, producingpay_<28-hex-chars>perPaymentIdentifierSchemaX402_RELAY.submitPayment(txHex, settle, paymentIdentifier)— same tx on retry → same identifier → relay returns cachedpaymentIdidempotentlyPAYMENT_IDENTIFIER_CONFLICTto a non-retryable 402 (client error, no Retry-After header)@aibtc/tx-schemasto^1.1.0(shipsRpcSubmitPaymentRequestSchema.paymentIdentifierandPAYMENT_IDENTIFIER_CONFLICTerror code)Context
Closes #624. Brings agent-news RPC path to canonical x402 V2 idempotency parity — the same contract already live in:
submitPaymentthroughPaymentIdServiceRpcSubmitPaymentRequestSchema.paymentIdentifierfield +PAYMENT_IDENTIFIER_CONFLICTerror code (shipped as tx-schemas 1.1.0)Deterministic key composition
The identifier is derived from
txHexalone (extracted from the payment header insideverifyPayment()). The same pre-signed transaction resubmitted on network timeout produces an identical identifier, allowing the relay to return the cachedpaymentIdwithout duplicate nonce consumption.Test plan
derivePaymentIdentifieris deterministic for the same inputsderivePaymentIdentifierproduces different outputs for different inputs^pay_[a-f0-9]{28}$(satisfiesPaymentIdentifierSchema)submitPaymentreceives the derived identifier as third arg (mock-verified)PAYMENT_IDENTIFIER_CONFLICTmaps to 402 non-retryable with no Retry-After🤖 Generated with Claude Code