Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/core/enums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
10 changes: 10 additions & 0 deletions src/core/primitives.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
);
5 changes: 4 additions & 1 deletion src/http/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
AmountStringSchema,
IsoDateTimeSchema,
NonNegativeIntegerSchema,
PaymentIdentifierSchema,
PaymentIdSchema,
PositiveIntegerSchema,
StacksAddressSchema,
Expand Down Expand Up @@ -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,
}),
});
Comment on lines +49 to 55

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.

Switching HttpPaymentIdentifierExtensionSchema.info.id from PaymentIdSchema to PaymentIdentifierSchema is a stricter validation change for some previously-accepted inputs (e.g., "pay_123" would have parsed before but now fails due to the 16+ length requirement). That makes the PR not purely additive as described; either (a) update the backward-compatibility notes/versioning to reflect the tightened HTTP validation, or (b) consider accepting both schemas here (e.g., union) if you need to preserve old callers while migrating.

Copilot uses AI. Check for mistakes.

Expand Down
4 changes: 4 additions & 0 deletions src/rpc/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
AmountStringSchema,
IsoDateTimeSchema,
NonNegativeIntegerSchema,
PaymentIdentifierSchema,
PaymentIdSchema,
PositiveIntegerSchema,
StacksAddressSchema,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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({
Expand Down
57 changes: 57 additions & 0 deletions tests/rpc.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
RpcCheckPaymentResultSchema,
RpcSubmitPaymentRequestSchema,
RpcSubmitPaymentResultSchema,
} from "../src/index.js";

Expand Down Expand Up @@ -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");
Comment on lines +151 to +153

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.

The acceptance test for PaymentIdentifierSchema uses a value with a "pay_" prefix, which can unintentionally suggest that the prefix is required (even though PaymentIdentifierSchema is meant to be distinct from relay-assigned PaymentIdSchema). Consider using a clearly caller-generated identifier without the "pay_" prefix in this test to better capture the intended contract.

Suggested change
paymentIdentifier: "pay_01JMVP9QE8XA3BDGM5",
});
expect(req.paymentIdentifier).toBe("pay_01JMVP9QE8XA3BDGM5");
paymentIdentifier: "client_01JMVP9QE8XA3BDGM5",
});
expect(req.paymentIdentifier).toBe("client_01JMVP9QE8XA3BDGM5");

Copilot uses AI. Check for mistakes.
});

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");
});
});
});