diff --git a/typescript/src/compat/x402.ts b/typescript/src/compat/x402.ts index 5b5c84f..68cdf3f 100644 --- a/typescript/src/compat/x402.ts +++ b/typescript/src/compat/x402.ts @@ -44,6 +44,92 @@ export interface x402PaymentRequirements { description?: string; facilitatorUrl?: string; extensions?: Record; + /** + * Scheme-private data — plus the two keys §6.1 reserves to the protocol, + * `paymentFlow` and `assetTransferMethod`. + * + * This field was previously absent from the inbound shape entirely, so the + * reserved keys were not "carried opaquely" — they were dropped on the floor, + * and a gate written to inspect them would have had nothing to read. + */ + extra?: Record; +} + +/** + * The three payment flows x402 §6.1 defines. Closed set — the protocol names + * these and only these, so an unrecognised string means upstream moved and the + * drift check should have caught it. + * + * `authorization` verify → resource → settle → respond (the default) + * `upfront` settle → resource → respond (omits /verify) + * `escrow` settle → resource → settle → respond (omits /verify) + */ +const X402_DEFINED_PAYMENT_FLOWS = new Set(['authorization', 'upfront', 'escrow']); + +/** + * The flows s402 can honour, declared rather than inherited by silence. + * + * s402's pipeline is verify → resource → settle, which **is** `authorization`. + * `upfront` and `escrow` commit funds *before* the resource executes; running + * either through s402's ordering would serve the resource before payment is + * durably committed — a serve-without-finality, not a cosmetic gap. + * + * **Architecture Invariant:** this set may only grow when the pipeline actually + * gains the corresponding ordering. Adding a flow here without implementing its + * ordering removes the gate and silently reintroduces the hazard, because the + * requirement will then lift and be paid under the wrong sequence. + */ +const S402_SUPPORTED_PAYMENT_FLOWS = new Set(['authorization']); + +/** §6.1: omitting the key resolves to the mechanism default. */ +const X402_DEFAULT_PAYMENT_FLOW = 'authorization'; + +/** + * Enforce x402 §6.1's payment-flow rules on a `PaymentRequirements.extra`. + * + * §6.1 makes `paymentFlow` and `assetTransferMethod` protocol-reserved: + * *"clients and servers MUST interpret them as defined here rather than as + * opaque scheme-private fields."* + * + * Absence is accepted as `authorization`, and that is safe rather than + * optimistic: §6.1 requires that *"when the resolved payment flow is not + * `authorization`, `accepts[].extra.paymentFlow` MUST be present"*. So for a + * conformant counterparty, an absent key proves the flow is `authorization`. + * `authorization` itself *"MAY be omitted or explicit."* + * + * `assetTransferMethod` is deliberately **not** validated. §6.1: *"Allowed + * `assetTransferMethod` string values are mechanism-defined; this protocol + * reserves the key name, not a global ATM vocabulary."* There is no set to + * check against, and enumerating one here would reject conformant peers using + * any mechanism not hard-coded — `eip3009`, `permit2`, `sequence`, + * `ticketSequence`, and whatever ships next. The real §6.1 obligation is to + * reject unsupported ATM/flow *combinations*, which needs the mechanism's + * declared flow-per-ATM table; compat does not have one, and inventing a + * vocabulary would be a bug wearing a check's clothes. + * + * @throws {s402Error} `SCHEME_NOT_SUPPORTED` if the flow is undefined by the + * spec, or defined but not one s402 implements. + */ +function assertSupportedPaymentFlow( + extra: Record | undefined, + context: string, +): void { + const flow = extra?.paymentFlow; + if (flow === undefined) return; // mechanism default — see above + + if (typeof flow !== 'string' || !X402_DEFINED_PAYMENT_FLOWS.has(flow)) { + throw new s402Error('SCHEME_NOT_SUPPORTED', + `${context} declares extra.paymentFlow ${JSON.stringify(flow)}, which x402 §6.1 does not define. ` + + `Known flows: ${[...X402_DEFINED_PAYMENT_FLOWS].join(', ')}. ` + + `If upstream added a flow, s402's x402 compat layer is behind the spec.`); + } + + if (!S402_SUPPORTED_PAYMENT_FLOWS.has(flow)) { + throw new s402Error('SCHEME_NOT_SUPPORTED', + `${context} requires the "${flow}" payment flow, which settles before the resource executes. ` + + `s402 implements "${X402_DEFAULT_PAYMENT_FLOW}" only (verify → resource → settle), so honouring ` + + `this would serve the resource before payment is durably committed.`); + } } /** @@ -88,6 +174,7 @@ export function fromX402Requirements(x402: x402PaymentRequirements, now?: number throw new s402Error('SCHEME_NOT_SUPPORTED', `x402 scheme "${x402.scheme}" has no s402 mapping; only "exact" is accepted inbound`); } + assertSupportedPaymentFlow(x402.extra, 'inbound x402 requirement'); // V1 uses maxAmountRequired, V2 uses amount const amount = x402.amount ?? x402.maxAmountRequired; if (!amount) { @@ -445,6 +532,10 @@ export function toX402V2Requirements( `toX402V2Requirements only translates s402.accepts[0] === 'exact'; got ${JSON.stringify(s402.accepts)}. ` + `Other s402 schemes (upto, prepaid, stream, escrow, unlock) have no direct x402 wire-format equivalent.`); } + // §6.1: "Clients MUST NOT construct a payment for a paymentFlow they do not + // recognize." Emitting one we cannot honour would advertise an ordering this + // implementation does not run. + assertSupportedPaymentFlow(options?.extra, 'outbound x402 V2 requirement'); return { scheme: 'exact', network: s402.network, diff --git a/typescript/test/compat-x402-payment-flow.test.ts b/typescript/test/compat-x402-payment-flow.test.ts new file mode 100644 index 0000000..422b71a --- /dev/null +++ b/typescript/test/compat-x402-payment-flow.test.ts @@ -0,0 +1,203 @@ +/** + * x402 §6.1 payment-flow conformance — DAN-846. + * + * §6.1 (merged 2026-08-08, #3053) makes `extra.paymentFlow` and + * `extra.assetTransferMethod` protocol-reserved keys and defines three flows: + * + * authorization (default) verify → resource → settle → respond + * upfront settle → resource → respond (no /verify) + * escrow settle → resource → settle → respond (no /verify) + * + * s402's pipeline is verify → resource → settle, which IS `authorization`. + * `upfront` and `escrow` commit funds *before* the resource runs, so honouring + * one of those requirements with s402's ordering would serve the resource + * without finality. Today no upstream `exact` mechanism declares either — the + * gap is latent, and this file is the tripwire for the day one does. + * + * Spec text quoted inline is from `specs/x402-specification-v2.md` §6.1 at + * x402-foundation/x402 `foundation/main` @ 167a828e. Note the local checkout + * may sit on a fork branch that predates §6.1 — read `foundation/main`, not HEAD. + */ +import { describe, it, expect } from 'vitest'; +import { + fromX402Requirements, + toX402V2Requirements, + toX402V2Envelope, + type x402PaymentRequirements, +} from '../src/compat/x402.js'; +import { s402Error } from '../src/errors.js'; +import type { s402PaymentRequirements } from '../src/types.js'; + +/** A minimal, valid inbound x402 `exact` requirement. */ +function inbound(extra?: Record): x402PaymentRequirements { + return { + x402Version: 2, + scheme: 'exact', + network: 'eip155:8453', + asset: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + amount: '1000', + payTo: '0x742d35Cc6634C0532925a3b844Bc9e7595f8fE00', + maxTimeoutSeconds: 60, + ...(extra === undefined ? {} : { extra }), + }; +} + +const s402Requirements: s402PaymentRequirements = { + s402Version: '1', + accepts: ['exact'], + network: 'eip155:8453', + asset: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + amount: '1000', + payTo: '0x742d35Cc6634C0532925a3b844Bc9e7595f8fE00', +}; + +// ══════════════════════════════════════════════════════════════ +// Inbound — the serve-without-finality gate +// ══════════════════════════════════════════════════════════════ + +describe('fromX402Requirements — payment flow', () => { + it('accepts a requirement with no extra at all (pre-§6.1 counterparties)', () => { + expect(fromX402Requirements(inbound()).accepts).toEqual(['exact']); + }); + + it('accepts an omitted paymentFlow — the mechanism default', () => { + // §6.1: "Omitting extra.assetTransferMethod or extra.paymentFlow means the + // mechanism default when resolving… When the resolved payment flow is not + // `authorization`, accepts[].extra.paymentFlow MUST be present." So for a + // conformant counterparty, absence proves the flow IS authorization. + expect(fromX402Requirements(inbound({})).accepts).toEqual(['exact']); + expect(fromX402Requirements(inbound({ assetTransferMethod: 'eip3009' })).accepts).toEqual(['exact']); + }); + + it('accepts an explicit authorization flow', () => { + // §6.1: "`authorization` MAY be omitted or explicit." + expect(fromX402Requirements(inbound({ paymentFlow: 'authorization' })).accepts).toEqual(['exact']); + }); + + it('rejects upfront — settle happens before the resource', () => { + expect(() => fromX402Requirements(inbound({ paymentFlow: 'upfront' }))).toThrow(s402Error); + expect(() => fromX402Requirements(inbound({ paymentFlow: 'upfront' }))).toThrow(/upfront/); + }); + + it('rejects escrow — two settles around the resource', () => { + expect(() => fromX402Requirements(inbound({ paymentFlow: 'escrow' }))).toThrow(s402Error); + expect(() => fromX402Requirements(inbound({ paymentFlow: 'escrow' }))).toThrow(/escrow/); + }); + + it('rejects an unrecognised flow string', () => { + // §6.1: "Clients MUST NOT construct a payment for a paymentFlow they do not + // recognize." + expect(() => fromX402Requirements(inbound({ paymentFlow: 'lightning-hold' }))).toThrow(s402Error); + }); + + it('rejects a non-string paymentFlow rather than coercing it', () => { + expect(() => fromX402Requirements(inbound({ paymentFlow: 42 }))).toThrow(s402Error); + expect(() => fromX402Requirements(inbound({ paymentFlow: null }))).toThrow(s402Error); + }); + + it('distinguishes "not supported by us" from "not defined by the spec"', () => { + // The operator's next move differs: an unsupported-but-defined flow is a + // deliberate s402 limit; an undefined one means upstream moved and our + // drift check should have caught it. One message for both would send half + // of readers to the wrong place. + let supported = ''; + let unknown = ''; + try { fromX402Requirements(inbound({ paymentFlow: 'escrow' })); } catch (e) { supported = (e as Error).message; } + try { fromX402Requirements(inbound({ paymentFlow: 'nonesuch' })); } catch (e) { unknown = (e as Error).message; } + expect(supported).not.toBe(unknown); + }); + + it('uses SCHEME_NOT_SUPPORTED, matching the sibling scheme gate', () => { + try { + fromX402Requirements(inbound({ paymentFlow: 'upfront' })); + throw new Error('expected a throw'); + } catch (e) { + expect(e).toBeInstanceOf(s402Error); + expect((e as s402Error).code).toBe('SCHEME_NOT_SUPPORTED'); + } + }); + + it('still rejects a non-exact scheme first', () => { + // The scheme gate must stay the outer check — an `upto` requirement is + // rejected as a scheme, not as a flow, whatever its extra says. + const upto = { ...inbound({ paymentFlow: 'escrow' }), scheme: 'upto' }; + expect(() => fromX402Requirements(upto)).toThrow(/scheme/i); + }); +}); + +// ══════════════════════════════════════════════════════════════ +// assetTransferMethod — reserved key, NO global vocabulary +// ══════════════════════════════════════════════════════════════ + +describe('assetTransferMethod is carried, not adjudicated', () => { + it('accepts any assetTransferMethod string, including ones we have never seen', () => { + // §6.1 is explicit: "Allowed assetTransferMethod string values are + // mechanism-defined; this protocol reserves the key name, not a global ATM + // vocabulary." There is no set to validate against. Enumerating one here + // would reject conformant counterparties using any mechanism we had not + // hard-coded — eip3009, permit2, sequence, ticketSequence, and whatever + // ships next. Rejecting an unknown ATM would be a bug, not a check. + for (const atm of ['eip3009', 'permit2', 'sequence', 'ticketSequence', 'something-invented-tomorrow']) { + expect(fromX402Requirements(inbound({ assetTransferMethod: atm })).accepts).toEqual(['exact']); + } + }); + + it('still applies the flow gate when an assetTransferMethod is present', () => { + expect(() => + fromX402Requirements(inbound({ assetTransferMethod: 'permit2', paymentFlow: 'upfront' })), + ).toThrow(s402Error); + }); +}); + +// ══════════════════════════════════════════════════════════════ +// Outbound — never construct a payment for a flow we do not honour +// ══════════════════════════════════════════════════════════════ + +describe('toX402V2Requirements — payment flow', () => { + it('emits an empty extra when none is supplied', () => { + expect(toX402V2Requirements(s402Requirements).extra).toEqual({}); + }); + + it('passes through an explicit authorization flow', () => { + const req = toX402V2Requirements(s402Requirements, { extra: { paymentFlow: 'authorization' } }); + expect(req.extra).toEqual({ paymentFlow: 'authorization' }); + }); + + it('refuses to emit upfront or escrow', () => { + for (const flow of ['upfront', 'escrow']) { + expect(() => toX402V2Requirements(s402Requirements, { extra: { paymentFlow: flow } })) + .toThrow(s402Error); + } + }); + + it('refuses to emit an unrecognised flow', () => { + expect(() => toX402V2Requirements(s402Requirements, { extra: { paymentFlow: 'made-up' } })) + .toThrow(s402Error); + }); + + it('preserves unrelated extra keys (EIP-712 domain name/version)', () => { + // The EVM EIP-3009 path documented on this function passes name/version + // through extra — the flow gate must not disturb it. + const req = toX402V2Requirements(s402Requirements, { + extra: { name: 'USD Coin', version: '2', assetTransferMethod: 'eip3009' }, + }); + expect(req.extra).toEqual({ name: 'USD Coin', version: '2', assetTransferMethod: 'eip3009' }); + }); +}); + +describe('toX402V2Envelope — inherits the gate by delegation', () => { + const resource = { url: 'https://api.example.com/weather' }; + + it('emits normally for an authorization flow', () => { + const env = toX402V2Envelope(s402Requirements, resource, { extra: { paymentFlow: 'authorization' } }); + expect(env.accepts).toHaveLength(1); + }); + + it('refuses an unsupported flow through the envelope path too', () => { + // toX402V2Envelope calls toX402V2Requirements rather than building its own + // literal, so one gate covers both emit paths. This test exists to keep + // that delegation true — if someone inlines the literal, this goes red. + expect(() => toX402V2Envelope(s402Requirements, resource, { extra: { paymentFlow: 'escrow' } })) + .toThrow(s402Error); + }); +});