nansen-cli 1.13.1 → 1.15.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 +28 -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 +31 -23
- package/src/keychain.js +229 -0
- package/src/privy.js +359 -0
- package/src/schema.json +42 -2
- package/src/trading.js +264 -118
- package/src/transfer.js +150 -25
- package/src/wallet.js +354 -70
- package/src/x402-svm.js +43 -24
- package/src/x402.js +2 -2
package/src/trading.js
CHANGED
|
@@ -8,10 +8,11 @@
|
|
|
8
8
|
import crypto from 'crypto';
|
|
9
9
|
import fs from 'fs';
|
|
10
10
|
import path from 'path';
|
|
11
|
-
import { exportWallet,
|
|
11
|
+
import { exportWallet, getWalletConfig, showWallet, listWallets } from './wallet.js';
|
|
12
12
|
import { base58Decode } from './transfer.js';
|
|
13
13
|
import { keccak256, signSecp256k1, rlpEncode } from './crypto.js';
|
|
14
14
|
import { getWalletConnectAddress, sendTransactionViaWalletConnect, sendApprovalViaWalletConnect } from './walletconnect-trading.js';
|
|
15
|
+
import { retrievePassword } from './keychain.js';
|
|
15
16
|
|
|
16
17
|
// ============= Constants =============
|
|
17
18
|
|
|
@@ -67,6 +68,33 @@ const EVM_RPC_URLS = {
|
|
|
67
68
|
base: process.env.NANSEN_RPC_BASE || 'https://mainnet.base.org',
|
|
68
69
|
};
|
|
69
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
|
+
|
|
70
98
|
function getQuotesDir() {
|
|
71
99
|
const configDir = path.join(process.env.HOME || process.env.USERPROFILE || '', '.nansen');
|
|
72
100
|
return path.join(configDir, 'quotes');
|
|
@@ -186,7 +214,7 @@ export async function executeTransaction(params, { retries = 2, retryDelayMs = 1
|
|
|
186
214
|
* Save a quote response to disk for later execution.
|
|
187
215
|
* @returns {string} Quote ID
|
|
188
216
|
*/
|
|
189
|
-
export function saveQuote(quoteResponse, chain, signerType = 'local') {
|
|
217
|
+
export function saveQuote(quoteResponse, chain, signerType = 'local', privyWalletIds = null) {
|
|
190
218
|
const dir = getQuotesDir();
|
|
191
219
|
if (!fs.existsSync(dir)) {
|
|
192
220
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
@@ -197,6 +225,7 @@ export function saveQuote(quoteResponse, chain, signerType = 'local') {
|
|
|
197
225
|
const quoteId = `${timestamp}-${hash}`;
|
|
198
226
|
|
|
199
227
|
const data = { quoteId, chain, timestamp, signerType, response: quoteResponse };
|
|
228
|
+
if (privyWalletIds) data.privyWalletIds = privyWalletIds;
|
|
200
229
|
|
|
201
230
|
fs.writeFileSync(path.join(dir, `${quoteId}.json`), JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
202
231
|
cleanupQuotes();
|
|
@@ -335,22 +364,8 @@ export function signEvmTransaction(txData, privateKeyHex, chain, nonce) {
|
|
|
335
364
|
* @returns {Promise<number>} Nonce
|
|
336
365
|
*/
|
|
337
366
|
export async function getEvmNonce(chain, address) {
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
const res = await fetch(rpcUrl, {
|
|
342
|
-
method: 'POST',
|
|
343
|
-
headers: { 'Content-Type': 'application/json' },
|
|
344
|
-
body: JSON.stringify({
|
|
345
|
-
jsonrpc: '2.0',
|
|
346
|
-
id: 1,
|
|
347
|
-
method: 'eth_getTransactionCount',
|
|
348
|
-
params: [address, 'pending'],
|
|
349
|
-
}),
|
|
350
|
-
});
|
|
351
|
-
const body = await res.json();
|
|
352
|
-
if (body.error) throw new Error(`RPC error: ${body.error.message}`);
|
|
353
|
-
return parseInt(body.result, 16);
|
|
367
|
+
const result = await evmRpcCall(chain, 'eth_getTransactionCount', [address, 'pending']);
|
|
368
|
+
return parseInt(result, 16);
|
|
354
369
|
}
|
|
355
370
|
|
|
356
371
|
/**
|
|
@@ -364,28 +379,21 @@ export async function getEvmNonce(chain, address) {
|
|
|
364
379
|
* @returns {Promise<object>} Transaction receipt
|
|
365
380
|
*/
|
|
366
381
|
export async function waitForReceipt(chain, txHash, timeoutMs = 30000, pollMs = 2000) {
|
|
367
|
-
const rpcUrl = EVM_RPC_URLS[chain];
|
|
368
|
-
if (!rpcUrl) throw new Error(`No RPC URL configured for chain: ${chain}`);
|
|
369
|
-
|
|
370
382
|
const start = Date.now();
|
|
371
383
|
while (Date.now() - start < timeoutMs) {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
}),
|
|
381
|
-
});
|
|
382
|
-
const body = await res.json();
|
|
383
|
-
if (body.result) {
|
|
384
|
-
const status = parseInt(body.result.status, 16);
|
|
385
|
-
if (status !== 1) {
|
|
386
|
-
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;
|
|
387
392
|
}
|
|
388
|
-
|
|
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.)
|
|
389
397
|
}
|
|
390
398
|
// Receipt not yet available — wait and retry
|
|
391
399
|
await new Promise(r => setTimeout(r, pollMs));
|
|
@@ -398,30 +406,21 @@ export async function waitForReceipt(chain, txHash, timeoutMs = 30000, pollMs =
|
|
|
398
406
|
* Returns { success: true } or { success: false, reason: string }.
|
|
399
407
|
*/
|
|
400
408
|
export async function simulateEvmCall(chain, { from, to, data, value, gas }) {
|
|
401
|
-
|
|
402
|
-
if (!rpcUrl) return { success: true }; // Can't simulate, skip
|
|
409
|
+
if (!EVM_RPC_URLS[chain]) return { success: true }; // Can't simulate, skip
|
|
403
410
|
|
|
404
411
|
try {
|
|
405
412
|
const callObj = { from, to, data, value: value || '0x0' };
|
|
406
413
|
if (gas) callObj.gas = gas; // Pass gas limit to catch under-gassed quotes
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
params: [callObj, 'latest'],
|
|
415
|
-
}),
|
|
416
|
-
});
|
|
417
|
-
const body = await res.json();
|
|
418
|
-
if (body.error) {
|
|
419
|
-
const reason = body.error.message || 'unknown';
|
|
420
|
-
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\): /, '') };
|
|
421
421
|
}
|
|
422
|
+
// Network/infrastructure errors (fetch failure, rate limit, non-JSON response) → non-blocking
|
|
422
423
|
return { success: true };
|
|
423
|
-
} catch {
|
|
424
|
-
return { success: true }; // Network error — don't block, let broadcast decide
|
|
425
424
|
}
|
|
426
425
|
}
|
|
427
426
|
|
|
@@ -430,23 +429,11 @@ export async function simulateEvmCall(chain, { from, to, data, value, gas }) {
|
|
|
430
429
|
* Used to fix under-gassed quotes from aggregators.
|
|
431
430
|
*/
|
|
432
431
|
export async function estimateEvmGas(chain, { from, to, data, value }) {
|
|
433
|
-
|
|
434
|
-
if (!rpcUrl) return null;
|
|
432
|
+
if (!EVM_RPC_URLS[chain]) return null;
|
|
435
433
|
|
|
436
434
|
try {
|
|
437
|
-
const
|
|
438
|
-
|
|
439
|
-
headers: { 'Content-Type': 'application/json' },
|
|
440
|
-
body: JSON.stringify({
|
|
441
|
-
jsonrpc: '2.0',
|
|
442
|
-
id: 1,
|
|
443
|
-
method: 'eth_estimateGas',
|
|
444
|
-
params: [{ from, to, data, value: value || '0x0' }],
|
|
445
|
-
}),
|
|
446
|
-
});
|
|
447
|
-
const body = await res.json();
|
|
448
|
-
if (body.error) return null;
|
|
449
|
-
return parseInt(body.result, 16);
|
|
435
|
+
const result = await evmRpcCall(chain, 'eth_estimateGas', [{ from, to, data, value: value || '0x0' }]);
|
|
436
|
+
return parseInt(result, 16);
|
|
450
437
|
} catch {
|
|
451
438
|
return null;
|
|
452
439
|
}
|
|
@@ -457,27 +444,16 @@ export async function estimateEvmGas(chain, { from, to, data, value }) {
|
|
|
457
444
|
* Returns the allowance as a BigInt, or 0n on failure.
|
|
458
445
|
*/
|
|
459
446
|
export async function checkErc20Allowance(chain, tokenAddress, ownerAddress, spenderAddress) {
|
|
460
|
-
|
|
461
|
-
if (!rpcUrl) return 0n;
|
|
447
|
+
if (!EVM_RPC_URLS[chain]) return 0n;
|
|
462
448
|
|
|
463
449
|
try {
|
|
464
450
|
// allowance(address,address) selector = 0xdd62ed3e
|
|
465
451
|
const data = '0xdd62ed3e'
|
|
466
452
|
+ ownerAddress.slice(2).toLowerCase().padStart(64, '0')
|
|
467
453
|
+ spenderAddress.slice(2).toLowerCase().padStart(64, '0');
|
|
468
|
-
const
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
body: JSON.stringify({
|
|
472
|
-
jsonrpc: '2.0',
|
|
473
|
-
id: 1,
|
|
474
|
-
method: 'eth_call',
|
|
475
|
-
params: [{ to: tokenAddress, data }, 'latest'],
|
|
476
|
-
}),
|
|
477
|
-
});
|
|
478
|
-
const body = await res.json();
|
|
479
|
-
if (body.error || !body.result) return 0n;
|
|
480
|
-
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);
|
|
481
457
|
} catch {
|
|
482
458
|
return 0n;
|
|
483
459
|
}
|
|
@@ -652,31 +628,15 @@ export function getWalletChainType(chainName) {
|
|
|
652
628
|
|
|
653
629
|
// ============= CLI Helpers =============
|
|
654
630
|
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
if (stdin.setRawMode) stdin.setRawMode(true);
|
|
665
|
-
stdin.resume();
|
|
666
|
-
const onData = (ch) => {
|
|
667
|
-
const c = ch.toString();
|
|
668
|
-
if (c === '\n' || c === '\r') {
|
|
669
|
-
if (stdin.setRawMode) stdin.setRawMode(wasRaw || false);
|
|
670
|
-
stdin.removeListener('data', onData);
|
|
671
|
-
process.stderr.write('\n');
|
|
672
|
-
rl.close();
|
|
673
|
-
resolve(input);
|
|
674
|
-
} else if (c === '\u0003') { rl.close(); process.exit(1); }
|
|
675
|
-
else if (c === '\u007f' || c === '\b') { input = input.slice(0, -1); }
|
|
676
|
-
else { input += c; }
|
|
677
|
-
};
|
|
678
|
-
stdin.on('data', onData);
|
|
679
|
-
});
|
|
631
|
+
function resolveTradePassword() {
|
|
632
|
+
const { password, source } = retrievePassword();
|
|
633
|
+
if (source === 'file') {
|
|
634
|
+
process.stderr.write(
|
|
635
|
+
'⚠️ Password loaded from ~/.nansen/wallets/.credentials (insecure — plaintext on disk).\n' +
|
|
636
|
+
' For better security, migrate to OS keychain: nansen wallet secure\n'
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
return password;
|
|
680
640
|
}
|
|
681
641
|
|
|
682
642
|
function isNativeToken(mintAddress) {
|
|
@@ -815,6 +775,8 @@ EXAMPLES:
|
|
|
815
775
|
const isWalletConnect = walletName === 'walletconnect' || walletName === 'wc';
|
|
816
776
|
|
|
817
777
|
let walletAddress;
|
|
778
|
+
let walletProvider = 'local';
|
|
779
|
+
let privyWalletIds = null;
|
|
818
780
|
if (isWalletConnect) {
|
|
819
781
|
if (chainType !== 'evm') {
|
|
820
782
|
log('WalletConnect is only supported for EVM chains');
|
|
@@ -830,9 +792,21 @@ EXAMPLES:
|
|
|
830
792
|
} else if (walletName) {
|
|
831
793
|
const wallet = showWallet(walletName);
|
|
832
794
|
walletAddress = chainType === 'solana' ? wallet.solana : wallet.evm;
|
|
795
|
+
if (wallet.provider === 'privy') {
|
|
796
|
+
walletProvider = 'privy';
|
|
797
|
+
privyWalletIds = wallet.privyWalletIds;
|
|
798
|
+
}
|
|
833
799
|
} else {
|
|
834
800
|
try {
|
|
835
|
-
|
|
801
|
+
const config = getWalletConfig();
|
|
802
|
+
if (config.defaultWallet) {
|
|
803
|
+
const wallet = showWallet(config.defaultWallet);
|
|
804
|
+
walletAddress = chainType === 'solana' ? wallet.solana : wallet.evm;
|
|
805
|
+
if (wallet.provider === 'privy') {
|
|
806
|
+
walletProvider = 'privy';
|
|
807
|
+
privyWalletIds = wallet.privyWalletIds;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
836
810
|
} catch {
|
|
837
811
|
// No wallet configured — fall through to the check below
|
|
838
812
|
}
|
|
@@ -876,7 +850,8 @@ EXAMPLES:
|
|
|
876
850
|
log('');
|
|
877
851
|
response.quotes.forEach((q, i) => log(formatQuote(q, i)));
|
|
878
852
|
|
|
879
|
-
const
|
|
853
|
+
const signerType = isWalletConnect ? 'walletconnect' : walletProvider;
|
|
854
|
+
const quoteId = saveQuote(response, chain, signerType, privyWalletIds);
|
|
880
855
|
log(`\n Quote ID: ${quoteId}`);
|
|
881
856
|
log(` Execute: nansen trade execute --quote ${quoteId}`);
|
|
882
857
|
if (response.quotes.length > 1) {
|
|
@@ -950,17 +925,36 @@ EXAMPLES:
|
|
|
950
925
|
return;
|
|
951
926
|
}
|
|
952
927
|
|
|
953
|
-
// Determine if this is a WalletConnect-signed quote
|
|
928
|
+
// Determine if this is a WalletConnect or Privy-signed quote
|
|
954
929
|
const isWalletConnect = quoteData.signerType === 'walletconnect'
|
|
955
930
|
|| walletName === 'walletconnect' || walletName === 'wc';
|
|
931
|
+
const isPrivy = quoteData.signerType === 'privy';
|
|
956
932
|
|
|
957
933
|
let exported = null;
|
|
958
|
-
|
|
934
|
+
let privyClient = null;
|
|
935
|
+
if (isPrivy) {
|
|
936
|
+
// Privy signing -- import + instantiate once for all quotes
|
|
937
|
+
const { PrivyClient } = await import('./privy.js');
|
|
938
|
+
privyClient = new PrivyClient(process.env.PRIVY_APP_ID, process.env.PRIVY_APP_SECRET);
|
|
939
|
+
} else if (!isWalletConnect) {
|
|
959
940
|
// Get wallet credentials once (before the loop)
|
|
960
941
|
const walletConfig = getWalletConfig();
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
942
|
+
let password = null;
|
|
943
|
+
if (walletConfig.passwordHash) {
|
|
944
|
+
password = resolveTradePassword();
|
|
945
|
+
if (!password) {
|
|
946
|
+
log(JSON.stringify({
|
|
947
|
+
error: 'PASSWORD_REQUIRED',
|
|
948
|
+
message: 'Wallet is encrypted and no password was found.',
|
|
949
|
+
resolution: [
|
|
950
|
+
'Set NANSEN_WALLET_PASSWORD environment variable',
|
|
951
|
+
'Or run: nansen wallet create (password is saved to OS keychain automatically)',
|
|
952
|
+
],
|
|
953
|
+
}));
|
|
954
|
+
exit(1);
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
}
|
|
964
958
|
|
|
965
959
|
let effectiveWalletName = walletName;
|
|
966
960
|
if (!effectiveWalletName) {
|
|
@@ -1023,7 +1017,159 @@ EXAMPLES:
|
|
|
1023
1017
|
let signedTransaction;
|
|
1024
1018
|
let requestId;
|
|
1025
1019
|
|
|
1026
|
-
if (chainType === 'solana') {
|
|
1020
|
+
if (chainType === 'solana' && isPrivy) {
|
|
1021
|
+
// Solana via Privy: sign the serialized transaction
|
|
1022
|
+
let txBase64 = currentQuote.transaction;
|
|
1023
|
+
if (typeof txBase64 === 'object' && txBase64.data) {
|
|
1024
|
+
txBase64 = base58Decode(txBase64.data).toString('base64');
|
|
1025
|
+
}
|
|
1026
|
+
log(' Signing Solana transaction via Privy...');
|
|
1027
|
+
const solWalletId = quoteData.privyWalletIds?.solana;
|
|
1028
|
+
if (!solWalletId) throw new Error('No Solana Privy wallet ID in quote');
|
|
1029
|
+
const signResult = await privyClient.signSolanaTransaction(solWalletId, txBase64);
|
|
1030
|
+
signedTransaction = signResult.data?.signed_transaction || signResult.signed_transaction;
|
|
1031
|
+
requestId = currentQuote.metadata?.requestId;
|
|
1032
|
+
|
|
1033
|
+
} else if (chainType === 'evm' && isPrivy) {
|
|
1034
|
+
// EVM via Privy: sign-only, then broadcast via Trading API
|
|
1035
|
+
const evmWalletId = quoteData.privyWalletIds?.evm;
|
|
1036
|
+
if (!evmWalletId) throw new Error('No EVM Privy wallet ID in quote');
|
|
1037
|
+
|
|
1038
|
+
const walletResult = await privyClient.getWallet(evmWalletId);
|
|
1039
|
+
const walletAddress = walletResult.address;
|
|
1040
|
+
|
|
1041
|
+
// Validate transaction.value (same checks as local wallet)
|
|
1042
|
+
const isNative = isNativeToken(currentQuote.inputMint);
|
|
1043
|
+
const txValue = BigInt(currentQuote.transaction.value || '0');
|
|
1044
|
+
if (isNative) {
|
|
1045
|
+
const expectedValue = BigInt(currentQuote.inAmount || currentQuote.inputAmount || '0');
|
|
1046
|
+
if (txValue !== expectedValue) {
|
|
1047
|
+
log(` ❌ Transaction value mismatch for ${quoteName}: tx.value=${txValue}, expected=${expectedValue}`);
|
|
1048
|
+
if (qi + 1 < endIndex) log(` Trying next quote...`);
|
|
1049
|
+
lastQuoteError = `${quoteName} transaction value mismatch`;
|
|
1050
|
+
continue;
|
|
1051
|
+
}
|
|
1052
|
+
} else {
|
|
1053
|
+
if (txValue > 0n) {
|
|
1054
|
+
log(` ❌ ERC-20 swap has non-zero tx.value (${txValue}) for ${quoteName} — aborting`);
|
|
1055
|
+
if (qi + 1 < endIndex) log(` Trying next quote...`);
|
|
1056
|
+
lastQuoteError = `${quoteName} unexpected tx.value`;
|
|
1057
|
+
continue;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// Handle approval if needed
|
|
1062
|
+
if (currentQuote.approvalAddress && !isNative) {
|
|
1063
|
+
const inputAmount = BigInt(currentQuote.inputAmount || currentQuote.inAmount || '0');
|
|
1064
|
+
const existingAllowance = await checkErc20Allowance(
|
|
1065
|
+
chain, currentQuote.inputMint, walletAddress, currentQuote.approvalAddress
|
|
1066
|
+
);
|
|
1067
|
+
|
|
1068
|
+
if (existingAllowance >= inputAmount && existingAllowance > 0n) {
|
|
1069
|
+
log(` ✓ Sufficient allowance exists for ${quoteName}, skipping approval`);
|
|
1070
|
+
} else {
|
|
1071
|
+
log(` ⚠ Approval required → ${currentQuote.approvalAddress}`);
|
|
1072
|
+
const approvalNonce = await getEvmNonce(chain, walletAddress);
|
|
1073
|
+
const MAX_UINT256 = 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff';
|
|
1074
|
+
const approvalData = '0x095ea7b3'
|
|
1075
|
+
+ currentQuote.approvalAddress.slice(2).toLowerCase().padStart(64, '0')
|
|
1076
|
+
+ MAX_UINT256;
|
|
1077
|
+
const approvalMaxFee = currentQuote.transaction?.maxFeePerGas || currentQuote.transaction?.gasPrice || '1000000';
|
|
1078
|
+
const approvalPriorityFee = currentQuote.transaction?.maxPriorityFeePerGas || '1000000';
|
|
1079
|
+
const approvalSignResult = await privyClient.signEvmTransaction(evmWalletId, {
|
|
1080
|
+
to: currentQuote.inputMint,
|
|
1081
|
+
data: approvalData,
|
|
1082
|
+
value: '0x0',
|
|
1083
|
+
chain_id: chainConfig.chainId,
|
|
1084
|
+
nonce: toHex(approvalNonce),
|
|
1085
|
+
gas_limit: toHex(100000),
|
|
1086
|
+
max_fee_per_gas: toHex(approvalMaxFee),
|
|
1087
|
+
max_priority_fee_per_gas: toHex(approvalPriorityFee),
|
|
1088
|
+
});
|
|
1089
|
+
const signedApproval = approvalSignResult.data?.signed_transaction || approvalSignResult.signed_transaction;
|
|
1090
|
+
const approvalResult = await executeTransaction({ signedTransaction: signedApproval, chain, simulate: !noSimulate });
|
|
1091
|
+
if (approvalResult.status !== 'Success') {
|
|
1092
|
+
log(` ❌ Approval failed for ${quoteName}: ${approvalResult.error || 'unknown'}`);
|
|
1093
|
+
if (qi + 1 < endIndex) log(` Trying next quote...`);
|
|
1094
|
+
lastQuoteError = `${quoteName} approval failed`;
|
|
1095
|
+
continue;
|
|
1096
|
+
}
|
|
1097
|
+
log(` Waiting for approval confirmation...`);
|
|
1098
|
+
try {
|
|
1099
|
+
const receipt = await waitForReceipt(chain, approvalResult.txHash);
|
|
1100
|
+
log(` ✓ Approval confirmed in block ${parseInt(receipt.blockNumber, 16)}: ${approvalResult.txHash}`);
|
|
1101
|
+
} catch (receiptErr) {
|
|
1102
|
+
log(` ❌ Approval may not have confirmed: ${receiptErr.message}`);
|
|
1103
|
+
if (qi + 1 < endIndex) log(` Trying next quote...`);
|
|
1104
|
+
lastQuoteError = `${quoteName} approval unconfirmed`;
|
|
1105
|
+
continue;
|
|
1106
|
+
}
|
|
1107
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
// Pre-flight simulation
|
|
1112
|
+
if (!noSimulate) {
|
|
1113
|
+
const sim = await simulateEvmCall(chain, {
|
|
1114
|
+
from: walletAddress,
|
|
1115
|
+
to: currentQuote.transaction.to,
|
|
1116
|
+
data: currentQuote.transaction.data,
|
|
1117
|
+
value: currentQuote.transaction.value ? '0x' + BigInt(currentQuote.transaction.value).toString(16) : '0x0',
|
|
1118
|
+
});
|
|
1119
|
+
if (!sim.success) {
|
|
1120
|
+
log(` ⚠ Simulation failed for ${quoteName}: ${sim.reason}`);
|
|
1121
|
+
if (qi + 1 < endIndex) log(` Trying next quote...`);
|
|
1122
|
+
lastQuoteError = `${quoteName} simulation failed: ${sim.reason}`;
|
|
1123
|
+
continue;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// Gas resolution — fall back to eth_estimateGas if quote has no gas
|
|
1128
|
+
const txData = currentQuote.transaction;
|
|
1129
|
+
const apiGas = parseInt(currentQuote.gas || '0');
|
|
1130
|
+
const txGas = parseInt(txData.gas || txData.gasLimit || '0');
|
|
1131
|
+
let finalGas = apiGas > 0 ? apiGas : txGas;
|
|
1132
|
+
if (finalGas === 0) {
|
|
1133
|
+
try {
|
|
1134
|
+
const rpcUrl = EVM_RPC_URLS[chain];
|
|
1135
|
+
const estRes = await fetch(rpcUrl, {
|
|
1136
|
+
method: 'POST',
|
|
1137
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1138
|
+
body: JSON.stringify({
|
|
1139
|
+
jsonrpc: '2.0', id: 1, method: 'eth_estimateGas',
|
|
1140
|
+
params: [{
|
|
1141
|
+
from: walletAddress, to: txData.to, data: txData.data || '0x',
|
|
1142
|
+
value: txData.value ? '0x' + BigInt(txData.value).toString(16) : '0x0',
|
|
1143
|
+
}],
|
|
1144
|
+
}),
|
|
1145
|
+
});
|
|
1146
|
+
const estBody = await estRes.json();
|
|
1147
|
+
if (estBody.result) finalGas = Math.ceil(parseInt(estBody.result, 16) * 1.5);
|
|
1148
|
+
} catch { /* ignore */ }
|
|
1149
|
+
if (finalGas === 0) finalGas = 210000;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
log(' Fetching nonce...');
|
|
1153
|
+
const nonce = await getEvmNonce(chain, walletAddress);
|
|
1154
|
+
|
|
1155
|
+
// Privy signs EIP-1559 (type 2) transactions, so convert gasPrice to EIP-1559 fields
|
|
1156
|
+
const maxFee = txData.maxFeePerGas || txData.gasPrice || '1000000';
|
|
1157
|
+
const priorityFee = txData.maxPriorityFeePerGas || '1000000';
|
|
1158
|
+
|
|
1159
|
+
log(' Signing EVM transaction via Privy...');
|
|
1160
|
+
const signResult = await privyClient.signEvmTransaction(evmWalletId, {
|
|
1161
|
+
to: txData.to,
|
|
1162
|
+
data: txData.data || '0x',
|
|
1163
|
+
value: txData.value ? '0x' + BigInt(txData.value).toString(16) : '0x0',
|
|
1164
|
+
chain_id: chainConfig.chainId,
|
|
1165
|
+
nonce: toHex(nonce),
|
|
1166
|
+
gas_limit: toHex(finalGas),
|
|
1167
|
+
max_fee_per_gas: toHex(maxFee),
|
|
1168
|
+
max_priority_fee_per_gas: toHex(priorityFee),
|
|
1169
|
+
});
|
|
1170
|
+
signedTransaction = signResult.data?.signed_transaction || signResult.signed_transaction;
|
|
1171
|
+
|
|
1172
|
+
} else if (chainType === 'solana') {
|
|
1027
1173
|
// Solana: transaction is either a base64 string (Jupiter) or an object
|
|
1028
1174
|
// with a base58-encoded `data` field (OKX). Normalize to base64.
|
|
1029
1175
|
let txBase64 = currentQuote.transaction;
|