nansen-cli 1.40.0 → 1.41.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 +32 -0
- package/package.json +1 -1
- package/src/limit-order.js +20 -0
- package/src/perp.js +6 -0
- package/src/rpc-urls.js +9 -0
- package/src/schema.json +2 -2
- package/src/solana-simulation.js +345 -0
- package/src/solana-tx.js +153 -0
- package/src/trade-validation.js +428 -13
- package/src/trading.js +362 -54
package/src/trading.js
CHANGED
|
@@ -13,9 +13,12 @@ import { base58Decode } from './transfer.js';
|
|
|
13
13
|
import { keccak256, signSecp256k1, rlpEncode } from './crypto.js';
|
|
14
14
|
import { getWalletConnectAddress, sendTransactionViaWalletConnect, sendSolanaTransactionViaWalletConnect, sendApprovalViaWalletConnect } from './walletconnect-trading.js';
|
|
15
15
|
import { retrievePassword } from './keychain.js';
|
|
16
|
-
import { validateQuoteInput, validateBalance, resolvePercentAmount, validateGasBalance, encodeApproveCalldata, assertValidApprovalSpender, assertQuoteMatchesRequest, assertSwapCalldataNotBareTransfer, assertSwapOutcome, approvalAmountForSwap, needsAllowanceRevoke, OVERSIZED_ALLOWANCE_MULTIPLIER } from './trade-validation.js';
|
|
16
|
+
import { validateQuoteInput, validateBalance, resolvePercentAmount, validateGasBalance, encodeApproveCalldata, assertValidApprovalSpender, assertQuoteMatchesRequest, assertSwapCalldataNotBareTransfer, assertSwapOutcome, assertSolanaInstructionsSafe, assertSolanaSwapOutcome, approvalAmountForSwap, needsAllowanceRevoke, OVERSIZED_ALLOWANCE_MULTIPLIER } from './trade-validation.js';
|
|
17
|
+
import { readCompactU16 } from './solana-tx.js';
|
|
18
|
+
export { readCompactU16 };
|
|
17
19
|
import { CHAIN_RPCS } from './rpc-urls.js';
|
|
18
20
|
import { simulateAssetChanges, SwapSimulationError, hasSimulationRpc } from './swap-simulation.js';
|
|
21
|
+
import { simulateSolanaAssetChanges, SolanaSimulationError, hasSolanaSimulationRpc } from './solana-simulation.js';
|
|
19
22
|
import { packageVersion, CommandError, telemetryHeaders, loadConfig } from './api.js';
|
|
20
23
|
|
|
21
24
|
// ============= Constants =============
|
|
@@ -109,7 +112,8 @@ export function getQuotesDir() {
|
|
|
109
112
|
export function safeQuotesPath(filename) {
|
|
110
113
|
const base = path.resolve(getQuotesDir());
|
|
111
114
|
const target = path.resolve(base, filename);
|
|
112
|
-
|
|
115
|
+
const relative = path.relative(base, target);
|
|
116
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) return null;
|
|
113
117
|
return target;
|
|
114
118
|
}
|
|
115
119
|
|
|
@@ -565,6 +569,30 @@ export function signEvmTransaction(txData, privateKeyHex, chain, nonce) {
|
|
|
565
569
|
return signLegacyTransaction({ ...common, gasPrice: toHex(txData.gasPrice) }, privateKeyHex);
|
|
566
570
|
}
|
|
567
571
|
|
|
572
|
+
/**
|
|
573
|
+
* Canonical EVM transaction hash: keccak256 over the raw signed tx bytes.
|
|
574
|
+
*
|
|
575
|
+
* Works for legacy (RLP) and typed (0x02-prefixed EIP-1559) transactions alike,
|
|
576
|
+
* because the tx hash is defined over exactly the bytes that get broadcast.
|
|
577
|
+
*
|
|
578
|
+
* NB: this is NOT the signing hash. signEvmTransaction/signLegacyTransaction hash
|
|
579
|
+
* the *unsigned* payload to produce the message that gets signed; this hashes the
|
|
580
|
+
* fully *signed* transaction to produce its on-chain identifier.
|
|
581
|
+
*
|
|
582
|
+
* @param {string} signedTxHex - 0x-prefixed (or bare) hex of the signed transaction
|
|
583
|
+
* @returns {string} 0x-prefixed transaction hash
|
|
584
|
+
*/
|
|
585
|
+
export function evmTxHash(signedTxHex) {
|
|
586
|
+
if (typeof signedTxHex !== 'string') {
|
|
587
|
+
throw new Error('evmTxHash: signed transaction must be a hex string');
|
|
588
|
+
}
|
|
589
|
+
const hex = signedTxHex.startsWith('0x') ? signedTxHex.slice(2) : signedTxHex;
|
|
590
|
+
if (hex.length === 0 || hex.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(hex)) {
|
|
591
|
+
throw new Error('evmTxHash: signed transaction is not valid hex');
|
|
592
|
+
}
|
|
593
|
+
return '0x' + keccak256(Buffer.from(hex, 'hex')).toString('hex');
|
|
594
|
+
}
|
|
595
|
+
|
|
568
596
|
// How many queued-but-unmined transactions we are willing to sign past.
|
|
569
597
|
//
|
|
570
598
|
// `pending` counts mempool-queued transactions as well as mined ones, and that is
|
|
@@ -658,7 +686,108 @@ export async function waitForReceipt(chain, txHash, timeoutMs = 180000, pollMs =
|
|
|
658
686
|
// Receipt not yet available — wait and retry
|
|
659
687
|
await new Promise(r => setTimeout(r, pollMs));
|
|
660
688
|
}
|
|
661
|
-
|
|
689
|
+
// A timeout is NOT a confirmed revert: the tx may still be pending under our
|
|
690
|
+
// nonce. Tag it so callers can distinguish "reverted" (safe to try the next
|
|
691
|
+
// quote) from "unconfirmed" (retrying may broadcast a second tx that races
|
|
692
|
+
// the first for the same nonce). See the swap-path receipt catch.
|
|
693
|
+
const timeoutErr = new Error(`Transaction receipt not found after ${timeoutMs}ms. Tx: ${txHash}`);
|
|
694
|
+
timeoutErr.code = 'RECEIPT_TIMEOUT';
|
|
695
|
+
throw timeoutErr;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Post-broadcast failures that must abort the whole `execute` rather than fall
|
|
700
|
+
* through to the next quote. Once a transaction is broadcast we hold no evidence
|
|
701
|
+
* about what landed on-chain, so "try the next quote" would sign and broadcast a
|
|
702
|
+
* second transaction — the one thing we must not do. Covers every path (swap,
|
|
703
|
+
* approval, revoke; Privy/WalletConnect/local-key). Each code is thrown with a
|
|
704
|
+
* rationale at its throw site:
|
|
705
|
+
* - TXHASH_MISMATCH — broadcaster reported a tx we did not sign
|
|
706
|
+
* - INVALID_SIGNED_TX — we cannot even derive a hash for what we broadcast
|
|
707
|
+
* - RECEIPT_TIMEOUT — receipt never landed; the tx may still be pending, so
|
|
708
|
+
* retrying would race a second tx against the same nonce
|
|
709
|
+
* (a confirmed on-chain revert is NOT this — it may retry)
|
|
710
|
+
*
|
|
711
|
+
* @param {Error} err
|
|
712
|
+
* @returns {boolean}
|
|
713
|
+
*/
|
|
714
|
+
function isFatalBroadcastError(err) {
|
|
715
|
+
return err?.code === 'TXHASH_MISMATCH'
|
|
716
|
+
|| err?.code === 'INVALID_SIGNED_TX'
|
|
717
|
+
|| err?.code === 'RECEIPT_TIMEOUT';
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Assert the broadcaster reported the transaction we actually signed, and return
|
|
722
|
+
* our locally-derived hash.
|
|
723
|
+
*
|
|
724
|
+
* Fails closed (TXHASH_MISMATCH) when the broadcaster's returned hash differs
|
|
725
|
+
* from keccak256 of our signed bytes: a mismatch means its receipt would confirm
|
|
726
|
+
* a transaction we never signed, so nothing has been verified. When the
|
|
727
|
+
* broadcaster returns no hash we cannot compare, so the returned local hash is
|
|
728
|
+
* what callers must poll for a receipt — a substituted transaction then times
|
|
729
|
+
* out rather than falsely confirming.
|
|
730
|
+
*
|
|
731
|
+
* @param {string} signedTxHex - the raw signed tx we sent to /execute
|
|
732
|
+
* @param {string} broadcasterTxHash - the txHash /execute returned (may be empty)
|
|
733
|
+
* @param {string} [label] - describes the tx for the error, e.g. "allowance-revoke"
|
|
734
|
+
* @returns {string} our locally-derived transaction hash
|
|
735
|
+
*/
|
|
736
|
+
function assertTxHashMatch(signedTxHex, broadcasterTxHash, label = '') {
|
|
737
|
+
const what = label ? `the ${label} transaction this CLI signed` : 'the transaction this CLI signed';
|
|
738
|
+
// A derivation failure here happens AFTER the tx was broadcast, so it must be
|
|
739
|
+
// fatal (INVALID_SIGNED_TX), never swallowed into "try the next quote": we
|
|
740
|
+
// hold no hash for the transaction we just sent.
|
|
741
|
+
let localHash;
|
|
742
|
+
try {
|
|
743
|
+
localHash = evmTxHash(signedTxHex);
|
|
744
|
+
} catch (hashErr) {
|
|
745
|
+
throw new CommandError(
|
|
746
|
+
`Aborting: cannot derive a local hash for ${what}: ${hashErr.message}. `
|
|
747
|
+
+ `The transaction may already have been broadcast, so nothing further will run — `
|
|
748
|
+
+ `check your wallet before retrying.`,
|
|
749
|
+
'INVALID_SIGNED_TX',
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
// Normalize both sides through the same bare-hex form before comparing.
|
|
753
|
+
// evmTxHash always emits 0x-prefixed, but a broadcaster may report bare hex;
|
|
754
|
+
// comparing 0x-prefixed against bare would be a false mismatch on the prefix
|
|
755
|
+
// alone — and TXHASH_MISMATCH is fatal, so that would wrongly abort.
|
|
756
|
+
if (broadcasterTxHash) {
|
|
757
|
+
const norm = h => h.toLowerCase().replace(/^0x/, '');
|
|
758
|
+
if (norm(localHash) !== norm(broadcasterTxHash)) {
|
|
759
|
+
throw new CommandError(
|
|
760
|
+
`Aborting: the broadcaster reported transaction ${broadcasterTxHash}, but ${what} `
|
|
761
|
+
+ `hashes to ${localHash}. These must match — a mismatch means the receipt would confirm `
|
|
762
|
+
+ `a transaction you did not sign, so nothing has been verified and no further steps will `
|
|
763
|
+
+ `run. Check both hashes on a block explorer to see what was actually broadcast before retrying.`,
|
|
764
|
+
'TXHASH_MISMATCH',
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
return localHash;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Confirm a broadcast EVM transaction against the hash we derived locally from
|
|
773
|
+
* the signed bytes — not the hash the broadcaster reported. See
|
|
774
|
+
* {@link assertTxHashMatch} for the two guarantees (fail closed on mismatch;
|
|
775
|
+
* poll our own hash so a silent substitution times out rather than confirms).
|
|
776
|
+
*
|
|
777
|
+
* @param {string} chain
|
|
778
|
+
* @param {string} signedTxHex - the raw signed tx we sent to /execute
|
|
779
|
+
* @param {string} broadcasterTxHash - the txHash /execute returned
|
|
780
|
+
* @param {string} [label] - describes the tx for a mismatch error, e.g.
|
|
781
|
+
* "allowance-revoke" — the least useful moment to lose context is a revoke
|
|
782
|
+
* mismatch with the allowance sitting at 0, so callers should pass it
|
|
783
|
+
* @returns {Promise<{receipt: object, hash: string}>} the receipt and the
|
|
784
|
+
* locally-derived hash it was confirmed against (log THIS, not the
|
|
785
|
+
* broadcaster's hash — it is the transaction we actually verified landed)
|
|
786
|
+
*/
|
|
787
|
+
export async function confirmEvmBroadcast(chain, signedTxHex, broadcasterTxHash, label = '') {
|
|
788
|
+
const hash = assertTxHashMatch(signedTxHex, broadcasterTxHash, label);
|
|
789
|
+
const receipt = await waitForReceipt(chain, hash);
|
|
790
|
+
return { receipt, hash };
|
|
662
791
|
}
|
|
663
792
|
|
|
664
793
|
/**
|
|
@@ -787,6 +916,43 @@ export async function verifySwapOutcome({ chain, from, quote, quoteData, apiKey
|
|
|
787
916
|
}
|
|
788
917
|
}
|
|
789
918
|
|
|
919
|
+
/**
|
|
920
|
+
* The Solana sibling of verifySwapOutcome: simulates the swap transaction via
|
|
921
|
+
* simulateTransaction and checks the resulting balance deltas against the
|
|
922
|
+
* persisted request intent, degrading (warn + proceed) on any RPC/sim outage
|
|
923
|
+
* so an outage never blocks a trade — only a real outcome mismatch or an
|
|
924
|
+
* in-simulation revert blocks (falls through to the next quote).
|
|
925
|
+
*/
|
|
926
|
+
export async function verifySolanaSwapOutcome({ chain, walletAddress, txBase64, quote, quoteData, log = () => {} }) {
|
|
927
|
+
if (chain !== 'solana') return { proceed: true };
|
|
928
|
+
// Cross-chain: the output settles on the destination chain and can never
|
|
929
|
+
// appear in a source-chain simulation (mirrors the EVM bridge skip above).
|
|
930
|
+
if (quoteData?.toChain && quoteData.toChain !== quoteData.chain) return { proceed: true };
|
|
931
|
+
if (!quoteData?.request) {
|
|
932
|
+
log(' ⚠ Swap-outcome verification skipped (no request intent — re-quote to enable it).');
|
|
933
|
+
return { proceed: true };
|
|
934
|
+
}
|
|
935
|
+
if (!hasSolanaSimulationRpc(chain)) {
|
|
936
|
+
log(` ⚠ Swap-outcome verification unavailable (no simulation endpoint for ${chain}); proceeding without it.`);
|
|
937
|
+
return { proceed: true };
|
|
938
|
+
}
|
|
939
|
+
try {
|
|
940
|
+
const sim = await simulateSolanaAssetChanges(chain, txBase64, { walletAddress });
|
|
941
|
+
const outcome = assertSolanaSwapOutcome(quoteData.request, quote, sim, { slippage: quoteData.slippage });
|
|
942
|
+
if (outcome.inputAssertionSkipped) {
|
|
943
|
+
log(' ℹ Native-SOL input spend is bounded with fee/rent slack, not exactly delta-verified; output and sibling checks still ran.');
|
|
944
|
+
}
|
|
945
|
+
log(` ✓ Swap outcome verified (via ${sim.method}).`);
|
|
946
|
+
return { proceed: true };
|
|
947
|
+
} catch (e) {
|
|
948
|
+
if (e instanceof SolanaSimulationError && ['NO_SIM_RPC', 'SIM_RPC_ERROR'].includes(e.code)) {
|
|
949
|
+
log(` ⚠ Swap-outcome verification could not run (${e.message}); proceeding without it.`);
|
|
950
|
+
return { proceed: true };
|
|
951
|
+
}
|
|
952
|
+
return { proceed: false, reason: e.message };
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
790
956
|
/**
|
|
791
957
|
* Estimate gas for an EVM transaction. Returns the gas estimate or null on failure.
|
|
792
958
|
* Used to fix under-gassed quotes from aggregators.
|
|
@@ -984,6 +1150,28 @@ export function assertCompleteEvmRequestIntent(request) {
|
|
|
984
1150
|
}
|
|
985
1151
|
}
|
|
986
1152
|
|
|
1153
|
+
/**
|
|
1154
|
+
* The Solana sibling of assertCompleteEvmRequestIntent. Solana signs the
|
|
1155
|
+
* aggregator's serialized VersionedTransaction verbatim — there is no
|
|
1156
|
+
* approval/calldata split to independently validate — so assertQuoteMatchesRequest
|
|
1157
|
+
* is the only guard between a compromised quote and a signed drain. That check's
|
|
1158
|
+
* per-field `if (request.x)` comparisons silently skip a missing field, so this
|
|
1159
|
+
* closes the gap by failing closed on any incomplete request intent up front.
|
|
1160
|
+
*/
|
|
1161
|
+
export function assertCompleteSolanaRequestIntent(request) {
|
|
1162
|
+
if (!request) {
|
|
1163
|
+
throw new Error('Quote is missing request intent. Re-quote with this CLI version before executing a Solana swap. Refusing to sign.');
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
const missing = [];
|
|
1167
|
+
for (const field of ['chain', 'walletAddress', 'fromToken', 'toToken', 'swapMode', 'amount', 'maxInputAmount']) {
|
|
1168
|
+
if (request[field] == null || request[field] === '') missing.push(field);
|
|
1169
|
+
}
|
|
1170
|
+
if (missing.length) {
|
|
1171
|
+
throw new Error(`Quote request intent is incomplete (${missing.join(', ')} missing). Re-quote before executing a Solana swap. Refusing to sign.`);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
987
1175
|
/**
|
|
988
1176
|
* Sanity-check the target of a swap transaction before signing it.
|
|
989
1177
|
*
|
|
@@ -1257,23 +1445,6 @@ function rlpNormalize(val) {
|
|
|
1257
1445
|
return toBuffer(val);
|
|
1258
1446
|
}
|
|
1259
1447
|
|
|
1260
|
-
// ============= Compact-u16 (Solana) =============
|
|
1261
|
-
|
|
1262
|
-
/**
|
|
1263
|
-
* Read a compact-u16 from a buffer (Solana transaction format).
|
|
1264
|
-
*/
|
|
1265
|
-
export function readCompactU16(buf, offset) {
|
|
1266
|
-
let value = 0;
|
|
1267
|
-
let size = 0;
|
|
1268
|
-
for (let i = 0; i < 3; i++) {
|
|
1269
|
-
const byte = buf[offset + i];
|
|
1270
|
-
value |= (byte & 0x7f) << (7 * i);
|
|
1271
|
-
size++;
|
|
1272
|
-
if ((byte & 0x80) === 0) break;
|
|
1273
|
-
}
|
|
1274
|
-
return { value, size };
|
|
1275
|
-
}
|
|
1276
|
-
|
|
1277
1448
|
// ============= Chain Utilities =============
|
|
1278
1449
|
|
|
1279
1450
|
/**
|
|
@@ -1593,9 +1764,9 @@ OPTIONS:
|
|
|
1593
1764
|
--swap-mode <mode> exactIn (default) or exactOut
|
|
1594
1765
|
--max-input <baseUnits> exactOut only: hard ceiling on the sell-token spend
|
|
1595
1766
|
(base units), measured against the slippage-buffered
|
|
1596
|
-
|
|
1597
|
-
Required for
|
|
1598
|
-
signing
|
|
1767
|
+
spend (input + slippage), not the bare quote input.
|
|
1768
|
+
Required for exactOut on every chain and enforced
|
|
1769
|
+
before signing.
|
|
1599
1770
|
--aggregator <name> Force a specific aggregator (lifi, relay, jupiter, okx).
|
|
1600
1771
|
Filters the quote list client-side; errors if none match.
|
|
1601
1772
|
|
|
@@ -1630,12 +1801,9 @@ CROSS-CHAIN NOTES (when using --to-chain):
|
|
|
1630
1801
|
throw new CommandError('Error: --amount-unit percent is not supported with --swap-mode exactOut. Percentage is relative to your sell-token balance.', 'INVALID_INPUT');
|
|
1631
1802
|
}
|
|
1632
1803
|
|
|
1633
|
-
//
|
|
1634
|
-
//
|
|
1635
|
-
//
|
|
1636
|
-
// Solana signs the API transaction verbatim (no approval to scope), so
|
|
1637
|
-
// requiring --max-input there would break existing Solana exactOut users
|
|
1638
|
-
// without buying any of that path a security guarantee. Gate on EVM source.
|
|
1804
|
+
// isEvmSource gates the ERC-20-approval-specific check just below (auto-slippage
|
|
1805
|
+
// sizing an approval has no Solana equivalent). The --max-input requirement
|
|
1806
|
+
// itself is NOT gated on it — see the check after maxInputOverride is parsed.
|
|
1639
1807
|
const isEvmSource = CHAIN_MAP[chain?.toLowerCase()]?.type === 'evm';
|
|
1640
1808
|
|
|
1641
1809
|
// exactOut scopes the ERC-20 approval to a slippage-buffered max input. With
|
|
@@ -1669,7 +1837,10 @@ CROSS-CHAIN NOTES (when using --to-chain):
|
|
|
1669
1837
|
throw new CommandError(`Error: invalid --max-input "${maxInputRaw}": must be an integer in base units of the sell token.`, 'INVALID_INPUT');
|
|
1670
1838
|
}
|
|
1671
1839
|
}
|
|
1672
|
-
|
|
1840
|
+
// Required on every chain: an exactOut cap derived from the API's own quote
|
|
1841
|
+
// response would just check that quote against itself and could never reject
|
|
1842
|
+
// anything (there is no independent signal to catch an inflated input).
|
|
1843
|
+
if (swapMode === 'exactOut' && maxInputOverride == null) {
|
|
1673
1844
|
throw new CommandError('Error: --swap-mode exactOut requires --max-input (base units of the sell token) so the input is independently capped before signing.', 'INVALID_INPUT');
|
|
1674
1845
|
}
|
|
1675
1846
|
|
|
@@ -1922,6 +2093,9 @@ CROSS-CHAIN NOTES (when using --to-chain):
|
|
|
1922
2093
|
}
|
|
1923
2094
|
|
|
1924
2095
|
const signerType = isWalletConnect ? 'walletconnect' : walletProvider;
|
|
2096
|
+
// exactOut has no request.amount input bound (amount is the OUTPUT), so
|
|
2097
|
+
// maxInputAmount is the only spend ceiling assertInputWithinMax can enforce.
|
|
2098
|
+
// Required explicitly via --max-input on every chain (checked above).
|
|
1925
2099
|
const maxInputAmount = swapMode === 'exactOut' ? maxInputOverride : String(resolvedAmount);
|
|
1926
2100
|
const quoteId = saveQuote(response, chain, signerType, privyWalletIds, isCrossChain ? toChainRaw : null, {
|
|
1927
2101
|
swapMode,
|
|
@@ -1994,7 +2168,7 @@ OPTIONS:
|
|
|
1994
2168
|
--quote <id> Quote ID from 'nansen quote'
|
|
1995
2169
|
--wallet <name> Wallet name (default: default wallet)
|
|
1996
2170
|
--no-simulate Skip pre-broadcast simulation (the eth_call revert check)
|
|
1997
|
-
--no-verify-outcome Skip
|
|
2171
|
+
--no-verify-outcome Skip swap-outcome verification (balance-delta check)
|
|
1998
2172
|
--no-revoke-excessive-allowance
|
|
1999
2173
|
Skip auto-revoking an oversized/legacy allowance before re-approving
|
|
2000
2174
|
--gasless Relay-only: have Relay's solver pay gas (no WalletConnect)
|
|
@@ -2141,9 +2315,45 @@ EXAMPLES:
|
|
|
2141
2315
|
if (typeof txBase64 === 'object' && txBase64.data) {
|
|
2142
2316
|
txBase64 = base58Decode(txBase64.data).toString('base64');
|
|
2143
2317
|
}
|
|
2144
|
-
log(' Signing Solana transaction via Privy...');
|
|
2145
2318
|
const solWalletId = quoteData.privyWalletIds?.solana;
|
|
2146
2319
|
if (!solWalletId) throw new Error('No Solana Privy wallet ID in quote');
|
|
2320
|
+
const walletResult = await privyClient.getWallet(solWalletId);
|
|
2321
|
+
const walletAddress = walletResult.address;
|
|
2322
|
+
// Fail closed if the signer address doesn't resolve: without it the
|
|
2323
|
+
// wallet-binding comparison below would silently skip, leaving the
|
|
2324
|
+
// quote unbound to the wallet that will sign it. This is resolved
|
|
2325
|
+
// independently of the persisted request so assertQuoteMatchesRequest
|
|
2326
|
+
// is a real check, not a comparison of the request against itself.
|
|
2327
|
+
if (!walletAddress) {
|
|
2328
|
+
throw new Error('Could not resolve the Solana Privy wallet address; cannot confirm the quote was built for this wallet. Refusing to sign.');
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
// Validate the persisted request/quote metadata (token pair, amounts,
|
|
2332
|
+
// signer) before signing the aggregator's serialized transaction.
|
|
2333
|
+
assertCompleteSolanaRequestIntent(quoteData.request);
|
|
2334
|
+
assertQuoteMatchesRequest(quoteData.request, currentQuote, { chain, walletAddress, slippage: quoteData.slippage });
|
|
2335
|
+
|
|
2336
|
+
// Then statically inspect the serialized transaction's own
|
|
2337
|
+
// instructions ahead of signing — catches a delegate grant, authority
|
|
2338
|
+
// change, close-to-stranger, or excessive fee that the metadata check
|
|
2339
|
+
// alone wouldn't see. The residual sibling-transfer gap is closed by
|
|
2340
|
+
// verifySolanaSwapOutcome below (degrades gracefully when no sim RPC
|
|
2341
|
+
// is available, so this static check remains a guard when sim is off).
|
|
2342
|
+
assertSolanaInstructionsSafe(txBase64, { walletAddress });
|
|
2343
|
+
|
|
2344
|
+
// Verify the swap's simulated on-chain outcome matches intent.
|
|
2345
|
+
// Degrades with a warning if no simulation endpoint is available.
|
|
2346
|
+
if (!noVerifyOutcome) {
|
|
2347
|
+
const outcome = await verifySolanaSwapOutcome({ chain, walletAddress, txBase64, quote: currentQuote, quoteData, log });
|
|
2348
|
+
if (!outcome.proceed) {
|
|
2349
|
+
log(` ❌ ${quoteName} failed swap-outcome verification: ${outcome.reason}`);
|
|
2350
|
+
if (qi + 1 < endIndex) log(' Trying next quote...');
|
|
2351
|
+
lastQuoteError = `${quoteName} outcome verification failed: ${outcome.reason}`;
|
|
2352
|
+
continue;
|
|
2353
|
+
}
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
log(' Signing Solana transaction via Privy...');
|
|
2147
2357
|
const signResult = await privyClient.signSolanaTransaction(solWalletId, txBase64);
|
|
2148
2358
|
signedTransaction = signResult.data?.signed_transaction || signResult.signed_transaction;
|
|
2149
2359
|
requestId = currentQuote.metadata?.requestId;
|
|
@@ -2254,9 +2464,10 @@ EXAMPLES:
|
|
|
2254
2464
|
}
|
|
2255
2465
|
log(` Waiting for allowance revoke confirmation...`);
|
|
2256
2466
|
try {
|
|
2257
|
-
const receipt = await
|
|
2258
|
-
log(` ✓ Allowance revoked in block ${parseInt(receipt.blockNumber, 16)}: ${
|
|
2467
|
+
const { receipt, hash: revokeHash } = await confirmEvmBroadcast(chain, signedRevoke, revokeResult.txHash, 'allowance-revoke');
|
|
2468
|
+
log(` ✓ Allowance revoked in block ${parseInt(receipt.blockNumber, 16)}: ${revokeHash}`);
|
|
2259
2469
|
} catch (receiptErr) {
|
|
2470
|
+
if (isFatalBroadcastError(receiptErr)) throw receiptErr;
|
|
2260
2471
|
log(` ❌ Allowance revoke may not have confirmed for ${quoteName}: ${receiptErr.message}.${allowanceRevokeRecoveryHint(revokeResult.txHash)}`);
|
|
2261
2472
|
if (qi + 1 < endIndex) log(` Trying next quote...`);
|
|
2262
2473
|
lastQuoteError = `${quoteName} allowance revoke unconfirmed`;
|
|
@@ -2312,9 +2523,10 @@ EXAMPLES:
|
|
|
2312
2523
|
}
|
|
2313
2524
|
log(` Waiting for approval confirmation...`);
|
|
2314
2525
|
try {
|
|
2315
|
-
const receipt = await
|
|
2316
|
-
log(` ✓ Approval confirmed in block ${parseInt(receipt.blockNumber, 16)}: ${
|
|
2526
|
+
const { receipt, hash: approvalHash } = await confirmEvmBroadcast(chain, signedApproval, approvalResult.txHash, 'allowance-approval');
|
|
2527
|
+
log(` ✓ Approval confirmed in block ${parseInt(receipt.blockNumber, 16)}: ${approvalHash}`);
|
|
2317
2528
|
} catch (receiptErr) {
|
|
2529
|
+
if (isFatalBroadcastError(receiptErr)) throw receiptErr;
|
|
2318
2530
|
log(` ❌ Approval may not have confirmed${shouldRevoke ? ' after revoking the prior allowance (now 0)' : ''}: ${receiptErr.message}`);
|
|
2319
2531
|
if (qi + 1 < endIndex) log(` Trying next quote...`);
|
|
2320
2532
|
lastQuoteError = `${quoteName} approval unconfirmed`;
|
|
@@ -2408,12 +2620,12 @@ EXAMPLES:
|
|
|
2408
2620
|
signedTransaction = signResult.data?.signed_transaction || signResult.signed_transaction;
|
|
2409
2621
|
|
|
2410
2622
|
} else if (chainType === 'solana') {
|
|
2411
|
-
// NB: validateSwapTarget (the EVM `to`/`data` guard)
|
|
2412
|
-
//
|
|
2413
|
-
//
|
|
2414
|
-
//
|
|
2415
|
-
//
|
|
2416
|
-
//
|
|
2623
|
+
// NB: validateSwapTarget (the EVM `to`/`data` guard) does not apply
|
|
2624
|
+
// here — Solana quotes are a pre-built serialized VersionedTransaction
|
|
2625
|
+
// with no `to`/`data`/approval split to validate. assertQuoteMatchesRequest
|
|
2626
|
+
// below binds the metadata (token pair, amounts, signer), and
|
|
2627
|
+
// assertSolanaInstructionsSafe statically inspects the tx's own
|
|
2628
|
+
// instructions before signing.
|
|
2417
2629
|
// Solana: transaction is either a base64 string (Jupiter) or an object
|
|
2418
2630
|
// with a base58-encoded `data` field (OKX). Normalize to base64.
|
|
2419
2631
|
let txBase64 = currentQuote.transaction;
|
|
@@ -2421,6 +2633,49 @@ EXAMPLES:
|
|
|
2421
2633
|
txBase64 = base58Decode(txBase64.data).toString('base64');
|
|
2422
2634
|
}
|
|
2423
2635
|
|
|
2636
|
+
// Resolve the signer for this sub-path so the intent-binding check
|
|
2637
|
+
// below can confirm the quote was built for this exact wallet.
|
|
2638
|
+
let solanaWalletAddress;
|
|
2639
|
+
if (isWalletConnect) {
|
|
2640
|
+
solanaWalletAddress = await getWalletConnectAddress(chainType);
|
|
2641
|
+
if (!solanaWalletAddress) {
|
|
2642
|
+
throw new CommandError('WalletConnect session lost during execute. Reconnect with `walletconnect connect` and retry.', 'NO_WALLET');
|
|
2643
|
+
}
|
|
2644
|
+
} else {
|
|
2645
|
+
solanaWalletAddress = exported.solana.address;
|
|
2646
|
+
// Fail closed if the signer address doesn't resolve: without it the
|
|
2647
|
+
// wallet-binding comparison below would silently skip, leaving the
|
|
2648
|
+
// quote unbound to the wallet that will sign it.
|
|
2649
|
+
if (!solanaWalletAddress) {
|
|
2650
|
+
throw new Error("Could not resolve the local wallet's Solana address; cannot confirm the quote was built for this wallet. Refusing to sign.");
|
|
2651
|
+
}
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
// Validate the persisted request/quote metadata (token pair, amounts,
|
|
2655
|
+
// signer) before signing the opaque Solana transaction.
|
|
2656
|
+
assertCompleteSolanaRequestIntent(quoteData.request);
|
|
2657
|
+
assertQuoteMatchesRequest(quoteData.request, currentQuote, { chain, walletAddress: solanaWalletAddress, slippage: quoteData.slippage });
|
|
2658
|
+
|
|
2659
|
+
// Then statically inspect the serialized transaction's own
|
|
2660
|
+
// instructions ahead of signing — catches a delegate grant, authority
|
|
2661
|
+
// change, close-to-stranger, or excessive fee that the metadata check
|
|
2662
|
+
// alone wouldn't see. The residual sibling-transfer gap is closed by
|
|
2663
|
+
// verifySolanaSwapOutcome below (degrades gracefully when no sim RPC
|
|
2664
|
+
// is available, so this static check remains a guard when sim is off).
|
|
2665
|
+
assertSolanaInstructionsSafe(txBase64, { walletAddress: solanaWalletAddress });
|
|
2666
|
+
|
|
2667
|
+
// Verify the swap's simulated on-chain outcome matches intent.
|
|
2668
|
+
// Degrades with a warning if no simulation endpoint is available.
|
|
2669
|
+
if (!noVerifyOutcome) {
|
|
2670
|
+
const outcome = await verifySolanaSwapOutcome({ chain, walletAddress: solanaWalletAddress, txBase64, quote: currentQuote, quoteData, log });
|
|
2671
|
+
if (!outcome.proceed) {
|
|
2672
|
+
log(` ❌ ${quoteName} failed swap-outcome verification: ${outcome.reason}`);
|
|
2673
|
+
if (qi + 1 < endIndex) log(' Trying next quote...');
|
|
2674
|
+
lastQuoteError = `${quoteName} outcome verification failed: ${outcome.reason}`;
|
|
2675
|
+
continue;
|
|
2676
|
+
}
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2424
2679
|
if (isWalletConnect) {
|
|
2425
2680
|
// Solana via WalletConnect: convert base64 → base58 for WC protocol
|
|
2426
2681
|
log(' Signing Solana transaction via WalletConnect...');
|
|
@@ -2567,12 +2822,13 @@ EXAMPLES:
|
|
|
2567
2822
|
if (broadcastResult.status !== 'Success') {
|
|
2568
2823
|
throw new Error(broadcastResult.error || 'broadcast failed');
|
|
2569
2824
|
}
|
|
2570
|
-
revokeTxHash = broadcastResult.txHash;
|
|
2825
|
+
revokeTxHash = assertTxHashMatch(revokeResult.signedTransaction, broadcastResult.txHash, 'allowance-revoke');
|
|
2571
2826
|
}
|
|
2572
2827
|
if (!revokeTxHash) {
|
|
2573
2828
|
throw new Error('Allowance revoke returned no transaction hash and no signed transaction; cannot confirm allowance was cleared');
|
|
2574
2829
|
}
|
|
2575
2830
|
} catch (revokeErr) {
|
|
2831
|
+
if (isFatalBroadcastError(revokeErr)) throw revokeErr;
|
|
2576
2832
|
log(` ❌ Allowance revoke failed for ${quoteName}: ${revokeErr.message}.${allowanceRevokeRecoveryHint(revokeTxHash)}`);
|
|
2577
2833
|
if (qi + 1 < endIndex) log(` Trying next quote...`);
|
|
2578
2834
|
lastQuoteError = `${quoteName} allowance revoke failed`;
|
|
@@ -2583,6 +2839,7 @@ EXAMPLES:
|
|
|
2583
2839
|
const receipt = await waitForReceipt(chain, revokeTxHash);
|
|
2584
2840
|
log(` ✓ Allowance revoked in block ${parseInt(receipt.blockNumber, 16)}: ${revokeTxHash}`);
|
|
2585
2841
|
} catch (receiptErr) {
|
|
2842
|
+
if (isFatalBroadcastError(receiptErr)) throw receiptErr;
|
|
2586
2843
|
log(` ❌ Allowance revoke may not have confirmed for ${quoteName}: ${receiptErr.message}.${allowanceRevokeRecoveryHint(revokeTxHash)}`);
|
|
2587
2844
|
if (qi + 1 < endIndex) log(` Trying next quote...`);
|
|
2588
2845
|
lastQuoteError = `${quoteName} allowance revoke unconfirmed`;
|
|
@@ -2621,7 +2878,7 @@ EXAMPLES:
|
|
|
2621
2878
|
if (broadcastResult.status !== 'Success') {
|
|
2622
2879
|
throw new Error(broadcastResult.error || 'broadcast failed');
|
|
2623
2880
|
}
|
|
2624
|
-
approvalTxHash = broadcastResult.txHash;
|
|
2881
|
+
approvalTxHash = assertTxHashMatch(approvalResult.signedTransaction, broadcastResult.txHash, 'allowance-approval');
|
|
2625
2882
|
}
|
|
2626
2883
|
if (!approvalTxHash) {
|
|
2627
2884
|
// Fail closed: the wallet returned neither a hash nor a
|
|
@@ -2632,6 +2889,7 @@ EXAMPLES:
|
|
|
2632
2889
|
throw new Error('returned no transaction hash and no signed transaction; cannot confirm approval landed');
|
|
2633
2890
|
}
|
|
2634
2891
|
} catch (approvalErr) {
|
|
2892
|
+
if (isFatalBroadcastError(approvalErr)) throw approvalErr;
|
|
2635
2893
|
const revokedMsg = shouldRevoke
|
|
2636
2894
|
? ' after revoking the prior allowance (now 0)'
|
|
2637
2895
|
: '';
|
|
@@ -2645,6 +2903,7 @@ EXAMPLES:
|
|
|
2645
2903
|
const receipt = await waitForReceipt(chain, approvalTxHash);
|
|
2646
2904
|
log(` ✓ Approval confirmed in block ${parseInt(receipt.blockNumber, 16)}: ${approvalTxHash}`);
|
|
2647
2905
|
} catch (receiptErr) {
|
|
2906
|
+
if (isFatalBroadcastError(receiptErr)) throw receiptErr;
|
|
2648
2907
|
const revokedMsg = shouldRevoke
|
|
2649
2908
|
? ' after revoking the prior allowance (now 0)'
|
|
2650
2909
|
: '';
|
|
@@ -2730,6 +2989,14 @@ EXAMPLES:
|
|
|
2730
2989
|
try {
|
|
2731
2990
|
await waitForReceipt(chain, wcResult.txHash);
|
|
2732
2991
|
} catch (receiptErr) {
|
|
2992
|
+
// A timeout here is uncertain post-broadcast state, not a
|
|
2993
|
+
// confirmed revert — fail closed rather than retry (which would
|
|
2994
|
+
// broadcast a second swap). Applies even though this path has no
|
|
2995
|
+
// locally-derived hash to bind to.
|
|
2996
|
+
if (receiptErr.code === 'RECEIPT_TIMEOUT') {
|
|
2997
|
+
throw new CommandError(`\n ⚠ Transaction was broadcast but NOT confirmed within the wait window.\n Tx Hash: ${wcResult.txHash}\n Explorer: ${chainConfig.explorer}${wcResult.txHash}\n ${receiptErr.message}\n\n The transaction may still be pending — do NOT assume it failed. Check the\n explorer before retrying; retrying may broadcast a second swap.`, 'RECEIPT_TIMEOUT');
|
|
2998
|
+
}
|
|
2999
|
+
if (isFatalBroadcastError(receiptErr)) throw receiptErr;
|
|
2733
3000
|
log(`\n ⚠ Transaction was broadcast but REVERTED on-chain!`);
|
|
2734
3001
|
log(` Tx Hash: ${wcResult.txHash}`);
|
|
2735
3002
|
log(` Explorer: ${chainConfig.explorer}${wcResult.txHash}`);
|
|
@@ -2892,9 +3159,10 @@ EXAMPLES:
|
|
|
2892
3159
|
|
|
2893
3160
|
log(` Waiting for allowance revoke confirmation...`);
|
|
2894
3161
|
try {
|
|
2895
|
-
const receipt = await
|
|
2896
|
-
log(` ✓ Allowance revoked in block ${parseInt(receipt.blockNumber, 16)}: ${
|
|
3162
|
+
const { receipt, hash: revokeHash } = await confirmEvmBroadcast(chain, revokeTxHex, revokeResult.txHash, 'allowance-revoke');
|
|
3163
|
+
log(` ✓ Allowance revoked in block ${parseInt(receipt.blockNumber, 16)}: ${revokeHash}`);
|
|
2897
3164
|
} catch (receiptErr) {
|
|
3165
|
+
if (isFatalBroadcastError(receiptErr)) throw receiptErr;
|
|
2898
3166
|
log(` ❌ Allowance revoke may not have confirmed for ${quoteName}: ${receiptErr.message}.${allowanceRevokeRecoveryHint(revokeResult.txHash)}`);
|
|
2899
3167
|
if (qi + 1 < endIndex) log(` Trying next quote...`);
|
|
2900
3168
|
lastQuoteError = `${quoteName} allowance revoke unconfirmed`;
|
|
@@ -2943,9 +3211,10 @@ EXAMPLES:
|
|
|
2943
3211
|
|
|
2944
3212
|
log(` Waiting for approval confirmation...`);
|
|
2945
3213
|
try {
|
|
2946
|
-
const receipt = await
|
|
2947
|
-
log(` ✓ Approval confirmed in block ${parseInt(receipt.blockNumber, 16)}: ${
|
|
3214
|
+
const { receipt, hash: approvalHash } = await confirmEvmBroadcast(chain, approvalTxHex, approvalResult.txHash, 'allowance-approval');
|
|
3215
|
+
log(` ✓ Approval confirmed in block ${parseInt(receipt.blockNumber, 16)}: ${approvalHash}`);
|
|
2948
3216
|
} catch (receiptErr) {
|
|
3217
|
+
if (isFatalBroadcastError(receiptErr)) throw receiptErr;
|
|
2949
3218
|
log(` ❌ Approval may not have confirmed${shouldRevoke ? ' after revoking the prior allowance (now 0)' : ''}: ${receiptErr.message}`);
|
|
2950
3219
|
if (qi + 1 < endIndex) log(` Trying next quote...`);
|
|
2951
3220
|
lastQuoteError = `${quoteName} approval unconfirmed`;
|
|
@@ -3065,17 +3334,51 @@ EXAMPLES:
|
|
|
3065
3334
|
const result = await executeTransaction(execParams);
|
|
3066
3335
|
|
|
3067
3336
|
if (result.status === 'Success') {
|
|
3068
|
-
|
|
3069
|
-
|
|
3337
|
+
let txId = result.signature || result.txHash;
|
|
3338
|
+
let explorerUrl = chainConfig.explorer + txId;
|
|
3070
3339
|
|
|
3071
3340
|
// For EVM: verify the tx actually succeeded on-chain
|
|
3072
|
-
if (chainType === 'evm'
|
|
3341
|
+
if (chainType === 'evm') {
|
|
3073
3342
|
log(' Verifying on-chain status...');
|
|
3343
|
+
// Non-gasless: derive our local hash up front, OUTSIDE the receipt-poll
|
|
3344
|
+
// try below. A hex-validation failure here means no poll ever ran, so it
|
|
3345
|
+
// must surface as itself — not as the "REVERTED on-chain" diagnostic that
|
|
3346
|
+
// catch is reserved for. (Gasless has no local hash to bind to: the Relay
|
|
3347
|
+
// solver wraps and broadcasts its own tx, so result.txHash legitimately is
|
|
3348
|
+
// not the hash of the bytes we signed.)
|
|
3349
|
+
if (!gasless) {
|
|
3350
|
+
try {
|
|
3351
|
+
txId = evmTxHash(signedTransaction);
|
|
3352
|
+
} catch (hashErr) {
|
|
3353
|
+
throw new CommandError(`Cannot derive local tx hash for ${quoteName}: ${hashErr.message}`, 'INVALID_SIGNED_TX');
|
|
3354
|
+
}
|
|
3355
|
+
explorerUrl = chainConfig.explorer + txId;
|
|
3356
|
+
}
|
|
3074
3357
|
try {
|
|
3075
|
-
|
|
3358
|
+
if (gasless) {
|
|
3359
|
+
// If the solver reported no hash there is nothing to poll — skip
|
|
3360
|
+
// rather than block on eth_getTransactionReceipt(undefined).
|
|
3361
|
+
if (result.txHash) await waitForReceipt(chain, result.txHash);
|
|
3362
|
+
} else {
|
|
3363
|
+
const { hash } = await confirmEvmBroadcast(chain, signedTransaction, result.txHash);
|
|
3364
|
+
txId = hash;
|
|
3365
|
+
explorerUrl = chainConfig.explorer + txId;
|
|
3366
|
+
}
|
|
3076
3367
|
} catch (receiptErr) {
|
|
3368
|
+
// A receipt TIMEOUT is not a confirmed revert: the tx was
|
|
3369
|
+
// broadcast and may still be pending under our nonce. Retrying
|
|
3370
|
+
// the next quote would sign and broadcast a SECOND swap racing
|
|
3371
|
+
// the first for that nonce — the duplicate-broadcast this PR
|
|
3372
|
+
// exists to prevent. (It's also exactly how guarantee #2's
|
|
3373
|
+
// silent-substitution case surfaces: a 180s timeout polling our
|
|
3374
|
+
// own hash.) Fail closed with a clearer banner than the generic
|
|
3375
|
+
// rethrow, then let isFatalBroadcastError handle the rest.
|
|
3376
|
+
if (receiptErr.code === 'RECEIPT_TIMEOUT') {
|
|
3377
|
+
throw new CommandError(`\n ⚠ Transaction was broadcast but NOT confirmed within the wait window.\n Tx Hash: ${txId || result.txHash}\n Explorer: ${explorerUrl}\n ${receiptErr.message}\n\n The transaction may still be pending — do NOT assume it failed. Check the\n explorer before retrying; retrying may broadcast a second swap against the\n same nonce.`, 'RECEIPT_TIMEOUT');
|
|
3378
|
+
}
|
|
3379
|
+
if (isFatalBroadcastError(receiptErr)) throw receiptErr;
|
|
3077
3380
|
log(`\n ⚠ Transaction was broadcast but REVERTED on-chain!`);
|
|
3078
|
-
log(` Tx Hash: ${result.txHash}`);
|
|
3381
|
+
log(` Tx Hash: ${txId || result.txHash}`);
|
|
3079
3382
|
log(` Explorer: ${explorerUrl}`);
|
|
3080
3383
|
log(` Error: ${receiptErr.message}`);
|
|
3081
3384
|
if (qi + 1 < endIndex) {
|
|
@@ -3083,7 +3386,7 @@ EXAMPLES:
|
|
|
3083
3386
|
lastQuoteError = `${quoteName} reverted on-chain`;
|
|
3084
3387
|
continue;
|
|
3085
3388
|
}
|
|
3086
|
-
throw new CommandError(`\n ⚠ Transaction was broadcast but REVERTED on-chain!\n Tx Hash: ${result.txHash}\n Explorer: ${explorerUrl}\n Error: ${receiptErr.message}\n\n The trading API reported success, but the contract execution failed.\n This can happen due to: stale quotes, insufficient gas, or liquidity changes.`, 'TX_REVERTED');
|
|
3389
|
+
throw new CommandError(`\n ⚠ Transaction was broadcast but REVERTED on-chain!\n Tx Hash: ${txId || result.txHash}\n Explorer: ${explorerUrl}\n Error: ${receiptErr.message}\n\n The trading API reported success, but the contract execution failed.\n This can happen due to: stale quotes, insufficient gas, or liquidity changes.`, 'TX_REVERTED');
|
|
3087
3390
|
}
|
|
3088
3391
|
}
|
|
3089
3392
|
|
|
@@ -3141,6 +3444,11 @@ EXAMPLES:
|
|
|
3141
3444
|
}
|
|
3142
3445
|
|
|
3143
3446
|
} catch (quoteErr) {
|
|
3447
|
+
// Post-broadcast failures abort the whole execute — never retry the
|
|
3448
|
+
// next quote once a transaction is already out and its outcome is
|
|
3449
|
+
// unknown (mismatch, underivable local hash, or an unconfirmed
|
|
3450
|
+
// receipt timeout). See isFatalBroadcastError.
|
|
3451
|
+
if (isFatalBroadcastError(quoteErr)) throw quoteErr;
|
|
3144
3452
|
const msg = quoteErr.message || '';
|
|
3145
3453
|
log(` ❌ Quote ${quoteName} failed: ${msg}`);
|
|
3146
3454
|
if (msg.includes('AccountNotFound') && chainType === 'solana') {
|