Skip to content
Closed
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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

174 changes: 174 additions & 0 deletions src/__tests__/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
6 changes: 6 additions & 0 deletions src/__tests__/cli.internal.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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', () => {
Expand Down
4 changes: 2 additions & 2 deletions src/__tests__/cli.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
});

Expand Down Expand Up @@ -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' }
});

Expand Down
107 changes: 107 additions & 0 deletions src/__tests__/unit.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
});
});
});
Loading