Skip to content

feat(news): derive paymentIdentifier from txHex for V2 RPC idempotency#626

Merged
whoabuddy merged 2 commits into
mainfrom
feat/payment-identifier-rpc-idempotency
Apr 23, 2026
Merged

feat(news): derive paymentIdentifier from txHex for V2 RPC idempotency#626
whoabuddy merged 2 commits into
mainfrom
feat/payment-identifier-rpc-idempotency

Conversation

@whoabuddy

Copy link
Copy Markdown
Contributor

Summary

  • Derives a deterministic paymentIdentifier from the transaction hex using Web Crypto SHA-256, producing pay_<28-hex-chars> per PaymentIdentifierSchema
  • Passes the identifier as the third arg to X402_RELAY.submitPayment(txHex, settle, paymentIdentifier) — same tx on retry → same identifier → relay returns cached paymentId idempotently
  • Maps PAYMENT_IDENTIFIER_CONFLICT to a non-retryable 402 (client error, no Retry-After header)
  • Bumps @aibtc/tx-schemas to ^1.1.0 (ships RpcSubmitPaymentRequestSchema.paymentIdentifier and PAYMENT_IDENTIFIER_CONFLICT error code)

Context

Closes #624. Brings agent-news RPC path to canonical x402 V2 idempotency parity — the same contract already live in:

Deterministic key composition

// In src/lib/helpers.ts — derivePaymentIdentifier(txHex)
const input = txHex;  // same tx on retry → same identifier
const hashHex = sha256hex(input);
return `pay_${hashHex.slice(0, 28)}`;  // 32 chars, satisfies [a-zA-Z0-9_-]{16,128}

The identifier is derived from txHex alone (extracted from the payment header inside verifyPayment()). The same pre-signed transaction resubmitted on network timeout produces an identical identifier, allowing the relay to return the cached paymentId without duplicate nonce consumption.

Test plan

  • derivePaymentIdentifier is deterministic for the same inputs
  • derivePaymentIdentifier produces different outputs for different inputs
  • Identifier matches ^pay_[a-f0-9]{28}$ (satisfies PaymentIdentifierSchema)
  • submitPayment receives the derived identifier as third arg (mock-verified)
  • Retry with the same txHex produces the same identifier (determinism across calls)
  • PAYMENT_IDENTIFIER_CONFLICT maps to 402 non-retryable with no Retry-After
  • All existing x402-rpc, x402-error-mapping, and helpers tests still pass (60 total)

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings April 23, 2026 08:47
@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
agent-news 158a20f Apr 23 2026, 08:47 AM

@cloudflare-workers-and-pages

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

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 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>
@whoabuddy whoabuddy force-pushed the feat/payment-identifier-rpc-idempotency branch from 158a20f to c49f174 Compare April 23, 2026 08:48

@arc0btc arc0btc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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:

  • derivePaymentIdentifier is clean: pure SHA-256 on txHex, variadic for future flexibility, and the pay_<28-hex> format satisfies PaymentIdentifierSchema exactly.
  • Using txHex as the sole input at the call site is the right choice — same pre-signed tx on retry → same identifier → relay returns cached paymentId without 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_CONFLICT as non-retryable (no Retry-After header) 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 in parts.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 ...parts API 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>
@github-actions

Copy link
Copy Markdown
Contributor

Preview deployed: https://agent-news-staging.hosting-962.workers.dev

This preview uses sample data — beats, signals, and streaks are seeded automatically.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 to X402_RELAY.submitPayment(...).
  • Map PAYMENT_IDENTIFIER_CONFLICT to a non-retryable 402 response (no Retry-After).
  • Bump @aibtc/tx-schemas to ^1.1.0 and 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.

Comment thread src/lib/helpers.ts
* (e.g. beatId, senderAddress, nonce or txHex)
*/
export async function derivePaymentIdentifier(...parts: string[]): Promise<string> {
const input = parts.join("|");

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

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

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.

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

Copilot uses AI. Check for mistakes.
Comment thread src/services/x402.ts
Comment on lines +458 to +466
// 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));

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.

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

Copy link
Copy Markdown

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

Comment thread src/lib/helpers.ts
* (e.g. beatId, senderAddress, nonce or txHex)
*/
export async function derivePaymentIdentifier(...parts: string[]): Promise<string> {
const input = parts.join("|");

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

@whoabuddy whoabuddy merged commit e132c08 into main Apr 23, 2026
7 checks passed
@whoabuddy whoabuddy deleted the feat/payment-identifier-rpc-idempotency branch April 23, 2026 08:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

news-do: send paymentIdentifier with X402_RELAY.submitPayment for V2-parity idempotency

3 participants