nansen-cli 1.14.0 → 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 +15 -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 +1 -1
- package/src/privy.js +359 -0
- package/src/schema.json +42 -2
- package/src/trading.js +238 -90
- package/src/transfer.js +150 -25
- package/src/wallet.js +112 -39
- package/src/x402-svm.js +43 -24
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;
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
521
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
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 -
|
|
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
|
|
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 (
|
|
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
|
package/src/x402-svm.js
CHANGED
|
@@ -268,28 +268,15 @@ function signEd25519(message, keypairHex) {
|
|
|
268
268
|
// ============= x402 Solana Payment =============
|
|
269
269
|
|
|
270
270
|
/**
|
|
271
|
-
* Build
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
* The facilitator is the fee payer (index 0), client signs at index 1.
|
|
275
|
-
*
|
|
276
|
-
* NOTE: This requires a recent blockhash from Solana RPC. For the initial implementation,
|
|
277
|
-
* we fetch it inline. In production, this should be cached.
|
|
271
|
+
* Build an unsigned Solana x402 payment transaction.
|
|
272
|
+
* Returns the serialized MessageV0 bytes and the full transaction bytes
|
|
273
|
+
* (with both signature slots as 64 zero bytes).
|
|
278
274
|
*
|
|
279
|
-
*
|
|
280
|
-
* @param {string} keypairHex - 128-char hex string (64 bytes: seed + pubkey)
|
|
281
|
-
* @param {string} walletAddress - Signer's Solana address (base58)
|
|
282
|
-
* @param {string} resource - Original request URL
|
|
283
|
-
* @param {string} recentBlockhash - Recent blockhash from Solana RPC (base58)
|
|
284
|
-
* @param {number} decimals - Token decimals (default 6 for USDC)
|
|
285
|
-
* @param {string} tokenProgram - Token program address (auto-detect if not provided)
|
|
286
|
-
* @returns {string} Base64-encoded PaymentPayload for Payment-Signature header
|
|
275
|
+
* Used by local-key signing (createSvmPaymentPayload) and Privy server wallet signing.
|
|
287
276
|
*/
|
|
288
|
-
export function
|
|
277
|
+
export function buildUnsignedSvmTransaction(
|
|
289
278
|
requirements,
|
|
290
|
-
keypairHex,
|
|
291
279
|
walletAddress,
|
|
292
|
-
resource,
|
|
293
280
|
recentBlockhash,
|
|
294
281
|
decimals = 6,
|
|
295
282
|
tokenProgram = TOKEN_PROGRAM,
|
|
@@ -356,7 +343,6 @@ export function createSvmPaymentPayload(
|
|
|
356
343
|
},
|
|
357
344
|
];
|
|
358
345
|
|
|
359
|
-
// Build MessageV0
|
|
360
346
|
const messageBytes = buildMessageV0({
|
|
361
347
|
feePayer: feePayerStr,
|
|
362
348
|
instructions,
|
|
@@ -364,17 +350,50 @@ export function createSvmPaymentPayload(
|
|
|
364
350
|
accounts: null,
|
|
365
351
|
});
|
|
366
352
|
|
|
353
|
+
// Build transaction: compact-u16(numSignatures) + signatures + message
|
|
354
|
+
// 2 signatures: [facilitator placeholder (64 zero bytes), client placeholder (64 zero bytes)]
|
|
355
|
+
const numSigs = encodeCompactU16(2);
|
|
356
|
+
const txBytes = Buffer.concat([
|
|
357
|
+
numSigs,
|
|
358
|
+
Buffer.alloc(64), // facilitator placeholder
|
|
359
|
+
Buffer.alloc(64), // client placeholder
|
|
360
|
+
messageBytes,
|
|
361
|
+
]);
|
|
362
|
+
|
|
363
|
+
return { messageBytes, txBase64: txBytes.toString('base64') };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Build a signed Solana x402 payment transaction using a local Ed25519 keypair.
|
|
368
|
+
* Calls buildUnsignedSvmTransaction internally, then signs with the private key.
|
|
369
|
+
*
|
|
370
|
+
* @returns {string} Base64-encoded PaymentPayload JSON for Payment-Signature header
|
|
371
|
+
*/
|
|
372
|
+
export function createSvmPaymentPayload(
|
|
373
|
+
requirements,
|
|
374
|
+
keypairHex,
|
|
375
|
+
walletAddress,
|
|
376
|
+
resource,
|
|
377
|
+
recentBlockhash,
|
|
378
|
+
decimals = 6,
|
|
379
|
+
tokenProgram = TOKEN_PROGRAM,
|
|
380
|
+
) {
|
|
381
|
+
const { messageBytes } = buildUnsignedSvmTransaction(
|
|
382
|
+
requirements,
|
|
383
|
+
walletAddress,
|
|
384
|
+
recentBlockhash,
|
|
385
|
+
decimals,
|
|
386
|
+
tokenProgram,
|
|
387
|
+
);
|
|
388
|
+
|
|
367
389
|
// Sign: client signs the full message (with 0x80 version prefix already included)
|
|
368
390
|
const clientSignature = signEd25519(messageBytes, keypairHex);
|
|
369
391
|
|
|
370
|
-
//
|
|
371
|
-
// 2 signatures: [facilitator placeholder (64 zero bytes), client signature]
|
|
392
|
+
// Rebuild transaction with the real client signature at slot 1
|
|
372
393
|
const numSigs = encodeCompactU16(2);
|
|
373
|
-
const facilitatorPlaceholder = Buffer.alloc(64); // all zeros
|
|
374
|
-
|
|
375
394
|
const txBytes = Buffer.concat([
|
|
376
395
|
numSigs,
|
|
377
|
-
|
|
396
|
+
Buffer.alloc(64), // facilitator placeholder
|
|
378
397
|
clientSignature,
|
|
379
398
|
messageBytes,
|
|
380
399
|
]);
|