nansen-cli 1.26.0 → 1.27.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,12 +13,14 @@ 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 } from './trade-validation.js';
16
+ import { validateQuoteInput, validateBalance, resolvePercentAmount, validateGasBalance } from './trade-validation.js';
17
17
  import { CHAIN_RPCS } from './rpc-urls.js';
18
+ import { packageVersion, CommandError } from './api.js';
18
19
 
19
20
  // ============= Constants =============
20
21
 
21
22
  const TRADING_API_URL = process.env.NANSEN_TRADING_API_URL || 'https://trading-api.nansen.ai';
23
+ const CLIENT_USER_AGENT = `nansen-cli/${packageVersion}`;
22
24
 
23
25
  const CHAIN_MAP = {
24
26
  solana: { index: '501', type: 'solana', chainId: 501, name: 'Solana', explorer: 'https://solscan.io/tx/', lifiChainId: '1151111081099710' },
@@ -115,7 +117,7 @@ export async function getQuote(params) {
115
117
  }
116
118
  }
117
119
 
118
- const headers = { 'Accept': 'application/json' };
120
+ const headers = { 'Accept': 'application/json', 'User-Agent': CLIENT_USER_AGENT };
119
121
 
120
122
  const res = await fetch(url.toString(), { headers });
121
123
 
