nansen-cli 1.41.1 → 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/src/trading.js CHANGED
@@ -14,7 +14,7 @@ import { buildMessageV0, fetchRecentBlockhash } from './x402-svm.js';
14
14
  import { keccak256, signSecp256k1, rlpEncode } from './crypto.js';
15
15
  import { getWalletConnectAddress, sendTransactionViaWalletConnect, sendSolanaTransactionViaWalletConnect, sendApprovalViaWalletConnect } from './walletconnect-trading.js';
16
16
  import { retrievePassword } from './keychain.js';
17
- import { validateQuoteInput, validateBalance, resolvePercentAmount, validateGasBalance, encodeApproveCalldata, assertValidApprovalSpender, assertQuoteMatchesRequest, assertSwapCalldataNotBareTransfer, assertSwapOutcome, assertSolanaInstructionsSafe, assertSolanaSwapOutcome, approvalAmountForSwap, needsAllowanceRevoke, OVERSIZED_ALLOWANCE_MULTIPLIER } from './trade-validation.js';
17
+ import { validateQuoteInput, validateBalance, resolvePercentAmount, validateGasBalance, encodeApproveCalldata, assertValidApprovalSpender, assertQuoteMatchesRequest, assertSwapCalldataNotBareTransfer, assertSwapOutcome, assertSolanaInstructionsSafe, assertSolanaSwapOutcome, approvalAmountForSwap, needsAllowanceRevoke, OVERSIZED_ALLOWANCE_MULTIPLIER, EVM_BRIDGE_NATIVE_FEE_SLACK, isBridgeRequest } from './trade-validation.js';
18
18
  import { readCompactU16 } from './solana-tx.js';
19
19
  export { readCompactU16 };
20
20
  import { CHAIN_RPCS } from './rpc-urls.js';
@@ -1008,7 +1008,18 @@ export async function verifySwapOutcome({ chain, from, quote, quoteData, apiKey
1008
1008
  { to: tx.to, data: tx.data, value: toRpcHexValue(tx.value) },
1009
1009
  { from, apiKey },
1010
1010
  );
1011
- const outcome = assertSwapOutcome(quoteData.request, quote, sim, { slippage: quoteData.slippage, expectedSpenders });
1011
+ // A cross-chain bridge may pay a fee in native ETH via msg.value on a
1012
+ // token-input route; that surfaces as a native sibling outflow which the
1013
+ // no-sibling-drain check (assertion 3) would otherwise reject. Tolerate it up
1014
+ // to the smaller of the tx's declared native value and the fixed cap — never
1015
+ // the full value, which a hostile quote could inflate to the whole balance.
1016
+ // assertSwapOutcome applies this only for bridges and only to native.
1017
+ let siblingDustThreshold = 0n;
1018
+ try {
1019
+ const declaredValue = BigInt(tx.value ?? 0);
1020
+ siblingDustThreshold = declaredValue < EVM_BRIDGE_NATIVE_FEE_SLACK ? declaredValue : EVM_BRIDGE_NATIVE_FEE_SLACK;
1021
+ } catch { /* non-integer value → leave 0n, assertion 3 stays strict */ }
1022
+ const outcome = assertSwapOutcome(quoteData.request, quote, sim, { slippage: quoteData.slippage, expectedSpenders, siblingDustThreshold });
1012
1023
  if (outcome.outputAssertionSkipped) {
1013
1024
  log(' ℹ Bridge: input-outflow and sibling checks ran; output arrives on the destination chain and is not simulated here.');
1014
1025
  }
@@ -1086,6 +1097,49 @@ export async function estimateEvmGas(chain, { from, to, data, value }) {
1086
1097
  }
1087
1098
  }
