nansen-cli 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/trading.js ADDED
@@ -0,0 +1,1081 @@
1
+ /**
2
+ * Nansen CLI - Trading Commands
3
+ * Quote and execute DEX swaps via the Nansen Trading API.
4
+ * Supports Solana and EVM chains (Ethereum, Base, BSC).
5
+ * Zero external dependencies — uses Node.js built-in crypto only.
6
+ */
7
+
8
+ import crypto from 'crypto';
9
+ import fs from 'fs';
10
+ import path from 'path';
11
+ import { exportWallet, getDefaultAddress, showWallet, listWallets } from './wallet.js';
12
+ import { base58Decode } from './transfer.js';
13
+ import { keccak256, signSecp256k1, rlpEncode } from './crypto.js';
14
+
15
+ // ============= Constants =============
16
+
17
+ const TRADING_API_URL = process.env.NANSEN_TRADING_API_URL || 'https://trading-api.nansen.ai';
18
+
19
+ const CHAIN_MAP = {
20
+ solana: { index: '501', type: 'solana', chainId: 501, name: 'Solana', explorer: 'https://solscan.io/tx/' },
21
+ ethereum: { index: '1', type: 'evm', chainId: 1, name: 'Ethereum', explorer: 'https://etherscan.io/tx/' },
22
+ base: { index: '8453', type: 'evm', chainId: 8453, name: 'Base', explorer: 'https://basescan.org/tx/' },
23
+ bsc: { index: '56', type: 'evm', chainId: 56, name: 'BSC', explorer: 'https://bscscan.com/tx/' },
24
+ };
25
+
26
+ // Default public RPC endpoints (used for nonce fetching)
27
+ const EVM_RPC_URLS = {
28
+ ethereum: process.env.NANSEN_RPC_ETHEREUM || 'https://eth.llamarpc.com',
29
+ base: process.env.NANSEN_RPC_BASE || 'https://mainnet.base.org',
30
+ bsc: process.env.NANSEN_RPC_BSC || 'https://bsc-dataseed.binance.org',
31
+ };
32
+
33
+ function getQuotesDir() {
34
+ const configDir = path.join(process.env.HOME || process.env.USERPROFILE || '', '.nansen');
35
+ return path.join(configDir, 'quotes');
36
+ }
37
+
38
+ // ============= Trading API Client =============
39
+
40
+ /**
41
+ * Get a trading quote from the Nansen Trading API.
42
+ * Returns quotes with transaction data ready for signing.
43
+ *
44
+ * @param {object} params - Query parameters for GET /quote
45
+ * @returns {Promise<object>} Quote response with quotes[].transaction
46
+ */
47
+ export async function getQuote(params) {
48
+ const url = new URL('/quote', TRADING_API_URL);
49
+ for (const [key, value] of Object.entries(params)) {
50
+ if (value !== undefined && value !== null) {
51
+ url.searchParams.set(key, String(value));
52
+ }
53
+ }
54
+
55
+ const headers = { 'Accept': 'application/json' };
56
+ if (process.env.NANSEN_API_KEY) {
57
+ headers['Authorization'] = `Bearer ${process.env.NANSEN_API_KEY}`;
58
+ }
59
+
60
+ const res = await fetch(url.toString(), { headers });
61
+
62
+ const text = await res.text();
63
+ let body;
64
+ try {
65
+ body = JSON.parse(text);
66
+ } catch {
67
+ throw Object.assign(
68
+ new Error(`Quote API returned non-JSON response (status ${res.status}). This may be a Cloudflare challenge or server error.`),
69
+ { code: 'NON_JSON_RESPONSE', status: res.status, details: text.slice(0, 200) }
70
+ );
71
+ }
72
+
73
+ if (!res.ok) {
74
+ const code = body.code || 'QUOTE_ERROR';
75
+ const msg = body.message || `Quote request failed with status ${res.status}`;
76
+ throw Object.assign(new Error(msg), { code, status: res.status, details: body.details });
77
+ }
78
+
79
+ return body;
80
+ }
81
+
82
+ /**
83
+ * Broadcast a signed transaction via the Nansen Trading API.
84
+ *
85
+ * @param {object} params
86
+ * @param {string} params.signedTransaction - Base64 (Solana) or 0x hex (EVM)
87
+ * @param {string} [params.chain] - Target chain name
88
+ * @param {string} [params.requestId] - Optional Jupiter request ID (Solana only)
89
+ * @param {boolean} [params.simulate] - Run pre-broadcast simulation
90
+ * @returns {Promise<object>} Execution result
91
+ */
92
+ export async function executeTransaction(params, { retries = 2, retryDelayMs = 1500 } = {}) {
93
+ const headers = {
94
+ 'Content-Type': 'application/json',
95
+ 'Accept': 'application/json',
96
+ };
97
+ if (process.env.NANSEN_API_KEY) {
98
+ headers['Authorization'] = `Bearer ${process.env.NANSEN_API_KEY}`;
99
+ }
100
+
101
+ let lastError;
102
+ for (let attempt = 0; attempt <= retries; attempt++) {
103
+ if (attempt > 0) {
104
+ await new Promise(r => setTimeout(r, retryDelayMs));
105
+ }
106
+
107
+ const res = await fetch(`${TRADING_API_URL}/execute`, {
108
+ method: 'POST',
109
+ headers,
110
+ body: JSON.stringify(params),
111
+ });
112
+
113
+ const text = await res.text();
114
+ let body;
115
+ try {
116
+ body = JSON.parse(text);
117
+ } catch {
118
+ const chainType = params.chain && CHAIN_MAP[params.chain]?.type;
119
+ const feeHint = res.status === 502
120
+ ? chainType === 'solana'
121
+ ? ' This often means the transaction failed simulation — check that you have enough SOL for fees (~0.005 SOL minimum).'
122
+ : chainType === 'evm'
123
+ ? ' This often means the transaction failed simulation — check that you have enough ETH for gas fees.'
124
+ : ''
125
+ : '';
126
+ lastError = Object.assign(
127
+ new Error(`Execute API returned non-JSON response (status ${res.status}).${feeHint || ' This may be a Cloudflare challenge or server error.'}`),
128
+ { code: 'BROADCAST_FAILED', status: res.status, details: text.slice(0, 200) }
129
+ );
130
+ // Retry on 502/503 (likely transient Cloudflare issues)
131
+ if ((res.status === 502 || res.status === 503) && attempt < retries) continue;
132
+ throw lastError;
133
+ }
134
+
135
+ if (!res.ok) {
136
+ const code = body.code || 'EXECUTE_ERROR';
137
+ const msg = body.message || `Execute request failed with status ${res.status}`;
138
+ throw Object.assign(new Error(msg), { code, status: res.status, details: body.details });
139
+ }
140
+
141
+ return body;
142
+ }
143
+ throw lastError;
144
+ }
145
+
146
+ // ============= Quote Storage =============
147
+
148
+ /**
149
+ * Save a quote response to disk for later execution.
150
+ * @returns {string} Quote ID
151
+ */
152
+ export function saveQuote(quoteResponse, chain) {
153
+ const dir = getQuotesDir();
154
+ if (!fs.existsSync(dir)) {
155
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
156
+ }
157
+
158
+ const timestamp = Date.now();
159
+ const hash = crypto.randomBytes(4).toString('hex');
160
+ const quoteId = `${timestamp}-${hash}`;
161
+
162
+ const data = { quoteId, chain, timestamp, response: quoteResponse };
163
+
164
+ fs.writeFileSync(path.join(dir, `${quoteId}.json`), JSON.stringify(data, null, 2), { mode: 0o600 });
165
+ cleanupQuotes();
166
+ return quoteId;
167
+ }
168
+
169
+ /**
170
+ * Load a saved quote by ID.
171
+ */
172
+ export function loadQuote(quoteId) {
173
+ const filePath = path.join(getQuotesDir(), `${quoteId}.json`);
174
+ if (!fs.existsSync(filePath)) {
175
+ throw new Error(`Quote "${quoteId}" not found. Quotes expire after 1 hour.`);
176
+ }
177
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
178
+ if (Date.now() - data.timestamp > 3600000) {
179
+ fs.unlinkSync(filePath);
180
+ throw new Error('Quote has expired. Please request a new quote.');
181
+ }
182
+ return data;
183
+ }
184
+
185
+ /**
186
+ * Remove quotes older than 1 hour.
187
+ */
188
+ export function cleanupQuotes() {
189
+ const dir = getQuotesDir();
190
+ if (!fs.existsSync(dir)) return;
191
+ const now = Date.now();
192
+ for (const file of fs.readdirSync(dir)) {
193
+ if (!file.endsWith('.json')) continue;
194
+ try {
195
+ const data = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'));
196
+ if (now - data.timestamp > 3600000) fs.unlinkSync(path.join(dir, file));
197
+ } catch { /* ignore */ }
198
+ }
199
+ }
200
+
201
+ // ============= Transaction Signing =============
202
+
203
+ // ----------------------------------------------------------------
204
+ // TODO: SECURITY REVIEW REQUIRED
205
+ // The signing functions below construct and sign raw transactions.
206
+ // They MUST be audited before any production/mainnet use.
207
+ // ----------------------------------------------------------------
208
+
209
+ /**
210
+ * Sign a Solana transaction from quote data.
211
+ *
212
+ * The trading API returns a base64-encoded serialized VersionedTransaction
213
+ * in quote.transaction. We deserialize, sign with Ed25519, re-serialize.
214
+ *
215
+ * Based on the e2e test pattern:
216
+ * const serializedTx = Buffer.from(quote.transaction, 'base64')
217
+ * const tx = VersionedTransaction.deserialize(serializedTx)
218
+ * tx.sign([signer])
219
+ *
220
+ * We replicate this without @solana/web3.js using raw crypto.
221
+ *
222
+ * @param {string} transactionBase64 - Base64-encoded serialized VersionedTransaction
223
+ * @param {string} privateKeyHex - 128-char hex (64 bytes: seed + pubkey)
224
+ * @returns {string} Base64-encoded signed transaction
225
+ */
226
+ // ⚠️ SECURITY: Solana transaction signing - requires thorough review before production use
227
+ export function signSolanaTransaction(transactionBase64, privateKeyHex) {
228
+ const txBytes = Buffer.from(transactionBase64, 'base64');
229
+
230
+ // Extract Ed25519 seed (first 32 bytes of the 64-byte keypair)
231
+ const seed = Buffer.from(privateKeyHex.slice(0, 64), 'hex');
232
+
233
+ const privateKey = crypto.createPrivateKey({
234
+ key: Buffer.concat([
235
+ Buffer.from('302e020100300506032b657004220420', 'hex'), // PKCS8 Ed25519 prefix
236
+ seed,
237
+ ]),
238
+ format: 'der',
239
+ type: 'pkcs8',
240
+ });
241
+
242
+ // VersionedTransaction wire format:
243
+ // [signatures_count (compact-u16)] [signatures (64 bytes each)...] [message_bytes...]
244
+ const { value: sigCount, size: sigCountSize } = readCompactU16(txBytes, 0);
245
+ const messageOffset = sigCountSize + (sigCount * 64);
246
+ const messageBytes = txBytes.subarray(messageOffset);
247
+
248
+ // Sign the message bytes
249
+ const signature = crypto.sign(null, messageBytes, privateKey);
250
+
251
+ // Write signature into the first slot (fee payer = our wallet)
252
+ const signedTx = Buffer.from(txBytes);
253
+ signature.copy(signedTx, sigCountSize);
254
+
255
+ return signedTx.toString('base64');
256
+ }
257
+
258
+ /**
259
+ * Sign an EVM transaction from quote data.
260
+ *
261
+ * The trading API returns transaction fields in quote.transaction:
262
+ * { to, data, value?, gas?, gasPrice? }
263
+ *
264
+ * The nonce must be fetched from the chain RPC.
265
+ * Signs as a legacy (type 0) transaction with gasPrice (matching the e2e tests).
266
+ *
267
+ * @param {object} txData - Transaction fields from quote.transaction { to, data, value, gas, gasPrice }
268
+ * @param {string} privateKeyHex - 64-char hex (32-byte secp256k1 private key)
269
+ * @param {string} chain - Chain name (ethereum, base, bsc)
270
+ * @param {number} nonce - Account nonce
271
+ * @returns {string} 0x-prefixed signed transaction hex
272
+ */
273
+ // ⚠️ SECURITY: EVM transaction signing - requires thorough review before production use
274
+ // TODO: Always signs as legacy (type 0) transactions. Do we need EIP-1559 (type 2) support?
275
+ export function signEvmTransaction(txData, privateKeyHex, chain, nonce) {
276
+ const chainConfig = CHAIN_MAP[chain];
277
+ if (!chainConfig || chainConfig.type !== 'evm') {
278
+ throw new Error(`Unsupported EVM chain: ${chain}`);
279
+ }
280
+
281
+ const tx = {
282
+ nonce,
283
+ gasPrice: toHex(txData.gasPrice || txData.maxFeePerGas || '1'),
284
+ gasLimit: toHex(txData.gas || txData.gasLimit || '210000'),
285
+ to: txData.to,
286
+ value: toHex(txData.value || '0'),
287
+ data: txData.data || '0x',
288
+ chainId: chainConfig.chainId,
289
+ };
290
+
291
+ return signLegacyTransaction(tx, privateKeyHex);
292
+ }
293
+
294
+ /**
295
+ * Fetch the pending nonce for an EVM address.
296
+ * @param {string} chain - Chain name
297
+ * @param {string} address - 0x address
298
+ * @returns {Promise<number>} Nonce
299
+ */
300
+ export async function getEvmNonce(chain, address) {
301
+ const rpcUrl = EVM_RPC_URLS[chain];
302
+ if (!rpcUrl) throw new Error(`No RPC URL configured for chain: ${chain}`);
303
+
304
+ const res = await fetch(rpcUrl, {
305
+ method: 'POST',
306
+ headers: { 'Content-Type': 'application/json' },
307
+ body: JSON.stringify({
308
+ jsonrpc: '2.0',
309
+ id: 1,
310
+ method: 'eth_getTransactionCount',
311
+ params: [address, 'pending'],
312
+ }),
313
+ });
314
+ const body = await res.json();
315
+ if (body.error) throw new Error(`RPC error: ${body.error.message}`);
316
+ return parseInt(body.result, 16);
317
+ }
318
+
319
+ /**
320
+ * Wait for an EVM transaction to be confirmed on-chain.
321
+ * Polls eth_getTransactionReceipt until receipt is available or timeout.
322
+ *
323
+ * @param {string} chain - Chain name
324
+ * @param {string} txHash - Transaction hash (0x...)
325
+ * @param {number} [timeoutMs=30000] - Max wait time
326
+ * @param {number} [pollMs=2000] - Poll interval
327
+ * @returns {Promise<object>} Transaction receipt
328
+ */
329
+ export async function waitForReceipt(chain, txHash, timeoutMs = 30000, pollMs = 2000) {
330
+ const rpcUrl = EVM_RPC_URLS[chain];
331
+ if (!rpcUrl) throw new Error(`No RPC URL configured for chain: ${chain}`);
332
+
333
+ const start = Date.now();
334
+ while (Date.now() - start < timeoutMs) {
335
+ const res = await fetch(rpcUrl, {
336
+ method: 'POST',
337
+ headers: { 'Content-Type': 'application/json' },
338
+ body: JSON.stringify({
339
+ jsonrpc: '2.0',
340
+ id: 1,
341
+ method: 'eth_getTransactionReceipt',
342
+ params: [txHash],
343
+ }),
344
+ });
345
+ const body = await res.json();
346
+ if (body.result) {
347
+ const status = parseInt(body.result.status, 16);
348
+ if (status !== 1) {
349
+ throw new Error(`Transaction reverted on-chain (status: ${body.result.status}). Tx: ${txHash}`);
350
+ }
351
+ return body.result;
352
+ }
353
+ // Receipt not yet available — wait and retry
354
+ await new Promise(r => setTimeout(r, pollMs));
355
+ }
356
+ throw new Error(`Transaction receipt not found after ${timeoutMs}ms. Tx: ${txHash}`);
357
+ }
358
+
359
+ /**
360
+ * Simulate an EVM transaction via eth_call before broadcasting.
361
+ * Returns { success: true } or { success: false, reason: string }.
362
+ */
363
+ export async function simulateEvmCall(chain, { from, to, data, value, gas }) {
364
+ const rpcUrl = EVM_RPC_URLS[chain];
365
+ if (!rpcUrl) return { success: true }; // Can't simulate, skip
366
+
367
+ try {
368
+ const callObj = { from, to, data, value: value || '0x0' };
369
+ if (gas) callObj.gas = gas; // Pass gas limit to catch under-gassed quotes
370
+ const res = await fetch(rpcUrl, {
371
+ method: 'POST',
372
+ headers: { 'Content-Type': 'application/json' },
373
+ body: JSON.stringify({
374
+ jsonrpc: '2.0',
375
+ id: 1,
376
+ method: 'eth_call',
377
+ params: [callObj, 'latest'],
378
+ }),
379
+ });
380
+ const body = await res.json();
381
+ if (body.error) {
382
+ const reason = body.error.message || 'unknown';
383
+ return { success: false, reason };
384
+ }
385
+ return { success: true };
386
+ } catch {
387
+ return { success: true }; // Network error — don't block, let broadcast decide
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Estimate gas for an EVM transaction. Returns the gas estimate or null on failure.
393
+ * Used to fix under-gassed quotes from aggregators.
394
+ */
395
+ export async function estimateEvmGas(chain, { from, to, data, value }) {
396
+ const rpcUrl = EVM_RPC_URLS[chain];
397
+ if (!rpcUrl) return null;
398
+
399
+ try {
400
+ const res = await fetch(rpcUrl, {
401
+ method: 'POST',
402
+ headers: { 'Content-Type': 'application/json' },
403
+ body: JSON.stringify({
404
+ jsonrpc: '2.0',
405
+ id: 1,
406
+ method: 'eth_estimateGas',
407
+ params: [{ from, to, data, value: value || '0x0' }],
408
+ }),
409
+ });
410
+ const body = await res.json();
411
+ if (body.error) return null;
412
+ return parseInt(body.result, 16);
413
+ } catch {
414
+ return null;
415
+ }
416
+ }
417
+
418
+ /**
419
+ * Check ERC-20 allowance for a given owner/spender pair.
420
+ * Returns the allowance as a BigInt, or 0n on failure.
421
+ */
422
+ export async function checkErc20Allowance(chain, tokenAddress, ownerAddress, spenderAddress) {
423
+ const rpcUrl = EVM_RPC_URLS[chain];
424
+ if (!rpcUrl) return 0n;
425
+
426
+ try {
427
+ // allowance(address,address) selector = 0xdd62ed3e
428
+ const data = '0xdd62ed3e'
429
+ + ownerAddress.slice(2).toLowerCase().padStart(64, '0')
430
+ + spenderAddress.slice(2).toLowerCase().padStart(64, '0');
431
+ const res = await fetch(rpcUrl, {
432
+ method: 'POST',
433
+ headers: { 'Content-Type': 'application/json' },
434
+ body: JSON.stringify({
435
+ jsonrpc: '2.0',
436
+ id: 1,
437
+ method: 'eth_call',
438
+ params: [{ to: tokenAddress, data }, 'latest'],
439
+ }),
440
+ });
441
+ const body = await res.json();
442
+ if (body.error || !body.result) return 0n;
443
+ return BigInt(body.result);
444
+ } catch {
445
+ return 0n;
446
+ }
447
+ }
448
+
449
+ /**
450
+ * Send an ERC-20 approval transaction.
451
+ * Required before swapping non-native EVM tokens.
452
+ *
453
+ * @param {string} tokenAddress - ERC-20 token contract
454
+ * @param {string} spenderAddress - Approval target (from quote.approvalAddress)
455
+ * @param {string} privateKeyHex - Wallet private key
456
+ * @param {string} chain - Chain name
457
+ * @param {number} nonce - Account nonce
458
+ * @returns {string} 0x-prefixed signed approval tx hex
459
+ */
460
+ // ⚠️ SECURITY: ERC-20 approval signing - requires thorough review
461
+ export function buildApprovalTransaction(tokenAddress, spenderAddress, privateKeyHex, chain, nonce, gasPrice) {
462
+ const chainConfig = CHAIN_MAP[chain];
463
+ if (!chainConfig) throw new Error(`Unsupported chain: ${chain}`);
464
+
465
+ // ERC-20 approve(address spender, uint256 amount) selector = 0x095ea7b3
466
+ // Approve max uint256
467
+ const MAX_UINT256_HEX = 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff';
468
+ const data = '0x095ea7b3'
469
+ + spenderAddress.slice(2).toLowerCase().padStart(64, '0')
470
+ + MAX_UINT256_HEX;
471
+
472
+ const tx = {
473
+ nonce,
474
+ gasPrice: toHex(gasPrice || '1000000'),
475
+ gasLimit: '0x186a0', // 100000
476
+ to: tokenAddress,
477
+ value: '0x0',
478
+ data,
479
+ chainId: chainConfig.chainId,
480
+ };
481
+
482
+ return signLegacyTransaction(tx, privateKeyHex);
483
+ }
484
+
485
+ // ============= Legacy (Type 0) EVM Transaction Signing =============
486
+ // ⚠️ SECURITY: Legacy EVM transaction signing - requires thorough review before production use
487
+
488
+ /**
489
+ * Strip all leading zero bytes from a buffer.
490
+ * RLP requires minimal encoding, so signature r/s values must not have leading zeros.
491
+ */
492
+ export function stripLeadingZeros(buf) {
493
+ let i = 0;
494
+ while (i < buf.length && buf[i] === 0) i++;
495
+ return buf.subarray(i);
496
+ }
497
+
498
+ /**
499
+ * Sign a legacy (type 0) EVM transaction.
500
+ *
501
+ * @param {object} tx - { nonce, gasPrice, gasLimit, to, value, data, chainId }
502
+ * @param {string} privateKeyHex - 32-byte private key as hex
503
+ * @returns {string} 0x-prefixed signed transaction hex
504
+ */
505
+ export function signLegacyTransaction(tx, privateKeyHex) {
506
+ // EIP-155 unsigned: RLP([nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0])
507
+ const unsignedFields = [
508
+ rlpNormalize(tx.nonce),
509
+ rlpNormalize(tx.gasPrice),
510
+ rlpNormalize(tx.gasLimit),
511
+ toBuffer(tx.to),
512
+ rlpNormalize(tx.value),
513
+ toBuffer(tx.data || '0x'),
514
+ rlpNormalize(tx.chainId),
515
+ Buffer.alloc(0), // EIP-155: empty for signing
516
+ Buffer.alloc(0), // EIP-155: empty for signing
517
+ ];
518
+
519
+ const unsignedPayload = rlpEncode(unsignedFields);
520
+ const msgHash = keccak256(unsignedPayload);
521
+
522
+ // Sign with secp256k1
523
+ const { r, s, v: recoveryBit } = signSecp256k1(msgHash, Buffer.from(privateKeyHex, 'hex'));
524
+
525
+ // EIP-155 v = chainId * 2 + 35 + recoveryBit
526
+ const v = tx.chainId * 2 + 35 + recoveryBit;
527
+
528
+ // Signed: RLP([nonce, gasPrice, gasLimit, to, value, data, v, r, s])
529
+ const signedFields = [
530
+ rlpNormalize(tx.nonce),
531
+ rlpNormalize(tx.gasPrice),
532
+ rlpNormalize(tx.gasLimit),
533
+ toBuffer(tx.to),
534
+ rlpNormalize(tx.value),
535
+ toBuffer(tx.data || '0x'),
536
+ rlpNormalize(v),
537
+ stripLeadingZeros(r),
538
+ stripLeadingZeros(s),
539
+ ];
540
+
541
+ return '0x' + rlpEncode(signedFields).toString('hex');
542
+ }
543
+
544
+ export function toBuffer(v) {
545
+ if (Buffer.isBuffer(v)) return v;
546
+ if (typeof v === 'string') {
547
+ if (v.startsWith('0x')) {
548
+ const hex = v.slice(2);
549
+ if (hex.length === 0) return Buffer.alloc(0);
550
+ return Buffer.from(hex.padStart(hex.length + (hex.length % 2), '0'), 'hex');
551
+ }
552
+ return Buffer.from(v);
553
+ }
554
+ if (typeof v === 'number' || typeof v === 'bigint') {
555
+ if (v === 0 || v === 0n) return Buffer.alloc(0);
556
+ const hex = BigInt(v).toString(16);
557
+ return Buffer.from(hex.padStart(hex.length + (hex.length % 2), '0'), 'hex');
558
+ }
559
+ return Buffer.alloc(0);
560
+ }
561
+
562
+ /**
563
+ * Convert a value to 0x hex string. Handles decimal strings, hex strings, and numbers.
564
+ */
565
+ function toHex(val) {
566
+ if (val === undefined || val === null || val === '' || val === '0' || val === 0) return '0x0';
567
+ if (typeof val === 'string' && val.startsWith('0x')) return val;
568
+ // Decimal string or number → hex
569
+ return '0x' + BigInt(val).toString(16);
570
+ }
571
+
572
+ function rlpNormalize(val) {
573
+ if (val === undefined || val === null || val === '0x0' || val === '0x' || val === 0 || val === '0') {
574
+ return Buffer.alloc(0);
575
+ }
576
+ return toBuffer(val);
577
+ }
578
+
579
+ // ============= Compact-u16 (Solana) =============
580
+
581
+ /**
582
+ * Read a compact-u16 from a buffer (Solana transaction format).
583
+ */
584
+ export function readCompactU16(buf, offset) {
585
+ let value = 0;
586
+ let size = 0;
587
+ for (let i = 0; i < 3; i++) {
588
+ const byte = buf[offset + i];
589
+ value |= (byte & 0x7f) << (7 * i);
590
+ size++;
591
+ if ((byte & 0x80) === 0) break;
592
+ }
593
+ return { value, size };
594
+ }
595
+
596
+ // ============= Chain Utilities =============
597
+
598
+ /**
599
+ * Resolve chain name to config.
600
+ */
601
+ export function resolveChain(chainName) {
602
+ const chain = CHAIN_MAP[chainName?.toLowerCase()];
603
+ if (!chain) {
604
+ throw new Error(`Unsupported chain "${chainName}". Supported: ${Object.keys(CHAIN_MAP).join(', ')}`);
605
+ }
606
+ return chain;
607
+ }
608
+
609
+ /**
610
+ * Get wallet chain type for address lookup.
611
+ */
612
+ export function getWalletChainType(chainName) {
613
+ return resolveChain(chainName).type;
614
+ }
615
+
616
+ // ============= CLI Helpers =============
617
+
618
+ async function promptPassword(prompt, deps = {}) {
619
+ if (deps.promptFn) return deps.promptFn(prompt);
620
+ const readline = await import('readline');
621
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
622
+ return new Promise((resolve) => {
623
+ process.stderr.write(prompt);
624
+ let input = '';
625
+ const stdin = process.stdin;
626
+ const wasRaw = stdin.isRaw;
627
+ if (stdin.setRawMode) stdin.setRawMode(true);
628
+ stdin.resume();
629
+ const onData = (ch) => {
630
+ const c = ch.toString();
631
+ if (c === '\n' || c === '\r') {
632
+ if (stdin.setRawMode) stdin.setRawMode(wasRaw || false);
633
+ stdin.removeListener('data', onData);
634
+ process.stderr.write('\n');
635
+ rl.close();
636
+ resolve(input);
637
+ } else if (c === '\u0003') { rl.close(); process.exit(1); }
638
+ else if (c === '\u007f' || c === '\b') { input = input.slice(0, -1); }
639
+ else { input += c; }
640
+ };
641
+ stdin.on('data', onData);
642
+ });
643
+ }
644
+
645
+ function isNativeToken(mintAddress) {
646
+ return /^0x[eE]{40}$/.test(mintAddress);
647
+ }
648
+
649
+ function formatQuote(quote, index) {
650
+ const lines = [];
651
+ const label = index !== undefined ? ` Quote #${index + 1}` : ' Best Quote';
652
+ lines.push(`${label} (${quote.aggregator || 'unknown'})`);
653
+ lines.push(` Input: ${quote.inAmount} → ${quote.inputMint?.slice(0, 12)}...`);
654
+ lines.push(` Output: ${quote.outAmount} → ${quote.outputMint?.slice(0, 12)}...`);
655
+ if (quote.inUsdValue) lines.push(` In USD: $${quote.inUsdValue}`);
656
+ if (quote.outUsdValue) lines.push(` Out USD: $${quote.outUsdValue}`);
657
+ if (quote.priceImpactPct) lines.push(` Price Impact: ${quote.priceImpactPct}%`);
658
+ if (quote.tradingFeeInUsd) lines.push(` Trading Fee: $${quote.tradingFeeInUsd}`);
659
+ if (quote.networkFeeInUsd) lines.push(` Network Fee: $${quote.networkFeeInUsd}`);
660
+ if (quote.approvalAddress && !isNativeToken(quote.inputMint)) lines.push(` ⚠ Requires token approval to: ${quote.approvalAddress}`);
661
+ return lines.join('\n');
662
+ }
663
+
664
+ // ============= CLI Command Builder =============
665
+
666
+ /**
667
+ * Build trading command handlers for CLI integration.
668
+ */
669
+ export function buildTradingCommands(deps = {}) {
670
+ const { errorOutput = console.error, exit = process.exit } = deps;
671
+
672
+ return {
673
+ 'quote': async (args, apiInstance, flags, options) => {
674
+ const chain = options.chain || args[0];
675
+ const from = options.from || options['from-token'] || args[1];
676
+ const to = options.to || options['to-token'] || args[2];
677
+ const amount = options.amount || args[3];
678
+ const walletName = options.wallet;
679
+ const slippage = options.slippage;
680
+ const autoSlippage = flags['auto-slippage'] || flags.autoSlippage;
681
+ const maxAutoSlippage = options['max-auto-slippage'];
682
+ const swapMode = options['swap-mode'] || 'exactIn';
683
+
684
+ if (!chain || !from || !to || !amount) {
685
+ errorOutput(`
686
+ Usage: nansen quote --chain <chain> --from <token> --to <token> --amount <baseUnits>
687
+
688
+ OPTIONS:
689
+ --chain <chain> Chain: solana, ethereum, base, bsc
690
+ --from <address> Input token address
691
+ --to <address> Output token address
692
+ --amount <units> Amount in BASE UNITS (e.g. lamports, wei)
693
+ --wallet <name> Wallet name (default: default wallet)
694
+ --slippage <pct> Slippage as decimal (e.g. 0.03 for 3%). Default: 0.03
695
+ --auto-slippage Enable auto slippage calculation
696
+ --max-auto-slippage <pct> Max auto slippage when auto-slippage enabled
697
+ --swap-mode <mode> exactIn (default) or exactOut
698
+
699
+ EXAMPLES:
700
+ nansen quote --chain solana --from So11111111111111111111111111111111111111112 --to EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v --amount 1000000000
701
+ nansen quote --chain base --from 0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee --to 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 --amount 1000000000000000000
702
+ `);
703
+ exit(1);
704
+ return;
705
+ }
706
+
707
+ try {
708
+ const chainConfig = resolveChain(chain);
709
+ const chainType = chainConfig.type === 'evm' ? 'evm' : 'solana';
710
+
711
+ let walletAddress;
712
+ if (walletName) {
713
+ const wallet = showWallet(walletName);
714
+ walletAddress = chainType === 'solana' ? wallet.solana : wallet.evm;
715
+ } else {
716
+ walletAddress = getDefaultAddress(chainType);
717
+ }
718
+
719
+ if (!walletAddress) {
720
+ errorOutput('No wallet found. Create one with: nansen wallet create');
721
+ exit(1);
722
+ return;
723
+ }
724
+
725
+ errorOutput(`\nFetching quote on ${chainConfig.name}...`);
726
+ errorOutput(` Wallet: ${walletAddress}`);
727
+
728
+ const params = {
729
+ chainIndex: chainConfig.index,
730
+ fromTokenAddress: from,
731
+ toTokenAddress: to,
732
+ amount,
733
+ userWalletAddress: walletAddress,
734
+ };
735
+ if (slippage) params.slippagePercent = slippage;
736
+ if (autoSlippage) params.autoSlippage = true;
737
+ if (maxAutoSlippage) params.maxAutoSlippagePercent = maxAutoSlippage;
738
+ if (swapMode !== 'exactIn') params.swapMode = swapMode;
739
+
740
+ const response = await getQuote(params);
741
+
742
+ if (!response.success || !response.quotes?.length) {
743
+ errorOutput('No quotes available');
744
+ if (response.warnings?.length) {
745
+ response.warnings.forEach(w => errorOutput(` Warning: ${w}`));
746
+ }
747
+ exit(1);
748
+ return;
749
+ }
750
+
751
+ errorOutput('');
752
+ response.quotes.forEach((q, i) => errorOutput(formatQuote(q, i)));
753
+
754
+ const quoteId = saveQuote(response, chain);
755
+ errorOutput(`\n Quote ID: ${quoteId}`);
756
+ errorOutput(` Execute: nansen execute --quote ${quoteId}`);
757
+
758
+ if (response.quotes[0]?.approvalAddress && !isNativeToken(response.quotes[0]?.inputMint)) {
759
+ errorOutput(`\n Warning: This token swap requires an ERC-20 approval step.`);
760
+ errorOutput(` The execute command will handle this automatically.`);
761
+ }
762
+
763
+ errorOutput('');
764
+ return undefined; // Output already printed above
765
+
766
+ } catch (err) {
767
+ errorOutput(`Error: ${err.message}`);
768
+ if (err.details) errorOutput(` Details: ${JSON.stringify(err.details)}`);
769
+ exit(1);
770
+ }
771
+ },
772
+
773
+ 'execute': async (args, apiInstance, flags, options) => {
774
+ const quoteId = options.quote || options['quote-id'] || args[0];
775
+ const walletName = options.wallet;
776
+ const noSimulate = flags['no-simulate'] || flags.noSimulate;
777
+
778
+ if (!quoteId) {
779
+ errorOutput(`
780
+ Usage: nansen execute --quote <quoteId> [options]
781
+
782
+ OPTIONS:
783
+ --quote <id> Quote ID from 'nansen quote'
784
+ --wallet <name> Wallet name (default: default wallet)
785
+ --no-simulate Skip pre-broadcast simulation
786
+
787
+ EXAMPLES:
788
+ nansen execute --quote 1708900000000-abc123
789
+ `);
790
+ exit(1);
791
+ return;
792
+ }
793
+
794
+ try {
795
+ const quoteData = loadQuote(quoteId);
796
+ const chain = quoteData.chain;
797
+ const chainConfig = resolveChain(chain);
798
+ const chainType = chainConfig.type;
799
+
800
+ const allQuotes = quoteData.response.quotes || [];
801
+ if (!allQuotes.length) {
802
+ errorOutput('❌ No quote data found');
803
+ exit(1);
804
+ return;
805
+ }
806
+
807
+ // --quote-index pins a specific quote (no fallback)
808
+ const pinIndex = options['quote-index'] != null ? parseInt(options['quote-index'], 10) : null;
809
+ const startIndex = pinIndex ?? 0;
810
+ const endIndex = pinIndex != null ? startIndex + 1 : allQuotes.length;
811
+
812
+ // Check if any quote in range has transaction data before prompting for password
813
+ const hasAnyTransaction = allQuotes.slice(startIndex, endIndex).some(q => q?.transaction);
814
+ if (!hasAnyTransaction) {
815
+ errorOutput('❌ No quotes contain transaction data.');
816
+ errorOutput(' Ensure userWalletAddress was provided when fetching the quote.');
817
+ exit(1);
818
+ return;
819
+ }
820
+
821
+ // Get wallet credentials once (before the loop)
822
+ const password = process.env.NANSEN_WALLET_PASSWORD || await promptPassword('Enter wallet password: ', deps);
823
+
824
+ let effectiveWalletName = walletName;
825
+ if (!effectiveWalletName) {
826
+ const list = listWallets();
827
+ effectiveWalletName = list.defaultWallet;
828
+ }
829
+ if (!effectiveWalletName) {
830
+ errorOutput('No wallet found. Create one with: nansen wallet create');
831
+ exit(1);
832
+ return;
833
+ }
834
+
835
+ const exported = exportWallet(effectiveWalletName, password);
836
+ let lastQuoteError = null;
837
+
838
+ for (let qi = startIndex; qi < endIndex; qi++) {
839
+ const currentQuote = allQuotes[qi];
840
+ if (!currentQuote) continue;
841
+
842
+ const quoteName = currentQuote.source || currentQuote.metadata?.source || `#${qi + 1}`;
843
+
844
+ // Verify transaction data exists
845
+ if (!currentQuote.transaction) {
846
+ errorOutput(` ⚠ Quote ${quoteName}: no transaction data, skipping...`);
847
+ lastQuoteError = `Quote ${quoteName} has no transaction data`;
848
+ continue;
849
+ }
850
+
851
+ errorOutput(`\nExecuting trade on ${chainConfig.name}...`);
852
+ if (endIndex - startIndex > 1) {
853
+ errorOutput(` Trying quote ${qi + 1}/${allQuotes.length} (${quoteName})...`);
854
+ }
855
+ errorOutput(formatQuote(currentQuote));
856
+ errorOutput('');
857
+
858
+ try {
859
+ let signedTransaction;
860
+ let requestId;
861
+
862
+ if (chainType === 'solana') {
863
+ // Solana: transaction is either a base64 string (Jupiter) or an object
864
+ // with a base58-encoded `data` field (OKX). Normalize to base64.
865
+ let txBase64 = currentQuote.transaction;
866
+ if (typeof txBase64 === 'object' && txBase64.data) {
867
+ txBase64 = base58Decode(txBase64.data).toString('base64');
868
+ }
869
+ errorOutput(' Signing Solana transaction...');
870
+ signedTransaction = signSolanaTransaction(txBase64, exported.solana.privateKey);
871
+ requestId = currentQuote.metadata?.requestId;
872
+
873
+ } else {
874
+ // EVM: quote.transaction is { to, data, value, gas, gasPrice }
875
+ const walletAddress = exported.evm.address;
876
+
877
+ // Handle approval if needed — skip for native ETH
878
+ // Check existing allowance first to avoid unnecessary approve txs
879
+ // (industry standard: LiFi SDK checkAllowance, 1inch Permit2)
880
+ const isNative = isNativeToken(currentQuote.inputMint);
881
+
882
+ // Validate transaction.value matches the swap type.
883
+ // ERC-20 swaps transfer tokens via calldata, so value must be 0.
884
+ // Native ETH swaps must have value matching the quoted inAmount.
885
+ // A compromised API could attach a large value to drain ETH silently.
886
+ const txValue = BigInt(currentQuote.transaction.value || '0');
887
+ if (isNative) {
888
+ const expectedValue = BigInt(currentQuote.inAmount || currentQuote.inputAmount || '0');
889
+ if (txValue !== expectedValue) {
890
+ errorOutput(` ❌ Transaction value mismatch for ${quoteName}: tx.value=${txValue}, expected=${expectedValue}`);
891
+ if (qi + 1 < endIndex) errorOutput(` Trying next quote...`);
892
+ lastQuoteError = `${quoteName} transaction value mismatch`;
893
+ continue;
894
+ }
895
+ } else {
896
+ if (txValue > 0n) {
897
+ errorOutput(` ❌ ERC-20 swap has non-zero tx.value (${txValue}) for ${quoteName} — aborting`);
898
+ if (qi + 1 < endIndex) errorOutput(` Trying next quote...`);
899
+ lastQuoteError = `${quoteName} unexpected tx.value`;
900
+ continue;
901
+ }
902
+ }
903
+
904
+ if (currentQuote.approvalAddress && !isNative) {
905
+ // Check if sufficient allowance already exists
906
+ const inputAmount = BigInt(currentQuote.inputAmount || currentQuote.inAmount || currentQuote.transaction?.value || '0');
907
+ const existingAllowance = await checkErc20Allowance(
908
+ chain, currentQuote.inputMint, walletAddress, currentQuote.approvalAddress
909
+ );
910
+
911
+ if (existingAllowance >= inputAmount && existingAllowance > 0n) {
912
+ errorOutput(` ✓ Sufficient allowance exists for ${quoteName}, skipping approval`);
913
+ } else {
914
+ errorOutput(` ⚠ Approval required → ${currentQuote.approvalAddress}`);
915
+ errorOutput(` Sending approval tx...`);
916
+ const approvalNonce = await getEvmNonce(chain, walletAddress);
917
+
918
+ const approvalGasPrice = currentQuote.transaction?.gasPrice || currentQuote.transaction?.maxFeePerGas || '1000000';
919
+ const approvalTxHex = buildApprovalTransaction(
920
+ currentQuote.inputMint,
921
+ currentQuote.approvalAddress,
922
+ exported.evm.privateKey,
923
+ chain,
924
+ approvalNonce,
925
+ approvalGasPrice,
926
+ );
927
+
928
+ const approvalResult = await executeTransaction({
929
+ signedTransaction: approvalTxHex,
930
+ chain,
931
+ simulate: !noSimulate,
932
+ });
933
+
934
+ if (approvalResult.status !== 'Success') {
935
+ errorOutput(` ❌ Approval failed for ${quoteName}: ${approvalResult.error || 'unknown error'}`);
936
+ if (qi + 1 < endIndex) errorOutput(` Trying next quote...`);
937
+ lastQuoteError = `${quoteName} approval failed`;
938
+ continue;
939
+ }
940
+
941
+ errorOutput(` Waiting for approval confirmation...`);
942
+ try {
943
+ const receipt = await waitForReceipt(chain, approvalResult.txHash);
944
+ errorOutput(` ✓ Approval confirmed in block ${parseInt(receipt.blockNumber, 16)}: ${approvalResult.txHash}`);
945
+ } catch (receiptErr) {
946
+ errorOutput(` ❌ Approval may not have confirmed: ${receiptErr.message}`);
947
+ if (qi + 1 < endIndex) errorOutput(` Trying next quote...`);
948
+ lastQuoteError = `${quoteName} approval unconfirmed`;
949
+ continue;
950
+ }
951
+ // Wait for RPC state propagation after approval
952
+ await new Promise(r => setTimeout(r, 2000));
953
+ errorOutput('');
954
+ }
955
+ }
956
+
957
+ // Pre-flight simulation (EVM only) — catch logic reverts before spending gas
958
+ // Runs AFTER approval so eth_call sees the current allowance state
959
+ // Simulates WITHOUT gas limit to check swap logic; gas re-estimation is separate
960
+ if (!noSimulate) {
961
+ const txData = currentQuote.transaction;
962
+ const sim = await simulateEvmCall(chain, {
963
+ from: walletAddress,
964
+ to: txData.to,
965
+ data: txData.data,
966
+ value: txData.value ? '0x' + BigInt(txData.value).toString(16) : '0x0',
967
+ });
968
+ if (!sim.success) {
969
+ errorOutput(` ⚠ Simulation failed for ${quoteName}: ${sim.reason}`);
970
+ if (qi + 1 < endIndex) errorOutput(` Trying next quote...`);
971
+ lastQuoteError = `${quoteName} simulation failed: ${sim.reason}`;
972
+ continue;
973
+ }
974
+ }
975
+
976
+ // Use the Trading API's gas estimation (quote.gas) directly.
977
+ // The API already applies a 1.5x buffer over eth_estimateGas.
978
+ // Skip client-side re-estimation — it adds latency and the API value is reliable.
979
+ const txData = currentQuote.transaction;
980
+ const apiGas = parseInt(currentQuote.gas || "0");
981
+ const txGas = parseInt(txData.gas || txData.gasLimit || "0");
982
+ const finalGas = apiGas > 0 ? apiGas : txGas;
983
+ if (finalGas !== txGas) {
984
+ errorOutput(` ℹ Using API gas ${finalGas} (tx.gas was ${txGas})`);
985
+ }
986
+ if (txData.gasLimit) txData.gasLimit = String(finalGas);
987
+ else txData.gas = String(finalGas);
988
+
989
+ errorOutput(' Fetching nonce...');
990
+ await new Promise(r => setTimeout(r, 1000));
991
+ const nonce = await getEvmNonce(chain, walletAddress);
992
+ errorOutput(` Nonce: ${nonce}`);
993
+
994
+ errorOutput(' Signing EVM transaction...');
995
+ signedTransaction = signEvmTransaction(
996
+ currentQuote.transaction,
997
+ exported.evm.privateKey,
998
+ chain,
999
+ nonce
1000
+ );
1001
+ }
1002
+
1003
+ errorOutput(' Broadcasting...');
1004
+ const execParams = {
1005
+ signedTransaction,
1006
+ chain,
1007
+ simulate: !noSimulate,
1008
+ };
1009
+ if (requestId) execParams.requestId = requestId;
1010
+
1011
+ const result = await executeTransaction(execParams);
1012
+
1013
+ if (result.status === 'Success') {
1014
+ const txId = result.signature || result.txHash;
1015
+ const explorerUrl = chainConfig.explorer + txId;
1016
+
1017
+ // For EVM: verify the tx actually succeeded on-chain
1018
+ if (chainType === 'evm' && result.txHash) {
1019
+ errorOutput(' Verifying on-chain status...');
1020
+ try {
1021
+ await waitForReceipt(chain, result.txHash);
1022
+ } catch (receiptErr) {
1023
+ errorOutput(`\n ⚠ Transaction was broadcast but REVERTED on-chain!`);
1024
+ errorOutput(` Tx Hash: ${result.txHash}`);
1025
+ errorOutput(` Explorer: ${explorerUrl}`);
1026
+ errorOutput(` Error: ${receiptErr.message}`);
1027
+ if (qi + 1 < endIndex) {
1028
+ errorOutput(` Trying next quote...`);
1029
+ lastQuoteError = `${quoteName} reverted on-chain`;
1030
+ continue;
1031
+ }
1032
+ errorOutput(`\n The trading API reported success, but the contract execution failed.`);
1033
+ errorOutput(` This can happen due to: stale quotes, insufficient gas, or liquidity changes.`);
1034
+ exit(1);
1035
+ return;
1036
+ }
1037
+ }
1038
+
1039
+ errorOutput(`\n ✓ Transaction successful!`);
1040
+ errorOutput(` Status: ${result.status}`);
1041
+ errorOutput(` ${result.signature ? 'Signature' : 'Tx Hash'}: ${txId}`);
1042
+ errorOutput(` Chain: ${chainConfig.name} (${result.chainType})`);
1043
+ errorOutput(` Broadcaster: ${result.broadcaster}`);
1044
+ errorOutput(` Explorer: ${explorerUrl}`);
1045
+
1046
+ if (result.swapEvents?.length) {
1047
+ errorOutput(` Swaps:`);
1048
+ result.swapEvents.forEach(e => {
1049
+ errorOutput(` ${e.inputAmount} ${e.inputMint?.slice(0, 8)}... → ${e.outputAmount} ${e.outputMint?.slice(0, 8)}...`);
1050
+ });
1051
+ }
1052
+ errorOutput('');
1053
+ return undefined; // Success — done
1054
+ } else {
1055
+ errorOutput(`\n ✗ Quote ${quoteName} failed: ${result.status}`);
1056
+ if (result.error) errorOutput(` Error: ${result.error}`);
1057
+ lastQuoteError = `${quoteName}: ${result.error || result.status}`;
1058
+ if (qi + 1 < endIndex) errorOutput(` Trying next quote...`);
1059
+ }
1060
+
1061
+ } catch (quoteErr) {
1062
+ errorOutput(` ❌ Quote ${quoteName} failed: ${quoteErr.message}`);
1063
+ lastQuoteError = `${quoteName}: ${quoteErr.message}`;
1064
+ if (qi + 1 < endIndex) errorOutput(` Trying next quote...`);
1065
+ }
1066
+ }
1067
+
1068
+ // All quotes exhausted
1069
+ errorOutput(`\n❌ All quotes failed. Last error: ${lastQuoteError || 'unknown'}`);
1070
+ errorOutput('');
1071
+ exit(1);
1072
+ return undefined;
1073
+
1074
+ } catch (err) {
1075
+ errorOutput(`Error: ${err.message}`);
1076
+ if (err.details) errorOutput(` Details: ${JSON.stringify(err.details)}`);
1077
+ exit(1);
1078
+ }
1079
+ },
1080
+ };
1081
+ }