@@ -153,6 +155,7 @@ export async function executeTransaction(params, { retries = 2, retryDelayMs = 1
153
155
  const headers = {
154
156
  'Content-Type': 'application/json',
155
157
  'Accept': 'application/json',
158
+ 'User-Agent': CLIENT_USER_AGENT,
156
159
  };
157
160
  let lastError;
158
161
  for (let attempt = 0; attempt <= retries; attempt++) {
@@ -216,7 +219,7 @@ export async function getBridgeStatus(txHash, fromChain, toChain) {
216
219
  url.searchParams.set('fromChain', fromConfig.lifiChainId || fromConfig.index);
217
220
  url.searchParams.set('toChain', toConfig.lifiChainId || toConfig.index);
218
221
 
219
- const res = await fetch(url.toString(), { headers: { 'Accept': 'application/json' } });
222
+ const res = await fetch(url.toString(), { headers: { 'Accept': 'application/json', 'User-Agent': CLIENT_USER_AGENT } });
220
223
  const text = await res.text();
221
224
  let body;
222
225
  try {
@@ -940,7 +943,7 @@ export function formatQuote(quote, index) {
940
943
  * Build trading command handlers for CLI integration.
941
944
  */
942
945
  export function buildTradingCommands(deps = {}) {
943
- const { log = console.log, exit = process.exit } = deps;
946
+ const { log = console.log } = deps;
944
947
 
945
948
  return {
946
949
  'quote': async (args, apiInstance, flags, options) => {
@@ -961,7 +964,7 @@ export function buildTradingCommands(deps = {}) {
961
964
  const amountUnit = options['amount-unit'];
962
965
 
963
966
  if (!chain || !from || !to || !amount) {
964
- log(`
967
+ throw new CommandError(`
965
968
  Usage: nansen trade quote --chain <chain> --from <token> --to <token> --amount <baseUnits>
966
969
 
967
970
  PREREQUISITE:
@@ -991,23 +994,17 @@ EXAMPLES:
991
994
  nansen trade quote --chain base --from ETH --to USDC --amount 1000000000000000000
992
995
  nansen trade quote --chain base --to-chain solana --from USDC --to USDC --amount 1000000
993
996
  nansen trade quote --chain solana --to-chain base --from SOL --to ETH --amount 1000000000
994
- `);
995
- exit(1);
996
- return;
997
+ `, 'MISSING_ARGS');
997
998
  }
998
999
 
999
1000
  // Validate --amount-unit if provided
1000
1001
  if (amountUnit && amountUnit !== 'token' && amountUnit !== 'base' && amountUnit !== 'usd' && amountUnit !== 'percent') {
1001
- log(`Error: Unknown --amount-unit "${amountUnit}". Supported values: token, base, usd, percent`);
1002
- exit(1);
1003
- return;
1002
+ throw new CommandError(`Error: Unknown --amount-unit "${amountUnit}". Supported values: token, base, usd, percent`, 'INVALID_INPUT');
1004
1003
  }
1005
1004
 
1006
1005
  // --amount-unit percent is only valid for exactIn (sell-side)
1007
1006
  if (amountUnit === 'percent' && swapMode === 'exactOut') {
1008
- log('Error: --amount-unit percent is not supported with --swap-mode exactOut. Percentage is relative to your sell-token balance.');
1009
- exit(1);
1010
- return;
1007
+ throw new CommandError('Error: --amount-unit percent is not supported with --swap-mode exactOut. Percentage is relative to your sell-token balance.', 'INVALID_INPUT');
1011
1008
  }
1012
1009
 
1013
1010
  // Static input validation — catches common agent errors (wrong addresses,
@@ -1015,9 +1012,7 @@ EXAMPLES:
1015
1012
  try {
1016
1013
  validateQuoteInput({ chain, toChain: toChainRaw || null, from, to, amount });
1017
1014
  } catch (validationErr) {
1018
- log(`Error: ${validationErr.message}`);
1019
- exit(1);
1020
- return;
1015
+ throw new CommandError(`Error: ${validationErr.message}`, 'INVALID_INPUT');
1021
1016
  }
1022
1017
 
1023
1018
  // When --amount-unit token is used, resolve decimals and convert to base units.
@@ -1036,9 +1031,7 @@ EXAMPLES:
1036
1031
  usdTokenAmount = tokenAmount.toFixed(resolvedDecimals);
1037
1032
  resolvedAmount = convertToBaseUnits(usdTokenAmount, resolvedDecimals);
1038
1033
  } catch (err) {
1039
- log(`Error converting USD amount: ${err.message}`);
1040
- exit(1);
1041
- return;
1034
+ throw new CommandError(`Error converting USD amount: ${err.message}`, 'INVALID_INPUT');
1042
1035
  }
1043
1036
  } else if (amountUnit === 'token') {
1044
1037
  try {
@@ -1046,18 +1039,14 @@ EXAMPLES:
1046
1039
  resolvedDecimals = await resolveTokenDecimals(tokenForDecimals, chain);
1047
1040
  resolvedAmount = convertToBaseUnits(amount, resolvedDecimals);
1048
1041
  } catch (err) {
1049
- log(`Error resolving token decimals: ${err.message}`);
1050
- exit(1);
1051
- return;
1042
+ throw new CommandError(`Error resolving token decimals: ${err.message}`, 'INVALID_INPUT');
1052
1043
  }
1053
1044
  } else if (amountUnit === 'percent') {
1054
1045
  // Resolved after wallet address is available — see percent resolution block below.
1055
1046
  } else {
1056
1047
  const amountError = validateBaseUnitAmount(amount);
1057
1048
  if (amountError) {
1058
- log(`Error: ${amountError}`);
1059
- exit(1);
1060
- return;
1049
+ throw new CommandError(`Error: ${amountError}`, 'INVALID_INPUT');
1061
1050
  }
1062
1051
  }
1063
1052
 
@@ -1073,9 +1062,7 @@ EXAMPLES:
1073
1062
  if (isWalletConnect) {
1074
1063
  walletAddress = await getWalletConnectAddress(chainType);
1075
1064
  if (!walletAddress) {
1076
- log('No WalletConnect session active. Run: walletconnect connect');
1077
- exit(1);
1078
- return;
1065
+ throw new CommandError('No WalletConnect session active. Run: walletconnect connect', 'NO_WALLET');
1079
1066
  }
1080
1067
  } else if (walletName) {
1081
1068
  const wallet = showWallet(walletName);
@@ -1101,9 +1088,7 @@ EXAMPLES:
1101
1088
  }
1102
1089
 
1103
1090
  if (!walletAddress) {
1104
- log('No wallet found. A wallet address is required for quotes because the trading API builds a transaction specific to the sender.\nCreate one with: nansen wallet create');
1105
- exit(1);
1106
- return;
1091
+ throw new CommandError('No wallet found. A wallet address is required for quotes because the trading API builds a transaction specific to the sender.\nCreate one with: nansen wallet create', 'NO_WALLET');
1107
1092
  }
1108
1093
 
1109
1094
  // --amount-unit percent: fetch balance, calculate percentage, convert to base units.
@@ -1120,9 +1105,7 @@ EXAMPLES:
1120
1105
  });
1121
1106
  resolvedAmount = convertToBaseUnits(tokenAmount, resolvedDecimals);
1122
1107
  } catch (err) {
1123
- log(`Error: ${err.message}`);
1124
- exit(1);
1125
- return;
1108
+ throw new CommandError(`Error: ${err.message}`, 'INVALID_INPUT');
1126
1109
  }
1127
1110
  }
1128
1111
 
@@ -1148,9 +1131,7 @@ EXAMPLES:
1148
1131
  resolvedAmount = convertToBaseUnits(balanceAdjusted, resolvedDecimals);
1149
1132
  }
1150
1133
  } catch (balanceErr) {
1151
- log(`Error: ${balanceErr.message}`);
1152
- exit(1);
1153
- return;
1134
+ throw new CommandError(`Error: ${balanceErr.message}`, 'INSUFFICIENT_BALANCE');
1154
1135
  }
1155
1136
  }
1156
1137
 
@@ -1197,17 +1178,23 @@ EXAMPLES:
1197
1178
  const response = await getQuote(params);
1198
1179
 
1199
1180
  if (!response.success || !response.quotes?.length) {
1200
- log('No quotes available');
1181
+ let msg = 'No quotes available';
1201
1182
  if (response.warnings?.length) {
1202
- response.warnings.forEach(w => log(` Warning: ${w}`));
1183
+ msg += '\n' + response.warnings.map(w => ` Warning: ${w}`).join('\n');
1203
1184
  }
1204
- exit(1);
1205
- return;
1185
+ throw new CommandError(msg, 'NO_QUOTES');
1206
1186
  }
1207
1187
 
1208
1188
  log('');
1209
1189
  response.quotes.forEach((q, i) => log(formatQuote(q, i)));
1210
1190
 
1191
+ // Gas balance validation — check that the wallet has enough native token for gas.
1192
+ try {
1193
+ await validateGasBalance({ chain, walletAddress });
1194
+ } catch (gasErr) {
1195
+ throw new CommandError(`Error: ${gasErr.message}`, 'INSUFFICIENT_GAS');
1196
+ }
1197
+
1211
1198
  const signerType = isWalletConnect ? 'walletconnect' : walletProvider;
1212
1199
  const quoteId = saveQuote(response, chain, signerType, privyWalletIds, isCrossChain ? toChainRaw : null);
1213
1200
  log(`\n Quote ID: ${quoteId}`);
@@ -1225,13 +1212,14 @@ EXAMPLES:
1225
1212
  return undefined; // Output already printed above
1226
1213
 
1227
1214
  } catch (err) {
1215
+ if (err instanceof CommandError) throw err;
1228
1216
  let message = err.message;
1229
1217
  if (err.code === 'INVALID_AMOUNT' || /amount/i.test(err.message)) {
1230
1218
  message += '. Amounts must be in base units (e.g., 1000000000 lamports for 1 SOL, 1000000000000000000 wei for 1 ETH)';
1231
1219
  }
1232
- log(`Error: ${message}`);
1233
- if (err.details) log(` Details: ${JSON.stringify(err.details)}`);
1234
- exit(1);
1220
+ let msg = `Error: ${message}`;
1221
+ if (err.details) msg += `\n Details: ${JSON.stringify(err.details)}`;
1222
+ throw new CommandError(msg, err.code || 'QUOTE_ERROR');
1235
1223
  }
