diff --git a/src/core/enums.ts b/src/core/enums.ts index a428d16..e64daca 100644 --- a/src/core/enums.ts +++ b/src/core/enums.ts @@ -162,7 +162,9 @@ export const CanonicalDomainBoundary = { ] as const, }, transportBoundaries: { - sharedDomain: ["state", "category", "terminal-reason", "paymentId ownership"] as const, + // paymentIdentifier is the shared idempotency input across both transports: + // HTTP uses payment-identifier extension; RPC uses paymentIdentifier field. + sharedDomain: ["state", "category", "terminal-reason", "paymentId ownership", "paymentIdentifier idempotency"] as const, rpc: ["service-binding request/response shapes", "internal relay error codes"] as const, http: ["x402 request/response shapes", "polling and error envelopes"] as const, }, diff --git a/src/core/primitives.ts b/src/core/primitives.ts index 87a6b3c..9f1f75a 100644 --- a/src/core/primitives.ts +++ b/src/core/primitives.ts @@ -35,3 +35,13 @@ export const NonNegativeIntegerSchema = z .nonnegative(); export const UrlSchema = z.string().url(); + +// Caller-controlled idempotency key for x402 V2 payment-identifier extension. +// Charset and length match the V2 spec: [a-zA-Z0-9_-]{16,128}. +// Distinct from PaymentIdSchema (relay-assigned, pay_ prefix); this is caller-provided. +export const PaymentIdentifierSchema = z + .string() + .regex( + /^[a-zA-Z0-9_-]{16,128}$/, + "Expected a caller-provided payment identifier: [a-zA-Z0-9_-]{16,128}", + ); diff --git a/src/http/schemas.ts b/src/http/schemas.ts index da370a1..21deb19 100644 --- a/src/http/schemas.ts +++ b/src/http/schemas.ts @@ -5,6 +5,7 @@ import { AmountStringSchema, IsoDateTimeSchema, NonNegativeIntegerSchema, + PaymentIdentifierSchema, PaymentIdSchema, PositiveIntegerSchema, StacksAddressSchema, @@ -45,9 +46,11 @@ export const HTTP_VERIFY_INVALID_REASONS = [ export const HttpVerifyInvalidReasonSchema = z.enum(HTTP_VERIFY_INVALID_REASONS); +// Uses PaymentIdentifierSchema rather than PaymentIdSchema: extension ids are caller-provided, +// not relay-assigned, so the pay_ prefix requirement was inappropriate. export const HttpPaymentIdentifierExtensionSchema = z.object({ info: z.object({ - id: PaymentIdSchema, + id: PaymentIdentifierSchema, }), }); diff --git a/src/rpc/schemas.ts b/src/rpc/schemas.ts index 58b3074..d14cf69 100644 --- a/src/rpc/schemas.ts +++ b/src/rpc/schemas.ts @@ -5,6 +5,7 @@ import { AmountStringSchema, IsoDateTimeSchema, NonNegativeIntegerSchema, + PaymentIdentifierSchema, PaymentIdSchema, PositiveIntegerSchema, StacksAddressSchema, @@ -37,6 +38,7 @@ export const RPC_ERROR_CODES = [ "BROADCAST_RATE_LIMITED", "SENDER_HAND_EXPIRED", "NONCE_OCCUPIED", + "PAYMENT_IDENTIFIER_CONFLICT", ] as const; export const RpcErrorCodeSchema = z.enum(RPC_ERROR_CODES); @@ -73,6 +75,8 @@ export const RpcSubmitPaymentWarningSchema = z.object({ export const RpcSubmitPaymentRequestSchema = z.object({ txHex: TransactionHexSchema, settle: RpcSettleOptionsSchema.optional(), + // Caller-controlled idempotency key; see PaymentIdentifierSchema for charset/length constraints. + paymentIdentifier: PaymentIdentifierSchema.optional(), }); export const RpcSubmitPaymentAcceptedSchema = z.object({ diff --git a/tests/rpc.test.ts b/tests/rpc.test.ts index 9bb1a84..854c95d 100644 --- a/tests/rpc.test.ts +++ b/tests/rpc.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { RpcCheckPaymentResultSchema, + RpcSubmitPaymentRequestSchema, RpcSubmitPaymentResultSchema, } from "../src/index.js"; @@ -140,4 +141,60 @@ describe("rpc schemas", () => { "https://example.com/payment/pay_01JMVP9QE8XA3BDGM5RN7KWTZ4", ); }); + + describe("paymentIdentifier — x402 V2 idempotency parity", () => { + const STUB_TX_HEX = "0x" + "ab".repeat(32); + + it("accepts a submit request with an optional paymentIdentifier", () => { + const req = RpcSubmitPaymentRequestSchema.parse({ + txHex: STUB_TX_HEX, + paymentIdentifier: "pay_01JMVP9QE8XA3BDGM5", + }); + expect(req.paymentIdentifier).toBe("pay_01JMVP9QE8XA3BDGM5"); + }); + + it("accepts a submit request without paymentIdentifier (backward compat)", () => { + const req = RpcSubmitPaymentRequestSchema.parse({ txHex: STUB_TX_HEX }); + expect(req.paymentIdentifier).toBeUndefined(); + }); + + it("rejects a paymentIdentifier shorter than 16 chars", () => { + const result = RpcSubmitPaymentRequestSchema.safeParse({ + txHex: STUB_TX_HEX, + paymentIdentifier: "short", + }); + expect(result.success).toBe(false); + }); + + it("rejects a paymentIdentifier longer than 128 chars", () => { + const result = RpcSubmitPaymentRequestSchema.safeParse({ + txHex: STUB_TX_HEX, + paymentIdentifier: "a".repeat(129), + }); + expect(result.success).toBe(false); + }); + + it("rejects a paymentIdentifier with disallowed characters", () => { + const result = RpcSubmitPaymentRequestSchema.safeParse({ + txHex: STUB_TX_HEX, + paymentIdentifier: "invalid identifier!", + }); + expect(result.success).toBe(false); + }); + + it("accepts PAYMENT_IDENTIFIER_CONFLICT as a rejected submit error code", () => { + const result = RpcSubmitPaymentResultSchema.parse({ + accepted: false, + error: "Same paymentIdentifier submitted with a different transaction", + code: "PAYMENT_IDENTIFIER_CONFLICT", + retryable: false, + }); + + expect(result.accepted).toBe(false); + if (result.accepted) { + throw new Error("Expected a rejected RPC submit result"); + } + expect(result.code).toBe("PAYMENT_IDENTIFIER_CONFLICT"); + }); + }); });