nansen-cli 1.42.0 → 1.43.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.43.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#539](https://github.com/nansen-ai/nansen-cli/pull/539) [`2ba6c40`](https://github.com/nansen-ai/nansen-cli/commit/2ba6c40376b5c1cc4d7105592cd63d47b5130f02) Thanks [@kome12](https://github.com/kome12)! - x402 auto-payment now refuses to sign payments for unknown tokens/networks and enforces a configurable per-payment USD cap (NANSEN_X402_MAX_AMOUNT, default $1.00) before signing.
8
+
9
+ ### Patch Changes
10
+
11
+ - [#535](https://github.com/nansen-ai/nansen-cli/pull/535) [`863ef23`](https://github.com/nansen-ai/nansen-cli/commit/863ef2372dd042eb37d198f16513eedf5df97dab) Thanks [@crazywriter1](https://github.com/crazywriter1)! - Fix EVM swap execution when quotes omit gas limits: WalletConnect and local wallet paths now fall back to eth_estimateGas (×1.5) and then 210000, matching the Privy path.
12
+
13
+ - [#541](https://github.com/nansen-ai/nansen-cli/pull/541) [`7492bbe`](https://github.com/nansen-ai/nansen-cli/commit/7492bbe6a86cd848168b400ba7c8a53c403d264c) Thanks [@kome12](https://github.com/kome12)! - Refuse x402 auto-payments whose payment requirement is missing a payTo/pay_to recipient, matching the existing missing-amount check. Previously this fell through to the per-signing-path field validation inconsistently, and the WalletConnect path had no check at all.
14
+
3
15
  ## 1.42.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.42.0",
3
+ "version": "1.43.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/privy.js CHANGED
@@ -10,6 +10,7 @@ import fs from "fs";
10
10
  import path from "path";
11
11
  import { parsePaymentRequirements } from "./x402.js";
12
12
  import { isEvmNetwork } from "./x402-evm.js";
13
+ import { evaluatePaymentRequirement, resolvePaymentAmount, resolvePayTo } from "./x402-policy.js";
13
14
  import {
14
15
  isSvmNetwork,
15
16
  getSolanaRpcUrl,
@@ -297,6 +298,11 @@ export async function* createPrivyPaymentSignatures(response, url) {
297
298
  const evmWallet = await getPrivyEvmWallet(client);
298
299
  if (evmWallet) {
299
300
  for (const requirement of evmRequirements) {
301
+ const decision = evaluatePaymentRequirement(requirement);
302
+ if (!decision.ok) {
303
+ console.error(`[x402] ${decision.reason}`);
304
+ continue;
305
+ }
300
306
  try {
301
307
  const typedData = buildEIP712TypedData({
302
308
  fromAddress: evmWallet.address,
@@ -311,8 +317,8 @@ export async function* createPrivyPaymentSignatures(response, url) {
311
317
 
312
318
  const authorization = {
313
319
  from: evmWallet.address,
314
- to: requirement.payTo,
315
- value: (requirement.amount || requirement.maxAmountRequired).toString(),
320
+ to: resolvePayTo(requirement),
321
+ value: resolvePaymentAmount(requirement).toString(),
316
322
  validAfter: typedData.message.validAfter.toString(),
317
323
  validBefore: typedData.message.validBefore.toString(),
318
324
  nonce: typedData.message.nonce,
@@ -342,6 +348,11 @@ export async function* createPrivyPaymentSignatures(response, url) {
342
348
  const solWallet = await getPrivySolanaWallet(client);
343
349
  if (solWallet) {
344
350
  for (const requirement of svmRequirements) {
351
+ const svmDecision = evaluatePaymentRequirement(requirement);
352
+ if (!svmDecision.ok) {
353
+ console.error(`[x402] ${svmDecision.reason}`);
354
+ continue;
355
+ }
345
356
  try {
346
357
  const rpcUrl = getSolanaRpcUrl(requirement.network);
347
358
  const recentBlockhash = await fetchRecentBlockhash(rpcUrl);
package/src/schema.json CHANGED
@@ -1800,7 +1800,7 @@
1800
1800
  }
1801
1801
  },
1802
1802
  "wallet": {
1803
- "description": "Wallet management",
1803
+ "description": "Wallet management. x402 auto-payments are guarded by a client-side policy: per-payment USD cap via NANSEN_X402_MAX_AMOUNT (default 1.00; 'unlimited' to disable) and an optional recipient allowlist via NANSEN_X402_ALLOWED_PAYTO (comma-separated addresses).",
1804
1804
  "subcommands": {
1805
1805
  "create": {
1806
1806
  "description": "Create a new wallet"
package/src/trading.js CHANGED
@@ -1097,6 +1097,49 @@ export async function estimateEvmGas(chain, { from, to, data, value }) {
1097
1097
  }
1098
1098
  }
1099
1099
 
1100
+ /**
1101
+ * Parse a gas field from quote/tx data (decimal or 0x-prefixed hex).
1102
+ */
1103
+ function parseGasField(v) {
1104
+ if (v === undefined || v === null || v === '') return 0;
1105
+ if (typeof v === 'number') return v;
1106
+ if (typeof v === 'string' && v.startsWith('0x')) return parseInt(v, 16);
1107
+ return parseInt(v, 10);
1108
+ }
1109
+
1110
+ /**
1111
+ * Resolve gas limit for an EVM swap from quote fields. When both quote.gas and
1112
+ * tx.gas/gasLimit are zero/missing, fall back to eth_estimateGas (×1.5) then 210000.
1113
+ */
1114
+ export async function resolveEvmSwapGasLimit(currentQuote, { chain, from }) {
1115
+ const txData = currentQuote.transaction;
1116
+ const apiGas = parseGasField(currentQuote.gas);
1117
+ const txGas = parseGasField(txData.gas || txData.gasLimit);
1118
+ let finalGas = apiGas > 0 ? apiGas : txGas;
1119
+ if (finalGas === 0) {
1120
+ const estimated = await estimateEvmGas(chain, {
1121
+ from,
1122
+ to: txData.to,
1123
+ data: txData.data || '0x',
1124
+ value: txData.value ? '0x' + BigInt(txData.value).toString(16) : '0x0',
1125
+ });
1126
+ if (estimated) finalGas = Math.ceil(estimated * 1.5);
1127
+ if (finalGas === 0) finalGas = 210000;
1128
+ }
1129
+ return finalGas;
1130
+ }
1131
+
1132
+ /** Log when gas was resolved from API vs estimate/fallback (all EVM signing paths). */
1133
+ function logEvmSwapGasResolution(log, currentQuote, txData, finalGas) {
1134
+ const apiGas = parseGasField(currentQuote.gas);
1135
+ const txGas = parseGasField(txData.gas || txData.gasLimit);
1136
+ if (apiGas > 0 && finalGas !== txGas) {
1137
+ log(` ℹ Using API gas ${finalGas} (tx.gas was ${txGas})`);
1138
+ } else if (finalGas > 0 && apiGas === 0 && txGas === 0) {
1139
+ log(` ℹ Using estimated gas ${finalGas} (quote had no gas)`);
1140
+ }
1141
+ }
1142
+
1100
1143
  /**
1101
1144
  * Read the current on-chain ERC-20 allowance, throwing on any RPC failure
1102
1145
  * instead of masking it. checkErc20Allowance below wraps this with a
@@ -2733,30 +2776,9 @@ EXAMPLES:
2733
2776
  }
2734
2777
  }
2735
2778
 
2736
- // Gas resolution — fall back to eth_estimateGas if quote has no gas
2737
2779
  const txData = currentQuote.transaction;
2738
- const apiGas = parseInt(currentQuote.gas || '0');
2739
- const txGas = parseInt(txData.gas || txData.gasLimit || '0');
2740
- let finalGas = apiGas > 0 ? apiGas : txGas;
2741
- if (finalGas === 0) {
2742
- try {
2743
- const rpcUrl = CHAIN_RPCS[chain];
2744
- const estRes = await fetch(rpcUrl, {
2745
- method: 'POST',
2746
- headers: { 'Content-Type': 'application/json' },
2747
- body: JSON.stringify({
2748
- jsonrpc: '2.0', id: 1, method: 'eth_estimateGas',
2749
- params: [{
2750
- from: walletAddress, to: txData.to, data: txData.data || '0x',
2751
- value: txData.value ? '0x' + BigInt(txData.value).toString(16) : '0x0',
2752
- }],
2753
- }),
2754
- });
2755
- const estBody = await estRes.json();
2756
- if (estBody.result) finalGas = Math.ceil(parseInt(estBody.result, 16) * 1.5);
2757
- } catch { /* ignore */ }
2758
- if (finalGas === 0) finalGas = 210000;
2759
- }
2780
+ const finalGas = await resolveEvmSwapGasLimit(currentQuote, { chain, from: walletAddress });
2781
+ logEvmSwapGasResolution(log, currentQuote, txData, finalGas);
2760
2782
 
2761
2783
  log(' Fetching nonce...');
2762
2784
  const nonce = await getEvmNonce(chain, walletAddress);
@@ -3127,11 +3149,9 @@ EXAMPLES:
3127
3149
  }
3128
3150
  }
3129
3151
 
3130
- // Resolve gas
3131
3152
  const txData = currentQuote.transaction;
3132
- const apiGas = parseInt(currentQuote.gas || "0");
3133
- const txGas = parseInt(txData.gas || txData.gasLimit || "0");
3134
- const finalGas = apiGas > 0 ? apiGas : txGas;
3153
+ const finalGas = await resolveEvmSwapGasLimit(currentQuote, { chain, from: wcAddress });
3154
+ logEvmSwapGasResolution(log, currentQuote, txData, finalGas);
3135
3155
 
3136
3156
  // Send transaction via WalletConnect
3137
3157
  log(' Sending transaction via WalletConnect...');
@@ -3443,16 +3463,9 @@ EXAMPLES:
3443
3463
  }
3444
3464
  }
3445
3465
 
3446
- // Use the Trading API's gas estimation (quote.gas) directly.
3447
- // The API already applies a 1.5x buffer over eth_estimateGas.
3448
- // Skip client-side re-estimation — it adds latency and the API value is reliable.
3449
3466
  const txData = currentQuote.transaction;
3450
- const apiGas = parseInt(currentQuote.gas || "0");
3451
- const txGas = parseInt(txData.gas || txData.gasLimit || "0");
3452
- const finalGas = apiGas > 0 ? apiGas : txGas;
3453
- if (finalGas !== txGas) {
3454
- log(` ℹ Using API gas ${finalGas} (tx.gas was ${txGas})`);
3455
- }
3467
+ const finalGas = await resolveEvmSwapGasLimit(currentQuote, { chain, from: walletAddress });
3468
+ logEvmSwapGasResolution(log, currentQuote, txData, finalGas);
3456
3469
  if (txData.gasLimit) txData.gasLimit = String(finalGas);
3457
3470
  else txData.gas = String(finalGas);
3458
3471
 
package/src/wallet.js CHANGED
@@ -1045,6 +1045,8 @@ ENVIRONMENT:
1045
1045
  NANSEN_EVM_RPC Custom Ethereum RPC endpoint (also generic EVM fallback)
1046
1046
  NANSEN_BASE_RPC Custom Base RPC endpoint
1047
1047
  NANSEN_SOLANA_RPC Custom Solana RPC endpoint
1048
+ NANSEN_X402_MAX_AMOUNT Max USD per x402 auto-payment (default 1.00; "unlimited" to disable)
1049
+ NANSEN_X402_ALLOWED_PAYTO Comma-separated recipient allowlist for x402 auto-payment (optional)
1048
1050
 
1049
1051
  EXAMPLES:
1050
1052
  NANSEN_WALLET_PASSWORD=mypass nansen wallet create --name trading
@@ -9,6 +9,7 @@ import crypto from 'crypto';
9
9
  import { NansenError, ErrorCode } from './api.js';
10
10
  import { wcExec } from './walletconnect-exec.js';
11
11
  import { EVM_CHAIN_IDS } from './chain-ids.js';
12
+ import { evaluatePaymentRequirement, resolvePaymentAmount, resolvePayTo } from './x402-policy.js';
12
13
 
13
14
  /**
14
15
  * Check if a WalletConnect wallet session is active.
@@ -52,9 +53,9 @@ function parseChainId(network) {
52
53
  * Build EIP-712 typed data for TransferWithAuthorization (EIP-3009).
53
54
  */
54
55
  export function buildEIP712TypedData({ fromAddress, requirement }) {
55
- const { asset, payTo, extra, maxTimeoutSeconds } = requirement;
56
- // x402 uses "amount", fall back to "maxAmountRequired" for compatibility
57
- const amount = requirement.amount || requirement.maxAmountRequired;
56
+ const payTo = resolvePayTo(requirement);
57
+ const { asset, extra, maxTimeoutSeconds } = requirement;
58
+ const amount = resolvePaymentAmount(requirement);
58
59
 
59
60
  // Determine chain ID: extra.chainId > parsed from network > fallback map > base
60
61
  const chainId = extra.chainId || parseChainId(requirement.network) || EVM_CHAIN_IDS[requirement.chain] || EVM_CHAIN_IDS.base;
@@ -121,7 +122,7 @@ export function buildPaymentSignatureHeader({ signature, authorization, resource
121
122
  */
122
123
  function formatPaymentAmount(requirement) {
123
124
  const { extra } = requirement;
124
- const rawAmount = requirement.amount || requirement.maxAmountRequired;
125
+ const rawAmount = resolvePaymentAmount(requirement);
125
126
  const symbol = extra.symbol || extra.name || 'tokens';
126
127
  const decimals = extra.decimals || 6;
127
128
  const amount = Number(rawAmount) / Math.pow(10, decimals);
@@ -171,15 +172,21 @@ export async function handleX402Payment(paymentRequirements) {
171
172
  );
172
173
  }
173
174
 
174
- // 3. Build EIP-712 typed data
175
+ // 3. Evaluate payment policy before touching any signing material
176
+ const decision = evaluatePaymentRequirement(requirement);
177
+ if (!decision.ok) {
178
+ throw new Error(decision.reason);
179
+ }
180
+
181
+ // 4. Build EIP-712 typed data
175
182
  const typedData = buildEIP712TypedData({ fromAddress, requirement });
176
183
  const typedDataJson = JSON.stringify(typedData);
177
184
 
178
- // 4. Log payment info to stderr (stdout is for JSON output)
185
+ // 5. Log payment info to stderr (stdout is for JSON output)
179
186
  const amountStr = formatPaymentAmount(requirement);
180
187
  process.stderr.write(`x402: Requesting payment approval (${amountStr})...\n`);
181
188
 
182
- // 5. Sign via walletconnect CLI (120s timeout for user approval)
189
+ // 6. Sign via walletconnect CLI (120s timeout for user approval)
183
190
  let signResult;
184
191
  try {
185
192
  const output = await wcExec('walletconnect', ['sign-typed-data', typedDataJson], 120000);
@@ -195,11 +202,11 @@ export async function handleX402Payment(paymentRequirements) {
195
202
  );
196
203
  }
197
204
 
198
- // 6. Build Payment-Signature header (authorization values must be strings per x402 spec)
205
+ // 7. Build Payment-Signature header (authorization values must be strings per x402 spec)
199
206
  const authorization = {
200
207
  from: fromAddress,
201
- to: requirement.payTo,
202
- value: (requirement.amount || requirement.maxAmountRequired).toString(),
208
+ to: resolvePayTo(requirement),
209
+ value: resolvePaymentAmount(requirement).toString(),
203
210
  validAfter: typedData.message.validAfter.toString(),
204
211
  validBefore: typedData.message.validBefore.toString(),
205
212
  nonce: typedData.message.nonce,
package/src/x402-evm.js CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  import crypto from 'crypto';
8
8
  import { keccak256, signSecp256k1 } from './crypto.js';
9
+ import { resolvePaymentAmount, resolvePayTo } from './x402-policy.js';
9
10
 
10
11
  // ============= EIP-712 Type Hashing =============
11
12
 
@@ -229,6 +230,7 @@ export function createEvmPaymentPayload(requirements, privateKeyHex, walletAddre
229
230
  const now = Math.floor(Date.now() / 1000);
230
231
  const validAfter = '0';
231
232
  const validBefore = String(now + 3600);
233
+ const amount = resolvePaymentAmount(requirements);
232
234
 
233
235
  // EIP-712 domain
234
236
  const domain = {
@@ -241,8 +243,8 @@ export function createEvmPaymentPayload(requirements, privateKeyHex, walletAddre
241
243
  // EIP-3009 message
242
244
  const message = {
243
245
  from: walletAddress,
244
- to: requirements.pay_to || requirements.payTo,
245
- value: BigInt(requirements.amount),
246
+ to: resolvePayTo(requirements),
247
+ value: BigInt(amount),
246
248
  validAfter: BigInt(validAfter),
247
249
  validBefore: BigInt(validBefore),
248
250
  nonce: nonce,
@@ -260,7 +262,7 @@ export function createEvmPaymentPayload(requirements, privateKeyHex, walletAddre
260
262
  authorization: {
261
263
  from: walletAddress,
262
264
  to: message.to,
263
- value: String(requirements.amount),
265
+ value: String(amount),
264
266
  validAfter: validAfter,
265
267
  validBefore: validBefore,
266
268
  nonce: nonce,
@@ -301,15 +303,16 @@ export function createPermit2ExactPayload(requirements, privateKeyHex, walletAdd
301
303
  throw new Error('spenderAddress missing from requirements.extra (required for permit2-exact)');
302
304
  }
303
305
 
304
- const payTo = requirements.pay_to || requirements.payTo;
306
+ const payTo = resolvePayTo(requirements);
305
307
  const now = Math.floor(Date.now() / 1000);
306
308
  // 256-bit random nonce — Permit2 uses an unordered nonce bitmap.
307
309
  const nonce = BigInt('0x' + crypto.randomBytes(32).toString('hex')).toString();
308
310
  const deadline = String(now + 3600);
309
311
  const validAfter = String(now - 60); // allow clock skew
312
+ const amount = resolvePaymentAmount(requirements);
310
313
 
311
314
  const message = {
312
- permitted: { token: requirements.asset, amount: BigInt(requirements.amount) },
315
+ permitted: { token: requirements.asset, amount: BigInt(amount) },
313
316
  spender,
314
317
  nonce: BigInt(nonce),
315
318
  deadline: BigInt(deadline),
@@ -324,7 +327,7 @@ export function createPermit2ExactPayload(requirements, privateKeyHex, walletAdd
324
327
  x402Version: 2,
325
328
  payload: {
326
329
  permit2Authorization: {
327
- permitted: { token: requirements.asset, amount: String(requirements.amount) },
330
+ permitted: { token: requirements.asset, amount: String(amount) },
328
331
  from: walletAddress,
329
332
  spender,
330
333
  nonce,
@@ -0,0 +1,201 @@
1
+ /**
2
+ * x402 payment policy guard.
3
+ * Decides whether an auto-payment is safe to sign before any signature is produced.
4
+ * Three layers: known-asset allowlist, optional payTo allowlist, per-payment USD cap.
5
+ */
6
+
7
+ import { EVM_X402_TOKENS, SVM_X402_TOKENS } from './x402-tokens.js';
8
+ import { getWalletConfig } from './wallet.js';
9
+
10
+ /**
11
+ * True for the exact Solana CAIP-2 network prefix ("solana:..."), matching
12
+ * isSvmNetwork() in x402-svm.js. Duplicated here (rather than imported) to
13
+ * avoid a circular dependency: x402-svm.js imports resolvePaymentAmount from
14
+ * this module.
15
+ */
16
+ function isSvmNetworkString(network) {
17
+ return typeof network === 'string' && network.startsWith('solana:');
18
+ }
19
+
20
+ /**
21
+ * Look up the known token entry for a (network, asset) pair.
22
+ * Returns { token, symbol, decimals } or null if the pair is not a known
23
+ * Nansen x402 payment asset.
24
+ *
25
+ * EVM addresses are compared case-insensitively (hex is case-insensitive
26
+ * modulo EIP-55 checksum casing). Solana mint addresses are base58 and
27
+ * MUST be compared case-sensitively — flipping the case of a base58 string
28
+ * decodes to different bytes entirely, not the same address in a different
29
+ * checksum casing.
30
+ */
31
+ export function resolveKnownToken(network, asset) {
32
+ if (typeof network !== 'string' || typeof asset !== 'string') return null;
33
+ if (network.startsWith('eip155:')) {
34
+ const table = EVM_X402_TOKENS[network];
35
+ if (!table) return null;
36
+ return table.find(t => t.token.toLowerCase() === asset.toLowerCase()) || null;
37
+ }
38
+ if (isSvmNetworkString(network)) {
39
+ const table = SVM_X402_TOKENS['solana'];
40
+ if (!table) return null;
41
+ return table.find(t => t.token === asset) || null;
42
+ }
43
+ return null;
44
+ }
45
+
46
+ // Default per-payment ceiling in USD. x402 API calls cost cents; $1.00 is a
47
+ // conservative safety ceiling — raise NANSEN_X402_MAX_AMOUNT if legitimate
48
+ // calls exceed it (they should not for normal API usage).
49
+ export const DEFAULT_X402_MAX_AMOUNT_USD = 1.0;
50
+
51
+ /**
52
+ * Resolve the per-payment USD cap.
53
+ * Precedence: NANSEN_X402_MAX_AMOUNT env var > wallet config x402MaxAmount > default.
54
+ * Returns a positive number, or Infinity when explicitly set to "unlimited".
55
+ */
56
+ export function resolveMaxAmountUsd() {
57
+ const env = process.env.NANSEN_X402_MAX_AMOUNT;
58
+ // An empty/whitespace-only value (e.g. an exported-but-unset shell var) must
59
+ // be treated as unset, not as Number('') === 0 — a $0.00 cap would refuse
60
+ // every real payment.
61
+ if (env !== undefined && env.trim() !== '') {
62
+ if (env.trim().toLowerCase() === 'unlimited') return Infinity;
63
+ const n = Number(env);
64
+ if (Number.isFinite(n) && n >= 0) return n;
65
+ // fall through on garbage value
66
+ }
67
+ try {
68
+ const cfg = getWalletConfig();
69
+ if (cfg && cfg.x402MaxAmount !== undefined) {
70
+ const n = Number(cfg.x402MaxAmount);
71
+ if (Number.isFinite(n) && n >= 0) return n;
72
+ }
73
+ } catch { /* no config yet */ }
74
+ return DEFAULT_X402_MAX_AMOUNT_USD;
75
+ }
76
+
77
+ /**
78
+ * Optional payTo allowlist.
79
+ * If NANSEN_X402_ALLOWED_PAYTO is set (comma-separated addresses), only those
80
+ * recipients may be paid. Unset = no recipient restriction.
81
+ *
82
+ * Comparison is case-insensitive for EVM (hex) but case-sensitive for Solana
83
+ * (base58) — see resolveKnownToken for why. `network` is required to know
84
+ * which rule applies; omit it only for EVM-style (case-insensitive) checks.
85
+ */
86
+ export function isPayToAllowed(payTo, network) {
87
+ const raw = process.env.NANSEN_X402_ALLOWED_PAYTO;
88
+ if (!raw) return true;
89
+ const caseSensitive = isSvmNetworkString(network);
90
+ const normalize = (s) => (caseSensitive ? s.trim() : s.trim().toLowerCase());
91
+ const allow = raw.split(',').map(normalize).filter(Boolean);
92
+ if (allow.length === 0) return true;
93
+ return typeof payTo === 'string' && allow.includes(normalize(payTo));
94
+ }
95
+
96
+ /**
97
+ * Resolve the payment amount field, preferring `amount` over the legacy
98
+ * `maxAmountRequired` alias (older x402 implementations only send the latter).
99
+ * A present-but-empty `amount` (e.g. "") is treated as missing so it falls
100
+ * through to `maxAmountRequired` here too — the one place this decision is
101
+ * made. Signers must call this instead of re-deriving the fallback themselves;
102
+ * a mismatched `??` vs `||` between here and a signer is exactly the
103
+ * amount-substitution bypass this guard exists to prevent.
104
+ */
105
+ export function resolvePaymentAmount(requirement) {
106
+ const amount = requirement.amount;
107
+ if (amount !== undefined && amount !== null && amount !== '') return amount;
108
+ return requirement.maxAmountRequired;
109
+ }
110
+
111
+ /**
112
+ * Resolve the payment recipient field, preferring `payTo` (camelCase) over
113
+ * the legacy `pay_to` (snake_case) alias. A present-but-empty `payTo` (e.g.
114
+ * "") is treated as missing, exactly like resolvePaymentAmount — the same
115
+ * single-source-of-truth rule signers must use instead of re-deriving the
116
+ * fallback themselves.
117
+ */
118
+ export function resolvePayTo(requirement) {
119
+ const payTo = requirement.payTo;
120
+ if (payTo !== undefined && payTo !== null && payTo !== '') return payTo;
121
+ return requirement.pay_to;
122
+ }
123
+
124
+ /**
125
+ * Decide whether an x402 payment requirement is safe to auto-sign.
126
+ * Returns { ok: true, usd, symbol } when allowed, or { ok: false, reason }
127
+ * with a human-readable, actionable reason when refused.
128
+ *
129
+ * Enforces in order: known (network, asset) allowlist, optional payTo
130
+ * allowlist, per-payment USD cap. Never signs; pure decision.
131
+ */
132
+ export function evaluatePaymentRequirement(requirement) {
133
+ const network = requirement.network;
134
+ const asset = requirement.asset;
135
+ const payTo = resolvePayTo(requirement);
136
+ const amountRaw = resolvePaymentAmount(requirement);
137
+
138
+ const known = resolveKnownToken(network, asset);
139
+ if (!known) {
140
+ return {
141
+ ok: false,
142
+ reason:
143
+ `Refusing to auto-pay: asset ${asset} on ${network} is not a recognized ` +
144
+ `Nansen payment token. No signature was produced.`,
145
+ };
146
+ }
147
+
148
+ if (payTo === undefined || payTo === null || payTo === '') {
149
+ return {
150
+ ok: false,
151
+ reason: 'Refusing to auto-pay: payTo field is missing from the payment requirement.',
152
+ };
153
+ }
154
+
155
+ if (!isPayToAllowed(payTo, network)) {
156
+ return {
157
+ ok: false,
158
+ reason: `Refusing to auto-pay: recipient ${payTo} is not in NANSEN_X402_ALLOWED_PAYTO.`,
159
+ };
160
+ }
161
+
162
+ if (amountRaw === undefined || amountRaw === null) {
163
+ return { ok: false, reason: 'Refusing to auto-pay: amount field is missing from the payment requirement.' };
164
+ }
165
+
166
+ let usd;
167
+ try {
168
+ // BigInt base units → USD. Keep integer/fraction split to avoid float loss
169
+ // on large values; final Number() is safe for display-scale amounts.
170
+ const base = BigInt(amountRaw);
171
+ // A transfer authorization must be for a positive amount; a negative value
172
+ // is never legitimate and should never be signed.
173
+ if (base < 0n) {
174
+ return { ok: false, reason: `Refusing to auto-pay: negative amount ${amountRaw}.` };
175
+ }
176
+ const scale = 10n ** BigInt(known.decimals);
177
+ // Number arithmetic is precise enough for stablecoin amounts at 6 or 18
178
+ // decimals against a dollar-scale cap. Would need BigInt-native comparison
179
+ // if supported decimals or cap magnitudes change significantly.
180
+ usd = Number(base / scale) + Number(base % scale) / Number(scale);
181
+ } catch {
182
+ return { ok: false, reason: `Refusing to auto-pay: unparseable amount ${amountRaw}.` };
183
+ }
184
+
185
+ // Cap is inclusive: an amount exactly at the cap is allowed (usd > cap, not >=).
186
+ // Note a cap of 0 still permits a zero-value payment (0 > 0 is false); use
187
+ // NANSEN_X402_ALLOWED_PAYTO or an unfunded wallet to block signing entirely.
188
+ const cap = resolveMaxAmountUsd();
189
+ if (usd > cap) {
190
+ const capStr = Number.isFinite(cap) ? `$${cap.toFixed(2)}` : 'unlimited';
191
+ return {
192
+ ok: false,
193
+ reason:
194
+ `Refusing to auto-pay $${usd.toFixed(2)} ${known.symbol}: exceeds the ` +
195
+ `${capStr} per-payment cap. To authorize, raise it with ` +
196
+ `NANSEN_X402_MAX_AMOUNT=<usd> (or NANSEN_X402_MAX_AMOUNT=unlimited to disable).`,
197
+ };
198
+ }
199
+
200
+ return { ok: true, usd, symbol: known.symbol };
201
+ }
package/src/x402-svm.js CHANGED
@@ -6,6 +6,7 @@
6
6
  import crypto from 'crypto';
7
7
  import { base58Encode, base58DecodePubkey } from './wallet.js';
8
8
  import { encodeCompactU16, deriveATA as _deriveATA } from './transfer.js';
9
+ import { resolvePaymentAmount, resolvePayTo } from './x402-policy.js';
9
10
 
10
11
  // ============= Constants =============
11
12
 
@@ -180,8 +181,8 @@ export function buildUnsignedSvmTransaction(
180
181
  }
181
182
 
182
183
  const mint = requirements.asset;
183
- const amount = BigInt(requirements.amount);
184
- const payTo = requirements.pay_to || requirements.payTo;
184
+ const amount = BigInt(resolvePaymentAmount(requirements));
185
+ const payTo = resolvePayTo(requirements);
185
186
 
186
187
  // Derive ATAs
187
188
  const sourceATA = deriveATA(walletAddress, mint, tokenProgram);
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Known x402 payment tokens per network.
3
+ * Leaf module — imported by both x402.js and x402-policy.js to avoid circular deps.
4
+ */
5
+
6
+ // Known payment tokens per EVM network (eip155:<chainId>).
7
+ // decimals is per token — BSC stablecoins are 18-decimal BEP-20 deployments,
8
+ // unlike the 6-decimal tokens on Base and X Layer.
9
+ export const EVM_X402_TOKENS = {
10
+ 'eip155:8453': [
11
+ { token: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', symbol: 'USDC', decimals: 6 }, // Base USDC
12
+ ],
13
+ 'eip155:196': [
14
+ { token: '0x779Ded0c9e1022225f8E0630b35a9b54bE713736', symbol: 'USDT0', decimals: 6 }, // X Layer USDT0
15
+ ],
16
+ 'eip155:56': [
17
+ { token: '0xcE24439F2D9C6a2289F741120FE202248B666666', symbol: 'U', decimals: 18 }, // United Stables
18
+ { token: '0x8d0d000ee44948fc98c9b98a4fa4921476f08b0d', symbol: 'USD1', decimals: 18 }, // World Liberty Financial USD
19
+ { token: '0x55d398326f99059fF775485246999027B3197955', symbol: 'USDT', decimals: 18 }, // Tether USD
20
+ { token: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', symbol: 'USDC', decimals: 18 }, // Binance-Peg USD Coin
21
+ ],
22
+ };
23
+
24
+ // Known payment tokens on Solana.
25
+ export const SVM_X402_TOKENS = {
26
+ solana: [
27
+ { token: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', symbol: 'USDC', decimals: 6 },
28
+ ],
29
+ };
package/src/x402.js CHANGED
@@ -13,6 +13,9 @@ import {
13
13
  } from './x402-svm.js';
14
14
  import { resolvePassword } from './keychain.js';
15
15
  import { CHAIN_RPCS } from './rpc-urls.js';
16
+ import { evaluatePaymentRequirement, resolvePaymentAmount } from './x402-policy.js';
17
+ import { EVM_X402_TOKENS } from './x402-tokens.js';
18
+ export { EVM_X402_TOKENS } from './x402-tokens.js';
16
19
 
17
20
  /**
18
21
  * Parse PaymentRequirements from a 402 response.
@@ -94,19 +97,26 @@ async function hasPermit2Allowance(network, token, owner, amount) {
94
97
  * @returns {string|null} Base64 payment signature, or null on failure
95
98
  */
96
99
  async function buildPaymentForRequirement(requirement, exported, url) {
100
+ const decision = evaluatePaymentRequirement(requirement);
101
+ if (!decision.ok) {
102
+ console.error(`[x402] ${decision.reason}`);
103
+ return null;
104
+ }
105
+
97
106
  if (isEvmNetwork(requirement.network)) {
98
107
  if ((requirement.extra || {}).assetTransferMethod === 'permit2-exact') {
108
+ const resolvedAmount = resolvePaymentAmount(requirement);
99
109
  const approved = await hasPermit2Allowance(
100
110
  requirement.network,
101
111
  requirement.asset,
102
112
  exported.evm.address,
103
- requirement.amount,
113
+ resolvedAmount,
104
114
  );
105
115
  if (!approved) {
106
116
  console.error(
107
117
  `[x402] Skipping ${requirement.network} permit2 option: Permit2 ` +
108
118
  `(${PERMIT2_ADDRESS}) allowance for token ${requirement.asset} is ` +
109
- `missing or below the payment amount (${requirement.amount}). ` +
119
+ `missing or below the payment amount (${resolvedAmount}). ` +
110
120
  `Send approve(${PERMIT2_ADDRESS}, <amount>) from the wallet to enable it.`,
111
121
  );
112
122
  return null;
@@ -207,11 +217,6 @@ export async function createPaymentSignature(response, url, options = {}) {
207
217
  return null;
208
218
  }
209
219
 
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
220
  // RPC endpoint per supported x402 EVM network.
216
221
  export const EVM_X402_RPCS = {
217
222
  'eip155:8453': CHAIN_RPCS.base,
@@ -223,24 +228,6 @@ function getEvmRpcUrl(network) {
223
228
  return EVM_X402_RPCS[network] || null;
224
229
  }
225
230
 
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
231
 
245
232
  /**
246
233
  * Check stablecoin balance for x402 payment wallet on the given network.