nansen-cli 1.26.0 → 1.26.1

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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.26.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#392](https://github.com/nansen-ai/nansen-cli/pull/392) [`025993d`](https://github.com/nansen-ai/nansen-cli/commit/025993df798ddb406340511e0dada1f9a962be56) Thanks [@TimNooren](https://github.com/TimNooren)! - Add gas balance validation: rejects trades when the wallet lacks sufficient native token for gas fees.
8
+
3
9
  ## 1.26.0
4
10
 
5
11
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nansen-cli",
3
- "version": "1.26.0",
3
+ "version": "1.26.1",
4
4
  "description": "Command-line interface for Nansen API - designed for AI agents",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/api.js CHANGED
@@ -60,6 +60,29 @@ export const ErrorCode = {
60
60
  UNKNOWN: 'UNKNOWN', // Unclassified error
61
61
  };
62
62
 
63
+ /**
64
+ * Error thrown by command handlers for user-facing failures (validation errors,
65
+ * missing args, etc.).
66
+ *
67
+ * Handlers must throw rather than calling log() + exit() directly, because
68
+ * direct exits bypass runCLI's catch block and skip telemetry tracking.
69
+ *
70
+ * runCLI outputs CommandError.message as plain text (not JSON-formatted like
71
+ * NansenError), then fires trackCommandFailed before exiting.
72
+ *
73
+ * When `data` is provided, runCLI outputs JSON.stringify(data) instead of the
74
+ * plain message — this preserves structured JSON output for errors that agents
75
+ * parse (e.g. PASSWORD_REQUIRED, API_KEY_REQUIRED).
76
+ */
77
+ export class CommandError extends Error {
78
+ constructor(message, code = 'COMMAND_ERROR', data = null) {
79
+ super(message);
80
+ this.name = 'CommandError';
81
+ this.code = code;
82
+ this.data = data;
83
+ }
84
+ }
85
+
63
86
  /**
64
87
  * Custom error class with structured error codes
65
88
  */
package/src/cli.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * Extracted from index.js for coverage
4
4
  */
5
5
 
6
- import { NansenAPI, NansenError, ErrorCode, saveConfig, deleteConfig, getConfigFile, clearCache, getCacheDir, validateAddress, normalizeAddress, sleep } from './api.js';
6
+ import { NansenAPI, NansenError, CommandError, ErrorCode, saveConfig, deleteConfig, getConfigFile, clearCache, getCacheDir, validateAddress, normalizeAddress, sleep } from './api.js';
7
7
  import { buildWalletCommands } from './wallet.js';
8
8
  import { buildTradingCommands } from './trading.js';
9
9
  import { formatAlertsTable, buildAlertsCommands } from './commands/alerts.js';
@@ -793,7 +793,6 @@ export function buildCommands(deps = {}) {
793
793
  saveConfigFn = saveConfig,
794
794
  deleteConfigFn = deleteConfig,
795
795
  getConfigFileFn = getConfigFile,
796
- exit = process.exit,
797
796
  isTTY = process.stdin.isTTY
798
797
  } = deps;
799
798
 
