diff --git a/src/x402-v2.ts b/src/x402-v2.ts index 53686e3..8990f26 100644 --- a/src/x402-v2.ts +++ b/src/x402-v2.ts @@ -22,6 +22,7 @@ import type { } from './types.js'; import { getErrorMessage, PayBotApiError } from './errors.js'; import { privateKeyToAccount } from 'viem/accounts'; +import type { PrivateKeyAccount } from 'viem/accounts'; import { generateEIP3009Nonce } from './crypto.js'; import { EIP712_DOMAINS, EIP3009_TYPES } from './networks.js'; @@ -139,8 +140,184 @@ export class X402Handler { } /** - * Sign payment payload using EIP-712 typed data - * Supports both x402 native format and MPP compatibility mode + * Sign an x402 native EIP-3009 `TransferWithAuthorization` for the given + * `PaymentRequirements` and signing `account`. + * + * Produces a signature byte-for-byte equivalent to the pre-refactor + * `protocol === 'x402'` branch (Story 14 verbatim-migration guarantee — + * regression-guarded by `tests/x402-v2.test.ts` Test #13). + * + * @param account - viem `PrivateKeyAccount` derived from the handler's wallet private key. + * @param requirements - Payment requirements (network, amount, payTo). + * @returns Object with the EIP-712 `signature` and an x402-shaped `signedData` + * containing `{ from, to, value, validAfter, validBefore, nonce, signature }`. + * @throws {PayBotApiError} with code `UNSUPPORTED_NETWORK` (HTTP 402) if the + * requested network has no registered EIP-712 domain. + * + * @example + * const r = await this.signX402(account, requirements); + * r.signature; // '0x...130 hex chars' + * r.signedData; // { from, to, value, validAfter, validBefore, nonce, signature } + */ + private async signX402( + account: PrivateKeyAccount, + requirements: PaymentRequirements, + ): Promise<{ signature: string; signedData: Record }> { + // x402 native signing (EIP-3009 TransferWithAuthorization) + const network = requirements.network || 'eip155:8453'; + const domain = EIP712_DOMAINS[network]; + + if (!domain) { + throw new PayBotApiError( + `No EIP-712 domain for network: ${network}`, + 'UNSUPPORTED_NETWORK', + 402 + ); + } + + const nonce = generateEIP3009Nonce(); + const nowSeconds = BigInt(Math.floor(Date.now() / 1000)); + const validAfter = BigInt(0); + const validBefore = nowSeconds + BigInt(3600); // 1 hour from now + + const value = BigInt(requirements.amount); + + const signature = await account.signTypedData({ + domain, + types: EIP3009_TYPES, + primaryType: 'TransferWithAuthorization', + message: { + from: account.address, + to: requirements.payTo as `0x${string}`, + value, + validAfter, + validBefore, + nonce, + }, + }); + + const signedData: Record = { + from: account.address, + to: requirements.payTo, + value: requirements.amount, + validAfter: validAfter.toString(), + validBefore: validBefore.toString(), + nonce, + signature, + }; + + return { signature, signedData }; + } + + /** + * Sign an MPP (Machine Payments Protocol — Stripe/Tempo) `PaymentAuthorization` + * for the given `PaymentRequirements` and signing `account`. + * + * Produces a signature byte-for-byte equivalent to the pre-refactor + * `protocol === 'mpp'` branch. The typed-data structure (`PaymentAuthorization`) + * differs from x402's EIP-3009 `TransferWithAuthorization`, so the resulting + * signature MUST differ from `signX402`'s for the same inputs. + * + * @param account - viem `PrivateKeyAccount` derived from the handler's wallet private key. + * @param requirements - Payment requirements (amount, payTo). Network not used in MPP domain. + * @param intentId - Optional payment-intent identifier. Falsy values fall back to the + * string `'unknown'` inside the signed message (gracefully handled, + * never throws). + * @returns Object with the EIP-712 `signature` and an MPP-shaped `signedData` + * containing `{ payer, recipient, amount, nonce, expires, paymentIntent, signature }`. + * + * @example + * const r = await this.signMPP(account, requirements, 'intent_abc'); + * r.signature; // '0x...130 hex chars' — differs from signX402 output + */ + private async signMPP( + account: PrivateKeyAccount, + requirements: PaymentRequirements, + intentId: string | undefined, + ): Promise<{ signature: string; signedData: Record }> { + // MPP (Stripe/Tempo) compatibility mode + // Uses different typed data structure + const domain = { + name: 'Machine Payments Protocol', + version: '1.0', + chainId: 1, // Ethereum mainnet + verifyingContract: requirements.payTo as `0x${string}`, + }; + + const nonce = generateEIP3009Nonce(); + const nowSeconds = BigInt(Math.floor(Date.now() / 1000)); + + const signature = await account.signTypedData({ + domain, + types: { + PaymentAuthorization: [ + { name: 'payer', type: 'address' }, + { name: 'recipient', type: 'address' }, + { name: 'amount', type: 'uint256' }, + { name: 'nonce', type: 'bytes32' }, + { name: 'expires', type: 'uint256' }, + { name: 'paymentIntent', type: 'string' }, + ], + }, + primaryType: 'PaymentAuthorization', + message: { + payer: account.address, + recipient: requirements.payTo as `0x${string}`, + amount: BigInt(requirements.amount), + nonce, + expires: nowSeconds + BigInt(3600), + paymentIntent: intentId || 'unknown', + }, + }); + + const signedData: Record = { + payer: account.address, + recipient: requirements.payTo, + amount: requirements.amount, + nonce, + expires: nowSeconds.toString(), + paymentIntent: intentId, + signature, + }; + + return { signature, signedData }; + } + + /** + * Sign a payment payload using EIP-712 typed data. + * + * Dispatches to the protocol-specific helper: + * - `x402` → `signX402` only (EIP-3009 TransferWithAuthorization) + * - `mpp` → `signMPP` only (MPP PaymentAuthorization) + * - `dual` → BOTH `signX402` AND `signMPP`; `signedData` is packed as + * `{ x402: , mpp: }` and the + * top-level `signature` is the x402 signature (primary, for + * legacy compatibility). + * + * Story 14 (Option C refactor) replaced the pre-existing if/else-if chain + * — whose `else if (protocol === 'dual')` arm was unreachable dead code — + * with this `switch` dispatcher. The new dual case calls both helpers and + * packs the result, so dual-mode now produces a REAL MPP cryptographic + * signature, not inert `mppFormat` metadata. + * + * @param payload - Parsed `PaymentPayload` from an HTTP 402 response. + * @returns A `SignedPayment` with `protocol`, `signedData`, `signature`, + * and `timestamp` populated. + * @throws {PayBotApiError} + * - `MISSING_WALLET_KEY` (HTTP 402) if no wallet private key was configured. + * - `UNSUPPORTED_NETWORK` (HTTP 402) if the requirements specify a network + * with no registered EIP-712 domain (bubbled from `signX402`). + * - `UNSUPPORTED_PROTOCOL` (HTTP 402) for any protocol outside + * `'x402' | 'mpp' | 'dual'`. + * + * @example + * // dual-mode result shape: + * const signed = await handler.signPayment(dualPayload); + * signed.protocol; // 'dual' + * signed.signature; // x402 signature (primary) + * signed.signedData.x402; // full x402 signed payload + * signed.signedData.mpp; // full MPP signed payload + * signed.signedData.mpp.signature; // real EIP-712 MPP signature (not metadata) */ async signPayment(payload: PaymentPayload): Promise { if (!this.walletPrivateKey) { @@ -153,161 +330,51 @@ export class X402Handler { const account = privateKeyToAccount(this.walletPrivateKey as `0x${string}`); const requirements = payload.paymentIntent.requirements; - - // Determine protocol mode (x402 vs MPP) const protocol = payload.paymentIntent.protocol; let signature: string; let signedData: Record; - if (protocol === 'x402' || protocol === 'dual') { - // x402 native signing (EIP-3009 TransferWithAuthorization) - const network = requirements.network || 'eip155:8453'; - const domain = EIP712_DOMAINS[network]; - - if (!domain) { - throw new PayBotApiError( - `No EIP-712 domain for network: ${network}`, - 'UNSUPPORTED_NETWORK', - 402 + switch (protocol) { + case 'x402': { + const r = await this.signX402(account, requirements); + signature = r.signature; + signedData = r.signedData; + break; + } + case 'mpp': { + const r = await this.signMPP( + account, + requirements, + payload.paymentIntent.intentId, ); + signature = r.signature; + signedData = r.signedData; + break; } - - const nonce = generateEIP3009Nonce(); - const nowSeconds = BigInt(Math.floor(Date.now() / 1000)); - const validAfter = BigInt(0); - const validBefore = nowSeconds + BigInt(3600); // 1 hour from now - - const value = BigInt(requirements.amount); - - signature = await account.signTypedData({ - domain, - types: EIP3009_TYPES, - primaryType: 'TransferWithAuthorization', - message: { - from: account.address, - to: requirements.payTo as `0x${string}`, - value, - validAfter, - validBefore, - nonce, - }, - }); - - signedData = { - from: account.address, - to: requirements.payTo, - value: requirements.amount, - validAfter: validAfter.toString(), - validBefore: validBefore.toString(), - nonce, - signature, - }; - } else if (protocol === 'mpp') { - // MPP (Stripe/Tempo) compatibility mode - // Uses different typed data structure - const domain = { - name: 'Machine Payments Protocol', - version: '1.0', - chainId: 1, // Ethereum mainnet - verifyingContract: requirements.payTo as `0x${string}`, - }; - - const nonce = generateEIP3009Nonce(); - const nowSeconds = BigInt(Math.floor(Date.now() / 1000)); - - signature = await account.signTypedData({ - domain, - types: { - PaymentAuthorization: [ - { name: 'payer', type: 'address' }, - { name: 'recipient', type: 'address' }, - { name: 'amount', type: 'uint256' }, - { name: 'nonce', type: 'bytes32' }, - { name: 'expires', type: 'uint256' }, - { name: 'paymentIntent', type: 'string' }, - ], - }, - primaryType: 'PaymentAuthorization', - message: { - payer: account.address, - recipient: requirements.payTo as `0x${string}`, - amount: BigInt(requirements.amount), - nonce, - expires: nowSeconds + BigInt(3600), - paymentIntent: payload.paymentIntent.intentId || 'unknown', - }, - }); - - signedData = { - payer: account.address, - recipient: requirements.payTo, - amount: requirements.amount, - nonce, - expires: nowSeconds.toString(), - paymentIntent: payload.paymentIntent.intentId, - signature, - }; - } else if (protocol === 'dual') { - // Dual-mode: sign with both x402 and MPP formats - // This provides maximum compatibility with all payment endpoints - const network = requirements.network || 'eip155:8453'; - const domain = EIP712_DOMAINS[network]; - - if (!domain) { + case 'dual': { + // WHY: dual-mode must produce BOTH a real EIP-3009 x402 signature + // AND a real MPP PaymentAuthorization signature. We expose them as + // a discriminated bag under `signedData = { x402, mpp }` so callers + // can submit to either protocol's endpoint without re-signing. The + // top-level `signature` mirrors the x402 signature for legacy + // consumers that expect a single primary string field. + const x = await this.signX402(account, requirements); + const m = await this.signMPP( + account, + requirements, + payload.paymentIntent.intentId, + ); + signature = x.signature; // primary signature = x402 + signedData = { x402: x.signedData, mpp: m.signedData }; + break; + } + default: throw new PayBotApiError( - `No EIP-712 domain for network: ${network}`, - 'UNSUPPORTED_NETWORK', + `Unsupported payment protocol: ${protocol}`, + 'UNSUPPORTED_PROTOCOL', 402 ); - } - - const nonce = generateEIP3009Nonce(); - const nowSeconds = BigInt(Math.floor(Date.now() / 1000)); - const validAfter = BigInt(0); - const validBefore = nowSeconds + BigInt(3600); - - const value = BigInt(requirements.amount); - - // Sign x402 format (primary) - signature = await account.signTypedData({ - domain, - types: EIP3009_TYPES, - primaryType: 'TransferWithAuthorization', - message: { - from: account.address, - to: requirements.payTo as `0x${string}`, - value, - validAfter, - validBefore, - nonce, - }, - }); - - signedData = { - from: account.address, - to: requirements.payTo, - value: requirements.amount, - validAfter: validAfter.toString(), - validBefore: validBefore.toString(), - nonce, - signature, - // Include MPP compatibility fields - mppFormat: { - payer: account.address, - recipient: requirements.payTo, - amount: requirements.amount, - nonce, - expires: nowSeconds.toString(), - paymentIntent: payload.paymentIntent.intentId, - }, - }; - } else { - throw new PayBotApiError( - `Unsupported payment protocol: ${protocol}`, - 'UNSUPPORTED_PROTOCOL', - 402 - ); } return { diff --git a/tests/x402-v2.test.ts b/tests/x402-v2.test.ts new file mode 100644 index 0000000..3e57f7a --- /dev/null +++ b/tests/x402-v2.test.ts @@ -0,0 +1,521 @@ +/** + * @module tests/x402-v2 + * + * Unit tests for `X402Handler.signPayment` and its new private dispatch helpers + * `signX402` and `signMPP` (Story 14 — Option C refactor). + * + * These tests: + * 1. Lock the existing x402 + MPP signing behavior byte-for-byte (regression shield). + * 2. Prove the dual-mode case produces a REAL MPP cryptographic signature, not + * inert metadata (the bug Story 14 fixes — see lines 251+ of pre-refactor + * `src/x402-v2.ts`, where the `else if (protocol === 'dual')` branch was + * unreachable dead code). + * 3. Verify the dispatcher's `default` arm throws `PayBotApiError` with + * `UNSUPPORTED_PROTOCOL` for any unknown protocol value. + * 4. Verify the wallet-key precondition still throws `MISSING_WALLET_KEY`. + * + * Determinism: + * - vitest's `useFakeTimers` + `setSystemTime` freezes `Date.now()`. + * - `generateEIP3009Nonce` is mocked to return a fixed bytes32 so signatures + * are byte-identical across runs (Story 14, Test Strategy). + * + * Test naming convention: `[UNIT] methodName — should [behavior] when [condition]`. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { recoverTypedDataAddress, verifyTypedData } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; + +import { X402Handler } from '../src/x402-v2.js'; +import { PayBotApiError } from '../src/errors.js'; +import type { + PaymentPayload, + PaymentIntent, + PaymentRequirements, +} from '../src/types.js'; +import { EIP712_DOMAINS, EIP3009_TYPES } from '../src/networks.js'; + +// --------------------------------------------------------------------------- +// Deterministic test fixtures +// --------------------------------------------------------------------------- + +/** + * Fixed test private key (NEVER use for anything real). + * Account address derived from this key: see TEST_ACCOUNT_ADDRESS below. + */ +const TEST_PRIVATE_KEY = + '0x4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318' as const; + +const TEST_ACCOUNT_ADDRESS = privateKeyToAccount(TEST_PRIVATE_KEY).address; + +/** Fixed system time for deterministic `nowSeconds + 3600` calculations. */ +const FIXED_NOW = new Date('2026-05-22T12:00:00Z'); + +/** Fixed bytes32 nonce — replaces `randomBytes(32)` for byte-identical sigs. */ +const FIXED_NONCE = + '0x1111111111111111111111111111111111111111111111111111111111111111' as `0x${string}`; + +// Mock the crypto module so `generateEIP3009Nonce()` is deterministic. +vi.mock('../src/crypto.js', () => ({ + generateEIP3009Nonce: vi.fn(() => FIXED_NONCE), +})); + +/** Build a `PaymentRequirements` with sensible defaults; override per test. */ +function buildRequirements( + overrides: Partial = {}, +): PaymentRequirements { + return { + scheme: 'exact', + network: 'eip155:8453', + asset: 'eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + amount: '1000000', // 1 USDC (6 decimals) + payTo: '0x000000000000000000000000000000000000bEEF', + maxTimeoutSeconds: 300, + ...overrides, + }; +} + +/** Build a `PaymentPayload` with a given protocol + requirements. */ +function buildPayload( + protocol: PaymentIntent['protocol'], + requirements: PaymentRequirements = buildRequirements(), + intentId: string | undefined = 'intent_test_123', +): PaymentPayload { + const paymentIntent: PaymentIntent = { + intentId: intentId as string, // PaymentIntent typing requires a string; tests pass 'unknown' branch via overrides below + protocol, + requirements, + version: '2.0', + createdAt: FIXED_NOW.toISOString(), + expiresAt: new Date(FIXED_NOW.getTime() + 300_000).toISOString(), + }; + return { + paymentIntent, + requirements, + }; +} + +/** Construct the MPP EIP-712 domain that `signMPP` uses internally. */ +function buildMppDomain(payTo: string) { + return { + name: 'Machine Payments Protocol', + version: '1.0', + chainId: 1, + verifyingContract: payTo as `0x${string}`, + }; +} + +/** Construct the MPP types object that `signMPP` uses internally. */ +const MPP_TYPES = { + PaymentAuthorization: [ + { name: 'payer', type: 'address' }, + { name: 'recipient', type: 'address' }, + { name: 'amount', type: 'uint256' }, + { name: 'nonce', type: 'bytes32' }, + { name: 'expires', type: 'uint256' }, + { name: 'paymentIntent', type: 'string' }, + ], +} as const; + +// --------------------------------------------------------------------------- +// Test setup +// --------------------------------------------------------------------------- + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(FIXED_NOW); +}); + +afterEach(() => { + vi.useRealTimers(); + // Note: do NOT call vi.restoreAllMocks() here — that would restore the + // generateEIP3009Nonce module mock, which we WANT to keep in place for + // every test in this file. +}); + +// =========================================================================== +// Test 1: signX402 — happy path +// =========================================================================== + +describe('[UNIT] signX402 (via signPayment protocol=x402)', () => { + it('[UNIT] signX402 — should produce a verifiable EIP-3009 TransferWithAuthorization signature with typical inputs', async () => { + const handler = new X402Handler(TEST_PRIVATE_KEY); + const payload = buildPayload('x402'); + + const signed = await handler.signPayment(payload); + + expect(signed.protocol).toBe('x402'); + expect(signed.signature).toMatch(/^0x[0-9a-fA-F]{130}$/); + + const sd = signed.signedData as Record; + const nowSec = BigInt(Math.floor(FIXED_NOW.getTime() / 1000)); + + // Recover signer from the EIP-712 signature → must match test account. + const recovered = await recoverTypedDataAddress({ + domain: EIP712_DOMAINS['eip155:8453'], + types: EIP3009_TYPES, + primaryType: 'TransferWithAuthorization', + message: { + from: TEST_ACCOUNT_ADDRESS, + to: payload.requirements.payTo as `0x${string}`, + value: BigInt(payload.requirements.amount), + validAfter: 0n, + validBefore: nowSec + 3600n, + nonce: FIXED_NONCE, + }, + signature: signed.signature as `0x${string}`, + }); + expect(recovered.toLowerCase()).toBe(TEST_ACCOUNT_ADDRESS.toLowerCase()); + + // signedData shape (verbatim from current branch — see Test 13). + expect(sd.from).toBe(TEST_ACCOUNT_ADDRESS); + expect(sd.to).toBe(payload.requirements.payTo); + expect(sd.value).toBe(payload.requirements.amount); + expect(sd.validAfter).toBe('0'); + expect(sd.validBefore).toBe((nowSec + 3600n).toString()); + expect(sd.nonce).toBe(FIXED_NONCE); + expect(sd.signature).toBe(signed.signature); + }); + + // ------------------------------------------------------------------------- + // Test 2: signX402 — error path + // ------------------------------------------------------------------------- + + it('[UNIT] signX402 — should throw UNSUPPORTED_NETWORK PayBotApiError when network has no EIP-712 domain', async () => { + const handler = new X402Handler(TEST_PRIVATE_KEY); + const payload = buildPayload( + 'x402', + buildRequirements({ network: 'eip155:999999' }), + ); + + await expect(handler.signPayment(payload)).rejects.toMatchObject({ + name: 'PayBotApiError', + code: 'UNSUPPORTED_NETWORK', + statusCode: 402, + }); + }); + + // ------------------------------------------------------------------------- + // Test 3: signX402 — edge case + // ------------------------------------------------------------------------- + + it('[UNIT] signX402 — should handle max-uint256 amount without overflow and produce a valid signature', async () => { + const handler = new X402Handler(TEST_PRIVATE_KEY); + const MAX_UINT256 = + '115792089237316195423570985008687907853269984665640564039457584007913129639935'; + const payload = buildPayload( + 'x402', + buildRequirements({ amount: MAX_UINT256 }), + ); + + const signed = await handler.signPayment(payload); + + expect(signed.signature).toMatch(/^0x[0-9a-fA-F]{130}$/); + expect((signed.signedData as Record).value).toBe( + MAX_UINT256, + ); + + // Round-trip the signature through verifyTypedData with the same uint256. + const ok = await verifyTypedData({ + address: TEST_ACCOUNT_ADDRESS, + domain: EIP712_DOMAINS['eip155:8453'], + types: EIP3009_TYPES, + primaryType: 'TransferWithAuthorization', + message: { + from: TEST_ACCOUNT_ADDRESS, + to: payload.requirements.payTo as `0x${string}`, + value: BigInt(MAX_UINT256), + validAfter: 0n, + validBefore: BigInt(Math.floor(FIXED_NOW.getTime() / 1000)) + 3600n, + nonce: FIXED_NONCE, + }, + signature: signed.signature as `0x${string}`, + }); + expect(ok).toBe(true); + }); +}); + +// =========================================================================== +// Test 4: signMPP — happy path +// =========================================================================== + +describe('[UNIT] signMPP (via signPayment protocol=mpp)', () => { + it('[UNIT] signMPP — should produce a valid PaymentAuthorization signature with typical inputs and intentId', async () => { + const handler = new X402Handler(TEST_PRIVATE_KEY); + const payload = buildPayload('mpp'); + + const signed = await handler.signPayment(payload); + + expect(signed.protocol).toBe('mpp'); + expect(signed.signature).toMatch(/^0x[0-9a-fA-F]{130}$/); + + const nowSec = BigInt(Math.floor(FIXED_NOW.getTime() / 1000)); + + // Recover signer from the MPP-typed signature → must match test account. + const recovered = await recoverTypedDataAddress({ + domain: buildMppDomain(payload.requirements.payTo), + types: MPP_TYPES, + primaryType: 'PaymentAuthorization', + message: { + payer: TEST_ACCOUNT_ADDRESS, + recipient: payload.requirements.payTo as `0x${string}`, + amount: BigInt(payload.requirements.amount), + nonce: FIXED_NONCE, + expires: nowSec + 3600n, + paymentIntent: payload.paymentIntent.intentId, + }, + signature: signed.signature as `0x${string}`, + }); + expect(recovered.toLowerCase()).toBe(TEST_ACCOUNT_ADDRESS.toLowerCase()); + + const sd = signed.signedData as Record; + expect(sd.payer).toBe(TEST_ACCOUNT_ADDRESS); + expect(sd.recipient).toBe(payload.requirements.payTo); + expect(sd.amount).toBe(payload.requirements.amount); + expect(sd.nonce).toBe(FIXED_NONCE); + expect(sd.paymentIntent).toBe(payload.paymentIntent.intentId); + expect(sd.signature).toBe(signed.signature); + }); + + // ------------------------------------------------------------------------- + // Test 5: signMPP — graceful fallback when intentId is missing + // ------------------------------------------------------------------------- + + it("[UNIT] signMPP — should handle missing intentId by defaulting paymentIntent field to 'unknown'", async () => { + const handler = new X402Handler(TEST_PRIVATE_KEY); + // Override intentId to an empty string so the `|| 'unknown'` fallback fires. + const payload = buildPayload('mpp', buildRequirements(), ''); + + const signed = await handler.signPayment(payload); + + // Recovery against `paymentIntent: 'unknown'` must succeed; recovery + // against any other string must fail. This proves the fallback fired. + const nowSec = BigInt(Math.floor(FIXED_NOW.getTime() / 1000)); + const ok = await verifyTypedData({ + address: TEST_ACCOUNT_ADDRESS, + domain: buildMppDomain(payload.requirements.payTo), + types: MPP_TYPES, + primaryType: 'PaymentAuthorization', + message: { + payer: TEST_ACCOUNT_ADDRESS, + recipient: payload.requirements.payTo as `0x${string}`, + amount: BigInt(payload.requirements.amount), + nonce: FIXED_NONCE, + expires: nowSec + 3600n, + paymentIntent: 'unknown', + }, + signature: signed.signature as `0x${string}`, + }); + expect(ok).toBe(true); + }); + + // ------------------------------------------------------------------------- + // Test 6: signMPP — different typed-data ≠ signX402 output + // ------------------------------------------------------------------------- + + it('[UNIT] signMPP — should produce a different signature than signX402 for identical inputs (proves it uses different typed-data)', async () => { + const handler = new X402Handler(TEST_PRIVATE_KEY); + const requirements = buildRequirements(); + + const x402Signed = await handler.signPayment(buildPayload('x402', requirements)); + const mppSigned = await handler.signPayment(buildPayload('mpp', requirements)); + + expect(x402Signed.signature).not.toBe(mppSigned.signature); + expect(x402Signed.protocol).toBe('x402'); + expect(mppSigned.protocol).toBe('mpp'); + }); +}); + +// =========================================================================== +// Tests 7-13: signPayment dispatcher, errors, and dual-mode regression +// =========================================================================== + +describe('[UNIT] signPayment dispatcher', () => { + // ------------------------------------------------------------------------- + // Test 7: dispatcher → signX402 + // ------------------------------------------------------------------------- + + it("[UNIT] signPayment — should dispatch to signX402 only when protocol is 'x402' and return identical shape to legacy behavior", async () => { + const handler = new X402Handler(TEST_PRIVATE_KEY); + const payload = buildPayload('x402'); + + const signed = await handler.signPayment(payload); + + expect(signed.protocol).toBe('x402'); + const sd = signed.signedData as Record; + // x402-shaped keys present. + expect(sd).toHaveProperty('from'); + expect(sd).toHaveProperty('to'); + expect(sd).toHaveProperty('value'); + expect(sd).toHaveProperty('validAfter'); + expect(sd).toHaveProperty('validBefore'); + expect(sd).toHaveProperty('nonce'); + // MPP-shaped keys absent. + expect(sd).not.toHaveProperty('payer'); + expect(sd).not.toHaveProperty('recipient'); + expect(sd).not.toHaveProperty('expires'); + // Dual-shaped keys absent. + expect(sd).not.toHaveProperty('x402'); + expect(sd).not.toHaveProperty('mpp'); + }); + + // ------------------------------------------------------------------------- + // Test 8: dispatcher → signMPP + // ------------------------------------------------------------------------- + + it("[UNIT] signPayment — should dispatch to signMPP only when protocol is 'mpp' and return identical shape to legacy behavior", async () => { + const handler = new X402Handler(TEST_PRIVATE_KEY); + const payload = buildPayload('mpp'); + + const signed = await handler.signPayment(payload); + + expect(signed.protocol).toBe('mpp'); + const sd = signed.signedData as Record; + // MPP-shaped keys present. + expect(sd).toHaveProperty('payer'); + expect(sd).toHaveProperty('recipient'); + expect(sd).toHaveProperty('amount'); + expect(sd).toHaveProperty('nonce'); + expect(sd).toHaveProperty('expires'); + expect(sd).toHaveProperty('paymentIntent'); + // x402-shaped keys absent. + expect(sd).not.toHaveProperty('from'); + expect(sd).not.toHaveProperty('validAfter'); + expect(sd).not.toHaveProperty('validBefore'); + // Dual-shaped keys absent. + expect(sd).not.toHaveProperty('x402'); + expect(sd).not.toHaveProperty('mpp'); + }); + + // ------------------------------------------------------------------------- + // Test 9: dispatcher → dual (BOTH helpers, packed signedData) + // ------------------------------------------------------------------------- + + it("[UNIT] signPayment — should call BOTH signX402 and signMPP when protocol is 'dual' and pack signedData as { x402, mpp } with x402 signature as primary", async () => { + const handler = new X402Handler(TEST_PRIVATE_KEY); + const payload = buildPayload('dual'); + + const signed = await handler.signPayment(payload); + + expect(signed.protocol).toBe('dual'); + const sd = signed.signedData as Record; + expect(sd).toHaveProperty('x402'); + expect(sd).toHaveProperty('mpp'); + + const x402Block = sd.x402 as Record; + const mppBlock = sd.mpp as Record; + + // x402 block carries x402-shape; mpp block carries mpp-shape. + expect(x402Block).toHaveProperty('from'); + expect(x402Block).toHaveProperty('signature'); + expect(mppBlock).toHaveProperty('payer'); + expect(mppBlock).toHaveProperty('signature'); + + // Primary signature = the x402 signature. + expect(signed.signature).toBe(x402Block.signature); + + // The two inner signatures MUST differ (different typed-data). + expect(x402Block.signature).not.toBe(mppBlock.signature); + }); + + // ------------------------------------------------------------------------- + // Test 10: dispatcher → unsupported protocol throws + // ------------------------------------------------------------------------- + + it('[UNIT] signPayment — should throw PayBotApiError with code UNSUPPORTED_PROTOCOL and status 402 for unknown protocol values', async () => { + const handler = new X402Handler(TEST_PRIVATE_KEY); + // Cast through `unknown` to bypass the TS union; we are testing the + // runtime default arm. + const payload = buildPayload( + 'not-a-real-protocol' as unknown as PaymentIntent['protocol'], + ); + + await expect(handler.signPayment(payload)).rejects.toBeInstanceOf( + PayBotApiError, + ); + await expect(handler.signPayment(payload)).rejects.toMatchObject({ + code: 'UNSUPPORTED_PROTOCOL', + statusCode: 402, + }); + await expect(handler.signPayment(payload)).rejects.toThrow( + /not-a-real-protocol/, + ); + }); + + // ------------------------------------------------------------------------- + // Test 11: dispatcher → MISSING_WALLET_KEY when no key configured + // ------------------------------------------------------------------------- + + it('[UNIT] signPayment — should throw PayBotApiError with code MISSING_WALLET_KEY when walletPrivateKey is not configured', async () => { + const handler = new X402Handler(); // no key + const payload = buildPayload('x402'); + + await expect(handler.signPayment(payload)).rejects.toMatchObject({ + name: 'PayBotApiError', + code: 'MISSING_WALLET_KEY', + statusCode: 402, + }); + }); + + // ------------------------------------------------------------------------- + // Test 12: REGRESSION — dual-mode MPP must be a real cryptographic signature + // ------------------------------------------------------------------------- + + it('[UNIT] signPayment — dual-mode mpp signature should be a real cryptographic signature, not metadata (proves dead-code bug is fixed)', async () => { + const handler = new X402Handler(TEST_PRIVATE_KEY); + const payload = buildPayload('dual'); + + const signed = await handler.signPayment(payload); + const sd = signed.signedData as Record; + const mppBlock = sd.mpp as Record; + + expect(mppBlock.signature).toMatch(/^0x[0-9a-fA-F]{130}$/); + + // The single MOST IMPORTANT assertion in this story: + // run EIP-712 signature recovery against the MPP typed-data + // structure, with the FIXED nonce + FIXED timestamp, and confirm the + // recovered address matches the test private key's account. + // + // If `signedData.mpp.signature` were inert metadata (the pre-refactor + // bug), this recovery would either fail or recover a wrong address. + const nowSec = BigInt(Math.floor(FIXED_NOW.getTime() / 1000)); + const recovered = await recoverTypedDataAddress({ + domain: buildMppDomain(payload.requirements.payTo), + types: MPP_TYPES, + primaryType: 'PaymentAuthorization', + message: { + payer: TEST_ACCOUNT_ADDRESS, + recipient: payload.requirements.payTo as `0x${string}`, + amount: BigInt(payload.requirements.amount), + nonce: FIXED_NONCE, + expires: nowSec + 3600n, + paymentIntent: payload.paymentIntent.intentId, + }, + signature: mppBlock.signature as `0x${string}`, + }); + expect(recovered.toLowerCase()).toBe(TEST_ACCOUNT_ADDRESS.toLowerCase()); + }); + + // ------------------------------------------------------------------------- + // Test 13: REGRESSION — dual-mode x402 signature == x402-only signature + // ------------------------------------------------------------------------- + + it('[UNIT] signPayment — dual-mode x402 signature byte-for-byte equals x402-only signature for the same inputs (proves refactor is non-breaking)', async () => { + const handler = new X402Handler(TEST_PRIVATE_KEY); + const requirements = buildRequirements(); + + // Same inputs (same nonce, same timestamp, same private key) — the x402 + // signature MUST be byte-for-byte identical whether it was produced by + // `protocol=x402` or by the `protocol=dual` extracted helper. + const x402Only = await handler.signPayment(buildPayload('x402', requirements)); + const dual = await handler.signPayment(buildPayload('dual', requirements)); + + const dualSd = dual.signedData as Record; + const dualX402Block = dualSd.x402 as Record; + + expect(dualX402Block.signature).toBe(x402Only.signature); + expect(dual.signature).toBe(x402Only.signature); // primary == x402 + // The complete x402-shaped sub-object must match the standalone output. + expect(dualX402Block).toEqual(x402Only.signedData); + }); +});