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/AGENTS.md +222 -0
- package/README.md +19 -1
- package/SKILL.md +25 -21
- package/TODO.md +17 -0
- package/package.json +1 -1
- package/src/api.js +40 -1
- package/src/cli.js +23 -10
- package/src/crypto.js +215 -0
- package/src/trading.js +1081 -0
- package/src/transfer.js +723 -0
- package/src/wallet.js +764 -0
- package/src/x402-evm.js +207 -0
- package/src/x402-svm.js +474 -0
- package/src/x402.js +205 -0
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
|
+
}
|