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/__tests__/cli.internal.test.js b/src/__tests__/cli.internal.test.js index 2f2d623a..0dae9ed0 100644 --- a/src/__tests__/cli.internal.test.js +++ b/src/__tests__/cli.internal.test.js @@ -1171,13 +1171,24 @@ describe('schema command', () => { expect(result.globalOptions).toBeDefined(); }); - it('should return full schema for unknown command', async () => { + it('should return wallet subcommands for schema("wallet")', 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(); + 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 + 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 +2425,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..b87d76a9 100644 --- a/src/__tests__/trading.test.js +++ b/src/__tests__/trading.test.js @@ -542,54 +542,78 @@ 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; + 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'; - // Mock fetch for the API call const origFetch = global.fetch; global.fetch = vi.fn().mockResolvedValue({ ok: true, - text: async () => JSON.stringify({ success: true, quotes: [{ aggregator: 'test' }] }), + 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: (msg) => logs.push(msg), - exit: () => { exitCalled = true; }, + const cmds = buildTradingCommands({ errorOutput: () => {} }); + const result = await cmds.quote([], null, {}, { + chain: 'solana', + from: 'So11111111111111111111111111111111111111112', + to: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + amount: '1000000000', }); - await cmds.quote([], null, {}, { - chain: 'solana', from: 'So111', to: 'EPjFW', amount: '1000', + 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({ + ok: true, + text: async () => JSON.stringify({ success: true, quotes: [{ aggregator: 'test' }] }), }); - expect(exitCalled).toBe(true); - expect(logs.some(l => l.includes('No wallet') || l.includes('No default wallet'))).toBe(true); + 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'), + }); global.fetch = origFetch; }); @@ -617,7 +641,7 @@ describe('buildTradingCommands', () => { exit: () => {}, }); - await cmds.execute([], null, {}, { quote: quoteId }); + 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; @@ -645,7 +669,7 @@ describe('buildTradingCommands', () => { exit: () => {}, }); - await cmds.execute([], null, {}, { quote: quoteId }); + 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; @@ -673,7 +697,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 (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); @@ -703,7 +728,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 (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); @@ -733,29 +759,23 @@ describe('buildTradingCommands', () => { exit: () => {}, }); - await cmds.execute([], null, {}, { quote: quoteId }); + 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; }); - 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 +802,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 +900,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 +911,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 +929,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 +1030,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/cli.js b/src/cli.js index 35e2ed2e..02211ad6 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' } + ); } 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..9bce09b0 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) => { @@ -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' } + ); } const amountError = validateBaseUnitAmount(amount); if (amountError) { - errorOutput(`Error: ${amountError}`); - exit(1); - return; + throw Object.assign(new Error(amountError), { code: 'INVALID_AMOUNT' }); } 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' }); } 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' }); } } 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. Create one with: nansen wallet create'), + { code: 'WALLET_REQUIRED' } + ); } 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', ...(warnings.length ? { details: { warnings } } : {}) } + ); } errorOutput(''); @@ -889,16 +862,36 @@ 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)'; + // Augment uncoded amount-related errors with base-unit guidance + if (!err.code && /amount/i.test(err.message)) { + 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; } - errorOutput(`Error: ${message}`); - if (err.details) errorOutput(` Details: ${JSON.stringify(err.details)}`); - exit(1); + throw err; } }, @@ -908,19 +901,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"), + { code: 'MISSING_PARAM' } + ); } try { @@ -931,9 +915,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' }); } // --quote-index pins a specific quote (no fallback) @@ -944,10 +926,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' } + ); } // Determine if this is a WalletConnect-signed quote @@ -965,32 +947,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' }); } 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' }); } 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' }); } // 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' } + ); } } @@ -1159,8 +1136,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' }); } errorOutput(`\n ✓ Transaction successful!`); @@ -1168,7 +1144,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 +1315,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}. Tx: ${result.txHash}`), + { code: 'TRADE_REVERTED' } + ); } } @@ -1354,7 +1336,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 +1362,18 @@ 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' } + ); } catch (err) { - errorOutput(`Error: ${err.message}`); - if (err.details) errorOutput(` Details: ${JSON.stringify(err.details)}`); - exit(1); + // Normalize errors without a code so the main runner can format them consistently + if (!err.code) { + err.code = 'TRADE_ERROR'; + err.status = err.status || null; + } + throw err; } }, };