Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/xlayer-x402-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nansen-cli": minor
---

Add X Layer (`eip155:196`) support for x402 payments. Low-balance warnings now resolve the correct RPC and token (e.g USDG/USDT) from the payment requirements instead of assuming Base USDC.
2 changes: 1 addition & 1 deletion src/__tests__/coverage.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ describe('Supported Chains Coverage', () => {
'ethereum', 'solana', 'base', 'bnb', 'arbitrum',
'polygon', 'optimism', 'avalanche', 'linea', 'scroll',
'mantle', 'ronin', 'sei', 'plasma',
'sonic', 'monad', 'hyperevm', 'iotaevm'
'sonic', 'monad', 'hyperevm', 'iotaevm', 'xlayer'
];

it('should document all supported chains', () => {
Expand Down
22 changes: 22 additions & 0 deletions src/__tests__/x402-api-retry.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,28 @@ describe('NansenAPI._x402Retry', () => {
expect(requestInit.headers['Payment-Signature']).toBe('my-payment-sig');
});

it('passes asset to checkX402Balance when provided', async () => {
mockFetch.mockResolvedValue({ ok: true, json: async () => ({ success: true }) });

// Mock checkX402Balance to verify it receives asset
const checkX402Balance = vi.fn().mockResolvedValue(0.10);
vi.doMock('../x402.js', () => ({ checkX402Balance }));

// Re-import to pick up the mock
const { NansenAPI: MockedAPI } = await import('../api.js');
const api = new MockedAPI('test-key', 'https://api.nansen.ai');
await api._x402Retry(
'test-sig', 'local wallet test', 'eip155:196',
'https://api.nansen.ai/test', {},
{}, '0x4ae46a509f6b1d9056937ba4500cb143933d2dc8',
);

expect(checkX402Balance).toHaveBeenCalledWith(
'eip155:196',
'0x4ae46a509f6b1d9056937ba4500cb143933d2dc8',
);
});

it('propagates json() rejection when the paid response body is not valid JSON', async () => {
// Regression for e918bdd: the explicit await ensures a parsing failure
// surfaces as a clean rejection from _x402Retry rather than being lost.
Expand Down
123 changes: 123 additions & 0 deletions src/__tests__/x402-xlayer.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* Tests for X Layer x402 payment support
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { createEvmPaymentPayload, isEvmNetwork } from '../x402-evm.js';
import { generateEvmWallet } from '../wallet.js';
import * as walletModule from '../wallet.js';

describe('x402 EVM with X Layer (eip155:196)', () => {
it('isEvmNetwork recognizes X Layer', () => {
expect(isEvmNetwork('eip155:196')).toBe(true);
});

it('createEvmPaymentPayload produces valid payload for X Layer USDG', () => {
const wallet = generateEvmWallet();
const requirements = {
scheme: 'exact',
network: 'eip155:196',
asset: '0x4ae46a509f6b1d9056937ba4500cb143933d2dc8',
amount: '100000',
pay_to: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
extra: { name: 'USDG', version: '1' },
};

const result = createEvmPaymentPayload(
requirements,
wallet.privateKey,
wallet.address,
'https://api.nansen.ai/v1/test',
);

const decoded = JSON.parse(Buffer.from(result, 'base64').toString('utf8'));
expect(decoded.x402Version).toBe(2);
expect(decoded.accepted.network).toBe('eip155:196');
expect(decoded.accepted.asset).toBe('0x4ae46a509f6b1d9056937ba4500cb143933d2dc8');
expect(decoded.payload.authorization.from).toBe(wallet.address);
expect(decoded.payload.signature).toMatch(/^0x/);
});

it('createEvmPaymentPayload produces valid payload for X Layer USDT', () => {
const wallet = generateEvmWallet();
const requirements = {
scheme: 'exact',
network: 'eip155:196',
asset: '0x779ded0c9e1022225f8e0630b35a9b54be713736',
amount: '50000',
pay_to: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
extra: { name: 'USDT', version: '1' },
};

const result = createEvmPaymentPayload(
requirements,
wallet.privateKey,
wallet.address,
'https://api.nansen.ai/v1/test',
);

const decoded = JSON.parse(Buffer.from(result, 'base64').toString('utf8'));
expect(decoded.accepted.network).toBe('eip155:196');
expect(decoded.accepted.asset).toBe('0x779ded0c9e1022225f8e0630b35a9b54be713736');
});
});

describe('checkX402Balance with X Layer', () => {
let mockFetch;
beforeEach(() => {
mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
vi.spyOn(walletModule, 'listWallets').mockReturnValue({
defaultWallet: 'test',
wallets: [{ name: 'test', evm: '0x1234567890abcdef1234567890abcdef12345678' }],
});
});

afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

it('queries the correct RPC and asset for X Layer (eip155:196)', async () => {
// Balance = 5.0 USDG (5000000 raw, 6 decimals)
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({ jsonrpc: '2.0', id: 1, result: '0x' + (5000000).toString(16) }),
});

const { checkX402Balance } = await import('../x402.js');
const balance = await checkX402Balance(
'eip155:196',
'0x4ae46a509f6b1d9056937ba4500cb143933d2dc8',
);

expect(balance).toBe(5);

// Verify fetch was called with X Layer RPC (okx.com), not Base RPC
const [fetchUrl, fetchOpts] = mockFetch.mock.calls[0];
expect(fetchUrl).toContain('xlayerrpc.okx.com');
const fetchBody = JSON.parse(fetchOpts.body);
expect(fetchBody.params[0].to).toBe('0x4ae46a509f6b1d9056937ba4500cb143933d2dc8');
});

it('returns null for unknown EVM chain', async () => {
const { checkX402Balance } = await import('../x402.js');
const balance = await checkX402Balance('eip155:99999', '0xdeadbeef');
expect(balance).toBeNull();
});

it('falls back to Base USDC when asset is not provided', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({ jsonrpc: '2.0', id: 1, result: '0x' + (1000000).toString(16) }),
});

const { checkX402Balance } = await import('../x402.js');
const balance = await checkX402Balance('eip155:8453');
expect(balance).toBe(1);

const [, fetchOpts] = mockFetch.mock.calls[0];
const fetchBody = JSON.parse(fetchOpts.body);
expect(fetchBody.params[0].to).toBe('0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913');
});
});
26 changes: 26 additions & 0 deletions src/__tests__/xlayer.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Tests for X Layer chain support
*/

