nansen-cli 1.14.0 → 1.16.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/CHANGELOG.md +21 -0
- package/README.md +1 -1
- package/package.json +2 -1
- package/src/api.js +107 -64
- package/src/chain-ids.js +2 -3
- package/src/cli.js +1 -1
- package/src/privy.js +359 -0
- package/src/schema.json +42 -2
- package/src/trading.js +290 -108
- package/src/transfer.js +151 -26
- package/src/wallet.js +112 -39
- package/src/walletconnect-trading.js +73 -9
- package/src/x402-svm.js +43 -24
package/src/schema.json
CHANGED
|
@@ -1831,6 +1831,48 @@
|
|
|
1831
1831
|
}
|
|
1832
1832
|
}
|
|
1833
1833
|
}
|
|
1834
|
+
},
|
|
1835
|
+
"wallet": {
|
|
1836
|
+
"description": "Wallet management (local or Privy server wallets)",
|
|
1837
|
+
"options": {
|
|
1838
|
+
"provider": {
|
|
1839
|
+
"type": "string",
|
|
1840
|
+
"enum": ["local", "privy"],
|
|
1841
|
+
"description": "Wallet provider (default: local). Use 'privy' for server-managed wallets."
|
|
1842
|
+
}
|
|
1843
|
+
},
|
|
1844
|
+
"subcommands": {
|
|
1845
|
+
"create": {
|
|
1846
|
+
"description": "Create a new wallet",
|
|
1847
|
+
"options": {
|
|
1848
|
+
"name": { "type": "string", "description": "Wallet name (default: 'default')" }
|
|
1849
|
+
}
|
|
1850
|
+
},
|
|
1851
|
+
"list": { "description": "List all wallets" },
|
|
1852
|
+
"show": {
|
|
1853
|
+
"description": "Show wallet details",
|
|
1854
|
+
"options": {
|
|
1855
|
+
"name": { "type": "string", "description": "Wallet name" }
|
|
1856
|
+
}
|
|
1857
|
+
},
|
|
1858
|
+
"delete": {
|
|
1859
|
+
"description": "Delete a wallet",
|
|
1860
|
+
"options": {
|
|
1861
|
+
"name": { "type": "string", "description": "Wallet name" }
|
|
1862
|
+
}
|
|
1863
|
+
},
|
|
1864
|
+
"send": {
|
|
1865
|
+
"description": "Send tokens or native currency",
|
|
1866
|
+
"options": {
|
|
1867
|
+
"to": { "type": "string", "required": true, "description": "Recipient address" },
|
|
1868
|
+
"amount": { "type": "string", "description": "Amount to send" },
|
|
1869
|
+
"chain": { "type": "string", "required": true, "description": "Blockchain to use" }
|
|
1870
|
+
}
|
|
1871
|
+
},
|
|
1872
|
+
"export": { "description": "Export private keys (local only, requires password)" },
|
|
1873
|
+
"default": { "description": "Set default wallet (local only)" },
|
|
1874
|
+
"help": { "description": "Show wallet help" }
|
|
1875
|
+
}
|
|
1834
1876
|
}
|
|
1835
1877
|
},
|
|
1836
1878
|
"globalOptions": {
|
|
@@ -1879,13 +1921,11 @@
|
|
|
1879
1921
|
"avalanche",
|
|
1880
1922
|
"linea",
|
|
1881
1923
|
"scroll",
|
|
1882
|
-
"zksync",
|
|
1883
1924
|
"mantle",
|
|
1884
1925
|
"ronin",
|
|
1885
1926
|
"sei",
|
|
1886
1927
|
"plasma",
|
|
1887
1928
|
"sonic",
|
|
1888
|
-
"unichain",
|
|
1889
1929
|
"monad",
|
|
1890
1930
|
"hyperevm",
|
|
1891
1931
|
"iotaevm"
|
package/src/trading.js
CHANGED
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
import crypto from 'crypto';
|
|
9
9
|
import fs from 'fs';
|
|
10
10
|
import path from 'path';
|
|
11
|
-
import { exportWallet,
|
|
11
|
+
import { base58Encode, exportWallet, getWalletConfig, 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
|
+
import { getWalletConnectAddress, sendTransactionViaWalletConnect, sendSolanaTransactionViaWalletConnect, sendApprovalViaWalletConnect } from './walletconnect-trading.js';
|
|
15
15
|
import { retrievePassword } from './keychain.js';
|
|
16
16
|
|
|
17
17
|
// ============= Constants =============
|
|
@@ -68,6 +68,33 @@ const EVM_RPC_URLS = {
|
|
|
68
68
|
base: process.env.NANSEN_RPC_BASE || 'https://mainnet.base.org',
|
|
69
69
|
};
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Make a JSON-RPC call to an EVM RPC endpoint.
|
|
73
|
+
* @param {string} chain - Chain name (key into EVM_RPC_URLS)
|
|
74
|
+
* @param {string} method - JSON-RPC method name
|
|
75
|
+
* @param {Array} params - Method parameters
|
|
76
|
+
* @returns {Promise<*>} Parsed result value
|
|
77
|
+
* @throws {Error} If chain has no configured RPC or the RPC returns an error
|
|
78
|
+
*/
|
|
79
|
+
async function evmRpcCall(chain, method, params = []) {
|
|
80
|
+
const rpcUrl = EVM_RPC_URLS[chain];
|
|
81
|
+
if (!rpcUrl) throw new Error(`No RPC URL configured for chain: ${chain}`);
|
|
82
|
+
const res = await fetch(rpcUrl, {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: { 'Content-Type': 'application/json' },
|
|
85
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
|
|
86
|
+
});
|
|
87
|
+
const text = await res.text();
|
|
88
|
+
let body;
|
|
89
|
+
try {
|
|
90
|
+
body = JSON.parse(text);
|
|
91
|
+
} catch {
|
|
92
|
+
throw new Error(`RPC endpoint returned non-JSON response (HTTP ${res.status}) for ${method}: ${text.slice(0, 100)}`);
|
|
93
|
+
}
|
|
94
|
+
if (body.error) throw new Error(`RPC error (${method}): ${body.error.message}`);
|
|
95
|
+
return body.result;
|
|
96
|
+
}
|
|
97
|
+
|
|
71
98
|
function getQuotesDir() {
|
|
72
99
|
const configDir = path.join(process.env.HOME || process.env.USERPROFILE || '', '.nansen');
|
|
73
100
|
return path.join(configDir, 'quotes');
|
|
@@ -187,7 +214,7 @@ export async function executeTransaction(params, { retries = 2, retryDelayMs = 1
|
|
|
187
214
|
* Save a quote response to disk for later execution.
|
|
188
215
|
* @returns {string} Quote ID
|
|
189
216
|
*/
|
|
190
|
-
export function saveQuote(quoteResponse, chain, signerType = 'local') {
|
|
217
|
+
export function saveQuote(quoteResponse, chain, signerType = 'local', privyWalletIds = null) {
|
|
191
218
|
const dir = getQuotesDir();
|
|
192
219
|
if (!fs.existsSync(dir)) {
|
|
193
220
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
@@ -198,6 +225,7 @@ export function saveQuote(quoteResponse, chain, signerType = 'local') {
|
|
|
198
225
|
const quoteId = `${timestamp}-${hash}`;
|
|
199
226
|
|
|
200
227
|
const data = { quoteId, chain, timestamp, signerType, response: quoteResponse };
|
|
228
|
+
if (privyWalletIds) data.privyWalletIds = privyWalletIds;
|
|
201
229
|
|
|
202
230
|
fs.writeFileSync(path.join(dir, `${quoteId}.json`), JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
203
231
|
cleanupQuotes();
|
|
@@ -336,22 +364,8 @@ export function signEvmTransaction(txData, privateKeyHex, chain, nonce) {
|
|
|
336
364
|
* @returns {Promise<number>} Nonce
|
|
337
365
|
*/
|
|
338
366
|
export async function getEvmNonce(chain, address) {
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
const res = await fetch(rpcUrl, {
|
|
343
|
-
method: 'POST',
|
|
344
|
-
headers: { 'Content-Type': 'application/json' },
|
|
345
|
-
body: JSON.stringify({
|
|
346
|
-
jsonrpc: '2.0',
|
|
347
|
-
id: 1,
|
|
348
|
-
method: 'eth_getTransactionCount',
|
|
349
|
-
params: [address, 'pending'],
|
|
350
|
-
}),
|
|
351
|
-
});
|
|
352
|
-
const body = await res.json();
|
|
353
|
-
if (body.error) throw new Error(`RPC error: ${body.error.message}`);
|
|
354
|
-
return parseInt(body.result, 16);
|
|
367
|
+
const result = await evmRpcCall(chain, 'eth_getTransactionCount', [address, 'pending']);
|
|
368
|
+
return parseInt(result, 16);
|
|
355
369
|
}
|
|
356
370
|
|
|
357
371
|
/**
|
|
@@ -365,28 +379,21 @@ export async function getEvmNonce(chain, address) {
|
|
|
365
379
|
* @returns {Promise<object>} Transaction receipt
|
|
366
380
|
*/
|
|
367
381
|
export async function waitForReceipt(chain, txHash, timeoutMs = 30000, pollMs = 2000) {
|
|
368
|
-
const rpcUrl = EVM_RPC_URLS[chain];
|
|
369
|
-
if (!rpcUrl) throw new Error(`No RPC URL configured for chain: ${chain}`);
|
|
370
|
-
|
|
371
382
|
const start = Date.now();
|
|
372
383
|
while (Date.now() - start < timeoutMs) {
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
}),
|
|
382
|
-
});
|
|
383
|
-
const body = await res.json();
|
|
384
|
-
if (body.result) {
|
|
385
|
-
const status = parseInt(body.result.status, 16);
|
|
386
|
-
if (status !== 1) {
|
|
387
|
-
throw new Error(`Transaction reverted on-chain (status: ${body.result.status}). Tx: ${txHash}`);
|
|
384
|
+
try {
|
|
385
|
+
const receipt = await evmRpcCall(chain, 'eth_getTransactionReceipt', [txHash]);
|
|
386
|
+
if (receipt) {
|
|
387
|
+
const status = parseInt(receipt.status, 16);
|
|
388
|
+
if (status !== 1) {
|
|
389
|
+
throw new Error(`Transaction reverted on-chain (status: ${receipt.status}). Tx: ${txHash}`);
|
|
390
|
+
}
|
|
391
|
+
return receipt;
|
|
388
392
|
}
|
|
389
|
-
|
|
393
|
+
} catch (e) {
|
|
394
|
+
// Re-throw confirmed on-chain reverts immediately; swallow transient RPC/network errors
|
|
395
|
+
if (e.message?.startsWith('Transaction reverted')) throw e;
|
|
396
|
+
// else: continue polling (pending tx, transient network error, etc.)
|
|
390
397
|
}
|
|
391
398
|
// Receipt not yet available — wait and retry
|
|
392
399
|
await new Promise(r => setTimeout(r, pollMs));
|
|
@@ -399,30 +406,21 @@ export async function waitForReceipt(chain, txHash, timeoutMs = 30000, pollMs =
|
|
|
399
406
|
* Returns { success: true } or { success: false, reason: string }.
|
|
400
407
|
*/
|
|
401
408
|
export async function simulateEvmCall(chain, { from, to, data, value, gas }) {
|
|
402
|
-
|
|
403
|
-
if (!rpcUrl) return { success: true }; // Can't simulate, skip
|
|
409
|
+
if (!EVM_RPC_URLS[chain]) return { success: true }; // Can't simulate, skip
|
|
404
410
|
|
|
405
411
|
try {
|
|
406
412
|
const callObj = { from, to, data, value: value || '0x0' };
|
|
407
413
|
if (gas) callObj.gas = gas; // Pass gas limit to catch under-gassed quotes
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
params: [callObj, 'latest'],
|
|
416
|
-
}),
|
|
417
|
-
});
|
|
418
|
-
const body = await res.json();
|
|
419
|
-
if (body.error) {
|
|
420
|
-
const reason = body.error.message || 'unknown';
|
|
421
|
-
return { success: false, reason };
|
|
414
|
+
await evmRpcCall(chain, 'eth_call', [callObj, 'latest']);
|
|
415
|
+
return { success: true };
|
|
416
|
+
} catch (e) {
|
|
417
|
+
const msg = e.message || 'unknown';
|
|
418
|
+
// Only block on actual contract-level revert errors from the RPC
|
|
419
|
+
if (msg.startsWith('RPC error (eth_call):')) {
|
|
420
|
+
return { success: false, reason: msg.replace(/^RPC error \(eth_call\): /, '') };
|
|
422
421
|
}
|
|
422
|
+
// Network/infrastructure errors (fetch failure, rate limit, non-JSON response) → non-blocking
|
|
423
423
|
return { success: true };
|
|
424
|
-
} catch {
|
|
425
|
-
return { success: true }; // Network error — don't block, let broadcast decide
|
|
426
424
|
}
|
|
427
425
|
}
|
|
428
426
|
|
|
@@ -431,23 +429,11 @@ export async function simulateEvmCall(chain, { from, to, data, value, gas }) {
|
|
|
431
429
|
* Used to fix under-gassed quotes from aggregators.
|
|
432
430
|
*/
|
|
433
431
|
export async function estimateEvmGas(chain, { from, to, data, value }) {
|
|
434
|
-
|
|
435
|
-
if (!rpcUrl) return null;
|
|
432
|
+
if (!EVM_RPC_URLS[chain]) return null;
|
|
436
433
|
|
|
437
434
|
try {
|
|
438
|
-
const
|
|
439
|
-
|
|
440
|
-
headers: { 'Content-Type': 'application/json' },
|
|
441
|
-
body: JSON.stringify({
|
|
442
|
-
jsonrpc: '2.0',
|
|
443
|
-
id: 1,
|
|
444
|
-
method: 'eth_estimateGas',
|
|
445
|
-
params: [{ from, to, data, value: value || '0x0' }],
|
|
446
|
-
}),
|
|
447
|
-
});
|
|
448
|
-
const body = await res.json();
|
|
449
|
-
if (body.error) return null;
|
|
450
|
-
return parseInt(body.result, 16);
|
|
435
|
+
const result = await evmRpcCall(chain, 'eth_estimateGas', [{ from, to, data, value: value || '0x0' }]);
|
|
436
|
+
return parseInt(result, 16);
|
|
451
437
|
} catch {
|
|
452
438
|
return null;
|
|
453
439
|
}
|
|
@@ -458,27 +444,16 @@ export async function estimateEvmGas(chain, { from, to, data, value }) {
|
|
|
458
444
|
* Returns the allowance as a BigInt, or 0n on failure.
|
|
459
445
|
*/
|
|
460
446
|
export async function checkErc20Allowance(chain, tokenAddress, ownerAddress, spenderAddress) {
|
|
461
|
-
|
|
462
|
-
if (!rpcUrl) return 0n;
|
|
447
|
+
if (!EVM_RPC_URLS[chain]) return 0n;
|
|
463
448
|
|
|
464
449
|
try {
|
|
465
450
|
// allowance(address,address) selector = 0xdd62ed3e
|
|
466
451
|
const data = '0xdd62ed3e'
|
|
467
452
|
+ ownerAddress.slice(2).toLowerCase().padStart(64, '0')
|
|
468
453
|
+ spenderAddress.slice(2).toLowerCase().padStart(64, '0');
|
|
469
|
-
const
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
body: JSON.stringify({
|
|
473
|
-
jsonrpc: '2.0',
|
|
474
|
-
id: 1,
|
|
475
|
-
method: 'eth_call',
|
|
476
|
-
params: [{ to: tokenAddress, data }, 'latest'],
|
|
477
|
-
}),
|
|
478
|
-
});
|
|
479
|
-
const body = await res.json();
|
|
480
|
-
if (body.error || !body.result) return 0n;
|
|
481
|
-
return BigInt(body.result);
|
|
454
|
+
const result = await evmRpcCall(chain, 'eth_call', [{ to: tokenAddress, data }, 'latest']);
|
|
455
|
+
if (!result) return 0n;
|
|
456
|
+
return BigInt(result);
|
|
482
457
|
} catch {
|
|
483
458
|
return 0n;
|
|
484
459
|
}
|
|
@@ -771,7 +746,7 @@ OPTIONS:
|
|
|
771
746
|
--from <symbol|address> Input token (symbol like SOL, USDC or address)
|
|
772
747
|
--to <symbol|address> Output token (symbol like USDC, ETH or address)
|
|
773
748
|
--amount <units> Amount in BASE UNITS (e.g. lamports, wei)
|
|
774
|
-
--wallet <name> Wallet name (default: default wallet). Use "walletconnect" or "wc" for WalletConnect
|
|
749
|
+
--wallet <name> Wallet name (default: default wallet). Use "walletconnect" or "wc" for WalletConnect.
|
|
775
750
|
--slippage <pct> Slippage as decimal (e.g. 0.03 for 3%). Default: 0.03
|
|
776
751
|
--auto-slippage Enable auto slippage calculation
|
|
777
752
|
--max-auto-slippage <pct> Max auto slippage when auto-slippage enabled
|
|
@@ -800,13 +775,10 @@ EXAMPLES:
|
|
|
800
775
|
const isWalletConnect = walletName === 'walletconnect' || walletName === 'wc';
|
|
801
776
|
|
|
802
777
|
let walletAddress;
|
|
778
|
+
let walletProvider = 'local';
|
|
779
|
+
let privyWalletIds = null;
|
|
803
780
|
if (isWalletConnect) {
|
|
804
|
-
|
|
805
|
-
log('WalletConnect is only supported for EVM chains');
|
|
806
|
-
exit(1);
|
|
807
|
-
return;
|
|
808
|
-
}
|
|
809
|
-
walletAddress = await getWalletConnectAddress();
|
|
781
|
+
walletAddress = await getWalletConnectAddress(chainType);
|
|
810
782
|
if (!walletAddress) {
|
|
811
783
|
log('No WalletConnect session active. Run: walletconnect connect');
|
|
812
784
|
exit(1);
|
|
@@ -815,9 +787,21 @@ EXAMPLES:
|
|
|
815
787
|
} else if (walletName) {
|
|
816
788
|
const wallet = showWallet(walletName);
|
|
817
789
|
walletAddress = chainType === 'solana' ? wallet.solana : wallet.evm;
|
|
790
|
+
if (wallet.provider === 'privy') {
|
|
791
|
+
walletProvider = 'privy';
|
|
792
|
+
privyWalletIds = wallet.privyWalletIds;
|
|
793
|
+
}
|
|
818
794
|
} else {
|
|
819
795
|
try {
|
|
820
|
-
|
|
796
|
+
const config = getWalletConfig();
|
|
797
|
+
if (config.defaultWallet) {
|
|
798
|
+
const wallet = showWallet(config.defaultWallet);
|
|
799
|
+
walletAddress = chainType === 'solana' ? wallet.solana : wallet.evm;
|
|
800
|
+
if (wallet.provider === 'privy') {
|
|
801
|
+
walletProvider = 'privy';
|
|
802
|
+
privyWalletIds = wallet.privyWalletIds;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
821
805
|
} catch {
|
|
822
806
|
// No wallet configured — fall through to the check below
|
|
823
807
|
}
|
|
@@ -861,7 +845,8 @@ EXAMPLES:
|
|
|
861
845
|
log('');
|
|
862
846
|
response.quotes.forEach((q, i) => log(formatQuote(q, i)));
|
|
863
847
|
|
|
864
|
-
const
|
|
848
|
+
const signerType = isWalletConnect ? 'walletconnect' : walletProvider;
|
|
849
|
+
const quoteId = saveQuote(response, chain, signerType, privyWalletIds);
|
|
865
850
|
log(`\n Quote ID: ${quoteId}`);
|
|
866
851
|
log(` Execute: nansen trade execute --quote ${quoteId}`);
|
|
867
852
|
if (response.quotes.length > 1) {
|
|
@@ -935,12 +920,18 @@ EXAMPLES:
|
|
|
935
920
|
return;
|
|
936
921
|
}
|
|
937
922
|
|
|
938
|
-
// Determine if this is a WalletConnect-signed quote
|
|
923
|
+
// Determine if this is a WalletConnect or Privy-signed quote
|
|
939
924
|
const isWalletConnect = quoteData.signerType === 'walletconnect'
|
|
940
925
|
|| walletName === 'walletconnect' || walletName === 'wc';
|
|
926
|
+
const isPrivy = quoteData.signerType === 'privy';
|
|
941
927
|
|
|
942
928
|
let exported = null;
|
|
943
|
-
|
|
929
|
+
let privyClient = null;
|
|
930
|
+
if (isPrivy) {
|
|
931
|
+
// Privy signing -- import + instantiate once for all quotes
|
|
932
|
+
const { PrivyClient } = await import('./privy.js');
|
|
933
|
+
privyClient = new PrivyClient(process.env.PRIVY_APP_ID, process.env.PRIVY_APP_SECRET);
|
|
934
|
+
} else if (!isWalletConnect) {
|
|
944
935
|
// Get wallet credentials once (before the loop)
|
|
945
936
|
const walletConfig = getWalletConfig();
|
|
946
937
|
let password = null;
|
|
@@ -974,12 +965,7 @@ EXAMPLES:
|
|
|
974
965
|
exported = exportWallet(effectiveWalletName, password);
|
|
975
966
|
} else {
|
|
976
967
|
// Verify WalletConnect session is still active and address matches quote
|
|
977
|
-
|
|
978
|
-
log('WalletConnect is only supported for EVM chains');
|
|
979
|
-
exit(1);
|
|
980
|
-
return;
|
|
981
|
-
}
|
|
982
|
-
const wcAddress = await getWalletConnectAddress();
|
|
968
|
+
const wcAddress = await getWalletConnectAddress(chainType);
|
|
983
969
|
if (!wcAddress) {
|
|
984
970
|
log('No WalletConnect session active. Run: walletconnect connect');
|
|
985
971
|
exit(1);
|
|
@@ -988,7 +974,9 @@ EXAMPLES:
|
|
|
988
974
|
// Check address matches the one used during quoting
|
|
989
975
|
const quoteWallet = quoteData.response?.quotes?.[0]?.transaction?.from
|
|
990
976
|
|| quoteData.response?.metadata?.userWalletAddress;
|
|
991
|
-
if (quoteWallet &&
|
|
977
|
+
if (quoteWallet && (chainType === 'solana'
|
|
978
|
+
? wcAddress.trim() !== quoteWallet.trim()
|
|
979
|
+
: wcAddress.toLowerCase().trim() !== quoteWallet.toLowerCase().trim())) {
|
|
992
980
|
log(`Connected wallet (${wcAddress}) doesn't match quote. Get a new quote with --wallet walletconnect`);
|
|
993
981
|
exit(1);
|
|
994
982
|
return;
|
|
@@ -1021,20 +1009,214 @@ EXAMPLES:
|
|
|
1021
1009
|
let signedTransaction;
|
|
1022
1010
|
let requestId;
|
|
1023
1011
|
|
|
1024
|
-
if (chainType === 'solana') {
|
|
1012
|
+
if (chainType === 'solana' && isPrivy) {
|
|
1013
|
+
// Solana via Privy: sign the serialized transaction
|
|
1014
|
+
let txBase64 = currentQuote.transaction;
|
|
1015
|
+
if (typeof txBase64 === 'object' && txBase64.data) {
|
|
1016
|
+
txBase64 = base58Decode(txBase64.data).toString('base64');
|
|
1017
|
+
}
|
|
1018
|
+
log(' Signing Solana transaction via Privy...');
|
|
1019
|
+
const solWalletId = quoteData.privyWalletIds?.solana;
|
|
1020
|
+
if (!solWalletId) throw new Error('No Solana Privy wallet ID in quote');
|
|
1021
|
+
const signResult = await privyClient.signSolanaTransaction(solWalletId, txBase64);
|
|
1022
|
+
signedTransaction = signResult.data?.signed_transaction || signResult.signed_transaction;
|
|
1023
|
+
requestId = currentQuote.metadata?.requestId;
|
|
1024
|
+
|
|
1025
|
+
} else if (chainType === 'evm' && isPrivy) {
|
|
1026
|
+
// EVM via Privy: sign-only, then broadcast via Trading API
|
|
1027
|
+
const evmWalletId = quoteData.privyWalletIds?.evm;
|
|
1028
|
+
if (!evmWalletId) throw new Error('No EVM Privy wallet ID in quote');
|
|
1029
|
+
|
|
1030
|
+
const walletResult = await privyClient.getWallet(evmWalletId);
|
|
1031
|
+
const walletAddress = walletResult.address;
|
|
1032
|
+
|
|
1033
|
+
// Validate transaction.value (same checks as local wallet)
|
|
1034
|
+
const isNative = isNativeToken(currentQuote.inputMint);
|
|
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
|
+
log(` ❌ Transaction value mismatch for ${quoteName}: tx.value=${txValue}, expected=${expectedValue}`);
|
|
1040
|
+
if (qi + 1 < endIndex) log(` Trying next quote...`);
|
|
1041
|
+
lastQuoteError = `${quoteName} transaction value mismatch`;
|
|
1042
|
+
continue;
|
|
1043
|
+
}
|
|
1044
|
+
} else {
|
|
1045
|
+
if (txValue > 0n) {
|
|
1046
|
+
log(` ❌ ERC-20 swap has non-zero tx.value (${txValue}) for ${quoteName} — aborting`);
|
|
1047
|
+
if (qi + 1 < endIndex) log(` Trying next quote...`);
|
|
1048
|
+
lastQuoteError = `${quoteName} unexpected tx.value`;
|
|
1049
|
+
continue;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// Handle approval 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, walletAddress, currentQuote.approvalAddress
|
|
1058
|
+
);
|
|
1059
|
+
|
|
1060
|
+
if (existingAllowance >= inputAmount && existingAllowance > 0n) {
|
|
1061
|
+
log(` ✓ Sufficient allowance exists for ${quoteName}, skipping approval`);
|
|
1062
|
+
} else {
|
|
1063
|
+
log(` ⚠ Approval required → ${currentQuote.approvalAddress}`);
|
|
1064
|
+
const approvalNonce = await getEvmNonce(chain, walletAddress);
|
|
1065
|
+
const MAX_UINT256 = 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff';
|
|
1066
|
+
const approvalData = '0x095ea7b3'
|
|
1067
|
+
+ currentQuote.approvalAddress.slice(2).toLowerCase().padStart(64, '0')
|
|
1068
|
+
+ MAX_UINT256;
|
|
1069
|
+
const approvalMaxFee = currentQuote.transaction?.maxFeePerGas || currentQuote.transaction?.gasPrice || '1000000';
|
|
1070
|
+
const approvalPriorityFee = currentQuote.transaction?.maxPriorityFeePerGas || '1000000';
|
|
1071
|
+
const approvalSignResult = await privyClient.signEvmTransaction(evmWalletId, {
|
|
1072
|
+
to: currentQuote.inputMint,
|
|
1073
|
+
data: approvalData,
|
|
1074
|
+
value: '0x0',
|
|
1075
|
+
chain_id: chainConfig.chainId,
|
|
1076
|
+
nonce: toHex(approvalNonce),
|
|
1077
|
+
gas_limit: toHex(100000),
|
|
1078
|
+
max_fee_per_gas: toHex(approvalMaxFee),
|
|
1079
|
+
max_priority_fee_per_gas: toHex(approvalPriorityFee),
|
|
1080
|
+
});
|
|
1081
|
+
const signedApproval = approvalSignResult.data?.signed_transaction || approvalSignResult.signed_transaction;
|
|
1082
|
+
const approvalResult = await executeTransaction({ signedTransaction: signedApproval, chain, simulate: !noSimulate });
|
|
1083
|
+
if (approvalResult.status !== 'Success') {
|
|
1084
|
+
log(` ❌ Approval failed for ${quoteName}: ${approvalResult.error || 'unknown'}`);
|
|
1085
|
+
if (qi + 1 < endIndex) log(` Trying next quote...`);
|
|
1086
|
+
lastQuoteError = `${quoteName} approval failed`;
|
|
1087
|
+
continue;
|
|
1088
|
+
}
|
|
1089
|
+
log(` Waiting for approval confirmation...`);
|
|
1090
|
+
try {
|
|
1091
|
+
const receipt = await waitForReceipt(chain, approvalResult.txHash);
|
|
1092
|
+
log(` ✓ Approval confirmed in block ${parseInt(receipt.blockNumber, 16)}: ${approvalResult.txHash}`);
|
|
1093
|
+
} catch (receiptErr) {
|
|
1094
|
+
log(` ❌ Approval may not have confirmed: ${receiptErr.message}`);
|
|
1095
|
+
if (qi + 1 < endIndex) log(` Trying next quote...`);
|
|
1096
|
+
lastQuoteError = `${quoteName} approval unconfirmed`;
|
|
1097
|
+
continue;
|
|
1098
|
+
}
|
|
1099
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
// Pre-flight simulation
|
|
1104
|
+
if (!noSimulate) {
|
|
1105
|
+
const sim = await simulateEvmCall(chain, {
|
|
1106
|
+
from: walletAddress,
|
|
1107
|
+
to: currentQuote.transaction.to,
|
|
1108
|
+
data: currentQuote.transaction.data,
|
|
1109
|
+
value: currentQuote.transaction.value ? '0x' + BigInt(currentQuote.transaction.value).toString(16) : '0x0',
|
|
1110
|
+
});
|
|
1111
|
+
if (!sim.success) {
|
|
1112
|
+
log(` ⚠ Simulation failed for ${quoteName}: ${sim.reason}`);
|
|
1113
|
+
if (qi + 1 < endIndex) log(` Trying next quote...`);
|
|
1114
|
+
lastQuoteError = `${quoteName} simulation failed: ${sim.reason}`;
|
|
1115
|
+
continue;
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// Gas resolution — fall back to eth_estimateGas if quote has no gas
|
|
1120
|
+
const txData = currentQuote.transaction;
|
|
1121
|
+
const apiGas = parseInt(currentQuote.gas || '0');
|
|
1122
|
+
const txGas = parseInt(txData.gas || txData.gasLimit || '0');
|
|
1123
|
+
let finalGas = apiGas > 0 ? apiGas : txGas;
|
|
1124
|
+
if (finalGas === 0) {
|
|
1125
|
+
try {
|
|
1126
|
+
const rpcUrl = EVM_RPC_URLS[chain];
|
|
1127
|
+
const estRes = await fetch(rpcUrl, {
|
|
1128
|
+
method: 'POST',
|
|
1129
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1130
|
+
body: JSON.stringify({
|
|
1131
|
+
jsonrpc: '2.0', id: 1, method: 'eth_estimateGas',
|
|
1132
|
+
params: [{
|
|
1133
|
+
from: walletAddress, to: txData.to, data: txData.data || '0x',
|
|
1134
|
+
value: txData.value ? '0x' + BigInt(txData.value).toString(16) : '0x0',
|
|
1135
|
+
}],
|
|
1136
|
+
}),
|
|
1137
|
+
});
|
|
1138
|
+
const estBody = await estRes.json();
|
|
1139
|
+
if (estBody.result) finalGas = Math.ceil(parseInt(estBody.result, 16) * 1.5);
|
|
1140
|
+
} catch { /* ignore */ }
|
|
1141
|
+
if (finalGas === 0) finalGas = 210000;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
log(' Fetching nonce...');
|
|
1145
|
+
const nonce = await getEvmNonce(chain, walletAddress);
|
|
1146
|
+
|
|
1147
|
+
// Privy signs EIP-1559 (type 2) transactions, so convert gasPrice to EIP-1559 fields
|
|
1148
|
+
const maxFee = txData.maxFeePerGas || txData.gasPrice || '1000000';
|
|
1149
|
+
const priorityFee = txData.maxPriorityFeePerGas || '1000000';
|
|
1150
|
+
|
|
1151
|
+
log(' Signing EVM transaction via Privy...');
|
|
1152
|
+
const signResult = await privyClient.signEvmTransaction(evmWalletId, {
|
|
1153
|
+
to: txData.to,
|
|
1154
|
+
data: txData.data || '0x',
|
|
1155
|
+
value: txData.value ? '0x' + BigInt(txData.value).toString(16) : '0x0',
|
|
1156
|
+
chain_id: chainConfig.chainId,
|
|
1157
|
+
nonce: toHex(nonce),
|
|
1158
|
+
gas_limit: toHex(finalGas),
|
|
1159
|
+
max_fee_per_gas: toHex(maxFee),
|
|
1160
|
+
max_priority_fee_per_gas: toHex(priorityFee),
|
|
1161
|
+
});
|
|
1162
|
+
signedTransaction = signResult.data?.signed_transaction || signResult.signed_transaction;
|
|
1163
|
+
|
|
1164
|
+
} else if (chainType === 'solana') {
|
|
1025
1165
|
// Solana: transaction is either a base64 string (Jupiter) or an object
|
|
1026
1166
|
// with a base58-encoded `data` field (OKX). Normalize to base64.
|
|
1027
1167
|
let txBase64 = currentQuote.transaction;
|
|
1028
1168
|
if (typeof txBase64 === 'object' && txBase64.data) {
|
|
1029
1169
|
txBase64 = base58Decode(txBase64.data).toString('base64');
|
|
1030
1170
|
}
|
|
1031
|
-
|
|
1032
|
-
|
|
1171
|
+
|
|
1172
|
+
if (isWalletConnect) {
|
|
1173
|
+
// Solana via WalletConnect: convert base64 → base58 for WC protocol
|
|
1174
|
+
log(' Signing Solana transaction via WalletConnect...');
|
|
1175
|
+
let txBase58;
|
|
1176
|
+
try {
|
|
1177
|
+
txBase58 = base58Encode(Buffer.from(txBase64, 'base64'));
|
|
1178
|
+
} catch (err) {
|
|
1179
|
+
throw new Error(`Failed to encode transaction for WalletConnect: ${err.message}`, { cause: err });
|
|
1180
|
+
}
|
|
1181
|
+
const wcResult = await sendSolanaTransactionViaWalletConnect(txBase58);
|
|
1182
|
+
|
|
1183
|
+
if (wcResult.signedTransaction) {
|
|
1184
|
+
signedTransaction = base58Decode(wcResult.signedTransaction).toString('base64');
|
|
1185
|
+
} else if (wcResult.signature) {
|
|
1186
|
+
// Wallet returned raw Ed25519 sig → inject into unsigned tx
|
|
1187
|
+
let sigBytes;
|
|
1188
|
+
try {
|
|
1189
|
+
sigBytes = base58Decode(wcResult.signature);
|
|
1190
|
+
} catch (err) {
|
|
1191
|
+
throw new Error(`Invalid base58 signature from WalletConnect: ${err.message}`, { cause: err });
|
|
1192
|
+
}
|
|
1193
|
+
if (sigBytes.length !== 64) {
|
|
1194
|
+
throw new Error(`Invalid Ed25519 signature length: expected 64 bytes, got ${sigBytes.length}`);
|
|
1195
|
+
}
|
|
1196
|
+
// Buffer.from() creates a new buffer — safe to mutate in-place
|
|
1197
|
+
const txBytes = Buffer.from(txBase64, 'base64');
|
|
1198
|
+
const { value: sigCount, size: sigCountSize } = readCompactU16(txBytes, 0);
|
|
1199
|
+
if (sigCount < 1) {
|
|
1200
|
+
throw new Error('Transaction has no signature slots');
|
|
1201
|
+
}
|
|
1202
|
+
if (txBytes.length < sigCountSize + 64) {
|
|
1203
|
+
throw new Error(`Transaction buffer too small for signature: need ${sigCountSize + 64}, got ${txBytes.length}`);
|
|
1204
|
+
}
|
|
1205
|
+
// Inject into the first signature slot (feePayer)
|
|
1206
|
+
sigBytes.copy(txBytes, sigCountSize);
|
|
1207
|
+
signedTransaction = txBytes.toString('base64');
|
|
1208
|
+
} else {
|
|
1209
|
+
throw new Error('WalletConnect returned neither signedTransaction nor signature');
|
|
1210
|
+
}
|
|
1211
|
+
} else {
|
|
1212
|
+
log(' Signing Solana transaction...');
|
|
1213
|
+
signedTransaction = signSolanaTransaction(txBase64, exported.solana.privateKey);
|
|
1214
|
+
}
|
|
1033
1215
|
requestId = currentQuote.metadata?.requestId;
|
|
1034
1216
|
|
|
1035
1217
|
} else if (isWalletConnect) {
|
|
1036
1218
|
// EVM via WalletConnect: wallet signs and may broadcast
|
|
1037
|
-
const wcAddress = await getWalletConnectAddress();
|
|
1219
|
+
const wcAddress = await getWalletConnectAddress(chainType);
|
|
1038
1220
|
const isNative = isNativeToken(currentQuote.inputMint);
|
|
1039
1221
|
|
|
1040
1222
|
// Validate transaction.value (same checks as local wallet)
|