1236
1224
  },
1237
1225
 
@@ -1241,8 +1229,7 @@ EXAMPLES:
1241
1229
  const noSimulate = flags['no-simulate'];
1242
1230
 
1243
1231
  if (!quoteId) {
1244
- log(`
1245
- Usage: nansen trade execute --quote <quoteId> [options]
1232
+ throw new CommandError(`Usage: nansen trade execute --quote <quoteId> [options]
1246
1233
 
1247
1234
  OPTIONS:
1248
1235
  --quote <id> Quote ID from 'nansen quote'
@@ -1250,10 +1237,7 @@ OPTIONS:
1250
1237
  --no-simulate Skip pre-broadcast simulation
1251
1238
 
1252
1239
  EXAMPLES:
1253
- nansen trade execute --quote 1708900000000-abc123
1254
- `);
1255
- exit(1);
1256
- return;
1240
+ nansen trade execute --quote 1708900000000-abc123`, 'MISSING_ARGS');
1257
1241
  }
1258
1242
 
1259
1243
  try {
@@ -1264,9 +1248,7 @@ EXAMPLES:
1264
1248
 
1265
1249
  const allQuotes = quoteData.response.quotes || [];
1266
1250
  if (!allQuotes.length) {
1267
- log('❌ No quote data found');
1268
- exit(1);
1269
- return;
1251
+ throw new CommandError('❌ No quote data found', 'NO_QUOTES');
1270
1252
  }
1271
1253
 
1272
1254
  // --quote-index pins a specific quote (no fallback)
@@ -1277,10 +1259,7 @@ EXAMPLES:
1277
1259
  // Check if any quote in range has transaction data before prompting for password
1278
1260
  const hasAnyTransaction = allQuotes.slice(startIndex, endIndex).some(q => q?.transaction);
1279
1261
  if (!hasAnyTransaction) {
1280
- log('❌ No quotes contain transaction data.');
1281
- log(' Ensure userWalletAddress was provided when fetching the quote.');
1282
- exit(1);
1283
- return;
1262
+ throw new CommandError('❌ No quotes contain transaction data.\n Ensure userWalletAddress was provided when fetching the quote.', 'NO_TRANSACTION');
1284
1263
  }
1285
1264
 
1286
1265
  // Determine if this is a WalletConnect or Privy-signed quote
@@ -1301,16 +1280,14 @@ EXAMPLES:
1301
1280
  if (walletConfig.passwordHash) {
1302
1281
  password = resolveTradePassword();
1303
1282
  if (!password) {
1304
- log(JSON.stringify({
1283
+ throw new CommandError('Wallet is encrypted and no password was found.', 'PASSWORD_REQUIRED', {
1305
1284
  error: 'PASSWORD_REQUIRED',
1306
1285
  message: 'Wallet is encrypted and no password was found.',
1307
1286
  resolution: [
1308
1287
  'Set NANSEN_WALLET_PASSWORD environment variable',
1309
1288
  'Or run: nansen wallet create (password is saved to OS keychain automatically)',
1310
1289
  ],
1311
- }));
1312
- exit(1);
1313
- return;
1290
+ });
1314
1291
  }
1315
1292
  }
1316
1293
 
@@ -1320,9 +1297,7 @@ EXAMPLES:
1320
1297
  effectiveWalletName = list.defaultWallet;
1321
1298
  }
