Skip to content

Commit 159ad69

Browse files
whoabuddyclaude
andauthored
feat(rpc): add optional paymentIdentifier to RpcSubmitPaymentArgsSchema for V2 parity (#29)
* feat(rpc): add optional paymentIdentifier to RpcSubmitPaymentArgsSchema for V2 parity Resolves #28. Adds canonical x402 V2 idempotency parity to the RPC transport: - **`PaymentIdentifierSchema`** (new, `src/core/primitives.ts`): shared caller-controlled idempotency key schema, `[a-zA-Z0-9_-]{16,128}`. Distinct from `PaymentIdSchema` (relay-assigned, `pay_` prefix). Exported from `@aibtc/tx-schemas/core`. - **`RpcSubmitPaymentRequestSchema.paymentIdentifier`** (new, optional): lets callers attach a client-controlled idempotency key to `submitPayment`. Same identifier + same `txHex` reuses the existing `paymentId`; same identifier + different `txHex` is rejected with the new error code. Existing callers that omit the field are unaffected. - **`RPC_PAYMENT_IDENTIFIER_CONFLICT`** (new error code): RPC-transport parity for the HTTP-side `payment_identifier_conflict` error reason. Follows the `RPC_*` naming convention already used in `RpcErrorCodeSchema`. - **`CanonicalDomainBoundary.transportBoundaries.sharedDomain`** updated to include `"paymentIdentifier idempotency"`, reflecting that the idempotency input is now shared across both HTTP (payment-identifier extension) and RPC transports. - **`HttpPaymentIdentifierExtensionSchema`** DRYed: extension `.info.id` now uses the new shared `PaymentIdentifierSchema` instead of the relay-assigned `PaymentIdSchema`. Downstream: relay PR #351 will consume `paymentIdentifier` in `submitPayment`; landing-page/#635 and agent-news/#624 adopt after. Closes #28 Co-Authored-By: Claude <noreply@anthropic.com> * refactor(rpc): normalize error code name and reduce test repetition - Rename RPC_PAYMENT_IDENTIFIER_CONFLICT → PAYMENT_IDENTIFIER_CONFLICT to match the bare naming convention of all other RPC error codes in the array (none carry an RPC_ prefix; the schema name already provides that context) - Extract repeated stub txHex literal to a STUB_TX_HEX const in the paymentIdentifier test suite - Trim inline comments to non-obvious WHY only Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 154603e commit 159ad69

5 files changed

Lines changed: 78 additions & 2 deletions

File tree

‎src/core/enums.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,9 @@ export const CanonicalDomainBoundary = {
162162
] as const,
163163
},
164164
transportBoundaries: {
165-
sharedDomain: ["state", "category", "terminal-reason", "paymentId ownership"] as const,
165+
// paymentIdentifier is the shared idempotency input across both transports:
166+
// HTTP uses payment-identifier extension; RPC uses paymentIdentifier field.
167+
sharedDomain: ["state", "category", "terminal-reason", "paymentId ownership", "paymentIdentifier idempotency"] as const,
166168
rpc: ["service-binding request/response shapes", "internal relay error codes"] as const,
167169
http: ["x402 request/response shapes", "polling and error envelopes"] as const,
168170
},

‎src/core/primitives.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,13 @@ export const NonNegativeIntegerSchema = z
3535
.nonnegative();
3636

3737
export const UrlSchema = z.string().url();
38+
39+
// Caller-controlled idempotency key for x402 V2 payment-identifier extension.
40+
// Charset and length match the V2 spec: [a-zA-Z0-9_-]{16,128}.
41+
// Distinct from PaymentIdSchema (relay-assigned, pay_ prefix); this is caller-provided.
42+
export const PaymentIdentifierSchema = z
43+
.string()
44+
.regex(
45+
/^[a-zA-Z0-9_-]{16,128}$/,
46+
"Expected a caller-provided payment identifier: [a-zA-Z0-9_-]{16,128}",
47+
);

