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/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 from = options.from || options['from-token'] || args[1];
676
- const to = options.to || options['to-token'] || args[2];
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> Input token address
691
- --to <address> Output token 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 (walletName) {
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
- errorOutput(`Error: ${err.message}`);
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
- // Get wallet credentials once (before the loop)
822
- const password = process.env.NANSEN_WALLET_PASSWORD || await promptPassword('Enter wallet password: ', deps);
948
+ // Determine if this is a WalletConnect-signed quote
949
+ const isWalletConnect = quoteData.signerType === 'walletconnect'
950
+ || walletName === 'walletconnect' || walletName === 'wc';
823
951
 
824
- let effectiveWalletName = walletName;
825
- if (!effectiveWalletName) {
826
- const list = listWallets();
827
- effectiveWalletName = list.defaultWallet;
828
- }
829
- if (!effectiveWalletName) {
830
- errorOutput('No wallet found. Create one with: nansen wallet create');
831
- exit(1);
832
- return;
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;