1322
1299
  if (!effectiveWalletName) {
1323
- log('No wallet found. Create one with: nansen wallet create');
1324
- exit(1);
1325
- return;
1300
+ throw new CommandError('No wallet found. Create one with: nansen wallet create', 'NO_WALLET');
1326
1301
  }
1327
1302
 
1328
1303
  exported = exportWallet(effectiveWalletName, password);
@@ -1330,9 +1305,7 @@ EXAMPLES:
1330
1305
  // Verify WalletConnect session is still active and address matches quote
1331
1306
  const wcAddress = await getWalletConnectAddress(chainType);
1332
1307
  if (!wcAddress) {
1333
- log('No WalletConnect session active. Run: walletconnect connect');
1334
- exit(1);
1335
- return;
1308
+ throw new CommandError('No WalletConnect session active. Run: walletconnect connect', 'NO_WALLET');
1336
1309
  }
1337
1310
  // Check address matches the one used during quoting
1338
1311
  const quoteWallet = quoteData.response?.quotes?.[0]?.transaction?.from
@@ -1340,9 +1313,7 @@ EXAMPLES:
1340
1313
  if (quoteWallet && (chainType === 'solana'
1341
1314
  ? wcAddress.trim() !== quoteWallet.trim()
1342
1315
  : wcAddress.toLowerCase().trim() !== quoteWallet.toLowerCase().trim())) {
1343
- log(`Connected wallet (${wcAddress}) doesn't match quote. Get a new quote with --wallet walletconnect`);
1344
- exit(1);
1345
- return;
1316
+ throw new CommandError(`Connected wallet (${wcAddress}) doesn't match quote. Get a new quote with --wallet walletconnect`, 'WALLET_MISMATCH');
1346
1317
  }
