diff --git a/packages/payments/src/index.test.ts b/packages/payments/src/index.test.ts index 91adf40..e2da6a6 100644 --- a/packages/payments/src/index.test.ts +++ b/packages/payments/src/index.test.ts @@ -25,11 +25,41 @@ describe('x402 header codec', () => { }); it('parses a 402 body and rejects malformed input', () => { - const body = { x402Version: 1, accepts: [{ network: 'base' }] }; + const body = { + x402Version: 1, + accepts: [ + { + scheme: 'exact', + network: 'base', + maxAmountRequired: '1000', + resource: 'https://x/y', + payTo: '0xdest', + asset: 'USDC', + }, + ], + }; expect(parsePaymentRequired(body).accepts).toHaveLength(1); expect(() => parsePaymentRequired({})).toThrow(/accepts/); expect(() => parsePaymentRequired(null)).toThrow(); }); + + it('rejects malformed accepts entries before payment processing', () => { + expect(() => parsePaymentRequired({ accepts: [null] })).toThrow(/accepts\[0\] is not an object/); + expect(() => + parsePaymentRequired({ + accepts: [ + { + scheme: 'exact', + network: 'base', + maxAmountRequired: 'not-a-number', + resource: 'https://x/y', + payTo: '0xdest', + asset: 'USDC', + }, + ], + }), + ).toThrow(/maxAmountRequired/); + }); }); describe('CoinPay address selection', () => { diff --git a/packages/payments/src/x402.ts b/packages/payments/src/x402.ts index d26245e..74b6d96 100644 --- a/packages/payments/src/x402.ts +++ b/packages/payments/src/x402.ts @@ -47,6 +47,35 @@ export interface PaymentPayload { payload: Record; } +const REQUIRED_ACCEPT_FIELDS = [ + 'scheme', + 'network', + 'maxAmountRequired', + 'resource', + 'payTo', + 'asset', +] as const satisfies readonly (keyof PaymentRequirements)[]; + +function validatePaymentRequirement(value: unknown, index: number): PaymentRequirements { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(`x402: accepts[${index}] is not an object`); + } + + const req = value as Record; + for (const field of REQUIRED_ACCEPT_FIELDS) { + if (typeof req[field] !== 'string' || !req[field].trim()) { + throw new Error(`x402: accepts[${index}].${field} must be a non-empty string`); + } + } + + const maxAmountRequired = req['maxAmountRequired'] as string; + if (!/^\d+$/.test(maxAmountRequired)) { + throw new Error(`x402: accepts[${index}].maxAmountRequired must be an atomic-unit integer string`); + } + + return req as unknown as PaymentRequirements; +} + /** Parses a 402 response body, throwing on malformed input. */ export function parsePaymentRequired(body: unknown): PaymentRequiredBody { if (typeof body !== 'object' || body === null) { @@ -58,7 +87,7 @@ export function parsePaymentRequired(body: unknown): PaymentRequiredBody { } return { x402Version: typeof b['x402Version'] === 'number' ? b['x402Version'] : X402_VERSION, - accepts: b['accepts'] as PaymentRequirements[], + accepts: b['accepts'].map(validatePaymentRequirement), ...(typeof b['error'] === 'string' ? { error: b['error'] } : {}), }; }