nansen-cli 1.32.0 → 1.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.33.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#457](https://github.com/nansen-ai/nansen-cli/pull/457) [`8149564`](https://github.com/nansen-ai/nansen-cli/commit/8149564f181dc7bfc9c66488ba2373df6f1aab5d) Thanks [@gulshngill](https://github.com/gulshngill)! - x402 on BNB Smart Chain: support all four stablecoins the API now advertises (U, USD1, USDT, USDC) and add Permit2 payment signing. Payments route on the 402's `extra.assetTransferMethod` — `eip3009` keeps the existing gasless flow (U, USD1), while `permit2-exact` (USDT, USDC on BSC) signs a Permit2 `PermitWitnessTransferFrom` against the spender contract advertised in the 402. Permit2 entries are skipped with an actionable message when the wallet hasn't made the one-time `approve(Permit2, …)` for the token. Post-payment balance warnings now check the exact token paid with (per-token decimals) instead of one hardcoded token per network.
8
+
9
+ ## 1.32.1
10
+
11
+ ### Patch Changes
12
+
13
+ - [#455](https://github.com/nansen-ai/nansen-cli/pull/455) [`875fabb`](https://github.com/nansen-ai/nansen-cli/commit/875fabb3f86736b03c874904e27a898315ef4bfa) Thanks [@gulshngill](https://github.com/gulshngill)! - Support x402 payments with USDT on BNB Smart Chain (`eip155:56`), which the Nansen API now advertises as a payment option. Payment signing already handled any EVM network generically; this adds BSC to the post-payment balance check with the correct token contract and 18-decimal precision (Base USDC and X Layer USDT0 use 6), plus a `bsc` entry in the shared RPC registry with a `NANSEN_BSC_RPC` override.
14
+
3
15
  ## 1.32.0
4
16
 
5
17
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nansen-cli",
3
- "version": "1.32.0",
3
+ "version": "1.33.0",
4
4
  "description": "AI-agent CLI for Nansen API analytics, DEX swaps, and cross-chain trading",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/api.js CHANGED
@@ -493,7 +493,7 @@ export class NansenAPI {
493
493
  * an attemptX402Payment() method so adding a new payment provider only requires
494
494
  * touching that one method, not hunting inside the retry loop.
495
495
  */
496
- async _x402Retry(signature, walletLabel, network, url, body, options = {}) {
496
+ async _x402Retry(signature, walletLabel, network, url, body, options = {}, asset = null) {
497
497
  const paidResponse = await fetch(url, {
498
498
  method: 'POST',
499
499
  headers: {
@@ -514,7 +514,7 @@ export class NansenAPI {
514
514
  if (network) {
515
515
  try {
516
516
  const { checkX402Balance } = await import('./x402.js');
517
- const result = await checkX402Balance(network);
517
+ const result = await checkX402Balance(network, asset);
518
518
  if (result !== null && result.balance < 0.25) {
519
519
  console.error(`[x402] Warning: ${result.symbol} balance low ($${result.balance.toFixed(2)}). Fund your wallet to avoid interruptions.`);
520
520
  }
@@ -651,8 +651,8 @@ export class NansenAPI {
651
651
  // 1. Try local wallet with fallback across payment networks
652
652
  try {
653
653
  const { createPaymentSignatures } = await import('./x402.js');
654
- for await (const { signature, network } of createPaymentSignatures(response, url)) {
655
- const result = await this._x402Retry(signature, `local wallet ${defaultWalletName}`, network, url, body, options);
654
+ for await (const { signature, network, asset } of createPaymentSignatures(response, url)) {
655
+ const result = await this._x402Retry(signature, `local wallet ${defaultWalletName}`, network, url, body, options, asset);
656
656
  if (result !== null) return result;
657
657
  // This payment option was rejected, try next
658
658
  }
package/src/rpc-urls.js CHANGED
@@ -8,6 +8,7 @@
8
8
  * Override env vars:
9
9
  * NANSEN_EVM_RPC Custom Ethereum RPC (also used as generic EVM fallback)
10
10
  * NANSEN_BASE_RPC Custom Base RPC
11
+ * NANSEN_BSC_RPC Custom BNB Smart Chain RPC
11
12
  * NANSEN_XLAYER_RPC Custom X Layer RPC
12
13
  * NANSEN_SOLANA_RPC Custom Solana RPC
13
14
  *
@@ -20,6 +21,7 @@
20
21
 
21
22
  const DEFAULT_EVM_RPC = 'https://eth.public-rpc.com';
22
23
  const DEFAULT_BASE_RPC = 'https://mainnet.base.org';
24
+ const DEFAULT_BSC_RPC = 'https://bsc-dataseed.binance.org';
23
25
  const DEFAULT_XLAYER_RPC = 'https://rpc.xlayer.tech';
24
26
  const DEFAULT_SOLANA_RPC = 'https://api.mainnet-beta.solana.com';
25
27
 
@@ -27,6 +29,7 @@ export const CHAIN_RPCS = {
27
29
  ethereum: process.env.NANSEN_EVM_RPC || DEFAULT_EVM_RPC,
28
30
  evm: process.env.NANSEN_EVM_RPC || DEFAULT_EVM_RPC, // generic EVM fallback
29
31
  base: process.env.NANSEN_BASE_RPC || process.env.NANSEN_RPC_BASE || DEFAULT_BASE_RPC,
32
+ bsc: process.env.NANSEN_BSC_RPC || DEFAULT_BSC_RPC,
30
33
  xlayer: process.env.NANSEN_XLAYER_RPC || DEFAULT_XLAYER_RPC,
31
34
  solana: process.env.NANSEN_SOLANA_RPC || DEFAULT_SOLANA_RPC,
32
35
  };
package/src/x402-evm.js CHANGED
@@ -25,6 +25,39 @@ const AUTHORIZATION_TYPES = [
25
25
  { name: 'nonce', type: 'bytes32' },
26
26
  ];
27
27
 
28
+ // ============= Permit2 (permit2-exact transfer method) =============
29
+ // Some tokens (e.g. USDT/USDC on BNB Smart Chain) predate EIP-3009, so
30
+ // facilitators settle them through Uniswap's canonical Permit2 contract
31
+ // instead: the payer signs a PermitWitnessTransferFrom and the facilitator's
32
+ // proxy (requirements.extra.spenderAddress) executes the transfer. Requires a
33
+ // one-time on-chain `token.approve(PERMIT2_ADDRESS, ...)` from the payer.
34
+
35
+ export const PERMIT2_ADDRESS = '0x000000000022D473030F116dDEE9F6B43aC78BA3';
36
+
37
+ // Permit2's EIP-712 domain has no version field.
38
+ const PERMIT2_DOMAIN_TYPES = [
39
+ { name: 'name', type: 'string' },
40
+ { name: 'chainId', type: 'uint256' },
41
+ { name: 'verifyingContract', type: 'address' },
42
+ ];
43
+
44
+ const TOKEN_PERMISSIONS_TYPES = [
45
+ { name: 'token', type: 'address' },
46
+ { name: 'amount', type: 'uint256' },
47
+ ];
48
+
49
+ const WITNESS_TYPES = [
50
+ { name: 'to', type: 'address' },
51
+ { name: 'validAfter', type: 'uint256' },
52
+ ];
53
+
54
+ // encodeType for a nested primary type: referenced struct types are appended
55
+ // in alphabetical order per EIP-712 (TokenPermissions before Witness).
56
+ const PERMIT_WITNESS_TYPE_STRING =
57
+ 'PermitWitnessTransferFrom(TokenPermissions permitted,address spender,uint256 nonce,uint256 deadline,Witness witness)' +
58
+ 'TokenPermissions(address token,uint256 amount)' +
59
+ 'Witness(address to,uint256 validAfter)';
60
+
28
61
  /**
29
62
  * Encode a type string for EIP-712 typeHash.
30
63
  * e.g. "TransferWithAuthorization(address from,address to,uint256 value,...)"
@@ -110,6 +143,38 @@ export function hashTypedData(domain, primaryType, fields, message) {
110
143
  ]));
111
144
  }
112
145
 
146
+ /**
147
+ * Compute the EIP-712 digest for a Permit2 PermitWitnessTransferFrom.
148
+ *
149
+ * Hand-rolled because the generic helpers above only support flat structs:
150
+ * the primary typeHash must cover the full type string including referenced
151
+ * structs, and struct-typed fields encode as their structHash.
152
+ *
153
+ * @param {number} chainId - EVM chain id (Permit2 domain field)
154
+ * @param {object} message - { permitted: {token, amount}, spender, nonce, deadline, witness: {to, validAfter} }
155
+ * @returns {Buffer} 32-byte digest to sign
156
+ */
157
+ export function hashPermit2WitnessTransfer(chainId, message) {
158
+ const domainSeparator = hashStruct('EIP712Domain', PERMIT2_DOMAIN_TYPES, {
159
+ name: 'Permit2',
160
+ chainId,
161
+ verifyingContract: PERMIT2_ADDRESS,
162
+ });
163
+ const structHash = keccak256(Buffer.concat([
164
+ keccak256(Buffer.from(PERMIT_WITNESS_TYPE_STRING, 'utf8')),
165
+ hashStruct('TokenPermissions', TOKEN_PERMISSIONS_TYPES, message.permitted),
166
+ encodeValue('address', message.spender),
167
+ encodeValue('uint256', message.nonce),
168
+ encodeValue('uint256', message.deadline),
169
+ hashStruct('Witness', WITNESS_TYPES, message.witness),
170
+ ]));
171
+ return keccak256(Buffer.concat([
172
+ Buffer.from([0x19, 0x01]),
173
+ domainSeparator,
174
+ structHash,
175
+ ]));
176
+ }
177
+
113
178
  // ============= x402 EVM Payment =============
114
179
 
115
180
  /**
@@ -123,7 +188,13 @@ function getChainId(network) {
123
188
  }
124
189
 
125
190
  /**
126
- * Create an x402 payment payload for EVM (EIP-3009 TransferWithAuthorization).
191
+ * Create an x402 payment payload for EVM.
192
+ *
193
+ * Routes on requirements.extra.assetTransferMethod: absent or "eip3009" signs
194
+ * an EIP-3009 TransferWithAuthorization (gasless); "permit2-exact" signs a
195
+ * Permit2 PermitWitnessTransferFrom (requires a prior one-time
196
+ * token.approve(PERMIT2_ADDRESS, ...)). Other methods throw so the fallback
197
+ * loop tries the next accepts entry.
127
198
  *
128
199
  * @param {object} requirements - Parsed PaymentRequirements from 402 response
129
200
  * @param {string} privateKeyHex - 32-byte EVM private key as hex
@@ -135,6 +206,14 @@ export function createEvmPaymentPayload(requirements, privateKeyHex, walletAddre
135
206
  const chainId = getChainId(requirements.network);
136
207
  const extra = requirements.extra || {};
137
208
 
209
+ const method = extra.assetTransferMethod;
210
+ if (method === 'permit2-exact') {
211
+ return createPermit2ExactPayload(requirements, privateKeyHex, walletAddress, resource);
212
+ }
213
+ if (method && method !== 'eip3009') {
214
+ throw new Error(`Unsupported assetTransferMethod: ${method}`);
215
+ }
216
+
138
217
  // Token name and version from requirements.extra (set by server/facilitator)
139
218
  const tokenName = extra.name;
140
219
  const tokenVersion = extra.version || '1';
@@ -199,6 +278,71 @@ export function createEvmPaymentPayload(requirements, privateKeyHex, walletAddre
199
278
  return Buffer.from(JSON.stringify(payload)).toString('base64');
200
279
  }
201
280
 
281
+ /**
282
+ * Create an x402 payment payload via Permit2 PermitWitnessTransferFrom
283
+ * (assetTransferMethod "permit2-exact").
284
+ *
285
+ * The spender is the facilitator's Permit2 proxy advertised in
286
+ * requirements.extra.spenderAddress; the witness binds the transfer to the
287
+ * merchant wallet (requirements.payTo). Wire-format numeric fields are
288
+ * decimal strings.
289
+ *
290
+ * @param {object} requirements - Parsed PaymentRequirements from 402 response
291
+ * @param {string} privateKeyHex - 32-byte EVM private key as hex
292
+ * @param {string} walletAddress - Signer's EVM address
293
+ * @param {string} resource - Original request URL
294
+ * @returns {string} Base64-encoded PaymentPayload for Payment-Signature header
295
+ */
296
+ export function createPermit2ExactPayload(requirements, privateKeyHex, walletAddress, resource) {
297
+ const chainId = getChainId(requirements.network);
298
+ const extra = requirements.extra || {};
299
+ const spender = extra.spenderAddress;
300
+ if (!spender) {
301
+ throw new Error('spenderAddress missing from requirements.extra (required for permit2-exact)');
302
+ }
303
+
304
+ const payTo = requirements.pay_to || requirements.payTo;
305
+ const now = Math.floor(Date.now() / 1000);
306
+ // 256-bit random nonce — Permit2 uses an unordered nonce bitmap.
307
+ const nonce = BigInt('0x' + crypto.randomBytes(32).toString('hex')).toString();
308
+ const deadline = String(now + 3600);
309
+ const validAfter = String(now - 60); // allow clock skew
310
+
311
+ const message = {
312
+ permitted: { token: requirements.asset, amount: BigInt(requirements.amount) },
313
+ spender,
314
+ nonce: BigInt(nonce),
315
+ deadline: BigInt(deadline),
316
+ witness: { to: payTo, validAfter: BigInt(validAfter) },
317
+ };
318
+
319
+ const msgHash = hashPermit2WitnessTransfer(chainId, message);
320
+ const { r, s, v } = signSecp256k1(msgHash, Buffer.from(privateKeyHex, 'hex'));
321
+ const signature = '0x' + r.toString('hex') + s.toString('hex') + (27 + v).toString(16);
322
+
323
+ const payload = {
324
+ x402Version: 2,
325
+ payload: {
326
+ permit2Authorization: {
327
+ permitted: { token: requirements.asset, amount: String(requirements.amount) },
328
+ from: walletAddress,
329
+ spender,
330
+ nonce,
331
+ deadline,
332
+ witness: { to: payTo, validAfter },
333
+ },
334
+ signature,
335
+ },
336
+ accepted: requirements,
337
+ };
338
+
339
+ if (resource) {
340
+ payload.resource = { url: resource };
341
+ }
342
+
343
+ return Buffer.from(JSON.stringify(payload)).toString('base64');
344
+ }
345
+
202
346
  /**
203
347
  * Check if a network string is an EVM network.
204
348
  */
package/src/x402.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * Supports EVM (EIP-3009 on Base) and Solana (SPL TransferChecked).
5
5
  */
6
6
 
7
- import { createEvmPaymentPayload, isEvmNetwork } from './x402-evm.js';
7
+ import { createEvmPaymentPayload, isEvmNetwork, PERMIT2_ADDRESS } from './x402-evm.js';
8
8
  import {
9
9
  createSvmPaymentPayload,
10
10
  isSvmNetwork,
@@ -55,12 +55,63 @@ function rankRequirements(requirements) {
55
55
  return ranked;
56
56
  }
57
57
 
58
+ // ERC-20 allowance(owner, spender) selector for the Permit2 preflight.
59
+ const ALLOWANCE_SELECTOR = '0xdd62ed3e';
60
+
61
+ /**
62
+ * Check whether `owner` has approved Permit2 to spend at least `amount` of
63
+ * `token`. Permit2-based payments are doomed without sufficient allowance
64
+ * (never approved, or a finite approval now below the payment amount), so
65
+ * skip those entries early instead of burning a failed verify round-trip.
66
+ * Returns true when the allowance is unknown (RPC failure) — let the server
67
+ * decide rather than block a possibly-valid payment.
68
+ */
69
+ async function hasPermit2Allowance(network, token, owner, amount) {
70
+ const rpc = getEvmRpcUrl(network);
71
+ if (!rpc) return true;
72
+ try {
73
+ const ownerArg = owner.replace(/^0x/, '').toLowerCase().padStart(64, '0');
74
+ const spenderArg = PERMIT2_ADDRESS.replace(/^0x/, '').toLowerCase().padStart(64, '0');
75
+ const resp = await fetch(rpc, {
76
+ method: 'POST',
77
+ headers: { 'Content-Type': 'application/json' },
78
+ body: JSON.stringify({
79
+ jsonrpc: '2.0', id: 1,
80
+ method: 'eth_call',
81
+ params: [{ to: token, data: `${ALLOWANCE_SELECTOR}${ownerArg}${spenderArg}` }, 'latest'],
82
+ }),
83
+ });
84
+ const data = await resp.json();
85
+ if (typeof data.result !== 'string') return true;
86
+ return BigInt(data.result) >= BigInt(amount);
87
+ } catch {
88
+ return true;
89
+ }
90
+ }
91
+
58
92
  /**
59
93
  * Build a payment signature for a single requirement.
60
94
  * @returns {string|null} Base64 payment signature, or null on failure
61
95
  */
62
96
  async function buildPaymentForRequirement(requirement, exported, url) {
63
97
  if (isEvmNetwork(requirement.network)) {
98
+ if ((requirement.extra || {}).assetTransferMethod === 'permit2-exact') {
99
+ const approved = await hasPermit2Allowance(
100
+ requirement.network,
101
+ requirement.asset,
102
+ exported.evm.address,
103
+ requirement.amount,
104
+ );
105
+ if (!approved) {
106
+ console.error(
107
+ `[x402] Skipping ${requirement.network} permit2 option: Permit2 ` +
108
+ `(${PERMIT2_ADDRESS}) allowance for token ${requirement.asset} is ` +
109
+ `missing or below the payment amount (${requirement.amount}). ` +
110
+ `Send approve(${PERMIT2_ADDRESS}, <amount>) from the wallet to enable it.`,
111
+ );
112
+ return null;
113
+ }
114
+ }
64
115
  return createEvmPaymentPayload(
65
116
  requirement,
66
117
  exported.evm.privateKey,
@@ -132,7 +183,7 @@ export async function* createPaymentSignatures(response, url, options = {}) {
132
183
  for (const req of ranked) {
133
184
  try {
134
185
  const sig = await buildPaymentForRequirement(req, exported, url);
135
- if (sig) yield { signature: sig, network: req.network };
186
+ if (sig) yield { signature: sig, network: req.network, asset: req.asset };
136
187
  } catch {
137
188
  // This payment option failed to build, try next
138
189
  continue;
@@ -156,11 +207,48 @@ export async function createPaymentSignature(response, url, options = {}) {
156
207
  return null;
157
208
  }
158
209
 
210
+ /**
211
+ * x402 payment tokens per EVM network, matching the stablecoins the API
212
+ * advertises in 402 `accepts` entries. `decimals` matters: USDT on BNB Smart
213
+ * Chain uses 18 decimals, unlike the 6-decimal tokens on Base and X Layer.
214
+ */
215
+ // RPC endpoint per supported x402 EVM network.
216
+ export const EVM_X402_RPCS = {
217
+ 'eip155:8453': CHAIN_RPCS.base,
218
+ 'eip155:196': CHAIN_RPCS.xlayer,
219
+ 'eip155:56': CHAIN_RPCS.bsc,
220
+ };
221
+
222
+ function getEvmRpcUrl(network) {
223
+ return EVM_X402_RPCS[network] || null;
224
+ }
225
+
226
+ // Known payment tokens per network, in the order servers typically advertise
227
+ // them. A network can accept several stablecoins (BSC accepts four); `decimals`
228
+ // is per token — every BSC stablecoin is an 18-decimal BEP-20 deployment,
229
+ // unlike the 6-decimal tokens on Base and X Layer.
230
+ export const EVM_X402_TOKENS = {
231
+ 'eip155:8453': [
232
+ { token: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', symbol: 'USDC', decimals: 6 }, // Base USDC
233
+ ],
234
+ 'eip155:196': [
235
+ { token: '0x779Ded0c9e1022225f8E0630b35a9b54bE713736', symbol: 'USDT0', decimals: 6 }, // X Layer USDT0
236
+ ],
237
+ 'eip155:56': [
238
+ { token: '0xcE24439F2D9C6a2289F741120FE202248B666666', symbol: 'U', decimals: 18 }, // United Stables
239
+ { token: '0x8d0d000ee44948fc98c9b98a4fa4921476f08b0d', symbol: 'USD1', decimals: 18 }, // World Liberty Financial USD
240
+ { token: '0x55d398326f99059fF775485246999027B3197955', symbol: 'USDT', decimals: 18 }, // Tether USD
241
+ { token: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', symbol: 'USDC', decimals: 18 }, // Binance-Peg USD Coin
242
+ ],
243
+ };
244
+
159
245
  /**
160
246
  * Check stablecoin balance for x402 payment wallet on the given network.
247
+ * Pass the token contract paid with (`asset`) to check that specific token;
248
+ * otherwise the network's first known token is checked.
161
249
  * Returns `{ balance, symbol }` (USD amount + token symbol) or null if check fails.
162
250
  */
163
- export async function checkX402Balance(network) {
251
+ export async function checkX402Balance(network, asset = null) {
164
252
  try {
165
253
  const { listWallets, exportWallet: _exportWallet } = await import('./wallet.js');
166
254
  const wallets = listWallets();
@@ -192,13 +280,13 @@ export async function checkX402Balance(network) {
192
280
  }
193
281
 
194
282
  if (network.startsWith('eip155:')) {
195
- // Per-network token + RPC. Both tokens are 6-decimals.
196
283
  // Default to Base USDC if the network is unknown so existing wallets keep working.
197
- const EVM_NETWORKS = {
198
- 'eip155:8453': { token: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', rpc: CHAIN_RPCS.base, symbol: 'USDC' }, // Base USDC
199
- 'eip155:196': { token: '0x779Ded0c9e1022225f8E0630b35a9b54bE713736', rpc: CHAIN_RPCS.xlayer, symbol: 'USDT0' }, // X Layer USDT0
200
- };
201
- const { token, rpc, symbol } = EVM_NETWORKS[network] || EVM_NETWORKS['eip155:8453'];
284
+ const tokens = EVM_X402_TOKENS[network] || EVM_X402_TOKENS['eip155:8453'];
285
+ const entry = (asset
286
+ && tokens.find(t => t.token.toLowerCase() === asset.toLowerCase()))
287
+ || tokens[0];
288
+ const { token, symbol, decimals } = entry;
289
+ const rpc = getEvmRpcUrl(network) || EVM_X402_RPCS['eip155:8453'];
202
290
  const addr = walletInfo.evm.replace('0x', '').toLowerCase().padStart(64, '0');
203
291
  const resp = await fetch(rpc, {
204
292
  method: 'POST',
@@ -210,7 +298,7 @@ export async function checkX402Balance(network) {
210
298
  }),
211
299
  });
212
300
  const data = await resp.json();
213
- return { balance: parseInt(data.result, 16) / 1e6, symbol };
301
+ return { balance: parseInt(data.result, 16) / 10 ** decimals, symbol };
214
302
  }
215
303
 
216
304
  return null;