Skip to content
Open
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
91 changes: 91 additions & 0 deletions typescript/src/compat/x402.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,92 @@ export interface x402PaymentRequirements {
description?: string;
facilitatorUrl?: string;
extensions?: Record<string, unknown>;
/**
* 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<string, unknown>;
}

/**
* 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<string, unknown> | 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.`);
}
}

/**
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
203 changes: 203 additions & 0 deletions typescript/test/compat-x402-payment-flow.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): 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);
});
});
Loading