Skip to content

Commit 5e94a6a

Browse files
authored
feat(adapter-x402): add V2 normalization and drift coverage (#573)
1 parent 289799f commit 5e94a6a

4 files changed

Lines changed: 349 additions & 1 deletion

File tree

.github/workflows/x402-drift.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,3 +134,16 @@ jobs:
134134
process.exit(1);
135135
}
136136
"
137+
138+
- name: Check V2 transport spec presence
139+
run: |
140+
# Verify the V2 HTTP transport spec still exists at the expected path.
141+
# If the file moves or is restructured, this fails and alerts maintainers.
142+
HTTP_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \
143+
"https://raw.githubusercontent.com/coinbase/x402/main/specs/transports-v2/http.md")
144+
if [ "$HTTP_STATUS" != "200" ]; then
145+
echo "WARNING: x402 V2 transport spec not found at expected path (HTTP $HTTP_STATUS)"
146+
echo "The upstream spec may have moved. Review and update raw-v2.ts if needed."
147+
exit 1
148+
fi
149+
echo "OK: V2 transport spec still at expected upstream path"

packages/adapters/x402/src/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,15 @@ export { X402_V2_HEADERS } from './raw-v2.js';
5757
export type { X402WireVersion, X402WireVersionDetection } from './version.js';
5858
export { detectX402Version, detectX402VersionFromSource } from './version.js';
5959

60-
// Normalized types and functions (Layer B)
60+
// Normalized types and functions (Layer B, V1)
6161
export type { NormalizedOfferPayload, NormalizedReceiptPayload } from './normalize.js';
6262

6363
export { normalizeOfferPayload, normalizeReceiptPayload } from './normalize.js';
6464

