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/AGENTS.md +118 -164
- package/CLAUDE.md +16 -19
- package/README.md +163 -112
- package/SKILL.md +170 -76
- package/package.json +3 -1
- package/scripts/check-changeset.js +28 -0
- package/src/api.js +92 -70
- package/src/chain-ids.js +19 -0
- package/src/cli.js +406 -353
- package/src/ens.js +163 -0
- package/src/trading.js +324 -25
- package/src/transfer.js +133 -2
- package/src/update-check.js +35 -0
- 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/vitest.e2e.config.js +10 -0
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
|
-
|
|
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
|
*/
|
package/src/update-check.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|