From d8b754a36d5754de04b3ed9b7f7ec7200b50e2fb Mon Sep 17 00:00:00 2001 From: "Nicolai Sondergaard (Openclaw Bot)" Date: Mon, 2 Mar 2026 09:23:52 +0000 Subject: [PATCH 1/6] fix: trade quote/execute JSON output, schema wallet + unknown-cmd, deprecated help examples - trade quote: return structured JSON to stdout on success; throw structured errors on failure (previously all output went to stderr, stdout was silent) - trade execute: same -- return structured JSON on success/failure - schema wallet: add wallet to schema.json so 'nansen schema wallet' returns the wallet subcommand schema instead of the full schema dump - schema : throw error instead of silently returning full schema - schema trade execute: add missing --quote required param - --help examples: fix deprecated 'nansen smart-money' paths to 'nansen research smart-money' (and all other research category prefixes) - generateSubcommandHelp: auto-prefix 'research' for research categories Fixes findings from clawfooding session: P0 #1-3, P1 #5-6, P2 #10-11 Does not overlap with open PRs: #58, #71, #72, #103, #137, #156 Co-Authored-By: Nicolai Sondergaard (Openclaw Bot) <93130718+nansen-devops@users.noreply.github.com> --- .changeset/fix-trade-json-output-schema.md | 51 ++++++ src/cli.js | 43 +++-- src/schema.json | 137 +++++++++++++++ src/trading.js | 188 ++++++++++----------- 4 files changed, 304 insertions(+), 115 deletions(-) create mode 100644 .changeset/fix-trade-json-output-schema.md diff --git a/.changeset/fix-trade-json-output-schema.md b/.changeset/fix-trade-json-output-schema.md new file mode 100644 index 00000000..72c87f61 --- /dev/null +++ b/.changeset/fix-trade-json-output-schema.md @@ -0,0 +1,51 @@ +--- +"nansen-cli": minor +--- + +fix: trade quote/execute now output structured JSON; schema command respects wallet and rejects unknown commands; fix deprecated --help examples + +## trade quote and execute: JSON output (agent-first) + +`nansen trade quote` and `nansen trade execute` previously output only +human-readable text to stdout, returning `undefined` to the CLI runner. +No JSON was emitted — making these commands unusable in agent pipelines +despite the CLI being marketed for agent use. + +**What changed:** + +- `trade quote` now returns a structured object on success: + `{ quoteId, chain, walletAddress, quotes: [...], executeCommand }` + The human-readable summary is preserved on stderr for TTY users. + +- `trade execute` now returns a structured object on success: + `{ status, txHash, chain, chainType, broadcaster, explorerUrl, swapEvents }` + +- All validation errors in both commands now throw structured errors + (caught by the CLI runner and emitted as `{"success":false,"error":"..."}`) + instead of printing plain text to stderr and exiting without stdout output. + This includes: missing required params, no wallet configured, invalid quote + ID, all-quotes-failed, and on-chain reverts. + +## schema command: wallet support + unknown-command error + +- `nansen schema wallet` previously returned the full schema (the wallet + command was not in `SCHEMA.commands`, so the lookup fell through to the + default). `wallet` is now a first-class entry in `schema.json` with + full subcommand and option definitions. + +- `nansen schema ` previously silently returned the full schema. + It now returns `{"success":false,"error":"Unknown schema command: ..."}`. + +## schema.json: trade execute --quote param added + +`trade execute` schema was missing the `--quote` param entirely — the only +required input. Agents reading the schema before calling the command could +not discover this requirement. + +## --help examples: fix deprecated command paths + +All `--help` example strings and no-arg command examples used the old +pre-research-namespace paths (e.g. `nansen smart-money netflow`) instead +of the current canonical paths (e.g. `nansen research smart-money netflow`). +Fixed in six hardcoded example strings and in the `generateSubcommandHelp` +auto-generated example builder. diff --git a/src/cli.js b/src/cli.js index 35e2ed2e..d52a6525 100644 --- a/src/cli.js +++ b/src/cli.js @@ -801,16 +801,23 @@ export function buildCommands(deps = {}) { 'schema': async (args, _apiInstance, flags, _options) => { const subcommand = args[0]; - const schemaEntry = subcommand && (SCHEMA.commands[subcommand] || SCHEMA.commands.research.subcommands[subcommand]); - - if (schemaEntry) { - return { - command: subcommand, - ...schemaEntry, - globalOptions: SCHEMA.globalOptions, - chains: SCHEMA.chains, - smartMoneyLabels: SCHEMA.smartMoneyLabels - }; + + if (subcommand) { + const schemaEntry = SCHEMA.commands[subcommand] || SCHEMA.commands.research?.subcommands[subcommand]; + if (schemaEntry) { + return { + command: subcommand, + ...schemaEntry, + globalOptions: SCHEMA.globalOptions, + chains: SCHEMA.chains, + smartMoneyLabels: SCHEMA.smartMoneyLabels + }; + } + // Unknown subcommand — return an error instead of silently dumping full schema + throw Object.assign( + new Error(`Unknown schema command: ${subcommand}. Available: ${Object.keys(SCHEMA.commands).join(', ')}`), + { code: 'UNKNOWN_COMMAND', status: null } + ); } if (flags.full) { @@ -876,7 +883,7 @@ export function buildCommands(deps = {}) { 'help': () => ({ commands: ['netflow', 'dex-trades', 'perp-trades', 'holdings', 'dcas', 'historical-holdings'], description: 'Smart Money analytics endpoints', - example: 'nansen smart-money netflow --chain solana --labels Fund' + example: 'nansen research smart-money netflow --chain solana --labels Fund' }) }; @@ -967,7 +974,7 @@ export function buildCommands(deps = {}) { 'help': () => ({ commands: ['balance', 'labels', 'transactions', 'pnl', 'search', 'historical-balances', 'related-wallets', 'counterparties', 'pnl-summary', 'perp-positions', 'perp-trades', 'batch', 'trace', 'compare'], description: 'Wallet profiling endpoints', - example: 'nansen profiler balance --address 0x123... --chain ethereum' + example: 'nansen research profiler balance --address 0x123... --chain ethereum' }) }; @@ -1055,7 +1062,7 @@ export function buildCommands(deps = {}) { 'help': () => ({ commands: ['info', 'ohlcv', 'screener', 'holders', 'flows', 'dex-trades', 'pnl', 'who-bought-sold', 'flow-intelligence', 'transfers', 'jup-dca', 'perp-trades', 'perp-positions', 'perp-pnl-leaderboard'], description: 'Token God Mode endpoints', - example: 'nansen token screener --chain solana --timeframe 24h --smart-money' + example: 'nansen research token screener --chain solana --timeframe 24h --smart-money' }) }; @@ -1083,7 +1090,7 @@ export function buildCommands(deps = {}) { 'help': () => ({ commands: ['defi', 'defi-holdings'], description: 'Portfolio analytics endpoints', - example: 'nansen portfolio defi --wallet 0x123...' + example: 'nansen research portfolio defi --wallet 0x123...' }) }; @@ -1107,7 +1114,7 @@ export function buildCommands(deps = {}) { 'help': () => ({ commands: ['screener', 'leaderboard'], description: 'Perpetual futures analytics endpoints', - example: 'nansen perp screener --days 7 --limit 20' + example: 'nansen research perp screener --days 7 --limit 20' }) }; @@ -1137,7 +1144,7 @@ export function buildCommands(deps = {}) { 'help': () => ({ commands: ['leaderboard'], description: 'Nansen Points analytics endpoints', - example: 'nansen points leaderboard --limit 100' + example: 'nansen research points leaderboard --limit 100' }) }; @@ -1257,7 +1264,9 @@ export function generateSubcommandHelp(command, subcommand) { const exampleValues = { address: '0x...', token: '0x...', query: '"term"', symbol: 'BTC', date: '2024-01-01' }; const chain = subSchema.options?.chain?.default || 'solana'; - let example = `nansen ${command} ${subcommand}`; + // Prepend 'research' prefix for research subcategories (e.g. smart-money, profiler, token) + const isResearchCategory = !SCHEMA.commands[command] && !!SCHEMA.commands.research?.subcommands[command]; + let example = `nansen ${isResearchCategory ? `research ${command}` : command} ${subcommand}`; if (subSchema.options) { for (const [name, opt] of Object.entries(subSchema.options)) { if (opt.required) example += ` --${name} ${exampleValues[name] || ''}`; diff --git a/src/schema.json b/src/schema.json index 2d7328f0..9b50d9fb 100644 --- a/src/schema.json +++ b/src/schema.json @@ -1444,10 +1444,147 @@ "wallet": { "type": "string", "description": "Wallet name, or \"walletconnect\"/\"wc\" for WalletConnect (EVM only)" + }, + "quote": { + "type": "string", + "required": true, + "description": "Quote ID from 'nansen trade quote' (stored locally, expires after 1 hour)" } } } } + }, + "wallet": { + "description": "Local wallet management for trading and API payments (EVM + Solana)", + "subcommands": { + "create": { + "description": "Generate a new EVM + Solana keypair and store it locally", + "options": { + "name": { + "type": "string", + "description": "Wallet name (default: 'default')" + } + }, + "envVars": { + "NANSEN_WALLET_PASSWORD": "Wallet encryption password (min 12 chars). If unset, interactive prompt is shown." + }, + "returns": [ + "name", + "evm", + "solana", + "isDefault" + ] + }, + "list": { + "description": "List all locally stored wallets", + "options": {}, + "returns": [ + "wallets", + "defaultWallet" + ] + }, + "show": { + "description": "Show details for a specific wallet", + "options": { + "name": { + "type": "string", + "required": true, + "description": "Wallet name" + } + }, + "returns": [ + "name", + "evm", + "solana", + "createdAt", + "isDefault" + ] + }, + "default": { + "description": "Set the default wallet used by trade commands", + "options": { + "name": { + "type": "string", + "required": true, + "description": "Wallet name to set as default" + } + }, + "returns": [ + "defaultWallet" + ] + }, + "delete": { + "description": "Delete a wallet from local storage", + "options": { + "name": { + "type": "string", + "required": true, + "description": "Wallet name to delete" + } + }, + "envVars": { + "NANSEN_WALLET_PASSWORD": "Required to decrypt and confirm deletion" + }, + "returns": [ + "deleted", + "newDefault" + ] + }, + "send": { + "description": "Send native tokens or USDC from a local wallet", + "options": { + "to": { + "type": "string", + "required": true, + "description": "Recipient address" + }, + "amount": { + "type": "string", + "required": true, + "description": "Amount to send (in token units, not base units)" + }, + "chain": { + "type": "string", + "required": true, + "description": "Chain (solana or base)" + }, + "wallet": { + "type": "string", + "description": "Wallet name (default: default wallet)" + }, + "token": { + "type": "string", + "description": "Token symbol to send (SOL, ETH, USDC). Defaults to native token." + } + }, + "envVars": { + "NANSEN_WALLET_PASSWORD": "Required to decrypt the signing key" + }, + "returns": [ + "txHash", + "chain", + "explorerUrl" + ] + }, + "export": { + "description": "Export wallet private keys (use with caution)", + "options": { + "name": { + "type": "string", + "required": true, + "description": "Wallet name" + } + }, + "envVars": { + "NANSEN_WALLET_PASSWORD": "Required to decrypt the wallet" + }, + "returns": [ + "name", + "evm", + "solana" + ] + } + } } }, "globalOptions": { diff --git a/src/trading.js b/src/trading.js index 67cc1006..888a6fdd 100644 --- a/src/trading.js +++ b/src/trading.js @@ -773,39 +773,16 @@ export function buildTradingCommands(deps = {}) { const swapMode = options['swap-mode'] || 'exactIn'; if (!chain || !from || !to || !amount) { - errorOutput(` -Usage: nansen trade quote --chain --from --to --amount - -PREREQUISITE: - A wallet must be configured before using this command (the trading API builds - a transaction specific to your sender address). - Set one up with: nansen wallet create - -OPTIONS: - --chain Chain: solana, base - --from Input token (symbol like SOL, USDC or address) - --to Output token (symbol like USDC, ETH or address) - --amount Amount in BASE UNITS (e.g. lamports, wei) - --wallet Wallet name (default: default wallet). Use "walletconnect" or "wc" for WalletConnect (EVM only). - --slippage Slippage as decimal (e.g. 0.03 for 3%). Default: 0.03 - --auto-slippage Enable auto slippage calculation - --max-auto-slippage Max auto slippage when auto-slippage enabled - --swap-mode exactIn (default) or exactOut - -EXAMPLES: - nansen trade quote --chain solana --from SOL --to USDC --amount 1000000000 - nansen trade quote --chain base --from ETH --to USDC --amount 1000000000000000000 - nansen trade quote --chain solana --from So11111111111111111111111111111111111111112 --to EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v --amount 1000000000 -`); - exit(1); - return; + const missing = ['chain', 'from', 'to', 'amount'].filter(k => !({ chain, from, to, amount }[k])); + throw Object.assign( + new Error(`Missing required options: --${missing.join(', --')}. Usage: nansen trade quote --chain --from --to --amount `), + { code: 'MISSING_PARAM', status: null } + ); } const amountError = validateBaseUnitAmount(amount); if (amountError) { - errorOutput(`Error: ${amountError}`); - exit(1); - return; + throw Object.assign(new Error(amountError), { code: 'INVALID_AMOUNT', status: null }); } try { @@ -817,15 +794,11 @@ EXAMPLES: let walletAddress; if (isWalletConnect) { if (chainType !== 'evm') { - errorOutput('WalletConnect is only supported for EVM chains'); - exit(1); - return; + throw Object.assign(new Error('WalletConnect is only supported for EVM chains'), { code: 'UNSUPPORTED_CHAIN', status: null }); } walletAddress = await getWalletConnectAddress(); if (!walletAddress) { - errorOutput('No WalletConnect session active. Run: walletconnect connect'); - exit(1); - return; + throw Object.assign(new Error('No WalletConnect session active. Run: walletconnect connect'), { code: 'WALLET_REQUIRED', status: null }); } } else if (walletName) { const wallet = showWallet(walletName); @@ -839,9 +812,10 @@ EXAMPLES: } if (!walletAddress) { - errorOutput('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'); - exit(1); - return; + throw Object.assign( + new Error('No wallet configured. A wallet address is required because the trading API builds a transaction specific to the sender. Create one with: nansen wallet create'), + { code: 'WALLET_REQUIRED', status: null } + ); } errorOutput(`\nFetching quote on ${chainConfig.name}...`); @@ -865,12 +839,11 @@ EXAMPLES: const response = await getQuote(params); if (!response.success || !response.quotes?.length) { - errorOutput('No quotes available'); - if (response.warnings?.length) { - response.warnings.forEach(w => errorOutput(` Warning: ${w}`)); - } - exit(1); - return; + const warnings = response.warnings?.length ? response.warnings : []; + throw Object.assign( + new Error('No quotes available for this swap'), + { code: 'NO_QUOTES', status: null, details: warnings.length ? { warnings } : null } + ); } errorOutput(''); @@ -889,16 +862,37 @@ EXAMPLES: } errorOutput(''); - return undefined; // Output already printed above + + // Return structured data for JSON output (human-readable summary already sent to stderr above) + return { + quoteId, + chain, + walletAddress, + executeCommand: `nansen trade execute --quote ${quoteId}`, + quotes: response.quotes.map(q => ({ + aggregator: q.aggregator, + inAmount: q.inAmount, + outAmount: q.outAmount, + inputMint: q.inputMint, + outputMint: q.outputMint, + inUsdValue: q.inUsdValue, + outUsdValue: q.outUsdValue, + priceImpactPct: q.priceImpactPct, + tradingFeeInUsd: q.tradingFeeInUsd, + networkFeeInUsd: q.networkFeeInUsd, + requiresApproval: !!(q.approvalAddress && !isNativeToken(q.inputMint)), + })), + }; } catch (err) { - let message = err.message; - if (err.code === 'INVALID_AMOUNT' || /amount/i.test(err.message)) { - message += '. Amounts must be in base units (e.g., 1000000000 lamports for 1 SOL, 1000000000000000000 wei for 1 ETH)'; + // Re-throw so the main runner outputs a structured JSON error to stdout + if (err.code === 'INVALID_AMOUNT' || (!err.code && /amount/i.test(err.message))) { + throw Object.assign( + new Error(err.message + '. Amounts must be in base units (e.g. 1000000000 lamports = 1 SOL, 1000000000000000000 wei = 1 ETH)'), + { code: 'INVALID_AMOUNT', status: err.status || null, details: err.details || null } + ); } - errorOutput(`Error: ${message}`); - if (err.details) errorOutput(` Details: ${JSON.stringify(err.details)}`); - exit(1); + throw err; } }, @@ -908,19 +902,10 @@ EXAMPLES: const noSimulate = flags['no-simulate'] || flags.noSimulate; if (!quoteId) { - errorOutput(` -Usage: nansen trade execute --quote [options] - -OPTIONS: - --quote Quote ID from 'nansen quote' - --wallet Wallet name (default: default wallet) - --no-simulate Skip pre-broadcast simulation - -EXAMPLES: - nansen trade execute --quote 1708900000000-abc123 -`); - exit(1); - return; + throw Object.assign( + new Error("Missing required option: --quote . Get a quote first with: nansen trade quote --chain --from --to --amount "), + { code: 'MISSING_PARAM', status: null } + ); } try { @@ -931,9 +916,7 @@ EXAMPLES: const allQuotes = quoteData.response.quotes || []; if (!allQuotes.length) { - errorOutput('❌ No quote data found'); - exit(1); - return; + throw Object.assign(new Error('No quote data found in stored quote'), { code: 'INVALID_QUOTE', status: null }); } // --quote-index pins a specific quote (no fallback) @@ -944,10 +927,10 @@ EXAMPLES: // Check if any quote in range has transaction data before prompting for password const hasAnyTransaction = allQuotes.slice(startIndex, endIndex).some(q => q?.transaction); if (!hasAnyTransaction) { - errorOutput('❌ No quotes contain transaction data.'); - errorOutput(' Ensure userWalletAddress was provided when fetching the quote.'); - exit(1); - return; + throw Object.assign( + new Error('No quotes contain transaction data. Ensure userWalletAddress was provided when fetching the quote.'), + { code: 'INVALID_QUOTE', status: null } + ); } // Determine if this is a WalletConnect-signed quote @@ -965,32 +948,27 @@ EXAMPLES: effectiveWalletName = list.defaultWallet; } if (!effectiveWalletName) { - errorOutput('No wallet found. Create one with: nansen wallet create'); - exit(1); - return; + throw Object.assign(new Error('No wallet found. Create one with: nansen wallet create'), { code: 'WALLET_REQUIRED', status: null }); } exported = exportWallet(effectiveWalletName, password); } else { // Verify WalletConnect session is still active and address matches quote if (chainType !== 'evm') { - errorOutput('WalletConnect is only supported for EVM chains'); - exit(1); - return; + throw Object.assign(new Error('WalletConnect is only supported for EVM chains'), { code: 'UNSUPPORTED_CHAIN', status: null }); } const wcAddress = await getWalletConnectAddress(); if (!wcAddress) { - errorOutput('No WalletConnect session active. Run: walletconnect connect'); - exit(1); - return; + throw Object.assign(new Error('No WalletConnect session active. Run: walletconnect connect'), { code: 'WALLET_REQUIRED', status: null }); } // Check address matches the one used during quoting const quoteWallet = quoteData.response?.quotes?.[0]?.transaction?.from || quoteData.response?.metadata?.userWalletAddress; if (quoteWallet && wcAddress.toLowerCase() !== quoteWallet.toLowerCase()) { - errorOutput(`Connected wallet (${wcAddress}) doesn't match quote. Get a new quote with --wallet walletconnect`); - exit(1); - return; + throw Object.assign( + new Error(`Connected wallet (${wcAddress}) does not match the wallet used when fetching the quote. Get a new quote with --wallet walletconnect`), + { code: 'WALLET_MISMATCH', status: null } + ); } } @@ -1159,8 +1137,7 @@ EXAMPLES: lastQuoteError = `${quoteName} reverted on-chain`; continue; } - exit(1); - return; + throw Object.assign(new Error(`Transaction reverted on-chain: ${receiptErr.message}. Tx: ${wcResult.txHash}`), { code: 'TRADE_REVERTED', status: null }); } errorOutput(`\n ✓ Transaction successful!`); @@ -1168,7 +1145,13 @@ EXAMPLES: errorOutput(` Chain: ${chainConfig.name}`); errorOutput(` Explorer: ${chainConfig.explorer}${wcResult.txHash}`); errorOutput(''); - return undefined; // Success + return { + status: 'Success', + txHash: wcResult.txHash, + chain: chainConfig.name, + explorerUrl: chainConfig.explorer + wcResult.txHash, + signer: 'walletconnect', + }; } // Wallet returned signedTransaction — fall through to broadcast via Trading API @@ -1333,10 +1316,10 @@ EXAMPLES: lastQuoteError = `${quoteName} reverted on-chain`; continue; } - errorOutput(`\n The trading API reported success, but the contract execution failed.`); - errorOutput(` This can happen due to: stale quotes, insufficient gas, or liquidity changes.`); - exit(1); - return; + throw Object.assign( + new Error(`Transaction reverted on-chain: ${receiptErr.message}. This can happen due to stale quotes, insufficient gas, or liquidity changes. Tx: ${result.txHash}`), + { code: 'TRADE_REVERTED', status: null } + ); } } @@ -1354,7 +1337,17 @@ EXAMPLES: }); } errorOutput(''); - return undefined; // Success — done + + // Return structured data for JSON output (human-readable summary already sent to stderr above) + return { + status: result.status, + txHash: txId, + chain: chainConfig.name, + chainType: result.chainType, + broadcaster: result.broadcaster, + explorerUrl, + swapEvents: result.swapEvents || [], + }; } else { errorOutput(`\n ✗ Quote ${quoteName} failed: ${result.status}`); if (result.error) errorOutput(` Error: ${result.error}`); @@ -1370,15 +1363,14 @@ EXAMPLES: } // All quotes exhausted - errorOutput(`\n❌ All quotes failed. Last error: ${lastQuoteError || 'unknown'}`); - errorOutput(''); - exit(1); - return undefined; + throw Object.assign( + new Error(`All quotes failed. Last error: ${lastQuoteError || 'unknown'}`), + { code: 'TRADE_FAILED', status: null } + ); } catch (err) { - errorOutput(`Error: ${err.message}`); - if (err.details) errorOutput(` Details: ${JSON.stringify(err.details)}`); - exit(1); + // Re-throw so the main runner (cli.js) outputs a structured JSON error to stdout + throw err; } }, }; From 4181e920892355c01959b52ef3e9c75e12186e95 Mon Sep 17 00:00:00 2001 From: "Nicolai Sondergaard (Openclaw Bot)" Date: Mon, 2 Mar 2026 09:59:12 +0000 Subject: [PATCH 2/6] fix: lint errors and update tests for throw-based error handling - Remove unused 'exit' from buildTradingCommands deps destructuring - Replace no-useless-catch in execute handler with meaningful normalization (codeless errors get TRADE_ERROR code for consistent downstream handling) - Update 17 tests that expected old errorOutput+exit pattern to expect structured throws (MISSING_PARAM, WALLET_REQUIRED, UNSUPPORTED_CHAIN, INVALID_AMOUNT, INVALID_QUOTE, TRADE_FAILED, UNKNOWN_COMMAND) - Update schema unknown-command test to expect error throw - Update deprecated quote route test to expect error type result - Wrap 'pass validation' execute tests in try/catch (downstream fails without network but we only care the safety check passed) npm run lint: 0 errors npm test: 682 passed, 0 failed, 2 skipped Co-Authored-By: Nicolai Sondergaard (Openclaw Bot) <93130718+nansen-devops@users.noreply.github.com> --- .gitignore | 1 + package-lock.json | 4 +- src/__tests__/cli.internal.test.js | 19 +-- src/__tests__/trading.test.js | 183 ++++++++++------------------- src/trading.js | 7 +- 5 files changed, 81 insertions(+), 133 deletions(-) diff --git a/.gitignore b/.gitignore index 272b4e35..872cd434 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ node_modules/ .vscode/ .idea/ coverage/ +.worktrees/ diff --git a/package-lock.json b/package-lock.json index e5771151..4cc68631 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nansen-cli", - "version": "1.9.3", + "version": "1.10.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nansen-cli", - "version": "1.9.3", + "version": "1.10.1", "license": "MIT", "bin": { "nansen": "src/index.js" diff --git a/src/__tests__/cli.internal.test.js b/src/__tests__/cli.internal.test.js index 2f2d623a..5a5cc17f 100644 --- a/src/__tests__/cli.internal.test.js +++ b/src/__tests__/cli.internal.test.js @@ -1171,13 +1171,13 @@ describe('schema command', () => { expect(result.globalOptions).toBeDefined(); }); - it('should return full schema for unknown command', async () => { + it('should throw structured error for unknown schema command', async () => { const commands = buildCommands({}); - const result = await commands.schema(['unknown'], null, {}, {}); - - // Returns full schema when command not found - expect(result.version).toBeDefined(); - expect(result.commands).toBeDefined(); + // Unknown schema subcommand now throws instead of silently dumping full schema + await expect(commands.schema(['unknown'], null, {}, {})).rejects.toMatchObject({ + code: 'UNKNOWN_COMMAND', + message: expect.stringContaining('Unknown schema command: unknown'), + }); }); it('should output JSON', async () => { @@ -2414,10 +2414,11 @@ describe('deprecation warnings', () => { errorOutput: (msg) => errors.push(msg), exit: () => {} }; - // quote with no args shows its help; confirms handler was reached + // quote with no args now throws MISSING_PARAM; confirms handler was reached const result = await runCLI(['quote'], deps); - expect(errors.some(e => e.includes('nansen trade quote'))).toBe(true); - expect(result.type).toBe('no-output'); + // The handler was reached and returned a structured error (not no-output) + expect(result.type).toBe('error'); + expect(result.data?.error).toMatch(/Missing required options/i); }); }); diff --git a/src/__tests__/trading.test.js b/src/__tests__/trading.test.js index 0354be35..9c928b17 100644 --- a/src/__tests__/trading.test.js +++ b/src/__tests__/trading.test.js @@ -542,55 +542,37 @@ describe('buildApprovalTransaction', () => { // ============= CLI Command Validation ============= describe('buildTradingCommands', () => { - it('should show help when required params missing for quote', async () => { - const logs = []; - let exitCalled = false; - const cmds = buildTradingCommands({ - errorOutput: (msg) => logs.push(msg), - exit: () => { exitCalled = true; }, + it('should throw structured error when required params missing for quote', async () => { + const cmds = buildTradingCommands({ errorOutput: () => {} }); + await expect(cmds.quote([], null, {}, {})).rejects.toMatchObject({ + code: 'MISSING_PARAM', + message: expect.stringContaining('Missing required options'), }); - - await cmds.quote([], null, {}, {}); - expect(exitCalled).toBe(true); - expect(logs.some(l => l.includes('Usage: nansen trade quote'))).toBe(true); }); - it('should show help when quote-id missing for execute', async () => { - const logs = []; - let exitCalled = false; - const cmds = buildTradingCommands({ - errorOutput: (msg) => logs.push(msg), - exit: () => { exitCalled = true; }, + it('should throw structured error when quote-id missing for execute', async () => { + const cmds = buildTradingCommands({ errorOutput: () => {} }); + await expect(cmds.execute([], null, {}, {})).rejects.toMatchObject({ + code: 'MISSING_PARAM', + message: expect.stringContaining('Missing required option: --quote'), }); - - await cmds.execute([], null, {}, {}); - expect(exitCalled).toBe(true); - expect(logs.some(l => l.includes('Usage: nansen trade execute'))).toBe(true); }); - it('should error when no wallet exists for quote', async () => { - const logs = []; - let exitCalled = false; - - // Mock fetch for the API call + it('should throw structured error when no wallet exists for quote', async () => { const origFetch = global.fetch; global.fetch = vi.fn().mockResolvedValue({ ok: true, text: async () => JSON.stringify({ success: true, quotes: [{ aggregator: 'test' }] }), }); - const cmds = buildTradingCommands({ - errorOutput: (msg) => logs.push(msg), - exit: () => { exitCalled = true; }, - }); - - await cmds.quote([], null, {}, { - chain: 'solana', from: 'So111', to: 'EPjFW', amount: '1000', + const cmds = buildTradingCommands({ errorOutput: () => {} }); + await expect( + cmds.quote([], null, {}, { chain: 'solana', from: 'So111', to: 'EPjFW', amount: '1000' }) + ).rejects.toMatchObject({ + code: 'WALLET_REQUIRED', + message: expect.stringContaining('No wallet'), }); - expect(exitCalled).toBe(true); - expect(logs.some(l => l.includes('No wallet') || l.includes('No default wallet'))).toBe(true); - global.fetch = origFetch; }); @@ -617,7 +599,7 @@ describe('buildTradingCommands', () => { exit: () => {}, }); - await cmds.execute([], null, {}, { quote: quoteId }); + await expect(cmds.execute([], null, {}, { quote: quoteId })).rejects.toBeDefined(); expect(logs.some(l => l.includes('non-zero tx.value'))).toBe(true); delete process.env.NANSEN_WALLET_PASSWORD; @@ -645,7 +627,7 @@ describe('buildTradingCommands', () => { exit: () => {}, }); - await cmds.execute([], null, {}, { quote: quoteId }); + await expect(cmds.execute([], null, {}, { quote: quoteId })).rejects.toBeDefined(); expect(logs.some(l => l.includes('value mismatch'))).toBe(true); delete process.env.NANSEN_WALLET_PASSWORD; @@ -673,7 +655,8 @@ describe('buildTradingCommands', () => { exit: () => {}, }); - await cmds.execute([], null, {}, { quote: quoteId }); + // May throw downstream (no network) — absorb; we only care it passed the safety check + try { await cmds.execute([], null, {}, { quote: quoteId }); } catch (_) {} // Should NOT hit the value validation rejection expect(logs.some(l => l.includes('non-zero tx.value'))).toBe(false); expect(logs.some(l => l.includes('value mismatch'))).toBe(false); @@ -703,7 +686,8 @@ describe('buildTradingCommands', () => { exit: () => {}, }); - await cmds.execute([], null, {}, { quote: quoteId }); + // May throw downstream (no network) — absorb; we only care it passed the safety check + try { await cmds.execute([], null, {}, { quote: quoteId }); } catch (_) {} // Should NOT hit the value validation rejection expect(logs.some(l => l.includes('non-zero tx.value'))).toBe(false); expect(logs.some(l => l.includes('value mismatch'))).toBe(false); @@ -733,29 +717,23 @@ describe('buildTradingCommands', () => { exit: () => {}, }); - await cmds.execute([], null, {}, { quote: quoteId }); + await expect(cmds.execute([], null, {}, { quote: quoteId })).rejects.toBeDefined(); expect(logs.some(l => l.includes('value mismatch'))).toBe(true); delete process.env.NANSEN_WALLET_PASSWORD; }); - it('should error when execute loads a quote without transaction data', async () => { - // Save a quote without transaction field + it('should throw structured error when execute loads a quote without transaction data', async () => { const quoteId = saveQuote({ success: true, quotes: [{ aggregator: 'test', inAmount: '100' }], // no .transaction }, 'solana'); - const logs = []; - let exitCalled = false; - const cmds = buildTradingCommands({ - errorOutput: (msg) => logs.push(msg), - exit: () => { exitCalled = true; }, + const cmds = buildTradingCommands({ errorOutput: () => {} }); + await expect(cmds.execute([], null, {}, { quote: quoteId })).rejects.toMatchObject({ + code: 'INVALID_QUOTE', + message: expect.stringContaining('transaction data'), }); - - await cmds.execute([], null, {}, { quote: quoteId }); - expect(exitCalled).toBe(true); - expect(logs.some(l => l.includes('transaction data'))).toBe(true); }); }); @@ -782,75 +760,57 @@ describe('WalletConnect quote support', () => { expect(loaded.signerType).toBe('local'); }); - it('should reject Solana + walletconnect for quote', async () => { + it('should throw structured error for Solana + walletconnect for quote', async () => { vi.spyOn(wcTrading, 'getWalletConnectAddress').mockResolvedValue('0x742d35Cc6bF4F3f4e0e3a8DD7e37ff4e4Be4E4B4'); + const cmds = buildTradingCommands({ errorOutput: () => {} }); - const logs = []; - let exitCalled = false; - const cmds = buildTradingCommands({ - errorOutput: (msg) => logs.push(msg), - exit: () => { exitCalled = true; }, - }); - - await cmds.quote([], null, {}, { + await expect(cmds.quote([], null, {}, { chain: 'solana', from: 'So11111111111111111111111111111111111111112', to: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', amount: '1000000000', wallet: 'walletconnect', + })).rejects.toMatchObject({ + code: 'UNSUPPORTED_CHAIN', + message: expect.stringContaining('WalletConnect is only supported for EVM chains'), }); - expect(exitCalled).toBe(true); - expect(logs.some(l => l.includes('WalletConnect is only supported for EVM chains'))).toBe(true); - vi.restoreAllMocks(); }); - it('should error when no WalletConnect session for quote', async () => { + it('should throw structured error when no WalletConnect session for quote', async () => { vi.spyOn(wcTrading, 'getWalletConnectAddress').mockResolvedValue(null); + const cmds = buildTradingCommands({ errorOutput: () => {} }); - const logs = []; - let exitCalled = false; - const cmds = buildTradingCommands({ - errorOutput: (msg) => logs.push(msg), - exit: () => { exitCalled = true; }, - }); - - await cmds.quote([], null, {}, { + await expect(cmds.quote([], null, {}, { chain: 'base', from: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', to: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', amount: '1000000000000000000', wallet: 'walletconnect', + })).rejects.toMatchObject({ + code: 'WALLET_REQUIRED', + message: expect.stringContaining('No WalletConnect session active'), }); - expect(exitCalled).toBe(true); - expect(logs.some(l => l.includes('No WalletConnect session active'))).toBe(true); - vi.restoreAllMocks(); }); - it('should accept "wc" as walletconnect alias for quote', async () => { + it('should treat "wc" as walletconnect alias and throw same error for quote', async () => { vi.spyOn(wcTrading, 'getWalletConnectAddress').mockResolvedValue(null); + const cmds = buildTradingCommands({ errorOutput: () => {} }); - const logs = []; - let exitCalled = false; - const cmds = buildTradingCommands({ - errorOutput: (msg) => logs.push(msg), - exit: () => { exitCalled = true; }, - }); - - await cmds.quote([], null, {}, { + await expect(cmds.quote([], null, {}, { chain: 'base', from: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', to: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', amount: '1000000000000000000', wallet: 'wc', + })).rejects.toMatchObject({ + code: 'WALLET_REQUIRED', + message: expect.stringContaining('No WalletConnect session active'), }); - expect(exitCalled).toBe(true); - expect(logs.some(l => l.includes('No WalletConnect session active'))).toBe(true); - vi.restoreAllMocks(); }); }); @@ -898,7 +858,7 @@ describe('WalletConnect execute support', () => { vi.restoreAllMocks(); }); - it('should error when WC session expired during execute', async () => { + it('should throw structured error when WC session expired during execute', async () => { vi.spyOn(wcTrading, 'getWalletConnectAddress').mockResolvedValue(null); const quoteId = saveQuote({ @@ -909,22 +869,16 @@ describe('WalletConnect execute support', () => { }], }, 'base', 'walletconnect'); - const logs = []; - let exitCalled = false; - const cmds = buildTradingCommands({ - errorOutput: (msg) => logs.push(msg), - exit: () => { exitCalled = true; }, + const cmds = buildTradingCommands({ errorOutput: () => {} }); + await expect(cmds.execute([], null, {}, { quote: quoteId })).rejects.toMatchObject({ + code: 'WALLET_REQUIRED', + message: expect.stringContaining('No WalletConnect session active'), }); - await cmds.execute([], null, {}, { quote: quoteId }); - - expect(exitCalled).toBe(true); - expect(logs.some(l => l.includes('No WalletConnect session active'))).toBe(true); - vi.restoreAllMocks(); }); - it('should reject Solana + walletconnect for execute', async () => { + it('should throw structured error for Solana + walletconnect for execute', async () => { const quoteId = saveQuote({ success: true, quotes: [{ @@ -933,17 +887,11 @@ describe('WalletConnect execute support', () => { }], }, 'solana', 'walletconnect'); - const logs = []; - let exitCalled = false; - const cmds = buildTradingCommands({ - errorOutput: (msg) => logs.push(msg), - exit: () => { exitCalled = true; }, + const cmds = buildTradingCommands({ errorOutput: () => {} }); + await expect(cmds.execute([], null, {}, { quote: quoteId })).rejects.toMatchObject({ + code: 'UNSUPPORTED_CHAIN', + message: expect.stringContaining('WalletConnect is only supported for EVM chains'), }); - - await cmds.execute([], null, {}, { quote: quoteId }); - - expect(exitCalled).toBe(true); - expect(logs.some(l => l.includes('WalletConnect is only supported for EVM chains'))).toBe(true); }); }); @@ -1040,25 +988,20 @@ describe('validateBaseUnitAmount', () => { }); describe('quote handler rejects decimal amounts before API call', () => { - it('should error on decimal amount and not call fetch', async () => { + it('should throw structured error on decimal amount and not call fetch', async () => { const origFetch = global.fetch; global.fetch = vi.fn(); - const logs = []; - let exitCalled = false; - const cmds = buildTradingCommands({ - errorOutput: (msg) => logs.push(msg), - exit: () => { exitCalled = true; }, - }); + const cmds = buildTradingCommands({ errorOutput: () => {} }); - await cmds.quote([], null, {}, { + await expect(cmds.quote([], null, {}, { chain: 'solana', from: 'So111', to: 'EPjFW', amount: '0.005', + })).rejects.toMatchObject({ + code: 'INVALID_AMOUNT', + message: expect.stringContaining('base units'), }); - expect(exitCalled).toBe(true); - expect(logs.some(l => l.includes('base units'))).toBe(true); expect(global.fetch).not.toHaveBeenCalled(); - global.fetch = origFetch; }); }); diff --git a/src/trading.js b/src/trading.js index 888a6fdd..a4616377 100644 --- a/src/trading.js +++ b/src/trading.js @@ -756,7 +756,7 @@ export function formatQuote(quote, index) { * Build trading command handlers for CLI integration. */ export function buildTradingCommands(deps = {}) { - const { errorOutput = console.error, exit = process.exit } = deps; + const { errorOutput = console.error } = deps; return { 'quote': async (args, apiInstance, flags, options) => { @@ -1369,7 +1369,10 @@ export function buildTradingCommands(deps = {}) { ); } catch (err) { - // Re-throw so the main runner (cli.js) outputs a structured JSON error to stdout + // Normalize errors without a code so the main runner can format them consistently + if (!err.code) { + throw Object.assign(new Error(err.message), { code: 'TRADE_ERROR', status: err.status || null, details: err.details || null }); + } throw err; } }, From 00720aaaa3267057d7d9d39f113bc28bb56aad1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolai=20S=C3=B8ndergaard?= <100568658+Nicolai1205@users.noreply.github.com> Date: Mon, 2 Mar 2026 10:03:08 +0000 Subject: [PATCH 3/6] fix: add comments to empty catch blocks to satisfy no-empty lint rule --- src/__tests__/trading.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/__tests__/trading.test.js b/src/__tests__/trading.test.js index 9c928b17..43771666 100644 --- a/src/__tests__/trading.test.js +++ b/src/__tests__/trading.test.js @@ -656,7 +656,7 @@ describe('buildTradingCommands', () => { }); // May throw downstream (no network) — absorb; we only care it passed the safety check - try { await cmds.execute([], null, {}, { quote: quoteId }); } catch (_) {} + try { await cmds.execute([], null, {}, { quote: quoteId }); } catch (_) { /* absorb downstream errors */ } // Should NOT hit the value validation rejection expect(logs.some(l => l.includes('non-zero tx.value'))).toBe(false); expect(logs.some(l => l.includes('value mismatch'))).toBe(false); @@ -687,7 +687,7 @@ describe('buildTradingCommands', () => { }); // May throw downstream (no network) — absorb; we only care it passed the safety check - try { await cmds.execute([], null, {}, { quote: quoteId }); } catch (_) {} + try { await cmds.execute([], null, {}, { quote: quoteId }); } catch (_) { /* absorb downstream errors */ } // Should NOT hit the value validation rejection expect(logs.some(l => l.includes('non-zero tx.value'))).toBe(false); expect(logs.some(l => l.includes('value mismatch'))).toBe(false); From f22a3a4c3d45909b1df04e43426de17c68e11d21 Mon Sep 17 00:00:00 2001 From: "Nicolai Sondergaard (Openclaw Bot)" Date: Mon, 2 Mar 2026 10:17:07 +0000 Subject: [PATCH 4/6] test: success-path coverage, schema wallet, strengthen assertions - GAP 1: Add trade quote success-path test verifying structured return (quoteId, chain, walletAddress, executeCommand, quotes array) - GAP 2: Add schema('wallet') test asserting all 7 subcommand keys - GAP 3: Replace 3x weak rejects.toBeDefined() with rejects.toMatchObject({ code: 'TRADE_FAILED' }) - GAP 4: Already covered (fetch NOT called assertion exists) Co-Authored-By: Claude Opus 4.6 Co-Authored-By: Nicolai Sondergaard (Openclaw Bot) <93130718+nansen-devops@users.noreply.github.com> --- src/__tests__/cli.internal.test.js | 11 +++++++ src/__tests__/trading.test.js | 48 ++++++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/__tests__/cli.internal.test.js b/src/__tests__/cli.internal.test.js index 5a5cc17f..0dae9ed0 100644 --- a/src/__tests__/cli.internal.test.js +++ b/src/__tests__/cli.internal.test.js @@ -1171,6 +1171,17 @@ describe('schema command', () => { expect(result.globalOptions).toBeDefined(); }); + it('should return wallet subcommands for schema("wallet")', async () => { + const commands = buildCommands({}); + const result = await commands.schema(['wallet'], null, {}, {}); + + expect(result.command).toBe('wallet'); + expect(result.subcommands).toBeDefined(); + expect(Object.keys(result.subcommands)).toEqual( + expect.arrayContaining(['create', 'list', 'show', 'default', 'delete', 'send', 'export']) + ); + }); + it('should throw structured error for unknown schema command', async () => { const commands = buildCommands({}); // Unknown schema subcommand now throws instead of silently dumping full schema diff --git a/src/__tests__/trading.test.js b/src/__tests__/trading.test.js index 43771666..01d2d25a 100644 --- a/src/__tests__/trading.test.js +++ b/src/__tests__/trading.test.js @@ -558,6 +558,48 @@ describe('buildTradingCommands', () => { }); }); + it('should return structured data on successful quote', async () => { + // Create a wallet so the quote handler can resolve it + createWallet('default', 'testpass'); + process.env.NANSEN_WALLET_PASSWORD = 'testpass'; + + const origFetch = global.fetch; + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + text: async () => JSON.stringify({ + success: true, + quotes: [{ + aggregator: 'jupiter', + inputMint: 'So11111111111111111111111111111111111111112', + outputMint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + inAmount: '1000000000', + outAmount: '150000000', + inUsdValue: '150.00', + outUsdValue: '150.00', + transaction: 'AQAAAA==', + }], + }), + }); + + const cmds = buildTradingCommands({ errorOutput: () => {} }); + const result = await cmds.quote([], null, {}, { + chain: 'solana', + from: 'So11111111111111111111111111111111111111112', + to: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + amount: '1000000000', + }); + + expect(result.quoteId).toMatch(/^\d+-[a-f0-9]+$/); + expect(result.chain).toBe('solana'); + expect(result.walletAddress).toBeDefined(); + expect(result.executeCommand).toContain('nansen trade execute'); + expect(result.quotes.length).toBeGreaterThanOrEqual(1); + expect(result.quotes[0].aggregator).toBe('jupiter'); + + global.fetch = origFetch; + delete process.env.NANSEN_WALLET_PASSWORD; + }); + it('should throw structured error when no wallet exists for quote', async () => { const origFetch = global.fetch; global.fetch = vi.fn().mockResolvedValue({ @@ -599,7 +641,7 @@ describe('buildTradingCommands', () => { exit: () => {}, }); - await expect(cmds.execute([], null, {}, { quote: quoteId })).rejects.toBeDefined(); + await expect(cmds.execute([], null, {}, { quote: quoteId })).rejects.toMatchObject({ code: 'TRADE_FAILED' }); expect(logs.some(l => l.includes('non-zero tx.value'))).toBe(true); delete process.env.NANSEN_WALLET_PASSWORD; @@ -627,7 +669,7 @@ describe('buildTradingCommands', () => { exit: () => {}, }); - await expect(cmds.execute([], null, {}, { quote: quoteId })).rejects.toBeDefined(); + await expect(cmds.execute([], null, {}, { quote: quoteId })).rejects.toMatchObject({ code: 'TRADE_FAILED' }); expect(logs.some(l => l.includes('value mismatch'))).toBe(true); delete process.env.NANSEN_WALLET_PASSWORD; @@ -717,7 +759,7 @@ describe('buildTradingCommands', () => { exit: () => {}, }); - await expect(cmds.execute([], null, {}, { quote: quoteId })).rejects.toBeDefined(); + await expect(cmds.execute([], null, {}, { quote: quoteId })).rejects.toMatchObject({ code: 'TRADE_FAILED' }); expect(logs.some(l => l.includes('value mismatch'))).toBe(true); delete process.env.NANSEN_WALLET_PASSWORD; From 00bc4a0c27f011179fd77b245b0370cb331c5418 Mon Sep 17 00:00:00 2001 From: "Nicolai Sondergaard (Openclaw Bot)" Date: Mon, 2 Mar 2026 10:27:19 +0000 Subject: [PATCH 5/6] refactor: simplify error handling and remove redundant wrapping - Remove `status: null` from 18 client-side validation throws (formatError already defaults to null) - Remove dead `err.code === 'INVALID_AMOUNT'` branch in quote catch (thrown before the try block, can never reach catch) - Remove unused `details: err.details || null` from catch re-throws (caught errors never have .details, and formatError reads .data not .details) - Shorten verbose error messages: WALLET_REQUIRED, execute MISSING_PARAM, TRADE_REVERTED (remove implementation details and speculative explanations) Co-Authored-By: Claude Opus 4.6 Co-Authored-By: Nicolai Sondergaard (Openclaw Bot) <93130718+nansen-devops@users.noreply.github.com> --- src/cli.js | 2 +- src/trading.js | 46 +++++++++++++++++++++++----------------------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/src/cli.js b/src/cli.js index d52a6525..02211ad6 100644 --- a/src/cli.js +++ b/src/cli.js @@ -816,7 +816,7 @@ export function buildCommands(deps = {}) { // Unknown subcommand — return an error instead of silently dumping full schema throw Object.assign( new Error(`Unknown schema command: ${subcommand}. Available: ${Object.keys(SCHEMA.commands).join(', ')}`), - { code: 'UNKNOWN_COMMAND', status: null } + { code: 'UNKNOWN_COMMAND' } ); } diff --git a/src/trading.js b/src/trading.js index a4616377..ee350a12 100644 --- a/src/trading.js +++ b/src/trading.js @@ -776,13 +776,13 @@ export function buildTradingCommands(deps = {}) { const missing = ['chain', 'from', 'to', 'amount'].filter(k => !({ chain, from, to, amount }[k])); throw Object.assign( new Error(`Missing required options: --${missing.join(', --')}. Usage: nansen trade quote --chain --from --to --amount `), - { code: 'MISSING_PARAM', status: null } + { code: 'MISSING_PARAM' } ); } const amountError = validateBaseUnitAmount(amount); if (amountError) { - throw Object.assign(new Error(amountError), { code: 'INVALID_AMOUNT', status: null }); + throw Object.assign(new Error(amountError), { code: 'INVALID_AMOUNT' }); } try { @@ -794,11 +794,11 @@ export function buildTradingCommands(deps = {}) { let walletAddress; if (isWalletConnect) { if (chainType !== 'evm') { - throw Object.assign(new Error('WalletConnect is only supported for EVM chains'), { code: 'UNSUPPORTED_CHAIN', status: null }); + throw Object.assign(new Error('WalletConnect is only supported for EVM chains'), { code: 'UNSUPPORTED_CHAIN' }); } walletAddress = await getWalletConnectAddress(); if (!walletAddress) { - throw Object.assign(new Error('No WalletConnect session active. Run: walletconnect connect'), { code: 'WALLET_REQUIRED', status: null }); + throw Object.assign(new Error('No WalletConnect session active. Run: walletconnect connect'), { code: 'WALLET_REQUIRED' }); } } else if (walletName) { const wallet = showWallet(walletName); @@ -813,8 +813,8 @@ export function buildTradingCommands(deps = {}) { if (!walletAddress) { throw Object.assign( - new Error('No wallet configured. A wallet address is required because the trading API builds a transaction specific to the sender. Create one with: nansen wallet create'), - { code: 'WALLET_REQUIRED', status: null } + new Error('No wallet configured. Create one with: nansen wallet create'), + { code: 'WALLET_REQUIRED' } ); } @@ -842,7 +842,7 @@ export function buildTradingCommands(deps = {}) { const warnings = response.warnings?.length ? response.warnings : []; throw Object.assign( new Error('No quotes available for this swap'), - { code: 'NO_QUOTES', status: null, details: warnings.length ? { warnings } : null } + { code: 'NO_QUOTES', details: warnings.length ? { warnings } : null } ); } @@ -885,11 +885,11 @@ export function buildTradingCommands(deps = {}) { }; } catch (err) { - // Re-throw so the main runner outputs a structured JSON error to stdout - if (err.code === 'INVALID_AMOUNT' || (!err.code && /amount/i.test(err.message))) { + // Augment uncoded amount-related errors with base-unit guidance + if (!err.code && /amount/i.test(err.message)) { throw Object.assign( new Error(err.message + '. Amounts must be in base units (e.g. 1000000000 lamports = 1 SOL, 1000000000000000000 wei = 1 ETH)'), - { code: 'INVALID_AMOUNT', status: err.status || null, details: err.details || null } + { code: 'INVALID_AMOUNT', status: err.status || null } ); } throw err; @@ -903,8 +903,8 @@ export function buildTradingCommands(deps = {}) { if (!quoteId) { throw Object.assign( - new Error("Missing required option: --quote . Get a quote first with: nansen trade quote --chain --from --to --amount "), - { code: 'MISSING_PARAM', status: null } + new Error("Missing required option: --quote . Get a quote first with: nansen trade quote"), + { code: 'MISSING_PARAM' } ); } @@ -916,7 +916,7 @@ export function buildTradingCommands(deps = {}) { const allQuotes = quoteData.response.quotes || []; if (!allQuotes.length) { - throw Object.assign(new Error('No quote data found in stored quote'), { code: 'INVALID_QUOTE', status: null }); + throw Object.assign(new Error('No quote data found in stored quote'), { code: 'INVALID_QUOTE' }); } // --quote-index pins a specific quote (no fallback) @@ -929,7 +929,7 @@ export function buildTradingCommands(deps = {}) { if (!hasAnyTransaction) { throw Object.assign( new Error('No quotes contain transaction data. Ensure userWalletAddress was provided when fetching the quote.'), - { code: 'INVALID_QUOTE', status: null } + { code: 'INVALID_QUOTE' } ); } @@ -948,18 +948,18 @@ export function buildTradingCommands(deps = {}) { effectiveWalletName = list.defaultWallet; } if (!effectiveWalletName) { - throw Object.assign(new Error('No wallet found. Create one with: nansen wallet create'), { code: 'WALLET_REQUIRED', status: null }); + throw Object.assign(new Error('No wallet found. Create one with: nansen wallet create'), { code: 'WALLET_REQUIRED' }); } exported = exportWallet(effectiveWalletName, password); } else { // Verify WalletConnect session is still active and address matches quote if (chainType !== 'evm') { - throw Object.assign(new Error('WalletConnect is only supported for EVM chains'), { code: 'UNSUPPORTED_CHAIN', status: null }); + throw Object.assign(new Error('WalletConnect is only supported for EVM chains'), { code: 'UNSUPPORTED_CHAIN' }); } const wcAddress = await getWalletConnectAddress(); if (!wcAddress) { - throw Object.assign(new Error('No WalletConnect session active. Run: walletconnect connect'), { code: 'WALLET_REQUIRED', status: null }); + throw Object.assign(new Error('No WalletConnect session active. Run: walletconnect connect'), { code: 'WALLET_REQUIRED' }); } // Check address matches the one used during quoting const quoteWallet = quoteData.response?.quotes?.[0]?.transaction?.from @@ -967,7 +967,7 @@ export function buildTradingCommands(deps = {}) { if (quoteWallet && wcAddress.toLowerCase() !== quoteWallet.toLowerCase()) { throw Object.assign( new Error(`Connected wallet (${wcAddress}) does not match the wallet used when fetching the quote. Get a new quote with --wallet walletconnect`), - { code: 'WALLET_MISMATCH', status: null } + { code: 'WALLET_MISMATCH' } ); } } @@ -1137,7 +1137,7 @@ export function buildTradingCommands(deps = {}) { lastQuoteError = `${quoteName} reverted on-chain`; continue; } - throw Object.assign(new Error(`Transaction reverted on-chain: ${receiptErr.message}. Tx: ${wcResult.txHash}`), { code: 'TRADE_REVERTED', status: null }); + throw Object.assign(new Error(`Transaction reverted on-chain: ${receiptErr.message}. Tx: ${wcResult.txHash}`), { code: 'TRADE_REVERTED' }); } errorOutput(`\n ✓ Transaction successful!`); @@ -1317,8 +1317,8 @@ export function buildTradingCommands(deps = {}) { continue; } throw Object.assign( - new Error(`Transaction reverted on-chain: ${receiptErr.message}. This can happen due to stale quotes, insufficient gas, or liquidity changes. Tx: ${result.txHash}`), - { code: 'TRADE_REVERTED', status: null } + new Error(`Transaction reverted on-chain: ${receiptErr.message}. Tx: ${result.txHash}`), + { code: 'TRADE_REVERTED' } ); } } @@ -1365,13 +1365,13 @@ export function buildTradingCommands(deps = {}) { // All quotes exhausted throw Object.assign( new Error(`All quotes failed. Last error: ${lastQuoteError || 'unknown'}`), - { code: 'TRADE_FAILED', status: null } + { code: 'TRADE_FAILED' } ); } catch (err) { // Normalize errors without a code so the main runner can format them consistently if (!err.code) { - throw Object.assign(new Error(err.message), { code: 'TRADE_ERROR', status: err.status || null, details: err.details || null }); + throw Object.assign(new Error(err.message), { code: 'TRADE_ERROR', status: err.status || null }); } throw err; } From aa68c23d1110b05634ccd567e91ffea4c60cf8be Mon Sep 17 00:00:00 2001 From: "Nicolai Sondergaard (Openclaw Bot)" Date: Mon, 2 Mar 2026 10:51:52 +0000 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20address=20review=20comments=20?= =?UTF-8?q?=E2=80=94=20revert=20lock=20version,=20preserve=20stack=20trace?= =?UTF-8?q?,=20omit=20null=20details?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Revert package-lock.json version bump (1.10.1 → 1.9.3) — changesets handles versioning - Preserve original stack trace in error normalization (mutate instead of new Error) - Omit details key when null instead of setting details: null (NO_QUOTES) - Strengthen pass-validation test catch blocks to assert no validation error codes - Remove out-of-scope .worktrees/ gitignore addition Co-Authored-By: Claude Opus 4.6 Co-Authored-By: Nicolai Sondergaard (Openclaw Bot) <93130718+nansen-devops@users.noreply.github.com> --- .gitignore | 1 - package-lock.json | 4 ++-- src/__tests__/trading.test.js | 4 ++-- src/trading.js | 12 ++++++------ 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index 872cd434..272b4e35 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,3 @@ node_modules/ .vscode/ .idea/ coverage/ -.worktrees/ diff --git a/package-lock.json b/package-lock.json index 4cc68631..e5771151 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nansen-cli", - "version": "1.10.1", + "version": "1.9.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nansen-cli", - "version": "1.10.1", + "version": "1.9.3", "license": "MIT", "bin": { "nansen": "src/index.js" diff --git a/src/__tests__/trading.test.js b/src/__tests__/trading.test.js index 01d2d25a..b87d76a9 100644 --- a/src/__tests__/trading.test.js +++ b/src/__tests__/trading.test.js @@ -698,7 +698,7 @@ describe('buildTradingCommands', () => { }); // May throw downstream (no network) — absorb; we only care it passed the safety check - try { await cmds.execute([], null, {}, { quote: quoteId }); } catch (_) { /* absorb downstream errors */ } + try { await cmds.execute([], null, {}, { quote: quoteId }); } catch (e) { expect(e?.code).not.toMatch(/^(MISSING_PARAM|WALLET_REQUIRED|INVALID_AMOUNT|UNSUPPORTED_CHAIN|INVALID_QUOTE)$/); } // Should NOT hit the value validation rejection expect(logs.some(l => l.includes('non-zero tx.value'))).toBe(false); expect(logs.some(l => l.includes('value mismatch'))).toBe(false); @@ -729,7 +729,7 @@ describe('buildTradingCommands', () => { }); // May throw downstream (no network) — absorb; we only care it passed the safety check - try { await cmds.execute([], null, {}, { quote: quoteId }); } catch (_) { /* absorb downstream errors */ } + try { await cmds.execute([], null, {}, { quote: quoteId }); } catch (e) { expect(e?.code).not.toMatch(/^(MISSING_PARAM|WALLET_REQUIRED|INVALID_AMOUNT|UNSUPPORTED_CHAIN|INVALID_QUOTE)$/); } // Should NOT hit the value validation rejection expect(logs.some(l => l.includes('non-zero tx.value'))).toBe(false); expect(logs.some(l => l.includes('value mismatch'))).toBe(false); diff --git a/src/trading.js b/src/trading.js index ee350a12..9bce09b0 100644 --- a/src/trading.js +++ b/src/trading.js @@ -842,7 +842,7 @@ export function buildTradingCommands(deps = {}) { const warnings = response.warnings?.length ? response.warnings : []; throw Object.assign( new Error('No quotes available for this swap'), - { code: 'NO_QUOTES', details: warnings.length ? { warnings } : null } + { code: 'NO_QUOTES', ...(warnings.length ? { details: { warnings } } : {}) } ); } @@ -887,10 +887,9 @@ export function buildTradingCommands(deps = {}) { } catch (err) { // Augment uncoded amount-related errors with base-unit guidance if (!err.code && /amount/i.test(err.message)) { - throw Object.assign( - new Error(err.message + '. Amounts must be in base units (e.g. 1000000000 lamports = 1 SOL, 1000000000000000000 wei = 1 ETH)'), - { code: 'INVALID_AMOUNT', status: err.status || null } - ); + err.message += '. Amounts must be in base units (e.g. 1000000000 lamports = 1 SOL, 1000000000000000000 wei = 1 ETH)'; + err.code = 'INVALID_AMOUNT'; + err.status = err.status || null; } throw err; } @@ -1371,7 +1370,8 @@ export function buildTradingCommands(deps = {}) { } catch (err) { // Normalize errors without a code so the main runner can format them consistently if (!err.code) { - throw Object.assign(new Error(err.message), { code: 'TRADE_ERROR', status: err.status || null }); + err.code = 'TRADE_ERROR'; + err.status = err.status || null; } throw err; }