nansen-cli 1.13.1 → 1.15.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/CHANGELOG.md +28 -0
- package/README.md +1 -1
- package/package.json +2 -1
- package/src/api.js +107 -64
- package/src/chain-ids.js +2 -3
- package/src/cli.js +31 -23
- package/src/keychain.js +229 -0
- package/src/privy.js +359 -0
- package/src/schema.json +42 -2
- package/src/trading.js +264 -118
- package/src/transfer.js +150 -25
- package/src/wallet.js +354 -70
- package/src/x402-svm.js +43 -24
- package/src/x402.js +2 -2
package/src/transfer.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import crypto from 'crypto';
|
|
7
7
|
import { base58 } from '@scure/base';
|
|
8
|
-
import { base58Encode, exportWallet, getWalletConfig, verifyPassword } from './wallet.js';
|
|
8
|
+
import { base58Encode, exportWallet, getWalletConfig, verifyPassword, showWallet } from './wallet.js';
|
|
9
9
|
import { keccak256, signSecp256k1, rlpEncode } from './crypto.js';
|
|
10
10
|
import { getWalletConnectAddress, sendTransactionViaWalletConnect } from './walletconnect-trading.js';
|
|
11
11
|
import { EVM_CHAIN_IDS } from './chain-ids.js';
|
|
@@ -328,13 +328,13 @@ async function getTokenInfo(rpcUrl, mint) {
|
|
|
328
328
|
return { tokenProgram: owner, decimals: decimals ?? 9 };
|
|
329
329
|
}
|
|
330
330
|
|
|
331
|
-
|
|
331
|
+
/**
|
|
332
|
+
* Build an unsigned Solana transaction for external signing (e.g. Privy).
|
|
333
|
+
* Returns base64-encoded serialized transaction with an empty signature slot.
|
|
334
|
+
*/
|
|
335
|
+
export async function buildUnsignedSolanaTransaction({ to, amount, amountStr, token, fromAddress }) {
|
|
332
336
|
const rpcUrl = CHAIN_RPCS.solana;
|
|
333
|
-
|
|
334
|
-
const keypairBuf = Buffer.from(privateKey, 'hex');
|
|
335
|
-
const seed = keypairBuf.subarray(0, 32);
|
|
336
|
-
const pubkey = keypairBuf.subarray(32, 64);
|
|
337
|
-
const fromAddr = base58Encode(pubkey);
|
|
337
|
+
const pubkey = base58DecodePubkey(fromAddress);
|
|
338
338
|
|
|
339
339
|
// Get recent blockhash
|
|
340
340
|
const bhResult = await rpcCall(rpcUrl, 'getLatestBlockhash', [{ commitment: 'finalized' }]);
|
|
@@ -347,7 +347,7 @@ async function buildSolanaTransaction({ to, amount, amountStr, token, privateKey
|
|
|
347
347
|
const { tokenProgram, decimals } = await getTokenInfo(rpcUrl, token);
|
|
348
348
|
const tokenAmount = parseAmount(amountStr, decimals);
|
|
349
349
|
const mintBuf = base58DecodePubkey(token);
|
|
350
|
-
const sourceATA = deriveATA(
|
|
350
|
+
const sourceATA = deriveATA(fromAddress, token, tokenProgram);
|
|
351
351
|
const destATA = deriveATA(to, token, tokenProgram);
|
|
352
352
|
const tokenProgBuf = base58DecodePubkey(tokenProgram);
|
|
353
353
|
|
|
@@ -381,9 +381,6 @@ async function buildSolanaTransaction({ to, amount, amountStr, token, privateKey
|
|
|
381
381
|
} catch { /* assume doesn't exist */ }
|
|
382
382
|
|
|
383
383
|
if (destAtaExists) {
|
|
384
|
-
// Simple: just TransferChecked, no CreateATA needed
|
|
385
|
-
// Account ordering: writable first, then readonly (Solana message format requirement)
|
|
386
|
-
// Accounts: [owner(s,w), sourceATA(w), destATA(w), mint(r), tokenProgram(r)]
|
|
387
384
|
accountKeys = [
|
|
388
385
|
pubkey, // 0: owner/feePayer (signer, writable)
|
|
389
386
|
sourceATA, // 1: source ATA (writable)
|
|
@@ -398,7 +395,6 @@ async function buildSolanaTransaction({ to, amount, amountStr, token, privateKey
|
|
|
398
395
|
}];
|
|
399
396
|
numReadonlyUnsigned = 2; // mint + tokenProgram
|
|
400
397
|
} else {
|
|
401
|
-
// Need CreateAssociatedTokenAccountIdempotent + TransferChecked
|
|
402
398
|
const ataProgBuf = base58DecodePubkey(ATA_PROGRAM);
|
|
403
399
|
const sysProgramBuf = base58DecodePubkey(SYSTEM_PROGRAM);
|
|
404
400
|
|
|
@@ -430,7 +426,7 @@ async function buildSolanaTransaction({ to, amount, amountStr, token, privateKey
|
|
|
430
426
|
// Native SOL transfer
|
|
431
427
|
|
|
432
428
|
// Pre-check: SOL balance
|
|
433
|
-
const balResult = await rpcCall(rpcUrl, 'getBalance', [
|
|
429
|
+
const balResult = await rpcCall(rpcUrl, 'getBalance', [fromAddress, { commitment: 'confirmed' }]);
|
|
434
430
|
const solBalance = BigInt(balResult.value);
|
|
435
431
|
const needed = amount + 5000n; // amount + ~fee
|
|
436
432
|
if (solBalance < needed) {
|
|
@@ -472,18 +468,32 @@ async function buildSolanaTransaction({ to, amount, amountStr, token, privateKey
|
|
|
472
468
|
|
|
473
469
|
const messageBytes = Buffer.concat(parts);
|
|
474
470
|
|
|
475
|
-
//
|
|
476
|
-
const
|
|
471
|
+
// Return unsigned: compact(1) + 64 zero bytes (empty sig slot) + message
|
|
472
|
+
const sigCount = encodeCompactU16(1);
|
|
473
|
+
const emptySignature = Buffer.alloc(64);
|
|
474
|
+
const txBytes = Buffer.concat([sigCount, emptySignature, messageBytes]);
|
|
475
|
+
return { unsignedTransaction: txBytes.toString('base64') };
|
|
476
|
+
}
|
|
477
477
|
|
|
478
|
-
|
|
479
|
-
const
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
478
|
+
async function buildSolanaTransaction({ to, amount, amountStr, token, privateKey }) {
|
|
479
|
+
const keypairBuf = Buffer.from(privateKey, 'hex');
|
|
480
|
+
const seed = keypairBuf.subarray(0, 32);
|
|
481
|
+
const fromPubkey = keypairBuf.subarray(32, 64);
|
|
482
|
+
const fromAddress = base58Encode(fromPubkey);
|
|
483
|
+
|
|
484
|
+
const { unsignedTransaction } = await buildUnsignedSolanaTransaction({
|
|
485
|
+
to, amount, amountStr, token, fromAddress,
|
|
486
|
+
});
|
|
484
487
|
|
|
485
|
-
//
|
|
486
|
-
|
|
488
|
+
// Sign: extract message bytes, sign, insert signature
|
|
489
|
+
const txBytes = Buffer.from(unsignedTransaction, 'base64');
|
|
490
|
+
const sigCountSize = 1; // compact-u16 for count=1 is always 1 byte
|
|
491
|
+
const messageBytes = txBytes.subarray(sigCountSize + 64);
|
|
492
|
+
const signature = signEd25519(messageBytes, seed);
|
|
493
|
+
const signedTx = Buffer.from(txBytes);
|
|
494
|
+
signature.copy(signedTx, sigCountSize);
|
|
495
|
+
|
|
496
|
+
return { signedTransaction: signedTx.toString('base64') };
|
|
487
497
|
}
|
|
488
498
|
|
|
489
499
|
// ============= Broadcasting =============
|
|
@@ -576,6 +586,115 @@ async function broadcastTransaction(signedTx, chain) {
|
|
|
576
586
|
// Exported for testing
|
|
577
587
|
export { parseAmount, formatAmount, signEd25519, encodeCompactU16, base58Decode, base58DecodePubkey, deriveATA, validateEvmAddress, validateSolanaAddress, bigIntToHex };
|
|
578
588
|
|
|
589
|
+
/**
|
|
590
|
+
* Send tokens via Privy server wallet. EVM uses Privy's sendTransaction (handles gas/nonce).
|
|
591
|
+
* Solana builds unsigned tx, signs via Privy, then broadcasts.
|
|
592
|
+
*/
|
|
593
|
+
async function sendTokensViaPrivy({ to, amount, chain, token, max, dryRun, walletInfo }) {
|
|
594
|
+
const { PrivyClient } = await import('./privy.js');
|
|
595
|
+
const client = new PrivyClient(process.env.PRIVY_APP_ID, process.env.PRIVY_APP_SECRET);
|
|
596
|
+
|
|
597
|
+
if (chain === 'solana') {
|
|
598
|
+
const walletId = walletInfo.privyWalletIds?.solana;
|
|
599
|
+
const fromAddress = walletInfo.solana;
|
|
600
|
+
if (!fromAddress) throw new Error('No Solana wallet in this Privy wallet');
|
|
601
|
+
if (!walletId) throw new Error('No Privy Solana wallet ID found. Re-create the wallet with: nansen wallet create --provider privy');
|
|
602
|
+
|
|
603
|
+
if (max && !token) {
|
|
604
|
+
const balResult = await rpcCall(CHAIN_RPCS.solana, 'getBalance', [fromAddress, { commitment: 'confirmed' }]);
|
|
605
|
+
const fee = 5000n;
|
|
606
|
+
const maxAmount = BigInt(balResult.value) - fee;
|
|
607
|
+
if (maxAmount <= 0n) throw new Error('Insufficient SOL balance for fees');
|
|
608
|
+
amount = formatAmount(maxAmount, 9);
|
|
609
|
+
} else if (max && token) {
|
|
610
|
+
const rpcUrl = CHAIN_RPCS.solana;
|
|
611
|
+
const { tokenProgram, decimals: _decimals } = await getTokenInfo(rpcUrl, token);
|
|
612
|
+
const sourceATA = deriveATA(fromAddress, token, tokenProgram);
|
|
613
|
+
const sourceAtaAddr = base58Encode(sourceATA);
|
|
614
|
+
const ataInfo = await rpcCall(rpcUrl, 'getTokenAccountBalance', [sourceAtaAddr]);
|
|
615
|
+
amount = ataInfo.value.uiAmountString;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (dryRun) return { dryRun: true, from: fromAddress, to, amount, token, chain };
|
|
619
|
+
|
|
620
|
+
const amountRaw = token ? null : parseAmount(amount, 9);
|
|
621
|
+
const { unsignedTransaction } = await buildUnsignedSolanaTransaction({
|
|
622
|
+
to, amount: amountRaw, amountStr: amount, token, fromAddress,
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
const signResult = await client.signSolanaTransaction(walletId, unsignedTransaction);
|
|
626
|
+
const signedTx = signResult.data?.signed_transaction || signResult.signed_transaction;
|
|
627
|
+
const txHash = await broadcastTransaction(signedTx, chain);
|
|
628
|
+
const confirmation = await waitForSolanaConfirmation(CHAIN_RPCS.solana, txHash);
|
|
629
|
+
|
|
630
|
+
return {
|
|
631
|
+
success: true, transactionHash: txHash, confirmed: confirmation.confirmed,
|
|
632
|
+
from: fromAddress, to, amount, token, chain,
|
|
633
|
+
explorer: getExplorerUrl(chain, txHash),
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// EVM: use Privy sendTransaction (Privy handles gas/nonce/broadcast)
|
|
638
|
+
const walletId = walletInfo.privyWalletIds?.evm;
|
|
639
|
+
const fromAddress = walletInfo.evm;
|
|
640
|
+
if (!fromAddress) throw new Error('No EVM wallet in this Privy wallet');
|
|
641
|
+
if (!walletId) throw new Error('No Privy EVM wallet ID found. Re-create the wallet with: nansen wallet create --provider privy');
|
|
642
|
+
|
|
643
|
+
const chainId = CHAIN_IDS[chain];
|
|
644
|
+
if (!chainId) throw new Error(`Unsupported chain: ${chain}`);
|
|
645
|
+
|
|
646
|
+
let decimals = 18;
|
|
647
|
+
const rpcUrl = CHAIN_RPCS[chain] || CHAIN_RPCS.evm;
|
|
648
|
+
if (token) decimals = await validateErc20Token(rpcUrl, token);
|
|
649
|
+
|
|
650
|
+
if (max && token) {
|
|
651
|
+
const balResult = await rpcCall(rpcUrl, 'eth_call', [{
|
|
652
|
+
to: token, data: '0x70a08231' + fromAddress.slice(2).padStart(64, '0'),
|
|
653
|
+
}, 'latest']);
|
|
654
|
+
const tokenBalance = BigInt(balResult || '0x0');
|
|
655
|
+
if (tokenBalance === 0n) throw new Error('Token balance is zero');
|
|
656
|
+
amount = formatAmount(tokenBalance, decimals);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const parsedAmount = (max && !token) ? null : parseAmount(amount, decimals);
|
|
660
|
+
|
|
661
|
+
let txParams;
|
|
662
|
+
let maxValue;
|
|
663
|
+
if (token) {
|
|
664
|
+
const amtHex = parsedAmount.toString(16).padStart(64, '0');
|
|
665
|
+
const data = '0xa9059cbb' + to.slice(2).padStart(64, '0') + amtHex;
|
|
666
|
+
txParams = { to: token, data, value: '0x0', chainId };
|
|
667
|
+
} else if (max) {
|
|
668
|
+
// Compute max native send: balance minus gas reserve
|
|
669
|
+
const balance = BigInt(await rpcCall(rpcUrl, 'eth_getBalance', [fromAddress, 'latest']));
|
|
670
|
+
const gasPrice = BigInt(await rpcCall(rpcUrl, 'eth_gasPrice', []));
|
|
671
|
+
// 21000 is for reserve estimation only. Privy's sendTransaction handles actual
|
|
672
|
+
// gas estimation, including EIP-7702 delegated accounts. 3x covers L2 data fees.
|
|
673
|
+
const gasReserve = gasPrice * 21000n * 3n;
|
|
674
|
+
maxValue = balance - gasReserve;
|
|
675
|
+
if (maxValue <= 0n) throw new Error('Insufficient balance to cover gas fees');
|
|
676
|
+
txParams = { to, value: '0x' + maxValue.toString(16), chainId };
|
|
677
|
+
} else {
|
|
678
|
+
txParams = { to, value: '0x' + parsedAmount.toString(16), chainId };
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const finalAmount = maxValue != null ? formatAmount(maxValue, 18) : amount;
|
|
682
|
+
|
|
683
|
+
if (dryRun) return { dryRun: true, from: fromAddress, to, amount: finalAmount, token, chain };
|
|
684
|
+
|
|
685
|
+
const result = await client.sendTransaction(walletId, txParams);
|
|
686
|
+
const txHash = result.data?.hash || result.hash;
|
|
687
|
+
|
|
688
|
+
const confirmation = await waitForEvmConfirmation(rpcUrl, txHash);
|
|
689
|
+
|
|
690
|
+
return {
|
|
691
|
+
success: true, transactionHash: txHash, confirmed: confirmation.confirmed,
|
|
692
|
+
...(confirmation.blockNumber ? { blockNumber: confirmation.blockNumber } : {}),
|
|
693
|
+
from: fromAddress, to, amount: finalAmount, token, chain,
|
|
694
|
+
explorer: getExplorerUrl(chain, txHash),
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
|
|
579
698
|
export async function sendTokens({ to, amount, chain, token = null, wallet = null, password, max = false, dryRun = false, walletconnect = false }) {
|
|
580
699
|
// Validate address
|
|
581
700
|
const validate = chain === 'solana' ? validateSolanaAddress : validateEvmAddress;
|
|
@@ -589,11 +708,17 @@ export async function sendTokens({ to, amount, chain, token = null, wallet = nul
|
|
|
589
708
|
return sendTokensViaWalletConnect({ to, amount, chain, token, max, dryRun });
|
|
590
709
|
}
|
|
591
710
|
|
|
711
|
+
// Resolve wallet and check provider
|
|
592
712
|
const config = getWalletConfig();
|
|
593
|
-
if (config.passwordHash && !verifyPassword(password, config)) throw new Error('Incorrect password');
|
|
594
|
-
|
|
595
713
|
const walletName = wallet || config.defaultWallet;
|
|
596
714
|
if (!walletName) throw new Error('No wallet specified and no default wallet set');
|
|
715
|
+
|
|
716
|
+
const walletInfo = showWallet(walletName);
|
|
717
|
+
if (walletInfo.provider === 'privy') {
|
|
718
|
+
return sendTokensViaPrivy({ to, amount, chain, token, max, dryRun, walletInfo });
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
if (config.passwordHash && !verifyPassword(password, config)) throw new Error('Incorrect password');
|
|
597
722
|
const walletData = exportWallet(walletName, password);
|
|
598
723
|
|
|
599
724
|
let result;
|