‎src/http/schemas.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
AmountStringSchema,
66
IsoDateTimeSchema,
77
NonNegativeIntegerSchema,
8+
PaymentIdentifierSchema,
89
PaymentIdSchema,
910
PositiveIntegerSchema,
1011
StacksAddressSchema,
@@ -45,9 +46,11 @@ export const HTTP_VERIFY_INVALID_REASONS = [
4546

4647
export const HttpVerifyInvalidReasonSchema = z.enum(HTTP_VERIFY_INVALID_REASONS);
4748

49+
// Uses PaymentIdentifierSchema rather than PaymentIdSchema: extension ids are caller-provided,
50+
// not relay-assigned, so the pay_ prefix requirement was inappropriate.
4851
export const HttpPaymentIdentifierExtensionSchema = z.object({
4952
info: z.object({
50-
id: PaymentIdSchema,
53+
id: PaymentIdentifierSchema,
5154
}),
5255
});
5356

‎src/rpc/schemas.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
AmountStringSchema,
66
IsoDateTimeSchema,
77
NonNegativeIntegerSchema,
8+
PaymentIdentifierSchema,
89
PaymentIdSchema,
910
PositiveIntegerSchema,
1011
StacksAddressSchema,
@@ -37,6 +38,7 @@ export const RPC_ERROR_CODES = [
3738
"BROADCAST_RATE_LIMITED",
3839
"SENDER_HAND_EXPIRED",
3940
"NONCE_OCCUPIED",
41+
"PAYMENT_IDENTIFIER_CONFLICT",
4042
] as const;
4143

4244
export const RpcErrorCodeSchema = z.enum(RPC_ERROR_CODES);
@@ -73,6 +75,8 @@ export const RpcSubmitPaymentWarningSchema = z.object({
7375
export const RpcSubmitPaymentRequestSchema = z.object({
7476
txHex: TransactionHexSchema,
7577
settle: RpcSettleOptionsSchema.optional(),
78+
// Caller-controlled idempotency key; see PaymentIdentifierSchema for charset/length constraints.
79+
paymentIdentifier: PaymentIdentifierSchema.optional(),
7680
});
7781

7882
export const RpcSubmitPaymentAcceptedSchema = z.object({

‎tests/rpc.test.ts‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, it } from "vitest";
22
import {
33
RpcCheckPaymentResultSchema,
4+
RpcSubmitPaymentRequestSchema,
45
RpcSubmitPaymentResultSchema,
56
} from "../src/index.js";
67

@@ -140,4 +141,60 @@ describe("rpc schemas", () => {
140141
"https://example.com/payment/pay_01JMVP9QE8XA3BDGM5RN7KWTZ4",
141142
);
142143
});
144+
145+
describe("paymentIdentifier — x402 V2 idempotency parity", () => {
146+
const STUB_TX_HEX = "0x" + "ab".repeat(32);
147+
148+
it("accepts a submit request with an optional paymentIdentifier", () => {
149+
const req = RpcSubmitPaymentRequestSchema.parse({
150+
txHex: STUB_TX_HEX,
151+
paymentIdentifier: "pay_01JMVP9QE8XA3BDGM5",
152+
});
153+
expect(req.paymentIdentifier).toBe("pay_01JMVP9QE8XA3BDGM5");
154+
});
155+
156+
it("accepts a submit request without paymentIdentifier (backward compat)", () => {
157+
const req = RpcSubmitPaymentRequestSchema.parse({ txHex: STUB_TX_HEX });
158+
expect(req.paymentIdentifier).toBeUndefined();
159+
});
160+
161+
it("rejects a paymentIdentifier shorter than 16 chars", () => {
162+
const result = RpcSubmitPaymentRequestSchema.safeParse({
163+
txHex: STUB_TX_HEX,
164+
paymentIdentifier: "short",
165+
});
166+
expect(result.success).toBe(false);
167+
});
168+
169+
it("rejects a paymentIdentifier longer than 128 chars", () => {
170+
const result = RpcSubmitPaymentRequestSchema.safeParse({
171+
txHex: STUB_TX_HEX,
172+
paymentIdentifier: "a".repeat(129),
173+
});
174+
expect(result.success).toBe(false);
175+
});
176+
177+
it("rejects a paymentIdentifier with disallowed characters", () => {
178+
const result = RpcSubmitPaymentRequestSchema.safeParse({
179+
txHex: STUB_TX_HEX,
180+
paymentIdentifier: "invalid identifier!",
181+
});
182+
expect(result.success).toBe(false);
183+
});
184+
185+
it("accepts PAYMENT_IDENTIFIER_CONFLICT as a rejected submit error code", () => {
186+
const result = RpcSubmitPaymentResultSchema.parse({
187+
accepted: false,
188+
error: "Same paymentIdentifier submitted with a different transaction",
189+
code: "PAYMENT_IDENTIFIER_CONFLICT",
190+
retryable: false,
191+
});
192+
193+
expect(result.accepted).toBe(false);
194+
if (result.accepted) {
195+
throw new Error("Expected a rejected RPC submit result");
196+
}
197+
expect(result.code).toBe("PAYMENT_IDENTIFIER_CONFLICT");
198+
});
199+
});
143200
});

0 commit comments

Comments
 (0)