1088
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
+
1089
1143
  /**
1090
1144
  * Read the current on-chain ERC-20 allowance, throwing on any RPC failure
1091
1145
  * instead of masking it. checkErc20Allowance below wraps this with a
@@ -2537,7 +2591,16 @@ EXAMPLES:
2537
2591
  continue;
2538
2592
  }
2539
2593
  } else {
2540
- if (txValue > 0n) {
2594
+ // A token-input swap sends no native value — except a cross-chain
2595
+ // bridge may carry a bounded native fee via msg.value. Allow that up
2596
+ // to the same ceiling assertSwapOutcome tolerates as a native sibling
2597
+ // (verifySwapOutcome runs below and re-bounds the actual simulated
2598
+ // outflow to min(tx.value, cap)); reject any other non-zero value, and
2599
+ // any bridge fee above the ceiling.
2600
+ const bridgeFeeAllowed = quoteData?.request
2601
+ && isBridgeRequest(quoteData.request)
2602
+ && txValue <= EVM_BRIDGE_NATIVE_FEE_SLACK;
2603
+ if (txValue > 0n && !bridgeFeeAllowed) {
2541
2604
  log(` ❌ ERC-20 swap has non-zero tx.value (${txValue}) for ${quoteName} — aborting`);
2542
2605
  if (qi + 1 < endIndex) log(` Trying next quote...`);
2543
2606
  lastQuoteError = `${quoteName} unexpected tx.value`;
@@ -2713,30 +2776,9 @@ EXAMPLES:
2713
2776
  }
2714
2777
  }
2715
2778
 
2716
- // Gas resolution — fall back to eth_estimateGas if quote has no gas
2717
2779
  const txData = currentQuote.transaction;
2718
- const apiGas = parseInt(currentQuote.gas || '0');
2719
- const txGas = parseInt(txData.gas || txData.gasLimit || '0');
2720
- let finalGas = apiGas > 0 ? apiGas : txGas;
2721
- if (finalGas === 0) {
2722
- try {
2723
- const rpcUrl = CHAIN_RPCS[chain];
2724
- const estRes = await fetch(rpcUrl, {
2725
- method: 'POST',
2726
- headers: { 'Content-Type': 'application/json' },
2727
- body: JSON.stringify({
2728
- jsonrpc: '2.0', id: 1, method: 'eth_estimateGas',
2729
- params: [{
2730
- from: walletAddress, to: txData.to, data: txData.data || '0x',
2731
- value: txData.value ? '0x' + BigInt(txData.value).toString(16) : '0x0',
2732
- }],
2733
- }),
2734
- });
2735
- const estBody = await estRes.json();
2736
- if (estBody.result) finalGas = Math.ceil(parseInt(estBody.result, 16) * 1.5);
2737
- } catch { /* ignore */ }
2738
- if (finalGas === 0) finalGas = 210000;
2739
- }
2780
+ const finalGas = await resolveEvmSwapGasLimit(currentQuote, { chain, from: walletAddress });
2781
+ logEvmSwapGasResolution(log, currentQuote, txData, finalGas);
2740
2782
 
2741
2783
  log(' Fetching nonce...');
2742
2784
  const nonce = await getEvmNonce(chain, walletAddress);
@@ -2904,7 +2946,16 @@ EXAMPLES:
2904
2946
  continue;
2905
2947
  }
2906
2948
  } else {
2907
- if (txValue > 0n) {
2949
+ // A token-input swap sends no native value — except a cross-chain
2950
+ // bridge may carry a bounded native fee via msg.value. Allow that up
2951
+ // to the same ceiling assertSwapOutcome tolerates as a native sibling
2952
+ // (verifySwapOutcome runs below and re-bounds the actual simulated
2953
+ // outflow to min(tx.value, cap)); reject any other non-zero value, and
2954
+ // any bridge fee above the ceiling.
2955
+ const bridgeFeeAllowed = quoteData?.request
2956
+ && isBridgeRequest(quoteData.request)
2957
+ && txValue <= EVM_BRIDGE_NATIVE_FEE_SLACK;
2958
+ if (txValue > 0n && !bridgeFeeAllowed) {
2908
2959
  log(` ❌ ERC-20 swap has non-zero tx.value (${txValue}) for ${quoteName} — aborting`);
2909
2960
  if (qi + 1 < endIndex) log(` Trying next quote...`);
2910
2961
  lastQuoteError = `${quoteName} unexpected tx.value`;
@@ -3098,11 +3149,9 @@ EXAMPLES:
3098
3149
  }
3099
3150
  }
3100
3151
 
3101
- // Resolve gas
3102
3152
  const txData = currentQuote.transaction;
3103
- const apiGas = parseInt(currentQuote.gas || "0");
3104
- const txGas = parseInt(txData.gas || txData.gasLimit || "0");
3105
- const finalGas = apiGas > 0 ? apiGas : txGas;
3153
+ const finalGas = await resolveEvmSwapGasLimit(currentQuote, { chain, from: wcAddress });
3154
+ logEvmSwapGasResolution(log, currentQuote, txData, finalGas);
3106
3155
 
3107
3156
  // Send transaction via WalletConnect
3108
3157
  log(' Sending transaction via WalletConnect...');
@@ -3232,7 +3281,16 @@ EXAMPLES:
3232
3281
  continue;
3233
3282
  }
