nansen-cli 1.40.1 → 1.41.1
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 +36 -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 +3 -2
- package/src/solana-simulation.js +345 -0
- package/src/solana-tx.js +153 -0
- package/src/trade-validation.js +538 -17
- package/src/trading.js +521 -74
- package/src/x402-svm.js +9 -5
package/src/trading.js
CHANGED
|
@@ -9,19 +9,25 @@ import crypto from 'crypto';
|
|
|
9
9
|
import fs from 'fs';
|
|
10
10
|
import path from 'path';
|
|
11
11
|
import { base58Encode, exportWallet, getWalletConfig, showWallet, listWallets } from './wallet.js';
|
|
12
|
-
import { base58Decode } from './transfer.js';
|
|
12
|
+
import { base58Decode, encodeCompactU16 } from './transfer.js';
|
|
13
|
+
import { buildMessageV0, fetchRecentBlockhash } from './x402-svm.js';
|
|
13
14
|
import { keccak256, signSecp256k1, rlpEncode } from './crypto.js';
|
|
14
15
|
import { getWalletConnectAddress, sendTransactionViaWalletConnect, sendSolanaTransactionViaWalletConnect, sendApprovalViaWalletConnect } from './walletconnect-trading.js';
|
|
15
16
|
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';
|
|
17
|
+
import { validateQuoteInput, validateBalance, resolvePercentAmount, validateGasBalance, encodeApproveCalldata, assertValidApprovalSpender, assertQuoteMatchesRequest, assertSwapCalldataNotBareTransfer, assertSwapOutcome, assertSolanaInstructionsSafe, assertSolanaSwapOutcome, approvalAmountForSwap, needsAllowanceRevoke, OVERSIZED_ALLOWANCE_MULTIPLIER } from './trade-validation.js';
|
|
18
|
+
import { readCompactU16 } from './solana-tx.js';
|
|
19
|
+
export { readCompactU16 };
|
|
17
20
|
import { CHAIN_RPCS } from './rpc-urls.js';
|
|
18
21
|
import { simulateAssetChanges, SwapSimulationError, hasSimulationRpc } from './swap-simulation.js';
|
|
22
|
+
import { simulateSolanaAssetChanges, SolanaSimulationError, hasSolanaSimulationRpc } from './solana-simulation.js';
|
|
19
23
|
import { packageVersion, CommandError, telemetryHeaders, loadConfig } from './api.js';
|
|
20
24
|
|
|
21
25
|
// ============= Constants =============
|
|
22
26
|
|
|
23
27
|
const TRADING_API_URL = process.env.NANSEN_TRADING_API_URL || 'https://trading-api.nansen.ai';
|
|
24
28
|
const CLIENT_USER_AGENT = `nansen-cli/${packageVersion}`;
|
|
29
|
+
// Solana's max transaction wire size (IPv6 MTU minus headers).
|
|
30
|
+
const SOLANA_MAX_TX_SIZE = 1232;
|
|
25
31
|
|
|
26
32
|
const CHAIN_MAP = {
|
|
27
33
|
solana: { index: '501', type: 'solana', chainId: 501, name: 'Solana', explorer: 'https://solscan.io/tx/', lifiChainId: '1151111081099710' },
|
|
@@ -109,7 +115,8 @@ export function getQuotesDir() {
|
|
|
109
115
|
export function safeQuotesPath(filename) {
|
|
110
116
|
const base = path.resolve(getQuotesDir());
|
|
111
117
|
const target = path.resolve(base, filename);
|
|
112
|
-
|
|
118
|
+
const relative = path.relative(base, target);
|
|
119
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) return null;
|
|
113
120
|
return target;
|
|
114
121
|
}
|
|
115
122
|
|
|
@@ -505,6 +512,107 @@ export function signSolanaTransaction(transactionBase64, privateKeyHex) {
|
|
|
505
512
|
return signedTx.toString('base64');
|
|
506
513
|
}
|
|
507
514
|
|
|
515
|
+
// Any valid base58 32-byte value works here — recentBlockhash is fixed-size
|
|
516
|
+
// regardless of its actual value, so this is exact for a size-only preflight
|
|
517
|
+
// and lets the signer/signature-count checks below run before the real
|
|
518
|
+
// blockhash fetch (no wasted RPC round trip on a request we're going to reject).
|
|
519
|
+
const SIZE_CHECK_BLOCKHASH = '11111111111111111111111111111111';
|
|
520
|
+
|
|
521
|
+
function decodeInstructionData(hex) {
|
|
522
|
+
if (hex == null || hex === '') return Buffer.alloc(0); // some instructions legitimately carry no data
|
|
523
|
+
const body = hex.startsWith('0x') ? hex.slice(2) : hex;
|
|
524
|
+
// Buffer.from(str, 'hex') silently drops a trailing odd nibble and stops at
|
|
525
|
+
// the first non-hex character, so it would decode malformed data into a
|
|
526
|
+
// plausible-but-wrong instruction that then gets signed. Reject instead.
|
|
527
|
+
if (body.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(body)) {
|
|
528
|
+
throw new Error(`Cannot compile Solana transaction: instruction data is not valid hex ("${hex}")`);
|
|
529
|
+
}
|
|
530
|
+
return Buffer.from(body, 'hex');
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Compile a raw, uncompiled Solana transaction — {instructions, addressLookupTableAddresses}
|
|
535
|
+
* — into a signable base64 VersionedTransaction. Some aggregators (Relay's Solana-source
|
|
536
|
+
* bridge quotes) return this shape instead of a ready-to-sign serialized transaction.
|
|
537
|
+
*
|
|
538
|
+
* Every account is kept static; the address-lookup-table hint is a size optimization,
|
|
539
|
+
* not a correctness requirement, so skipping it is valid as long as the compiled
|
|
540
|
+
* transaction still fits Solana's packet limit. Full lookup-table compilation is
|
|
541
|
+
* unimplemented — throws instead of silently building an oversized/invalid transaction.
|
|
542
|
+
*
|
|
543
|
+
* getExpectedSigner is an async thunk resolving to the address of the wallet that is
|
|
544
|
+
* about to sign. The transaction only ever gets a single signature written into slot 0
|
|
545
|
+
* (see signSolanaTransaction / the WalletConnect injection path), so the instructions'
|
|
546
|
+
* own declared signer must both be unambiguous (exactly one signer) and match that
|
|
547
|
+
* wallet — otherwise the transaction would silently sign the wrong account or leave a
|
|
548
|
+
* required signature slot empty, failing on-chain with an opaque error.
|
|
549
|
+
*/
|
|
550
|
+
export async function compileRawSolanaTransaction(transaction, rpcUrl, getExpectedSigner) {
|
|
551
|
+
const instructions = transaction.instructions.map(ix => {
|
|
552
|
+
if (!Array.isArray(ix.keys)) {
|
|
553
|
+
throw new Error('Cannot compile Solana transaction: instruction is missing its "keys" accounts list');
|
|
554
|
+
}
|
|
555
|
+
return { programId: ix.programId, accounts: ix.keys, data: decodeInstructionData(ix.data) };
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
const feePayer = instructions.flatMap(ix => ix.accounts).find(a => a.isSigner)?.pubkey;
|
|
559
|
+
if (!feePayer) {
|
|
560
|
+
throw new Error('Cannot compile Solana transaction: no signer account found in instructions');
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const expectedSigner = await getExpectedSigner();
|
|
564
|
+
if (!expectedSigner) {
|
|
565
|
+
throw new Error('Cannot compile Solana transaction: wallet address unavailable to verify the signer');
|
|
566
|
+
}
|
|
567
|
+
if (feePayer !== expectedSigner) {
|
|
568
|
+
throw new Error(
|
|
569
|
+
`Solana transaction signer (${feePayer}) doesn't match the wallet executing this trade ` +
|
|
570
|
+
`(${expectedSigner}). Refusing to sign — get a new quote.`
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const preflight = buildMessageV0({ feePayer, instructions, recentBlockhash: SIZE_CHECK_BLOCKHASH });
|
|
575
|
+
if (preflight.numRequiredSignatures !== 1) {
|
|
576
|
+
throw new Error(
|
|
577
|
+
`Cannot compile Solana transaction: requires ${preflight.numRequiredSignatures} signatures, ` +
|
|
578
|
+
`but only the wallet's own signature can be provided.`
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
const unsignedSize = 1 + 64 + preflight.messageBytes.length; // compact-u16(1) + 1 signature slot
|
|
582
|
+
if (unsignedSize > SOLANA_MAX_TX_SIZE) {
|
|
583
|
+
throw new Error(
|
|
584
|
+
`Solana transaction too large to compile without address-lookup-table support ` +
|
|
585
|
+
`(${unsignedSize} bytes > ${SOLANA_MAX_TX_SIZE} limit). This route needs its ` +
|
|
586
|
+
`address lookup tables resolved, which isn't supported yet.`
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const recentBlockhash = await fetchRecentBlockhash(rpcUrl);
|
|
591
|
+
const { messageBytes } = buildMessageV0({ feePayer, instructions, recentBlockhash });
|
|
592
|
+
const unsignedTx = Buffer.concat([encodeCompactU16(1), Buffer.alloc(64), messageBytes]);
|
|
593
|
+
return unsignedTx.toString('base64');
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Normalize a Solana quote's `transaction` field to a base64-encoded, ready-to-sign
|
|
598
|
+
* VersionedTransaction. Three shapes seen across aggregators: Jupiter (already base64),
|
|
599
|
+
* OKX ({data: base58}), and Relay bridge quotes (raw uncompiled
|
|
600
|
+
* {instructions, addressLookupTableAddresses} — compiled client-side).
|
|
601
|
+
*
|
|
602
|
+
* getExpectedSigner (only consulted for the Relay shape) is an async thunk resolving to
|
|
603
|
+
* the signing wallet's address — see compileRawSolanaTransaction.
|
|
604
|
+
*/
|
|
605
|
+
export async function normalizeSolanaTransaction(transaction, rpcUrl, getExpectedSigner) {
|
|
606
|
+
if (typeof transaction === 'string') return transaction; // Jupiter: already base64
|
|
607
|
+
// Dispatch most-specific shape first. Only Relay carries `instructions` and
|
|
608
|
+
// only OKX carries `data`; checking `instructions` ahead of the bare
|
|
609
|
+
// `data` truthiness test keeps a future Relay shape that also had a `data`
|
|
610
|
+
// field from being mis-routed into the OKX base58 decode.
|
|
611
|
+
if (Array.isArray(transaction.instructions)) return compileRawSolanaTransaction(transaction, rpcUrl, getExpectedSigner);
|
|
612
|
+
if (transaction.data) return base58Decode(transaction.data).toString('base64'); // OKX: base58 serialized tx
|
|
613
|
+
throw new Error('Unrecognized Solana transaction format in quote');
|
|
614
|
+
}
|
|
615
|
+
|
|
508
616
|
/**
|
|
509
617
|
* Sign an EVM transaction from quote data.
|
|
510
618
|
*
|
|
@@ -565,6 +673,30 @@ export function signEvmTransaction(txData, privateKeyHex, chain, nonce) {
|
|
|
565
673
|
return signLegacyTransaction({ ...common, gasPrice: toHex(txData.gasPrice) }, privateKeyHex);
|
|
566
674
|
}
|
|
567
675
|
|
|
676
|
+
/**
|
|
677
|
+
* Canonical EVM transaction hash: keccak256 over the raw signed tx bytes.
|
|
678
|
+
*
|
|
679
|
+
* Works for legacy (RLP) and typed (0x02-prefixed EIP-1559) transactions alike,
|
|
680
|
+
* because the tx hash is defined over exactly the bytes that get broadcast.
|
|
681
|
+
*
|
|
682
|
+
* NB: this is NOT the signing hash. signEvmTransaction/signLegacyTransaction hash
|
|
683
|
+
* the *unsigned* payload to produce the message that gets signed; this hashes the
|
|
684
|
+
* fully *signed* transaction to produce its on-chain identifier.
|
|
685
|
+
*
|
|
686
|
+
* @param {string} signedTxHex - 0x-prefixed (or bare) hex of the signed transaction
|
|
687
|
+
* @returns {string} 0x-prefixed transaction hash
|
|
688
|
+
*/
|
|
689
|
+
export function evmTxHash(signedTxHex) {
|
|
690
|
+
if (typeof signedTxHex !== 'string') {
|
|
691
|
+
throw new Error('evmTxHash: signed transaction must be a hex string');
|
|
692
|
+
}
|
|
693
|
+
const hex = signedTxHex.startsWith('0x') ? signedTxHex.slice(2) : signedTxHex;
|
|
694
|
+
if (hex.length === 0 || hex.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(hex)) {
|
|
695
|
+
throw new Error('evmTxHash: signed transaction is not valid hex');
|
|
696
|
+
}
|
|
697
|
+
return '0x' + keccak256(Buffer.from(hex, 'hex')).toString('hex');
|
|
698
|
+
}
|
|
699
|
+
|
|
568
700
|
// How many queued-but-unmined transactions we are willing to sign past.
|
|
569
701
|
//
|
|
570
702
|
// `pending` counts mempool-queued transactions as well as mined ones, and that is
|
|
@@ -658,7 +790,108 @@ export async function waitForReceipt(chain, txHash, timeoutMs = 180000, pollMs =
|
|
|
658
790
|
// Receipt not yet available — wait and retry
|
|
659
791
|
await new Promise(r => setTimeout(r, pollMs));
|
|
660
792
|
}
|
|
661
|
-
|
|
793
|
+
// A timeout is NOT a confirmed revert: the tx may still be pending under our
|
|
794
|
+
// nonce. Tag it so callers can distinguish "reverted" (safe to try the next
|
|
795
|
+
// quote) from "unconfirmed" (retrying may broadcast a second tx that races
|
|
796
|
+
// the first for the same nonce). See the swap-path receipt catch.
|
|
797
|
+
const timeoutErr = new Error(`Transaction receipt not found after ${timeoutMs}ms. Tx: ${txHash}`);
|
|
798
|
+
timeoutErr.code = 'RECEIPT_TIMEOUT';
|
|
799
|
+
throw timeoutErr;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Post-broadcast failures that must abort the whole `execute` rather than fall
|
|
804
|
+
* through to the next quote. Once a transaction is broadcast we hold no evidence
|
|
805
|
+
* about what landed on-chain, so "try the next quote" would sign and broadcast a
|
|
806
|
+
* second transaction — the one thing we must not do. Covers every path (swap,
|
|
807
|
+
* approval, revoke; Privy/WalletConnect/local-key). Each code is thrown with a
|
|
808
|
+
* rationale at its throw site:
|
|
809
|
+
* - TXHASH_MISMATCH — broadcaster reported a tx we did not sign
|
|
810
|
+
* - INVALID_SIGNED_TX — we cannot even derive a hash for what we broadcast
|
|
811
|
+
* - RECEIPT_TIMEOUT — receipt never landed; the tx may still be pending, so
|
|
812
|
+
* retrying would race a second tx against the same nonce
|
|
813
|
+
* (a confirmed on-chain revert is NOT this — it may retry)
|
|
814
|
+
*
|
|
815
|
+
* @param {Error} err
|
|
816
|
+
* @returns {boolean}
|
|
817
|
+
*/
|
|
818
|
+
function isFatalBroadcastError(err) {
|
|
819
|
+
return err?.code === 'TXHASH_MISMATCH'
|
|
820
|
+
|| err?.code === 'INVALID_SIGNED_TX'
|
|
821
|
+
|| err?.code === 'RECEIPT_TIMEOUT';
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Assert the broadcaster reported the transaction we actually signed, and return
|
|
826
|
+
* our locally-derived hash.
|
|
827
|
+
*
|
|
828
|
+
* Fails closed (TXHASH_MISMATCH) when the broadcaster's returned hash differs
|
|
829
|
+
* from keccak256 of our signed bytes: a mismatch means its receipt would confirm
|
|
830
|
+
* a transaction we never signed, so nothing has been verified. When the
|
|
831
|
+
* broadcaster returns no hash we cannot compare, so the returned local hash is
|
|
832
|
+
* what callers must poll for a receipt — a substituted transaction then times
|
|
833
|
+
* out rather than falsely confirming.
|
|
834
|
+
*
|
|
835
|
+
* @param {string} signedTxHex - the raw signed tx we sent to /execute
|
|
836
|
+
* @param {string} broadcasterTxHash - the txHash /execute returned (may be empty)
|
|
837
|
+
* @param {string} [label] - describes the tx for the error, e.g. "allowance-revoke"
|
|
838
|
+
* @returns {string} our locally-derived transaction hash
|
|
839
|
+
*/
|
|
840
|
+
function assertTxHashMatch(signedTxHex, broadcasterTxHash, label = '') {
|
|
841
|
+
const what = label ? `the ${label} transaction this CLI signed` : 'the transaction this CLI signed';
|
|
842
|
+
// A derivation failure here happens AFTER the tx was broadcast, so it must be
|
|
843
|
+
// fatal (INVALID_SIGNED_TX), never swallowed into "try the next quote": we
|
|
844
|
+
// hold no hash for the transaction we just sent.
|
|
845
|
+
let localHash;
|
|
846
|
+
try {
|
|
847
|
+
localHash = evmTxHash(signedTxHex);
|
|
848
|
+
} catch (hashErr) {
|
|
849
|
+
throw new CommandError(
|
|
850
|
+
`Aborting: cannot derive a local hash for ${what}: ${hashErr.message}. `
|
|
851
|
+
+ `The transaction may already have been broadcast, so nothing further will run — `
|
|
852
|
+
+ `check your wallet before retrying.`,
|
|
853
|
+
'INVALID_SIGNED_TX',
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
// Normalize both sides through the same bare-hex form before comparing.
|
|
857
|
+
// evmTxHash always emits 0x-prefixed, but a broadcaster may report bare hex;
|
|
858
|
+
// comparing 0x-prefixed against bare would be a false mismatch on the prefix
|
|
859
|
+
// alone — and TXHASH_MISMATCH is fatal, so that would wrongly abort.
|
|
860
|
+
if (broadcasterTxHash) {
|
|
861
|
+
const norm = h => h.toLowerCase().replace(/^0x/, '');
|
|
862
|
+
if (norm(localHash) !== norm(broadcasterTxHash)) {
|
|
863
|
+
throw new CommandError(
|
|
864
|
+
`Aborting: the broadcaster reported transaction ${broadcasterTxHash}, but ${what} `
|
|
865
|
+
+ `hashes to ${localHash}. These must match — a mismatch means the receipt would confirm `
|
|
866
|
+
+ `a transaction you did not sign, so nothing has been verified and no further steps will `
|
|
867
|
+
+ `run. Check both hashes on a block explorer to see what was actually broadcast before retrying.`,
|
|
868
|
+
'TXHASH_MISMATCH',
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
return localHash;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Confirm a broadcast EVM transaction against the hash we derived locally from
|
|
877
|
+
* the signed bytes — not the hash the broadcaster reported. See
|
|
878
|
+
* {@link assertTxHashMatch} for the two guarantees (fail closed on mismatch;
|
|
879
|
+
* poll our own hash so a silent substitution times out rather than confirms).
|
|
880
|
+
*
|
|
881
|
+
* @param {string} chain
|
|
882
|
+
* @param {string} signedTxHex - the raw signed tx we sent to /execute
|
|
883
|
+
* @param {string} broadcasterTxHash - the txHash /execute returned
|
|
884
|
+
* @param {string} [label] - describes the tx for a mismatch error, e.g.
|
|
885
|
+
* "allowance-revoke" — the least useful moment to lose context is a revoke
|
|
886
|
+
* mismatch with the allowance sitting at 0, so callers should pass it
|
|
887
|
+
* @returns {Promise<{receipt: object, hash: string}>} the receipt and the
|
|
888
|
+
* locally-derived hash it was confirmed against (log THIS, not the
|
|
889
|
+
* broadcaster's hash — it is the transaction we actually verified landed)
|
|
890
|
+
*/
|
|
891
|
+
export async function confirmEvmBroadcast(chain, signedTxHex, broadcasterTxHash, label = '') {
|
|
892
|
+
const hash = assertTxHashMatch(signedTxHex, broadcasterTxHash, label);
|
|
893
|
+
const receipt = await waitForReceipt(chain, hash);
|
|
894
|
+
return { receipt, hash };
|
|
662
895
|
}
|
|
663
896
|
|
|
664
897
|
/**
|
|
@@ -724,10 +957,12 @@ function toRpcHexValue(value) {
|
|
|
724
957
|
* guards: the cheap eth_call sim answers "will it revert", this answers "does the
|
|
725
958
|
* outcome match intent" (see assertSwapOutcome in trade-validation.js).
|
|
726
959
|
*
|
|
727
|
-
* EVM-only, and on its own gate independent of --no-simulate/gasless.
|
|
728
|
-
*
|
|
729
|
-
*
|
|
730
|
-
*
|
|
960
|
+
* EVM-only, and on its own gate independent of --no-simulate/gasless. Runs for
|
|
961
|
+
* cross-chain bridges too — assertSwapOutcome skips only the output-arrival
|
|
962
|
+
* assertion internally, since the output lands on the destination chain and a
|
|
963
|
+
* source-chain simulation can't observe it; the input-outflow and no-sibling-
|
|
964
|
+
* drain assertions still bound the source-chain leg. When no simulation-capable
|
|
965
|
+
* endpoint is configured it DEGRADES — logs a warning, then proceeds — so a simulation
|
|
731
966
|
* outage never blocks trading. --no-verify-outcome skips it entirely.
|
|
732
967
|
*
|
|
733
968
|
* Returns { proceed, reason }. proceed=false means this quote failed
|
|
@@ -745,11 +980,12 @@ function toRpcHexValue(value) {
|
|
|
745
980
|
*/
|
|
746
981
|
export async function verifySwapOutcome({ chain, from, quote, quoteData, apiKey = null, log = () => {} }) {
|
|
747
982
|
if (CHAIN_MAP[chain?.toLowerCase()]?.type !== 'evm') return { proceed: true }; // EVM-only
|
|
748
|
-
// Cross-chain: the output token settles on the destination chain,
|
|
749
|
-
//
|
|
750
|
-
//
|
|
751
|
-
//
|
|
752
|
-
|
|
983
|
+
// Cross-chain (bridge): the output token settles on the destination chain,
|
|
984
|
+
// so the source-chain simulation still runs but assertSwapOutcome skips
|
|
985
|
+
// only the output-arrival assertion internally (isBridge, derived from
|
|
986
|
+
// quoteData.request). The input-outflow cap and no-sibling-drain checks
|
|
987
|
+
// still bound the source-chain leg.
|
|
988
|
+
|
|
753
989
|
// No request intent recorded (a pre-intent quote): assertSwapOutcome has
|
|
754
990
|
// nothing to compare the simulated deltas against and would raise a misleading
|
|
755
991
|
// SWAP_OUTCOME_MISMATCH. Degrade cleanly — the static guards still ran, and a
|
|
@@ -772,7 +1008,10 @@ export async function verifySwapOutcome({ chain, from, quote, quoteData, apiKey
|
|
|
772
1008
|
{ to: tx.to, data: tx.data, value: toRpcHexValue(tx.value) },
|
|
773
1009
|
{ from, apiKey },
|
|
774
1010
|
);
|
|
775
|
-
assertSwapOutcome(quoteData.request, quote, sim, { slippage: quoteData.slippage, expectedSpenders });
|
|
1011
|
+
const outcome = assertSwapOutcome(quoteData.request, quote, sim, { slippage: quoteData.slippage, expectedSpenders });
|
|
1012
|
+
if (outcome.outputAssertionSkipped) {
|
|
1013
|
+
log(' ℹ Bridge: input-outflow and sibling checks ran; output arrives on the destination chain and is not simulated here.');
|
|
1014
|
+
}
|
|
776
1015
|
log(` ✓ Swap outcome verified (via ${sim.method}).`);
|
|
777
1016
|
return { proceed: true };
|
|
778
1017
|
} catch (e) {
|
|
@@ -787,6 +1026,51 @@ export async function verifySwapOutcome({ chain, from, quote, quoteData, apiKey
|
|
|
787
1026
|
}
|
|
788
1027
|
}
|
|
789
1028
|
|
|
1029
|
+
/**
|
|
1030
|
+
* The Solana sibling of verifySwapOutcome: simulates the swap transaction via
|
|
1031
|
+
* simulateTransaction and checks the resulting balance deltas against the
|
|
1032
|
+
* persisted request intent, degrading (warn + proceed) on any RPC/sim outage
|
|
1033
|
+
* so an outage never blocks a trade — only a real outcome mismatch or an
|
|
1034
|
+
* in-simulation revert blocks (falls through to the next quote).
|
|
1035
|
+
*/
|
|
1036
|
+
export async function verifySolanaSwapOutcome({ chain, walletAddress, txBase64, quote, quoteData, log = () => {} }) {
|
|
1037
|
+
if (chain !== 'solana') return { proceed: true };
|
|
1038
|
+
// Cross-chain (bridge): the output settles on the destination chain, so
|
|
1039
|
+
// the source-chain simulation still runs but assertSolanaSwapOutcome skips
|
|
1040
|
+
// only the output-arrival assertion internally (mirrors the EVM path above).
|
|
1041
|
+
|
|
1042
|
+
if (!quoteData?.request) {
|
|
1043
|
+
log(' ⚠ Swap-outcome verification skipped (no request intent — re-quote to enable it).');
|
|
1044
|
+
return { proceed: true };
|
|
1045
|
+
}
|
|
1046
|
+
if (!hasSolanaSimulationRpc(chain)) {
|
|
1047
|
+
log(` ⚠ Swap-outcome verification unavailable (no simulation endpoint for ${chain}); proceeding without it.`);
|
|
1048
|
+
return { proceed: true };
|
|
1049
|
+
}
|
|
1050
|
+
try {
|
|
1051
|
+
const sim = await simulateSolanaAssetChanges(chain, txBase64, { walletAddress });
|
|
1052
|
+
const outcome = assertSolanaSwapOutcome(quoteData.request, quote, sim, { slippage: quoteData.slippage });
|
|
1053
|
+
if (outcome.inputAssertionSkipped) {
|
|
1054
|
+
// On a native-SOL bridge the output assertion did NOT run (it settles on
|
|
1055
|
+
// the destination chain), so don't claim "output ... checks still ran" —
|
|
1056
|
+
// that would contradict the bridge line logged just below.
|
|
1057
|
+
const alsoRan = outcome.outputAssertionSkipped ? 'sibling checks still ran' : 'output and sibling checks still ran';
|
|
1058
|
+
log(` ℹ Native-SOL input spend is bounded with fee/rent slack, not exactly delta-verified; ${alsoRan}.`);
|
|
1059
|
+
}
|
|
1060
|
+
if (outcome.outputAssertionSkipped) {
|
|
1061
|
+
log(' ℹ Bridge: input-outflow and sibling checks ran; output arrives on the destination chain and is not simulated here.');
|
|
1062
|
+
}
|
|
1063
|
+
log(` ✓ Swap outcome verified (via ${sim.method}).`);
|
|
1064
|
+
return { proceed: true };
|
|
1065
|
+
} catch (e) {
|
|
1066
|
+
if (e instanceof SolanaSimulationError && ['NO_SIM_RPC', 'SIM_RPC_ERROR'].includes(e.code)) {
|
|
1067
|
+
log(` ⚠ Swap-outcome verification could not run (${e.message}); proceeding without it.`);
|
|
1068
|
+
return { proceed: true };
|
|
1069
|
+
}
|
|
1070
|
+
return { proceed: false, reason: e.message };
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
790
1074
|
/**
|
|
791
1075
|
* Estimate gas for an EVM transaction. Returns the gas estimate or null on failure.
|
|
792
1076
|
* Used to fix under-gassed quotes from aggregators.
|
|
@@ -982,6 +1266,42 @@ export function assertCompleteEvmRequestIntent(request) {
|
|
|
982
1266
|
if (missing.length) {
|
|
983
1267
|
throw new Error(`Quote request intent is incomplete (${missing.join(', ')} missing). Re-quote before executing an EVM swap. Refusing to sign.`);
|
|
984
1268
|
}
|
|
1269
|
+
// swapMode must be a recognized mode, not merely present. This runs
|
|
1270
|
+
// unconditionally before signing — unlike the swap-outcome verifier, which is
|
|
1271
|
+
// skipped by --no-verify-outcome or when the sim RPC degrades — so a corrupted
|
|
1272
|
+
// or edited quote record with a garbage swapMode fails closed regardless of
|
|
1273
|
+
// the outcome-verification path.
|
|
1274
|
+
if (request.swapMode !== 'exactIn' && request.swapMode !== 'exactOut') {
|
|
1275
|
+
throw new Error(`Quote request intent has an unrecognized swap mode ("${request.swapMode}"); expected exactIn or exactOut. Re-quote before executing an EVM swap. Refusing to sign.`);
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
/**
|
|
1280
|
+
* The Solana sibling of assertCompleteEvmRequestIntent. Solana signs the
|
|
1281
|
+
* aggregator's serialized VersionedTransaction verbatim — there is no
|
|
1282
|
+
* approval/calldata split to independently validate — so assertQuoteMatchesRequest
|
|
1283
|
+
* is the only guard between a compromised quote and a signed drain. That check's
|
|
1284
|
+
* per-field `if (request.x)` comparisons silently skip a missing field, so this
|
|
1285
|
+
* closes the gap by failing closed on any incomplete request intent up front.
|
|
1286
|
+
*/
|
|
1287
|
+
export function assertCompleteSolanaRequestIntent(request) {
|
|
1288
|
+
if (!request) {
|
|
1289
|
+
throw new Error('Quote is missing request intent. Re-quote with this CLI version before executing a Solana swap. Refusing to sign.');
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
const missing = [];
|
|
1293
|
+
for (const field of ['chain', 'walletAddress', 'fromToken', 'toToken', 'swapMode', 'amount', 'maxInputAmount']) {
|
|
1294
|
+
if (request[field] == null || request[field] === '') missing.push(field);
|
|
1295
|
+
}
|
|
1296
|
+
if (missing.length) {
|
|
1297
|
+
throw new Error(`Quote request intent is incomplete (${missing.join(', ')} missing). Re-quote before executing a Solana swap. Refusing to sign.`);
|
|
1298
|
+
}
|
|
1299
|
+
// swapMode must be a recognized mode, not merely present — see the EVM sibling.
|
|
1300
|
+
// Runs unconditionally before signing, so a garbage swapMode fails closed even
|
|
1301
|
+
// when the swap-outcome verifier is skipped or degraded.
|
|
1302
|
+
if (request.swapMode !== 'exactIn' && request.swapMode !== 'exactOut') {
|
|
1303
|
+
throw new Error(`Quote request intent has an unrecognized swap mode ("${request.swapMode}"); expected exactIn or exactOut. Re-quote before executing a Solana swap. Refusing to sign.`);
|
|
1304
|
+
}
|
|
985
1305
|
}
|
|
986
1306
|
|
|
987
1307
|
/**
|
|
@@ -1257,23 +1577,6 @@ function rlpNormalize(val) {
|
|
|
1257
1577
|
return toBuffer(val);
|
|
1258
1578
|
}
|
|
1259
1579
|
|
|
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
1580
|
// ============= Chain Utilities =============
|
|
1278
1581
|
|
|
1279
1582
|
/**
|
|
@@ -1547,6 +1850,12 @@ export function buildTradingCommands(deps = {}) {
|
|
|
1547
1850
|
const autoSlippage = flags['auto-slippage'];
|
|
1548
1851
|
const maxAutoSlippage = options['max-auto-slippage'];
|
|
1549
1852
|
const swapMode = options['swap-mode'] || 'exactIn';
|
|
1853
|
+
if (swapMode !== 'exactIn' && swapMode !== 'exactOut') {
|
|
1854
|
+
throw new CommandError(
|
|
1855
|
+
`Invalid --swap-mode: "${swapMode}". Use one of: exactIn, exactOut.`,
|
|
1856
|
+
'INVALID_INPUT',
|
|
1857
|
+
);
|
|
1858
|
+
}
|
|
1550
1859
|
const amountUnit = options['amount-unit'];
|
|
1551
1860
|
const aggregatorFilter = options.aggregator;
|
|
1552
1861
|
if (aggregatorFilter && !['lifi', 'relay', 'jupiter', 'okx'].includes(aggregatorFilter)) {
|
|
@@ -1593,9 +1902,9 @@ OPTIONS:
|
|
|
1593
1902
|
--swap-mode <mode> exactIn (default) or exactOut
|
|
1594
1903
|
--max-input <baseUnits> exactOut only: hard ceiling on the sell-token spend
|
|
1595
1904
|
(base units), measured against the slippage-buffered
|
|
1596
|
-
|
|
1597
|
-
Required for
|
|
1598
|
-
signing
|
|
1905
|
+
spend (input + slippage), not the bare quote input.
|
|
1906
|
+
Required for exactOut on every chain and enforced
|
|
1907
|
+
before signing.
|
|
1599
1908
|
--aggregator <name> Force a specific aggregator (lifi, relay, jupiter, okx).
|
|
1600
1909
|
Filters the quote list client-side; errors if none match.
|
|
1601
1910
|
|
|
@@ -1630,12 +1939,9 @@ CROSS-CHAIN NOTES (when using --to-chain):
|
|
|
1630
1939
|
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
1940
|
}
|
|
1632
1941
|
|
|
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.
|
|
1942
|
+
// isEvmSource gates the ERC-20-approval-specific check just below (auto-slippage
|
|
1943
|
+
// sizing an approval has no Solana equivalent). The --max-input requirement
|
|
1944
|
+
// itself is NOT gated on it — see the check after maxInputOverride is parsed.
|
|
1639
1945
|
const isEvmSource = CHAIN_MAP[chain?.toLowerCase()]?.type === 'evm';
|
|
1640
1946
|
|
|
1641
1947
|
// exactOut scopes the ERC-20 approval to a slippage-buffered max input. With
|
|
@@ -1669,7 +1975,10 @@ CROSS-CHAIN NOTES (when using --to-chain):
|
|
|
1669
1975
|
throw new CommandError(`Error: invalid --max-input "${maxInputRaw}": must be an integer in base units of the sell token.`, 'INVALID_INPUT');
|
|
1670
1976
|
}
|
|
1671
1977
|
}
|
|
1672
|
-
|
|
1978
|
+
// Required on every chain: an exactOut cap derived from the API's own quote
|
|
1979
|
+
// response would just check that quote against itself and could never reject
|
|
1980
|
+
// anything (there is no independent signal to catch an inflated input).
|
|
1981
|
+
if (swapMode === 'exactOut' && maxInputOverride == null) {
|
|
1673
1982
|
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
1983
|
}
|
|
1675
1984
|
|
|
@@ -1922,6 +2231,9 @@ CROSS-CHAIN NOTES (when using --to-chain):
|
|
|
1922
2231
|
}
|
|
1923
2232
|
|
|
1924
2233
|
const signerType = isWalletConnect ? 'walletconnect' : walletProvider;
|
|
2234
|
+
// exactOut has no request.amount input bound (amount is the OUTPUT), so
|
|
2235
|
+
// maxInputAmount is the only spend ceiling assertInputWithinMax can enforce.
|
|
2236
|
+
// Required explicitly via --max-input on every chain (checked above).
|
|
1925
2237
|
const maxInputAmount = swapMode === 'exactOut' ? maxInputOverride : String(resolvedAmount);
|
|
1926
2238
|
const quoteId = saveQuote(response, chain, signerType, privyWalletIds, isCrossChain ? toChainRaw : null, {
|
|
1927
2239
|
swapMode,
|
|
@@ -1994,7 +2306,7 @@ OPTIONS:
|
|
|
1994
2306
|
--quote <id> Quote ID from 'nansen quote'
|
|
1995
2307
|
--wallet <name> Wallet name (default: default wallet)
|
|
1996
2308
|
--no-simulate Skip pre-broadcast simulation (the eth_call revert check)
|
|
1997
|
-
--no-verify-outcome Skip
|
|
2309
|
+
--no-verify-outcome Skip swap-outcome verification (balance-delta check)
|
|
1998
2310
|
--no-revoke-excessive-allowance
|
|
1999
2311
|
Skip auto-revoking an oversized/legacy allowance before re-approving
|
|
2000
2312
|
--gasless Relay-only: have Relay's solver pay gas (no WalletConnect)
|
|
@@ -2137,13 +2449,50 @@ EXAMPLES:
|
|
|
2137
2449
|
|
|
2138
2450
|
if (chainType === 'solana' && isPrivy) {
|
|
2139
2451
|
// Solana via Privy: sign the serialized transaction
|
|
2140
|
-
let txBase64 = currentQuote.transaction;
|
|
2141
|
-
if (typeof txBase64 === 'object' && txBase64.data) {
|
|
2142
|
-
txBase64 = base58Decode(txBase64.data).toString('base64');
|
|
2143
|
-
}
|
|
2144
|
-
log(' Signing Solana transaction via Privy...');
|
|
2145
2452
|
const solWalletId = quoteData.privyWalletIds?.solana;
|
|
2146
2453
|
if (!solWalletId) throw new Error('No Solana Privy wallet ID in quote');
|
|
2454
|
+
const walletResult = await privyClient.getWallet(solWalletId);
|
|
2455
|
+
const walletAddress = walletResult.address;
|
|
2456
|
+
// Fail closed if the signer address doesn't resolve: without it the
|
|
2457
|
+
// wallet-binding comparison below would silently skip, leaving the
|
|
2458
|
+
// quote unbound to the wallet that will sign it. This is resolved
|
|
2459
|
+
// independently of the persisted request so assertQuoteMatchesRequest
|
|
2460
|
+
// is a real check, not a comparison of the request against itself.
|
|
2461
|
+
if (!walletAddress) {
|
|
2462
|
+
throw new Error('Could not resolve the Solana Privy wallet address; cannot confirm the quote was built for this wallet. Refusing to sign.');
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
// Solana: transaction is a base64 string (Jupiter), an object with a
|
|
2466
|
+
// base58-encoded `data` field (OKX), or raw uncompiled instructions
|
|
2467
|
+
// (Relay bridge quotes). Normalize to base64.
|
|
2468
|
+
const txBase64 = await normalizeSolanaTransaction(currentQuote.transaction, CHAIN_RPCS.solana, async () => walletAddress);
|
|
2469
|
+
|
|
2470
|
+
// Validate the persisted request/quote metadata (token pair, amounts,
|
|
2471
|
+
// signer) before signing the aggregator's serialized transaction.
|
|
2472
|
+
assertCompleteSolanaRequestIntent(quoteData.request);
|
|
2473
|
+
assertQuoteMatchesRequest(quoteData.request, currentQuote, { chain, walletAddress, slippage: quoteData.slippage });
|
|
2474
|
+
|
|
2475
|
+
// Then statically inspect the serialized transaction's own
|
|
2476
|
+
// instructions ahead of signing — catches a delegate grant, authority
|
|
2477
|
+
// change, close-to-stranger, or excessive fee that the metadata check
|
|
2478
|
+
// alone wouldn't see. The residual sibling-transfer gap is closed by
|
|
2479
|
+
// verifySolanaSwapOutcome below (degrades gracefully when no sim RPC
|
|
2480
|
+
// is available, so this static check remains a guard when sim is off).
|
|
2481
|
+
assertSolanaInstructionsSafe(txBase64, { walletAddress });
|
|
2482
|
+
|
|
2483
|
+
// Verify the swap's simulated on-chain outcome matches intent.
|
|
2484
|
+
// Degrades with a warning if no simulation endpoint is available.
|
|
2485
|
+
if (!noVerifyOutcome) {
|
|
2486
|
+
const outcome = await verifySolanaSwapOutcome({ chain, walletAddress, txBase64, quote: currentQuote, quoteData, log });
|
|
2487
|
+
if (!outcome.proceed) {
|
|
2488
|
+
log(` ❌ ${quoteName} failed swap-outcome verification: ${outcome.reason}`);
|
|
2489
|
+
if (qi + 1 < endIndex) log(' Trying next quote...');
|
|
2490
|
+
lastQuoteError = `${quoteName} outcome verification failed: ${outcome.reason}`;
|
|
2491
|
+
continue;
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
log(' Signing Solana transaction via Privy...');
|
|
2147
2496
|
const signResult = await privyClient.signSolanaTransaction(solWalletId, txBase64);
|
|
2148
2497
|
signedTransaction = signResult.data?.signed_transaction || signResult.signed_transaction;
|
|
2149
2498
|
requestId = currentQuote.metadata?.requestId;
|
|
@@ -2254,9 +2603,10 @@ EXAMPLES:
|
|
|
2254
2603
|
}
|
|
2255
2604
|
log(` Waiting for allowance revoke confirmation...`);
|
|
2256
2605
|
try {
|
|
2257
|
-
const receipt = await
|
|
2258
|
-
log(` ✓ Allowance revoked in block ${parseInt(receipt.blockNumber, 16)}: ${
|
|
2606
|
+
const { receipt, hash: revokeHash } = await confirmEvmBroadcast(chain, signedRevoke, revokeResult.txHash, 'allowance-revoke');
|
|
2607
|
+
log(` ✓ Allowance revoked in block ${parseInt(receipt.blockNumber, 16)}: ${revokeHash}`);
|
|
2259
2608
|
} catch (receiptErr) {
|
|
2609
|
+
if (isFatalBroadcastError(receiptErr)) throw receiptErr;
|
|
2260
2610
|
log(` ❌ Allowance revoke may not have confirmed for ${quoteName}: ${receiptErr.message}.${allowanceRevokeRecoveryHint(revokeResult.txHash)}`);
|
|
2261
2611
|
if (qi + 1 < endIndex) log(` Trying next quote...`);
|
|
2262
2612
|
lastQuoteError = `${quoteName} allowance revoke unconfirmed`;
|
|
@@ -2312,9 +2662,10 @@ EXAMPLES:
|
|
|
2312
2662
|
}
|
|
2313
2663
|
log(` Waiting for approval confirmation...`);
|
|
2314
2664
|
try {
|
|
2315
|
-
const receipt = await
|
|
2316
|
-
log(` ✓ Approval confirmed in block ${parseInt(receipt.blockNumber, 16)}: ${
|
|
2665
|
+
const { receipt, hash: approvalHash } = await confirmEvmBroadcast(chain, signedApproval, approvalResult.txHash, 'allowance-approval');
|
|
2666
|
+
log(` ✓ Approval confirmed in block ${parseInt(receipt.blockNumber, 16)}: ${approvalHash}`);
|
|
2317
2667
|
} catch (receiptErr) {
|
|
2668
|
+
if (isFatalBroadcastError(receiptErr)) throw receiptErr;
|
|
2318
2669
|
log(` ❌ Approval may not have confirmed${shouldRevoke ? ' after revoking the prior allowance (now 0)' : ''}: ${receiptErr.message}`);
|
|
2319
2670
|
if (qi + 1 < endIndex) log(` Trying next quote...`);
|
|
2320
2671
|
lastQuoteError = `${quoteName} approval unconfirmed`;
|
|
@@ -2408,17 +2759,60 @@ EXAMPLES:
|
|
|
2408
2759
|
signedTransaction = signResult.data?.signed_transaction || signResult.signed_transaction;
|
|
2409
2760
|
|
|
2410
2761
|
} else if (chainType === 'solana') {
|
|
2411
|
-
// NB: validateSwapTarget (the EVM `to`/`data` guard)
|
|
2412
|
-
//
|
|
2413
|
-
//
|
|
2414
|
-
//
|
|
2415
|
-
//
|
|
2416
|
-
//
|
|
2417
|
-
// Solana: transaction is
|
|
2418
|
-
//
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2762
|
+
// NB: validateSwapTarget (the EVM `to`/`data` guard) does not apply
|
|
2763
|
+
// here — Solana quotes are a pre-built serialized VersionedTransaction
|
|
2764
|
+
// with no `to`/`data`/approval split to validate. assertQuoteMatchesRequest
|
|
2765
|
+
// below binds the metadata (token pair, amounts, signer), and
|
|
2766
|
+
// assertSolanaInstructionsSafe statically inspects the tx's own
|
|
2767
|
+
// instructions before signing.
|
|
2768
|
+
// Solana: transaction is a base64 string (Jupiter), an object with a
|
|
2769
|
+
// base58-encoded `data` field (OKX), or raw uncompiled instructions
|
|
2770
|
+
// (Relay bridge quotes). Normalize to base64.
|
|
2771
|
+
|
|
2772
|
+
// Resolve the signer first — both the Relay-shape compiler (which needs
|
|
2773
|
+
// an expected signer for its fee-payer check) and the intent-binding
|
|
2774
|
+
// check below use this exact same address.
|
|
2775
|
+
let solanaWalletAddress;
|
|
2776
|
+
if (isWalletConnect) {
|
|
2777
|
+
solanaWalletAddress = await getWalletConnectAddress(chainType);
|
|
2778
|
+
if (!solanaWalletAddress) {
|
|
2779
|
+
throw new CommandError('WalletConnect session lost during execute. Reconnect with `walletconnect connect` and retry.', 'NO_WALLET');
|
|
2780
|
+
}
|
|
2781
|
+
} else {
|
|
2782
|
+
solanaWalletAddress = exported.solana.address;
|
|
2783
|
+
// Fail closed if the signer address doesn't resolve: without it the
|
|
2784
|
+
// wallet-binding comparison below would silently skip, leaving the
|
|
2785
|
+
// quote unbound to the wallet that will sign it.
|
|
2786
|
+
if (!solanaWalletAddress) {
|
|
2787
|
+
throw new Error("Could not resolve the local wallet's Solana address; cannot confirm the quote was built for this wallet. Refusing to sign.");
|
|
2788
|
+
}
|
|
2789
|
+
}
|
|
2790
|
+
|
|
2791
|
+
const txBase64 = await normalizeSolanaTransaction(currentQuote.transaction, CHAIN_RPCS.solana, async () => solanaWalletAddress);
|
|
2792
|
+
|
|
2793
|
+
// Validate the persisted request/quote metadata (token pair, amounts,
|
|
2794
|
+
// signer) before signing the opaque Solana transaction.
|
|
2795
|
+
assertCompleteSolanaRequestIntent(quoteData.request);
|
|
2796
|
+
assertQuoteMatchesRequest(quoteData.request, currentQuote, { chain, walletAddress: solanaWalletAddress, slippage: quoteData.slippage });
|
|
2797
|
+
|
|
2798
|
+
// Then statically inspect the serialized transaction's own
|
|
2799
|
+
// instructions ahead of signing — catches a delegate grant, authority
|
|
2800
|
+
// change, close-to-stranger, or excessive fee that the metadata check
|
|
2801
|
+
// alone wouldn't see. The residual sibling-transfer gap is closed by
|
|
2802
|
+
// verifySolanaSwapOutcome below (degrades gracefully when no sim RPC
|
|
2803
|
+
// is available, so this static check remains a guard when sim is off).
|
|
2804
|
+
assertSolanaInstructionsSafe(txBase64, { walletAddress: solanaWalletAddress });
|
|
2805
|
+
|
|
2806
|
+
// Verify the swap's simulated on-chain outcome matches intent.
|
|
2807
|
+
// Degrades with a warning if no simulation endpoint is available.
|
|
2808
|
+
if (!noVerifyOutcome) {
|
|
2809
|
+
const outcome = await verifySolanaSwapOutcome({ chain, walletAddress: solanaWalletAddress, txBase64, quote: currentQuote, quoteData, log });
|
|
2810
|
+
if (!outcome.proceed) {
|
|
2811
|
+
log(` ❌ ${quoteName} failed swap-outcome verification: ${outcome.reason}`);
|
|
2812
|
+
if (qi + 1 < endIndex) log(' Trying next quote...');
|
|
2813
|
+
lastQuoteError = `${quoteName} outcome verification failed: ${outcome.reason}`;
|
|
2814
|
+
continue;
|
|
2815
|
+
}
|
|
2422
2816
|
}
|
|
2423
2817
|
|
|
2424
2818
|
if (isWalletConnect) {
|
|
@@ -2567,12 +2961,13 @@ EXAMPLES:
|
|
|
2567
2961
|
if (broadcastResult.status !== 'Success') {
|
|
2568
2962
|
throw new Error(broadcastResult.error || 'broadcast failed');
|
|
2569
2963
|
}
|
|
2570
|
-
revokeTxHash = broadcastResult.txHash;
|
|
2964
|
+
revokeTxHash = assertTxHashMatch(revokeResult.signedTransaction, broadcastResult.txHash, 'allowance-revoke');
|
|
2571
2965
|
}
|
|
2572
2966
|
if (!revokeTxHash) {
|
|
2573
2967
|
throw new Error('Allowance revoke returned no transaction hash and no signed transaction; cannot confirm allowance was cleared');
|
|
2574
2968
|
}
|
|
2575
2969
|
} catch (revokeErr) {
|
|
2970
|
+
if (isFatalBroadcastError(revokeErr)) throw revokeErr;
|
|
2576
2971
|
log(` ❌ Allowance revoke failed for ${quoteName}: ${revokeErr.message}.${allowanceRevokeRecoveryHint(revokeTxHash)}`);
|
|
2577
2972
|
if (qi + 1 < endIndex) log(` Trying next quote...`);
|
|
2578
2973
|
lastQuoteError = `${quoteName} allowance revoke failed`;
|
|
@@ -2583,6 +2978,7 @@ EXAMPLES:
|
|
|
2583
2978
|
const receipt = await waitForReceipt(chain, revokeTxHash);
|
|
2584
2979
|
log(` ✓ Allowance revoked in block ${parseInt(receipt.blockNumber, 16)}: ${revokeTxHash}`);
|
|
2585
2980
|
} catch (receiptErr) {
|
|
2981
|
+
if (isFatalBroadcastError(receiptErr)) throw receiptErr;
|
|
2586
2982
|
log(` ❌ Allowance revoke may not have confirmed for ${quoteName}: ${receiptErr.message}.${allowanceRevokeRecoveryHint(revokeTxHash)}`);
|
|
2587
2983
|
if (qi + 1 < endIndex) log(` Trying next quote...`);
|
|
2588
2984
|
lastQuoteError = `${quoteName} allowance revoke unconfirmed`;
|
|
@@ -2621,7 +3017,7 @@ EXAMPLES:
|
|
|
2621
3017
|
if (broadcastResult.status !== 'Success') {
|
|
2622
3018
|
throw new Error(broadcastResult.error || 'broadcast failed');
|
|
2623
3019
|
}
|
|
2624
|
-
approvalTxHash = broadcastResult.txHash;
|
|
3020
|
+
approvalTxHash = assertTxHashMatch(approvalResult.signedTransaction, broadcastResult.txHash, 'allowance-approval');
|
|
2625
3021
|
}
|
|
2626
3022
|
if (!approvalTxHash) {
|
|
2627
3023
|
// Fail closed: the wallet returned neither a hash nor a
|
|
@@ -2632,6 +3028,7 @@ EXAMPLES:
|
|
|
2632
3028
|
throw new Error('returned no transaction hash and no signed transaction; cannot confirm approval landed');
|
|
2633
3029
|
}
|
|
2634
3030
|
} catch (approvalErr) {
|
|
3031
|
+
if (isFatalBroadcastError(approvalErr)) throw approvalErr;
|
|
2635
3032
|
const revokedMsg = shouldRevoke
|
|
2636
3033
|
? ' after revoking the prior allowance (now 0)'
|
|
2637
3034
|
: '';
|
|
@@ -2645,6 +3042,7 @@ EXAMPLES:
|
|
|
2645
3042
|
const receipt = await waitForReceipt(chain, approvalTxHash);
|
|
2646
3043
|
log(` ✓ Approval confirmed in block ${parseInt(receipt.blockNumber, 16)}: ${approvalTxHash}`);
|
|
2647
3044
|
} catch (receiptErr) {
|
|
3045
|
+
if (isFatalBroadcastError(receiptErr)) throw receiptErr;
|
|
2648
3046
|
const revokedMsg = shouldRevoke
|
|
2649
3047
|
? ' after revoking the prior allowance (now 0)'
|
|
2650
3048
|
: '';
|
|
@@ -2730,6 +3128,14 @@ EXAMPLES:
|
|
|
2730
3128
|
try {
|
|
2731
3129
|
await waitForReceipt(chain, wcResult.txHash);
|
|
2732
3130
|
} catch (receiptErr) {
|
|
3131
|
+
// A timeout here is uncertain post-broadcast state, not a
|
|
3132
|
+
// confirmed revert — fail closed rather than retry (which would
|
|
3133
|
+
// broadcast a second swap). Applies even though this path has no
|
|
3134
|
+
// locally-derived hash to bind to.
|
|
3135
|
+
if (receiptErr.code === 'RECEIPT_TIMEOUT') {
|
|
3136
|
+
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');
|
|
3137
|
+
}
|
|
3138
|
+
if (isFatalBroadcastError(receiptErr)) throw receiptErr;
|
|
2733
3139
|
log(`\n ⚠ Transaction was broadcast but REVERTED on-chain!`);
|
|
2734
3140
|
log(` Tx Hash: ${wcResult.txHash}`);
|
|
2735
3141
|
log(` Explorer: ${chainConfig.explorer}${wcResult.txHash}`);
|
|
@@ -2892,9 +3298,10 @@ EXAMPLES:
|
|
|
2892
3298
|
|
|
2893
3299
|
log(` Waiting for allowance revoke confirmation...`);
|
|
2894
3300
|
try {
|
|
2895
|
-
const receipt = await
|
|
2896
|
-
log(` ✓ Allowance revoked in block ${parseInt(receipt.blockNumber, 16)}: ${
|
|
3301
|
+
const { receipt, hash: revokeHash } = await confirmEvmBroadcast(chain, revokeTxHex, revokeResult.txHash, 'allowance-revoke');
|
|
3302
|
+
log(` ✓ Allowance revoked in block ${parseInt(receipt.blockNumber, 16)}: ${revokeHash}`);
|
|
2897
3303
|
} catch (receiptErr) {
|
|
3304
|
+
if (isFatalBroadcastError(receiptErr)) throw receiptErr;
|
|
2898
3305
|
log(` ❌ Allowance revoke may not have confirmed for ${quoteName}: ${receiptErr.message}.${allowanceRevokeRecoveryHint(revokeResult.txHash)}`);
|
|
2899
3306
|
if (qi + 1 < endIndex) log(` Trying next quote...`);
|
|
2900
3307
|
lastQuoteError = `${quoteName} allowance revoke unconfirmed`;
|
|
@@ -2943,9 +3350,10 @@ EXAMPLES:
|
|
|
2943
3350
|
|
|
2944
3351
|
log(` Waiting for approval confirmation...`);
|
|
2945
3352
|
try {
|
|
2946
|
-
const receipt = await
|
|
2947
|
-
log(` ✓ Approval confirmed in block ${parseInt(receipt.blockNumber, 16)}: ${
|
|
3353
|
+
const { receipt, hash: approvalHash } = await confirmEvmBroadcast(chain, approvalTxHex, approvalResult.txHash, 'allowance-approval');
|
|
3354
|
+
log(` ✓ Approval confirmed in block ${parseInt(receipt.blockNumber, 16)}: ${approvalHash}`);
|
|
2948
3355
|
} catch (receiptErr) {
|
|
3356
|
+
if (isFatalBroadcastError(receiptErr)) throw receiptErr;
|
|
2949
3357
|
log(` ❌ Approval may not have confirmed${shouldRevoke ? ' after revoking the prior allowance (now 0)' : ''}: ${receiptErr.message}`);
|
|
2950
3358
|
if (qi + 1 < endIndex) log(` Trying next quote...`);
|
|
2951
3359
|
lastQuoteError = `${quoteName} approval unconfirmed`;
|
|
@@ -3065,17 +3473,51 @@ EXAMPLES:
|
|
|
3065
3473
|
const result = await executeTransaction(execParams);
|
|
3066
3474
|
|
|
3067
3475
|
if (result.status === 'Success') {
|
|
3068
|
-
|
|
3069
|
-
|
|
3476
|
+
let txId = result.signature || result.txHash;
|
|
3477
|
+
let explorerUrl = chainConfig.explorer + txId;
|
|
3070
3478
|
|
|
3071
3479
|
// For EVM: verify the tx actually succeeded on-chain
|
|
3072
|
-
if (chainType === 'evm'
|
|
3480
|
+
if (chainType === 'evm') {
|
|
3073
3481
|
log(' Verifying on-chain status...');
|
|
3482
|
+
// Non-gasless: derive our local hash up front, OUTSIDE the receipt-poll
|
|
3483
|
+
// try below. A hex-validation failure here means no poll ever ran, so it
|
|
3484
|
+
// must surface as itself — not as the "REVERTED on-chain" diagnostic that
|
|
3485
|
+
// catch is reserved for. (Gasless has no local hash to bind to: the Relay
|
|
3486
|
+
// solver wraps and broadcasts its own tx, so result.txHash legitimately is
|
|
3487
|
+
// not the hash of the bytes we signed.)
|
|
3488
|
+
if (!gasless) {
|
|
3489
|
+
try {
|
|
3490
|
+
txId = evmTxHash(signedTransaction);
|
|
3491
|
+
} catch (hashErr) {
|
|
3492
|
+
throw new CommandError(`Cannot derive local tx hash for ${quoteName}: ${hashErr.message}`, 'INVALID_SIGNED_TX');
|
|
3493
|
+
}
|
|
3494
|
+
explorerUrl = chainConfig.explorer + txId;
|
|
3495
|
+
}
|
|
3074
3496
|
try {
|
|
3075
|
-
|
|
3497
|
+
if (gasless) {
|
|
3498
|
+
// If the solver reported no hash there is nothing to poll — skip
|
|
3499
|
+
// rather than block on eth_getTransactionReceipt(undefined).
|
|
3500
|
+
if (result.txHash) await waitForReceipt(chain, result.txHash);
|
|
3501
|
+
} else {
|
|
3502
|
+
const { hash } = await confirmEvmBroadcast(chain, signedTransaction, result.txHash);
|
|
3503
|
+
txId = hash;
|
|
3504
|
+
explorerUrl = chainConfig.explorer + txId;
|
|
3505
|
+
}
|
|
3076
3506
|
} catch (receiptErr) {
|
|
3507
|
+
// A receipt TIMEOUT is not a confirmed revert: the tx was
|
|
3508
|
+
// broadcast and may still be pending under our nonce. Retrying
|
|
3509
|
+
// the next quote would sign and broadcast a SECOND swap racing
|
|
3510
|
+
// the first for that nonce — the duplicate-broadcast this PR
|
|
3511
|
+
// exists to prevent. (It's also exactly how guarantee #2's
|
|
3512
|
+
// silent-substitution case surfaces: a 180s timeout polling our
|
|
3513
|
+
// own hash.) Fail closed with a clearer banner than the generic
|
|
3514
|
+
// rethrow, then let isFatalBroadcastError handle the rest.
|
|
3515
|
+
if (receiptErr.code === 'RECEIPT_TIMEOUT') {
|
|
3516
|
+
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');
|
|
3517
|
+
}
|
|
3518
|
+
if (isFatalBroadcastError(receiptErr)) throw receiptErr;
|
|
3077
3519
|
log(`\n ⚠ Transaction was broadcast but REVERTED on-chain!`);
|
|
3078
|
-
log(` Tx Hash: ${result.txHash}`);
|
|
3520
|
+
log(` Tx Hash: ${txId || result.txHash}`);
|
|
3079
3521
|
log(` Explorer: ${explorerUrl}`);
|
|
3080
3522
|
log(` Error: ${receiptErr.message}`);
|
|
3081
3523
|
if (qi + 1 < endIndex) {
|
|
@@ -3083,7 +3525,7 @@ EXAMPLES:
|
|
|
3083
3525
|
lastQuoteError = `${quoteName} reverted on-chain`;
|
|
3084
3526
|
continue;
|
|
3085
3527
|
}
|
|
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');
|
|
3528
|
+
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
3529
|
}
|
|
3088
3530
|
}
|
|
3089
3531
|
|
|
@@ -3141,6 +3583,11 @@ EXAMPLES:
|
|
|
3141
3583
|
}
|
|
3142
3584
|
|
|
3143
3585
|
} catch (quoteErr) {
|
|
3586
|
+
// Post-broadcast failures abort the whole execute — never retry the
|
|
3587
|
+
// next quote once a transaction is already out and its outcome is
|
|
3588
|
+
// unknown (mismatch, underivable local hash, or an unconfirmed
|
|
3589
|
+
// receipt timeout). See isFatalBroadcastError.
|
|
3590
|
+
if (isFatalBroadcastError(quoteErr)) throw quoteErr;
|
|
3144
3591
|
const msg = quoteErr.message || '';
|
|
3145
3592
|
log(` ❌ Quote ${quoteName} failed: ${msg}`);
|
|
3146
3593
|
if (msg.includes('AccountNotFound') && chainType === 'solana') {
|