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/ens.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ENS (Ethereum Name Service) resolution
|
|
3
|
+
* Resolves .eth names to addresses using public APIs with onchain RPC fallback.
|
|
4
|
+
* Zero external dependencies.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import https from 'https';
|
|
8
|
+
import { keccak256 } from './crypto.js';
|
|
9
|
+
|
|
10
|
+
const ENS_PATTERN = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.eth$/;
|
|
11
|
+
|
|
12
|
+
const EVM_CHAINS = [
|
|
13
|
+
'ethereum', 'base', 'optimism', 'arbitrum', 'polygon', 'bnb',
|
|
14
|
+
'avalanche', 'fantom', 'gnosis', 'linea', 'scroll', 'zksync',
|
|
15
|
+
'blast', 'mantle', 'ronin', 'sei', 'plasma', 'sonic', 'unichain', 'monad', 'hyperevm', 'iotaevm'
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Check if a string looks like an ENS name
|
|
20
|
+
*/
|
|
21
|
+
export function isEnsName(name) {
|
|
22
|
+
return typeof name === 'string' && ENS_PATTERN.test(name.trim());
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve an address input — if it's an ENS name, resolve it; otherwise pass through.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} addressOrName - Address (0x...) or ENS name (*.eth)
|
|
29
|
+
* @param {string} chain - Chain context (ENS only resolves on EVM chains)
|
|
30
|
+
* @returns {Promise<{address: string, ensName?: string}>}
|
|
31
|
+
*/
|
|
32
|
+
export async function resolveAddress(addressOrName, chain = 'ethereum') {
|
|
33
|
+
if (!addressOrName || typeof addressOrName !== 'string') {
|
|
34
|
+
return { address: addressOrName };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const trimmed = addressOrName.trim();
|
|
38
|
+
|
|
39
|
+
if (!isEnsName(trimmed)) {
|
|
40
|
+
return { address: trimmed };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!EVM_CHAINS.includes(chain)) {
|
|
44
|
+
throw new Error(`ENS names can only be resolved on EVM chains, not ${chain}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const name = trimmed.toLowerCase();
|
|
48
|
+
const errors = [];
|
|
49
|
+
|
|
50
|
+
// Try ensideas API first (fast, no auth)
|
|
51
|
+
try {
|
|
52
|
+
const addr = await resolveViaEnsIdeas(name);
|
|
53
|
+
if (addr) return { address: addr, ensName: name };
|
|
54
|
+
} catch (e) {
|
|
55
|
+
errors.push(`ensideas: ${e.message}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Fallback: onchain resolution via public RPC
|
|
59
|
+
try {
|
|
60
|
+
const addr = await resolveOnchain(name);
|
|
61
|
+
if (addr) return { address: addr, ensName: name };
|
|
62
|
+
} catch (e) {
|
|
63
|
+
errors.push(`onchain: ${e.message}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
throw new Error(`Could not resolve ENS name: ${name}${errors.length ? ` (${errors.join('; ')})` : ''}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ============= Resolvers =============
|
|
70
|
+
|
|
71
|
+
function httpsGet(url, timeoutMs = 5000) {
|
|
72
|
+
return new Promise((resolve, reject) => {
|
|
73
|
+
const req = https.get(url, { timeout: timeoutMs }, (res) => {
|
|
74
|
+
let data = '';
|
|
75
|
+
res.on('data', chunk => { data += chunk; });
|
|
76
|
+
res.on('end', () => {
|
|
77
|
+
if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`));
|
|
78
|
+
try { resolve(JSON.parse(data)); } catch (e) { reject(e); }
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
req.on('error', reject);
|
|
82
|
+
req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); });
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function httpsPost(url, body, timeoutMs = 5000) {
|
|
87
|
+
return new Promise((resolve, reject) => {
|
|
88
|
+
const payload = JSON.stringify(body);
|
|
89
|
+
const parsed = new URL(url);
|
|
90
|
+
const req = https.request({
|
|
91
|
+
hostname: parsed.hostname,
|
|
92
|
+
path: parsed.pathname,
|
|
93
|
+
method: 'POST',
|
|
94
|
+
timeout: timeoutMs,
|
|
95
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) }
|
|
96
|
+
}, (res) => {
|
|
97
|
+
let buf = '';
|
|
98
|
+
res.on('data', chunk => { buf += chunk; });
|
|
99
|
+
res.on('end', () => {
|
|
100
|
+
if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`));
|
|
101
|
+
try { resolve(JSON.parse(buf)); } catch (e) { reject(e); }
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
req.on('error', reject);
|
|
105
|
+
req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); });
|
|
106
|
+
req.write(payload);
|
|
107
|
+
req.end();
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const VALID_ADDR = /^0x[0-9a-fA-F]{40}$/;
|
|
112
|
+
|
|
113
|
+
async function resolveViaEnsIdeas(name) {
|
|
114
|
+
const result = await httpsGet(`https://api.ensideas.com/ens/resolve/${encodeURIComponent(name)}`);
|
|
115
|
+
if (result?.address && VALID_ADDR.test(result.address)) return result.address;
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Compute ENS namehash using keccak256 from crypto.js
|
|
121
|
+
*/
|
|
122
|
+
function namehash(name) {
|
|
123
|
+
let node = Buffer.alloc(32, 0); // bytes32(0)
|
|
124
|
+
if (!name) return node.toString('hex');
|
|
125
|
+
|
|
126
|
+
const labels = name.split('.').reverse();
|
|
127
|
+
for (const label of labels) {
|
|
128
|
+
const labelHash = keccak256(Buffer.from(label, 'utf8'));
|
|
129
|
+
node = keccak256(Buffer.concat([node, labelHash]));
|
|
130
|
+
}
|
|
131
|
+
return node.toString('hex');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const ENS_REGISTRY = '0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e';
|
|
135
|
+
const ZERO_HASH = '0000000000000000000000000000000000000000000000000000000000000000';
|
|
136
|
+
const RPC_URL = 'https://eth.llamarpc.com';
|
|
137
|
+
|
|
138
|
+
async function resolveOnchain(name) {
|
|
139
|
+
const hash = namehash(name);
|
|
140
|
+
|
|
141
|
+
// Step 1: Get resolver from ENS registry — resolver(bytes32)
|
|
142
|
+
const resolverResult = await httpsPost(RPC_URL, {
|
|
143
|
+
jsonrpc: '2.0', id: 1, method: 'eth_call',
|
|
144
|
+
params: [{ to: ENS_REGISTRY, data: '0x0178b8bf' + hash }, 'latest']
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const resolverHex = resolverResult?.result;
|
|
148
|
+
if (!resolverHex || resolverHex === '0x' || resolverHex.slice(2) === ZERO_HASH) return null;
|
|
149
|
+
|
|
150
|
+
const resolver = '0x' + resolverHex.slice(26);
|
|
151
|
+
|
|
152
|
+
// Step 2: Call addr(bytes32) on the resolver — selector 0x3b3b57de
|
|
153
|
+
const addrResult = await httpsPost(RPC_URL, {
|
|
154
|
+
jsonrpc: '2.0', id: 2, method: 'eth_call',
|
|
155
|
+
params: [{ to: resolver, data: '0x3b3b57de' + hash }, 'latest']
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const addrHex = addrResult?.result;
|
|
159
|
+
if (!addrHex || addrHex === '0x' || addrHex.slice(2) === ZERO_HASH) return null;
|
|
160
|
+
|
|
161
|
+
const address = '0x' + addrHex.slice(26);
|
|
162
|
+
return VALID_ADDR.test(address) ? address : null;
|
|
163
|
+
}
|
package/src/trading.js
CHANGED
|
@@ -11,6 +11,7 @@ import path from 'path';
|
|
|
11
11
|
import { exportWallet, getDefaultAddress, showWallet, listWallets } from './wallet.js';
|
|
12
12
|
import { base58Decode } from './transfer.js';
|
|
13
13
|
import { keccak256, signSecp256k1, rlpEncode } from './crypto.js';
|
|
14
|
+
import { getWalletConnectAddress, sendTransactionViaWalletConnect, sendApprovalViaWalletConnect } from './walletconnect-trading.js';
|
|
14
15
|
|
|
15
16
|
// ============= Constants =============
|
|
16
17
|
|
|
@@ -23,6 +24,60 @@ const CHAIN_MAP = {
|
|
|
23
24
|
bsc: { index: '56', type: 'evm', chainId: 56, name: 'BSC', explorer: 'https://bscscan.com/tx/' },
|
|
24
25
|
};
|
|
25
26
|
|
|
27
|
+
// Extend when adding new EVM chains (e.g. arbitrum WETH, polygon WMATIC)
|
|
28
|
+
const WRAPPED_NATIVE_TOKENS = {
|
|
29
|
+
ethereum: { address: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', symbol: 'WETH', nativeSymbol: 'ETH' },
|
|
30
|
+
base: { address: '0x4200000000000000000000000000000000000006', symbol: 'WETH', nativeSymbol: 'ETH' },
|
|
31
|
+
bsc: { address: '0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c', symbol: 'WBNB', nativeSymbol: 'BNB' },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Common token symbol → address lookup per chain.
|
|
35
|
+
// Native sentinels: Solana uses native mint, EVM uses 0xeee…eee.
|
|
36
|
+
// Wrapped-native addresses (WETH, WBNB) are derived from WRAPPED_NATIVE_TOKENS
|
|
37
|
+
// to avoid duplication — keep that map as the single source of truth.
|
|
38
|
+
const EVM_NATIVE = '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee';
|
|
39
|
+
const TOKEN_SYMBOLS = {
|
|
40
|
+
solana: {
|
|
41
|
+
SOL: 'So11111111111111111111111111111111111111112',
|
|
42
|
+
WSOL: 'So11111111111111111111111111111111111111112',
|
|
43
|
+
USDC: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
|
|
44
|
+
USDT: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB',
|
|
45
|
+
},
|
|
46
|
+
ethereum: {
|
|
47
|
+
ETH: EVM_NATIVE,
|
|
48
|
+
WETH: WRAPPED_NATIVE_TOKENS.ethereum.address,
|
|
49
|
+
USDC: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
|
|
50
|
+
USDT: '0xdac17f958d2ee523a2206206994597c13d831ec7',
|
|
51
|
+
},
|
|
52
|
+
base: {
|
|
53
|
+
ETH: EVM_NATIVE,
|
|
54
|
+
WETH: WRAPPED_NATIVE_TOKENS.base.address,
|
|
55
|
+
USDC: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913',
|
|
56
|
+
// NOTE: Legacy L2-bridged USDT on Base. If Tether deploys natively on Base
|
|
57
|
+
// (like Circle did with USDC), this address will need updating.
|
|
58
|
+
USDT: '0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2',
|
|
59
|
+
},
|
|
60
|
+
bsc: {
|
|
61
|
+
BNB: EVM_NATIVE,
|
|
62
|
+
WBNB: WRAPPED_NATIVE_TOKENS.bsc.address,
|
|
63
|
+
USDC: '0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d',
|
|
64
|
+
USDT: '0x55d398326f99059ff775485246999027b3197955',
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Resolve a token symbol (e.g. "SOL", "USDC") to its canonical address
|
|
70
|
+
* for the given chain. Returns the input unchanged if no match is found
|
|
71
|
+
* (assumes it's already a raw address).
|
|
72
|
+
*/
|
|
73
|
+
export function resolveTokenAddress(symbolOrAddress, chainName) {
|
|
74
|
+
if (!symbolOrAddress || !chainName) return symbolOrAddress;
|
|
75
|
+
const chainTokens = TOKEN_SYMBOLS[chainName.toLowerCase()];
|
|
76
|
+
if (!chainTokens) return symbolOrAddress;
|
|
77
|
+
const resolved = chainTokens[symbolOrAddress.toUpperCase()];
|
|
78
|
+
return resolved || symbolOrAddress;
|
|
79
|
+
}
|
|
80
|
+
|
|
26
81
|
// Default public RPC endpoints (used for nonce fetching)
|
|
27
82
|
const EVM_RPC_URLS = {
|
|
28
83
|
ethereum: process.env.NANSEN_RPC_ETHEREUM || 'https://eth.llamarpc.com',
|
|
@@ -149,7 +204,7 @@ export async function executeTransaction(params, { retries = 2, retryDelayMs = 1
|
|
|
149
204
|
* Save a quote response to disk for later execution.
|
|
150
205
|
* @returns {string} Quote ID
|
|
151
206
|
*/
|
|
152
|
-
export function saveQuote(quoteResponse, chain) {
|
|
207
|
+
export function saveQuote(quoteResponse, chain, signerType = 'local') {
|
|
153
208
|
const dir = getQuotesDir();
|
|
154
209
|
if (!fs.existsSync(dir)) {
|
|
155
210
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
@@ -159,7 +214,7 @@ export function saveQuote(quoteResponse, chain) {
|
|
|
159
214
|
const hash = crypto.randomBytes(4).toString('hex');
|
|
160
215
|
const quoteId = `${timestamp}-${hash}`;
|
|
161
216
|
|
|
162
|
-
const data = { quoteId, chain, timestamp, response: quoteResponse };
|
|
217
|
+
const data = { quoteId, chain, timestamp, signerType, response: quoteResponse };
|
|
163
218
|
|
|
164
219
|
fs.writeFileSync(path.join(dir, `${quoteId}.json`), JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
165
220
|
cleanupQuotes();
|
|
@@ -646,6 +701,47 @@ function isNativeToken(mintAddress) {
|
|
|
646
701
|
return /^0x[eE]{40}$/.test(mintAddress);
|
|
647
702
|
}
|
|
648
703
|
|
|
704
|
+
/**
|
|
705
|
+
* Check if --from is a wrapped native token or native sentinel and return
|
|
706
|
+
* a warning string, or null if no warning is needed. Pure function.
|
|
707
|
+
*/
|
|
708
|
+
export function getWrappedNativeFromWarning(tokenAddress, chain) {
|
|
709
|
+
if (!tokenAddress || !chain) return null;
|
|
710
|
+
const wrapped = WRAPPED_NATIVE_TOKENS[chain.toLowerCase()];
|
|
711
|
+
if (!wrapped) return null;
|
|
712
|
+
|
|
713
|
+
const addr = tokenAddress.toLowerCase();
|
|
714
|
+
|
|
715
|
+
// Case 1: --from is wrapped token (e.g. WETH) — suggest native sentinel
|
|
716
|
+
if (addr === wrapped.address.toLowerCase()) {
|
|
717
|
+
return `Warning: --from is ${wrapped.symbol} (wrapped ${wrapped.nativeSymbol}). ` +
|
|
718
|
+
`If you hold native ${wrapped.nativeSymbol}, use: 0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee`;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Case 2: --from is native sentinel — mention the wrapped alternative
|
|
722
|
+
if (isNativeToken(tokenAddress)) {
|
|
723
|
+
return `Warning: --from is native ${wrapped.nativeSymbol}. ` +
|
|
724
|
+
`If you hold ${wrapped.symbol} instead, use: ${wrapped.address}`;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
return null;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Check if amount contains a decimal point (i.e. not in base units).
|
|
732
|
+
* Returns an error string if invalid, or null if OK. Pure function.
|
|
733
|
+
*/
|
|
734
|
+
export function validateBaseUnitAmount(amount) {
|
|
735
|
+
if (!amount) return null;
|
|
736
|
+
const str = String(amount);
|
|
737
|
+
if (str.includes('.')) {
|
|
738
|
+
return 'Amount must be in base units (integer), not token units. ' +
|
|
739
|
+
'Examples: 1000000000 lamports = 1 SOL, 1000000000000000000 wei = 1 ETH, ' +
|
|
740
|
+
'1000000 = 1 USDC. Got: ' + str;
|
|
741
|
+
}
|
|
742
|
+
return null;
|
|
743
|
+
}
|
|
744
|
+
|
|
649
745
|
function formatQuote(quote, index) {
|
|
650
746
|
const lines = [];
|
|
651
747
|
const label = index !== undefined ? ` Quote #${index + 1}` : ' Best Quote';
|
|
@@ -672,8 +768,10 @@ export function buildTradingCommands(deps = {}) {
|
|
|
672
768
|
return {
|
|
673
769
|
'quote': async (args, apiInstance, flags, options) => {
|
|
674
770
|
const chain = options.chain || args[0];
|
|
675
|
-
const
|
|
676
|
-
const
|
|
771
|
+
const fromRaw = options.from || options['from-token'] || args[1];
|
|
772
|
+
const toRaw = options.to || options['to-token'] || args[2];
|
|
773
|
+
const from = resolveTokenAddress(fromRaw, chain);
|
|
774
|
+
const to = resolveTokenAddress(toRaw, chain);
|
|
677
775
|
const amount = options.amount || args[3];
|
|
678
776
|
const walletName = options.wallet;
|
|
679
777
|
const slippage = options.slippage;
|
|
@@ -687,8 +785,8 @@ Usage: nansen quote --chain <chain> --from <token> --to <token> --amount <baseUn
|
|
|
687
785
|
|
|
688
786
|
OPTIONS:
|
|
689
787
|
--chain <chain> Chain: solana, ethereum, base, bsc
|
|
690
|
-
--from <address>
|
|
691
|
-
--to <address>
|
|
788
|
+
--from <symbol|address> Input token (symbol like SOL, USDC or address)
|
|
789
|
+
--to <symbol|address> Output token (symbol like USDC, ETH or address)
|
|
692
790
|
--amount <units> Amount in BASE UNITS (e.g. lamports, wei)
|
|
693
791
|
--wallet <name> Wallet name (default: default wallet)
|
|
694
792
|
--slippage <pct> Slippage as decimal (e.g. 0.03 for 3%). Default: 0.03
|
|
@@ -697,19 +795,41 @@ OPTIONS:
|
|
|
697
795
|
--swap-mode <mode> exactIn (default) or exactOut
|
|
698
796
|
|
|
699
797
|
EXAMPLES:
|
|
798
|
+
nansen quote --chain solana --from SOL --to USDC --amount 1000000000
|
|
799
|
+
nansen quote --chain base --from ETH --to USDC --amount 1000000000000000000
|
|
700
800
|
nansen quote --chain solana --from So11111111111111111111111111111111111111112 --to EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v --amount 1000000000
|
|
701
|
-
nansen quote --chain base --from 0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee --to 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 --amount 1000000000000000000
|
|
702
801
|
`);
|
|
703
802
|
exit(1);
|
|
704
803
|
return;
|
|
705
804
|
}
|
|
706
805
|
|
|
806
|
+
const amountError = validateBaseUnitAmount(amount);
|
|
807
|
+
if (amountError) {
|
|
808
|
+
errorOutput(`Error: ${amountError}`);
|
|
809
|
+
exit(1);
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
|
|
707
813
|
try {
|
|
708
814
|
const chainConfig = resolveChain(chain);
|
|
709
815
|
const chainType = chainConfig.type === 'evm' ? 'evm' : 'solana';
|
|
710
816
|
|
|
817
|
+
const isWalletConnect = walletName === 'walletconnect' || walletName === 'wc';
|
|
818
|
+
|
|
711
819
|
let walletAddress;
|
|
712
|
-
if (
|
|
820
|
+
if (isWalletConnect) {
|
|
821
|
+
if (chainType !== 'evm') {
|
|
822
|
+
errorOutput('WalletConnect is only supported for EVM chains');
|
|
823
|
+
exit(1);
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
walletAddress = await getWalletConnectAddress();
|
|
827
|
+
if (!walletAddress) {
|
|
828
|
+
errorOutput('No WalletConnect session active. Run: walletconnect connect');
|
|
829
|
+
exit(1);
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
} else if (walletName) {
|
|
713
833
|
const wallet = showWallet(walletName);
|
|
714
834
|
walletAddress = chainType === 'solana' ? wallet.solana : wallet.evm;
|
|
715
835
|
} else {
|
|
@@ -725,6 +845,9 @@ EXAMPLES:
|
|
|
725
845
|
errorOutput(`\nFetching quote on ${chainConfig.name}...`);
|
|
726
846
|
errorOutput(` Wallet: ${walletAddress}`);
|
|
727
847
|
|
|
848
|
+
const fromWarning = getWrappedNativeFromWarning(from, chain);
|
|
849
|
+
if (fromWarning) errorOutput(` ${fromWarning}`);
|
|
850
|
+
|
|
728
851
|
const params = {
|
|
729
852
|
chainIndex: chainConfig.index,
|
|
730
853
|
fromTokenAddress: from,
|
|
@@ -751,9 +874,9 @@ EXAMPLES:
|
|
|
751
874
|
errorOutput('');
|
|
752
875
|
response.quotes.forEach((q, i) => errorOutput(formatQuote(q, i)));
|
|
753
876
|
|
|
754
|
-
const quoteId = saveQuote(response, chain);
|
|
877
|
+
const quoteId = saveQuote(response, chain, isWalletConnect ? 'walletconnect' : 'local');
|
|
755
878
|
errorOutput(`\n Quote ID: ${quoteId}`);
|
|
756
|
-
errorOutput(` Execute: nansen execute --quote ${quoteId}`);
|
|
879
|
+
errorOutput(` Execute: nansen trade execute --quote ${quoteId}`);
|
|
757
880
|
|
|
758
881
|
if (response.quotes[0]?.approvalAddress && !isNativeToken(response.quotes[0]?.inputMint)) {
|
|
759
882
|
errorOutput(`\n Warning: This token swap requires an ERC-20 approval step.`);
|
|
@@ -764,7 +887,11 @@ EXAMPLES:
|
|
|
764
887
|
return undefined; // Output already printed above
|
|
765
888
|
|
|
766
889
|
} catch (err) {
|
|
767
|
-
|
|
890
|
+
let message = err.message;
|
|
891
|
+
if (err.code === 'INVALID_AMOUNT' || /amount/i.test(err.message)) {
|
|
892
|
+
message += '. Amounts must be in base units (e.g., 1000000000 lamports for 1 SOL, 1000000000000000000 wei for 1 ETH)';
|
|
893
|
+
}
|
|
894
|
+
errorOutput(`Error: ${message}`);
|
|
768
895
|
if (err.details) errorOutput(` Details: ${JSON.stringify(err.details)}`);
|
|
769
896
|
exit(1);
|
|
770
897
|
}
|
|
@@ -777,7 +904,7 @@ EXAMPLES:
|
|
|
777
904
|
|
|
778
905
|
if (!quoteId) {
|
|
779
906
|
errorOutput(`
|
|
780
|
-
Usage: nansen execute --quote <quoteId> [options]
|
|
907
|
+
Usage: nansen trade execute --quote <quoteId> [options]
|
|
781
908
|
|
|
782
909
|
OPTIONS:
|
|
783
910
|
--quote <id> Quote ID from 'nansen quote'
|
|
@@ -785,7 +912,7 @@ OPTIONS:
|
|
|
785
912
|
--no-simulate Skip pre-broadcast simulation
|
|
786
913
|
|
|
787
914
|
EXAMPLES:
|
|
788
|
-
nansen execute --quote 1708900000000-abc123
|
|
915
|
+
nansen trade execute --quote 1708900000000-abc123
|
|
789
916
|
`);
|
|
790
917
|
exit(1);
|
|
791
918
|
return;
|
|
@@ -818,21 +945,50 @@ EXAMPLES:
|
|
|
818
945
|
return;
|
|
819
946
|
}
|
|
820
947
|
|
|
821
|
-
//
|
|
822
|
-
const
|
|
948
|
+
// Determine if this is a WalletConnect-signed quote
|
|
949
|
+
const isWalletConnect = quoteData.signerType === 'walletconnect'
|
|
950
|
+
|| walletName === 'walletconnect' || walletName === 'wc';
|
|
823
951
|
|
|
824
|
-
let
|
|
825
|
-
if (!
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
952
|
+
let exported = null;
|
|
953
|
+
if (!isWalletConnect) {
|
|
954
|
+
// Get wallet credentials once (before the loop)
|
|
955
|
+
const password = process.env.NANSEN_WALLET_PASSWORD || await promptPassword('Enter wallet password: ', deps);
|
|
956
|
+
|
|
957
|
+
let effectiveWalletName = walletName;
|
|
958
|
+
if (!effectiveWalletName) {
|
|
959
|
+
const list = listWallets();
|
|
960
|
+
effectiveWalletName = list.defaultWallet;
|
|
961
|
+
}
|
|
962
|
+
if (!effectiveWalletName) {
|
|
963
|
+
errorOutput('No wallet found. Create one with: nansen wallet create');
|
|
964
|
+
exit(1);
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
exported = exportWallet(effectiveWalletName, password);
|
|
969
|
+
} else {
|
|
970
|
+
// Verify WalletConnect session is still active and address matches quote
|
|
971
|
+
if (chainType !== 'evm') {
|
|
972
|
+
errorOutput('WalletConnect is only supported for EVM chains');
|
|
973
|
+
exit(1);
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
const wcAddress = await getWalletConnectAddress();
|
|
977
|
+
if (!wcAddress) {
|
|
978
|
+
errorOutput('No WalletConnect session active. Run: walletconnect connect');
|
|
979
|
+
exit(1);
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
// Check address matches the one used during quoting
|
|
983
|
+
const quoteWallet = quoteData.response?.quotes?.[0]?.transaction?.from
|
|
984
|
+
|| quoteData.response?.metadata?.userWalletAddress;
|
|
985
|
+
if (quoteWallet && wcAddress.toLowerCase() !== quoteWallet.toLowerCase()) {
|
|
986
|
+
errorOutput(`Connected wallet (${wcAddress}) doesn't match quote. Get a new quote with --wallet walletconnect`);
|
|
987
|
+
exit(1);
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
833
990
|
}
|
|
834
991
|
|
|
835
|
-
const exported = exportWallet(effectiveWalletName, password);
|
|
836
992
|
let lastQuoteError = null;
|
|
837
993
|
|
|
838
994
|
for (let qi = startIndex; qi < endIndex; qi++) {
|
|
@@ -870,6 +1026,149 @@ EXAMPLES:
|
|
|
870
1026
|
signedTransaction = signSolanaTransaction(txBase64, exported.solana.privateKey);
|
|
871
1027
|
requestId = currentQuote.metadata?.requestId;
|
|
872
1028
|
|
|
1029
|
+
} else if (isWalletConnect) {
|
|
1030
|
+
// EVM via WalletConnect: wallet signs and may broadcast
|
|
1031
|
+
const wcAddress = await getWalletConnectAddress();
|
|
1032
|
+
const isNative = isNativeToken(currentQuote.inputMint);
|
|
1033
|
+
|
|
1034
|
+
// Validate transaction.value (same checks as local wallet)
|
|
1035
|
+
const txValue = BigInt(currentQuote.transaction.value || '0');
|
|
1036
|
+
if (isNative) {
|
|
1037
|
+
const expectedValue = BigInt(currentQuote.inAmount || currentQuote.inputAmount || '0');
|
|
1038
|
+
if (txValue !== expectedValue) {
|
|
1039
|
+
errorOutput(` ❌ Transaction value mismatch for ${quoteName}: tx.value=${txValue}, expected=${expectedValue}`);
|
|
1040
|
+
if (qi + 1 < endIndex) errorOutput(` Trying next quote...`);
|
|
1041
|
+
lastQuoteError = `${quoteName} transaction value mismatch`;
|
|
1042
|
+
continue;
|
|
1043
|
+
}
|
|
1044
|
+
} else {
|
|
1045
|
+
if (txValue > 0n) {
|
|
1046
|
+
errorOutput(` ❌ ERC-20 swap has non-zero tx.value (${txValue}) for ${quoteName} — aborting`);
|
|
1047
|
+
if (qi + 1 < endIndex) errorOutput(` Trying next quote...`);
|
|
1048
|
+
lastQuoteError = `${quoteName} unexpected tx.value`;
|
|
1049
|
+
continue;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// Handle approval via WalletConnect if needed
|
|
1054
|
+
if (currentQuote.approvalAddress && !isNative) {
|
|
1055
|
+
const inputAmount = BigInt(currentQuote.inputAmount || currentQuote.inAmount || '0');
|
|
1056
|
+
const existingAllowance = await checkErc20Allowance(
|
|
1057
|
+
chain, currentQuote.inputMint, wcAddress, currentQuote.approvalAddress
|
|
1058
|
+
);
|
|
1059
|
+
|
|
1060
|
+
if (existingAllowance >= inputAmount && existingAllowance > 0n) {
|
|
1061
|
+
errorOutput(` ✓ Sufficient allowance exists for ${quoteName}, skipping approval`);
|
|
1062
|
+
} else {
|
|
1063
|
+
errorOutput(` ⚠ Approval required → ${currentQuote.approvalAddress}`);
|
|
1064
|
+
errorOutput(` Sending approval via WalletConnect...`);
|
|
1065
|
+
try {
|
|
1066
|
+
const approvalResult = await sendApprovalViaWalletConnect(
|
|
1067
|
+
currentQuote.inputMint,
|
|
1068
|
+
currentQuote.approvalAddress,
|
|
1069
|
+
chainConfig.chainId,
|
|
1070
|
+
);
|
|
1071
|
+
let approvalTxHash = approvalResult.txHash;
|
|
1072
|
+
if (!approvalTxHash && approvalResult.signedTransaction) {
|
|
1073
|
+
// Wallet returned a signed tx instead of broadcasting — broadcast via Trading API
|
|
1074
|
+
errorOutput(` Broadcasting approval via Trading API...`);
|
|
1075
|
+
const broadcastResult = await executeTransaction({
|
|
1076
|
+
signedTransaction: approvalResult.signedTransaction,
|
|
1077
|
+
chain,
|
|
1078
|
+
simulate: !noSimulate,
|
|
1079
|
+
});
|
|
1080
|
+
if (broadcastResult.status !== 'Success') {
|
|
1081
|
+
throw new Error(broadcastResult.error || 'broadcast failed');
|
|
1082
|
+
}
|
|
1083
|
+
approvalTxHash = broadcastResult.txHash;
|
|
1084
|
+
}
|
|
1085
|
+
if (approvalTxHash) {
|
|
1086
|
+
errorOutput(` Waiting for approval confirmation...`);
|
|
1087
|
+
const receipt = await waitForReceipt(chain, approvalTxHash);
|
|
1088
|
+
errorOutput(` ✓ Approval confirmed in block ${parseInt(receipt.blockNumber, 16)}: ${approvalTxHash}`);
|
|
1089
|
+
}
|
|
1090
|
+
} catch (approvalErr) {
|
|
1091
|
+
errorOutput(` ❌ Approval failed for ${quoteName}: ${approvalErr.message}`);
|
|
1092
|
+
if (qi + 1 < endIndex) errorOutput(` Trying next quote...`);
|
|
1093
|
+
lastQuoteError = `${quoteName} approval failed`;
|
|
1094
|
+
continue;
|
|
1095
|
+
}
|
|
1096
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
1097
|
+
errorOutput('');
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// Pre-flight simulation
|
|
1102
|
+
if (!noSimulate) {
|
|
1103
|
+
const txData = currentQuote.transaction;
|
|
1104
|
+
const sim = await simulateEvmCall(chain, {
|
|
1105
|
+
from: wcAddress,
|
|
1106
|
+
to: txData.to,
|
|
1107
|
+
data: txData.data,
|
|
1108
|
+
value: txData.value ? '0x' + BigInt(txData.value).toString(16) : '0x0',
|
|
1109
|
+
});
|
|
1110
|
+
if (!sim.success) {
|
|
1111
|
+
errorOutput(` ⚠ Simulation failed for ${quoteName}: ${sim.reason}`);
|
|
1112
|
+
if (qi + 1 < endIndex) errorOutput(` Trying next quote...`);
|
|
1113
|
+
lastQuoteError = `${quoteName} simulation failed: ${sim.reason}`;
|
|
1114
|
+
continue;
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// Resolve gas
|
|
1119
|
+
const txData = currentQuote.transaction;
|
|
1120
|
+
const apiGas = parseInt(currentQuote.gas || "0");
|
|
1121
|
+
const txGas = parseInt(txData.gas || txData.gasLimit || "0");
|
|
1122
|
+
const finalGas = apiGas > 0 ? apiGas : txGas;
|
|
1123
|
+
|
|
1124
|
+
// Send transaction via WalletConnect
|
|
1125
|
+
errorOutput(' Sending transaction via WalletConnect...');
|
|
1126
|
+
let wcResult;
|
|
1127
|
+
try {
|
|
1128
|
+
wcResult = await sendTransactionViaWalletConnect({
|
|
1129
|
+
to: txData.to,
|
|
1130
|
+
data: txData.data,
|
|
1131
|
+
value: txData.value || '0',
|
|
1132
|
+
gas: String(finalGas),
|
|
1133
|
+
chainId: chainConfig.chainId,
|
|
1134
|
+
});
|
|
1135
|
+
} catch (wcErr) {
|
|
1136
|
+
errorOutput(` ❌ WalletConnect transaction failed for ${quoteName}: ${wcErr.message}`);
|
|
1137
|
+
if (qi + 1 < endIndex) errorOutput(` Trying next quote...`);
|
|
1138
|
+
lastQuoteError = `${quoteName}: ${wcErr.message}`;
|
|
1139
|
+
continue;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
if (wcResult.txHash) {
|
|
1143
|
+
// Wallet broadcast — verify on-chain
|
|
1144
|
+
errorOutput(' Verifying on-chain status...');
|
|
1145
|
+
try {
|
|
1146
|
+
await waitForReceipt(chain, wcResult.txHash);
|
|
1147
|
+
} catch (receiptErr) {
|
|
1148
|
+
errorOutput(`\n ⚠ Transaction was broadcast but REVERTED on-chain!`);
|
|
1149
|
+
errorOutput(` Tx Hash: ${wcResult.txHash}`);
|
|
1150
|
+
errorOutput(` Explorer: ${chainConfig.explorer}${wcResult.txHash}`);
|
|
1151
|
+
errorOutput(` Error: ${receiptErr.message}`);
|
|
1152
|
+
if (qi + 1 < endIndex) {
|
|
1153
|
+
errorOutput(` Trying next quote...`);
|
|
1154
|
+
lastQuoteError = `${quoteName} reverted on-chain`;
|
|
1155
|
+
continue;
|
|
1156
|
+
}
|
|
1157
|
+
exit(1);
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
errorOutput(`\n ✓ Transaction successful!`);
|
|
1162
|
+
errorOutput(` Tx Hash: ${wcResult.txHash}`);
|
|
1163
|
+
errorOutput(` Chain: ${chainConfig.name}`);
|
|
1164
|
+
errorOutput(` Explorer: ${chainConfig.explorer}${wcResult.txHash}`);
|
|
1165
|
+
errorOutput('');
|
|
1166
|
+
return undefined; // Success
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// Wallet returned signedTransaction — fall through to broadcast via Trading API
|
|
1170
|
+
signedTransaction = wcResult.signedTransaction;
|
|
1171
|
+
|
|
873
1172
|
} else {
|
|
874
1173
|
// EVM: quote.transaction is { to, data, value, gas, gasPrice }
|
|
875
1174
|
const walletAddress = exported.evm.address;
|