3234
3283
  } else {
3235
- if (txValue > 0n) {
3284
+ // A token-input swap sends no native value — except a cross-chain
3285
+ // bridge may carry a bounded native fee via msg.value. Allow that up
3286
+ // to the same ceiling assertSwapOutcome tolerates as a native sibling
3287
+ // (verifySwapOutcome runs below and re-bounds the actual simulated
3288
+ // outflow to min(tx.value, cap)); reject any other non-zero value, and
3289
+ // any bridge fee above the ceiling.
3290
+ const bridgeFeeAllowed = quoteData?.request
3291
+ && isBridgeRequest(quoteData.request)
3292
+ && txValue <= EVM_BRIDGE_NATIVE_FEE_SLACK;
3293
+ if (txValue > 0n && !bridgeFeeAllowed) {
3236
3294
  log(` ❌ ERC-20 swap has non-zero tx.value (${txValue}) for ${quoteName} — aborting`);
3237
3295
  if (qi + 1 < endIndex) log(` Trying next quote...`);
3238
3296
  lastQuoteError = `${quoteName} unexpected tx.value`;
@@ -3405,16 +3463,9 @@ EXAMPLES:
3405
3463
  }
3406
3464
  }
3407
3465
 
3408
- // Use the Trading API's gas estimation (quote.gas) directly.
3409
- // The API already applies a 1.5x buffer over eth_estimateGas.
3410
- // Skip client-side re-estimation — it adds latency and the API value is reliable.
3411
3466
  const txData = currentQuote.transaction;
3412
- const apiGas = parseInt(currentQuote.gas || "0");
3413
- const txGas = parseInt(txData.gas || txData.gasLimit || "0");
3414
- const finalGas = apiGas > 0 ? apiGas : txGas;
3415
- if (finalGas !== txGas) {
3416
- log(` ℹ Using API gas ${finalGas} (tx.gas was ${txGas})`);
3417
- }
3467
+ const finalGas = await resolveEvmSwapGasLimit(currentQuote, { chain, from: walletAddress });
3468
+ logEvmSwapGasResolution(log, currentQuote, txData, finalGas);
3418
3469
  if (txData.gasLimit) txData.gasLimit = String(finalGas);
3419
3470
  else txData.gas = String(finalGas);
3420
3471
 
package/src/wallet.js CHANGED
@@ -281,27 +281,32 @@ function hashPassword(password) {
281
281
 
282
282
  // ============= Prompt Helper =============
283
283
 
284
- async function promptPassword(question, deps = {}) {
284
+ // Exported for testing (mirrors the exported `prompt` in cli.js). The streams
285
+ // are injectable so the masking behavior can be exercised without a real TTY.
286
+ export async function promptPassword(question, deps = {}, { input: inStream = process.stdin, output: outStream = process.stderr } = {}) {
285
287
  const promptFn = deps.promptFn;
286
288
  if (promptFn) {
287
289
  return promptFn(question, true);
288
290
  }
289
291
  // Fallback to readline (only available in --human mode)
290
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
291
292
  return new Promise((resolve) => {
292
- if (process.stdout.isTTY) {
293
- process.stdout.write(question);
293
+ // Gate on stdin, not stdout: raw-mode masking disables the terminal's own
294
+ // echo, so a redirected stdout (e.g. `wallet export > backup.json`) can no
295
+ // longer fall through to readline and echo the password in cleartext. Prompt
296
+ // and mask characters go to stderr so they stay on the terminal and never
297
+ // pollute — or leak into — a redirected stdout.
298
+ if (inStream.isTTY) {
299
+ outStream.write(question);
294
300
  let input = '';
295
- process.stdin.setRawMode(true);
296
- process.stdin.resume();
297
- process.stdin.setEncoding('utf8');
301
+ inStream.setRawMode(true);
302
+ inStream.resume();
303
+ inStream.setEncoding('utf8');
298
304
  const onData = (char) => {
299
305
  if (char === '\n' || char === '\r') {
300
- process.stdin.setRawMode(false);
301
- process.stdin.pause();
302
- process.stdin.removeListener('data', onData);
303
- process.stdout.write('\n');
304
- rl.close();
306
+ inStream.setRawMode(false);
307
+ inStream.pause();
308
+ inStream.removeListener('data', onData);
309
+ outStream.write('\n');
305
310
  resolve(input);
306
311
  } else if (char === '\u0003') {
307
312
  process.exit();
@@ -309,11 +314,12 @@ async function promptPassword(question, deps = {}) {
309
314
  input = input.slice(0, -1);
310
315
  } else {
311
316
  input += char;
312
- process.stdout.write('*');
317
+ outStream.write('*');
313
318
  }
314
319
  };
315
- process.stdin.on('data', onData);
320
+ inStream.on('data', onData);
316
321
  } else {
322
+ const rl = readline.createInterface({ input: inStream, output: outStream });
317
323
  rl.question(question, (answer) => { rl.close(); resolve(answer); });
318
324
  }
319
325
  });
@@ -1039,6 +1045,8 @@ ENVIRONMENT:
1039
1045
  NANSEN_EVM_RPC Custom Ethereum RPC endpoint (also generic EVM fallback)
1040
1046
  NANSEN_BASE_RPC Custom Base RPC endpoint
1041
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)
1042
1050
 
1043
1051
  EXAMPLES:
1044
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
+ };