@@ -895,12 +894,10 @@ export function buildCommands(deps = {}) {
895
894
 
896
895
  if (!apiKey && flags.human) {
897
896
  if (!isTTY) {
898
- log(JSON.stringify({
897
+ throw new CommandError('--human requires an interactive terminal. Use --api-key or NANSEN_API_KEY env var instead.', 'NOT_A_TTY', {
899
898
  error: 'NOT_A_TTY',
900
899
  message: '--human requires an interactive terminal. Use --api-key or NANSEN_API_KEY env var instead.',
901
- }));
902
- exit(1);
903
- return;
900
+ });
904
901
  }
905
902
  log('Nansen CLI Login\n');
906
903
  log('Get your API key at: https://app.nansen.ai/auth/agent-setup\n');
@@ -908,7 +905,7 @@ export function buildCommands(deps = {}) {
908
905
  }
909
906
 
910
907
  if (!apiKey || apiKey.trim().length === 0) {
911
- log(JSON.stringify({
908
+ throw new CommandError('No API key provided.', 'API_KEY_REQUIRED', {
912
909
  error: 'API_KEY_REQUIRED',
913
910
  message: 'No API key provided.',
914
911
  resolution: [
@@ -916,9 +913,7 @@ export function buildCommands(deps = {}) {
916
913
  'Or set NANSEN_API_KEY environment variable',
917
914
  'Get your API key at: https://app.nansen.ai/auth/agent-setup',
918
915
  ],
919
- }));
920
- exit(1);
921
- return;
916
+ });
922
917
  }
923
918
 
924
919
  // Verify API key before saving
@@ -933,20 +928,17 @@ export function buildCommands(deps = {}) {
933
928
  accountInfo = await testApi.getAccount();
934
929
  } catch (error) {
935
930
  if (error.code === ErrorCode.UNAUTHORIZED) {
936
- log(JSON.stringify({
931
+ throw new CommandError('The API key is not valid.', 'INVALID_API_KEY', {
937
932
  error: 'INVALID_API_KEY',
938
933
  message: 'The API key is not valid.',
939
- resolution: ['Check your key at https://app.nansen.ai/auth/agent-setup']
940
- }));
941
- } else {
942
- log(JSON.stringify({
943
- error: 'VERIFICATION_FAILED',
944
- message: `Could not verify API key: ${error.message}`,
945
- resolution: ['Check your internet connection', 'Try again']
946
- }));
934
+ resolution: ['Check your key at https://app.nansen.ai/auth/agent-setup'],
935
+ });
947
936
  }
948
- exit(1);
949
- return;
937
+ throw new CommandError(`Could not verify API key: ${error.message}`, 'VERIFICATION_FAILED', {
938
+ error: 'VERIFICATION_FAILED',
939
+ message: `Could not verify API key: ${error.message}`,
940
+ resolution: ['Check your internet connection', 'Try again'],
941
+ });
950
942
  }
951
943
 
952
944
  // Key is valid - now save
@@ -1732,7 +1724,7 @@ export async function runCLI(rawArgs, deps = {}) {
1732
1724
  };
1733
1725
  const formatted = formatOutput(errorData, { pretty, table });
1734
1726
  output(formatted.text);
1735
- trackCommandFailed({ command: fullCommand, duration_ms: Date.now() - startTime, error_code: 'UNKNOWN_COMMAND', flags: usedFlags, chain });
1727
+ await trackCommandFailed({ command: fullCommand, duration_ms: Date.now() - startTime, error_code: 'UNKNOWN_COMMAND', flags: usedFlags, chain });
1736
1728
  exit(1);
1737
1729
  return { type: 'error', data: errorData };
1738
1730
  }
@@ -1759,7 +1751,7 @@ export async function runCLI(rawArgs, deps = {}) {
1759
1751
 
1760
1752
  // Commands that handle their own output return undefined
1761
1753
  if (result === undefined) {
1762
- trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, flags: usedFlags, chain });
1754
+ await trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, flags: usedFlags, chain });
1763
1755
  return { type: 'no-output', command };
1764
1756
  }
1765
1757
 
@@ -1767,7 +1759,7 @@ export async function runCLI(rawArgs, deps = {}) {
1767
1759
  if (command === 'schema') {
1768
1760
  const formatted = formatOutput(result, { pretty, table: false });
1769
1761
  output(formatted.text);
1770
- trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, flags: usedFlags, chain });
1762
+ await trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, flags: usedFlags, chain });
1771
1763
  return { type: 'schema', data: result };
1772
1764
  }
1773
1765
 
@@ -1780,6 +1772,7 @@ export async function runCLI(rawArgs, deps = {}) {
1780
1772
  // Alerts list with --table uses custom table format
1781
1773
  if (command === 'alerts' && subcommand === 'list' && table) {
1782
1774
  output(formatAlertsTable(result));
1775
+ await trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, flags: usedFlags, chain });
1783
1776
  return { type: 'success', data: result };
1784
1777
  }
1785
1778
 
@@ -1790,20 +1783,26 @@ export async function runCLI(rawArgs, deps = {}) {
1790
1783
  if (streamOutput) {
1791
1784
  output(streamOutput);
1792
1785
  }
1793
- trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, from_cache: !!result?.fromCache, flags: usedFlags, chain });
1786
+ await trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, from_cache: !!result?.fromCache, flags: usedFlags, chain });
1794
1787
  return { type: 'stream', data: result };
1795
1788
  }
1796
1789
 
1797
1790
  const successData = { success: true, data: result };
1798
1791
  const formatted = formatOutput(successData, { pretty, table, csv });