65+
// Normalized types and functions (Layer B, V2)
66+
export type { NormalizedV2Offer, NormalizedV2Receipt } from './normalize-v2.js';
67+
export { normalizeV2Offer, normalizeV2Offers, normalizeV2Receipt } from './normalize-v2.js';
68+
6569
// Public types (Layer B aliases + adapter-specific)
6670
export type {
6771
SignatureFormat,
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/**
2+
* x402 V2 semantic normalization (Layer B)
3+
*
4+
* V2 transport is JWS-primary with no EIP-712 placeholder semantics.
5+
* Optional fields are truly optional in V2, so placeholder normalization
6+
* (the core of V1 Layer B) is not needed.
7+
*
8+
* Instead, V2 normalization:
9+
* 1. Flattens the transport structure (PaymentRequired -> per-offer)
10+
* 2. Preserves all upstream fields including V2-specific ones
11+
* (maxTimeoutSeconds, extra, resource metadata)
12+
* 3. Produces V2-specific normalized types that carry forward all semantics
13+
*
14+
* These V2 normalized types are NOT forced into V1 NormalizedOfferPayload.
15+
* Downstream mapping (map.ts) and verification (verify.ts) will handle
16+
* the V1/V2 split explicitly. That work belongs in the full PR5.
17+
*/
18+
19+
import type {
20+
RawV2PaymentRequired,
21+
RawV2PaymentRequiredAccept,
22+
RawV2Resource,
23+
RawV2SettlementResponse,
24+
} from './raw-v2.js';
25+
26+
// ---------------------------------------------------------------------------
27+
// V2 Normalized Types
28+
// ---------------------------------------------------------------------------
29+
30+
/**
31+
* V2 normalized offer (preserves all upstream semantics).
32+
*
33+
* Unlike V1 NormalizedOfferPayload, this carries V2-specific fields
34+
* like maxTimeoutSeconds, extra, and resource metadata.
35+
*/
36+
export interface NormalizedV2Offer {
37+
/** Protocol version (always 2) */
38+
version: 2;
39+
/** Resource being offered */
40+
resource: RawV2Resource;
41+
/** Settlement scheme */
42+
scheme: string;
43+
/** CAIP-2 network identifier */
44+
network: string;
45+
/** Payment asset identifier */
46+
asset: string;
47+
/** Payment recipient address */
48+
payTo: string;
49+
/** Payment amount in minor units */
50+
amount: string;
51+
/**
52+
* Maximum timeout for payment settlement in seconds.
53+
* V2-specific: NOT an epoch timestamp (unlike V1 validUntil).
54+
* Represents a duration, not an absolute time.
55+
*/
56+
maxTimeoutSeconds: number;
57+
/** Scheme-specific additional data (preserved from upstream) */
58+
extra: Record<string, unknown>;
59+
}
60+
61+
/**
62+
* V2 normalized receipt (preserves all upstream semantics).
63+
*
64+
* The upstream SettlementResponse does not carry resourceUrl or issuedAt;
65+
* those must be supplied by the caller from request context.
66+
*/
67+
export interface NormalizedV2Receipt {
68+
/** Protocol version (always 2) */
69+
version: 2;
70+
/** CAIP-2 network identifier */
71+
network: string;
72+
/** Payer address */
73+
payer: string;
74+
/** On-chain transaction hash (present on success) */
75+
transaction?: string;
76+
/** Resource URL (caller-supplied from request context) */
77+
resourceUrl: string;
78+
/** Receipt issuance timestamp in epoch seconds (caller-supplied from response timing) */
79+
issuedAt: number;
80+
}
81+
82+
// ---------------------------------------------------------------------------
83+
// V2 Offer Normalization
84+
// ---------------------------------------------------------------------------
85+
86+
/**
87+
* Normalize a V2 PaymentRequired accept entry into a NormalizedV2Offer.
88+
*
89+
* V2 offers are per-accept-entry (each entry in accepts[] is one offer).
90+
* All upstream fields are preserved, including maxTimeoutSeconds and extra.
91+
*
92+
* @param accept - A single accept entry from PaymentRequired.accepts[]
93+
* @param resource - The PaymentRequired resource descriptor
94+
* @returns Normalized V2 offer with all upstream semantics preserved
95+
*/
96+
export function normalizeV2Offer(
97+
accept: RawV2PaymentRequiredAccept,
98+
resource: RawV2Resource
99+
): NormalizedV2Offer {
100+
return {
101+
version: 2,
102+
resource,
103+
scheme: accept.scheme,
104+
network: accept.network,
105+
asset: accept.asset,
106+
payTo: accept.payTo,
107+
amount: accept.amount,
108+
maxTimeoutSeconds: accept.maxTimeoutSeconds,
109+
extra: accept.extra,
110+
};
111+
}
112+
113+
/**
114+
* Normalize all offers from a V2 PaymentRequired challenge.
115+
*
116+
* @param challenge - The full V2 PaymentRequired object
117+
* @returns Array of normalized V2 offers (one per accept entry)
118+
*/
119+
export function normalizeV2Offers(challenge: RawV2PaymentRequired): NormalizedV2Offer[] {
120+
return challenge.accepts.map((accept) => normalizeV2Offer(accept, challenge.resource));
121+
}
122+
123+
// ---------------------------------------------------------------------------
124+
// V2 Receipt Normalization
125+
// ---------------------------------------------------------------------------
126+
127+
/**
128+
* Normalize a V2 SettlementResponse into a NormalizedV2Receipt.
129+
*
130+
* Only successful settlements produce meaningful receipt payloads.
131+
* Failed settlements should be handled as errors, not receipts.
132+
*
133+
* The upstream SettlementResponse does not carry resourceUrl or issuedAt;
134+
* callers must supply these from request context and response timing.
135+
*
136+
* @param settlement - The V2 SettlementResponse (success or failure)
137+
* @param resourceUrl - Resource URL from the original request context
138+
* @param issuedAt - Receipt issuance timestamp (epoch seconds, from response timing)
139+
* @returns Normalized V2 receipt, or null if settlement failed
140+
*/
141+
export function normalizeV2Receipt(
142+
settlement: RawV2SettlementResponse,
143+
resourceUrl: string,
144+
issuedAt: number
145+
): NormalizedV2Receipt | null {
146+
if (!settlement.success) {
147+
return null;
148+
}
149+
150+
return {
151+
version: 2,
152+
network: settlement.network,
153+
payer: settlement.payer,
154+
resourceUrl,
155+
issuedAt,
156+
...(settlement.transaction && { transaction: settlement.transaction }),
157+
};
158+
}
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { normalizeV2Offer, normalizeV2Offers, normalizeV2Receipt } from '../src/normalize-v2.js';
3+
import type {
4+
RawV2PaymentRequired,
5+
RawV2PaymentRequiredAccept,
6+
RawV2Resource,
7+
RawV2SettlementResponse,
8+
} from '../src/raw-v2.js';
9+
10+
// ---------------------------------------------------------------------------
11+
// Fixtures
12+
// ---------------------------------------------------------------------------
13+
14+
const RESOURCE: RawV2Resource = {
15+
url: 'https://api.example.com/premium',
16+
description: 'Premium API access',
17+
mimeType: 'application/json',
18+
};
19+
20+
const ACCEPT_ENTRY: RawV2PaymentRequiredAccept = {
21+
scheme: 'exact',
22+
network: 'eip155:84532',
23+
amount: '100000',
24+
asset: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
25+
payTo: '0x1234567890abcdef1234567890abcdef12345678',
26+
maxTimeoutSeconds: 300,
27+
extra: { customField: 'preserved' },
28+
};
29+
30+
const CHALLENGE: RawV2PaymentRequired = {
31+
x402Version: 2,
32+
error: 'PAYMENT-SIGNATURE header is required',
33+
resource: RESOURCE,
34+
accepts: [ACCEPT_ENTRY],
35+
};
36+
37+
const SUCCESS_SETTLEMENT: RawV2SettlementResponse = {
38+
success: true,
39+
transaction: '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890',
40+
network: 'eip155:84532',
41+
payer: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef',
42+
};
43+
44+
const FAILED_SETTLEMENT: RawV2SettlementResponse = {
45+
success: false,
46+
errorReason: 'insufficient_funds',
47+
transaction: '',
48+
network: 'eip155:84532',
49+
payer: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef',
50+
};
51+
52+
// ---------------------------------------------------------------------------
53+
// Tests: normalizeV2Offer
54+
// ---------------------------------------------------------------------------
55+
56+
describe('normalizeV2Offer', () => {
57+
it('maps accept entry fields to NormalizedV2Offer', () => {
58+
const result = normalizeV2Offer(ACCEPT_ENTRY, RESOURCE);
59+
expect(result.version).toBe(2);
60+
expect(result.scheme).toBe('exact');
61+
expect(result.network).toBe('eip155:84532');
62+
expect(result.asset).toBe('0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48');
63+
expect(result.payTo).toBe('0x1234567890abcdef1234567890abcdef12345678');
64+
expect(result.amount).toBe('100000');
65+
});
66+
67+
it('preserves resource metadata', () => {
68+
const result = normalizeV2Offer(ACCEPT_ENTRY, RESOURCE);
69+
expect(result.resource.url).toBe('https://api.example.com/premium');
70+
expect(result.resource.description).toBe('Premium API access');
71+
expect(result.resource.mimeType).toBe('application/json');
72+
});
73+
74+
it('preserves maxTimeoutSeconds (V2-specific, not epoch timestamp)', () => {
75+
const result = normalizeV2Offer(ACCEPT_ENTRY, RESOURCE);
76+
expect(result.maxTimeoutSeconds).toBe(300);
77+
});
78+
79+
it('preserves extra data from upstream', () => {
80+
const result = normalizeV2Offer(ACCEPT_ENTRY, RESOURCE);
81+
expect(result.extra).toEqual({ customField: 'preserved' });
82+
});
83+
84+
it('preserves empty extra object', () => {
85+
const entry = { ...ACCEPT_ENTRY, extra: {} };
86+
const result = normalizeV2Offer(entry, RESOURCE);
87+
expect(result.extra).toEqual({});
88+
});
89+
});
90+
91+
// ---------------------------------------------------------------------------
92+
// Tests: normalizeV2Offers
93+
// ---------------------------------------------------------------------------
94+
95+
describe('normalizeV2Offers', () => {
96+
it('normalizes all accepts from a challenge', () => {
97+
const results = normalizeV2Offers(CHALLENGE);
98+
expect(results).toHaveLength(1);
99+
expect(results[0].version).toBe(2);
100+
expect(results[0].resource.url).toBe('https://api.example.com/premium');
101+
});
102+
103+
it('handles multiple accept entries with different timeouts', () => {
104+
const multiChallenge: RawV2PaymentRequired = {
105+
...CHALLENGE,
106+
accepts: [
107+
ACCEPT_ENTRY,
108+
{ ...ACCEPT_ENTRY, network: 'eip155:1', amount: '200000', maxTimeoutSeconds: 600 },
109+
],
110+
};
111+
const results = normalizeV2Offers(multiChallenge);
112+
expect(results).toHaveLength(2);
113+
expect(results[0].maxTimeoutSeconds).toBe(300);
114+
expect(results[1].maxTimeoutSeconds).toBe(600);
115+
expect(results[1].network).toBe('eip155:1');
116+
expect(results[1].amount).toBe('200000');
117+
});
118+
});
119+
120+
// ---------------------------------------------------------------------------
121+
// Tests: normalizeV2Receipt
122+
// ---------------------------------------------------------------------------
123+
124+
describe('normalizeV2Receipt', () => {
125+
it('normalizes successful settlement to V2 receipt', () => {
126+
const result = normalizeV2Receipt(
127+
SUCCESS_SETTLEMENT,
128+
'https://api.example.com/premium',
129+
1711900000
130+
);
131+
expect(result).not.toBeNull();
132+
expect(result!.version).toBe(2);
133+
expect(result!.network).toBe('eip155:84532');
134+
expect(result!.payer).toBe('0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef');
135+
expect(result!.resourceUrl).toBe('https://api.example.com/premium');
136+
expect(result!.issuedAt).toBe(1711900000);
137+
expect(result!.transaction).toBe(
138+
'0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'
139+
);
140+
});
141+
142+
it('returns null for failed settlement', () => {
143+
const result = normalizeV2Receipt(
144+
FAILED_SETTLEMENT,
145+
'https://api.example.com/premium',
146+
1711900000
147+
);
148+
expect(result).toBeNull();
149+
});
150+
151+
it('omits transaction when empty string in success', () => {
152+
const settlement: RawV2SettlementResponse = {
153+
success: true,
154+
transaction: '',
155+
network: 'eip155:84532',
156+
payer: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef',
157+
};
158+
const result = normalizeV2Receipt(settlement, 'https://api.example.com/premium', 1711900000);
159+
expect(result).not.toBeNull();
160+
expect(result!.transaction).toBeUndefined();
161+
});
162+
163+
it('preserves transaction when present', () => {
164+
const result = normalizeV2Receipt(
165+
SUCCESS_SETTLEMENT,
166+
'https://api.example.com/premium',
167+
1711900000
168+
);
169+
expect(result!.transaction).toBe(
170+
'0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'
171+
);
172+
});
173+
});

0 commit comments

Comments
 (0)