nansen-cli 1.8.0 → 1.9.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/SKILL.md +170 -76
- package/package.json +1 -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/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
|
+
}
|