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/SKILL.md +170 -76
- package/package.json +1 -1
- package/src/api.js +51 -11
- package/src/chain-ids.js +19 -0
- package/src/cli.js +28 -9
- package/src/trading.js +324 -25
- package/src/transfer.js +133 -2
- 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/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;
|
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
|
*/
|