nansen-cli 1.8.0 → 1.9.1
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 +103 -0
- package/README.md +1 -1
- package/package.json +5 -1
- package/src/api.js +51 -11
- package/src/chain-ids.js +19 -0
- package/src/cli.js +28 -9
- package/src/trading.js +324 -25
- package/src/transfer.js +133 -2
- package/src/wallet.js +11 -9
- package/src/walletconnect-exec.js +22 -0
- package/src/walletconnect-trading.js +91 -0
- package/src/walletconnect-x402.js +215 -0
- package/.changeset/README.md +0 -8
- package/AGENTS.md +0 -176
- package/CLAUDE.md +0 -217
- package/SKILL.md +0 -136
- package/TODO.md +0 -42
- package/scripts/check-changeset.js +0 -28
- package/vitest.e2e.config.js +0 -10
package/src/wallet.js
CHANGED
|
@@ -629,45 +629,47 @@ export function buildWalletCommands(deps = {}) {
|
|
|
629
629
|
|
|
630
630
|
'send': async () => {
|
|
631
631
|
const { sendTokens } = await import('./transfer.js');
|
|
632
|
-
|
|
632
|
+
|
|
633
633
|
if (!options.to) {
|
|
634
634
|
log('❌ --to <address> is required');
|
|
635
635
|
exit(1);
|
|
636
636
|
return;
|
|
637
637
|
}
|
|
638
|
-
|
|
638
|
+
|
|
639
639
|
const isMax = flags.max || options.amount === 'max';
|
|
640
640
|
if (!options.amount && !isMax) {
|
|
641
641
|
log('❌ --amount <number> or --max is required');
|
|
642
642
|
exit(1);
|
|
643
643
|
return;
|
|
644
644
|
}
|
|
645
|
-
|
|
645
|
+
|
|
646
646
|
if (!options.chain) {
|
|
647
647
|
log('❌ --chain <evm|solana> is required');
|
|
648
648
|
exit(1);
|
|
649
649
|
return;
|
|
650
650
|
}
|
|
651
|
-
|
|
651
|
+
|
|
652
652
|
if (!['evm', 'solana', 'ethereum', 'base'].includes(options.chain)) {
|
|
653
653
|
log('❌ --chain must be one of: evm, solana, ethereum, base');
|
|
654
654
|
exit(1);
|
|
655
655
|
return;
|
|
656
656
|
}
|
|
657
|
-
|
|
658
|
-
const
|
|
657
|
+
|
|
658
|
+
const isWalletConnect = options.wallet === 'walletconnect' || options.wallet === 'wc';
|
|
659
|
+
const password = isWalletConnect ? null : (process.env.NANSEN_WALLET_PASSWORD || await promptPassword('Enter wallet password: ', deps));
|
|
659
660
|
const dryRun = flags['dry-run'] || flags.dryRun;
|
|
660
|
-
|
|
661
|
+
|
|
661
662
|
try {
|
|
662
663
|
const sendOpts = {
|
|
663
664
|
to: options.to,
|
|
664
665
|
amount: isMax ? '0' : String(options.amount),
|
|
665
666
|
chain: options.chain,
|
|
666
667
|
token: options.token || null,
|
|
667
|
-
wallet: options.wallet || null,
|
|
668
|
+
wallet: isWalletConnect ? null : (options.wallet || null),
|
|
668
669
|
max: isMax,
|
|
669
670
|
password,
|
|
670
671
|
dryRun,
|
|
672
|
+
walletconnect: isWalletConnect,
|
|
671
673
|
};
|
|
672
674
|
|
|
673
675
|
if (dryRun) {
|
|
@@ -731,7 +733,7 @@ OPTIONS:
|
|
|
731
733
|
--amount <number> Amount to send in human-readable format (required unless --max)
|
|
732
734
|
--chain <evm|solana> Blockchain to use (required for send)
|
|
733
735
|
--token <address> Token contract/mint address (optional, sends native if omitted)
|
|
734
|
-
--wallet <name> Wallet to use (optional, uses default if omitted)
|
|
736
|
+
--wallet <name> Wallet to use (optional, uses default if omitted; use "walletconnect" or "wc" for WalletConnect, EVM only)
|
|
735
737
|
--max Send entire balance (deducts gas for native transfers)
|
|
736
738
|
|
|
737
739
|
ENVIRONMENT:
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared subprocess helper for WalletConnect CLI calls.
|
|
3
|
+
*
|
|
4
|
+
* Used by walletconnect-x402.js and walletconnect-trading.js.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { execFile } from 'child_process';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Execute a walletconnect CLI command and return stdout.
|
|
11
|
+
*/
|
|
12
|
+
export function wcExec(cmd, args, timeoutMs = 10000) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
execFile(cmd, args, { timeout: timeoutMs }, (err, stdout, stderr) => {
|
|
15
|
+
if (err) {
|
|
16
|
+
reject(new Error(err.message));
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
resolve(stdout.trim());
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WalletConnect Trading & Transfer Support
|
|
3
|
+
*
|
|
4
|
+
* Allows signing and broadcasting transactions via a WalletConnect-connected wallet
|
|
5
|
+
* (hardware wallets, mobile wallets) instead of local key storage.
|
|
6
|
+
* Uses the walletconnect CLI binary (subprocess-based, same as x402).
|
|
7
|
+
*
|
|
8
|
+
* EVM only — Solana via WalletConnect is not supported.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { wcExec } from './walletconnect-exec.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Get the address of the connected WalletConnect wallet.
|
|
15
|
+
* Returns the first account address, or null if not connected / binary missing.
|
|
16
|
+
*/
|
|
17
|
+
export async function getWalletConnectAddress() {
|
|
18
|
+
try {
|
|
19
|
+
const output = await wcExec('walletconnect', ['whoami', '--json'], 3000);
|
|
20
|
+
const data = JSON.parse(output);
|
|
21
|
+
if (data.connected === false) return null;
|
|
22
|
+
return data.accounts?.[0]?.address || null;
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Send a transaction via WalletConnect.
|
|
30
|
+
*
|
|
31
|
+
* The connected wallet signs and may broadcast the transaction.
|
|
32
|
+
* Returns either { txHash } (wallet broadcast) or { signedTransaction } (we broadcast).
|
|
33
|
+
*
|
|
34
|
+
* @param {object} txData - Transaction data: { to, data, value, gas, chainId }
|
|
35
|
+
* @param {number} [timeoutMs=120000] - Timeout for user approval
|
|
36
|
+
* @returns {{ txHash?: string, signedTransaction?: string }}
|
|
37
|
+
*/
|
|
38
|
+
export async function sendTransactionViaWalletConnect(txData, timeoutMs = 120000) {
|
|
39
|
+
// The walletconnect CLI expects chainId as "eip155:<id>" string format
|
|
40
|
+
const chainId = txData.chainId
|
|
41
|
+
? (String(txData.chainId).startsWith('eip155:') ? txData.chainId : `eip155:${txData.chainId}`)
|
|
42
|
+
: undefined;
|
|
43
|
+
|
|
44
|
+
const payload = {
|
|
45
|
+
to: txData.to,
|
|
46
|
+
data: txData.data || '0x',
|
|
47
|
+
value: txData.value ? '0x' + BigInt(txData.value).toString(16) : '0x0',
|
|
48
|
+
gas: txData.gas ? '0x' + BigInt(txData.gas).toString(16) : undefined,
|
|
49
|
+
chainId,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const output = await wcExec('walletconnect', ['send-transaction', JSON.stringify(payload)], timeoutMs);
|
|
53
|
+
|
|
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
|
+
if (result.transactionHash) return { txHash: result.transactionHash };
|
|
61
|
+
if (result.txHash) return { txHash: result.txHash };
|
|
62
|
+
if (result.signedTransaction) return { signedTransaction: result.signedTransaction };
|
|
63
|
+
|
|
64
|
+
throw new Error('Unexpected response from walletconnect send-transaction');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Send an ERC-20 approval via WalletConnect.
|
|
69
|
+
*
|
|
70
|
+
* Builds approve(spender, MAX_UINT256) calldata and delegates to sendTransactionViaWalletConnect.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} tokenAddress - ERC-20 token contract
|
|
73
|
+
* @param {string} spenderAddress - Approval target (e.g. DEX router)
|
|
74
|
+
* @param {number} chainId - EIP-155 chain ID
|
|
75
|
+
* @returns {{ txHash?: string, signedTransaction?: string }}
|
|
76
|
+
*/
|
|
77
|
+
export async function sendApprovalViaWalletConnect(tokenAddress, spenderAddress, chainId) {
|
|
78
|
+
// ERC-20 approve(address spender, uint256 amount) selector = 0x095ea7b3
|
|
79
|
+
const MAX_UINT256_HEX = 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff';
|
|
80
|
+
const data = '0x095ea7b3'
|
|
81
|
+
+ spenderAddress.slice(2).toLowerCase().padStart(64, '0')
|
|
82
|
+
+ MAX_UINT256_HEX;
|
|
83
|
+
|
|
84
|
+
return sendTransactionViaWalletConnect({
|
|
85
|
+
to: tokenAddress,
|
|
86
|
+
data,
|
|
87
|
+
value: '0',
|
|
88
|
+
gas: '100000',
|
|
89
|
+
chainId,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* x402 Auto-Payment via WalletConnect
|
|
3
|
+
*
|
|
4
|
+
* Handles automatic payment signing when the API returns HTTP 402.
|
|
5
|
+
* Uses the walletconnect CLI to check wallet connection and sign EIP-712 typed data.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import crypto from 'crypto';
|
|
9
|
+
import { NansenError, ErrorCode } from './api.js';
|
|
10
|
+
import { wcExec } from './walletconnect-exec.js';
|
|
11
|
+
import { EVM_CHAIN_IDS } from './chain-ids.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Check if a WalletConnect wallet session is active.
|
|
15
|
+
* Returns { wallet, accounts, expires } or null.
|
|
16
|
+
*/
|
|
17
|
+
export async function checkWalletConnection() {
|
|
18
|
+
try {
|
|
19
|
+
const output = await wcExec('walletconnect', ['whoami', '--json'], 3000);
|
|
20
|
+
const data = JSON.parse(output);
|
|
21
|
+
if (data.connected === false) return null;
|
|
22
|
+
return data;
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Select a compatible payment requirement from the accepts array.
|
|
30
|
+
* Requires scheme=exact and EIP-3009 TransferWithAuthorization support (extra.name + extra.version).
|
|
31
|
+
*/
|
|
32
|
+
export function selectPaymentRequirement(accepts) {
|
|
33
|
+
if (!Array.isArray(accepts) || accepts.length === 0) return null;
|
|
34
|
+
|
|
35
|
+
return accepts.find(req =>
|
|
36
|
+
req.scheme === 'exact' &&
|
|
37
|
+
req.extra?.name &&
|
|
38
|
+
req.extra?.version
|
|
39
|
+
) || null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parse chain ID from network string (e.g., "eip155:8453" → 8453)
|
|
44
|
+
*/
|
|
45
|
+
function parseChainId(network) {
|
|
46
|
+
if (!network) return null;
|
|
47
|
+
const match = network.match(/^eip155:(\d+)$/);
|
|
48
|
+
return match ? Number(match[1]) : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Build EIP-712 typed data for TransferWithAuthorization (EIP-3009).
|
|
53
|
+
*/
|
|
54
|
+
export function buildEIP712TypedData({ fromAddress, requirement }) {
|
|
55
|
+
const { asset, payTo, extra, maxTimeoutSeconds } = requirement;
|
|
56
|
+
// x402 uses "amount", fall back to "maxAmountRequired" for compatibility
|
|
57
|
+
const amount = requirement.amount || requirement.maxAmountRequired;
|
|
58
|
+
|
|
59
|
+
// Determine chain ID: extra.chainId > parsed from network > fallback map > base
|
|
60
|
+
const chainId = extra.chainId || parseChainId(requirement.network) || EVM_CHAIN_IDS[requirement.chain] || EVM_CHAIN_IDS.base;
|
|
61
|
+
|
|
62
|
+
const now = Math.floor(Date.now() / 1000);
|
|
63
|
+
const nonce = '0x' + crypto.randomBytes(32).toString('hex');
|
|
64
|
+
|
|
65
|
+
const typedData = {
|
|
66
|
+
types: {
|
|
67
|
+
EIP712Domain: [
|
|
68
|
+
{ name: 'name', type: 'string' },
|
|
69
|
+
{ name: 'version', type: 'string' },
|
|
70
|
+
{ name: 'chainId', type: 'uint256' },
|
|
71
|
+
{ name: 'verifyingContract', type: 'address' },
|
|
72
|
+
],
|
|
73
|
+
TransferWithAuthorization: [
|
|
74
|
+
{ name: 'from', type: 'address' },
|
|
75
|
+
{ name: 'to', type: 'address' },
|
|
76
|
+
{ name: 'value', type: 'uint256' },
|
|
77
|
+
{ name: 'validAfter', type: 'uint256' },
|
|
78
|
+
{ name: 'validBefore', type: 'uint256' },
|
|
79
|
+
{ name: 'nonce', type: 'bytes32' },
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
primaryType: 'TransferWithAuthorization',
|
|
83
|
+
domain: {
|
|
84
|
+
name: extra.name,
|
|
85
|
+
version: extra.version,
|
|
86
|
+
chainId,
|
|
87
|
+
verifyingContract: asset,
|
|
88
|
+
},
|
|
89
|
+
message: {
|
|
90
|
+
from: fromAddress,
|
|
91
|
+
to: payTo,
|
|
92
|
+
value: amount,
|
|
93
|
+
validAfter: now - 600, // 10 min in the past to tolerate clock skew between client and verifier
|
|
94
|
+
validBefore: now + (maxTimeoutSeconds || 120),
|
|
95
|
+
nonce,
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
return typedData;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Build the base64-encoded Payment-Signature header value.
|
|
104
|
+
* Follows x402 v2 spec: { x402Version, resource, accepted, payload }
|
|
105
|
+
*/
|
|
106
|
+
export function buildPaymentSignatureHeader({ signature, authorization, resource, accepted }) {
|
|
107
|
+
const paymentPayload = {
|
|
108
|
+
x402Version: 2,
|
|
109
|
+
resource: resource || { url: '', description: '', mimeType: '' },
|
|
110
|
+
accepted: accepted || {},
|
|
111
|
+
payload: {
|
|
112
|
+
signature,
|
|
113
|
+
authorization,
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
return btoa(JSON.stringify(paymentPayload));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Format amount for human-readable display (e.g., "0.01 USDC")
|
|
121
|
+
*/
|
|
122
|
+
function formatPaymentAmount(requirement) {
|
|
123
|
+
const { extra } = requirement;
|
|
124
|
+
const rawAmount = requirement.amount || requirement.maxAmountRequired;
|
|
125
|
+
const symbol = extra.symbol || extra.name || 'tokens';
|
|
126
|
+
const decimals = extra.decimals || 6;
|
|
127
|
+
const amount = Number(rawAmount) / Math.pow(10, decimals);
|
|
128
|
+
const chain = requirement.network || requirement.chain || 'unknown';
|
|
129
|
+
return `${amount} ${symbol} on ${chain}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Handle x402 payment: check wallet, sign, return Payment-Signature header.
|
|
134
|
+
*
|
|
135
|
+
* @param {Object} paymentRequirements - Decoded payment requirements from 402 response
|
|
136
|
+
* @param {string} requestUrl - The original request URL (for context in errors)
|
|
137
|
+
* @returns {string} Base64-encoded Payment-Signature header value
|
|
138
|
+
* @throws {NansenError} On failure
|
|
139
|
+
*/
|
|
140
|
+
export async function handleX402Payment(paymentRequirements) {
|
|
141
|
+
// 1. Check wallet connection
|
|
142
|
+
const wallet = await checkWalletConnection();
|
|
143
|
+
if (!wallet) {
|
|
144
|
+
throw new NansenError(
|
|
145
|
+
'x402 payment required but no wallet connected. Run `walletconnect connect` first.',
|
|
146
|
+
ErrorCode.PAYMENT_REQUIRED,
|
|
147
|
+
402
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const fromAddress = wallet.accounts[0]?.address;
|
|
152
|
+
if (!fromAddress) {
|
|
153
|
+
throw new NansenError(
|
|
154
|
+
'x402 payment required but wallet has no accounts.',
|
|
155
|
+
ErrorCode.PAYMENT_REQUIRED,
|
|
156
|
+
402
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 2. Select compatible payment requirement
|
|
161
|
+
const accepts = paymentRequirements.accepts || paymentRequirements;
|
|
162
|
+
const requirement = selectPaymentRequirement(Array.isArray(accepts) ? accepts : [accepts]);
|
|
163
|
+
if (!requirement) {
|
|
164
|
+
const available = (Array.isArray(accepts) ? accepts : []).map(r => r.scheme).join(', ');
|
|
165
|
+
throw new NansenError(
|
|
166
|
+
`x402 payment required but no compatible payment method found. Available: ${available || 'none'}. Need scheme=exact with EIP-3009 support.`,
|
|
167
|
+
ErrorCode.PAYMENT_REQUIRED,
|
|
168
|
+
402
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 3. Build EIP-712 typed data
|
|
173
|
+
const typedData = buildEIP712TypedData({ fromAddress, requirement });
|
|
174
|
+
const typedDataJson = JSON.stringify(typedData);
|
|
175
|
+
|
|
176
|
+
// 4. Log payment info to stderr (stdout is for JSON output)
|
|
177
|
+
const amountStr = formatPaymentAmount(requirement);
|
|
178
|
+
process.stderr.write(`x402: Requesting payment approval (${amountStr})...\n`);
|
|
179
|
+
|
|
180
|
+
// 5. Sign via walletconnect CLI (120s timeout for user approval)
|
|
181
|
+
let signResult;
|
|
182
|
+
try {
|
|
183
|
+
const output = await wcExec('walletconnect', ['sign-typed-data', typedDataJson], 120000);
|
|
184
|
+
// walletconnect may print status messages before the JSON line — extract JSON only
|
|
185
|
+
const jsonLine = output.split('\n').find(line => line.startsWith('{'));
|
|
186
|
+
if (!jsonLine) throw new Error('No JSON output from walletconnect sign-typed-data');
|
|
187
|
+
signResult = JSON.parse(jsonLine);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
throw new NansenError(
|
|
190
|
+
`x402 payment signing failed: ${err.message}`,
|
|
191
|
+
ErrorCode.PAYMENT_REQUIRED,
|
|
192
|
+
402
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 6. Build Payment-Signature header (authorization values must be strings per x402 spec)
|
|
197
|
+
const authorization = {
|
|
198
|
+
from: fromAddress,
|
|
199
|
+
to: requirement.payTo,
|
|
200
|
+
value: (requirement.amount || requirement.maxAmountRequired).toString(),
|
|
201
|
+
validAfter: typedData.message.validAfter.toString(),
|
|
202
|
+
validBefore: typedData.message.validBefore.toString(),
|
|
203
|
+
nonce: typedData.message.nonce,
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const headerValue = buildPaymentSignatureHeader({
|
|
207
|
+
signature: signResult.signature,
|
|
208
|
+
authorization,
|
|
209
|
+
resource: paymentRequirements.resource || { url: '', description: '', mimeType: '' },
|
|
210
|
+
accepted: requirement,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
process.stderr.write(`x402: Payment signed successfully.\n`);
|
|
214
|
+
return headerValue;
|
|
215
|
+
}
|
package/.changeset/README.md
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
# Changesets
|
|
2
|
-
|
|
3
|
-
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
|
|
4
|
-
with multi-package repos, or single-package repos to help you version and publish your code. You can
|
|
5
|
-
find the full documentation for it [in the repository](https://github.com/changesets/changesets)
|
|
6
|
-
|
|
7
|
-
We have a quick list of common questions to get you started engaging with this project in
|
|
8
|
-
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
|
package/AGENTS.md
DELETED
|
@@ -1,176 +0,0 @@
|
|
|
1
|
-
# AGENTS.md — Contributor Guide
|
|
2
|
-
|
|
3
|
-
Guidance for AI coding agents (Claude Code, Codex, Copilot, etc.) working on this repository. If you're an agent **using** the CLI, see [README.md](README.md).
|
|
4
|
-
|
|
5
|
-
## Architecture
|
|
6
|
-
|
|
7
|
-
```
|
|
8
|
-
src/
|
|
9
|
-
├── index.js # Entry point (shebang, calls runCLI)
|
|
10
|
-
├── cli.js # Command router, arg parsing, schema, help text
|
|
11
|
-
├── api.js # NansenAPI client (REST, retry, cache, x402 auto-pay)
|
|
12
|
-
├── wallet.js # Wallet CRUD (create/list/show/export/delete/send)
|
|
13
|
-
├── trading.js # Quote + execute swaps (OKX router via API)
|
|
14
|
-
├── transfer.js # Token/native transfers (EVM + Solana)
|
|
15
|
-
├── x402.js # x402 payment orchestration (picks network, signs)
|
|
16
|
-
├── x402-evm.js # EVM payment signing (EIP-3009 transferWithAuthorization)
|
|
17
|
-
├── x402-svm.js # Solana payment signing (SPL transfer)
|
|
18
|
-
├── crypto.js # Key encryption/decryption (AES-256-GCM or plaintext)
|
|
19
|
-
└── update-check.js # Version upgrade notice
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
### Command routing
|
|
23
|
-
|
|
24
|
-
`src/index.js` → `runCLI()` in `src/cli.js`
|
|
25
|
-
|
|
26
|
-
Commands are built by three functions, merged in `runCLI()`:
|
|
27
|
-
- `buildCommands()` in cli.js — analytics commands (smart-money, profiler, token, etc.)
|
|
28
|
-
- `buildWalletCommands()` in wallet.js — wallet subcommands
|
|
29
|
-
- `buildTradingCommands()` in trading.js — quote/execute
|
|
30
|
-
|
|
31
|
-
Commands listed in `NO_AUTH_COMMANDS` skip API initialization. Everything else instantiates `NansenAPI` with retry, cache, and x402 config.
|
|
32
|
-
|
|
33
|
-
### Data flow: trade
|
|
34
|
-
|
|
35
|
-
```
|
|
36
|
-
CLI args → api.js GET /defi/quote → quote response
|
|
37
|
-
→ wallet.js decrypt key → trading.js sign tx → api.js POST /defi/execute → broadcast
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
### Data flow: x402 auto-pay
|
|
41
|
-
|
|
42
|
-
```
|
|
43
|
-
api.js (any call) → 402 response with payment requirements
|
|
44
|
-
→ x402.js rankRequirements() → picks cheapest network (EVM first)
|
|
45
|
-
→ x402-evm.js or x402-svm.js → sign USDC payment
|
|
46
|
-
→ api.js retries original request with Payment-Signature header
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
If EVM payment fails (insufficient funds), the async generator yields a Solana signature as fallback.
|
|
50
|
-
|
|
51
|
-
### Output convention
|
|
52
|
-
|
|
53
|
-
Core functions return data objects. The CLI layer formats via `formatOutput()`. Never `console.log` in core functions — use the `log` dependency injection for CLI output.
|
|
54
|
-
|
|
55
|
-
## Development
|
|
56
|
-
|
|
57
|
-
```bash
|
|
58
|
-
npm install # Install dependencies
|
|
59
|
-
npm test # Run tests (vitest)
|
|
60
|
-
npm run test:watch # Watch mode
|
|
61
|
-
npm run test:coverage # With coverage
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
### Running locally
|
|
65
|
-
|
|
66
|
-
```bash
|
|
67
|
-
node src/index.js <command> [options]
|
|
68
|
-
|
|
69
|
-
# Examples
|
|
70
|
-
node src/index.js wallet create my-wallet
|
|
71
|
-
node src/index.js smart-money --chain solana --limit 5
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
## Testing
|
|
75
|
-
|
|
76
|
-
- **Framework:** Vitest
|
|
77
|
-
- **Test files:** `src/__tests__/*.test.js`
|
|
78
|
-
- **Current:** 577 tests across 13 test files
|
|
79
|
-
- **All new code must have tests**
|
|
80
|
-
- **Mock all RPC/API calls** — never hit real networks in tests
|
|
81
|
-
|
|
82
|
-
### Test structure
|
|
83
|
-
|
|
84
|
-
```js
|
|
85
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
86
|
-
|
|
87
|
-
global.fetch = vi.fn();
|
|
88
|
-
|
|
89
|
-
describe('featureName', () => {
|
|
90
|
-
beforeEach(() => {
|
|
91
|
-
fetch.mockReset();
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it('should do the thing', async () => {
|
|
95
|
-
fetch.mockResolvedValueOnce({
|
|
96
|
-
ok: true,
|
|
97
|
-
json: async () => ({ jsonrpc: '2.0', result: '0x...', id: 1 })
|
|
98
|
-
});
|
|
99
|
-
// test logic
|
|
100
|
-
});
|
|
101
|
-
});
|
|
102
|
-
```
|
|
103
|
-
|
|
104
|
-
### Required RPC mocks by code path
|
|
105
|
-
|
|
106
|
-
**EVM transfers:** `eth_getBalance`, `eth_gasPrice`, `eth_maxPriorityFeePerGas`, `eth_getTransactionCount`, `eth_estimateGas`, `eth_getCode`, `eth_sendRawTransaction`, `eth_getTransactionReceipt`
|
|
107
|
-
|
|
108
|
-
**Solana transfers:** `getBalance`, `getLatestBlockhash`, `sendTransaction`, `getSignatureStatuses`
|
|
109
|
-
|
|
110
|
-
**SPL token transfers** (additionally): `getTokenAccountsByOwner`, `getAccountInfo`
|
|
111
|
-
|
|
112
|
-
**Wallet operations:** No RPC mocks needed (file I/O only). Mock `fs` if testing file paths.
|
|
113
|
-
|
|
114
|
-
**API calls:** Mock `fetch` to return `{ ok: true, json: () => ({...}) }` or `{ ok: false, status: 402, headers: new Headers({...}) }` for x402 paths.
|
|
115
|
-
|
|
116
|
-
## Style Guide
|
|
117
|
-
|
|
118
|
-
- **ESM only** (`import`/`export`). No TypeScript, no transpilation.
|
|
119
|
-
- **No interactive prompts in core functions.** Use env vars: `NANSEN_WALLET_PASSWORD`, `NANSEN_API_KEY`.
|
|
120
|
-
- **Error handling:** `throw new Error('descriptive message')` in core. CLI catches and formats.
|
|
121
|
-
- **Actionable error messages** — tell the user what to do:
|
|
122
|
-
- ❌ `"Authentication failed"`
|
|
123
|
-
- ✅ `"Not logged in. Run: nansen login"`
|
|
124
|
-
- **BigInt for token amounts.** Never use floating point. Parse to BigInt with decimals.
|
|
125
|
-
- **Chain branching:** Use `chain === 'solana'` checks, not inheritance/polymorphism.
|
|
126
|
-
- **Minimal dependencies.** Prefer Node.js built-in APIs (crypto, fs, path, http).
|
|
127
|
-
|
|
128
|
-
## PR Checklist
|
|
129
|
-
|
|
130
|
-
- [ ] `npm test` passes (all tests)
|
|
131
|
-
- [ ] New code paths have test coverage
|
|
132
|
-
- [ ] No hardcoded secrets, API keys, or private keys
|
|
133
|
-
- [ ] No `console.log` in core functions (use `log` dep injection)
|
|
134
|
-
- [ ] Error messages are actionable (tell user what to do)
|
|
135
|
-
- [ ] CLI help text updated if adding/changing commands
|
|
136
|
-
- [ ] RPC mocks cover all methods in the code path
|
|
137
|
-
- [ ] Wallet flows work both with and without `NANSEN_WALLET_PASSWORD`
|
|
138
|
-
- [ ] Changeset added if changing user-facing behavior (add a `.changeset/<name>.md` file — `npm test` will warn if missing)
|
|
139
|
-
|
|
140
|
-
## Chains & Networks
|
|
141
|
-
|
|
142
|
-
**EVM:** Ethereum (chain ID 1), Base (8453). `CHAIN_IDS` in transfer.js only maps these two — other EVM chains will fail for transfers.
|
|
143
|
-
|
|
144
|
-
**Solana:** mainnet-beta. Supports native SOL, standard SPL tokens, and Token-2022 (Token Extensions).
|
|
145
|
-
|
|
146
|
-
**RPC endpoints:** Hardcoded in `CHAIN_RPCS` (transfer.js). Nansen API handles RPC for trading.
|
|
147
|
-
|
|
148
|
-
## Key Constants
|
|
149
|
-
|
|
150
|
-
| Constant | Value |
|
|
151
|
-
|----------|-------|
|
|
152
|
-
| USDC (Base) | `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913` |
|
|
153
|
-
| USDC (Solana) | `EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v` |
|
|
154
|
-
| x402 payment | $0.05 USDC per API call |
|
|
155
|
-
| Gas buffer | API provides `quote.gas` with 1.5x multiplier — use directly |
|
|
156
|
-
|
|
157
|
-
## Endpoint Quirks
|
|
158
|
-
|
|
159
|
-
These are internal details agents should know when writing or debugging tests:
|
|
160
|
-
|
|
161
|
-
- **`token holders --smart-money`** — Returns `UNSUPPORTED_FILTER` for tokens without smart money tracking. Not all tokens have this data.
|
|
162
|
-
- **`token flow-intelligence`** — May return all-zero flows for illiquid tokens. Normal, not an error.
|
|
163
|
-
- **`token screener --search`** — Client-side filtering. The CLI fetches up to 500 results, then filters locally.
|
|
164
|
-
- **`--fields`** — Applies to the entire response tree, including the `success`/`data` wrapper.
|
|
165
|
-
- **Profiler beta endpoints** use `recordsPerPage` instead of `per_page`. The CLI handles this automatically.
|
|
166
|
-
- **`profiler perp-positions`** — No pagination support; the API ignores the pagination parameter.
|
|
167
|
-
|
|
168
|
-
## Known Gotchas
|
|
169
|
-
|
|
170
|
-
1. **EIP-7702 delegated accounts** on Base have contract code. Always use `eth_estimateGas`, never hardcode 21000 gas.
|
|
171
|
-
2. **Solana SPL account ordering:** Writable accounts (destATA) must precede readonly (mint) in the transaction message.
|
|
172
|
-
3. **`getSignatureStatuses`** over `confirmTransaction` — the latter is deprecated and unreliable on public RPCs.
|
|
173
|
-
4. **`--max` native SOL:** Reserve 5000 lamports for fee. On EVM L2s, reserve 3x estimated gas for L1 data posting fees.
|
|
174
|
-
5. **Token-2022:** Use `TOKEN_2022_PROGRAM_ID` and `TransferCheckedInstruction` (not plain `Transfer`).
|
|
175
|
-
6. **CreateATA path:** When recipient doesn't have a token account, the sender creates it. This path in transfer.js has limited test coverage — add tests if modifying.
|
|
176
|
-
7. **`CHAIN_IDS` is incomplete:** Only ethereum and base are mapped. Adding new EVM chain support requires updating this map.
|