nansen-cli 1.7.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/src/transfer.js CHANGED
@@ -7,6 +7,8 @@
7
7
  import crypto from 'crypto';
8
8
  import { base58Encode, exportWallet, getWalletConfig, verifyPassword } from './wallet.js';
9
9
  import { keccak256, signSecp256k1, rlpEncode, bigIntToMinBuf } from './crypto.js';
10
+ import { getWalletConnectAddress, sendTransactionViaWalletConnect } from './walletconnect-trading.js';
11
+ import { EVM_CHAIN_IDS } from './chain-ids.js';
10
12
 
11
13
  // ============= Constants =============
12
14
 
@@ -27,7 +29,8 @@ const CHAIN_RPCS = {
27
29
  'solana': process.env.NANSEN_SOLANA_RPC || DEFAULT_SOLANA_RPC,
28
30
  };
29
31
 
30
- const CHAIN_IDS = { 'ethereum': 1, 'evm': 1, 'base': 8453 };
32
+ // Alias: buildEvmTransaction uses 'evm' as a generic fallback
33
+ const CHAIN_IDS = { ...EVM_CHAIN_IDS, evm: 1 };
31
34
 
32
35
  // ============= Base58 =============
33
36
 
@@ -587,12 +590,19 @@ async function broadcastTransaction(signedTx, chain) {
587
590
  // Exported for testing
588
591
  export { parseAmount, formatAmount, signEd25519, encodeCompactU16, base58Decode, base58DecodePubkey, deriveATA, validateEvmAddress, validateSolanaAddress, bigIntToHex };
589
592
 
590
- export async function sendTokens({ to, amount, chain, token = null, wallet = null, password, max = false, dryRun = false }) {
593
+ export async function sendTokens({ to, amount, chain, token = null, wallet = null, password, max = false, dryRun = false, walletconnect = false }) {
591
594
  // Validate address
592
595
  const validate = chain === 'solana' ? validateSolanaAddress : validateEvmAddress;
593
596
  const v = validate(to);
594
597
  if (!v.valid) throw new Error(`Invalid recipient: ${v.error}`);
595
598
 
599
+ if (walletconnect) {
600
+ if (chain === 'solana') {
601
+ throw new Error('WalletConnect is only supported for EVM chains');
602
+ }
603
+ return sendTokensViaWalletConnect({ to, amount, chain, token, max, dryRun });
604
+ }
605
+
596
606
  const config = getWalletConfig();
597
607
  if (!verifyPassword(password, config)) throw new Error('Incorrect password');
598
608
 
@@ -701,6 +711,127 @@ export async function sendTokens({ to, amount, chain, token = null, wallet = nul
701
711
  };
702
712
  }
703
713
 
714
+ /**
715
+ * Send tokens via WalletConnect (EVM only).
716
+ */
717
+ async function sendTokensViaWalletConnect({ to, amount, chain, token, max, dryRun }) {
718
+ const rpcUrl = CHAIN_RPCS[chain] || CHAIN_RPCS.evm;
719
+ const chainId = CHAIN_IDS[chain] || 1;
720
+
721
+ const wcAddress = await getWalletConnectAddress();
722
+ if (!wcAddress) throw new Error('No WalletConnect session active. Run: walletconnect connect');
723
+
724
+ let txTo, txValue, txData, decimals = 18;
725
+
726
+ if (token) {
727
+ // Validate ERC-20 contract
728
+ const code = await rpcCall(rpcUrl, 'eth_getCode', [token, 'latest']);
729
+ if (!code || code === '0x' || code === '0x0') {
730
+ throw new Error(`Address ${token} is not a contract — not a valid ERC-20 token`);
731
+ }
732
+ const decResult = await rpcCall(rpcUrl, 'eth_call', [{ to: token, data: '0x313ce567' }, 'latest']);
733
+ decimals = parseInt(decResult, 16);
734
+
735
+ if (max) {
736
+ // Max ERC-20: full token balance
737
+ const balResult = await rpcCall(rpcUrl, 'eth_call', [{
738
+ to: token, data: '0x70a08231' + wcAddress.slice(2).toLowerCase().padStart(64, '0'),
739
+ }, 'latest']);
740
+ const tokenBalance = BigInt(balResult || '0x0');
741
+ if (tokenBalance === 0n) throw new Error('Token balance is zero');
742
+ amount = formatAmount(tokenBalance, decimals);
743
+ stderr(` Max send: ${amount} (ERC-20)`);
744
+ }
745
+
746
+ const amountRaw = parseAmount(amount, decimals);
747
+ const toStripped = to.replace(/^0x/, '').padStart(64, '0');
748
+ const amtHex = amountRaw.toString(16).padStart(64, '0');
749
+
750
+ txTo = token;
751
+ txValue = '0';
752
+ txData = '0x' + ERC20_TRANSFER_SELECTOR + toStripped + amtHex;
753
+ } else {
754
+ // Native ETH
755
+ if (max) {
756
+ const balHex = await rpcCall(rpcUrl, 'eth_getBalance', [wcAddress, 'latest']);
757
+ const ethBalance = BigInt(balHex);
758
+ // Reserve gas estimate (3x gasLimit * baseFee estimate)
759
+ let estGasLimit;
760
+ try {
761
+ const dummyEstimate = await rpcCall(rpcUrl, 'eth_estimateGas', [
762
+ { from: wcAddress, to, value: '0x1' },
763
+ ]);
764
+ estGasLimit = BigInt(dummyEstimate) * 120n / 100n;
765
+ } catch {
766
+ estGasLimit = 21000n;
767
+ }
768
+ const feeHistory = await rpcCall(rpcUrl, 'eth_feeHistory', [4, 'latest', [50]]);
769
+ const baseFee = BigInt(feeHistory.baseFeePerGas[feeHistory.baseFeePerGas.length - 1]);
770
+ const safeReserve = baseFee * 2n * estGasLimit * 3n;
771
+ if (ethBalance <= safeReserve) throw new Error(`Insufficient balance: ${ethBalance} wei (need > ${safeReserve} for gas)`);
772
+ const maxAmount = ethBalance - safeReserve;
773
+ amount = formatAmount(maxAmount, 18);
774
+ stderr(` Max send: ${amount} ETH (reserved ${formatAmount(safeReserve, 18)} for gas)`);
775
+ }
776
+
777
+ const amountRaw = parseAmount(amount, 18);
778
+ txTo = to;
779
+ txValue = amountRaw.toString();
780
+ txData = '0x';
781
+ }
782
+
783
+ if (dryRun) {
784
+ return {
785
+ dryRun: true,
786
+ from: wcAddress,
787
+ to, amount, token, chain,
788
+ };
789
+ }
790
+
791
+ // Estimate gas instead of hardcoding — ERC-20 transfers with hooks may need more than 100k
792
+ let gasLimit;
793
+ try {
794
+ const estimateParams = { from: wcAddress, to: txTo };
795
+ if (txData && txData !== '0x') estimateParams.data = txData;
796
+ if (txValue && txValue !== '0') estimateParams.value = '0x' + BigInt(txValue).toString(16);
797
+ const gasEstimate = await rpcCall(rpcUrl, 'eth_estimateGas', [estimateParams]);
798
+ gasLimit = (BigInt(gasEstimate) * 120n / 100n).toString(); // 20% buffer
799
+ } catch {
800
+ gasLimit = token ? '100000' : '21000'; // fallback
801
+ }
802
+
803
+ stderr(' Sending transaction via WalletConnect...');
804
+ const wcResult = await sendTransactionViaWalletConnect({
805
+ to: txTo,
806
+ data: txData,
807
+ value: txValue,
808
+ gas: gasLimit,
809
+ chainId,
810
+ });
811
+
812
+ let txHash;
813
+ if (wcResult.txHash) {
814
+ txHash = wcResult.txHash;
815
+ } else if (wcResult.signedTransaction) {
816
+ txHash = await broadcastTransaction(wcResult.signedTransaction, chain);
817
+ } else {
818
+ throw new Error('No transaction hash or signed transaction returned from WalletConnect');
819
+ }
820
+
821
+ // Wait for confirmation
822
+ const confirmation = await waitForEvmConfirmation(rpcUrl, txHash);
823
+
824
+ return {
825
+ success: true,
826
+ transactionHash: txHash,
827
+ confirmed: confirmation.confirmed,
828
+ ...(confirmation.blockNumber ? { blockNumber: confirmation.blockNumber } : {}),
829
+ from: wcAddress,
830
+ to, amount, token, chain,
831
+ explorer: getExplorerUrl(chain, txHash),
832
+ };
833
+ }
834
+
704
835
  /**
705
836
  * Get block explorer URL for a transaction.
706
837
  */
@@ -28,6 +28,41 @@ function isNewer(latest, current) {
28
28
  return lp > cp;
29
29
  }
30
30
 
31
+ const LAST_VERSION_FILE = path.join(CONFIG_DIR, 'last-version.json');
32
+
33
+ /**
34
+ * After an update, show a one-time "what's new" notice on the first run.
35
+ * Compares current version against the stored last-seen version.
36
+ * Returns a notice string or null. Writes current version to disk.
37
+ */
38
+ export function getUpgradeNotice(currentVersion) {
39
+ try {
40
+ if (process.env.NO_UPDATE_NOTIFIER || process.env.CI) return null;
41
+
42
+ let previousVersion = null;
43
+ if (fs.existsSync(LAST_VERSION_FILE)) {
44
+ const raw = fs.readFileSync(LAST_VERSION_FILE, 'utf8');
45
+ const data = JSON.parse(raw);
46
+ previousVersion = data.version;
47
+ }
48
+
49
+ // Always update the stored version
50
+ if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { mode: 0o700, recursive: true });
51
+ fs.writeFileSync(LAST_VERSION_FILE, JSON.stringify({ version: currentVersion }));
52
+
53
+ // If no previous version stored, this is a fresh install — no notice
54
+ if (!previousVersion) return null;
55
+
56
+ // If versions match, no update happened
57
+ if (previousVersion === currentVersion) return null;
58
+
59
+ // Version changed — show notice
60
+ return `\n ✨ Updated to ${currentVersion} (was ${previousVersion}). Run \`nansen changelog --since ${previousVersion}\` for details.\n`;
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
31
66
  /**
32
67
  * Read the cached check result and return a notification string (or null).
33
68
  */
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 password = process.env.NANSEN_WALLET_PASSWORD || await promptPassword('Enter wallet password: ', deps);
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
+ }
@@ -0,0 +1,10 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: 'node',
7
+ include: ['src/**/*.e2e.test.js'],
8
+ testTimeout: 120000,
9
+ },
10
+ });