nansen-cli 1.25.1 → 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,23 @@
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
+
9
+ ## 1.26.0
10
+
11
+ ### Minor Changes
12
+
13
+ - [#380](https://github.com/nansen-ai/nansen-cli/pull/380) [`12e4e25`](https://github.com/nansen-ai/nansen-cli/commit/12e4e25d50f50ff1ebbae160ba1016abd1cdbb4d) Thanks [@TimNooren](https://github.com/TimNooren)! - Add `--amount-unit percent` to trade commands, allowing trades as a percentage of wallet balance (e.g. `--amount 100 --amount-unit percent` to sell all)
14
+
15
+ ### Patch Changes
16
+
17
+ - [#382](https://github.com/nansen-ai/nansen-cli/pull/382) [`d9c87ef`](https://github.com/nansen-ai/nansen-cli/commit/d9c87ef9df51a3e9c53ea59674ad9efe9aa33fb7) Thanks [@kome12](https://github.com/kome12)! - fix: default `profiler balance` chain to `'all'` instead of `'ethereum'`
18
+
19
+ Previously, `nansen profiler balance --address <addr>` without `--chain` defaulted to `ethereum`, returning empty results for wallets with no ETH mainnet holdings (e.g. Base-only or Solana-only wallets). Now defaults to `'all'`, letting the API auto-route based on address format.
20
+
3
21
  ## 1.25.1
4
22
 
5
23
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nansen-cli",
3
- "version": "1.25.1",
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
  */
@@ -766,7 +789,7 @@ export class NansenAPI {
766
789
  // ============= Profiler Endpoints =============
767
790
 
768
791
  async addressBalance(params = {}) {
769
- const { address, entityName, chain = 'ethereum', hideSpamToken = true, filters = {}, orderBy } = params;
792
+ const { address, entityName, chain = 'all', hideSpamToken = true, filters = {}, orderBy } = params;
770
793
  if (address) {
771
794
  const validation = validateAddress(address, chain);
772
795
  if (!validation.valid) throw new NansenError(validation.error, validation.code);
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
@@ -1110,7 +1102,7 @@ export function buildCommands(deps = {}) {
1110
1102
  const subcommand = args[0] || 'help';
1111
1103
  let address = options.address;
1112
1104
  const entityName = options.entity || options['entity-name'];
1113
- const chain = options.chain || 'ethereum';
1105
+ const chain = options.chain || 'all';
1114
1106
 
1115
1107
  // Resolve ENS names (e.g. vitalik.eth → 0x...)
1116
1108
  let ensName;
@@ -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/schema.json CHANGED
@@ -821,11 +821,11 @@
821
821
  "amount": {
822
822
  "type": "string",
823
823
  "required": true,
824
- "description": "Amount to swap (base units by default, or token units with --amount-unit token, or USD with --amount-unit usd)"
824
+ "description": "Amount to swap (base units by default, or token units with --amount-unit token, USD with --amount-unit usd, or percentage of balance with --amount-unit percent)"
825
825
  },
826
826
  "amount-unit": {
827
827
  "type": "string",
828
- "description": "\"token\" to specify amount in token units (e.g. 0.5 SOL), \"usd\" to specify amount in USD (e.g. 50), or \"base\" for base units (default). The CLI resolves the current token price and decimals locally; the API always receives base units."
828
+ "description": "\"token\" to specify amount in token units (e.g. 0.5 SOL), \"usd\" to specify amount in USD (e.g. 50), \"percent\" to sell a percentage of your balance (e.g. 100 for all), or \"base\" for base units (default). The CLI resolves the current token price, decimals, and balance locally; the API always receives base units."
829
829
  },
830
830
  "wallet": {
831
831
  "type": "string",
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;
@@ -210,6 +211,91 @@ export async function validateBalance({ chain, from, amount, amountUnit, walletA
210
211
  return { adjustedAmount: amount };
211
212
  }
212
213
 
214
+ /**
215
+ * Resolve a percentage amount to a token-unit amount string.
216
+ * Fetches the wallet's balance of the sell token, calculates the percentage,
217
+ * and applies a native-token fee buffer when selling >=95%.
218
+ *
219
+ * Returns the amount in human-readable token units (e.g. "1.5"),
220
+ * ready for convertToBaseUnits().
221
+ */
222
+ export async function resolvePercentAmount({ chain, from, walletAddress, percentage, decimals }) {
223
+ if (!Number.isFinite(percentage) || percentage <= 0 || percentage > 100) {
224
+ throw new Error(
225
+ percentage > 100
226
+ ? `Cannot sell more than 100% of balance. Got: ${percentage}%`
227
+ : `Percentage must be between 0 and 100. Got: ${percentage}%`
228
+ );
229
+ }
230
+
231
+ const normalizedChain = chain.toLowerCase();
232
+ const isNative = isNativeAddress(from, normalizedChain);
233
+
234
+ let balance;
235
+ if (isNative) {
236
+ balance = await fetchNativeBalance(normalizedChain, walletAddress);
237
+ } else {
238
+ balance = await fetchTokenBalance(normalizedChain, from, walletAddress, decimals);
239
+ }
240
+
241
+ if (balance === null) {
242
+ throw new Error(`Could not fetch balance for ${from} on ${normalizedChain}. Check your RPC connection.`);
243
+ }
244
+ if (balance === 0) {
245
+ const symbol = isNative ? (NATIVE_SYMBOLS[normalizedChain] || from) : from;
246
+ throw new Error(`No ${symbol} balance in wallet. You cannot trade a token you don't own.`);
247
+ }
248
+
249
+ // Calculate token amount from percentage.
250
+ // Use exact balance for 100% to avoid floating-point precision loss.
251
+ let tokenAmount = percentage === 100 ? balance : balance * (percentage / 100);
252
+
253
+ // Native token fee buffer: when selling >=95%, cap at balance - reserve.
254
+ if (isNative && percentage >= HIGH_PERCENTAGE_THRESHOLD) {
255
+ const reserve = FEE_BUFFER[normalizedChain] || 0;
256
+ const maxSellable = parseFloat((balance - reserve).toFixed(NATIVE_DECIMALS[normalizedChain]));
257
+ if (maxSellable <= 0) {
258
+ const symbol = NATIVE_SYMBOLS[normalizedChain] || from;
259
+ throw new Error(`Insufficient ${symbol} balance after reserving gas fees.`);
260
+ }
261
+ if (tokenAmount > maxSellable) {
262
+ const symbol = NATIVE_SYMBOLS[normalizedChain] || from;
263
+ process.stderr.write(
264
+ `Warning: Reserving ${reserve} ${symbol} for gas. Adjusted sell amount to ${maxSellable} ${symbol}.\n`
265
+ );
266
+ tokenAmount = maxSellable;
267
+ }
268
+ }
269
+
270
+ return String(parseFloat(tokenAmount.toFixed(decimals)));
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
+
213
299
  /**
214
300
  * Fetch an ERC-20 or SPL token balance for a wallet.
215
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 } 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:
@@ -975,7 +978,7 @@ OPTIONS:
975
978
  --from <symbol|address> Input token (symbol like SOL, USDC or address)
976
979
  --to <symbol|address> Output token (symbol like USDC, ETH or address)
977
980
  --amount <units> Amount in BASE UNITS (e.g. lamports, wei)
978
- --amount-unit <unit> "token" for token units (e.g. 0.5 SOL), "usd" for USD (e.g. 50)
981
+ --amount-unit <unit> "token" for token units, "usd" for USD, "percent" for % of balance
979
982
  --wallet <name> Wallet name (default: default wallet). Use "walletconnect" or "wc" for WalletConnect.
980
983
  --to-wallet <address> Destination wallet address (auto-derived for cross-chain if omitted)
981
984
  --slippage <pct> Slippage as decimal (e.g. 0.03 for 3%). Default: 0.03
@@ -987,19 +990,21 @@ EXAMPLES:
987
990
  nansen trade quote --chain solana --from SOL --to USDC --amount 1000000000
988
991
  nansen trade quote --chain solana --from SOL --to USDC --amount 0.5 --amount-unit token
989
992
  nansen trade quote --chain solana --from SOL --to USDC --amount 50 --amount-unit usd
993
+ nansen trade quote --chain solana --from SOL --to USDC --amount 100 --amount-unit percent
990
994
  nansen trade quote --chain base --from ETH --to USDC --amount 1000000000000000000
991
995
  nansen trade quote --chain base --to-chain solana --from USDC --to USDC --amount 1000000
992
996
  nansen trade quote --chain solana --to-chain base --from SOL --to ETH --amount 1000000000
993
- `);
994
- exit(1);
995
- return;
997
+ `, 'MISSING_ARGS');
996
998
  }
997
999
 
998
1000
  // Validate --amount-unit if provided
999
- if (amountUnit && amountUnit !== 'token' && amountUnit !== 'base' && amountUnit !== 'usd') {
1000
- log(`Error: Unknown --amount-unit "${amountUnit}". Supported values: token, base, usd`);
1001
- exit(1);
1002
- return;
1001
+ if (amountUnit && amountUnit !== 'token' && amountUnit !== 'base' && amountUnit !== 'usd' && amountUnit !== 'percent') {
1002
+ throw new CommandError(`Error: Unknown --amount-unit "${amountUnit}". Supported values: token, base, usd, percent`, 'INVALID_INPUT');
1003
+ }
1004
+
1005
+ // --amount-unit percent is only valid for exactIn (sell-side)
1006
+ if (amountUnit === 'percent' && swapMode === 'exactOut') {
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');
1003
1008
  }
1004
1009
 
1005
1010
  // Static input validation — catches common agent errors (wrong addresses,
@@ -1007,9 +1012,7 @@ EXAMPLES:
1007
1012
  try {
1008
1013
  validateQuoteInput({ chain, toChain: toChainRaw || null, from, to, amount });
1009
1014
  } catch (validationErr) {
1010
- log(`Error: ${validationErr.message}`);
1011
- exit(1);
1012
- return;
1015
+ throw new CommandError(`Error: ${validationErr.message}`, 'INVALID_INPUT');
1013
1016
  }
1014
1017
 
1015
1018
  // When --amount-unit token is used, resolve decimals and convert to base units.
@@ -1028,9 +1031,7 @@ EXAMPLES:
1028
1031
  usdTokenAmount = tokenAmount.toFixed(resolvedDecimals);
1029
1032
  resolvedAmount = convertToBaseUnits(usdTokenAmount, resolvedDecimals);
1030
1033
  } catch (err) {
1031
- log(`Error converting USD amount: ${err.message}`);
1032
- exit(1);
1033
- return;
1034
+ throw new CommandError(`Error converting USD amount: ${err.message}`, 'INVALID_INPUT');
1034
1035
  }
1035
1036
  } else if (amountUnit === 'token') {
1036
1037
  try {
@@ -1038,16 +1039,14 @@ EXAMPLES:
1038
1039
  resolvedDecimals = await resolveTokenDecimals(tokenForDecimals, chain);
1039
1040
  resolvedAmount = convertToBaseUnits(amount, resolvedDecimals);
1040
1041
  } catch (err) {
1041
- log(`Error resolving token decimals: ${err.message}`);
1042
- exit(1);
1043
- return;
1042
+ throw new CommandError(`Error resolving token decimals: ${err.message}`, 'INVALID_INPUT');
1044
1043
  }
1044
+ } else if (amountUnit === 'percent') {
1045
+ // Resolved after wallet address is available — see percent resolution block below.
1045
1046
  } else {
1046
1047
  const amountError = validateBaseUnitAmount(amount);
1047
1048
  if (amountError) {
1048
- log(`Error: ${amountError}`);
1049
- exit(1);
1050
- return;
1049
+ throw new CommandError(`Error: ${amountError}`, 'INVALID_INPUT');
1051
1050
  }
1052
1051
  }
1053
1052
 
@@ -1063,9 +1062,7 @@ EXAMPLES:
1063
1062
  if (isWalletConnect) {
1064
1063
  walletAddress = await getWalletConnectAddress(chainType);
1065
1064
  if (!walletAddress) {
1066
- log('No WalletConnect session active. Run: walletconnect connect');
1067
- exit(1);
1068
- return;
1065
+ throw new CommandError('No WalletConnect session active. Run: walletconnect connect', 'NO_WALLET');
1069
1066
  }
1070
1067
  } else if (walletName) {
1071
1068
  const wallet = showWallet(walletName);
@@ -1091,9 +1088,25 @@ EXAMPLES:
1091
1088
  }
1092
1089
 
1093
1090
  if (!walletAddress) {
1094
- 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');
1095
- exit(1);
1096
- 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');
1092
+ }
1093
+
1094
+ // --amount-unit percent: fetch balance, calculate percentage, convert to base units.
1095
+ // Placed after wallet resolution because we need the wallet address to fetch balance.
1096
+ if (amountUnit === 'percent') {
1097
+ try {
1098
+ resolvedDecimals = await resolveTokenDecimals(from, chain);
1099
+ const tokenAmount = await resolvePercentAmount({
1100
+ chain,
1101
+ from,
1102
+ walletAddress,
1103
+ percentage: parseFloat(amount),
1104
+ decimals: resolvedDecimals,
1105
+ });
1106
+ resolvedAmount = convertToBaseUnits(tokenAmount, resolvedDecimals);
1107
+ } catch (err) {
1108
+ throw new CommandError(`Error: ${err.message}`, 'INVALID_INPUT');
1109
+ }
1097
1110
  }
1098
1111
 
1099
1112
  // Balance pre-check — catches zero balances and insufficient funds
@@ -1118,9 +1131,7 @@ EXAMPLES:
1118
1131
  resolvedAmount = convertToBaseUnits(balanceAdjusted, resolvedDecimals);
1119
1132
  }
1120
1133
  } catch (balanceErr) {
1121
- log(`Error: ${balanceErr.message}`);
1122
- exit(1);
1123
- return;
1134
+ throw new CommandError(`Error: ${balanceErr.message}`, 'INSUFFICIENT_BALANCE');
1124
1135
  }
1125
1136
  }
1126
1137
 
@@ -1167,17 +1178,23 @@ EXAMPLES:
1167
1178
  const response = await getQuote(params);
1168
1179
 
1169
1180
  if (!response.success || !response.quotes?.length) {
1170
- log('No quotes available');
1181
+ let msg = 'No quotes available';
1171
1182
  if (response.warnings?.length) {
1172
- response.warnings.forEach(w => log(` Warning: ${w}`));
1183
+ msg += '\n' + response.warnings.map(w => ` Warning: ${w}`).join('\n');
1173
1184
  }
1174
- exit(1);
1175
- return;
1185
+ throw new CommandError(msg, 'NO_QUOTES');
1176
1186
  }
1177
1187
 
1178
1188
  log('');
1179
1189
  response.quotes.forEach((q, i) => log(formatQuote(q, i)));
1180
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
+
1181
1198
  const signerType = isWalletConnect ? 'walletconnect' : walletProvider;
1182
1199
  const quoteId = saveQuote(response, chain, signerType, privyWalletIds, isCrossChain ? toChainRaw : null);
1183
1200
  log(`\n Quote ID: ${quoteId}`);
@@ -1195,13 +1212,14 @@ EXAMPLES:
1195
1212
  return undefined; // Output already printed above
1196
1213
 
1197
1214
  } catch (err) {
1215
+ if (err instanceof CommandError) throw err;
1198
1216
  let message = err.message;
1199
1217
  if (err.code === 'INVALID_AMOUNT' || /amount/i.test(err.message)) {
1200
1218
  message += '. Amounts must be in base units (e.g., 1000000000 lamports for 1 SOL, 1000000000000000000 wei for 1 ETH)';
1201
1219
  }
1202
- log(`Error: ${message}`);
1203
- if (err.details) log(` Details: ${JSON.stringify(err.details)}`);
1204
- 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');
1205
1223
  }
1206
1224
  },
1207
1225
 
@@ -1211,8 +1229,7 @@ EXAMPLES:
1211
1229
  const noSimulate = flags['no-simulate'];
1212
1230
 
1213
1231
  if (!quoteId) {
1214
- log(`
1215
- Usage: nansen trade execute --quote <quoteId> [options]
1232
+ throw new CommandError(`Usage: nansen trade execute --quote <quoteId> [options]
1216
1233
 
1217
1234
  OPTIONS:
1218
1235
  --quote <id> Quote ID from 'nansen quote'
@@ -1220,10 +1237,7 @@ OPTIONS:
1220
1237
  --no-simulate Skip pre-broadcast simulation
1221
1238
 
1222
1239
  EXAMPLES:
1223
- nansen trade execute --quote 1708900000000-abc123
1224
- `);
1225
- exit(1);
1226
- return;
1240
+ nansen trade execute --quote 1708900000000-abc123`, 'MISSING_ARGS');
1227
1241
  }
1228
1242
 
1229
1243
  try {
@@ -1234,9 +1248,7 @@ EXAMPLES:
1234
1248
 
1235
1249
  const allQuotes = quoteData.response.quotes || [];
1236
1250
  if (!allQuotes.length) {
1237
- log('❌ No quote data found');
1238
- exit(1);
1239
- return;
1251
+ throw new CommandError('❌ No quote data found', 'NO_QUOTES');
1240
1252
  }
1241
1253
 
1242
1254
  // --quote-index pins a specific quote (no fallback)
@@ -1247,10 +1259,7 @@ EXAMPLES:
1247
1259
  // Check if any quote in range has transaction data before prompting for password
1248
1260
  const hasAnyTransaction = allQuotes.slice(startIndex, endIndex).some(q => q?.transaction);
1249
1261
  if (!hasAnyTransaction) {
1250
- log('❌ No quotes contain transaction data.');
1251
- log(' Ensure userWalletAddress was provided when fetching the quote.');
1252
- exit(1);
1253
- return;
1262
+ throw new CommandError('❌ No quotes contain transaction data.\n Ensure userWalletAddress was provided when fetching the quote.', 'NO_TRANSACTION');
1254
1263
  }
1255
1264
 
1256
1265
  // Determine if this is a WalletConnect or Privy-signed quote
@@ -1271,16 +1280,14 @@ EXAMPLES:
1271
1280
  if (walletConfig.passwordHash) {
1272
1281
  password = resolveTradePassword();
1273
1282
  if (!password) {
1274
- log(JSON.stringify({
1283
+ throw new CommandError('Wallet is encrypted and no password was found.', 'PASSWORD_REQUIRED', {
1275
1284
  error: 'PASSWORD_REQUIRED',
1276
1285
  message: 'Wallet is encrypted and no password was found.',
1277
1286
  resolution: [
1278
1287
  'Set NANSEN_WALLET_PASSWORD environment variable',
1279
1288
  'Or run: nansen wallet create (password is saved to OS keychain automatically)',
1280
1289
  ],
1281
- }));
1282
- exit(1);
1283
- return;
1290
+ });
1284
1291
  }
1285
1292
  }
1286
1293
 
@@ -1290,9 +1297,7 @@ EXAMPLES:
1290
1297
  effectiveWalletName = list.defaultWallet;
1291
1298
  }
1292
1299
  if (!effectiveWalletName) {
1293
- log('No wallet found. Create one with: nansen wallet create');
1294
- exit(1);
1295
- return;
1300
+ throw new CommandError('No wallet found. Create one with: nansen wallet create', 'NO_WALLET');
1296
1301
  }
1297
1302
 
1298
1303
  exported = exportWallet(effectiveWalletName, password);
@@ -1300,9 +1305,7 @@ EXAMPLES:
1300
1305
  // Verify WalletConnect session is still active and address matches quote
1301
1306
  const wcAddress = await getWalletConnectAddress(chainType);
1302
1307
  if (!wcAddress) {
1303
- log('No WalletConnect session active. Run: walletconnect connect');
1304
- exit(1);
1305
- return;
1308
+ throw new CommandError('No WalletConnect session active. Run: walletconnect connect', 'NO_WALLET');
1306
1309
  }
1307
1310
  // Check address matches the one used during quoting
1308
1311
  const quoteWallet = quoteData.response?.quotes?.[0]?.transaction?.from
@@ -1310,9 +1313,7 @@ EXAMPLES:
1310
1313
  if (quoteWallet && (chainType === 'solana'
1311
1314
  ? wcAddress.trim() !== quoteWallet.trim()
1312
1315
  : wcAddress.toLowerCase().trim() !== quoteWallet.toLowerCase().trim())) {
1313
- log(`Connected wallet (${wcAddress}) doesn't match quote. Get a new quote with --wallet walletconnect`);
1314
- exit(1);
1315
- return;
1316
+ throw new CommandError(`Connected wallet (${wcAddress}) doesn't match quote. Get a new quote with --wallet walletconnect`, 'WALLET_MISMATCH');
1316
1317
  }
1317
1318
  }
1318
1319
 
@@ -1675,8 +1676,7 @@ EXAMPLES:
1675
1676
  lastQuoteError = `${quoteName} reverted on-chain`;
1676
1677
  continue;
1677
1678
  }
1678
- exit(1);
1679
- 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');
1680
1680
  }
1681
1681
 
1682
1682
  log(`\n ✓ Transaction successful!`);
@@ -1866,10 +1866,7 @@ EXAMPLES:
1866
1866
  lastQuoteError = `${quoteName} reverted on-chain`;
1867
1867
  continue;
1868
1868
  }
1869
- log(`\n The trading API reported success, but the contract execution failed.`);
1870
- log(` This can happen due to: stale quotes, insufficient gas, or liquidity changes.`);
1871
- exit(1);
1872
- 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');
1873
1870
  }
1874
1871
  }
1875
1872
 
@@ -1924,15 +1921,13 @@ EXAMPLES:
1924
1921
  }
1925
1922
 
1926
1923
  // All quotes exhausted
1927
- log(`\n❌ All quotes failed. Last error: ${lastQuoteError || 'unknown'}`);
1928
- log('');
1929
- exit(1);
1930
- return undefined;
1924
+ throw new CommandError(`\n❌ All quotes failed. Last error: ${lastQuoteError || 'unknown'}\n`, 'ALL_QUOTES_FAILED');
1931
1925
 
1932
1926
  } catch (err) {
1933
- log(`Error: ${err.message}`);
1934
- if (err.details) log(` Details: ${JSON.stringify(err.details)}`);
1935
- 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');
1936
1931
  }
1937
1932
  },
1938
1933
 
@@ -1942,8 +1937,7 @@ EXAMPLES:
1942
1937
  const toChain = options['to-chain'] || args[2];
1943
1938
 
1944
1939
  if (!txHash || !fromChain || !toChain) {
1945
- log(`
1946
- 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>
1947
1941
 
1948
1942
  Check the status of a cross-chain bridge transaction.
1949
1943
 
@@ -1953,10 +1947,7 @@ OPTIONS:
1953
1947
  --to-chain <chain> Destination chain (solana or base)
1954
1948
 
1955
1949
  EXAMPLES:
1956
- nansen trade bridge-status --tx-hash 0xabc... --from-chain base --to-chain solana
1957
- `);
1958
- exit(1);
1959
- return;
1950
+ nansen trade bridge-status --tx-hash 0xabc... --from-chain base --to-chain solana`, 'MISSING_ARGS');
1960
1951
  }
1961
1952
 
1962
1953
  try {
@@ -1980,9 +1971,10 @@ EXAMPLES:
1980
1971
  if (status.lifiExplorerLink) log(` Li.Fi: ${status.lifiExplorerLink}`);
1981
1972
  log('');
1982
1973
  } catch (err) {
1983
- log(`Error: ${err.message}`);
1984
- if (err.details) log(` Details: ${JSON.stringify(err.details)}`);
1985
- 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');
1986
1978
  }
1987
1979
  },
1988
1980
  };
package/src/transfer.js CHANGED
@@ -559,8 +559,7 @@ async function broadcastTransaction(signedTx, chain) {
559
559
 
560
560
  // ============= Public API =============
561
561
 
562
- // Exported for testing
563
- export { parseAmount, formatAmount, signEd25519, encodeCompactU16, base58Decode, base58DecodePubkey, deriveATA, validateEvmAddress, validateSolanaAddress, bigIntToHex };
562
+ export { parseAmount, formatAmount, signEd25519, encodeCompactU16, base58Decode, base58DecodePubkey, deriveATA, isOnEd25519Curve, validateEvmAddress, validateSolanaAddress, bigIntToHex };
564
563
 
565
564
  /**
566
565
  * Send tokens via Privy server wallet. EVM uses Privy's sendTransaction (handles gas/nonce).
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 } 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,81 +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 (!isOnCurve(hash)) {
45
- return base58Encode(hash);
46
- }
47
- }
48
- throw new Error('Could not derive ATA: no valid PDA found');
49
- }
50
-
51
- /**
52
- * Check if a 32-byte buffer represents a valid ed25519 curve point.
53
- * Ed25519 curve: -x² + y² = 1 + d*x²*y² over GF(p) where p = 2^255 - 19
54
- *
55
- * Decode y from the 32 bytes, compute x² = (y² - 1) / (d*y² + 1),
56
- * then check if x² is a quadratic residue (QR) mod p.
57
- */
58
- function isOnCurve(bytes) {
59
- const p = (1n << 255n) - 19n;
60
- const d = -121665n * modInverse(121666n, p) % p;
61
-
62
- // Read y-coordinate (little-endian, clear top bit which is sign of x)
63
- let y = 0n;
64
- for (let i = 0; i < 32; i++) {
65
- y |= BigInt(bytes[i]) << (BigInt(i) * 8n);
66
- }
67
- y &= (1n << 255n) - 1n; // Clear top bit
68
-
69
- if (y >= p) return false;
70
-
71
- // y² mod p
72
- const y2 = modPow(y, 2n, p);
73
-
74
- // x² = (y² - 1) * inverse(d*y² + 1) mod p
75
- const num = ((y2 - 1n) % p + p) % p;
76
- const den = ((d * y2 + 1n) % p + p) % p;
77
- const denInv = modInverse(den, p);
78
- if (denInv === null) return false;
79
-
80
- const x2 = (num * denInv) % p;
81
-
82
- // Check if x² is a quadratic residue: x^((p-1)/2) == 1 mod p
83
- if (x2 === 0n) return true;
84
- const euler = modPow(x2, (p - 1n) / 2n, p);
85
- return euler === 1n;
86
- }
87
-
88
- function modPow(base, exp, mod) {
89
- let result = 1n;
90
- base = ((base % mod) + mod) % mod;
91
- while (exp > 0n) {
92
- if (exp & 1n) result = (result * base) % mod;
93
- exp >>= 1n;
94
- base = (base * base) % mod;
95
- }
96
- return result;
97
- }
98
-
99
- function modInverse(a, mod) {
100
- return modPow(((a % mod) + mod) % mod, mod - 2n, mod);
28
+ return base58Encode(_deriveATA(ownerBase58, mintBase58, tokenProgramBase58));
101
29
  }
102
30
 
103
31
  // ============= MessageV0 Builder =============