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/transfer.js
ADDED
|
@@ -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
|
+
}
|