nansen-cli 1.14.0 → 1.16.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;
@@ -584,16 +703,22 @@ export async function sendTokens({ to, amount, chain, token = null, wallet = nul
584
703
 
585
704
  if (walletconnect) {
586
705
  if (chain === 'solana') {
587
- throw new Error('WalletConnect is only supported for EVM chains');
706
+ throw new Error('WalletConnect Solana transfers are not yet supported. Use a local wallet for Solana transfers.');
588
707
  }
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;
package/src/wallet.js CHANGED
@@ -239,8 +239,8 @@ function getWalletFile(name) {
239
239
  * Verify the global password against stored hash.
240
240
  */
241
241
  export function verifyPassword(password, config) {
242
+ if (password == null) return false;
242
243
  if (!config.passwordHash) return true; // No password set yet
243
- if (password === null || password === undefined) return false;
244
244
  const { salt, hash } = config.passwordHash;
245
245
  const derived = crypto.scryptSync(password, Buffer.from(salt, 'hex'), 32, {
246
246
  N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P, maxmem: 256 * 1024 * 1024,
@@ -367,6 +367,7 @@ export function listWallets() {
367
367
  const data = JSON.parse(fs.readFileSync(path.join(getWalletsDir(), f), 'utf8'));
368
368
  return {
369
369
  name: data.name,
370
+ provider: data.provider || 'local',
370
371
  evm: data.evm?.address || null,
371
372
  solana: data.solana?.address || null,
372
373
  createdAt: data.createdAt,
@@ -397,10 +398,18 @@ export function createWallet(name, password) {
397
398
  } else {
398
399
  // Encrypted mode
399
400
  if (!config.passwordHash) {
400
- // First encrypted wallet: reject if passwordless wallets exist
401
- const existingWallets = fs.readdirSync(getWalletsDir())
402
- .filter(f => f.endsWith('.json') && f !== 'config.json');
403
- if (existingWallets.length > 0) {
401
+ // First encrypted wallet: reject if passwordless local wallets exist
402
+ // (non-local wallets like Privy don't contain private keys, so skip them)
403
+ const walletsDir = getWalletsDir();
404
+ const existingLocalWallets = fs.readdirSync(walletsDir)
405
+ .filter(f => f.endsWith('.json') && f !== 'config.json')
406
+ .filter(f => {
407
+ try {
408
+ const data = JSON.parse(fs.readFileSync(path.join(walletsDir, f), 'utf8'));
409
+ return !data.provider || data.provider === 'local';
410
+ } catch { return true; }
411
+ });
412
+ if (existingLocalWallets.length > 0) {
404
413
  throw new Error('Existing wallets are passwordless. Cannot mix encrypted and unencrypted wallets.');
405
414
  }
406
415
  config.passwordHash = hashPassword(password);
@@ -452,13 +461,21 @@ export function showWallet(name) {
452
461
 
453
462
  const data = JSON.parse(fs.readFileSync(walletFile, 'utf8'));
454
463
  const config = getWalletConfig();
464
+ const isPrivy = data.provider === 'privy';
455
465
 
456
466
  return {
457
467
  name: data.name,
468
+ provider: data.provider || 'local',
458
469
  evm: data.evm?.address || null,
459
470
  solana: data.solana?.address || null,
460
471
  createdAt: data.createdAt,
461
472
  isDefault: data.name === config.defaultWallet,
473
+ ...(isPrivy ? {
474
+ privyWalletIds: {
475
+ evm: data.evm?.privyWalletId,
476
+ solana: data.solana?.privyWalletId,
477
+ }
478
+ } : {}),
462
479
  };
463
480
  }
464
481
 
@@ -471,13 +488,16 @@ export function exportWallet(name, password) {
471
488
  throw new Error(`Wallet "${name}" not found`);
472
489
  }
473
490
 
491
+ const data = JSON.parse(fs.readFileSync(walletFile, 'utf8'));
492
+ if (data.provider && data.provider !== 'local') {
493
+ throw new Error(`${data.provider} wallets don't support key export. Keys are managed by the provider.`);
494
+ }
495
+
474
496
  const config = getWalletConfig();
475
497
  if (config.passwordHash && !verifyPassword(password, config)) {
476
498
  throw new Error('Incorrect password');
477
499
  }
478
500
 
479
- const data = JSON.parse(fs.readFileSync(walletFile, 'utf8'));
480
-
481
501
  return {
482
502
  name: data.name,
483
503
  evm: {
@@ -510,15 +530,21 @@ export function setDefaultWallet(name) {
510
530
  /**
511
531
  * Delete a wallet.
512
532
  */
513
- export function deleteWallet(name, password) {
533
+ export async function deleteWallet(name, password) {
514
534
  const walletFile = getWalletFile(name);
515
535
  if (!fs.existsSync(walletFile)) {
516
536
  throw new Error(`Wallet "${name}" not found`);
517
537
  }
518
538
 
539
+ const data = JSON.parse(fs.readFileSync(walletFile, 'utf8'));
519
540
  const config = getWalletConfig();
520
- if (config.passwordHash && !verifyPassword(password, config)) {
521
- throw new Error('Incorrect password');
541
+
542
+ if (data.provider && data.provider !== 'local') {
543
+ // Non-local wallets: just remove local reference, no password needed
544
+ } else {
545
+ if (config.passwordHash && !verifyPassword(password, config)) {
546
+ throw new Error('Incorrect password');
547
+ }
522
548
  }
523
549
 
524
550
  fs.unlinkSync(walletFile);
@@ -537,20 +563,6 @@ export function deleteWallet(name, password) {
537
563
  return { deleted: name, newDefault: config.defaultWallet };
538
564
  }
539
565
 
540
- /**
541
- * Get the default wallet's address for a given chain type.
542
- */
543
- export function getDefaultAddress(chainType = 'evm') {
544
- const config = getWalletConfig();
545
- if (!config.defaultWallet) {
546
- throw new Error('No default wallet set. Run: nansen wallet create');
547
- }
548
-
549
- const wallet = showWallet(config.defaultWallet);
550
- const field = chainType === 'solana' ? 'solana' : 'evm';
551
- return wallet[field];
552
- }
553
-
554
566
  // ============= CLI Command Builder =============
555
567
 
556
568
  /**
@@ -563,6 +575,27 @@ export function buildWalletCommands(deps = {}) {
563
575
  'wallet': async (args, apiInstance, flags, options) => {
564
576
  const subcommand = args[0] || 'help';
565
577
 
578
+ // Privy-specific: only 'create' and policy commands need --provider privy
579
+ if (options.provider === 'privy' || process.env.NANSEN_WALLET_PROVIDER === 'privy') {
580
+ if (subcommand === 'create') {
581
+ const { createPrivyWalletPair } = await import('./privy.js');
582
+ const name = options.name || args[1] || 'default';
583
+ try {
584
+ const result = await createPrivyWalletPair(name);
585
+ log(`\n✓ Privy wallet "${result.name}" created\n`);
586
+ log(` EVM: ${result.evm.address}`);
587
+ log(` Solana: ${result.solana.address}`);
588
+ log('');
589
+ return;
590
+ } catch (err) {
591
+ log(`❌ ${err.message}`);
592
+ exit(1);
593
+ return;
594
+ }
595
+ }
596
+ // All other subcommands fall through to unified handlers below
597
+ }
598
+
566
599
  const handlers = {
567
600
  'create': async () => {
568
601
  const name = options.name || args[1] || 'default';
@@ -688,7 +721,8 @@ export function buildWalletCommands(deps = {}) {
688
721
  log('');
689
722
  for (const w of result.wallets) {
690
723
  const star = w.isDefault ? ' ★' : '';
691
- log(` ${w.name}${star}`);
724
+ const providerTag = w.provider === 'privy' ? ' (privy)' : '';
725
+ log(` ${w.name}${star}${providerTag}`);
692
726
  log(` EVM: ${w.evm}`);
693
727
  log(` Solana: ${w.solana}`);
694
728
  log('');
@@ -705,7 +739,8 @@ export function buildWalletCommands(deps = {}) {
705
739
  try {
706
740
  const result = showWallet(name);
707
741
  const star = result.isDefault ? ' ★' : '';
708
- log(`\n ${result.name}${star}`);
742
+ const providerTag = result.provider === 'privy' ? ' (privy)' : '';
743
+ log(`\n ${result.name}${star}${providerTag}`);
709
744
  log(` EVM: ${result.evm}`);
710
745
  log(` Solana: ${result.solana}`);
711
746
  log(` Created: ${result.createdAt}\n`);
@@ -723,6 +758,7 @@ export function buildWalletCommands(deps = {}) {
723
758
  exit(1);
724
759
  return;
725
760
  }
761
+
726
762
  const config = getWalletConfig();
727
763
  const { password, error } = await resolvePasswordForCommand(config, flags, deps);
728
764
  if (error) {
@@ -771,16 +807,33 @@ export function buildWalletCommands(deps = {}) {
771
807
  exit(1);
772
808
  return;
773
809
  }
774
- const config = getWalletConfig();
775
- const { password, error } = await resolvePasswordForCommand(config, flags, deps);
776
- if (error) {
777
- log(error);
778
- exit(1);
779
- return;
810
+
811
+ // Check if this is a Privy wallet (no password needed)
812
+ let isPrivy = false;
813
+ try {
814
+ const walletFile = path.join(getWalletsDir(), `${name}.json`);
815
+ const data = JSON.parse(fs.readFileSync(walletFile, 'utf8'));
816
+ if (data.provider === 'privy') isPrivy = true;
817
+ } catch { /* file might not exist, deleteWallet will throw */ }
818
+
819
+ let password = null;
820
+ if (!isPrivy) {
821
+ const config = getWalletConfig();
822
+ const resolved = await resolvePasswordForCommand(config, flags, deps);
823
+ if (resolved.error) {
824
+ log(resolved.error);
825
+ exit(1);
826
+ return;
827
+ }
828
+ password = resolved.password;
780
829
  }
830
+
781
831
  try {
782
- const result = deleteWallet(name, password);
832
+ const result = await deleteWallet(name, password);
783
833
  log(`✓ Wallet "${result.deleted}" deleted`);
834
+ if (isPrivy) {
835
+ log(` Note: server-side wallet still exists on Privy`);
836
+ }
784
837
  if (result.newDefault) {
785
838
  log(` New default: ${result.newDefault}`);
786
839
  }
@@ -820,8 +873,22 @@ export function buildWalletCommands(deps = {}) {
820
873
  }
821
874
 
822
875
  const isWalletConnect = options.wallet === 'walletconnect' || options.wallet === 'wc';
876
+
877
+ // Check if the wallet is Privy (no password needed)
878
+ let isPrivyWallet = false;
879
+ if (!isWalletConnect) {
880
+ try {
881
+ const walletName = options.wallet || getWalletConfig().defaultWallet;
882
+ if (walletName) {
883
+ const walletFile = path.join(getWalletsDir(), `${walletName}.json`);
884
+ const data = JSON.parse(fs.readFileSync(walletFile, 'utf8'));
885
+ if (data.provider === 'privy') isPrivyWallet = true;
886
+ }
887
+ } catch { /* ignore */ }
888
+ }
889
+
823
890
  let password;
824
- if (isWalletConnect) {
891
+ if (isWalletConnect || isPrivyWallet) {
825
892
  password = null;
826
893
  } else {
827
894
  const sendConfig = getWalletConfig();
@@ -959,19 +1026,19 @@ export function buildWalletCommands(deps = {}) {
959
1026
 
960
1027
  'help': async () => {
961
1028
  log(`
962
- Wallet Management - Local key storage for EVM and Solana
1029
+ Wallet Management - EVM and Solana wallets (local or Privy server-side)
963
1030
 
964
1031
  USAGE:
965
1032
  nansen wallet <command> [options]
966
1033
 
967
1034
  COMMANDS:
968
- create [--name <label>] [--unsafe-no-password]
1035
+ create [--name <label>] [--provider <local|privy>] [--unsafe-no-password]
969
1036
  Create a new wallet pair (EVM + Solana)
970
1037
  list List all wallets
971
1038
  show <name> Show wallet addresses
972
- export <name> Export private keys (requires password)
1039
+ export <name> Export private keys (local wallets only, requires password)
973
1040
  default <name> Set the default wallet
974
- delete <name> Delete a wallet (requires password)
1041
+ delete <name> Delete a wallet
975
1042
  send --to <address> --amount <number> --chain <evm|solana> [--token <address>] [--wallet <name>] [--max] [--dry-run]
976
1043
  Send tokens or native currency (--max sends entire balance, --dry-run previews without sending)
977
1044
  forget-password Remove saved password from all stores
@@ -979,13 +1046,15 @@ COMMANDS:
979
1046
 
980
1047
  OPTIONS:
981
1048
  --name <label> Wallet name (default: "default")
1049
+ --provider <local|privy> Wallet provider: "local" (default) stores encrypted keys on disk,
1050
+ "privy" creates server-side wallets via Privy API
982
1051
  --to <address> Recipient address (required for send)
983
1052
  --amount <number> Amount to send in human-readable format (required unless --max)
984
1053
  --chain <evm|solana> Blockchain to use (required for send)
985
1054
  --token <address> Token contract/mint address (optional, sends native if omitted)
986
1055
  --wallet <name> Wallet to use (optional, uses default if omitted; use "walletconnect" or "wc" for WalletConnect, EVM only)
987
1056
  --max Send entire balance (deducts gas for native transfers)
988
- --unsafe-no-password Skip encryption — private keys stored UNENCRYPTED on disk (create only)
1057
+ --unsafe-no-password Skip encryption — private keys stored UNENCRYPTED on disk (local only)
989
1058
  --human Enable interactive prompts (for human terminal use only)
990
1059
 
991
1060
  PASSWORD RESOLUTION (automatic, in order):
@@ -995,11 +1064,15 @@ PASSWORD RESOLUTION (automatic, in order):
995
1064
 
996
1065
  ENVIRONMENT:
997
1066
  NANSEN_WALLET_PASSWORD Wallet encryption password
1067
+ PRIVY_APP_ID Privy application ID (required for --provider privy)
1068
+ PRIVY_APP_SECRET Privy application secret (required for --provider privy)
1069
+ NANSEN_WALLET_PROVIDER Default provider for wallet create ("local" or "privy")
998
1070
  NANSEN_EVM_RPC Custom EVM RPC endpoint
999
1071
  NANSEN_SOLANA_RPC Custom Solana RPC endpoint
1000
1072
 
1001
1073
  EXAMPLES:
1002
1074
  NANSEN_WALLET_PASSWORD=mypass nansen wallet create --name trading
1075
+ nansen wallet create --name agent-wallet --provider privy
1003
1076
  nansen wallet list
1004
1077
  nansen wallet export trading
1005
1078
  nansen wallet default trading
@@ -5,21 +5,62 @@
5
5
  * (hardware wallets, mobile wallets) instead of local key storage.
6
6
  * Uses the walletconnect CLI binary (subprocess-based, same as x402).
7
7
  *
8
- * EVM only Solana via WalletConnect is not supported.
8
+ * Supports EVM chains and Solana (trading only).
9
9
  */
10
10
 
11
11
  import { wcExec } from './walletconnect-exec.js';
12
12
 
13
+ const SOLANA_MAINNET_CHAIN = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp';
14
+
15
+ /**
16
+ * Extract the first JSON line from walletconnect CLI output.
17
+ * The CLI may print status messages before the JSON result.
18
+ */
19
+ function parseWcJson(output) {
20
+ const lines = output.split('\n');
21
+ const startIdx = lines.findIndex(l => l.trimStart().startsWith('{'));
22
+ if (startIdx === -1) throw new Error('No JSON output from walletconnect');
23
+
24
+ // Handle multi-line JSON: collect lines until braces balance
25
+ let braces = 0;
26
+ const jsonLines = [];
27
+ for (let i = startIdx; i < lines.length; i++) {
28
+ jsonLines.push(lines[i]);
29
+ for (const ch of lines[i]) {
30
+ if (ch === '{') braces++;
31
+ else if (ch === '}') braces--;
32
+ }
33
+ if (braces === 0) break;
34
+ }
35
+ return JSON.parse(jsonLines.join('\n'));
36
+ }
37
+
13
38
  /**
14
39
  * Get the address of the connected WalletConnect wallet.
15
40
  * Returns the first account address, or null if not connected / binary missing.
41
+ *
42
+ * @param {string} [chainType] - Optional: 'evm' or 'solana'. Filters accounts by chain prefix.
43
+ * No arg = first account (backward compat).
16
44
  */
17
- export async function getWalletConnectAddress() {
45
+ export async function getWalletConnectAddress(chainType) {
18
46
  try {
19
47
  const output = await wcExec('walletconnect', ['whoami', '--json'], 3000);
20
48
  const data = JSON.parse(output);
21
49
  if (data.connected === false) return null;
22
- return data.accounts?.[0]?.address || null;
50
+ const accounts = data.accounts || [];
51
+ if (!accounts.length) return null;
52
+
53
+ if (chainType === 'solana') {
54
+ // Match Solana mainnet only — reject devnet/testnet to prevent wrong-network trades
55
+ const solAccount = accounts.find(a => a.chain === SOLANA_MAINNET_CHAIN);
56
+ return solAccount?.address || null;
57
+ }
58
+ if (chainType === 'evm') {
59
+ const evmAccount = accounts.find(a => a.chain?.startsWith('eip155:'));
60
+ return evmAccount?.address || null;
61
+ }
62
+ // No filter — return first account address (backward compat)
63
+ return accounts[0]?.address || null;
23
64
  } catch {
24
65
  return null;
25
66
  }
@@ -50,13 +91,8 @@ export async function sendTransactionViaWalletConnect(txData, timeoutMs = 120000
50
91
  };
51
92
 
52
93
  const output = await wcExec('walletconnect', ['send-transaction', JSON.stringify(payload)], timeoutMs);
94
+ const result = parseWcJson(output);
53
95
 
54
- // walletconnect may print status messages before the JSON line — extract JSON only
55
- const jsonLine = output.split('\n').find(line => line.startsWith('{'));
56
- if (!jsonLine) throw new Error('No JSON output from walletconnect send-transaction');
57
- const result = JSON.parse(jsonLine);
58
-
59
- // The CLI returns { transactionHash: "0x..." }
60
96
  if (result.transactionHash) return { txHash: result.transactionHash };
61
97
  if (result.txHash) return { txHash: result.txHash };
62
98
  if (result.signedTransaction) return { signedTransaction: result.signedTransaction };
@@ -89,3 +125,31 @@ export async function sendApprovalViaWalletConnect(tokenAddress, spenderAddress,
89
125
  chainId,
90
126
  });
91
127
  }
128
+
129
+ /**
130
+ * Sign a Solana transaction via WalletConnect.
131
+ *
132
+ * The wallet signs the transaction and returns either:
133
+ * - { signedTransaction: "<base58>" } — full signed transaction
134
+ * - { signature: "<base58>" } — raw Ed25519 signature only
135
+ *
136
+ * @param {string} txBase58 - Base58-encoded Solana transaction
137
+ * @param {number} [timeoutMs=120000] - Timeout for user approval
138
+ * @returns {{ signedTransaction?: string, signature?: string }}
139
+ */
140
+ export async function sendSolanaTransactionViaWalletConnect(txBase58, timeoutMs = 120000) {
141
+ const payload = {
142
+ transaction: txBase58,
143
+ chainId: SOLANA_MAINNET_CHAIN,
144
+ };
145
+
146
+ const output = await wcExec('walletconnect', ['send-transaction', JSON.stringify(payload)], timeoutMs);
147
+ const result = parseWcJson(output);
148
+
149
+ if (result.signedTransaction) return { signedTransaction: result.signedTransaction };
150
+ if (result.signature) return { signature: result.signature };
151
+ // Some wallets (e.g. Phantom) return 'transaction' instead of 'signedTransaction'
152
+ if (result.transaction) return { signedTransaction: result.transaction };
153
+
154
+ throw new Error('Unexpected response from walletconnect Solana sign');
155
+ }