Skip to content
Merged
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
361 changes: 214 additions & 147 deletions src/x402-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<string, unknown> }> {
// 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<string, unknown> = {
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<string, unknown> }> {
// 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<string, unknown> = {
payer: account.address,
recipient: requirements.payTo,
amount: requirements.amount,
nonce,
expires: nowSeconds.toString(),
paymentIntent: intentId,
signature,
};
Comment on lines +247 to +281
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The signMPP method contains logic discrepancies between the signed message and the returned signedData, which will cause verification failures for recipients.

  1. Expiration Mismatch: The signature is generated using nowSeconds + BigInt(3600), but signedData.expires is set to nowSeconds.toString(). This makes the payload appear expired immediately to any verifier, as the signed expiration time is 1 hour in the future while the metadata claims it expires now.
  2. Intent ID Mismatch: The signature uses intentId || 'unknown', but signedData.paymentIntent uses the raw intentId. If intentId is falsy (e.g., undefined or ''), the verifier will receive a value that doesn't match the one used for the signature recovery.

Extracting these to local variables ensures consistency between the cryptographic signature and the returned metadata.

    const nonce = generateEIP3009Nonce();
    const nowSeconds = BigInt(Math.floor(Date.now() / 1000));
    const expires = nowSeconds + BigInt(3600);
    const paymentIntent = intentId || 'unknown';

    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,
        paymentIntent,
      },
    });

    const signedData: Record<string, unknown> = {
      payer: account.address,
      recipient: requirements.payTo,
      amount: requirements.amount,
      nonce,
      expires: expires.toString(),
      paymentIntent,
      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: <x402-signedData>, mpp: <mpp-signedData> }` 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<SignedPayment> {
if (!this.walletPrivateKey) {
Expand All @@ -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<string, unknown>;

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 {
Expand Down
Loading
Loading