nansen-cli 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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;
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
  */