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.
@@ -0,0 +1,723 @@
1
+ /**
2
+ * Nansen CLI - Token Transfer
3
+ * Send native and ERC-20/SPL tokens on EVM and Solana chains.
4
+ * Zero external dependencies — uses Node.js built-in crypto only.
5
+ */
6
+
7
+ import crypto from 'crypto';
8
+ import { base58Encode, exportWallet, getWalletConfig, verifyPassword } from './wallet.js';
9
+ import { keccak256, signSecp256k1, rlpEncode, bigIntToMinBuf } from './crypto.js';
10
+
11
+ // ============= Constants =============
12
+
13
+ const DEFAULT_EVM_RPC = 'https://eth.public-rpc.com';
14
+ const DEFAULT_SOLANA_RPC = 'https://api.mainnet-beta.solana.com';
15
+
16
+ const PRIORITY_FEE_DEFAULTS = { base: 100000000n, ethereum: 1500000000n, evm: 1500000000n };
17
+
18
+ const ERC20_TRANSFER_SELECTOR = 'a9059cbb'; // transfer(address,uint256)
19
+ const SYSTEM_PROGRAM = '11111111111111111111111111111111'; // 32 zero bytes in base58
20
+ const ATA_PROGRAM = 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL';
21
+
22
+ // Chain-specific RPC endpoints
23
+ const CHAIN_RPCS = {
24
+ 'ethereum': process.env.NANSEN_EVM_RPC || DEFAULT_EVM_RPC,
25
+ 'evm': process.env.NANSEN_EVM_RPC || DEFAULT_EVM_RPC,
26
+ 'base': process.env.NANSEN_BASE_RPC || 'https://mainnet.base.org',
27
+ 'solana': process.env.NANSEN_SOLANA_RPC || DEFAULT_SOLANA_RPC,
28
+ };
29
+
30
+ const CHAIN_IDS = { 'ethereum': 1, 'evm': 1, 'base': 8453 };
31
+
32
+ // ============= Base58 =============
33
+
34
+ const BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
35
+
36
+ function base58Decode(str) {
37
+ let num = 0n;
38
+ for (const ch of str) {
39
+ const idx = BASE58_ALPHABET.indexOf(ch);
40
+ if (idx === -1) throw new Error(`Invalid base58 character: ${ch}`);
41
+ num = num * 58n + BigInt(idx);
42
+ }
43
+ const hex = num.toString(16);
44
+ const paddedHex = hex.length % 2 ? '0' + hex : hex;
45
+ const bytes = num === 0n ? [] : [...Buffer.from(paddedHex, 'hex')];
46
+ let leadingZeros = 0;
47
+ for (const ch of str) { if (ch === '1') leadingZeros++; else break; }
48
+ return Buffer.from([...Array(leadingZeros).fill(0), ...bytes]);
49
+ }
50
+
51
+ function base58DecodePubkey(str) {
52
+ const raw = base58Decode(str);
53
+ if (raw.length === 32) return raw;
54
+ if (raw.length < 32) return Buffer.concat([Buffer.alloc(32 - raw.length), raw]);
55
+ return raw.subarray(raw.length - 32);
56
+ }
57
+
58
+ // ============= Address Validation =============
59
+
60
+ function validateEvmAddress(address) {
61
+ if (!/^0x[0-9a-fA-F]{40}$/.test(address)) return { valid: false, error: 'Invalid EVM address' };
62
+ return { valid: true };
63
+ }
64
+
65
+ function validateSolanaAddress(address) {
66
+ try {
67
+ const decoded = base58Decode(address);
68
+ if (decoded.length !== 32) return { valid: false, error: 'Invalid Solana address length' };
69
+ return { valid: true };
70
+ } catch { return { valid: false, error: 'Invalid Solana address' }; }
71
+ }
72
+
73
+ // ============= Amount Parsing =============
74
+
75
+ function parseAmount(amountStr, decimals) {
76
+ const parts = amountStr.split('.');
77
+ const whole = parts[0] || '0';
78
+ let frac = (parts[1] || '').padEnd(decimals, '0').slice(0, decimals);
79
+ return BigInt(whole) * (10n ** BigInt(decimals)) + BigInt(frac);
80
+ }
81
+
82
+ function formatAmount(rawAmount, decimals) {
83
+ const divisor = 10n ** BigInt(decimals);
84
+ const whole = rawAmount / divisor;
85
+ const frac = rawAmount % divisor;
86
+ if (frac === 0n) return whole.toString();
87
+ const fracStr = frac.toString().padStart(decimals, '0').replace(/0+$/, '');
88
+ return `${whole}.${fracStr}`;
89
+ }
90
+
91
+ // ============= RPC =============
92
+
93
+ async function rpcCall(url, method, params = []) {
94
+ const response = await fetch(url, {
95
+ method: 'POST',
96
+ headers: { 'Content-Type': 'application/json' },
97
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
98
+ });
99
+ const data = await response.json();
100
+ if (data.error) throw new Error(friendlyRpcError(data.error));
101
+ return data.result;
102
+ }
103
+
104
+ /**
105
+ * Convert raw RPC errors into actionable messages.
106
+ */
107
+ function friendlyRpcError(error) {
108
+ const msg = error.message || JSON.stringify(error);
109
+ const lower = msg.toLowerCase();
110
+
111
+ if (lower.includes('no record of a prior credit') || lower.includes('accountnotfound')) {
112
+ return 'Insufficient SOL for transaction fees. Send at least 0.01 SOL to your wallet.';
113
+ }
114
+ if (lower.includes('insufficient lamports') || lower.includes('insufficient funds')) {
115
+ return 'Insufficient SOL balance for this transaction. Top up your wallet with SOL.';
116
+ }
117
+ if (lower.includes('blockhash not found') || lower.includes('blockhash')) {
118
+ return 'Transaction expired. Please try again.';
119
+ }
120
+ if (lower.includes('too large') || lower.includes('transaction too big')) {
121
+ return 'Transaction too large. Try a simpler transaction or fewer instructions.';
122
+ }
123
+ if (lower.includes('program failed') || lower.includes('custom program error')) {
124
+ return `Transaction rejected by on-chain program: ${msg}`;
125
+ }
126
+
127
+ return `RPC error: ${msg}`;
128
+ }
129
+
130
+
131
+ function bigIntToHex(n) {
132
+ if (n === 0n) return '0x';
133
+ const hex = n.toString(16);
134
+ return '0x' + hex;
135
+ }
136
+
137
+ // ============= EVM Transaction =============
138
+
139
+ async function buildEvmTransaction({ to, amount, token, privateKey, chain, max = false }) {
140
+ const rpcUrl = CHAIN_RPCS[chain] || CHAIN_RPCS.evm;
141
+ const chainId = CHAIN_IDS[chain] || 1;
142
+
143
+ // Derive address
144
+ const privBuf = Buffer.from(privateKey, 'hex');
145
+ const ecdh = crypto.createECDH('secp256k1');
146
+ ecdh.setPrivateKey(privBuf);
147
+ const pubKey = ecdh.getPublicKey(null, 'uncompressed');
148
+ const from = '0x' + keccak256(pubKey.subarray(1)).subarray(12).toString('hex');
149
+
150
+ // Nonce
151
+ const nonceHex = await rpcCall(rpcUrl, 'eth_getTransactionCount', [from, 'latest']);
152
+ const nonce = BigInt(nonceHex);
153
+
154
+ // Fees — dynamic priority fee
155
+ const feeHistory = await rpcCall(rpcUrl, 'eth_feeHistory', [4, 'latest', [50]]);
156
+ const baseFee = BigInt(feeHistory.baseFeePerGas[feeHistory.baseFeePerGas.length - 1]);
157
+ let maxPriorityFee;
158
+ try {
159
+ const dynamicTip = await rpcCall(rpcUrl, 'eth_maxPriorityFeePerGas', []);
160
+ maxPriorityFee = BigInt(dynamicTip);
161
+ } catch {
162
+ // Fallback: median tip from fee history, or chain-specific default
163
+ const tips = (feeHistory.reward || []).map(r => r[0] ? BigInt(r[0]) : 0n).filter(t => t > 0n);
164
+ if (tips.length > 0) {
165
+ tips.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
166
+ maxPriorityFee = tips[Math.floor(tips.length / 2)];
167
+ } else {
168
+ maxPriorityFee = PRIORITY_FEE_DEFAULTS[chain] || PRIORITY_FEE_DEFAULTS.evm;
169
+ }
170
+ }
171
+ const maxFee = baseFee * 2n + maxPriorityFee;
172
+
173
+ let txTo, txValue, txData;
174
+ if (token) {
175
+ const toStripped = to.replace(/^0x/, '').padStart(64, '0');
176
+ const amtHex = amount.toString(16).padStart(64, '0');
177
+ txTo = token;
178
+ txValue = 0n;
179
+ txData = Buffer.from(ERC20_TRANSFER_SELECTOR + toStripped + amtHex, 'hex');
180
+
181
+ // Pre-check: ERC-20 balance
182
+ const balResult = await rpcCall(rpcUrl, 'eth_call', [{
183
+ to: token,
184
+ data: '0x70a08231' + from.slice(2).padStart(64, '0'), // balanceOf(address)
185
+ }, 'latest']);
186
+ const tokenBalance = BigInt(balResult || '0x0');
187
+ if (tokenBalance < amount) {
188
+ throw new Error(`Insufficient token balance: have ${tokenBalance}, need ${amount}`);
189
+ }
190
+ } else {
191
+ txTo = to;
192
+ txData = Buffer.alloc(0);
193
+
194
+ // Native ETH balance check / max calculation
195
+ const balHex = await rpcCall(rpcUrl, 'eth_getBalance', [from, 'latest']);
196
+ const ethBalance = BigInt(balHex);
197
+
198
+ if (max) {
199
+ // First estimate gas for a dummy transfer to get the right gasLimit
200
+ // (EIP-7702 delegated accounts need more than 21000)
201
+ let estGasLimit;
202
+ try {
203
+ const dummyEstimate = await rpcCall(rpcUrl, 'eth_estimateGas', [
204
+ { from, to, value: '0x1' },
205
+ ]);
206
+ estGasLimit = BigInt(dummyEstimate) * 120n / 100n;
207
+ } catch {
208
+ estGasLimit = 21000n;
209
+ }
210
+ // Reserve: L2 gas (gasLimit * maxFee) + L1 data fee buffer
211
+ // L1 fees on Base/OP are typically ~0.5-2% of L2 gas cost
212
+ // Use 3x L2 gas cost as safe total reserve
213
+ const l2GasCost = maxFee * estGasLimit;
214
+ const safeReserve = l2GasCost * 3n;
215
+ if (ethBalance <= safeReserve) throw new Error(`Insufficient balance: ${ethBalance} wei (need > ${safeReserve} for gas + L1 fees)`);
216
+ txValue = ethBalance - safeReserve;
217
+ amount = txValue;
218
+ stderr(` Max send: ${formatAmount(txValue, 18)} ETH (reserved ${formatAmount(safeReserve, 18)} for gas)`);
219
+ } else {
220
+ txValue = amount;
221
+ const estimatedCost = amount + maxFee * 21000n;
222
+ if (ethBalance < estimatedCost) {
223
+ throw new Error(`Insufficient ETH balance: have ${ethBalance} wei, need ~${estimatedCost} wei (${amount} value + gas)`);
224
+ }
225
+ }
226
+ }
227
+
228
+ // Estimate gas dynamically
229
+ const estimateParams = { from, to: txTo, data: txData.length > 0 ? '0x' + txData.toString('hex') : '0x' };
230
+ if (txValue > 0n) estimateParams.value = bigIntToHex(txValue);
231
+ let gasLimit;
232
+ try {
233
+ const gasEstimate = await rpcCall(rpcUrl, 'eth_estimateGas', [estimateParams]);
234
+ // Add 20% buffer for safety
235
+ gasLimit = BigInt(gasEstimate) * 120n / 100n;
236
+ } catch {
237
+ // Fallback to safe defaults if estimation fails
238
+ gasLimit = token ? 100000n : 21000n;
239
+ }
240
+
241
+ // RLP: [chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gasLimit, to, value, data, accessList]
242
+ const txFields = [
243
+ bigIntToHex(BigInt(chainId)),
244
+ bigIntToHex(nonce),
245
+ bigIntToHex(maxPriorityFee),
246
+ bigIntToHex(maxFee),
247
+ bigIntToHex(gasLimit),
248
+ txTo,
249
+ bigIntToHex(txValue),
250
+ txData.length > 0 ? '0x' + txData.toString('hex') : '0x',
251
+ [], // accessList
252
+ ];
253
+
254
+ const unsigned = rlpEncode(txFields);
255
+ const txHash = keccak256(Buffer.concat([Buffer.from([0x02]), unsigned]));
256
+ const sig = signSecp256k1(txHash, privBuf);
257
+
258
+ const signed = rlpEncode([
259
+ ...txFields,
260
+ bigIntToHex(BigInt(sig.v)),
261
+ '0x' + sig.r.toString('hex'),
262
+ '0x' + sig.s.toString('hex'),
263
+ ]);
264
+
265
+ const rawTx = Buffer.concat([Buffer.from([0x02]), signed]);
266
+ return { signedTransaction: '0x' + rawTx.toString('hex'), amount: txValue };
267
+ }
268
+
269
+ // ============= Solana Transaction =============
270
+
271
+ function encodeCompactU16(value) {
272
+ if (value < 0x80) return Buffer.from([value]);
273
+ if (value < 0x4000) return Buffer.from([(value & 0x7f) | 0x80, (value >> 7) & 0x7f]);
274
+ return Buffer.from([(value & 0x7f) | 0x80, ((value >> 7) & 0x7f) | 0x80, (value >> 14) & 0x03]);
275
+ }
276
+
277
+ function signEd25519(message, seed) {
278
+ const keyObj = crypto.createPrivateKey({
279
+ key: Buffer.concat([
280
+ Buffer.from('302e020100300506032b657004220420', 'hex'), // PKCS8 Ed25519 prefix
281
+ seed,
282
+ ]),
283
+ format: 'der',
284
+ type: 'pkcs8',
285
+ });
286
+ return crypto.sign(null, message, keyObj);
287
+ }
288
+
289
+ // ============= Solana PDA / ATA =============
290
+
291
+ function deriveATA(owner, mint, tokenProgram) {
292
+ const ownerBuf = typeof owner === 'string' ? base58DecodePubkey(owner) : owner;
293
+ const mintBuf = typeof mint === 'string' ? base58DecodePubkey(mint) : mint;
294
+ const tokenProgBuf = base58DecodePubkey(tokenProgram);
295
+ const ataBuf = base58DecodePubkey(ATA_PROGRAM);
296
+
297
+ for (let nonce = 255; nonce >= 0; nonce--) {
298
+ const hash = crypto.createHash('sha256')
299
+ .update(Buffer.concat([ownerBuf, tokenProgBuf, mintBuf, Buffer.from([nonce]), ataBuf, Buffer.from('ProgramDerivedAddress')]))
300
+ .digest();
301
+ // PDA must NOT be on the ed25519 curve
302
+ if (!isOnEd25519Curve(hash)) return hash;
303
+ }
304
+ throw new Error('Could not derive ATA');
305
+ }
306
+
307
+ function isOnEd25519Curve(bytes) {
308
+ const p = (1n << 255n) - 19n;
309
+ const d = (-121665n * modPowBig(121666n, p - 2n, p) % p + p) % p;
310
+ let y = 0n;
311
+ for (let i = 0; i < 32; i++) y |= BigInt(bytes[i]) << (BigInt(i) * 8n);
312
+ y &= (1n << 255n) - 1n;
313
+ if (y >= p) return false;
314
+ const y2 = modPowBig(y, 2n, p);
315
+ const num = ((y2 - 1n) % p + p) % p;
316
+ const den = ((d * y2 + 1n) % p + p) % p;
317
+ const x2 = (num * modPowBig(den, p - 2n, p)) % p;
318
+ if (x2 === 0n) return true;
319
+ return modPowBig(x2, (p - 1n) / 2n, p) === 1n;
320
+ }
321
+
322
+ function modPowBig(base, exp, mod) {
323
+ let result = 1n;
324
+ base = ((base % mod) + mod) % mod;
325
+ while (exp > 0n) {
326
+ if (exp & 1n) result = (result * base) % mod;
327
+ exp >>= 1n;
328
+ base = (base * base) % mod;
329
+ }
330
+ return result;
331
+ }
332
+
333
+ async function getTokenInfo(rpcUrl, mint) {
334
+ // Get mint account to determine token program and decimals
335
+ const info = await rpcCall(rpcUrl, 'getAccountInfo', [mint, { encoding: 'jsonParsed' }]);
336
+ if (!info || !info.value) throw new Error(`Token mint ${mint} not found`);
337
+ const owner = info.value.owner;
338
+ const decimals = info.value.data?.parsed?.info?.decimals;
339
+ return { tokenProgram: owner, decimals: decimals ?? 9 };
340
+ }
341
+
342
+ async function buildSolanaTransaction({ to, amount, amountStr, token, privateKey }) {
343
+ const rpcUrl = CHAIN_RPCS.solana;
344
+
345
+ const keypairBuf = Buffer.from(privateKey, 'hex');
346
+ const seed = keypairBuf.subarray(0, 32);
347
+ const pubkey = keypairBuf.subarray(32, 64);
348
+ const fromAddr = base58Encode(pubkey);
349
+
350
+ // Get recent blockhash
351
+ const bhResult = await rpcCall(rpcUrl, 'getLatestBlockhash', [{ commitment: 'finalized' }]);
352
+ const blockhash = bhResult.value.blockhash;
353
+
354
+ let accountKeys, instructions, numReadonlyUnsigned;
355
+
356
+ if (token) {
357
+ // SPL Token TransferChecked
358
+ const { tokenProgram, decimals } = await getTokenInfo(rpcUrl, token);
359
+ const tokenAmount = parseAmount(amountStr, decimals);
360
+ const mintBuf = base58DecodePubkey(token);
361
+ const sourceATA = deriveATA(fromAddr, token, tokenProgram);
362
+ const destATA = deriveATA(to, token, tokenProgram);
363
+ const tokenProgBuf = base58DecodePubkey(tokenProgram);
364
+
365
+ // Pre-check: SPL token balance
366
+ const sourceAtaAddr = base58Encode(sourceATA);
367
+ try {
368
+ const ataInfo = await rpcCall(rpcUrl, 'getTokenAccountBalance', [sourceAtaAddr]);
369
+ const tokenBalance = BigInt(ataInfo.value.amount);
370
+ if (tokenBalance < tokenAmount) {
371
+ throw new Error(`Insufficient token balance: have ${ataInfo.value.uiAmountString}, need ${amountStr}`);
372
+ }
373
+ } catch (e) {
374
+ if (e.message.includes('Insufficient')) throw e;
375
+ throw new Error(`Source token account not found. Do you hold this token?`);
376
+ }
377
+
378
+ // TransferChecked instruction data: [12, amount u64 LE, decimals u8]
379
+ const instrData = Buffer.alloc(10);
380
+ instrData[0] = 12; // TransferChecked
381
+ instrData.writeBigUInt64LE(tokenAmount, 1);
382
+ instrData[9] = decimals;
383
+
384
+ const destPubkey = base58DecodePubkey(to);
385
+
386
+ // Check if destination ATA already exists — skip CreateATA if so
387
+ const destAtaAddr = base58Encode(destATA);
388
+ let destAtaExists = false;
389
+ try {
390
+ const destInfo = await rpcCall(rpcUrl, 'getAccountInfo', [destAtaAddr, { encoding: 'base64' }]);
391
+ destAtaExists = destInfo?.value !== null;
392
+ } catch { /* assume doesn't exist */ }
393
+
394
+ if (destAtaExists) {
395
+ // Simple: just TransferChecked, no CreateATA needed
396
+ // Account ordering: writable first, then readonly (Solana message format requirement)
397
+ // Accounts: [owner(s,w), sourceATA(w), destATA(w), mint(r), tokenProgram(r)]
398
+ accountKeys = [
399
+ pubkey, // 0: owner/feePayer (signer, writable)
400
+ sourceATA, // 1: source ATA (writable)
401
+ destATA, // 2: dest ATA (writable)
402
+ mintBuf, // 3: mint (readonly)
403
+ tokenProgBuf, // 4: token program (readonly)
404
+ ];
405
+ instructions = [{
406
+ programIdIndex: 4,
407
+ accountIndices: [1, 3, 2, 0], // source, mint, dest, authority
408
+ data: instrData,
409
+ }];
410
+ numReadonlyUnsigned = 2; // mint + tokenProgram
411
+ } else {
412
+ // Need CreateAssociatedTokenAccountIdempotent + TransferChecked
413
+ const ataProgBuf = base58DecodePubkey(ATA_PROGRAM);
414
+ const sysProgramBuf = base58DecodePubkey(SYSTEM_PROGRAM);
415
+
416
+ accountKeys = [
417
+ pubkey, // 0
418
+ sourceATA, // 1
419
+ destATA, // 2
420
+ destPubkey, // 3
421
+ mintBuf, // 4
422
+ sysProgramBuf, // 5
423
+ tokenProgBuf, // 6
424
+ ataProgBuf, // 7
425
+ ];
426
+
427
+ const createAtaInstr = {
428
+ programIdIndex: 7,
429
+ accountIndices: [0, 2, 3, 4, 5, 6],
430
+ data: Buffer.from([1]),
431
+ };
432
+ const transferInstr = {
433
+ programIdIndex: 6,
434
+ accountIndices: [1, 4, 2, 0],
435
+ data: instrData,
436
+ };
437
+ instructions = [createAtaInstr, transferInstr];
438
+ numReadonlyUnsigned = 5; // destOwner, mint, systemProg, tokenProg, ataProg
439
+ }
440
+ } else {
441
+ // Native SOL transfer
442
+
443
+ // Pre-check: SOL balance
444
+ const balResult = await rpcCall(rpcUrl, 'getBalance', [fromAddr, { commitment: 'confirmed' }]);
445
+ const solBalance = BigInt(balResult.value);
446
+ const needed = amount + 5000n; // amount + ~fee
447
+ if (solBalance < needed) {
448
+ throw new Error(`Insufficient SOL balance: have ${solBalance} lamports, need ${needed} (${amount} + fees)`);
449
+ }
450
+
451
+ const instrData = Buffer.alloc(12);
452
+ instrData.writeUInt32LE(2, 0);
453
+ instrData.writeBigUInt64LE(amount, 4);
454
+
455
+ accountKeys = [
456
+ pubkey,
457
+ base58DecodePubkey(to),
458
+ base58DecodePubkey(SYSTEM_PROGRAM),
459
+ ];
460
+
461
+ instructions = [{
462
+ programIdIndex: 2,
463
+ accountIndices: [0, 1],
464
+ data: instrData,
465
+ }];
466
+ numReadonlyUnsigned = 1;
467
+ }
468
+
469
+ // Serialize legacy message
470
+ const parts = [];
471
+ parts.push(Buffer.from([1, 0, numReadonlyUnsigned]));
472
+ parts.push(encodeCompactU16(accountKeys.length));
473
+ for (const key of accountKeys) parts.push(key);
474
+ parts.push(base58DecodePubkey(blockhash));
475
+ parts.push(encodeCompactU16(instructions.length));
476
+ for (const ix of instructions) {
477
+ parts.push(Buffer.from([ix.programIdIndex]));
478
+ parts.push(encodeCompactU16(ix.accountIndices.length));
479
+ parts.push(Buffer.from(ix.accountIndices));
480
+ parts.push(encodeCompactU16(ix.data.length));
481
+ parts.push(ix.data);
482
+ }
483
+
484
+ const messageBytes = Buffer.concat(parts);
485
+
486
+ // Sign
487
+ const signature = signEd25519(messageBytes, seed);
488
+
489
+ // Serialize transaction: compact(numSigs) + signatures + message
490
+ const txBytes = Buffer.concat([
491
+ encodeCompactU16(1),
492
+ signature, // 64 bytes
493
+ messageBytes,
494
+ ]);
495
+
496
+ // Solana sendTransaction expects base64
497
+ return { signedTransaction: txBytes.toString('base64') };
498
+ }
499
+
500
+ // ============= Broadcasting =============
501
+
502
+ // ============= Confirmation =============
503
+
504
+ function stderr(msg) { process.stderr.write(msg + '\n'); }
505
+
506
+ async function waitForEvmConfirmation(rpcUrl, txHash, timeoutMs = 30000) {
507
+ stderr(' Waiting for confirmation...');
508
+ const start = Date.now();
509
+ while (Date.now() - start < timeoutMs) {
510
+ try {
511
+ const receipt = await rpcCall(rpcUrl, 'eth_getTransactionReceipt', [txHash]);
512
+ if (receipt) {
513
+ if (receipt.status === '0x0') throw new Error(`Transaction reverted on-chain: ${txHash}`);
514
+ const block = parseInt(receipt.blockNumber, 16);
515
+ stderr(` ✓ Confirmed in block ${block}`);
516
+ return { confirmed: true, blockNumber: block };
517
+ }
518
+ } catch (e) {
519
+ if (e.message.includes('reverted')) throw e;
520
+ }
521
+ await new Promise(r => setTimeout(r, 2000));
522
+ }
523
+ stderr(' ⚠ Confirmation timed out (tx may still succeed)');
524
+ return { confirmed: false };
525
+ }
526
+
527
+ async function waitForSolanaConfirmation(rpcUrl, txHash, timeoutMs = 30000) {
528
+ stderr(' Waiting for confirmation...');
529
+ const start = Date.now();
530
+ while (Date.now() - start < timeoutMs) {
531
+ try {
532
+ const result = await rpcCall(rpcUrl, 'getSignatureStatuses', [[txHash]]);
533
+ const status = result?.value?.[0];
534
+ if (status) {
535
+ if (status.err) throw new Error(`Transaction failed: ${JSON.stringify(status.err)}`);
536
+ if (status.confirmationStatus === 'confirmed' || status.confirmationStatus === 'finalized') {
537
+ stderr(` ✓ Confirmed (${status.confirmationStatus}, slot ${status.slot})`);
538
+ return { confirmed: true, slot: status.slot };
539
+ }
540
+ }
541
+ } catch (e) {
542
+ if (e.message.includes('failed')) throw e;
543
+ }
544
+ await new Promise(r => setTimeout(r, 2000));
545
+ }
546
+ stderr(' ⚠ Confirmation timed out (tx may still succeed)');
547
+ return { confirmed: false };
548
+ }
549
+
550
+ // ============= Token Validation =============
551
+
552
+ async function validateErc20Token(rpcUrl, tokenAddress) {
553
+ // Check it's a contract
554
+ const code = await rpcCall(rpcUrl, 'eth_getCode', [tokenAddress, 'latest']);
555
+ if (!code || code === '0x' || code === '0x0') {
556
+ throw new Error(`Address ${tokenAddress} is not a contract — not a valid ERC-20 token`);
557
+ }
558
+ // Check decimals() is callable
559
+ try {
560
+ const decResult = await rpcCall(rpcUrl, 'eth_call', [{ to: tokenAddress, data: '0x313ce567' }, 'latest']);
561
+ const decimals = parseInt(decResult, 16);
562
+ if (isNaN(decimals) || decimals > 255) {
563
+ throw new Error(`Contract ${tokenAddress} returned invalid decimals — may not be a valid ERC-20 token`);
564
+ }
565
+ return decimals;
566
+ } catch (e) {
567
+ if (e.message.includes('not a valid')) throw e;
568
+ throw new Error(`Contract ${tokenAddress} does not implement ERC-20 decimals() — may not be a valid token`);
569
+ }
570
+ }
571
+
572
+ // ============= Broadcasting =============
573
+
574
+ async function broadcastTransaction(signedTx, chain) {
575
+ if (chain === 'solana') {
576
+ return rpcCall(CHAIN_RPCS.solana, 'sendTransaction', [signedTx, {
577
+ encoding: 'base64',
578
+ skipPreflight: false,
579
+ preflightCommitment: 'confirmed',
580
+ }]);
581
+ }
582
+ return rpcCall(CHAIN_RPCS[chain] || CHAIN_RPCS.evm, 'eth_sendRawTransaction', [signedTx]);
583
+ }
584
+
585
+ // ============= Public API =============
586
+
587
+ // Exported for testing
588
+ export { parseAmount, formatAmount, signEd25519, encodeCompactU16, base58Decode, base58DecodePubkey, deriveATA, validateEvmAddress, validateSolanaAddress, bigIntToHex };
589
+
590
+ export async function sendTokens({ to, amount, chain, token = null, wallet = null, password, max = false, dryRun = false }) {
591
+ // Validate address
592
+ const validate = chain === 'solana' ? validateSolanaAddress : validateEvmAddress;
593
+ const v = validate(to);
594
+ if (!v.valid) throw new Error(`Invalid recipient: ${v.error}`);
595
+
596
+ const config = getWalletConfig();
597
+ if (!verifyPassword(password, config)) throw new Error('Incorrect password');
598
+
599
+ const walletName = wallet || config.defaultWallet;
600
+ if (!walletName) throw new Error('No wallet specified and no default wallet set');
601
+ const walletData = exportWallet(walletName, password);
602
+
603
+ let result;
604
+ if (chain === 'solana') {
605
+ if (max && !token) {
606
+ // Max native SOL: balance - 5000 lamports fee
607
+ const rpcUrl = CHAIN_RPCS.solana;
608
+ const fromAddr = walletData.solana.address;
609
+ const balResult = await rpcCall(rpcUrl, 'getBalance', [fromAddr, { commitment: 'confirmed' }]);
610
+ const solBalance = BigInt(balResult.value);
611
+ const fee = 5000n;
612
+ if (solBalance <= fee) throw new Error(`Insufficient SOL balance: ${solBalance} lamports (need > ${fee} for fees)`);
613
+ const maxAmount = solBalance - fee;
614
+ amount = formatAmount(maxAmount, 9);
615
+ stderr(` Max send: ${amount} SOL`);
616
+ } else if (max && token) {
617
+ // Max SPL: full token balance
618
+ const rpcUrl = CHAIN_RPCS.solana;
619
+ const { tokenProgram, decimals } = await getTokenInfo(rpcUrl, token);
620
+ const sourceATA = deriveATA(walletData.solana.address, token, tokenProgram);
621
+ const sourceAtaAddr = base58Encode(sourceATA);
622
+ const ataInfo = await rpcCall(rpcUrl, 'getTokenAccountBalance', [sourceAtaAddr]);
623
+ amount = ataInfo.value.uiAmountString;
624
+ stderr(` Max send: ${amount} (SPL token)`);
625
+ }
626
+ const amountRaw = token ? null : parseAmount(amount, 9);
627
+ result = await buildSolanaTransaction({
628
+ to, amount: amountRaw, amountStr: amount, token,
629
+ privateKey: walletData.solana.privateKey,
630
+ });
631
+ } else {
632
+ const rpcUrl = CHAIN_RPCS[chain] || CHAIN_RPCS.evm;
633
+
634
+ // Validate ERC-20 token contract
635
+ let decimals = 18;
636
+ if (token) {
637
+ decimals = await validateErc20Token(rpcUrl, token);
638
+ }
639
+
640
+ if (max && token) {
641
+ // Max ERC-20: full token balance
642
+ const privBuf = Buffer.from(walletData.evm.privateKey, 'hex');
643
+ const ecdh = crypto.createECDH('secp256k1');
644
+ ecdh.setPrivateKey(privBuf);
645
+ const pubKey = ecdh.getPublicKey(null, 'uncompressed');
646
+ const from = '0x' + keccak256(pubKey.subarray(1)).subarray(12).toString('hex');
647
+ const balResult = await rpcCall(rpcUrl, 'eth_call', [{
648
+ to: token, data: '0x70a08231' + from.slice(2).padStart(64, '0'),
649
+ }, 'latest']);
650
+ const tokenBalance = BigInt(balResult || '0x0');
651
+ if (tokenBalance === 0n) throw new Error('Token balance is zero');
652
+ amount = formatAmount(tokenBalance, decimals);
653
+ stderr(` Max send: ${amount} (ERC-20)`);
654
+ }
655
+
656
+ const amountRaw = (max && !token) ? 0n : parseAmount(amount, decimals);
657
+ result = await buildEvmTransaction({
658
+ to, amount: amountRaw, token,
659
+ privateKey: walletData.evm.privateKey,
660
+ chain, max: max && !token,
661
+ });
662
+ }
663
+
664
+ // Dry run: return transaction details without broadcasting
665
+ if (dryRun) {
666
+ const finalAmount = (max && !token && result.amount != null)
667
+ ? formatAmount(result.amount, chain === 'solana' ? 9 : 18)
668
+ : amount;
669
+ return {
670
+ dryRun: true,
671
+ from: chain === 'solana' ? walletData.solana.address : walletData.evm.address,
672
+ to, amount: finalAmount, token, chain,
673
+ ...(result.estimatedFee ? { estimatedFee: result.estimatedFee } : {}),
674
+ };
675
+ }
676
+
677
+ const txHash = await broadcastTransaction(result.signedTransaction, chain);
678
+
679
+ // Wait for confirmation
680
+ let confirmation;
681
+ if (chain === 'solana') {
682
+ confirmation = await waitForSolanaConfirmation(CHAIN_RPCS.solana, txHash);
683
+ } else {
684
+ const rpcUrl = CHAIN_RPCS[chain] || CHAIN_RPCS.evm;
685
+ confirmation = await waitForEvmConfirmation(rpcUrl, txHash);
686
+ }
687
+
688
+ // For max native sends, use the actual amount from the tx builder
689
+ const finalAmount = (max && !token && result.amount != null)
690
+ ? formatAmount(result.amount, chain === 'solana' ? 9 : 18)
691
+ : amount;
692
+
693
+ return {
694
+ success: true,
695
+ transactionHash: txHash,
696
+ confirmed: confirmation.confirmed,
697
+ ...(confirmation.blockNumber ? { blockNumber: confirmation.blockNumber } : {}),
698
+ from: chain === 'solana' ? walletData.solana.address : walletData.evm.address,
699
+ to, amount: finalAmount, token, chain,
700
+ explorer: getExplorerUrl(chain, txHash),
701
+ };
702
+ }
703
+
704
+ /**
705
+ * Get block explorer URL for a transaction.
706
+ */
707
+ function getExplorerUrl(chain, txHash) {
708
+ const explorers = {
709
+ solana: 'https://solscan.io/tx/',
710
+ ethereum: 'https://etherscan.io/tx/',
711
+ base: 'https://basescan.org/tx/',
712
+ arbitrum: 'https://arbiscan.io/tx/',
713
+ polygon: 'https://polygonscan.com/tx/',
714
+ optimism: 'https://optimistic.etherscan.io/tx/',
715
+ bnb: 'https://bscscan.com/tx/',
716
+ avalanche: 'https://snowtrace.io/tx/',
717
+ linea: 'https://lineascan.build/tx/',
718
+ scroll: 'https://scrollscan.com/tx/',
719
+ mantle: 'https://mantlescan.xyz/tx/',
720
+ };
721
+ const base = explorers[chain] || explorers.ethereum;
722
+ return `${base}${txHash}`;
723
+ }