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/CHANGELOG.md +42 -0
- package/README.md +2 -1
- package/package.json +1 -1
- package/skills/nansen-trading/SKILL.md +2 -0
- package/skills/nansen-wallet-keychain-migration/SKILL.md +14 -12
- package/src/api.js +11 -0
- package/src/cli.js +122 -58
- package/src/cost-cache.js +19 -1
- package/src/doctor.js +480 -0
- package/src/keychain.js +46 -0
- package/src/perp.js +134 -5
- package/src/schema.json +93 -0
- package/src/telemetry.js +59 -2
- package/src/trade-validation.js +441 -0
- package/src/trading.js +387 -18
- package/src/update-check.js +45 -24
- package/src/walletconnect-trading.js +11 -7
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
|
-
//
|
|
746
|
-
//
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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
|
|
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 >=
|
|
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
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
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 >=
|
|
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 ||
|
|
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 >=
|
|
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({
|
package/src/update-check.js
CHANGED
|
@@ -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
|
-
|
|
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
|
});
|