nansen-cli 1.36.2 → 1.38.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
@@ -13,7 +13,7 @@ import { base58Decode } from './transfer.js';
13
13
  import { keccak256, signSecp256k1, rlpEncode } from './crypto.js';
14
14
  import { getWalletConnectAddress, sendTransactionViaWalletConnect, sendSolanaTransactionViaWalletConnect, sendApprovalViaWalletConnect } from './walletconnect-trading.js';
15
15
  import { retrievePassword } from './keychain.js';
16
- import { validateQuoteInput, validateBalance, resolvePercentAmount, validateGasBalance } from './trade-validation.js';
16
+ import { validateQuoteInput, validateBalance, resolvePercentAmount, validateGasBalance, encodeApproveCalldata, assertValidApprovalSpender, assertQuoteMatchesRequest, assertSwapCalldataNotBareTransfer, approvalAmountForSwap } from './trade-validation.js';
17
17
  import { CHAIN_RPCS } from './rpc-urls.js';
18
18
  import { packageVersion, CommandError, telemetryHeaders } from './api.js';
19
19
 
@@ -380,7 +380,7 @@ export function loadTxRecord(txHash) {
380
380
  * Save a quote response to disk for later execution.
381
381
  * @returns {string} Quote ID
382
382
  */
383
- export function saveQuote(quoteResponse, chain, signerType = 'local', privyWalletIds = null, toChain = null) {
383
+ export function saveQuote(quoteResponse, chain, signerType = 'local', privyWalletIds = null, toChain = null, meta = {}) {
384
384
  const dir = getQuotesDir();
385
385
  if (!fs.existsSync(dir)) {
386
386
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
@@ -393,6 +393,15 @@ export function saveQuote(quoteResponse, chain, signerType = 'local', privyWalle
393
393
  const data = { quoteId, type: 'swap', chain, timestamp, signerType, response: quoteResponse };
394
394
  if (toChain) data.toChain = toChain;
395
395
  if (privyWalletIds) data.privyWalletIds = privyWalletIds;
396
+ // Persisted so the execute path can scope ERC-20 approvals to the trade
397
+ // (exactOut is buffered by the slippage that was actually used).
398
+ if (meta.swapMode) data.swapMode = meta.swapMode;
399
+ if (meta.slippage != null) data.slippage = meta.slippage;
400
+ // Immutable request intent — the chain, wallet, token pair, mode, and amount
401
+ // the user actually asked for. The execute path revalidates the API's quote
402
+ // against this (see assertQuoteMatchesRequest) so a compromised or buggy quote
403
+ // can't inflate the input, approval, or native value past the user's intent.
404
+ if (meta.request) data.request = meta.request;
396
405
 
397
406
  fs.writeFileSync(path.join(dir, `${quoteId}.json`), JSON.stringify(data, null, 2), { mode: 0o600 });
398
407
  cleanupQuotes();
@@ -726,6 +735,141 @@ export async function checkErc20Allowance(chain, tokenAddress, ownerAddress, spe
726
735
  }
727
736
  }
728
737
 
738
+ // approvalAmountForSwap now lives in trade-validation.js alongside the approval
739
+ // encoder and the spend-ceiling check that both consume it, so the "how much can
740
+ // leave the wallet" math has a single definition. Re-exported here because the
741
+ // execute paths below (and tests) import it from this module.
742
+ export { approvalAmountForSwap };
743
+
744
+ /**
745
+ * The maximum allowance (spend ceiling, in the SELL token's base units) to hand
746
+ * the approval encoder for a saved quote. Centralised so every signing path
747
+ * shares one definition and a refactor can't reintroduce a wrong-unit cap.
748
+ *
749
+ * Returns the persisted `maxInputAmount` when present. Otherwise:
750
+ * - exactIn: falls back to `request.amount`, which for exactIn IS the input
751
+ * bound (covers quotes saved before maxInputAmount existed).
752
+ * - exactOut: returns undefined — there is NO safe fallback, because
753
+ * `request.amount` is the OUTPUT amount (a different token). The encoder
754
+ * still bounds the amount below MAX_UINT256, and assertInputWithinMax fails
755
+ * closed on a missing exactOut cap before any approval is built, so exactOut
756
+ * never legitimately reaches here without a cap.
757
+ *
758
+ * @param {object} quoteData - The loaded quote record (with .swapMode, .request)
759
+ * @returns {string|number|undefined} allowance cap, or undefined for no cap
760
+ */
761
+ export function approvalCapForQuote(quoteData) {
762
+ const cap = quoteData?.request?.maxInputAmount;
763
+ if (cap != null) return cap;
764
+ return quoteData?.swapMode === 'exactOut' ? undefined : quoteData?.request?.amount;
765
+ }
766
+
767
+ export function assertCompleteEvmRequestIntent(request) {
768
+ if (!request) {
769
+ throw new Error('Quote is missing request intent. Re-quote with this CLI version before executing an EVM swap. Refusing to sign.');
770
+ }
771
+
772
+ const missing = [];
773
+ for (const field of ['chain', 'walletAddress', 'fromToken', 'toToken', 'swapMode', 'amount', 'maxInputAmount']) {
774
+ if (request[field] == null || request[field] === '') missing.push(field);
775
+ }
776
+ if (missing.length) {
777
+ throw new Error(`Quote request intent is incomplete (${missing.join(', ')} missing). Re-quote before executing an EVM swap. Refusing to sign.`);
778
+ }
779
+ }
780
+
781
+ /**
782
+ * Sanity-check the target of a swap transaction before signing it.
783
+ *
784
+ * This is a defensive gate, not a router allowlist. It rejects the crude cases
785
+ * where the transaction clearly isn't a swap routed through an aggregator: a
786
+ * null/zero target, or a call straight at the token being sold (which would
787
+ * encode a transfer/approve of that token rather than a swap — the one
788
+ * full-balance drain that needs no prior approval). It also confirms the target
789
+ * carries contract code. The code check fails closed: it retries a few times
790
+ * and, if it still can't confirm the target is a contract, throws rather than
791
+ * signing against an unverified target — a flaky or hostile RPC must not be
792
+ * able to silently disable the guard. A missing RPC config throws immediately.
793
+ *
794
+ * Throws on a definitive rejection; returns nothing on pass. Callers run this
795
+ * inside the per-quote try so a rejected quote falls through to the next one.
796
+ *
797
+ * @param {string} chain - Chain name
798
+ * @param {string} to - Transaction target (quote.transaction.to)
799
+ * @param {string} inputMint - The token being sold (quote.inputMint)
800
+ */
801
+ export async function validateSwapTarget(chain, to, inputMint, { verifiedTargets } = {}) {
802
+ if (!to || /^0x0+$/i.test(to)) {
803
+ throw new Error(`Swap target address is empty or zero (${to ?? 'undefined'}). Refusing to sign.`);
804
+ }
805
+ // A legit swap — same-chain OR cross-chain bridge — routes through an
806
+ // aggregator/router, never the sold token itself. This gate is intentionally
807
+ // NOT same-chain-scoped: a bare ERC-20 `transfer`/`approve` necessarily
808
+ // targets the token contract, so `to === inputMint` is the drain shape in both
809
+ // cases, and the bridge routes this CLI uses (Relay/Li.Fi) route deposits
810
+ // through a router (to != token), so this never fires on a legitimate bridge.
811
+ // Loosening it for cross-chain would let a compromised bridge quote encode a
812
+ // bare transfer to an attacker (the cross-chain path does not parse the
813
+ // calldata recipient/amount), so it fails closed here. (A WETH-style direct
814
+ // unwrap can trip this; re-quote or use the native sentinel 0xeee…eee if so.)
815
+ if (inputMint && to.toLowerCase() === inputMint.toLowerCase()) {
816
+ throw new Error(
817
+ `Swap target equals the token being sold (${to}). A swap routes through an aggregator, not the token itself. Refusing to sign.`,
818
+ );
819
+ }
820
+ // Skip the RPC round-trip (and its retries) for a target already confirmed to
821
+ // carry contract code earlier in this same execute run. Quote lists commonly
822
+ // share one router across all quotes, so this avoids re-verifying — and, on a
823
+ // flaky RPC, re-retrying — the same target N times. Only SUCCESSFUL checks are
824
+ // cached, so a transient failure still gets a fresh attempt on the next quote.
825
+ const targetKey = `${chain}:${to.toLowerCase()}`;
826
+ if (verifiedTargets?.has(targetKey)) return;
827
+
828
+ // Fail CLOSED on an unverifiable target: retry a few times, then refuse rather
829
+ // than sign against a target we couldn't confirm carries contract code. A
830
+ // flaky — or hostile — RPC must not be able to silently disable this guard.
831
+ let code;
832
+ let lastErr = null;
833
+ const MAX_ATTEMPTS = 3;
834
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
835
+ try {
836
+ code = await evmRpcCall(chain, 'eth_getCode', [to, 'latest']);
837
+ lastErr = null;
838
+ break;
839
+ } catch (err) {
840
+ // A missing RPC config is a deterministic setup error, not a flaky
841
+ // network — surface it immediately rather than burn retries on it.
842
+ if (err?.message?.startsWith('No RPC URL')) throw err;
843
+ lastErr = err;
844
+ if (attempt < MAX_ATTEMPTS) {
845
+ process.stderr.write(` ⚠ Swap target check attempt ${attempt}/${MAX_ATTEMPTS} failed (${err.message}); retrying...\n`);
846
+ await new Promise(r => setTimeout(r, 300));
847
+ }
848
+ }
849
+ }
850
+ if (lastErr) {
851
+ throw new Error(
852
+ `Could not verify swap target ${to} is a contract after ${MAX_ATTEMPTS} attempts (${lastErr.message}). Refusing to sign — check RPC connectivity or configure a reliable RPC URL.`,
853
+ );
854
+ }
855
+ if (!code || code === '0x' || code === '0x0') {
856
+ throw new Error(`Swap target ${to} is not a contract (no code). Refusing to sign.`);
857
+ }
858
+ verifiedTargets?.add(targetKey);
859
+ }
860
+
861
+ /**
862
+ * Reject an approval whose spender is not a well-formed, non-zero 20-byte EVM
863
+ * address. A real aggregator spender is always a 20-byte contract address; an
864
+ * empty, zero, or over-length value means the quote is malformed or tampered.
865
+ * An over-length spender is especially dangerous — concatenated into approval
866
+ * calldata it shifts the ABI word layout — so we refuse before signing.
867
+ * Delegates to the shared strict validator used by the calldata encoder.
868
+ */
869
+ export function assertUsableSpender(spenderAddress) {
870
+ assertValidApprovalSpender(spenderAddress);
871
+ }
872
+
729
873
  /**
730
874
  * Send an ERC-20 approval transaction.
731
875
  * Required before swapping non-native EVM tokens.
@@ -735,19 +879,21 @@ export async function checkErc20Allowance(chain, tokenAddress, ownerAddress, spe
735
879
  * @param {string} privateKeyHex - Wallet private key
736
880
  * @param {string} chain - Chain name
737
881
  * @param {number} nonce - Account nonce
882
+ * @param {string|number} gasPrice - Legacy gas price
883
+ * @param {bigint|string|number} amount - Allowance to grant, in base units (see approvalAmountForSwap)
884
+ * @param {bigint|string|number} [maxAllowance] - Hard cap from persisted request intent
738
885
  * @returns {string} 0x-prefixed signed approval tx hex
739
886
  */
740
887
  // ⚠️ SECURITY: ERC-20 approval signing - requires thorough review
741
- export function buildApprovalTransaction(tokenAddress, spenderAddress, privateKeyHex, chain, nonce, gasPrice) {
888
+ export function buildApprovalTransaction(tokenAddress, spenderAddress, privateKeyHex, chain, nonce, gasPrice, amount, maxAllowance) {
742
889
  const chainConfig = CHAIN_MAP[chain];
743
890
  if (!chainConfig) throw new Error(`Unsupported chain: ${chain}`);
744
891
 
745
- // ERC-20 approve(address spender, uint256 amount) selector = 0x095ea7b3
746
- // Approve max uint256
747
- const MAX_UINT256_HEX = 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff';
748
- const data = '0x095ea7b3'
749
- + spenderAddress.slice(2).toLowerCase().padStart(64, '0')
750
- + MAX_UINT256_HEX;
892
+ // Scope the approval to the swap's input amount so a malicious or buggy quote
893
+ // can drain at most this one trade, never the wallet's full token balance.
894
+ // encodeApproveCalldata enforces a valid 20-byte spender, a bounded (< MAX)
895
+ // amount within the request cap, and exactly-68-byte calldata.
896
+ const data = encodeApproveCalldata(spenderAddress, amount, { maxAllowance });
751
897
 
752
898
  const tx = {
753
899
  nonce,
@@ -1233,6 +1379,11 @@ OPTIONS:
1233
1379
  --auto-slippage Enable auto slippage calculation
1234
1380
  --max-auto-slippage <pct> Max auto slippage when auto-slippage enabled
1235
1381
  --swap-mode <mode> exactIn (default) or exactOut
1382
+ --max-input <baseUnits> exactOut only: hard ceiling on the sell-token spend
1383
+ (base units), measured against the slippage-buffered
1384
+ approval (input + slippage), not the bare quote input.
1385
+ Required for EVM (Base) exactOut and enforced before
1386
+ signing; optional on Solana (no ERC-20 approval to scope).
1236
1387
  --aggregator <name> Force a specific aggregator (lifi, relay, jupiter, okx).
1237
1388
  Filters the quote list client-side; errors if none match.
1238
1389
 
@@ -1267,6 +1418,49 @@ CROSS-CHAIN NOTES (when using --to-chain):
1267
1418
  throw new CommandError('Error: --amount-unit percent is not supported with --swap-mode exactOut. Percentage is relative to your sell-token balance.', 'INVALID_INPUT');
1268
1419
  }
1269
1420
 
1421
+ // The exactOut spend-ceiling requirements below only guard the EVM signing
1422
+ // path: the ERC-20 approval scoping, request-intent binding, and
1423
+ // assertInputWithinMax checks are wired into the EVM execute paths only.
1424
+ // Solana signs the API transaction verbatim (no approval to scope), so
1425
+ // requiring --max-input there would break existing Solana exactOut users
1426
+ // without buying any of that path a security guarantee. Gate on EVM source.
1427
+ const isEvmSource = CHAIN_MAP[chain?.toLowerCase()]?.type === 'evm';
1428
+
1429
+ // exactOut scopes the ERC-20 approval to a slippage-buffered max input. With
1430
+ // uncapped auto-slippage the actual bound is server-side and unknown, so the
1431
+ // buffer could be under-sized and the swap would revert on allowance. Require
1432
+ // an explicit cap so the approval is always bounded by a value we know.
1433
+ if (isEvmSource && swapMode === 'exactOut' && autoSlippage && maxAutoSlippage == null) {
1434
+ throw new CommandError('Error: --swap-mode exactOut with --auto-slippage requires --max-auto-slippage so the approval can be scoped to a bounded input (e.g. --max-auto-slippage 0.05).', 'INVALID_INPUT');
1435
+ }
1436
+
1437
+ // --max-input: an explicit ceiling (base units of the sell token) on how
1438
+ // much may leave the wallet for an exactOut swap, persisted as intent and
1439
+ // enforced before signing. exactIn is already capped at --amount (the
1440
+ // input the user names), so the flag is exactOut-only.
1441
+ const maxInputRaw = options['max-input'];
1442
+ let maxInputOverride = null;
1443
+ if (maxInputRaw != null) {
1444
+ if (swapMode !== 'exactOut') {
1445
+ throw new CommandError('Error: --max-input only applies to --swap-mode exactOut (exactIn already caps spend at --amount).', 'INVALID_INPUT');
1446
+ }
1447
+ const maxInputError = validateBaseUnitAmount(maxInputRaw);
1448
+ if (maxInputError) {
1449
+ throw new CommandError(`Error: invalid --max-input: ${maxInputError} (--max-input is in base units of the sell token).`, 'INVALID_INPUT');
1450
+ }
1451
+ // validateBaseUnitAmount catches negatives/decimals but not non-numeric
1452
+ // input (e.g. "abc"); guard the BigInt so it surfaces cleanly, not as a
1453
+ // raw "Cannot convert … to a BigInt".
1454
+ try {
1455
+ maxInputOverride = BigInt(maxInputRaw).toString();
1456
+ } catch {
1457
+ throw new CommandError(`Error: invalid --max-input "${maxInputRaw}": must be an integer in base units of the sell token.`, 'INVALID_INPUT');
1458
+ }
1459
+ }
1460
+ if (isEvmSource && swapMode === 'exactOut' && maxInputOverride == null) {
1461
+ throw new CommandError('Error: --swap-mode exactOut requires --max-input (base units of the sell token) so the input is independently capped before signing.', 'INVALID_INPUT');
1462
+ }
1463
+
1270
1464
  // Static input validation — catches common agent errors (wrong addresses,
1271
1465
  // same-token swaps, bad amounts) before any network or wallet call.
1272
1466
  try {
@@ -1463,6 +1657,46 @@ CROSS-CHAIN NOTES (when using --to-chain):
1463
1657
  response.quotes = matching;
1464
1658
  }
1465
1659
 
1660
+ // Slippage actually in effect. Computed here (not just at save time) so
1661
+ // the --max-input filter below measures the same buffered approval the
1662
+ // execute path will build, keeping quote-time and execute-time in lockstep.
1663
+ const effectiveSlippage = slippage != null ? Number(slippage)
1664
+ : autoSlippage ? (maxAutoSlippage != null ? Number(maxAutoSlippage) : 0.05)
1665
+ : 0.03;
1666
+
1667
+ // Explicit --max-input: drop quotes whose *buffered* input exceeds the cap
1668
+ // so we never print a Quote ID the execute path would refuse. For exactOut
1669
+ // the approval is slippage-buffered (approvalAmountForSwap), so a raw input
1670
+ // at the cap still overflows it once buffered (1,000,000 @ 3% → 1,030,000);
1671
+ // filtering on the raw input would save a quote the approval encoder later
1672
+ // rejects for exceeding the cap. (max-input is exactOut-only. The derived
1673
+ // default is computed from the max quote input below, so it can never
1674
+ // exclude a quote — only an explicit cap can.)
1675
+ if (maxInputOverride != null) {
1676
+ const cap = BigInt(maxInputOverride);
1677
+ // Max sell-token base units that can leave the wallet for this quote.
1678
+ const spendFor = (q) => approvalAmountForSwap({
1679
+ inputAmount: q.inputAmount ?? q.inAmount ?? '0',
1680
+ swapMode,
1681
+ slippage: effectiveSlippage,
1682
+ });
1683
+ const withinCap = response.quotes.filter((q) => {
1684
+ const spend = spendFor(q);
1685
+ return spend > 0n && spend <= cap;
1686
+ });
1687
+ if (!withinCap.length) {
1688
+ const cheapest = response.quotes.reduce((min, q) => {
1689
+ const spend = spendFor(q);
1690
+ return spend > 0n && (min == null || spend < min) ? spend : min;
1691
+ }, null);
1692
+ throw new CommandError(
1693
+ `No quote fits --max-input ${cap}. The cheapest fits within ${cheapest ?? 'unknown'} base units (input + ${effectiveSlippage} slippage buffer). Raise --max-input or lower the requested output.`,
1694
+ 'MAX_INPUT_EXCEEDED'
1695
+ );
1696
+ }
1697
+ response.quotes = withinCap;
1698
+ }
1699
+
1466
1700
  log('');
1467
1701
  response.quotes.forEach((q, i) => log(formatQuote(q, i)));
1468
1702
 
@@ -1476,7 +1710,26 @@ CROSS-CHAIN NOTES (when using --to-chain):
1476
1710
  }
1477
1711
 
1478
1712
  const signerType = isWalletConnect ? 'walletconnect' : walletProvider;
1479
- const quoteId = saveQuote(response, chain, signerType, privyWalletIds, isCrossChain ? toChainRaw : null);
1713
+ const maxInputAmount = swapMode === 'exactOut' ? maxInputOverride : String(resolvedAmount);
1714
+ const quoteId = saveQuote(response, chain, signerType, privyWalletIds, isCrossChain ? toChainRaw : null, {
1715
+ swapMode,
1716
+ slippage: effectiveSlippage,
1717
+ // Immutable record of what the user asked for; revalidated at execute
1718
+ // time so the API's quote can't drift beyond it. For exactIn `amount`
1719
+ // is the input; for exactOut it is the requested output. `maxInputAmount`
1720
+ // is the spend ceiling enforced in both modes before signing.
1721
+ request: {
1722
+ chain,
1723
+ toChain: isCrossChain ? toChainRaw : null,
1724
+ walletAddress,
1725
+ recipient: params.toWalletAddress ?? null,
1726
+ fromToken: from,
1727
+ toToken: to,
1728
+ swapMode,
1729
+ amount: resolvedAmount,
1730
+ maxInputAmount,
1731
+ },
1732
+ });
1480
1733
  log(`\n Quote ID: ${quoteId}`);
1481
1734
  log(` Execute: nansen trade execute --quote ${quoteId}`);
1482
1735
  if (response.quotes.length > 1) {
@@ -1610,6 +1863,10 @@ EXAMPLES:
1610
1863
  }
1611
1864
 
1612
1865
  let lastQuoteError = null;
1866
+ // Swap targets confirmed to carry contract code in this execute run, so a
1867
+ // router shared across quotes is verified once, not per quote (see
1868
+ // validateSwapTarget). Scoped to this run — never cached across processes.
1869
+ const verifiedTargets = new Set();
1613
1870
 
1614
1871
  for (let qi = startIndex; qi < endIndex; qi++) {
1615
1872
  const currentQuote = allQuotes[qi];
@@ -1672,6 +1929,26 @@ EXAMPLES:
1672
1929
  const walletResult = await privyClient.getWallet(evmWalletId);
1673
1930
  const walletAddress = walletResult.address;
1674
1931
 
1932
+ // Guard the swap target before any RPC call, approval, or signing —
1933
+ // whatever `to`/`data` the quote supplied gets signed verbatim.
1934
+ await validateSwapTarget(chain, currentQuote.transaction.to, currentQuote.inputMint, { verifiedTargets });
1935
+
1936
+ // Bind the quote to the immutable request intent persisted at quote
1937
+ // time, so a compromised API can't inflate the input (and therefore
1938
+ // the scoped approval and native value) past what the user asked to spend.
1939
+ assertCompleteEvmRequestIntent(quoteData.request);
1940
+ assertQuoteMatchesRequest(quoteData.request, currentQuote, { chain, walletAddress, slippage: quoteData.slippage });
1941
+
1942
+ // Same-chain only: a legit swap's outer call is a router method,
1943
+ // never a bare ERC-20 transfer/approve. Reject that drain shape.
1944
+ // Cross-chain routes skip THIS selector check, but a bare transfer
1945
+ // still targets the token contract, so validateSwapTarget's
1946
+ // `to === inputMint` gate above already refuses it on both paths —
1947
+ // cross-chain bare transfers are not actually waved through here.
1948
+ if (!quoteData.toChain) {
1949
+ assertSwapCalldataNotBareTransfer(currentQuote.transaction.data);
1950
+ }
1951
+
1675
1952
  // Validate transaction.value (same checks as local wallet)
1676
1953
  const isNative = isNativeToken(currentQuote.inputMint);
1677
1954
  const txValue = BigInt(currentQuote.transaction.value || '0');
@@ -1695,20 +1972,32 @@ EXAMPLES:
1695
1972
  // Handle approval if needed
1696
1973
  // Empty-string approvalAddress is Relay's "no approval needed" sentinel — skip.
1697
1974
  if (currentQuote.approvalAddress && currentQuote.approvalAddress !== '' && !isNative) {
1975
+ assertUsableSpender(currentQuote.approvalAddress);
1698
1976
  const inputAmount = BigInt(currentQuote.inputAmount || currentQuote.inAmount || '0');
1977
+ const approveAmt = approvalAmountForSwap({ inputAmount, swapMode: quoteData.swapMode, slippage: quoteData.slippage });
1978
+ if (approveAmt <= 0n) {
1979
+ // Malformed quote (no/invalid input amount): a zero-scoped approval
1980
+ // would waste gas and the swap would revert on insufficient allowance.
1981
+ log(` ❌ ${quoteName} has a zero input amount — cannot scope approval, skipping.`);
1982
+ lastQuoteError = `${quoteName} has a zero input amount`;
1983
+ if (qi + 1 < endIndex) log(` Trying next quote...`);
1984
+ continue;
1985
+ }
1699
1986
  const existingAllowance = await checkErc20Allowance(
1700
1987
  chain, currentQuote.inputMint, walletAddress, currentQuote.approvalAddress
1701
1988
  );
1702
1989
 
1703
- if (existingAllowance >= inputAmount && existingAllowance > 0n) {
1990
+ if (existingAllowance >= approveAmt && existingAllowance > 0n) {
1704
1991
  log(` ✓ Sufficient allowance exists for ${quoteName}, skipping approval`);
1705
1992
  } else {
1706
1993
  log(` ⚠ Approval required → ${currentQuote.approvalAddress}`);
1707
1994
  const approvalNonce = await getEvmNonce(chain, walletAddress);
1708
- const MAX_UINT256 = 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff';
1709
- const approvalData = '0x095ea7b3'
1710
- + currentQuote.approvalAddress.slice(2).toLowerCase().padStart(64, '0')
1711
- + MAX_UINT256;
1995
+ // Scope the approval to this trade's input (see approvalAmountForSwap).
1996
+ // encodeApproveCalldata enforces a valid 20-byte spender, a
1997
+ // bounded (< MAX) amount within the request cap, and 68-byte calldata.
1998
+ const approvalData = encodeApproveCalldata(currentQuote.approvalAddress, approveAmt, {
1999
+ maxAllowance: approvalCapForQuote(quoteData),
2000
+ });
1712
2001
  const approvalMaxFee = currentQuote.transaction?.maxFeePerGas || currentQuote.transaction?.gasPrice || '1000000';
1713
2002
  const approvalPriorityFee = currentQuote.transaction?.maxPriorityFeePerGas || '1000000';
1714
2003
  const approvalSignResult = await privyClient.signEvmTransaction(evmWalletId, {
@@ -1805,6 +2094,12 @@ EXAMPLES:
1805
2094
  signedTransaction = signResult.data?.signed_transaction || signResult.signed_transaction;
1806
2095
 
1807
2096
  } else if (chainType === 'solana') {
2097
+ // NB: validateSwapTarget (the EVM `to`/`data` guard) intentionally does
2098
+ // not apply here — Solana quotes are a pre-built serialized
2099
+ // VersionedTransaction with no `to`/`data`/approval split to validate,
2100
+ // and this path (including the WalletConnect sub-branch below) signs it
2101
+ // as supplied. Deeper Solana inspection (e.g. checking instruction
2102
+ // program IDs) is tracked as a follow-up, not an oversight.
1808
2103
  // Solana: transaction is either a base64 string (Jupiter) or an object
1809
2104
  // with a base58-encoded `data` field (OKX). Normalize to base64.
1810
2105
  let txBase64 = currentQuote.transaction;
@@ -1860,8 +2155,36 @@ EXAMPLES:
1860
2155
  } else if (isWalletConnect) {
1861
2156
  // EVM via WalletConnect: wallet signs and may broadcast
1862
2157
  const wcAddress = await getWalletConnectAddress(chainType);
2158
+ // A session dropped mid-execute returns null here. Without this
2159
+ // guard a null address would fall through to assertQuoteMatchesRequest,
2160
+ // whose `request.walletAddress && walletAddress` condition would
2161
+ // silently skip the signer-binding check. Fail closed instead.
2162
+ if (!wcAddress) {
2163
+ throw new CommandError('WalletConnect session lost during execute. Reconnect with `walletconnect connect` and retry.', 'NO_WALLET');
2164
+ }
1863
2165
  const isNative = isNativeToken(currentQuote.inputMint);
1864
2166
 
2167
+ // Guard the swap target before any RPC call, approval, or signing —
2168
+ // whatever `to`/`data` the quote supplied gets signed verbatim.
2169
+ await validateSwapTarget(chain, currentQuote.transaction.to, currentQuote.inputMint, { verifiedTargets });
2170
+
2171
+ // Bind the quote to the immutable request intent persisted at quote
2172
+ // time, so a compromised API can't inflate the input (and therefore
2173
+ // the scoped approval and native value) past what the user asked to spend.
2174
+ // The connected WC address is the signer here.
2175
+ assertCompleteEvmRequestIntent(quoteData.request);
2176
+ assertQuoteMatchesRequest(quoteData.request, currentQuote, { chain, walletAddress: wcAddress, slippage: quoteData.slippage });
2177
+
2178
+ // Same-chain only: a legit swap's outer call is a router method,
2179
+ // never a bare ERC-20 transfer/approve. Reject that drain shape.
2180
+ // Cross-chain routes skip THIS selector check, but a bare transfer
2181
+ // still targets the token contract, so validateSwapTarget's
2182
+ // `to === inputMint` gate above already refuses it on both paths —
2183
+ // cross-chain bare transfers are not actually waved through here.
2184
+ if (!quoteData.toChain) {
2185
+ assertSwapCalldataNotBareTransfer(currentQuote.transaction.data);
2186
+ }
2187
+
1865
2188
  // Validate transaction.value (same checks as local wallet)
1866
2189
  const txValue = BigInt(currentQuote.transaction.value || '0');
1867
2190
  if (isNative) {
@@ -1884,12 +2207,22 @@ EXAMPLES:
1884
2207
  // Handle approval via WalletConnect if needed
1885
2208
  // Empty-string approvalAddress is Relay's "no approval needed" sentinel — skip.
1886
2209
  if (currentQuote.approvalAddress && currentQuote.approvalAddress !== '' && !isNative) {
2210
+ assertUsableSpender(currentQuote.approvalAddress);
1887
2211
  const inputAmount = BigInt(currentQuote.inputAmount || currentQuote.inAmount || '0');
2212
+ const approveAmt = approvalAmountForSwap({ inputAmount, swapMode: quoteData.swapMode, slippage: quoteData.slippage });
2213
+ if (approveAmt <= 0n) {
2214
+ // Malformed quote (no/invalid input amount): a zero-scoped approval
2215
+ // would waste gas and the swap would revert on insufficient allowance.
2216
+ log(` ❌ ${quoteName} has a zero input amount — cannot scope approval, skipping.`);
2217
+ lastQuoteError = `${quoteName} has a zero input amount`;
2218
+ if (qi + 1 < endIndex) log(` Trying next quote...`);
2219
+ continue;
2220
+ }
1888
2221
  const existingAllowance = await checkErc20Allowance(
1889
2222
  chain, currentQuote.inputMint, wcAddress, currentQuote.approvalAddress
1890
2223
  );
1891
2224
 
1892
- if (existingAllowance >= inputAmount && existingAllowance > 0n) {
2225
+ if (existingAllowance >= approveAmt && existingAllowance > 0n) {
1893
2226
  log(` ✓ Sufficient allowance exists for ${quoteName}, skipping approval`);
1894
2227
  } else {
1895
2228
  log(` ⚠ Approval required → ${currentQuote.approvalAddress}`);
@@ -1899,6 +2232,8 @@ EXAMPLES:
1899
2232
  currentQuote.inputMint,
1900
2233
  currentQuote.approvalAddress,
1901
2234
  chainConfig.chainId,
2235
+ approveAmt,
2236
+ approvalCapForQuote(quoteData),
1902
2237
  );
1903
2238
  let approvalTxHash = approvalResult.txHash;
1904
2239
  if (!approvalTxHash && approvalResult.signedTransaction) {
@@ -2032,6 +2367,28 @@ EXAMPLES:
2032
2367
  // EVM: quote.transaction is { to, data, value, gas, gasPrice }
2033
2368
  const walletAddress = exported.evm.address;
2034
2369
 
2370
+ // Guard the swap target before any RPC call, approval, or signing —
2371
+ // whatever `to`/`data` the quote supplied gets signed verbatim, so
2372
+ // reject an implausible target (zero, EOA, or the sold token itself)
2373
+ // before spending gas on an approval.
2374
+ await validateSwapTarget(chain, currentQuote.transaction.to, currentQuote.inputMint, { verifiedTargets });
2375
+
2376
+ // Bind the quote to the immutable request intent persisted at quote
2377
+ // time, so a compromised API can't inflate the input (and therefore
2378
+ // the scoped approval and native value) past what the user asked to spend.
2379
+ assertCompleteEvmRequestIntent(quoteData.request);
2380
+ assertQuoteMatchesRequest(quoteData.request, currentQuote, { chain, walletAddress, slippage: quoteData.slippage });
2381
+
2382
+ // Same-chain only: a legit swap's outer call is a router method,
2383
+ // never a bare ERC-20 transfer/approve. Reject that drain shape.
2384
+ // Cross-chain routes skip THIS selector check, but a bare transfer
2385
+ // still targets the token contract, so validateSwapTarget's
2386
+ // `to === inputMint` gate above already refuses it on both paths —
2387
+ // cross-chain bare transfers are not actually waved through here.
2388
+ if (!quoteData.toChain) {
2389
+ assertSwapCalldataNotBareTransfer(currentQuote.transaction.data);
2390
+ }
2391
+
2035
2392
  // Handle approval if needed — skip for native ETH
2036
2393
  // Check existing allowance first to avoid unnecessary approve txs
2037
2394
  // (industry standard: LiFi SDK checkAllowance, 1inch Permit2)
@@ -2061,13 +2418,23 @@ EXAMPLES:
2061
2418
 
2062
2419
  // Empty-string approvalAddress is Relay's "no approval needed" sentinel — skip.
2063
2420
  if (currentQuote.approvalAddress && currentQuote.approvalAddress !== '' && !isNative) {
2421
+ assertUsableSpender(currentQuote.approvalAddress);
2064
2422
  // Check if sufficient allowance already exists
2065
- const inputAmount = BigInt(currentQuote.inputAmount || currentQuote.inAmount || currentQuote.transaction?.value || '0');
2423
+ const inputAmount = BigInt(currentQuote.inputAmount || currentQuote.inAmount || '0');
2424
+ const approveAmt = approvalAmountForSwap({ inputAmount, swapMode: quoteData.swapMode, slippage: quoteData.slippage });
2425
+ if (approveAmt <= 0n) {
2426
+ // Malformed quote (no/invalid input amount): a zero-scoped approval
2427
+ // would waste gas and the swap would revert on insufficient allowance.
2428
+ log(` ❌ ${quoteName} has a zero input amount — cannot scope approval, skipping.`);
2429
+ lastQuoteError = `${quoteName} has a zero input amount`;
2430
+ if (qi + 1 < endIndex) log(` Trying next quote...`);
2431
+ continue;
2432
+ }
2066
2433
  const existingAllowance = await checkErc20Allowance(
2067
2434
  chain, currentQuote.inputMint, walletAddress, currentQuote.approvalAddress
2068
2435
  );
2069
2436
 
2070
- if (existingAllowance >= inputAmount && existingAllowance > 0n) {
2437
+ if (existingAllowance >= approveAmt && existingAllowance > 0n) {
2071
2438
  log(` ✓ Sufficient allowance exists for ${quoteName}, skipping approval`);
2072
2439
  } else {
2073
2440
  log(` ⚠ Approval required → ${currentQuote.approvalAddress}`);
@@ -2082,6 +2449,8 @@ EXAMPLES:
2082
2449
  chain,
2083
2450
  approvalNonce,
2084
2451
  approvalGasPrice,
2452
+ approveAmt,
2453
+ approvalCapForQuote(quoteData),
2085
2454
  );
2086
2455
 
2087
2456
  const approvalResult = await executeTransaction({
@@ -18,8 +18,9 @@ const PACKAGE_NAME = 'nansen-cli';
18
18
 
19
19
  /**
20
20
  * Compare two semver strings. Returns true if latest > current.
21
+ * Exported so `nansen doctor` reports upgrade state with identical semantics.
21
22
  */
22
- function isNewer(latest, current) {
23
+ export function isNewer(latest, current) {
23
24
  const parse = v => v.replace(/^v/, '').split('.').map(Number);
24
25
  const [lM, lm, lp] = parse(latest);
25
26
  const [cM, cm, cp] = parse(current);
@@ -84,6 +85,48 @@ export function getUpdateNotification(currentVersion) {
84
85
  }
85
86
  }
86
87
 
88
+ const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
89
+
90
+ /**
91
+ * Build the Node source run by the detached child. It fetches the latest
92
+ * version and writes it to `file` atomically: the JSON is written to a
93
+ * pid-scoped temp file, then renamed over the target. rename(2) is atomic on
94
+ * POSIX, so a concurrent `nansen` reader always sees either the old file or the
95
+ * fully-written new one — never a truncated/empty file.
96
+ *
97
+ * The registry URL is overridable via NANSEN_REGISTRY_URL purely as a test seam
98
+ * (lets a test point the child at a local server); it defaults to npm.
99
+ */
100
+ export function buildCheckScript(dir, file, url = process.env.NANSEN_REGISTRY_URL || REGISTRY_URL) {
101
+ return `
102
+ const url = ${JSON.stringify(url)};
103
+ const http = require(url.startsWith('https:') ? 'https' : 'http');
104
+ const fs = require('fs');
105
+ const dir = ${JSON.stringify(dir)};
106
+ const file = ${JSON.stringify(file)};
107
+ const req = http.get(url, { timeout: 5000 }, (res) => {
108
+ let body = '';
109
+ res.on('data', c => body += c);
110
+ res.on('end', () => {
111
+ try {
112
+ const { version } = JSON.parse(body);
113
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { mode: 0o700, recursive: true });
114
+ const tmp = file + '.' + process.pid + '.tmp';
115
+ try {
116
+ fs.writeFileSync(tmp, JSON.stringify({ latest: version, checkedAt: Date.now() }));
117
+ fs.renameSync(tmp, file);
118
+ } catch (e) {
119
+ try { fs.unlinkSync(tmp); } catch {}
120
+ throw e;
121
+ }
122
+ } catch {}
123
+ });
124
+ });
125
+ req.on('error', () => {});
126
+ req.setTimeout(5000, () => req.destroy());
127
+ `;
128
+ }
129
+
87
130
  /**
88
131
  * If the cache is missing or stale, spawn a detached background process to refresh it.
89
132
  */
@@ -97,29 +140,7 @@ export function scheduleUpdateCheck() {
97
140
  if (checkedAt && Date.now() - checkedAt < STALE_MS) return;
98
141
  }
99
142
 
100
- // Inline script executed by the detached child
101
- const script = `
102
- const https = require('https');
103
- const fs = require('fs');
104
- const path = require('path');
105
- const dir = ${JSON.stringify(CONFIG_DIR)};
106
- const file = ${JSON.stringify(CACHE_FILE)};
107
- const req = https.get('https://registry.npmjs.org/${PACKAGE_NAME}/latest', { timeout: 5000 }, (res) => {
108
- let body = '';
109
- res.on('data', c => body += c);
110
- res.on('end', () => {
111
- try {
112
- const { version } = JSON.parse(body);
113
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { mode: 0o700, recursive: true });
114
- fs.writeFileSync(file, JSON.stringify({ latest: version, checkedAt: Date.now() }));
115
- } catch {}
116
- });
117
- });
118
- req.on('error', () => {});
119
- req.setTimeout(5000, () => req.destroy());
120
- `;
121
-
122
- const child = childProcess.spawn(process.execPath, ['-e', script], {
143
+ const child = childProcess.spawn(process.execPath, ['-e', buildCheckScript(CONFIG_DIR, CACHE_FILE)], {
123
144
  detached: true,
124
145
  stdio: 'ignore'
125
146
  });