1347
1318
  }
1348
1319
 
@@ -1705,8 +1676,7 @@ EXAMPLES:
1705
1676
  lastQuoteError = `${quoteName} reverted on-chain`;
1706
1677
  continue;
1707
1678
  }
1708
- exit(1);
1709
- return;
1679
+ throw new CommandError(`\n ⚠ Transaction was broadcast but REVERTED on-chain!\n Tx Hash: ${wcResult.txHash}\n Explorer: ${chainConfig.explorer}${wcResult.txHash}\n Error: ${receiptErr.message}`, 'TX_REVERTED');
1710
1680
  }
1711
1681
 
1712
1682
  log(`\n ✓ Transaction successful!`);
@@ -1896,10 +1866,7 @@ EXAMPLES:
1896
1866
  lastQuoteError = `${quoteName} reverted on-chain`;
1897
1867
  continue;
1898
1868
  }
1899
- log(`\n The trading API reported success, but the contract execution failed.`);
1900
- log(` This can happen due to: stale quotes, insufficient gas, or liquidity changes.`);
1901
- exit(1);
1902
- return;
1869
+ throw new CommandError(`\n ⚠ Transaction was broadcast but REVERTED on-chain!\n Tx Hash: ${result.txHash}\n Explorer: ${explorerUrl}\n Error: ${receiptErr.message}\n\n The trading API reported success, but the contract execution failed.\n This can happen due to: stale quotes, insufficient gas, or liquidity changes.`, 'TX_REVERTED');
1903
1870
  }
1904
1871
  }
1905
1872
 
@@ -1954,15 +1921,13 @@ EXAMPLES:
1954
1921
  }
1955
1922
 
1956
1923
  // All quotes exhausted
1957
- log(`\n❌ All quotes failed. Last error: ${lastQuoteError || 'unknown'}`);
1958
- log('');
1959
- exit(1);
1960
- return undefined;
1924
+ throw new CommandError(`\n❌ All quotes failed. Last error: ${lastQuoteError || 'unknown'}\n`, 'ALL_QUOTES_FAILED');
1961
1925
 
1962
1926
  } catch (err) {
1963
- log(`Error: ${err.message}`);
1964
- if (err.details) log(` Details: ${JSON.stringify(err.details)}`);
1965
- exit(1);
1927
+ if (err instanceof CommandError) throw err;
1928
+ let msg = `Error: ${err.message}`;
1929
+ if (err.details) msg += `\n Details: ${JSON.stringify(err.details)}`;
1930
+ throw new CommandError(msg, err.code || 'EXECUTE_ERROR');
1966
1931
  }
1967
1932
  },
1968
1933
 
@@ -1972,8 +1937,7 @@ EXAMPLES:
1972
1937
  const toChain = options['to-chain'] || args[2];
1973
1938
 
1974
1939
  if (!txHash || !fromChain || !toChain) {
1975
- log(`
1976
- Usage: nansen trade bridge-status --tx-hash <hash> --from-chain <chain> --to-chain <chain>
1940
+ throw new CommandError(`Usage: nansen trade bridge-status --tx-hash <hash> --from-chain <chain> --to-chain <chain>
1977
1941
 
1978
1942
  Check the status of a cross-chain bridge transaction.
1979
1943
 
@@ -1983,10 +1947,7 @@ OPTIONS:
1983
1947
  --to-chain <chain> Destination chain (solana or base)
1984
1948
 
1985
1949
  EXAMPLES:
1986
- nansen trade bridge-status --tx-hash 0xabc... --from-chain base --to-chain solana
1987
- `);
1988
- exit(1);
1989
- return;
1950
+ nansen trade bridge-status --tx-hash 0xabc... --from-chain base --to-chain solana`, 'MISSING_ARGS');
1990
1951
  }
