nansen-cli 1.37.0 → 1.39.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,9 +13,10 @@ 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, assertSwapOutcome, approvalAmountForSwap } from './trade-validation.js';
17
17
  import { CHAIN_RPCS } from './rpc-urls.js';
18
- import { packageVersion, CommandError, telemetryHeaders } from './api.js';
18
+ import { simulateAssetChanges, SwapSimulationError, hasSimulationRpc } from './swap-simulation.js';
19
+ import { packageVersion, CommandError, telemetryHeaders, loadConfig } from './api.js';
19
20
 
20
21
  // ============= Constants =============
21
22
 
@@ -380,7 +381,7 @@ export function loadTxRecord(txHash) {
380
381
  * Save a quote response to disk for later execution.
381
382
  * @returns {string} Quote ID
382
383
  */
383
- export function saveQuote(quoteResponse, chain, signerType = 'local', privyWalletIds = null, toChain = null) {
384
+ export function saveQuote(quoteResponse, chain, signerType = 'local', privyWalletIds = null, toChain = null, meta = {}) {
384
385
  const dir = getQuotesDir();
385
386
  if (!fs.existsSync(dir)) {
386
387
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
@@ -393,6 +394,15 @@ export function saveQuote(quoteResponse, chain, signerType = 'local', privyWalle
393
394
  const data = { quoteId, type: 'swap', chain, timestamp, signerType, response: quoteResponse };
394
395
  if (toChain) data.toChain = toChain;
395
396
  if (privyWalletIds) data.privyWalletIds = privyWalletIds;
397
+ // Persisted so the execute path can scope ERC-20 approvals to the trade
398
+ // (exactOut is buffered by the slippage that was actually used).
399
+ if (meta.swapMode) data.swapMode = meta.swapMode;
400
+ if (meta.slippage != null) data.slippage = meta.slippage;
401
+ // Immutable request intent — the chain, wallet, token pair, mode, and amount
402
+ // the user actually asked for. The execute path revalidates the API's quote
403
+ // against this (see assertQuoteMatchesRequest) so a compromised or buggy quote
404
+ // can't inflate the input, approval, or native value past the user's intent.
405
+ if (meta.request) data.request = meta.request;
396
406
 
397
407
  fs.writeFileSync(path.join(dir, `${quoteId}.json`), JSON.stringify(data, null, 2), { mode: 0o600 });
398
408
  cleanupQuotes();
@@ -691,6 +701,92 @@ export async function simulateEvmCall(chain, { from, to, data, value, gas }) {
691
701
  }
692
702
  }
693
703
 
704
+ /**
705
+ * Normalise an aggregator's transaction `value` to a 0x-hex string the RPC
706
+ * accepts. The field may be a decimal string ('1000000'), a 0x-hex string, a
707
+ * bare '0x' (no digits — `BigInt('0x')` throws), or absent. Anything unparseable
708
+ * becomes '0x0' rather than throwing, so a malformed value can't crash the
709
+ * degrade path or misfire as an outcome mismatch. Note: unlike swap-simulation's
710
+ * hexToBigInt, this keeps BigInt's decimal parsing (tx.value is often decimal).
711
+ */
712
+ function toRpcHexValue(value) {
713
+ if (!value || value === '0x') return '0x0';
714
+ try {
715
+ return '0x' + BigInt(value).toString(16);
716
+ } catch {
717
+ return '0x0';
718
+ }
719
+ }
720
+
721
+ /**
722
+ * Verify — via balance-delta simulation — that a swap does to the wallet what
723
+ * the user asked and no more. Defence-in-depth on top of the static calldata
724
+ * guards: the cheap eth_call sim answers "will it revert", this answers "does the
725
+ * outcome match intent" (see assertSwapOutcome in trade-validation.js).
726
+ *
727
+ * EVM-only, and on its own gate independent of --no-simulate/gasless. Skipped
728
+ * for cross-chain bridges (the output lands on the destination chain, so a
729
+ * source-chain simulation can't observe it). When no simulation-capable endpoint
730
+ * is configured it DEGRADES — logs a warning, then proceeds — so a simulation
731
+ * outage never blocks trading. --no-verify-outcome skips it entirely.
732
+ *
733
+ * Returns { proceed, reason }. proceed=false means this quote failed
734
+ * verification: the caller should fall through to the next candidate WITHOUT
735
+ * signing or broadcasting the swap. proceed=true covers a clean pass AND a
736
+ * degrade (the warning is logged here).
737
+ *
738
+ * @param {object} args
739
+ * @param {string} args.chain
740
+ * @param {string} args.from - the wallet that will sign (the sender simulated)
741
+ * @param {object} args.quote - the quote about to be executed (currentQuote)
742
+ * @param {object} args.quoteData - the loaded quote record (.request, .slippage)
743
+ * @param {string|null} [args.apiKey] - Nansen API key for the hosted endpoint
744
+ * @param {function} [args.log]
745
+ */
746
+ export async function verifySwapOutcome({ chain, from, quote, quoteData, apiKey = null, log = () => {} }) {
747
+ if (CHAIN_MAP[chain?.toLowerCase()]?.type !== 'evm') return { proceed: true }; // EVM-only
748
+ // Cross-chain: the output token settles on the destination chain, so it can
749
+ // never appear in a source-chain simulation and the output-received assertion
750
+ // would always fail. The source-chain leg only spends/locks the input here;
751
+ // skip outcome verification for bridges (mirrors the bridge branch below).
752
+ if (quoteData?.toChain && quoteData.toChain !== quoteData.chain) return { proceed: true };
753
+ // No request intent recorded (a pre-intent quote): assertSwapOutcome has
754
+ // nothing to compare the simulated deltas against and would raise a misleading
755
+ // SWAP_OUTCOME_MISMATCH. Degrade cleanly — the static guards still ran, and a
756
+ // re-quote re-enables this check.
757
+ if (!quoteData?.request) {
758
+ log(' ⚠ Swap-outcome verification skipped (no request intent — re-quote to enable it).');
759
+ return { proceed: true };
760
+ }
761
+ if (!hasSimulationRpc(chain)) {
762
+ log(` ⚠ Swap-outcome verification unavailable (no simulation endpoint for ${chain}); proceeding without it.`);
763
+ return { proceed: true };
764
+ }
765
+ const tx = quote?.transaction || {};
766
+ // Spenders the wallet may legitimately (re)approve mid-swap: the approval
767
+ // target and the router it routes through. Anything else fails assertion 4.
768
+ const expectedSpenders = [quote?.approvalAddress, tx.to].filter(Boolean);
769
+ try {
770
+ const sim = await simulateAssetChanges(
771
+ chain,
772
+ { to: tx.to, data: tx.data, value: toRpcHexValue(tx.value) },
773
+ { from, apiKey },
774
+ );
775
+ assertSwapOutcome(quoteData.request, quote, sim, { slippage: quoteData.slippage, expectedSpenders });
776
+ log(` ✓ Swap outcome verified (via ${sim.method}).`);
777
+ return { proceed: true };
778
+ } catch (e) {
779
+ // Degrade (warn + proceed) when the simulation itself could not run; block
780
+ // (fall through to the next quote) when the outcome did not match or the
781
+ // swap reverts in simulation.
782
+ if (e instanceof SwapSimulationError && ['NO_SIM_RPC', 'NOT_SIM_CAPABLE', 'SIM_RPC_ERROR'].includes(e.code)) {
783
+ log(` ⚠ Swap-outcome verification could not run (${e.message}); proceeding without it.`);
784
+ return { proceed: true };
785
+ }
786
+ return { proceed: false, reason: e.message };
787
+ }
788
+ }
789
+
694
790
  /**
695
791
  * Estimate gas for an EVM transaction. Returns the gas estimate or null on failure.
696
792
  * Used to fix under-gassed quotes from aggregators.
@@ -726,6 +822,141 @@ export async function checkErc20Allowance(chain, tokenAddress, ownerAddress, spe
726
822
  }
727
823
  }
728
824
 
825
+ // approvalAmountForSwap now lives in trade-validation.js alongside the approval
826
+ // encoder and the spend-ceiling check that both consume it, so the "how much can
827
+ // leave the wallet" math has a single definition. Re-exported here because the
828
+ // execute paths below (and tests) import it from this module.
829
+ export { approvalAmountForSwap };
830
+
831
+ /**
832
+ * The maximum allowance (spend ceiling, in the SELL token's base units) to hand
833
+ * the approval encoder for a saved quote. Centralised so every signing path
834
+ * shares one definition and a refactor can't reintroduce a wrong-unit cap.
835
+ *
836
+ * Returns the persisted `maxInputAmount` when present. Otherwise:
837
+ * - exactIn: falls back to `request.amount`, which for exactIn IS the input
838
+ * bound (covers quotes saved before maxInputAmount existed).
839
+ * - exactOut: returns undefined — there is NO safe fallback, because
840
+ * `request.amount` is the OUTPUT amount (a different token). The encoder
841
+ * still bounds the amount below MAX_UINT256, and assertInputWithinMax fails
842
+ * closed on a missing exactOut cap before any approval is built, so exactOut
843
+ * never legitimately reaches here without a cap.
844
+ *
845
+ * @param {object} quoteData - The loaded quote record (with .swapMode, .request)
846
+ * @returns {string|number|undefined} allowance cap, or undefined for no cap
847
+ */
848
+ export function approvalCapForQuote(quoteData) {
849
+ const cap = quoteData?.request?.maxInputAmount;
850
+ if (cap != null) return cap;
851
+ return quoteData?.swapMode === 'exactOut' ? undefined : quoteData?.request?.amount;
852
+ }
853
+
854
+ export function assertCompleteEvmRequestIntent(request) {
855
+ if (!request) {
856
+ throw new Error('Quote is missing request intent. Re-quote with this CLI version before executing an EVM swap. Refusing to sign.');
857
+ }
858
+
859
+ const missing = [];
860
+ for (const field of ['chain', 'walletAddress', 'fromToken', 'toToken', 'swapMode', 'amount', 'maxInputAmount']) {
861
+ if (request[field] == null || request[field] === '') missing.push(field);
862
+ }
863
+ if (missing.length) {
864
+ throw new Error(`Quote request intent is incomplete (${missing.join(', ')} missing). Re-quote before executing an EVM swap. Refusing to sign.`);
865
+ }
866
+ }
867
+
868
+ /**
869
+ * Sanity-check the target of a swap transaction before signing it.
870
+ *
871
+ * This is a defensive gate, not a router allowlist. It rejects the crude cases
872
+ * where the transaction clearly isn't a swap routed through an aggregator: a
873
+ * null/zero target, or a call straight at the token being sold (which would
874
+ * encode a transfer/approve of that token rather than a swap — the one
875
+ * full-balance drain that needs no prior approval). It also confirms the target
876
+ * carries contract code. The code check fails closed: it retries a few times
877
+ * and, if it still can't confirm the target is a contract, throws rather than
878
+ * signing against an unverified target — a flaky or hostile RPC must not be
879
+ * able to silently disable the guard. A missing RPC config throws immediately.
880
+ *
881
+ * Throws on a definitive rejection; returns nothing on pass. Callers run this
882
+ * inside the per-quote try so a rejected quote falls through to the next one.
883
+ *
884
+ * @param {string} chain - Chain name
885
+ * @param {string} to - Transaction target (quote.transaction.to)
886
+ * @param {string} inputMint - The token being sold (quote.inputMint)
887
+ */
888
+ export async function validateSwapTarget(chain, to, inputMint, { verifiedTargets } = {}) {
889
+ if (!to || /^0x0+$/i.test(to)) {
890
+ throw new Error(`Swap target address is empty or zero (${to ?? 'undefined'}). Refusing to sign.`);
891
+ }
892
+ // A legit swap — same-chain OR cross-chain bridge — routes through an
893
+ // aggregator/router, never the sold token itself. This gate is intentionally
894
+ // NOT same-chain-scoped: a bare ERC-20 `transfer`/`approve` necessarily
895
+ // targets the token contract, so `to === inputMint` is the drain shape in both
896
+ // cases, and the bridge routes this CLI uses (Relay/Li.Fi) route deposits
897
+ // through a router (to != token), so this never fires on a legitimate bridge.
898
+ // Loosening it for cross-chain would let a compromised bridge quote encode a
899
+ // bare transfer to an attacker (the cross-chain path does not parse the
900
+ // calldata recipient/amount), so it fails closed here. (A WETH-style direct
901
+ // unwrap can trip this; re-quote or use the native sentinel 0xeee…eee if so.)
902
+ if (inputMint && to.toLowerCase() === inputMint.toLowerCase()) {
903
+ throw new Error(
904
+ `Swap target equals the token being sold (${to}). A swap routes through an aggregator, not the token itself. Refusing to sign.`,
905
+ );
906
+ }
907
+ // Skip the RPC round-trip (and its retries) for a target already confirmed to
908
+ // carry contract code earlier in this same execute run. Quote lists commonly
909
+ // share one router across all quotes, so this avoids re-verifying — and, on a
910
+ // flaky RPC, re-retrying — the same target N times. Only SUCCESSFUL checks are
911
+ // cached, so a transient failure still gets a fresh attempt on the next quote.
912
+ const targetKey = `${chain}:${to.toLowerCase()}`;
913
+ if (verifiedTargets?.has(targetKey)) return;
914
+
915
+ // Fail CLOSED on an unverifiable target: retry a few times, then refuse rather
916
+ // than sign against a target we couldn't confirm carries contract code. A
917
+ // flaky — or hostile — RPC must not be able to silently disable this guard.
918
+ let code;
919
+ let lastErr = null;
920
+ const MAX_ATTEMPTS = 3;
921
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
922
+ try {
923
+ code = await evmRpcCall(chain, 'eth_getCode', [to, 'latest']);
924
+ lastErr = null;
925
+ break;
926
+ } catch (err) {
927
+ // A missing RPC config is a deterministic setup error, not a flaky
928
+ // network — surface it immediately rather than burn retries on it.
929
+ if (err?.message?.startsWith('No RPC URL')) throw err;
930
+ lastErr = err;
931
+ if (attempt < MAX_ATTEMPTS) {
932
+ process.stderr.write(` ⚠ Swap target check attempt ${attempt}/${MAX_ATTEMPTS} failed (${err.message}); retrying...\n`);
933
+ await new Promise(r => setTimeout(r, 300));
934
+ }
935
+ }
936
+ }
937
+ if (lastErr) {
938
+ throw new Error(
939
+ `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.`,
940
+ );
941
+ }
942
+ if (!code || code === '0x' || code === '0x0') {
943
+ throw new Error(`Swap target ${to} is not a contract (no code). Refusing to sign.`);
944
+ }
945
+ verifiedTargets?.add(targetKey);
946
+ }
947
+
948
+ /**
949
+ * Reject an approval whose spender is not a well-formed, non-zero 20-byte EVM
950
+ * address. A real aggregator spender is always a 20-byte contract address; an
951
+ * empty, zero, or over-length value means the quote is malformed or tampered.
952
+ * An over-length spender is especially dangerous — concatenated into approval
953
+ * calldata it shifts the ABI word layout — so we refuse before signing.
954
+ * Delegates to the shared strict validator used by the calldata encoder.
955
+ */
956
+ export function assertUsableSpender(spenderAddress) {
957
+ assertValidApprovalSpender(spenderAddress);
958
+ }
959
+
729
960
  /**
730
961
  * Send an ERC-20 approval transaction.
731
962
  * Required before swapping non-native EVM tokens.
@@ -735,19 +966,21 @@ export async function checkErc20Allowance(chain, tokenAddress, ownerAddress, spe
735
966
  * @param {string} privateKeyHex - Wallet private key
736
967
  * @param {string} chain - Chain name
737
968
  * @param {number} nonce - Account nonce
969
+ * @param {string|number} gasPrice - Legacy gas price
970
+ * @param {bigint|string|number} amount - Allowance to grant, in base units (see approvalAmountForSwap)
971
+ * @param {bigint|string|number} [maxAllowance] - Hard cap from persisted request intent
738
972
  * @returns {string} 0x-prefixed signed approval tx hex
739
973
  */
740
974
  // ⚠️ SECURITY: ERC-20 approval signing - requires thorough review
741
- export function buildApprovalTransaction(tokenAddress, spenderAddress, privateKeyHex, chain, nonce, gasPrice) {
975
+ export function buildApprovalTransaction(tokenAddress, spenderAddress, privateKeyHex, chain, nonce, gasPrice, amount, maxAllowance) {
742
976
  const chainConfig = CHAIN_MAP[chain];
743
977
  if (!chainConfig) throw new Error(`Unsupported chain: ${chain}`);
744
978
 
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;
979
+ // Scope the approval to the swap's input amount so a malicious or buggy quote
980
+ // can drain at most this one trade, never the wallet's full token balance.
981
+ // encodeApproveCalldata enforces a valid 20-byte spender, a bounded (< MAX)
982
+ // amount within the request cap, and exactly-68-byte calldata.
983
+ const data = encodeApproveCalldata(spenderAddress, amount, { maxAllowance });
751
984
 
752
985
  const tx = {
753
986
  nonce,
@@ -1233,6 +1466,11 @@ OPTIONS:
1233
1466
  --auto-slippage Enable auto slippage calculation
1234
1467
  --max-auto-slippage <pct> Max auto slippage when auto-slippage enabled
1235
1468
  --swap-mode <mode> exactIn (default) or exactOut
1469
+ --max-input <baseUnits> exactOut only: hard ceiling on the sell-token spend
1470
+ (base units), measured against the slippage-buffered
1471
+ approval (input + slippage), not the bare quote input.
1472
+ Required for EVM (Base) exactOut and enforced before
1473
+ signing; optional on Solana (no ERC-20 approval to scope).
1236
1474
  --aggregator <name> Force a specific aggregator (lifi, relay, jupiter, okx).
1237
1475
  Filters the quote list client-side; errors if none match.
1238
1476
 
@@ -1267,6 +1505,49 @@ CROSS-CHAIN NOTES (when using --to-chain):
1267
1505
  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
1506
  }
1269
1507
 
1508
+ // The exactOut spend-ceiling requirements below only guard the EVM signing
1509
+ // path: the ERC-20 approval scoping, request-intent binding, and
1510
+ // assertInputWithinMax checks are wired into the EVM execute paths only.
1511
+ // Solana signs the API transaction verbatim (no approval to scope), so
1512
+ // requiring --max-input there would break existing Solana exactOut users
1513
+ // without buying any of that path a security guarantee. Gate on EVM source.
1514
+ const isEvmSource = CHAIN_MAP[chain?.toLowerCase()]?.type === 'evm';
1515
+
1516
+ // exactOut scopes the ERC-20 approval to a slippage-buffered max input. With
1517
+ // uncapped auto-slippage the actual bound is server-side and unknown, so the
1518
+ // buffer could be under-sized and the swap would revert on allowance. Require
1519
+ // an explicit cap so the approval is always bounded by a value we know.
1520
+ if (isEvmSource && swapMode === 'exactOut' && autoSlippage && maxAutoSlippage == null) {
1521
+ 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');
1522
+ }
1523
+
1524
+ // --max-input: an explicit ceiling (base units of the sell token) on how
1525
+ // much may leave the wallet for an exactOut swap, persisted as intent and
1526
+ // enforced before signing. exactIn is already capped at --amount (the
1527
+ // input the user names), so the flag is exactOut-only.
1528
+ const maxInputRaw = options['max-input'];
1529
+ let maxInputOverride = null;
1530
+ if (maxInputRaw != null) {
1531
+ if (swapMode !== 'exactOut') {
1532
+ throw new CommandError('Error: --max-input only applies to --swap-mode exactOut (exactIn already caps spend at --amount).', 'INVALID_INPUT');
1533
+ }
1534
+ const maxInputError = validateBaseUnitAmount(maxInputRaw);
1535
+ if (maxInputError) {
1536
+ throw new CommandError(`Error: invalid --max-input: ${maxInputError} (--max-input is in base units of the sell token).`, 'INVALID_INPUT');
1537
+ }
1538
+ // validateBaseUnitAmount catches negatives/decimals but not non-numeric
1539
+ // input (e.g. "abc"); guard the BigInt so it surfaces cleanly, not as a
1540
+ // raw "Cannot convert … to a BigInt".
1541
+ try {
1542
+ maxInputOverride = BigInt(maxInputRaw).toString();
1543
+ } catch {
1544
+ throw new CommandError(`Error: invalid --max-input "${maxInputRaw}": must be an integer in base units of the sell token.`, 'INVALID_INPUT');
1545
+ }
1546
+ }
1547
+ if (isEvmSource && swapMode === 'exactOut' && maxInputOverride == null) {
1548
+ 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');
1549
+ }
1550
+
1270
1551
  // Static input validation — catches common agent errors (wrong addresses,
1271
1552
  // same-token swaps, bad amounts) before any network or wallet call.
1272
1553
  try {
@@ -1463,6 +1744,46 @@ CROSS-CHAIN NOTES (when using --to-chain):
1463
1744
  response.quotes = matching;
1464
1745
  }
1465
1746
 
1747
+ // Slippage actually in effect. Computed here (not just at save time) so
1748
+ // the --max-input filter below measures the same buffered approval the
1749
+ // execute path will build, keeping quote-time and execute-time in lockstep.
1750
+ const effectiveSlippage = slippage != null ? Number(slippage)
1751
+ : autoSlippage ? (maxAutoSlippage != null ? Number(maxAutoSlippage) : 0.05)
1752
+ : 0.03;
1753
+
1754
+ // Explicit --max-input: drop quotes whose *buffered* input exceeds the cap
1755
+ // so we never print a Quote ID the execute path would refuse. For exactOut
1756
+ // the approval is slippage-buffered (approvalAmountForSwap), so a raw input
1757
+ // at the cap still overflows it once buffered (1,000,000 @ 3% → 1,030,000);
1758
+ // filtering on the raw input would save a quote the approval encoder later
1759
+ // rejects for exceeding the cap. (max-input is exactOut-only. The derived
1760
+ // default is computed from the max quote input below, so it can never
1761
+ // exclude a quote — only an explicit cap can.)
1762
+ if (maxInputOverride != null) {
1763
+ const cap = BigInt(maxInputOverride);
1764
+ // Max sell-token base units that can leave the wallet for this quote.
1765
+ const spendFor = (q) => approvalAmountForSwap({
1766
+ inputAmount: q.inputAmount ?? q.inAmount ?? '0',
1767
+ swapMode,
1768
+ slippage: effectiveSlippage,
1769
+ });
1770
+ const withinCap = response.quotes.filter((q) => {
1771
+ const spend = spendFor(q);
1772
+ return spend > 0n && spend <= cap;
1773
+ });
1774
+ if (!withinCap.length) {
1775
+ const cheapest = response.quotes.reduce((min, q) => {
1776
+ const spend = spendFor(q);
1777
+ return spend > 0n && (min == null || spend < min) ? spend : min;
1778
+ }, null);
1779
+ throw new CommandError(
1780
+ `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.`,
1781
+ 'MAX_INPUT_EXCEEDED'
1782
+ );
1783
+ }
1784
+ response.quotes = withinCap;
1785
+ }
1786
+
1466
1787
  log('');
1467
1788
  response.quotes.forEach((q, i) => log(formatQuote(q, i)));
1468
1789
 
@@ -1476,7 +1797,26 @@ CROSS-CHAIN NOTES (when using --to-chain):
1476
1797
  }
1477
1798
 
1478
1799
  const signerType = isWalletConnect ? 'walletconnect' : walletProvider;
1479
- const quoteId = saveQuote(response, chain, signerType, privyWalletIds, isCrossChain ? toChainRaw : null);
1800
+ const maxInputAmount = swapMode === 'exactOut' ? maxInputOverride : String(resolvedAmount);
1801
+ const quoteId = saveQuote(response, chain, signerType, privyWalletIds, isCrossChain ? toChainRaw : null, {
1802
+ swapMode,
1803
+ slippage: effectiveSlippage,
1804
+ // Immutable record of what the user asked for; revalidated at execute
1805
+ // time so the API's quote can't drift beyond it. For exactIn `amount`
1806
+ // is the input; for exactOut it is the requested output. `maxInputAmount`
1807
+ // is the spend ceiling enforced in both modes before signing.
1808
+ request: {
1809
+ chain,
1810
+ toChain: isCrossChain ? toChainRaw : null,
1811
+ walletAddress,
1812
+ recipient: params.toWalletAddress ?? null,
1813
+ fromToken: from,
1814
+ toToken: to,
1815
+ swapMode,
1816
+ amount: resolvedAmount,
1817
+ maxInputAmount,
1818
+ },
1819
+ });
1480
1820
  log(`\n Quote ID: ${quoteId}`);
1481
1821
  log(` Execute: nansen trade execute --quote ${quoteId}`);
1482
1822
  if (response.quotes.length > 1) {
@@ -1508,7 +1848,18 @@ CROSS-CHAIN NOTES (when using --to-chain):
1508
1848
  const quoteId = options.quote || options['quote-id'] || args[0];
1509
1849
  const walletName = options.wallet;
1510
1850
  const noSimulate = flags['no-simulate'];
1851
+ const noVerifyOutcome = flags['no-verify-outcome'];
1511
1852
  const gasless = Boolean(flags.gasless);
1853
+ // Read the API key for the swap-outcome sim endpoint. It's optional (the
1854
+ // check degrades to a warning if the endpoint can't authenticate), so a
1855
+ // malformed config must not crash an in-progress trade — fall back to null.
1856
+ const apiKey = (() => {
1857
+ try {
1858
+ return loadConfig().apiKey;
1859
+ } catch {
1860
+ return null;
1861
+ }
1862
+ })();
1512
1863
 
1513
1864
  if (!quoteId) {
1514
1865
  throw new CommandError(`Usage: nansen trade execute --quote <quoteId> [options]
@@ -1516,7 +1867,8 @@ CROSS-CHAIN NOTES (when using --to-chain):
1516
1867
  OPTIONS:
1517
1868
  --quote <id> Quote ID from 'nansen quote'
1518
1869
  --wallet <name> Wallet name (default: default wallet)
1519
- --no-simulate Skip pre-broadcast simulation
1870
+ --no-simulate Skip pre-broadcast simulation (the eth_call revert check)
1871
+ --no-verify-outcome Skip EVM swap-outcome verification (balance-delta check)
1520
1872
  --gasless Relay-only: have Relay's solver pay gas (no WalletConnect)
1521
1873
 
1522
1874
  EXAMPLES:
@@ -1610,6 +1962,10 @@ EXAMPLES:
1610
1962
  }
1611
1963
 
1612
1964
  let lastQuoteError = null;
1965
+ // Swap targets confirmed to carry contract code in this execute run, so a
1966
+ // router shared across quotes is verified once, not per quote (see
1967
+ // validateSwapTarget). Scoped to this run — never cached across processes.
1968
+ const verifiedTargets = new Set();
1613
1969
 
1614
1970
  for (let qi = startIndex; qi < endIndex; qi++) {
1615
1971
  const currentQuote = allQuotes[qi];
@@ -1672,6 +2028,26 @@ EXAMPLES:
1672
2028
  const walletResult = await privyClient.getWallet(evmWalletId);
1673
2029
  const walletAddress = walletResult.address;
1674
2030
 
2031
+ // Guard the swap target before any RPC call, approval, or signing —
2032
+ // whatever `to`/`data` the quote supplied gets signed verbatim.
2033
+ await validateSwapTarget(chain, currentQuote.transaction.to, currentQuote.inputMint, { verifiedTargets });
2034
+
2035
+ // Bind the quote to the immutable request intent persisted at quote
2036
+ // time, so a compromised API can't inflate the input (and therefore
2037
+ // the scoped approval and native value) past what the user asked to spend.
2038
+ assertCompleteEvmRequestIntent(quoteData.request);
2039
+ assertQuoteMatchesRequest(quoteData.request, currentQuote, { chain, walletAddress, slippage: quoteData.slippage });
2040
+
2041
+ // Reject a bare ERC-20 transfer/approve/transferFrom as the outer
2042
+ // call: a real swap or bridge routes through an aggregator/router,
2043
+ // never a direct token method. Runs on cross-chain too — the
2044
+ // validateSwapTarget gate above only refuses `to === inputMint`, so
2045
+ // a bare transfer to a SIBLING token the wallet holds would
2046
+ // otherwise slip through the bridge path (which doesn't parse the
2047
+ // calldata recipient) and drain it. Legitimate bridges route through
2048
+ // a router selector, so this never fires on a real cross-chain quote.
2049
+ assertSwapCalldataNotBareTransfer(currentQuote.transaction.data);
2050
+
1675
2051
  // Validate transaction.value (same checks as local wallet)
1676
2052
  const isNative = isNativeToken(currentQuote.inputMint);
1677
2053
  const txValue = BigInt(currentQuote.transaction.value || '0');
@@ -1695,20 +2071,32 @@ EXAMPLES:
1695
2071
  // Handle approval if needed
1696
2072
  // Empty-string approvalAddress is Relay's "no approval needed" sentinel — skip.
1697
2073
  if (currentQuote.approvalAddress && currentQuote.approvalAddress !== '' && !isNative) {
2074
+ assertUsableSpender(currentQuote.approvalAddress);
1698
2075
  const inputAmount = BigInt(currentQuote.inputAmount || currentQuote.inAmount || '0');
2076
+ const approveAmt = approvalAmountForSwap({ inputAmount, swapMode: quoteData.swapMode, slippage: quoteData.slippage });
2077
+ if (approveAmt <= 0n) {
2078
+ // Malformed quote (no/invalid input amount): a zero-scoped approval
2079
+ // would waste gas and the swap would revert on insufficient allowance.
2080
+ log(` ❌ ${quoteName} has a zero input amount — cannot scope approval, skipping.`);
2081
+ lastQuoteError = `${quoteName} has a zero input amount`;
2082
+ if (qi + 1 < endIndex) log(` Trying next quote...`);
2083
+ continue;
2084
+ }
1699
2085
  const existingAllowance = await checkErc20Allowance(
1700
2086
  chain, currentQuote.inputMint, walletAddress, currentQuote.approvalAddress
1701
2087
  );
1702
2088
 
1703
- if (existingAllowance >= inputAmount && existingAllowance > 0n) {
2089
+ if (existingAllowance >= approveAmt && existingAllowance > 0n) {
1704
2090
  log(` ✓ Sufficient allowance exists for ${quoteName}, skipping approval`);
1705
2091
  } else {
1706
2092
  log(` ⚠ Approval required → ${currentQuote.approvalAddress}`);
1707
2093
  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;
2094
+ // Scope the approval to this trade's input (see approvalAmountForSwap).
2095
+ // encodeApproveCalldata enforces a valid 20-byte spender, a
2096
+ // bounded (< MAX) amount within the request cap, and 68-byte calldata.
2097
+ const approvalData = encodeApproveCalldata(currentQuote.approvalAddress, approveAmt, {
2098
+ maxAllowance: approvalCapForQuote(quoteData),
2099
+ });
1712
2100
  const approvalMaxFee = currentQuote.transaction?.maxFeePerGas || currentQuote.transaction?.gasPrice || '1000000';
1713
2101
  const approvalPriorityFee = currentQuote.transaction?.maxPriorityFeePerGas || '1000000';
1714
2102
  const approvalSignResult = await privyClient.signEvmTransaction(evmWalletId, {
@@ -1759,6 +2147,20 @@ EXAMPLES:
1759
2147
  }
1760
2148
  }
1761
2149
 
2150
+ // Verify the swap's simulated on-chain outcome matches intent.
2151
+ // Its own gate (runs even when --no-simulate/gasless skip the
2152
+ // cheap revert check above); degrades with a warning if no
2153
+ // simulation endpoint is available.
2154
+ if (!noVerifyOutcome) {
2155
+ const outcome = await verifySwapOutcome({ chain, from: walletAddress, quote: currentQuote, quoteData, apiKey, log });
2156
+ if (!outcome.proceed) {
2157
+ log(` ❌ ${quoteName} failed swap-outcome verification: ${outcome.reason}`);
2158
+ if (qi + 1 < endIndex) log(` Trying next quote...`);
2159
+ lastQuoteError = `${quoteName} outcome verification failed: ${outcome.reason}`;
2160
+ continue;
2161
+ }
2162
+ }
2163
+
1762
2164
  // Gas resolution — fall back to eth_estimateGas if quote has no gas
1763
2165
  const txData = currentQuote.transaction;
1764
2166
  const apiGas = parseInt(currentQuote.gas || '0');
@@ -1805,6 +2207,12 @@ EXAMPLES:
1805
2207
  signedTransaction = signResult.data?.signed_transaction || signResult.signed_transaction;
1806
2208
 
1807
2209
  } else if (chainType === 'solana') {
2210
+ // NB: validateSwapTarget (the EVM `to`/`data` guard) intentionally does
2211
+ // not apply here — Solana quotes are a pre-built serialized
2212
+ // VersionedTransaction with no `to`/`data`/approval split to validate,
2213
+ // and this path (including the WalletConnect sub-branch below) signs it
2214
+ // as supplied. Deeper Solana inspection (e.g. checking instruction
2215
+ // program IDs) is tracked as a follow-up, not an oversight.
1808
2216
  // Solana: transaction is either a base64 string (Jupiter) or an object
1809
2217
  // with a base58-encoded `data` field (OKX). Normalize to base64.
1810
2218
  let txBase64 = currentQuote.transaction;
@@ -1860,8 +2268,36 @@ EXAMPLES:
1860
2268
  } else if (isWalletConnect) {
1861
2269
  // EVM via WalletConnect: wallet signs and may broadcast
1862
2270
  const wcAddress = await getWalletConnectAddress(chainType);
2271
+ // A session dropped mid-execute returns null here. Without this
2272
+ // guard a null address would fall through to assertQuoteMatchesRequest,
2273
+ // whose `request.walletAddress && walletAddress` condition would
2274
+ // silently skip the signer-binding check. Fail closed instead.
2275
+ if (!wcAddress) {
2276
+ throw new CommandError('WalletConnect session lost during execute. Reconnect with `walletconnect connect` and retry.', 'NO_WALLET');
2277
+ }
1863
2278
  const isNative = isNativeToken(currentQuote.inputMint);
1864
2279
 
2280
+ // Guard the swap target before any RPC call, approval, or signing —
2281
+ // whatever `to`/`data` the quote supplied gets signed verbatim.
2282
+ await validateSwapTarget(chain, currentQuote.transaction.to, currentQuote.inputMint, { verifiedTargets });
2283
+
2284
+ // Bind the quote to the immutable request intent persisted at quote
2285
+ // time, so a compromised API can't inflate the input (and therefore
2286
+ // the scoped approval and native value) past what the user asked to spend.
2287
+ // The connected WC address is the signer here.
2288
+ assertCompleteEvmRequestIntent(quoteData.request);
2289
+ assertQuoteMatchesRequest(quoteData.request, currentQuote, { chain, walletAddress: wcAddress, slippage: quoteData.slippage });
2290
+
2291
+ // Reject a bare ERC-20 transfer/approve/transferFrom as the outer
2292
+ // call: a real swap or bridge routes through an aggregator/router,
2293
+ // never a direct token method. Runs on cross-chain too — the
2294
+ // validateSwapTarget gate above only refuses `to === inputMint`, so
2295
+ // a bare transfer to a SIBLING token the wallet holds would
2296
+ // otherwise slip through the bridge path (which doesn't parse the
2297
+ // calldata recipient) and drain it. Legitimate bridges route through
2298
+ // a router selector, so this never fires on a real cross-chain quote.
2299
+ assertSwapCalldataNotBareTransfer(currentQuote.transaction.data);
2300
+
1865
2301
  // Validate transaction.value (same checks as local wallet)
1866
2302
  const txValue = BigInt(currentQuote.transaction.value || '0');
1867
2303
  if (isNative) {
@@ -1884,12 +2320,22 @@ EXAMPLES:
1884
2320
  // Handle approval via WalletConnect if needed
1885
2321
  // Empty-string approvalAddress is Relay's "no approval needed" sentinel — skip.
1886
2322
  if (currentQuote.approvalAddress && currentQuote.approvalAddress !== '' && !isNative) {
2323
+ assertUsableSpender(currentQuote.approvalAddress);
1887
2324
  const inputAmount = BigInt(currentQuote.inputAmount || currentQuote.inAmount || '0');
2325
+ const approveAmt = approvalAmountForSwap({ inputAmount, swapMode: quoteData.swapMode, slippage: quoteData.slippage });
2326
+ if (approveAmt <= 0n) {
2327
+ // Malformed quote (no/invalid input amount): a zero-scoped approval
2328
+ // would waste gas and the swap would revert on insufficient allowance.
2329
+ log(` ❌ ${quoteName} has a zero input amount — cannot scope approval, skipping.`);
2330
+ lastQuoteError = `${quoteName} has a zero input amount`;
2331
+ if (qi + 1 < endIndex) log(` Trying next quote...`);
2332
+ continue;
2333
+ }
1888
2334
  const existingAllowance = await checkErc20Allowance(
1889
2335
  chain, currentQuote.inputMint, wcAddress, currentQuote.approvalAddress
1890
2336
  );
1891
2337
 
1892
- if (existingAllowance >= inputAmount && existingAllowance > 0n) {
2338
+ if (existingAllowance >= approveAmt && existingAllowance > 0n) {
1893
2339
  log(` ✓ Sufficient allowance exists for ${quoteName}, skipping approval`);
1894
2340
  } else {
1895
2341
  log(` ⚠ Approval required → ${currentQuote.approvalAddress}`);
@@ -1899,6 +2345,8 @@ EXAMPLES:
1899
2345
  currentQuote.inputMint,
1900
2346
  currentQuote.approvalAddress,
1901
2347
  chainConfig.chainId,
2348
+ approveAmt,
2349
+ approvalCapForQuote(quoteData),
1902
2350
  );
1903
2351
  let approvalTxHash = approvalResult.txHash;
1904
2352
  if (!approvalTxHash && approvalResult.signedTransaction) {
@@ -1947,6 +2395,20 @@ EXAMPLES:
1947
2395
  }
1948
2396
  }
1949
2397
 
2398
+ // Verify the swap's simulated on-chain outcome matches intent. Its
2399
+ // own gate: runs even when --no-simulate/gasless skip the cheap
2400
+ // eth_call revert check above; degrades with a warning when no
2401
+ // simulation endpoint is set.
2402
+ if (!noVerifyOutcome) {
2403
+ const outcome = await verifySwapOutcome({ chain, from: wcAddress, quote: currentQuote, quoteData, apiKey, log });
2404
+ if (!outcome.proceed) {
2405
+ log(` ❌ ${quoteName} failed swap-outcome verification: ${outcome.reason}`);
2406
+ if (qi + 1 < endIndex) log(` Trying next quote...`);
2407
+ lastQuoteError = `${quoteName} outcome verification failed: ${outcome.reason}`;
2408
+ continue;
2409
+ }
2410
+ }
2411
+
1950
2412
  // Resolve gas
1951
2413
  const txData = currentQuote.transaction;
1952
2414
  const apiGas = parseInt(currentQuote.gas || "0");
@@ -2032,6 +2494,28 @@ EXAMPLES:
2032
2494
  // EVM: quote.transaction is { to, data, value, gas, gasPrice }
2033
2495
  const walletAddress = exported.evm.address;
2034
2496
 
2497
+ // Guard the swap target before any RPC call, approval, or signing —
2498
+ // whatever `to`/`data` the quote supplied gets signed verbatim, so
2499
+ // reject an implausible target (zero, EOA, or the sold token itself)
2500
+ // before spending gas on an approval.
2501
+ await validateSwapTarget(chain, currentQuote.transaction.to, currentQuote.inputMint, { verifiedTargets });
2502
+
2503
+ // Bind the quote to the immutable request intent persisted at quote
2504
+ // time, so a compromised API can't inflate the input (and therefore
2505
+ // the scoped approval and native value) past what the user asked to spend.
2506
+ assertCompleteEvmRequestIntent(quoteData.request);
2507
+ assertQuoteMatchesRequest(quoteData.request, currentQuote, { chain, walletAddress, slippage: quoteData.slippage });
2508
+
2509
+ // Reject a bare ERC-20 transfer/approve/transferFrom as the outer
2510
+ // call: a real swap or bridge routes through an aggregator/router,
2511
+ // never a direct token method. Runs on cross-chain too — the
2512
+ // validateSwapTarget gate above only refuses `to === inputMint`, so
2513
+ // a bare transfer to a SIBLING token the wallet holds would
2514
+ // otherwise slip through the bridge path (which doesn't parse the
2515
+ // calldata recipient) and drain it. Legitimate bridges route through
2516
+ // a router selector, so this never fires on a real cross-chain quote.
2517
+ assertSwapCalldataNotBareTransfer(currentQuote.transaction.data);
2518
+
2035
2519
  // Handle approval if needed — skip for native ETH
2036
2520
  // Check existing allowance first to avoid unnecessary approve txs
2037
2521
  // (industry standard: LiFi SDK checkAllowance, 1inch Permit2)
@@ -2061,13 +2545,23 @@ EXAMPLES:
2061
2545
 
2062
2546
  // Empty-string approvalAddress is Relay's "no approval needed" sentinel — skip.
2063
2547
  if (currentQuote.approvalAddress && currentQuote.approvalAddress !== '' && !isNative) {
2548
+ assertUsableSpender(currentQuote.approvalAddress);
2064
2549
  // Check if sufficient allowance already exists
2065
- const inputAmount = BigInt(currentQuote.inputAmount || currentQuote.inAmount || currentQuote.transaction?.value || '0');
2550
+ const inputAmount = BigInt(currentQuote.inputAmount || currentQuote.inAmount || '0');
2551
+ const approveAmt = approvalAmountForSwap({ inputAmount, swapMode: quoteData.swapMode, slippage: quoteData.slippage });
2552
+ if (approveAmt <= 0n) {
2553
+ // Malformed quote (no/invalid input amount): a zero-scoped approval
2554
+ // would waste gas and the swap would revert on insufficient allowance.
2555
+ log(` ❌ ${quoteName} has a zero input amount — cannot scope approval, skipping.`);
2556
+ lastQuoteError = `${quoteName} has a zero input amount`;
2557
+ if (qi + 1 < endIndex) log(` Trying next quote...`);
2558
+ continue;
2559
+ }
2066
2560
  const existingAllowance = await checkErc20Allowance(
2067
2561
  chain, currentQuote.inputMint, walletAddress, currentQuote.approvalAddress
2068
2562
  );
2069
2563
 
2070
- if (existingAllowance >= inputAmount && existingAllowance > 0n) {
2564
+ if (existingAllowance >= approveAmt && existingAllowance > 0n) {
2071
2565
  log(` ✓ Sufficient allowance exists for ${quoteName}, skipping approval`);
2072
2566
  } else {
2073
2567
  log(` ⚠ Approval required → ${currentQuote.approvalAddress}`);
@@ -2082,6 +2576,8 @@ EXAMPLES:
2082
2576
  chain,
2083
2577
  approvalNonce,
2084
2578
  approvalGasPrice,
2579
+ approveAmt,
2580
+ approvalCapForQuote(quoteData),
2085
2581
  );
2086
2582
 
2087
2583
  const approvalResult = await executeTransaction({
@@ -2132,6 +2628,20 @@ EXAMPLES:
2132
2628
  }
2133
2629
  }
2134
2630
 
2631
+ // Verify the swap's simulated on-chain outcome matches intent. Its
2632
+ // own gate: runs even when --no-simulate/gasless skip the cheap
2633
+ // eth_call revert check above; degrades with a warning when no
2634
+ // simulation endpoint is set.
2635
+ if (!noVerifyOutcome) {
2636
+ const outcome = await verifySwapOutcome({ chain, from: walletAddress, quote: currentQuote, quoteData, apiKey, log });
2637
+ if (!outcome.proceed) {
2638
+ log(` ❌ ${quoteName} failed swap-outcome verification: ${outcome.reason}`);
2639
+ if (qi + 1 < endIndex) log(` Trying next quote...`);
2640
+ lastQuoteError = `${quoteName} outcome verification failed: ${outcome.reason}`;
2641
+ continue;
2642
+ }
2643
+ }
2644
+
2135
2645
  // Use the Trading API's gas estimation (quote.gas) directly.
2136
2646
  // The API already applies a 1.5x buffer over eth_estimateGas.
2137
2647
  // Skip client-side re-estimation — it adds latency and the API value is reliable.