nansen-cli 1.35.0 → 1.36.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
@@ -80,7 +80,7 @@ export function resolveTokenAddress(symbolOrAddress, chainName) {
80
80
  * @returns {Promise<*>} Parsed result value
81
81
  * @throws {Error} If chain has no configured RPC or the RPC returns an error
82
82
  */
83
- async function evmRpcCall(chain, method, params = []) {
83
+ export async function evmRpcCall(chain, method, params = []) {
84
84
  const rpcUrl = CHAIN_RPCS[chain];
85
85
  if (!rpcUrl) throw new Error(`No RPC URL configured for chain: ${chain}`);
86
86
  const res = await fetch(rpcUrl, {
@@ -99,13 +99,13 @@ async function evmRpcCall(chain, method, params = []) {
99
99
  return body.result;
100
100
  }
101
101
 
102
- function getQuotesDir() {
102
+ export function getQuotesDir() {
103
103
  const configDir = path.join(process.env.HOME || process.env.USERPROFILE || '', '.nansen');
104
104
  return path.join(configDir, 'quotes');
105
105
  }
106
106
 
107
107
  // Resolve a filename inside the quotes dir, rejecting path traversal.
108
- function safeQuotesPath(filename) {
108
+ export function safeQuotesPath(filename) {
109
109
  const base = path.resolve(getQuotesDir());
110
110
  const target = path.resolve(base, filename);
111
111
  if (path.relative(base, target).startsWith('..')) return null;
@@ -390,7 +390,7 @@ export function saveQuote(quoteResponse, chain, signerType = 'local', privyWalle
390
390
  const hash = crypto.randomBytes(4).toString('hex');
391
391
  const quoteId = `${timestamp}-${hash}`;
392
392
 
393
- const data = { quoteId, chain, timestamp, signerType, response: quoteResponse };
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
396
 
@@ -412,6 +412,11 @@ export function loadQuote(quoteId) {
412
412
  fs.unlinkSync(filePath);
413
413
  throw new Error('Quote has expired. Please request a new quote.');
414
414
  }
415
+ // Guard against running a bridge quote through the swap path. Older swap
416
+ // quotes predate the `type` field, so only reject a known-mismatched type.
417
+ if (data.type && data.type !== 'swap') {
418
+ throw new Error(`Quote "${quoteId}" is a ${data.type} quote. Use the matching command (e.g. "nansen bridge execute" for a bridge quote).`);
419
+ }
415
420
  return data;
416
421
  }
417
422
 
@@ -498,25 +503,29 @@ export function signSolanaTransaction(transactionBase64, privateKeyHex) {
498
503
  * { to, data, value?, gas?, gasPrice? }
499
504
  *
500
505
  * The nonce must be fetched from the chain RPC.
501
- * Signs as a legacy (type 0) transaction with gasPrice (matching the e2e tests).
502
506
  *
503
- * @param {object} txData - Transaction fields from quote.transaction { to, data, value, gas, gasPrice }
507
+ * Emits an EIP-1559 (type 2) transaction when the quote supplies fee-cap
508
+ * fields, and a legacy (type 0) one otherwise. Quotes from the trading API and
509
+ * from Relay both carry maxFeePerGas/maxPriorityFeePerGas, so type 2 is the
510
+ * normal path; flattening those into a single legacy gasPrice — as this used to
511
+ * do — discards the fee cap the aggregator computed and leaves the transaction
512
+ * unincludable the moment the base fee rises past it.
513
+ *
514
+ * @param {object} txData - Transaction fields from a quote { to, data, value, gas, gasPrice | maxFeePerGas + maxPriorityFeePerGas }
504
515
  * @param {string} privateKeyHex - 64-char hex (32-byte secp256k1 private key)
505
516
  * @param {string} chain - Chain name
506
517
  * @param {number} nonce - Account nonce
507
518
  * @returns {string} 0x-prefixed signed transaction hex
508
519
  */
509
520
  // ⚠️ SECURITY: EVM transaction signing - requires thorough review before production use
510
- // TODO: Always signs as legacy (type 0) transactions. Do we need EIP-1559 (type 2) support?
511
521
  export function signEvmTransaction(txData, privateKeyHex, chain, nonce) {
512
522
  const chainConfig = CHAIN_MAP[chain];
513
523
  if (!chainConfig || chainConfig.type !== 'evm') {
514
524
  throw new Error(`Unsupported EVM chain: ${chain}`);
515
525
  }
516
526
 
517
- const tx = {
527
+ const common = {
518
528
  nonce,
519
- gasPrice: toHex(txData.gasPrice || txData.maxFeePerGas || '1'),
520
529
  gasLimit: toHex(txData.gas || txData.gasLimit || '210000'),
521
530
  to: txData.to,
522
531
  value: toHex(txData.value || '0'),
@@ -524,18 +533,82 @@ export function signEvmTransaction(txData, privateKeyHex, chain, nonce) {
524
533
  chainId: chainConfig.chainId,
525
534
  };
526
535
 
527
- return signLegacyTransaction(tx, privateKeyHex);
536
+ if (txData.maxFeePerGas) {
537
+ return signEip1559Transaction({
538
+ ...common,
539
+ maxFeePerGas: toHex(txData.maxFeePerGas),
540
+ // A zero priority fee is a valid choice but not a sane default, so fall
541
+ // back to the fee cap rather than to nothing when the quote omits it.
542
+ maxPriorityFeePerGas: toHex(txData.maxPriorityFeePerGas || txData.maxFeePerGas),
543
+ }, privateKeyHex);
544
+ }
545
+
546
+ // Previously this fell back to a gasPrice of 1 wei, which signs a transaction
547
+ // that can never be mined and burns the nonce. Refuse instead: a quote with no
548
+ // fee information at all is a bug upstream, not something to sign through.
549
+ if (!txData.gasPrice) {
550
+ throw new Error(
551
+ 'Quote supplied no gas price (expected gasPrice or maxFeePerGas), so any signed transaction would be unmineable. Refusing to sign.',
552
+ );
553
+ }
554
+
555
+ return signLegacyTransaction({ ...common, gasPrice: toHex(txData.gasPrice) }, privateKeyHex);
528
556
  }
529
557
 
558
+ // How many queued-but-unmined transactions we are willing to sign past.
559
+ //
560
+ // `pending` counts mempool-queued transactions as well as mined ones, and that is
561
+ // what callers want: the bridge signs its approve and deposit steps back to back,
562
+ // so the second has to be numbered after the first while the first is still
563
+ // pending. But a transaction that *cannot* be mined — priced below what the chain
564
+ // is currently including — keeps the count elevated for as long as it sits there,
565
+ // and every later signature is numbered behind it, unexecutable until it clears.
566
+ //
567
+ // One or two in flight is normal for a multi-step run. Beyond that, something is
568
+ // wedged, and adding another transaction to the queue cannot help.
569
+ const MAX_PENDING_NONCE_GAP = 2;
570
+
530
571
  /**
531
- * Fetch the pending nonce for an EVM address.
572
+ * Fetch the next nonce for an EVM address, reconciled against the mined count.
573
+ *
574
+ * Returns a DECIMAL number, not a hex string — callers must not decode it again.
575
+ * (bridge.js did, and `parseInt(20, 16)` is 32: a wallet at nonce 20 signed at
576
+ * 32, which no node can execute. It only showed up past nonce 9, where decimal
577
+ * and hex digits diverge.)
578
+ *
532
579
  * @param {string} chain - Chain name
533
580
  * @param {string} address - 0x address
534
- * @returns {Promise<number>} Nonce
581
+ * @returns {Promise<number>} Next nonce, decimal
535
582
  */
536
583
  export async function getEvmNonce(chain, address) {
537
- const result = await evmRpcCall(chain, 'eth_getTransactionCount', [address, 'pending']);
538
- return parseInt(result, 16);
584
+ const [pendingHex, latestHex] = await Promise.all([
585
+ evmRpcCall(chain, 'eth_getTransactionCount', [address, 'pending']),
586
+ evmRpcCall(chain, 'eth_getTransactionCount', [address, 'latest']),
587
+ ]);
588
+ const pending = parseInt(pendingHex, 16);
589
+ const latest = parseInt(latestHex, 16);
590
+ if (!Number.isInteger(pending) || !Number.isInteger(latest)) {
591
+ throw new Error(
592
+ `Could not read the nonce for ${address} on ${chain} (pending: ${pendingHex}, latest: ${latestHex}).`,
593
+ );
594
+ }
595
+
596
+ const gap = pending - latest;
597
+ if (gap > MAX_PENDING_NONCE_GAP) {
598
+ // Refuse rather than pile on. Signing at `pending` here produces a
599
+ // transaction that cannot execute until everything ahead of it does, and the
600
+ // symptom the operator sees is only "no receipt" — no indication that the
601
+ // real problem is a transaction from an earlier run.
602
+ throw new Error(
603
+ `${address} has ${gap} unmined transactions queued on ${chain} (next mined nonce ${latest}, next pending ${pending}). `
604
+ + `Signing another would queue behind them and stay unexecutable until they clear. `
605
+ + `Replace the transaction at nonce ${latest} with a higher fee first: request a fresh quote and run `
606
+ + `"nansen bridge execute --quote <id> --nonce ${latest} --priority-fee <gwei>". `
607
+ + `Note that a load-balanced public RPC may deny holding a transaction it does in fact hold, so do not diagnose from one endpoint.`,
608
+ );
609
+ }
610
+
611
+ return pending;
539
612
  }
540
613
 
541
614
  /**
@@ -544,11 +617,18 @@ export async function getEvmNonce(chain, address) {
544
617
  *
545
618
  * @param {string} chain - Chain name
546
619
  * @param {string} txHash - Transaction hash (0x...)
547
- * @param {number} [timeoutMs=30000] - Max wait time
620
+ * The default window is deliberately generous: by the time this is called the
621
+ * transaction is already broadcast, so giving up early converts "still
622
+ * confirming" into a hard failure the caller has to interpret, without undoing
623
+ * anything. A tight 30s window did exactly that during a real Base deposit.
624
+ *
625
+ * @param {string} chain - Chain name
626
+ * @param {string} txHash - Transaction hash (0x...)
627
+ * @param {number} [timeoutMs=180000] - Max wait time
548
628
  * @param {number} [pollMs=2000] - Poll interval
549
629
  * @returns {Promise<object>} Transaction receipt
550
630
  */
551
- export async function waitForReceipt(chain, txHash, timeoutMs = 30000, pollMs = 2000) {
631
+ export async function waitForReceipt(chain, txHash, timeoutMs = 180000, pollMs = 2000) {
552
632
  const start = Date.now();
553
633
  while (Date.now() - start < timeoutMs) {
554
634
  try {
@@ -741,6 +821,49 @@ export function signLegacyTransaction(tx, privateKeyHex) {
741
821
  return '0x' + rlpEncode(signedFields).toString('hex');
742
822
  }
743
823
 
824
+ /**
825
+ * Sign an EIP-1559 (type 2) transaction.
826
+ *
827
+ * Envelope: 0x02 || RLP([chainId, nonce, maxPriorityFeePerGas, maxFeePerGas,
828
+ * gasLimit, to, value, data, accessList, yParity, r, s]).
829
+ *
830
+ * Type 2 exists here because a legacy transaction pays exactly `gasPrice`: once
831
+ * the base fee rises above it the transaction is not slow, it is permanently
832
+ * unincludable at that nonce. A type-2 transaction pays baseFee + priority
833
+ * capped at maxFeePerGas, so it rides fee movement instead of dying.
834
+ *
835
+ * Note yParity is the raw recovery bit (0/1), not EIP-155's chainId*2+35+bit —
836
+ * the chain id is already a first-class field in the payload.
837
+ */
838
+ export function signEip1559Transaction(tx, privateKeyHex) {
839
+ const payloadFields = [
840
+ rlpNormalize(tx.chainId),
841
+ rlpNormalize(tx.nonce),
842
+ rlpNormalize(tx.maxPriorityFeePerGas),
843
+ rlpNormalize(tx.maxFeePerGas),
844
+ rlpNormalize(tx.gasLimit),
845
+ toBuffer(tx.to),
846
+ rlpNormalize(tx.value),
847
+ toBuffer(tx.data || '0x'),
848
+ [], // accessList — always empty; we never build access-listed transactions
849
+ ];
850
+
851
+ const msgHash = keccak256(Buffer.concat([Buffer.from([0x02]), rlpEncode(payloadFields)]));
852
+ const { r, s, v: recoveryBit } = signSecp256k1(msgHash, Buffer.from(privateKeyHex, 'hex'));
853
+
854
+ const signed = Buffer.concat([
855
+ Buffer.from([0x02]),
856
+ rlpEncode([
857
+ ...payloadFields,
858
+ rlpNormalize(recoveryBit),
859
+ stripLeadingZeros(r),
860
+ stripLeadingZeros(s),
861
+ ]),
862
+ ]);
863
+
864
+ return '0x' + signed.toString('hex');
865
+ }
866
+
744
867
  export function toBuffer(v) {
745
868
  if (Buffer.isBuffer(v)) return v;
746
869
  if (typeof v === 'string') {
@@ -1074,6 +1197,19 @@ export function buildTradingCommands(deps = {}) {
1074
1197
  'INVALID_AGGREGATOR'
1075
1198
  );
1076
1199
  }
1200
+ // Slippage is a decimal fraction (0.03 = 3%). Reject non-numeric or
1201
+ // out-of-range values so a percent-vs-decimal mix-up (e.g. "3" meaning 3%)
1202
+ // can't become a 300% slippage tolerance.
1203
+ for (const [optName, optVal] of [['slippage', slippage], ['max-auto-slippage', maxAutoSlippage]]) {
1204
+ if (optVal == null) continue;
1205
+ const n = Number(optVal);
1206
+ if (!Number.isFinite(n) || n < 0 || n > 1) {
1207
+ throw new CommandError(
1208
+ `Invalid --${optName} "${optVal}". Use a decimal between 0 and 1 (e.g. 0.03 for 3%).`,
1209
+ 'INVALID_SLIPPAGE'
1210
+ );
1211
+ }
1212
+ }
1077
1213
 
1078
1214
  if (!chain || !from || !to || !amount) {
1079
1215
  throw new CommandError(`
@@ -1399,7 +1535,16 @@ EXAMPLES:
1399
1535
  }
1400
1536
 
1401
1537
  // --quote-index pins a specific quote (no fallback)
1402
- const pinIndex = options['quote-index'] != null ? parseInt(options['quote-index'], 10) : null;
1538
+ let pinIndex = null;
1539
+ if (options['quote-index'] != null) {
1540
+ pinIndex = parseInt(options['quote-index'], 10);
1541
+ if (!Number.isInteger(pinIndex) || pinIndex < 0 || pinIndex >= allQuotes.length) {
1542
+ throw new CommandError(
1543
+ `❌ Invalid --quote-index "${options['quote-index']}". Must be an integer between 0 and ${allQuotes.length - 1}.`,
1544
+ 'INVALID_QUOTE_INDEX',
1545
+ );
1546
+ }
1547
+ }
1403
1548
  const startIndex = pinIndex ?? 0;
1404
1549
  const endIndex = pinIndex != null ? startIndex + 1 : allQuotes.length;
1405
1550
 
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Nansen CLI — shared wallet resolution for the money paths (perp, bridge).
3
+ *
4
+ * Every command that signs needs the same three things: the wallet's EVM
5
+ * address, a clear error when the wallet is encrypted but no password reached
6
+ * us, and the signing material (a local private key, or a Privy handle).
7
+ *
8
+ * These lived twice — once in perp.js, once in bridge.js — and the copies had
9
+ * already drifted: perp.js grew an explicit PASSWORD_REQUIRED error while
10
+ * bridge.js kept calling exportWallet(name, null), which reports "Incorrect
11
+ * password" for a password that was never entered. One copy means the next fix
12
+ * can't land on one path and miss the other.
13
+ */
14
+
15
+ import { CommandError } from './api.js';
16
+ import { retrievePassword } from './keychain.js';
17
+ import { exportWallet, getWalletConfig, showWallet } from './wallet.js';
18
+
19
+ const EVM_ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
20
+
21
+ // Resolve --wallet (or the configured default) to a wallet with a usable EVM
22
+ // address. `context` names the feature in the error so the message stays
23
+ // actionable ("Hyperliquid perp trading requires an EVM wallet").
24
+ //
25
+ // Returns the resolved name alongside the address so callers can hand the same
26
+ // resolution to resolveSigningCredentials instead of looking the wallet up a
27
+ // second time — a second lookup can read a different wallet if the default
28
+ // changed in between, and it re-reads the wallet file for no reason.
29
+ export function resolveEvmWallet(walletName, context = 'This command') {
30
+ const config = getWalletConfig();
31
+ const name = walletName || config.defaultWallet;
32
+ const wallet = name ? showWallet(name) : undefined;
33
+ if (!wallet) {
34
+ throw new CommandError('No wallet found. Create one with: nansen wallet create', 'NO_WALLET');
35
+ }
36
+ if (!wallet.evm || !EVM_ADDRESS_RE.test(wallet.evm)) {
37
+ throw new CommandError(
38
+ `Wallet "${wallet.name || name}" has no valid EVM address. ${context} requires an EVM wallet.`,
39
+ 'INVALID_WALLET',
40
+ );
41
+ }
42
+ return {
43
+ name: wallet.name || name,
44
+ address: wallet.evm,
45
+ provider: wallet.provider || 'local',
46
+ privyWalletIds: wallet.privyWalletIds || null,
47
+ };
48
+ }
49
+
50
+ // The local private key for an already-resolved wallet.
51
+ export function resolvePrivateKey(wallet) {
52
+ const config = getWalletConfig();
53
+ let password = null;
54
+ if (config.passwordHash) {
55
+ const { password: pw, source } = retrievePassword();
56
+ if (source === 'file') {
57
+ process.stderr.write('⚠️ Password loaded from ~/.nansen/wallets/.credentials (insecure).\n');
58
+ }
59
+ password = pw;
60
+ // Distinguish "no password reached us" from "wrong password": without this,
61
+ // exportWallet(name, null) fails with the misleading "Incorrect password"
62
+ // even though nothing was entered. Mirror trade/limit-order's
63
+ // PASSWORD_REQUIRED.
64
+ if (!password) {
65
+ throw new CommandError('Wallet is encrypted and no password was found.', 'PASSWORD_REQUIRED', {
66
+ error: 'PASSWORD_REQUIRED',
67
+ message: 'Wallet is encrypted and no password was found.',
68
+ resolution: [
69
+ 'Set NANSEN_WALLET_PASSWORD environment variable',
70
+ 'Or run: nansen wallet create (password is saved to OS keychain automatically)',
71
+ ],
72
+ });
73
+ }
74
+ }
75
+ const exported = exportWallet(wallet.name, password);
76
+ return exported.evm.privateKey;
77
+ }
78
+
79
+ // Signing material for an already-resolved wallet. Privy wallets have no
80
+ // exportable key — the caller signs through the Privy client instead, using the
81
+ // privyWalletIds already on `wallet`.
82
+ export function resolveSigningCredentials(wallet) {
83
+ if (wallet.provider === 'privy') {
84
+ return { provider: 'privy', privateKey: null };
85
+ }
86
+ return { provider: 'local', privateKey: resolvePrivateKey(wallet) };
87
+ }