1991
1952
 
1992
1953
  try {
@@ -2010,9 +1971,10 @@ EXAMPLES:
2010
1971
  if (status.lifiExplorerLink) log(` Li.Fi: ${status.lifiExplorerLink}`);
2011
1972
  log('');
2012
1973
  } catch (err) {
2013
- log(`Error: ${err.message}`);
2014
- if (err.details) log(` Details: ${JSON.stringify(err.details)}`);
2015
- exit(1);
1974
+ if (err instanceof CommandError) throw err;
1975
+ let msg = `Error: ${err.message}`;
1976
+ if (err.details) msg += `\n Details: ${JSON.stringify(err.details)}`;
1977
+ throw new CommandError(msg, err.code || 'BRIDGE_STATUS_ERROR');
2016
1978
  }
2017
1979
  },
2018
1980
  };
package/src/transfer.js CHANGED
@@ -21,6 +21,16 @@ const ATA_PROGRAM = 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL';
21
21
  // Alias: buildEvmTransaction uses 'evm' as a generic fallback
22
22
  const CHAIN_IDS = { ...EVM_CHAIN_IDS, evm: 1 };
23
23
 
24
+ // ============= Address Derivation =============
25
+
26
+ function deriveEvmAddress(privateKeyHex) {
27
+ const privBuf = Buffer.from(privateKeyHex, 'hex');
28
+ const ecdh = crypto.createECDH('secp256k1');
29
+ ecdh.setPrivateKey(privBuf);
30
+ const pubKey = ecdh.getPublicKey(null, 'uncompressed');
31
+ return '0x' + keccak256(pubKey.subarray(1)).subarray(12).toString('hex');
32
+ }
33
+
24
34
  // ============= Address Validation =============
25
35
 
26
36
  function validateEvmAddress(address) {
@@ -106,12 +116,9 @@ async function buildEvmTransaction({ to, amount, token, privateKey, chain, max =
106
116
  const rpcUrl = CHAIN_RPCS[chain] || CHAIN_RPCS.evm;
107
117
  const chainId = CHAIN_IDS[chain] || 1;
108
118
 
109
- // Derive address
119
+ // Derive address and buffer for signing
110
120
  const privBuf = Buffer.from(privateKey, 'hex');
111
- const ecdh = crypto.createECDH('secp256k1');
112
- ecdh.setPrivateKey(privBuf);
113
- const pubKey = ecdh.getPublicKey(null, 'uncompressed');
114
- const from = '0x' + keccak256(pubKey.subarray(1)).subarray(12).toString('hex');
121
+ const from = deriveEvmAddress(privateKey);
115
122
 
116
123
  // Nonce
117
124
  const nonceHex = await rpcCall(rpcUrl, 'eth_getTransactionCount', [from, 'latest']);
@@ -735,11 +742,7 @@ export async function sendTokens({ to, amount, chain, token = null, wallet = nul
735
742
 
736
743
  if (max && token) {
737
744
  // Max ERC-20: full token balance
738
- const privBuf = Buffer.from(walletData.evm.privateKey, 'hex');
739
- const ecdh = crypto.createECDH('secp256k1');
740
- ecdh.setPrivateKey(privBuf);
741
- const pubKey = ecdh.getPublicKey(null, 'uncompressed');
742
- const from = '0x' + keccak256(pubKey.subarray(1)).subarray(12).toString('hex');
745
+ const from = deriveEvmAddress(walletData.evm.privateKey);
743
746
  const balResult = await rpcCall(rpcUrl, 'eth_call', [{
744
747
  to: token, data: '0x70a08231' + from.slice(2).padStart(64, '0'),
745
748
  }, 'latest']);