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/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
- async function buildSolanaTransaction({ to, amount, amountStr, token, privateKey }) {
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(fromAddr, token, tokenProgram);
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', [fromAddr, { commitment: 'confirmed' }]);
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
- // Sign
476
- const signature = signEd25519(messageBytes, seed);
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
- // Serialize transaction: compact(numSigs) + signatures + message
479
- const txBytes = Buffer.concat([
480
- encodeCompactU16(1),
481
- signature, // 64 bytes
482
- messageBytes,
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
- // Solana sendTransaction expects base64
486
- return { signedTransaction: txBytes.toString('base64') };
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;