diff --git a/package-lock.json b/package-lock.json index 4e7f004b..d5bfa115 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nansen-cli", - "version": "1.5.1", + "version": "1.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nansen-cli", - "version": "1.5.1", + "version": "1.6.0", "license": "MIT", "bin": { "nansen": "src/index.js" diff --git a/src/__tests__/api.test.js b/src/__tests__/api.test.js index d59d60fc..a8ad432a 100644 --- a/src/__tests__/api.test.js +++ b/src/__tests__/api.test.js @@ -1866,6 +1866,180 @@ describe('NansenAPI', () => { }); }); + // =================== x402 Auto-Payment =================== + + describe('x402 Auto-Payment', () => { + it('should auto-pay on 402 and retry successfully', async () => { + if (LIVE_TEST) return; + + const paymentReqs = { + accepts: [{ + scheme: 'exact', + asset: '0xUSDC', + payTo: '0xRecipient', + amount: '10000', + network: 'base', + maxTimeoutSeconds: 120, + extra: { name: 'USD Coin', version: '2', chainId: 8453, symbol: 'USDC', decimals: 6 }, + }], + }; + const paymentHeader = btoa(JSON.stringify(paymentReqs)); + + const errorResponse = { + ok: false, + status: 402, + json: async () => ({ message: 'Payment required' }), + headers: { get: (h) => h === 'payment-required' ? paymentHeader : null }, + }; + const successData = { netflows: [{ token_symbol: 'TEST' }] }; + const successResponse = { + ok: true, + text: async () => JSON.stringify(successData), + }; + + mockFetch + .mockResolvedValueOnce(errorResponse) + .mockResolvedValueOnce(successResponse); + + // Create API with autoPay enabled, and mock x402 module + const autoPayApi = new NansenAPI('test-key', 'https://api.nansen.ai', { autoPay: true }); + + // Mock the dynamic import of x402.js + const originalImport = vi.fn(); + const mockHandleX402Payment = vi.fn().mockResolvedValue('mock-payment-sig'); + vi.doMock('../x402.js', () => ({ handleX402Payment: mockHandleX402Payment })); + + const result = await autoPayApi.smartMoneyNetflow({}); + expect(result._meta?.x402Paid).toBe(true); + expect(result.netflows).toBeDefined(); + + // Verify the retry had the Payment-Signature header + expect(mockFetch).toHaveBeenCalledTimes(2); + const retryCall = mockFetch.mock.calls[1]; + expect(retryCall[1].headers['Payment-Signature']).toBe('mock-payment-sig'); + + vi.doUnmock('../x402.js'); + }); + + it('should fall through to manual error when autoPay is disabled', async () => { + if (LIVE_TEST) return; + + const paymentReqs = { + accepts: [{ + scheme: 'exact', + asset: '0xUSDC', + payTo: '0xRecipient', + amount: '10000', + network: 'base', + extra: { name: 'USD Coin', version: '2', chainId: 8453 }, + }], + }; + const paymentHeader = btoa(JSON.stringify(paymentReqs)); + + const errorResponse = { + ok: false, + status: 402, + json: async () => ({ message: 'Payment required' }), + headers: { get: (h) => h === 'payment-required' ? paymentHeader : null }, + }; + + mockFetch.mockResolvedValueOnce(errorResponse); + + const noAutoPayApi = new NansenAPI('test-key', 'https://api.nansen.ai', { autoPay: false }); + + let thrownError; + try { + await noAutoPayApi.smartMoneyNetflow({}); + } catch (err) { + thrownError = err; + } + + expect(thrownError).toBeDefined(); + expect(thrownError.code).toBe(ErrorCode.PAYMENT_REQUIRED); + expect(thrownError.message).toContain('x402-payment-signature'); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('should fall through when manual Payment-Signature header is set', async () => { + if (LIVE_TEST) return; + + const paymentReqs = { accepts: [{ scheme: 'exact', asset: '0xUSDC', payTo: '0xR', amount: '1', extra: { name: 'X', version: '1', chainId: 1 } }] }; + const paymentHeader = btoa(JSON.stringify(paymentReqs)); + + const errorResponse = { + ok: false, + status: 402, + json: async () => ({ message: 'Payment required' }), + headers: { get: (h) => h === 'payment-required' ? paymentHeader : null }, + }; + + mockFetch.mockResolvedValueOnce(errorResponse); + + const manualApi = new NansenAPI('test-key', 'https://api.nansen.ai', { + autoPay: true, + defaultHeaders: { 'Payment-Signature': 'manual-sig' }, + }); + + let thrownError; + try { + await manualApi.smartMoneyNetflow({}); + } catch (err) { + thrownError = err; + } + + expect(thrownError).toBeDefined(); + expect(thrownError.code).toBe(ErrorCode.PAYMENT_REQUIRED); + // Should use the manual error message, not attempt auto-pay + expect(thrownError.message).toContain('x402-payment-signature'); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('should propagate x402 auto-pay failure as error', async () => { + if (LIVE_TEST) return; + + const paymentReqs = { + accepts: [{ + scheme: 'exact', + asset: '0xUSDC', + payTo: '0xR', + amount: '1', + network: 'base', + extra: { name: 'X', version: '1', chainId: 1 }, + }], + }; + const paymentHeader = btoa(JSON.stringify(paymentReqs)); + + const errorResponse = { + ok: false, + status: 402, + json: async () => ({ message: 'Payment required' }), + headers: { get: (h) => h === 'payment-required' ? paymentHeader : null }, + }; + + mockFetch.mockResolvedValueOnce(errorResponse); + + // Mock x402 to throw + vi.doMock('../x402.js', () => ({ + handleX402Payment: vi.fn().mockRejectedValue(new Error('No wallet connected')), + })); + + const autoPayApi = new NansenAPI('test-key', 'https://api.nansen.ai', { autoPay: true }); + + let thrownError; + try { + await autoPayApi.smartMoneyNetflow({}); + } catch (err) { + thrownError = err; + } + + expect(thrownError).toBeDefined(); + expect(thrownError.code).toBe(ErrorCode.PAYMENT_REQUIRED); + expect(thrownError.message).toContain('auto-payment failed'); + + vi.doUnmock('../x402.js'); + }); + }); + // =================== Supported Chains =================== describe('Supported Chains', () => { diff --git a/src/__tests__/cli.internal.test.js b/src/__tests__/cli.internal.test.js index 120159ab..aae24eae 100644 --- a/src/__tests__/cli.internal.test.js +++ b/src/__tests__/cli.internal.test.js @@ -40,6 +40,11 @@ describe('parseArgs', () => { expect(result.flags).toEqual({ pretty: true, table: true, 'no-retry': true }); }); + it('should parse --no-auto-pay flag', () => { + const result = parseArgs(['token', 'screener', '--no-auto-pay']); + expect(result.flags['no-auto-pay']).toBe(true); + }); + it('should parse short flags', () => { const result = parseArgs(['-p', '-t']); expect(result.flags).toEqual({ p: true, t: true }); @@ -1165,6 +1170,7 @@ describe('SCHEMA', () => { expect(SCHEMA.globalOptions.table).toBeDefined(); expect(SCHEMA.globalOptions.fields).toBeDefined(); expect(SCHEMA.globalOptions['no-retry']).toBeDefined(); + expect(SCHEMA.globalOptions['no-auto-pay']).toBeDefined(); }); it('should list supported chains', () => { diff --git a/src/__tests__/cli.test.js b/src/__tests__/cli.test.js index d312bb5a..2397290e 100644 --- a/src/__tests__/cli.test.js +++ b/src/__tests__/cli.test.js @@ -65,7 +65,7 @@ describe('CLI Smoke Tests', () => { // =================== JSON Output Format =================== it('should output valid JSON on error', () => { - const { stdout, stderr, exitCode } = runCLI('smart-money netflow', { + const { stdout, stderr, exitCode } = runCLI('smart-money netflow --no-auto-pay', { env: { NANSEN_API_KEY: 'invalid-key' } }); @@ -107,7 +107,7 @@ describe('CLI Smoke Tests', () => { // =================== Environment Variables =================== it('should use NANSEN_API_KEY from environment', () => { - const { stdout, stderr } = runCLI('smart-money netflow', { + const { stdout, stderr } = runCLI('smart-money netflow --no-auto-pay', { env: { NANSEN_API_KEY: 'test-env-key' } }); diff --git a/src/__tests__/unit.test.js b/src/__tests__/unit.test.js index b04b5103..e922e92e 100644 --- a/src/__tests__/unit.test.js +++ b/src/__tests__/unit.test.js @@ -12,6 +12,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { validateAddress, validateTokenAddress, saveConfig, deleteConfig, getConfigFile, getConfigDir, ErrorCode, NansenError } from '../api.js'; import { parseArgs, parseSort, formatValue } from '../cli.js'; +import { selectPaymentRequirement, buildEIP712TypedData, buildPaymentSignatureHeader } from '../x402.js'; import fs from 'fs'; import path from 'path'; import os from 'os'; @@ -429,3 +430,109 @@ describe('Error Codes', () => { }); }); }); + +// =================== x402 Payment =================== + +describe('x402 Payment', () => { + const MOCK_REQUIREMENT = { + scheme: 'exact', + network: 'eip155:8453', + asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + amount: '10000', + payTo: '0xRecipient', + maxTimeoutSeconds: 300, + extra: { + name: 'USD Coin', + version: '2', + }, + }; + + describe('selectPaymentRequirement', () => { + it('should select a requirement with scheme=exact and EIP-3009 support', () => { + const result = selectPaymentRequirement([MOCK_REQUIREMENT]); + expect(result).toEqual(MOCK_REQUIREMENT); + }); + + it('should return null for empty accepts array', () => { + expect(selectPaymentRequirement([])).toBeNull(); + }); + + it('should return null when no requirement has extra.name', () => { + const noName = { ...MOCK_REQUIREMENT, extra: { version: '2' } }; + expect(selectPaymentRequirement([noName])).toBeNull(); + }); + + it('should return null when scheme is not exact', () => { + const wrongScheme = { ...MOCK_REQUIREMENT, scheme: 'streaming' }; + expect(selectPaymentRequirement([wrongScheme])).toBeNull(); + }); + + it('should return null for null/undefined input', () => { + expect(selectPaymentRequirement(null)).toBeNull(); + expect(selectPaymentRequirement(undefined)).toBeNull(); + }); + + it('should pick the first compatible requirement from multiple', () => { + const other = { scheme: 'streaming', extra: {} }; + const result = selectPaymentRequirement([other, MOCK_REQUIREMENT]); + expect(result).toEqual(MOCK_REQUIREMENT); + }); + }); + + describe('buildEIP712TypedData', () => { + it('should produce correct EIP-712 structure', () => { + const typedData = buildEIP712TypedData({ + fromAddress: '0xSender', + requirement: MOCK_REQUIREMENT, + }); + + expect(typedData.primaryType).toBe('TransferWithAuthorization'); + expect(typedData.domain.name).toBe('USD Coin'); + expect(typedData.domain.version).toBe('2'); + expect(typedData.domain.chainId).toBe(8453); + expect(typedData.domain.verifyingContract).toBe('0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'); + expect(typedData.message.from).toBe('0xSender'); + expect(typedData.message.to).toBe('0xRecipient'); + expect(typedData.message.value).toBe('10000'); // from amount field + expect(typedData.message.validAfter).toBeGreaterThan(0); + expect(typedData.message.validBefore).toBeGreaterThan(typedData.message.validAfter); + expect(typedData.message.nonce).toMatch(/^0x[0-9a-f]{64}$/); + }); + + it('should include all required EIP-712 types', () => { + const typedData = buildEIP712TypedData({ + fromAddress: '0xSender', + requirement: MOCK_REQUIREMENT, + }); + + expect(typedData.types.EIP712Domain).toBeDefined(); + expect(typedData.types.TransferWithAuthorization).toBeDefined(); + expect(typedData.types.TransferWithAuthorization).toHaveLength(6); + }); + + it('should generate unique nonces', () => { + const td1 = buildEIP712TypedData({ fromAddress: '0xA', requirement: MOCK_REQUIREMENT }); + const td2 = buildEIP712TypedData({ fromAddress: '0xA', requirement: MOCK_REQUIREMENT }); + expect(td1.message.nonce).not.toBe(td2.message.nonce); + }); + }); + + describe('buildPaymentSignatureHeader', () => { + it('should produce valid base64 JSON with x402Version and required fields', () => { + const header = buildPaymentSignatureHeader({ + signature: '0xSig', + authorization: { from: '0xA', to: '0xB', value: '100', validAfter: 0, validBefore: 999, nonce: '0x123' }, + resource: { url: 'https://api.example.com/test', description: 'Test', mimeType: '' }, + accepted: MOCK_REQUIREMENT, + }); + + const decoded = JSON.parse(atob(header)); + expect(decoded.x402Version).toBe(2); + expect(decoded.resource.url).toBe('https://api.example.com/test'); + expect(decoded.accepted.scheme).toBe('exact'); + expect(decoded.payload.signature).toBe('0xSig'); + expect(decoded.payload.authorization.from).toBe('0xA'); + expect(decoded.payload.authorization.to).toBe('0xB'); + }); + }); +}); diff --git a/src/api.js b/src/api.js index e0bdf08d..8ac63933 100644 --- a/src/api.js +++ b/src/api.js @@ -396,6 +396,7 @@ export class NansenAPI { ttl: options.cache?.ttl ?? DEFAULT_CACHE_TTL }; this.defaultHeaders = options.defaultHeaders || {}; + this.autoPay = options.autoPay ?? true; } static cleanBody(body) { @@ -489,15 +490,65 @@ export class NansenAPI { } else if (code === ErrorCode.CREDITS_EXHAUSTED) { message = message.replace(/\.+$/, '') + '. No retry will help. Check your Nansen dashboard for credit balance.'; } else if (code === ErrorCode.PAYMENT_REQUIRED) { - message = 'Payment required (x402). Sign the paymentRequirements below per https://docs.x402.org and pass the result with --x402-payment-signature .'; + // Payment requirements can come from header (base64) or JSON body + let paymentRequirements; const paymentHeader = response.headers.get('payment-required'); if (paymentHeader) { try { - data.paymentRequirements = JSON.parse(atob(paymentHeader)); + paymentRequirements = JSON.parse(atob(paymentHeader)); } catch { data.paymentRequiredRaw = paymentHeader; } } + if (!paymentRequirements && data.paymentRequirements) { + paymentRequirements = data.paymentRequirements; + } + + // Auto-pay via WalletConnect if enabled and no manual signature was provided + const hasManualSignature = !!(this.defaultHeaders['Payment-Signature'] || options.headers?.['Payment-Signature']); + if (this.autoPay && !hasManualSignature && paymentRequirements) { + try { + const { handleX402Payment } = await import('./x402.js'); + const paymentSignature = await handleX402Payment(paymentRequirements); + // Retry the original request with the payment signature + const paidResponse = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Client-Type': 'nansen-cli', + 'X-Client-Version': packageVersion, + ...(this.apiKey ? { 'apikey': this.apiKey } : {}), + ...this.defaultHeaders, + ...options.headers, + 'Payment-Signature': paymentSignature + }, + body: JSON.stringify(NansenAPI.cleanBody(body)) + }); + let paidData; + const paidText = await paidResponse.text(); + try { + paidData = JSON.parse(paidText); + } catch { + // Non-JSON response from paid request + throw new Error(`Server returned ${paidResponse.status}: ${paidText.slice(0, 200)}`); + } + if (paidResponse.ok) { + paidData._meta = { ...(paidData._meta || {}), x402Paid: true }; + if (useCache) setCachedResponse(endpoint, body, paidData); + return paidData; + } + // Paid request also failed — fall through to normal error handling + data = paidData; + message = paidData.message || paidData.error || `API error after x402 payment: ${paidResponse.status}`; + } catch (x402Err) { + // Auto-pay failed — fall through to manual instructions + message = `x402 auto-payment failed: ${x402Err.message}`; + } + } else { + message = 'Payment required (x402). Sign the paymentRequirements below per https://docs.x402.org and pass the result with --x402-payment-signature .'; + } + + if (paymentRequirements) data.paymentRequirements = paymentRequirements; } lastError = new NansenError(message, code, response.status, { diff --git a/src/cli.js b/src/cli.js index 7fab07c3..84b41a3d 100644 --- a/src/cli.js +++ b/src/cli.js @@ -318,7 +318,8 @@ export const SCHEMA = { 'no-retry': { type: 'boolean', description: 'Disable automatic retry on rate limits/errors' }, retries: { type: 'number', default: 3, description: 'Max retry attempts' }, format: { type: 'string', enum: ['json', 'csv'], description: 'Output format (default: json)' }, - 'x402-payment-signature': { type: 'string', description: 'Pre-signed x402 payment signature header' } + 'x402-payment-signature': { type: 'string', description: 'Pre-signed x402 payment signature header' }, + 'no-auto-pay': { type: 'boolean', description: 'Disable automatic x402 payment via WalletConnect' } }, chains: ['ethereum', 'solana', 'base', 'bnb', 'arbitrum', 'polygon', 'optimism', 'avalanche', 'linea', 'scroll', 'mantle', 'ronin', 'sei', 'plasma', 'sonic', 'monad', 'hyperevm', 'iotaevm'], smartMoneyLabels: ['Fund', 'Smart Trader', '30D Smart Trader', '90D Smart Trader', '180D Smart Trader', 'Smart HL Perps Trader'] @@ -867,6 +868,7 @@ GLOBAL OPTIONS: --no-retry Disable automatic retry on rate limits/errors --retries Max retry attempts (default: 3) --x402-payment-signature Pre-signed x402 payment signature header + --no-auto-pay Disable automatic x402 payment via WalletConnect --cache Enable response caching (default: off) --no-cache Disable cache for this request --cache-ttl Cache TTL in seconds (default: 300) @@ -1572,7 +1574,8 @@ export async function runCLI(rawArgs, deps = {}) { if (options['x402-payment-signature']) { defaultHeaders['Payment-Signature'] = options['x402-payment-signature']; } - const api = new NansenAPIClass(undefined, undefined, { retry: retryOptions, cache: cacheOptions, defaultHeaders }); + const autoPay = !flags['no-auto-pay'] && !options['x402-payment-signature']; + const api = new NansenAPIClass(undefined, undefined, { retry: retryOptions, cache: cacheOptions, defaultHeaders, autoPay }); let result = await commands[command](subArgs, api, flags, options); // Apply field filtering if --fields is specified diff --git a/src/x402.js b/src/x402.js new file mode 100644 index 00000000..5014b81d --- /dev/null +++ b/src/x402.js @@ -0,0 +1,245 @@ +/** + * x402 Auto-Payment via WalletConnect + * + * Handles automatic payment signing when the API returns HTTP 402. + * Uses the walletconnect CLI to check wallet connection and sign EIP-712 typed data. + */ + +import { execFile } from 'child_process'; +import crypto from 'crypto'; +import { NansenError, ErrorCode } from './api.js'; + +// Chain name → EIP-155 chain ID mapping +const CHAIN_IDS = { + ethereum: 1, + base: 8453, + optimism: 10, + arbitrum: 42161, + polygon: 137, + avalanche: 43114, + bnb: 56, + linea: 59144, + scroll: 534352, + zksync: 324, + mantle: 5000, +}; + +/** + * Execute a CLI command and return stdout + */ +function exec(cmd, args, timeoutMs = 10000) { + return new Promise((resolve, reject) => { + execFile(cmd, args, { timeout: timeoutMs }, (err, stdout, stderr) => { + if (err) { + // Prefer the actual error message; stderr may just contain status logs + reject(new Error(err.message)); + return; + } + resolve(stdout.trim()); + }); + }); +} + +/** + * Check if a WalletConnect wallet session is active. + * Returns { wallet, accounts, expires } or null. + */ +export async function checkWalletConnection() { + try { + const output = await exec('walletconnect', ['whoami', '--json']); + const data = JSON.parse(output); + if (data.connected === false) return null; + return data; + } catch { + return null; + } +} + +/** + * Select a compatible payment requirement from the accepts array. + * Requires scheme=exact and EIP-3009 TransferWithAuthorization support (extra.name + extra.version). + */ +export function selectPaymentRequirement(accepts) { + if (!Array.isArray(accepts) || accepts.length === 0) return null; + + return accepts.find(req => + req.scheme === 'exact' && + req.extra?.name && + req.extra?.version + ) || null; +} + +/** + * Parse chain ID from network string (e.g., "eip155:8453" → 8453) + */ +function parseChainId(network) { + if (!network) return null; + const match = network.match(/^eip155:(\d+)$/); + return match ? Number(match[1]) : null; +} + +/** + * Build EIP-712 typed data for TransferWithAuthorization (EIP-3009). + */ +export function buildEIP712TypedData({ fromAddress, requirement }) { + const { asset, payTo, extra, maxTimeoutSeconds } = requirement; + // x402 uses "amount", fall back to "maxAmountRequired" for compatibility + const amount = requirement.amount || requirement.maxAmountRequired; + + // Determine chain ID: extra.chainId > parsed from network > fallback map > base + const chainId = extra.chainId || parseChainId(requirement.network) || CHAIN_IDS[requirement.chain] || CHAIN_IDS.base; + + const now = Math.floor(Date.now() / 1000); + const nonce = '0x' + crypto.randomBytes(32).toString('hex'); + + const typedData = { + types: { + EIP712Domain: [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, + ], + TransferWithAuthorization: [ + { name: 'from', type: 'address' }, + { name: 'to', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'validAfter', type: 'uint256' }, + { name: 'validBefore', type: 'uint256' }, + { name: 'nonce', type: 'bytes32' }, + ], + }, + primaryType: 'TransferWithAuthorization', + domain: { + name: extra.name, + version: extra.version, + chainId, + verifyingContract: asset, + }, + message: { + from: fromAddress, + to: payTo, + value: amount, + validAfter: now - 600, + validBefore: now + (maxTimeoutSeconds || 120), + nonce, + }, + }; + + return typedData; +} + +/** + * Build the base64-encoded Payment-Signature header value. + * Follows x402 v2 spec: { x402Version, resource, accepted, payload } + */ +export function buildPaymentSignatureHeader({ signature, authorization, resource, accepted }) { + const paymentPayload = { + x402Version: 2, + resource: resource || { url: '', description: '', mimeType: '' }, + accepted: accepted || {}, + payload: { + signature, + authorization, + }, + }; + return btoa(JSON.stringify(paymentPayload)); +} + +/** + * Format amount for human-readable display (e.g., "0.01 USDC") + */ +function formatPaymentAmount(requirement) { + const { extra } = requirement; + const rawAmount = requirement.amount || requirement.maxAmountRequired; + const symbol = extra.symbol || extra.name || 'tokens'; + const decimals = extra.decimals || 6; + const amount = Number(rawAmount) / Math.pow(10, decimals); + const chain = requirement.network || requirement.chain || 'unknown'; + return `${amount} ${symbol} on ${chain}`; +} + +/** + * Handle x402 payment: check wallet, sign, return Payment-Signature header. + * + * @param {Object} paymentRequirements - Decoded payment requirements from 402 response + * @param {string} requestUrl - The original request URL (for context in errors) + * @returns {string} Base64-encoded Payment-Signature header value + * @throws {NansenError} On failure + */ +export async function handleX402Payment(paymentRequirements) { + // 1. Check wallet connection + const wallet = await checkWalletConnection(); + if (!wallet) { + throw new NansenError( + 'x402 payment required but no wallet connected. Run `walletconnect connect` first.', + ErrorCode.PAYMENT_REQUIRED, + 402 + ); + } + + const fromAddress = wallet.accounts[0]?.address; + if (!fromAddress) { + throw new NansenError( + 'x402 payment required but wallet has no accounts.', + ErrorCode.PAYMENT_REQUIRED, + 402 + ); + } + + // 2. Select compatible payment requirement + const accepts = paymentRequirements.accepts || paymentRequirements; + const requirement = selectPaymentRequirement(Array.isArray(accepts) ? accepts : [accepts]); + if (!requirement) { + const available = (Array.isArray(accepts) ? accepts : []).map(r => r.scheme).join(', '); + throw new NansenError( + `x402 payment required but no compatible payment method found. Available: ${available || 'none'}. Need scheme=exact with EIP-3009 support.`, + ErrorCode.PAYMENT_REQUIRED, + 402 + ); + } + + // 3. Build EIP-712 typed data + const typedData = buildEIP712TypedData({ fromAddress, requirement }); + const typedDataJson = JSON.stringify(typedData); + + // 4. Log payment info to stderr (stdout is for JSON output) + const amountStr = formatPaymentAmount(requirement); + process.stderr.write(`x402: Requesting payment approval (${amountStr})...\n`); + + // 5. Sign via walletconnect CLI (120s timeout for user approval) + let signResult; + try { + const output = await exec('walletconnect', ['sign-typed-data', typedDataJson], 120000); + // walletconnect may print status messages before the JSON line — extract JSON only + const jsonLine = output.split('\n').find(line => line.startsWith('{')); + if (!jsonLine) throw new Error('No JSON output from walletconnect sign-typed-data'); + signResult = JSON.parse(jsonLine); + } catch (err) { + throw new NansenError( + `x402 payment signing failed: ${err.message}`, + ErrorCode.PAYMENT_REQUIRED, + 402 + ); + } + + // 6. Build Payment-Signature header (authorization values must be strings per x402 spec) + const authorization = { + from: fromAddress, + to: requirement.payTo, + value: (requirement.amount || requirement.maxAmountRequired).toString(), + validAfter: typedData.message.validAfter.toString(), + validBefore: typedData.message.validBefore.toString(), + nonce: typedData.message.nonce, + }; + + const headerValue = buildPaymentSignatureHeader({ + signature: signResult.signature, + authorization, + resource: paymentRequirements.resource || { url: '', description: '', mimeType: '' }, + accepted: requirement, + }); + + process.stderr.write(`x402: Payment signed successfully.\n`); + return headerValue; +}