1799
1792
  output(formatted.text);
1800
- trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, from_cache: !!result?.fromCache, flags: usedFlags, chain });
1793
+ await trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, from_cache: !!result?.fromCache, flags: usedFlags, chain });
1801
1794
  return { type: csv ? 'csv' : 'success', data: result };
1802
1795
  } catch (error) {
1803
- const errorData = formatError(error);
1804
- const formatted = formatOutput(errorData, { pretty, table, csv });
1805
- output(formatted.text);
1806
- trackCommandFailed({
1796
+ let errorData;
1797
+ if (error instanceof CommandError) {
1798
+ output(error.data ? JSON.stringify(error.data) : error.message);
1799
+ errorData = { error: error.message, code: error.code };
1800
+ } else {
1801
+ errorData = formatError(error);
1802
+ const formatted = formatOutput(errorData, { pretty, table, csv });
1803
+ output(formatted.text);
1804
+ }
1805
+ await trackCommandFailed({
1807
1806
  command: fullCommand,
1808
1807
  duration_ms: Date.now() - startTime,
1809
1808
  error_code: error.code || 'UNKNOWN',
package/src/telemetry.js CHANGED
@@ -22,7 +22,7 @@ const { version: cliVersion } = JSON.parse(
22
22
  const TELEMETRY_URL =
23
23
  'https://bi-data-sources.nansen.ai/events-service-68ifmnpsx2uq7cgab8dw/v2/event';
24
24
 
25
- const TIMEOUT_MS = 2000;
25
+ const TIMEOUT_MS = 1000;
26
26
 
27
27
  // ─── opt-out ──────────────────────────────────────────────
28
28
 
@@ -122,15 +122,17 @@ export function getSessionId() {
122
122
  // ─── send ──────────────────────────────────────────────────
123
123
 
124
124
  /**
125
- * Send a telemetry event. Fire-and-forget never throws.
125
+ * Send a telemetry event. Returns a promise that resolves when the request
126
+ * completes (or fails/times out). Never rejects — errors are swallowed.
127
+ * Callers that need to ensure delivery before process.exit can await this.
126
128
  */
127
129
  function sendEvent(event) {
128
- if (TELEMETRY_DISABLED) return;
130
+ if (TELEMETRY_DISABLED) return Promise.resolve();
129
131
  const controller = new AbortController();
130
132
  const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
131
133
  timer.unref();
132
134
 
133
- fetch(TELEMETRY_URL, {
135
+ return fetch(TELEMETRY_URL, {
134
136
  method: 'POST',
135
137
  headers: { 'Content-Type': 'application/json' },
136
138
  body: JSON.stringify(event),
@@ -178,7 +180,7 @@ export function trackCommandSucceeded({
178
180
  flags = [],
179
181
  chain = null,
180
182
  }) {
181
- sendEvent({
183
+ return sendEvent({
182
184
  event: 'cli_command_succeeded',
183
185
  event_source: getEventSource(),
184
186
  event_id: crypto.randomUUID(),
@@ -216,7 +218,7 @@ export function trackCommandFailed({
216
218
  flags = [],
217
219
  chain = null,
218
220
  }) {
219
- sendEvent({
221
+ return sendEvent({
220
222
  event: 'cli_command_failed',
221
223
  event_source: getEventSource(),
222
224
  event_id: crypto.randomUUID(),
@@ -115,6 +115,7 @@ const NATIVE_TOKEN_ADDRESSES = {
115
115
  // Native token symbols for error messages.
116
116
  const NATIVE_SYMBOLS = { solana: 'SOL', base: 'ETH' };
117
117
 
118
+ const MIN_GAS_AMOUNTS = { solana: 0.01, base: 0.000024 };
118
119
  const FEE_BUFFER = { solana: 0.005, base: 0.00004 };
119
120
  const HIGH_PERCENTAGE_THRESHOLD = 95;
120
121
  const AUTO_ADJUST_THRESHOLD_PERCENT = 2;
@@ -269,6 +270,32 @@ export async function resolvePercentAmount({ chain, from, walletAddress, percent
269
270
  return String(parseFloat(tokenAmount.toFixed(decimals)));
270
271
  }
271
272
 
273
+ /**
274
+ * Validate that the wallet has enough native token for gas fees.
275
+ *
276
+ * Returns { hasSufficientNative } or throws on validation failure.
277
+ * Best-effort: if RPC fails, returns passing result.
278
+ */
279
+ export async function validateGasBalance({ chain, walletAddress }) {
280
+ const normalizedChain = chain.toLowerCase();
281
+ const minGas = MIN_GAS_AMOUNTS[normalizedChain];
282
+ if (minGas === undefined) return { hasSufficientNative: true };
283
+
284
+ const balance = await fetchNativeBalance(normalizedChain, walletAddress);
285
+
286
+ // RPC failure — proceed without validation.
287
+ if (balance === null) return { hasSufficientNative: true };
288
+
289
+ if (balance >= minGas) {
290
+ return { hasSufficientNative: true };
291
+ }
292
+
293
+ const symbol = NATIVE_SYMBOLS[normalizedChain] || 'native token';
294
+ throw new Error(
295
+ `Insufficient ${symbol} for gas fees. Wallet has ${balance} ${symbol} but needs at least ${minGas} ${symbol}. Fund the wallet before trading.`
296
+ );
297
+ }
298
+
272
299
  /**
273
300
  * Fetch an ERC-20 or SPL token balance for a wallet.
274
301
  * Returns balance in human-readable token units, or null on RPC failure.
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/wallet.js CHANGED
@@ -9,6 +9,7 @@ import path from 'path';
9
9
  import * as readline from 'readline';
10
10
  import { base58 } from '@scure/base';
11
11
  import { storePassword, retrievePassword, deletePassword, deleteCredentialsFile } from './keychain.js';
12
+ import { CommandError } from './api.js';
12
13
 
13
14
  // ============= Constants =============
14
15
 
@@ -335,8 +336,8 @@ function resolveWalletPassword() {
335
336
  *
336
337
  * @param {object} config - wallet config (needs config.passwordHash)
337
338
  * @param {object} flags - CLI flags
338
- * @param {object} deps - { promptFn, log, exit }
339
- * @returns {{ password: string|null, error: string|null }}
339
+ * @param {object} deps - { promptFn, log }
340
+ * @returns {{ password: string|null, error: object|null }}
340
341
  */
341
342
  async function resolvePasswordForCommand(config, flags, deps) {
342
343
  if (!config.passwordHash) {
@@ -353,14 +354,14 @@ async function resolvePasswordForCommand(config, flags, deps) {
353
354
 
354
355
  return {
355
356
  password: null,
356
- error: JSON.stringify({
357
+ error: {
357
358
  error: 'PASSWORD_REQUIRED',
358
359
  message: 'Wallet is encrypted and no password was found.',
359
360
  resolution: [
360
361
  'Set NANSEN_WALLET_PASSWORD environment variable',
361
362
  'Or re-run wallet create with the password (it will be persisted for future use)',
362
363
  ],
363
- }),
364
+ },
364
365
  };
365
366
  }
366
367
 
@@ -580,7 +581,7 @@ export async function deleteWallet(name, password) {
580
581
  * Build wallet command handlers for integration into CLI.
581
582
  */
582
583
  export function buildWalletCommands(deps = {}) {
583
- const { log = console.log, promptFn: _promptFn, exit = process.exit } = deps;
584
+ const { log = console.log } = deps;
584
585
 
585
586
  return {
586
587
  'wallet': async (args, apiInstance, flags, options) => {
@@ -599,9 +600,7 @@ export function buildWalletCommands(deps = {}) {
599
600
  log('');
600
601
  return;
601
602
  } catch (err) {
602
- log(`❌ ${err.message}`);
603
- exit(1);
604
- return;
603
+ throw new CommandError(`❌ ${err.message}`, 'PRIVY_ERROR');
605
604
  }
606
605
  }
607
606
  // All other subcommands fall through to unified handlers below
@@ -612,8 +611,8 @@ export function buildWalletCommands(deps = {}) {
612
611
  const handlers = {
613
612
  'create': async () => {
614
613
  // Privy wallets are handled above — if we reach here with provider=privy,
615
- // the privy path already failed and called exit(). Guard against environments
616
- // where exit() does not terminate (agent frameworks, test harnesses).
614
+ // the privy path already threw CommandError. Guard against environments
615
+ // where throw does not propagate (agent frameworks, test harnesses).
617
616
  if (isPrivy) return;
618
617
 
619
618
  const name = options.name || args[1] || 'default';
@@ -628,28 +627,22 @@ export function buildWalletCommands(deps = {}) {
628
627
 
629
628
  // Step 2: If --human flag, allow interactive prompt (requires TTY)
630
629
  if (!password && flags.human && !process.stdin.isTTY && !deps.promptFn) {
631
- log(JSON.stringify({
630
+ throw new CommandError('--human requires an interactive terminal. Set NANSEN_WALLET_PASSWORD env var instead.', 'NOT_A_TTY', {
632
631
  error: 'NOT_A_TTY',
633
632
  message: '--human requires an interactive terminal. Set NANSEN_WALLET_PASSWORD env var instead.',
634
- }));
635
- exit(1);
636
- return;
633
+ });
637
634
  }
638
635
  if (!password && flags.human && (process.stdin.isTTY || deps.promptFn)) {
639
636
  password = await promptPassword('Enter wallet password: ', deps);
640
637
  if (password && password.length < 12) {
641
- log('❌ Password must be at least 12 characters');
642
- exit(1);
643
- return;
638
+ throw new CommandError('❌ Password must be at least 12 characters', 'INVALID_INPUT');
644
639
  }
645
640
  if (password) {
646
641
  const config = getWalletConfig();
647
642
  if (!config.passwordHash) {
648
643
  const confirm = await promptPassword('Confirm password: ', deps);
649
644
  if (password !== confirm) {
650
- log('❌ Passwords do not match');
651
- exit(1);
652
- return;
645
+ throw new CommandError('❌ Passwords do not match', 'INVALID_INPUT');
653
646
  }
654
647
  }
655
648
  }
@@ -657,20 +650,16 @@ export function buildWalletCommands(deps = {}) {
657
650
 
658
651
  // Step 3: No password available — return structured error for agents
659
652
  if (!password) {
660
- log(JSON.stringify({
653
+ throw new CommandError('A wallet password is required. Ask the user to provide one.', 'PASSWORD_REQUIRED', {
661
654
  error: 'PASSWORD_REQUIRED',
662
655
  message: 'A wallet password is required. Ask the user to provide one.',
663
656
  instructions: 'Re-run with: NANSEN_WALLET_PASSWORD=<password> nansen wallet create',
664
657
  note: 'Password must be at least 12 characters. After creation, the password is saved to the OS keychain automatically — future operations will not require it.',
665
- }));
666
- exit(1);
667
- return;
658
+ });
668
659
  }
669
660
 
670
661
  if (password.length < 12) {
671
- log('❌ Password must be at least 12 characters');
672
- exit(1);
673
- return;
662
+ throw new CommandError('❌ Password must be at least 12 characters', 'INVALID_INPUT');
674
663
  }
675
664
  }
676
665
 
@@ -678,9 +667,7 @@ export function buildWalletCommands(deps = {}) {
678
667
  if (password !== null) {
679
668
  const config = getWalletConfig();
680
669
  if (config.passwordHash && !verifyPassword(password, config)) {
681
- log('❌ Incorrect password — does not match existing wallets.');
682
- exit(1);
683
- return;
670
+ throw new CommandError('❌ Incorrect password — does not match existing wallets.', 'INCORRECT_PASSWORD');
684
671
  }
685
672
  }
686
673
 
@@ -725,8 +712,7 @@ export function buildWalletCommands(deps = {}) {
725
712
  log('');
726
713
  return;
727
714
  } catch (err) {
728
- log(`❌ ${err.message}`);
729
- exit(1);
715
+ throw new CommandError(`❌ ${err.message}`, 'CREATE_FAILED');
730
716
  }
731
717
  },
732
718
 
@@ -750,9 +736,7 @@ export function buildWalletCommands(deps = {}) {
750
736
  'show': async () => {
751
737
  const name = options.name || args[1];
752
738
  if (!name) {
753
- log('Usage: nansen wallet show <name>');
754
- exit(1);
755
- return;
739
+ throw new CommandError('Usage: nansen wallet show <name>', 'MISSING_ARGS');
756
740
  }
757
741
  try {
758
742
  const result = showWallet(name);
@@ -764,25 +748,20 @@ export function buildWalletCommands(deps = {}) {
764
748
  log(` Created: ${result.createdAt}\n`);
765
749
  return;
766
750
  } catch (err) {
767
- log(`❌ ${err.message}`);
768
- exit(1);
751
+ throw new CommandError(`❌ ${err.message}`, 'SHOW_FAILED');
769
752
  }
770
753
  },
771
754
 
772
755
  'export': async () => {
773
756
  const name = options.name || args[1];
774
757
  if (!name) {
775
- log('Usage: nansen wallet export <name>');
776
- exit(1);
777
- return;
758
+ throw new CommandError('Usage: nansen wallet export <name>', 'MISSING_ARGS');
778
759
  }
779
760
 
780
761
  const config = getWalletConfig();
781
762
  const { password, error } = await resolvePasswordForCommand(config, flags, deps);
782
763
  if (error) {
783
- log(error);
784
- exit(1);
785
- return;
764
+ throw new CommandError(error.message, 'PASSWORD_REQUIRED', error);
786
765
  }
787
766
  try {
788
767
  const result = exportWallet(name, password);
@@ -796,34 +775,28 @@ export function buildWalletCommands(deps = {}) {
796
775
  log('');
797
776
  return;
798
777
  } catch (err) {
799
- log(`❌ ${err.message}`);
800
- exit(1);
778
+ throw new CommandError(`❌ ${err.message}`, 'EXPORT_FAILED');
801
779
  }
802
780
  },
803
781
 
804
782
  'default': async () => {
805
783
  const name = options.name || args[1];
806
784
  if (!name) {
807
- log('Usage: nansen wallet default <name>');
808
- exit(1);
809
- return;
785
+ throw new CommandError('Usage: nansen wallet default <name>', 'MISSING_ARGS');
810
786
  }
811
787
  try {
812
788
  const result = setDefaultWallet(name);
813
789
  log(`✓ Default wallet set to "${result.defaultWallet}"`);
814
790
  return;
815
791
  } catch (err) {
816
- log(`❌ ${err.message}`);
817
- exit(1);
792
+ throw new CommandError(`❌ ${err.message}`, 'DEFAULT_FAILED');
818
793
  }
819
794
  },
820
795
 
821
796
  'delete': async () => {
822
797
  const name = options.name || args[1];
823
798
  if (!name) {
824
- log('Usage: nansen wallet delete <name>');
825
- exit(1);
826
- return;
799
+ throw new CommandError('Usage: nansen wallet delete <name>', 'MISSING_ARGS');
827
800
  }
828
801
 
829
802
  // Check if this is a Privy wallet (no password needed)
@@ -839,9 +812,7 @@ export function buildWalletCommands(deps = {}) {
839
812
  const config = getWalletConfig();
840
813
  const resolved = await resolvePasswordForCommand(config, flags, deps);
841
814
  if (resolved.error) {
842
- log(resolved.error);
843
- exit(1);
844
- return;
815
+ throw new CommandError(resolved.error.message, 'PASSWORD_REQUIRED', resolved.error);
845
816
  }
846
817
  password = resolved.password;
847
818
  }
@@ -857,8 +828,7 @@ export function buildWalletCommands(deps = {}) {
857
828
  }
858
829
  return;
859
830
  } catch (err) {
860
- log(`❌ ${err.message}`);
861
- exit(1);
831
+ throw new CommandError(`❌ ${err.message}`, 'DELETE_FAILED');
862
832
  }
863
833
  },
864
834
 
@@ -866,28 +836,20 @@ export function buildWalletCommands(deps = {}) {
866
836
  const { sendTokens } = await import('./transfer.js');
867
837
 
868
838
  if (!options.to) {
869
- log('--to <address> is required');
870
- exit(1);
871
- return;
839
+ throw new CommandError('--to <address> is required', 'MISSING_ARGS');
872
840
  }
873
841
 
874
842
  const isMax = flags.max || options.amount === 'max';
875
843
  if (!options.amount && !isMax) {
876
- log('--amount <number> or --max is required');
877
- exit(1);
878
- return;
844
+ throw new CommandError('--amount <number> or --max is required', 'MISSING_ARGS');
879
845
  }
880
846
 
881
847
  if (!options.chain) {
882
- log('--chain <evm|solana> is required');
883
- exit(1);
884
- return;
848
+ throw new CommandError('--chain <evm|solana> is required', 'MISSING_ARGS');
885
849
  }
886
850
 
887
851
  if (!['evm', 'solana', 'ethereum', 'base'].includes(options.chain)) {
888
- log('--chain must be one of: evm, solana, ethereum, base');
889
- exit(1);
890
- return;
852
+ throw new CommandError('--chain must be one of: evm, solana, ethereum, base', 'INVALID_INPUT');
891
853
  }
892
854
 
893
855
  const isWalletConnect = options.wallet === 'walletconnect' || options.wallet === 'wc';
@@ -912,9 +874,7 @@ export function buildWalletCommands(deps = {}) {
912
874
  const sendConfig = getWalletConfig();
913
875
  const resolved = await resolvePasswordForCommand(sendConfig, flags, deps);
914
876
  if (resolved.error) {
915
- log(resolved.error);
916
- exit(1);
917
- return;
877
+ throw new CommandError(resolved.error.message, 'PASSWORD_REQUIRED', resolved.error);
918
878
  }
919
879
  password = resolved.password;
920
880
  }
@@ -960,8 +920,7 @@ export function buildWalletCommands(deps = {}) {
960
920
  log('');
961
921
  return;
962
922
  } catch (err) {
963
- log(`Error: ${err.message}`);
964
- exit(1);
923
+ throw new CommandError(err.message, 'SEND_FAILED');
965
924
  }
966
925
  },
967
926
 
@@ -979,16 +938,14 @@ export function buildWalletCommands(deps = {}) {
979
938
  'secure': async () => {
980
939
  const { password, source } = retrievePassword();
981
940
  if (!password) {
982
- log(JSON.stringify({
941
+ throw new CommandError('No wallet password found in any store.', 'NO_PASSWORD_FOUND', {
983
942
  error: 'NO_PASSWORD_FOUND',
984
943
  message: 'No wallet password found in any store.',
985
944
  resolution: [
986
945
  'Set NANSEN_WALLET_PASSWORD and run: nansen wallet secure',
987
946
  'This will store it in the OS keychain.',
988
947
  ],
989
- }));
990
- exit(1);
991
- return;
948
+ });
992
949
  }
993
950
 
994
951
  if (source === 'keychain') {
@@ -999,20 +956,19 @@ export function buildWalletCommands(deps = {}) {
999
956
  // Verify password actually decrypts wallets before overwriting keychain
1000
957
  const walletConfig = getWalletConfig();
1001
958
  if (walletConfig.passwordHash && !verifyPassword(password, walletConfig)) {
1002
- log(JSON.stringify({
959
+ const resolution = source === 'file'
960
+ ? [
961
+ 'The password in ~/.nansen/wallets/.credentials is incorrect.',
962
+ 'Run: nansen wallet forget-password then re-run with the correct password: NANSEN_WALLET_PASSWORD=<pw> nansen wallet secure',
963
+ ]
964
+ : [
965
+ 'Unset NANSEN_WALLET_PASSWORD if it is stale, then re-run: nansen wallet secure',
966
+ ];
967
+ throw new CommandError(`Password from '${source}' does not match the wallet's stored hash.`, 'INCORRECT_PASSWORD', {
1003
968
  error: 'INCORRECT_PASSWORD',
1004
969
  message: `Password from '${source}' does not match the wallet's stored hash.`,
1005
- resolution: source === 'file'
1006
- ? [
1007
- 'The password in ~/.nansen/wallets/.credentials is incorrect.',
1008
- 'Run: nansen wallet forget-password then re-run with the correct password: NANSEN_WALLET_PASSWORD=<pw> nansen wallet secure',
1009
- ]
1010
- : [
1011
- 'Unset NANSEN_WALLET_PASSWORD if it is stale, then re-run: nansen wallet secure',
1012
- ],
1013
- }));
1014
- exit(1);
1015
- return;
970
+ resolution,
971
+ });
1016
972
  }
1017
973
 
1018
974
  // Try to migrate to keychain
@@ -1027,18 +983,17 @@ export function buildWalletCommands(deps = {}) {
1027
983
  log(' Removed ~/.nansen/wallets/.credentials.');
1028
984
  }
1029
985
  } else {
1030
- log(JSON.stringify({
986
+ const msg = source === 'file'
987
+ ? 'OS keychain is not available. Password remains in ~/.nansen/wallets/.credentials (insecure).'
988
+ : 'OS keychain is not available. Password is only in the NANSEN_WALLET_PASSWORD env var (not persisted).';
989
+ throw new CommandError(msg, 'KEYCHAIN_UNAVAILABLE', {
1031
990
  error: 'KEYCHAIN_UNAVAILABLE',
1032
- message: source === 'file'
1033
- ? 'OS keychain is not available. Password remains in ~/.nansen/wallets/.credentials (insecure).'
1034
- : 'OS keychain is not available. Password is only in the NANSEN_WALLET_PASSWORD env var (not persisted).',
991
+ message: msg,
1035
992
  resolution: [
1036
993
  'Set NANSEN_WALLET_PASSWORD in a secrets manager or system keyring',
1037
994
  'Use a containerized secrets agent (e.g. Vault, 1Password CLI)',
1038
995
  ],
1039
- }));
1040
- exit(1);
1041
- return;
996
+ });
1042
997
  }
1043
998
  },
1044
999
 
@@ -1104,9 +1059,7 @@ EXAMPLES:
1104
1059
  };
1105
1060
 
1106
1061
  if (!handlers[subcommand]) {
1107
- log(`Unknown subcommand: ${subcommand}. Available: ${Object.keys(handlers).join(', ')}`);
1108
- exit(1);
1109
- return;
1062
+ throw new CommandError(`Unknown subcommand: ${subcommand}. Available: ${Object.keys(handlers).join(', ')}`, 'UNKNOWN_COMMAND');
1110
1063
  }
1111
1064
 
1112
1065
  // --help on any wallet subcommand shows wallet help instead of executing
package/src/x402-svm.js CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  import crypto from 'crypto';
7
7
  import { base58Encode, base58DecodePubkey } from './wallet.js';
8
- import { encodeCompactU16, isOnEd25519Curve } from './transfer.js';
8
+ import { encodeCompactU16, deriveATA as _deriveATA } from './transfer.js';
9
9
 
10
10
  // ============= Constants =============
11
11
 
@@ -13,7 +13,6 @@ const TOKEN_PROGRAM = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA';
13
13
  const _TOKEN_2022_PROGRAM = 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb';
14
14
  const COMPUTE_BUDGET_PROGRAM = 'ComputeBudget111111111111111111111111111111';
15
15
  const MEMO_PROGRAM = 'MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr';
16
- const ATA_PROGRAM = 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL';
17
16
  const _SYSTEM_PROGRAM = '11111111111111111111111111111111';
18
17
 
19
18
  const DEFAULT_COMPUTE_UNIT_LIMIT = 20000;
@@ -23,29 +22,10 @@ const DEFAULT_COMPUTE_UNIT_PRICE_MICROLAMPORTS = 1;
23
22
 
24
23
  /**
25
24
  * Derive Associated Token Account (ATA) address.
26
- * PDA seeds: [owner, tokenProgram, mint] with ATA program.
25
+ * Returns base58-encoded PDA. Delegates algorithm to transfer.js.
27
26
  */
28
27
  export function deriveATA(ownerBase58, mintBase58, tokenProgramBase58 = TOKEN_PROGRAM) {
29
- const owner = base58DecodePubkey(ownerBase58);
30
- const tokenProgram = base58DecodePubkey(tokenProgramBase58);
31
- const mint = base58DecodePubkey(mintBase58);
32
- const ataProgramKey = base58DecodePubkey(ATA_PROGRAM);
33
-
34
- // find_program_address: try nonce 255 down to 0
35
- // PDA = SHA256(seeds... || programId || "ProgramDerivedAddress")
36
- // A valid PDA must NOT be on the ed25519 curve.
37
- // Checking on-curve in pure JS without a full ed25519 implementation is hard.
38
- // We use the mathematical approach: decode y-coordinate, compute x², check QR.
39
- for (let nonce = 255; nonce >= 0; nonce--) {
40
- const hash = crypto.createHash('sha256')
41
- .update(Buffer.concat([owner, tokenProgram, mint, Buffer.from([nonce]), ataProgramKey, Buffer.from('ProgramDerivedAddress')]))
42
- .digest();
43
-
44
- if (!isOnEd25519Curve(hash)) {
45
- return base58Encode(hash);
46
- }
47
- }
48
- throw new Error('Could not derive ATA: no valid PDA found');
28
+ return base58Encode(_deriveATA(ownerBase58, mintBase58, tokenProgramBase58));
49
29
  }
50
30
 
51
31
  // ============= MessageV0 Builder =============