import { describe, it, expect } from 'vitest';
import { EVM_CHAIN_IDS, EVM_CHAINS } from '../chain-ids.js';
import { CHAIN_RPCS } from '../rpc-urls.js';

describe('X Layer chain configuration', () => {
it('has chain ID 196 in EVM_CHAIN_IDS', () => {
expect(EVM_CHAIN_IDS.xlayer).toBe(196);
});

it('is listed in EVM_CHAINS', () => {
expect(EVM_CHAINS).toContain('xlayer');
});

it('has an RPC URL configured', () => {
expect(CHAIN_RPCS.xlayer).toBeDefined();
expect(CHAIN_RPCS.xlayer).toMatch(/^https:\/\//);
});

it('RPC URL points to okx.com', () => {
expect(CHAIN_RPCS.xlayer).toContain('xlayerrpc.okx.com');
});
});
17 changes: 9 additions & 8 deletions src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -471,13 +471,14 @@ export class NansenAPI {
* @param {string} url - Request URL
* @param {object} body - Request body (will be cleaned)
* @param {object} [options={}] - Request options (may include .headers)
* @param {string|null} [asset=null] - Asset used for payment (e.g. "USDC"), passed to checkX402Balance for post-payment balance check
* @returns {Promise<object|null>} Parsed JSON on success, null if rejected
*
* TODO: full fix — extract the entire x402 provider dispatch from request() into
* an attemptX402Payment() method so adding a new payment provider only requires
* touching that one method, not hunting inside the retry loop.
*/
async _x402Retry(signature, walletLabel, network, url, body, options = {}) {
async _x402Retry(signature, walletLabel, network, url, body, options = {}, asset = null) {
const paidResponse = await fetch(url, {
method: 'POST',
headers: {
Expand All @@ -498,9 +499,9 @@ export class NansenAPI {
if (network) {
try {
const { checkX402Balance } = await import('./x402.js');
const balance = await checkX402Balance(network);
if (balance !== null && balance < 0.25) {
console.error(`[x402] Warning: USDC balance low ($${balance.toFixed(2)}). Fund your wallet to avoid interruptions.`);
const balance = await checkX402Balance(network, asset);
if (balance !== null && balance < 0.0001) {
console.error(`[x402] Warning: Stablecoin balance low ($${balance.toFixed(2)}). Fund your wallet to avoid interruptions.`);
}
} catch { /* balance check is best-effort */ }
}
Expand Down Expand Up @@ -623,8 +624,8 @@ export class NansenAPI {
// Default wallet is Privy: sign via Privy
try {
const { createPrivyPaymentSignatures } = await import('./privy.js');
for await (const { signature, network } of createPrivyPaymentSignatures(response, url)) {
const result = await this._x402Retry(signature, `Privy wallet ${defaultWalletName}`, network, url, body, options);
for await (const { signature, network, asset } of createPrivyPaymentSignatures(response, url)) {
const result = await this._x402Retry(signature, `Privy wallet ${defaultWalletName}`, network, url, body, options, asset);
if (result !== null) return result;
}
} catch (privyErr) {
Expand All @@ -635,8 +636,8 @@ export class NansenAPI {
// 1. Try local wallet with fallback across payment networks
try {
const { createPaymentSignatures } = await import('./x402.js');
for await (const { signature, network } of createPaymentSignatures(response, url)) {
const result = await this._x402Retry(signature, `local wallet ${defaultWalletName}`, network, url, body, options);
for await (const { signature, network, asset } of createPaymentSignatures(response, url)) {
const result = await this._x402Retry(signature, `local wallet ${defaultWalletName}`, network, url, body, options, asset);
if (result !== null) return result;
// This payment option was rejected, try next
}
Expand Down
3 changes: 2 additions & 1 deletion src/chain-ids.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const EVM_CHAIN_IDS = {
linea: 59144,
scroll: 534352,
mantle: 5000,
xlayer: 196,
};

/**
Expand All @@ -25,5 +26,5 @@ export const EVM_CHAIN_IDS = {
export const EVM_CHAINS = [
'ethereum', 'arbitrum', 'base', 'bnb', 'polygon', 'optimism',
'avalanche', 'linea', 'scroll', 'mantle', 'ronin',
'sei', 'plasma', 'sonic', 'monad', 'hyperevm', 'iotaevm',
'sei', 'plasma', 'sonic', 'monad', 'hyperevm', 'iotaevm', 'xlayer',
];
4 changes: 2 additions & 2 deletions src/privy.js
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ export async function* createPrivyPaymentSignatures(response, url) {
accepted: requirement,
});

yield { signature: header, network: requirement.network };
yield { signature: header, network: requirement.network, asset: requirement.asset };
} catch (err) {
console.error(`[x402] Privy EVM signing failed for ${requirement.network}: ${err.message}`);
continue;
Expand Down Expand Up @@ -355,7 +355,7 @@ export async function* createPrivyPaymentSignatures(response, url) {
}

const header = Buffer.from(JSON.stringify(payload)).toString("base64");
yield { signature: header, network: requirement.network };
yield { signature: header, network: requirement.network, asset: requirement.asset };
} catch (err) {
console.error(`[x402] Privy Solana signing failed for ${requirement.network}: ${err.message}`);
continue;
Expand Down
3 changes: 3 additions & 0 deletions src/rpc-urls.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
* NANSEN_EVM_RPC Custom Ethereum RPC (also used as generic EVM fallback)
* NANSEN_BASE_RPC Custom Base RPC
* NANSEN_SOLANA_RPC Custom Solana RPC
* NANSEN_XLAYER_RPC Custom X Layer RPC
*
* Backward-compat aliases (deprecated — prefer the forms above):
* NANSEN_RPC_BASE Old name for NANSEN_BASE_RPC; trading.js previously read this
Expand All @@ -20,10 +21,12 @@
const DEFAULT_EVM_RPC = 'https://eth.public-rpc.com';
const DEFAULT_BASE_RPC = 'https://mainnet.base.org';
const DEFAULT_SOLANA_RPC = 'https://api.mainnet-beta.solana.com';
const DEFAULT_XLAYER_RPC = 'https://xlayerrpc.okx.com';

export const CHAIN_RPCS = {
ethereum: process.env.NANSEN_EVM_RPC || DEFAULT_EVM_RPC,
evm: process.env.NANSEN_EVM_RPC || DEFAULT_EVM_RPC, // generic EVM fallback
base: process.env.NANSEN_BASE_RPC || process.env.NANSEN_RPC_BASE || DEFAULT_BASE_RPC,
solana: process.env.NANSEN_SOLANA_RPC || DEFAULT_SOLANA_RPC,
xlayer: process.env.NANSEN_XLAYER_RPC || DEFAULT_XLAYER_RPC,
};
3 changes: 2 additions & 1 deletion src/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1222,7 +1222,8 @@
"sonic",
"monad",
"hyperevm",
"iotaevm"
"iotaevm",
"xlayer"
],
"smartMoneyLabels": [
"Fund",
Expand Down
2 changes: 1 addition & 1 deletion src/transfer.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { CHAIN_RPCS } from './rpc-urls.js';

// ============= Constants =============

const PRIORITY_FEE_DEFAULTS = { base: 100000000n, ethereum: 1500000000n, evm: 1500000000n };
const PRIORITY_FEE_DEFAULTS = { base: 100000000n, ethereum: 1500000000n, evm: 1500000000n, xlayer: 100000000n };

const ERC20_TRANSFER_SELECTOR = 'a9059cbb'; // transfer(address,uint256)
const SYSTEM_PROGRAM = '11111111111111111111111111111111'; // 32 zero bytes in base58
Expand Down
31 changes: 23 additions & 8 deletions src/x402.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export function parsePaymentRequirements(response) {
if (!header) return null;

try {
const decoded = JSON.parse(atob(header));
const decoded = JSON.parse(Buffer.from(header, 'base64').toString('utf8'));
// V2 format: { accepts: [...], ... }
if (decoded.accepts && Array.isArray(decoded.accepts)) {
return decoded.accepts;
Expand Down Expand Up @@ -130,7 +130,7 @@ export async function* createPaymentSignatures(response, url, options = {}) {
for (const req of ranked) {
try {
const sig = await buildPaymentForRequirement(req, exported, url);
if (sig) yield { signature: sig, network: req.network };
if (sig) yield { signature: sig, network: req.network, asset: req.asset };
} catch {
// This payment option failed to build, try next
continue;
Expand All @@ -155,16 +155,26 @@ export async function createPaymentSignature(response, url, options = {}) {
}

/**
* Check USDC balance for x402 payment wallet.
* Map EVM chainId to RPC URL for balance checks.
*/
const CHAIN_ID_TO_RPC = {
8453: () => CHAIN_RPCS.base,
196: () => CHAIN_RPCS.xlayer,
};

/**
* Check stablecoin balance for x402 payment wallet.
* Returns balance in USD (number) or null if check fails.
*
* @param {string} network - CAIP-2 network, e.g. "eip155:8453" or "solana:..."
* @param {string} [asset] - Token contract address. EVM only: falls back to Base USDC if omitted.
*/
export async function checkX402Balance(network) {
export async function checkX402Balance(network, asset) {
try {
const { listWallets, exportWallet: _exportWallet } = await import('./wallet.js');
const wallets = listWallets();
if (!wallets.defaultWallet) return null;

// Find wallet addresses without needing password
const walletInfo = wallets.wallets.find(w => w.name === wallets.defaultWallet);
if (!walletInfo) return null;

Expand All @@ -188,16 +198,21 @@ export async function checkX402Balance(network) {
}

if (network.startsWith('eip155:')) {
// Base USDC balance check — RPC URL from shared registry so NANSEN_BASE_RPC override applies
const chainId = parseInt(network.split(':')[1], 10);
const rpcGetter = CHAIN_ID_TO_RPC[chainId];
if (!rpcGetter) return null;
const rpcUrl = rpcGetter();

const USDC_BASE = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913';
const tokenAddress = asset || USDC_BASE;
const addr = walletInfo.evm.replace('0x', '').toLowerCase().padStart(64, '0');
const resp = await fetch(CHAIN_RPCS.base, {
const resp = await fetch(rpcUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0', id: 1,
method: 'eth_call',
params: [{ to: USDC_BASE, data: `0x70a08231${addr}` }, 'latest'],
params: [{ to: tokenAddress, data: `0x70a08231${addr}` }, 'latest'],
}),
});
const data = await resp.json();
Expand Down