diff --git a/.changeset/xlayer-x402-support.md b/.changeset/xlayer-x402-support.md new file mode 100644 index 00000000..c24e4833 --- /dev/null +++ b/.changeset/xlayer-x402-support.md @@ -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. diff --git a/src/__tests__/coverage.test.js b/src/__tests__/coverage.test.js index c8937a27..f42cbf4c 100644 --- a/src/__tests__/coverage.test.js +++ b/src/__tests__/coverage.test.js @@ -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', () => { diff --git a/src/__tests__/x402-api-retry.test.js b/src/__tests__/x402-api-retry.test.js index 57385e23..b6a3a1eb 100644 --- a/src/__tests__/x402-api-retry.test.js +++ b/src/__tests__/x402-api-retry.test.js @@ -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. diff --git a/src/__tests__/x402-xlayer.test.js b/src/__tests__/x402-xlayer.test.js new file mode 100644 index 00000000..74646678 --- /dev/null +++ b/src/__tests__/x402-xlayer.test.js @@ -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'); + }); +}); diff --git a/src/__tests__/xlayer.test.js b/src/__tests__/xlayer.test.js new file mode 100644 index 00000000..80ff5823 --- /dev/null +++ b/src/__tests__/xlayer.test.js @@ -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'); + }); +}); diff --git a/src/api.js b/src/api.js index 675f1f0c..d05ea09e 100644 --- a/src/api.js +++ b/src/api.js @@ -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} 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: { @@ -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 */ } } @@ -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) { @@ -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 } diff --git a/src/chain-ids.js b/src/chain-ids.js index f774ff74..f6c001b0 100644 --- a/src/chain-ids.js +++ b/src/chain-ids.js @@ -15,6 +15,7 @@ export const EVM_CHAIN_IDS = { linea: 59144, scroll: 534352, mantle: 5000, + xlayer: 196, }; /** @@ -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', ]; diff --git a/src/privy.js b/src/privy.js index 88bc8b8c..d363bd24 100644 --- a/src/privy.js +++ b/src/privy.js @@ -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; @@ -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; diff --git a/src/rpc-urls.js b/src/rpc-urls.js index e14d4f19..a227f56f 100644 --- a/src/rpc-urls.js +++ b/src/rpc-urls.js @@ -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 @@ -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, }; diff --git a/src/schema.json b/src/schema.json index d8320691..b27a352e 100644 --- a/src/schema.json +++ b/src/schema.json @@ -1222,7 +1222,8 @@ "sonic", "monad", "hyperevm", - "iotaevm" + "iotaevm", + "xlayer" ], "smartMoneyLabels": [ "Fund", diff --git a/src/transfer.js b/src/transfer.js index f2931f9d..b8d6d65f 100644 --- a/src/transfer.js +++ b/src/transfer.js @@ -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 diff --git a/src/x402.js b/src/x402.js index b80b3604..6c919a4a 100644 --- a/src/x402.js +++ b/src/x402.js @@ -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; @@ -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; @@ -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; @@ -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();