diff --git a/.changeset/bind-evm-receipt-to-local-hash.md b/.changeset/bind-evm-receipt-to-local-hash.md new file mode 100644 index 00000000..38447ef3 --- /dev/null +++ b/.changeset/bind-evm-receipt-to-local-hash.md @@ -0,0 +1,13 @@ +--- +"nansen-cli": patch +--- + +trade execute: confirm EVM transactions against the hash derived locally from +the signed bytes instead of trusting the broadcaster's reported hash, and fail +closed if they disagree. Once a transaction has been broadcast, every uncertain +outcome now aborts the whole execute instead of silently trying the next quote +(which could broadcast a second transaction): a hash mismatch, a signed +transaction we cannot re-derive a hash for, and a receipt-confirmation timeout +(distinguished from a genuine on-chain revert) are all fatal across the swap, +approval, and revoke paths. Broadcaster hashes are also compared +prefix-insensitively, so a bare (0x-less) hash is no longer a false mismatch. diff --git a/src/__tests__/trading.test.js b/src/__tests__/trading.test.js index b0e6b245..54b6ff04 100644 --- a/src/__tests__/trading.test.js +++ b/src/__tests__/trading.test.js @@ -22,6 +22,9 @@ import { signLegacyTransaction, signSolanaTransaction, signEvmTransaction, + evmTxHash, + confirmEvmBroadcast, + waitForReceipt, buildApprovalTransaction, approvalAmountForSwap, approvalCapForQuote, @@ -602,6 +605,51 @@ describe('signEvmTransaction (API response format)', () => { }); }); +describe('evmTxHash', () => { + it('hashes to the known keccak256 vector for the underlying bytes', () => { + // 0x616263 decodes to the ASCII bytes "abc", whose keccak256 is the same + // known-good vector crypto.test.js verifies independently — this checks + // evmTxHash's hex decoding lines up with that, not just self-consistency. + expect(evmTxHash('0x616263')).toBe( + '0x4e03657aea45a94fc7d47ba826c8d667c0d1e6e33a64a036ec44f58fa12d6c45', + ); + }); + + it('accepts hex without a 0x prefix', () => { + expect(evmTxHash('616263')).toBe(evmTxHash('0x616263')); + }); + + it('rejects non-hex / odd-length / empty / non-string input', () => { + expect(() => evmTxHash('0xzz')).toThrow(); + expect(() => evmTxHash('0x123')).toThrow(); + expect(() => evmTxHash('0x')).toThrow(); + expect(() => evmTxHash(123)).toThrow(); + }); + + it('hashes a signed EIP-1559 (type 2) transaction to keccak256 of its raw bytes', () => { + const wallet = generateEvmWallet(); + const txData = { + to: '0x' + 'ab'.repeat(20), data: '0x', value: '0', gas: '21000', + maxFeePerGas: '1000000', maxPriorityFeePerGas: '1000000', + }; + const signedHex = signEvmTransaction(txData, wallet.privateKey, 'base', 0); + expect(signedHex.startsWith('0x02')).toBe(true); + const expected = '0x' + keccak256(Buffer.from(signedHex.slice(2), 'hex')).toString('hex'); + expect(evmTxHash(signedHex)).toBe(expected); + }); + + it('hashes a signed legacy (type 0) transaction to keccak256 of its raw bytes', () => { + const wallet = generateEvmWallet(); + const tx = { + nonce: 0, gasPrice: '0x3B9ACA00', gasLimit: '0x5208', + to: '0x' + 'ab'.repeat(20), value: '0x0', data: '0x', chainId: 8453, + }; + const signedHex = signLegacyTransaction(tx, wallet.privateKey); + const expected = '0x' + keccak256(Buffer.from(signedHex.slice(2), 'hex')).toString('hex'); + expect(evmTxHash(signedHex)).toBe(expected); + }); +}); + // ============= ERC-20 Approval Transaction ============= describe('buildApprovalTransaction', () => { @@ -1359,7 +1407,7 @@ describe('Privy execute support', () => { if (urlStr.includes('privy.io') && opts?.method === 'POST') { return Promise.resolve({ ok: true, - json: () => Promise.resolve({ data: { signed_transaction: '0xSignedTxHex' } }), + json: () => Promise.resolve({ data: { signed_transaction: '0xdeadbeef01' } }), }); } // RPC call (nonce, simulation, waitForReceipt) @@ -1381,9 +1429,10 @@ describe('Privy execute support', () => { } // Trading API executeTransaction if (urlStr.includes('trading-api')) { + const body = JSON.parse(opts.body); return Promise.resolve({ ok: true, - text: () => Promise.resolve(JSON.stringify({ status: 'Success', txHash: '0xTxHash', chainType: 'evm', broadcaster: 'test' })), + text: () => Promise.resolve(JSON.stringify({ status: 'Success', txHash: evmTxHash(body.signedTransaction), chainType: 'evm', broadcaster: 'test' })), }); } return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); @@ -1404,6 +1453,72 @@ describe('Privy execute support', () => { expect(logs.every(l => !l.includes('Enter wallet password'))).toBe(true); }); + it('aborts (does not try the next quote) when the signed tx cannot be hashed after a successful broadcast', async () => { + // The wallet (here Privy) hands back malformed signed bytes; /execute reports + // Success, but we then cannot derive a local hash for what we just broadcast. + // That is INVALID_SIGNED_TX, and post-broadcast it must be fatal — trying the + // next quote would broadcast a SECOND swap while the first's fate is unknown. + const quoteId = saveQuote({ + success: true, + quotes: [ + { + aggregator: 'lifi', inputMint: BASE_ETH, outputMint: BASE_USDC, + inAmount: '1000000000000000000', outAmount: '3000000000', + transaction: { to: LIFI_ROUTER, data: '0x12345678', value: '1000000000000000000', gas: '210000' }, + }, + { + aggregator: 'lifi', inputMint: BASE_ETH, outputMint: BASE_USDC, + inAmount: '1000000000000000000', outAmount: '3000000000', + transaction: { to: LIFI_ROUTER, data: '0x87654321', value: '1000000000000000000', gas: '210000' }, + }, + ], + }, 'base', 'privy', { evm: 'wl_evm_1', solana: 'wl_sol_1' }, null, { + swapMode: 'exactIn', + request: evmIntent({ + walletAddress: '0xPrivyAddr', fromToken: BASE_ETH, toToken: BASE_USDC, + amount: '1000000000000000000', maxInputAmount: '1000000000000000000', + }), + }); + + const executeBodies = []; + vi.stubGlobal('fetch', vi.fn().mockImplementation((url, opts) => { + const urlStr = typeof url === 'string' ? url : url.toString(); + if (urlStr.includes('privy.io') && opts?.method === 'GET') { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ id: 'wl_evm_1', address: '0xPrivyAddr', chain_type: 'ethereum' }) }); + } + // Privy returns bytes that are NOT valid hex. + if (urlStr.includes('privy.io') && opts?.method === 'POST') { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ data: { signed_transaction: '0xnothexZZ' } }) }); + } + if (urlStr.includes('base') || urlStr.includes('mainnet')) { + const body = opts?.body ? JSON.parse(opts.body) : {}; + if (body.method === 'eth_getTransactionCount') return Promise.resolve({ text: () => Promise.resolve(JSON.stringify({ jsonrpc: '2.0', id: body.id, result: '0x5' })) }); + if (body.method === 'eth_getCode') return Promise.resolve({ text: () => Promise.resolve(JSON.stringify({ jsonrpc: '2.0', id: body.id, result: '0x6080604052' })) }); + if (body.method === 'eth_call') return Promise.resolve({ text: () => Promise.resolve(JSON.stringify({ jsonrpc: '2.0', id: body.id, result: '0x' })) }); + return Promise.resolve({ text: () => Promise.resolve(JSON.stringify({ jsonrpc: '2.0', id: body.id, result: null })) }); + } + if (urlStr.includes('trading-api') && urlStr.endsWith('/execute')) { + executeBodies.push(JSON.parse(opts.body)); + // A broadcaster that accepted the bytes and reports success with its own hash. + return Promise.resolve({ ok: true, text: () => Promise.resolve(JSON.stringify({ status: 'Success', txHash: '0x' + '11'.repeat(32), chainType: 'evm', broadcaster: 'test' })) }); + } + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + })); + + const logs = []; + const cmds = buildTradingCommands({ log: (m) => logs.push(m), exit: () => {} }); + delete process.env.NANSEN_WALLET_PASSWORD; + + await expect(cmds.execute([], null, {}, { quote: quoteId })) + .rejects.toMatchObject({ code: 'INVALID_SIGNED_TX' }); + + // Exactly one broadcast, and the derivation failure reached the user rather + // than being swallowed into ALL_QUOTES_FAILED after a second broadcast. + expect(executeBodies).toHaveLength(1); + expect(logs.some(l => l.includes('Trying next quote'))).toBe(false); + expect(logs.some(l => l.includes('Transaction successful'))).toBe(false); + }); + it('should sign Solana transaction via Privy and broadcast via Trading API', async () => { const quoteId = saveQuote({ success: true, @@ -1492,7 +1607,7 @@ describe('Privy execute support', () => { if (urlStr.includes('privy.io') && opts?.method === 'POST') { return Promise.resolve({ ok: true, - json: () => Promise.resolve({ data: { signed_transaction: '0xSignedTxHex' } }), + json: () => Promise.resolve({ data: { signed_transaction: '0xdeadbeef02' } }), }); } // RPC calls @@ -1518,9 +1633,10 @@ describe('Privy execute support', () => { // Trading API executeTransaction if (urlStr.includes('trading-api')) { currentAllowance = 1000000n; // the approval that was just broadcast lands on-chain + const body = JSON.parse(opts.body); return Promise.resolve({ ok: true, - text: () => Promise.resolve(JSON.stringify({ status: 'Success', txHash: '0xApprovalHash', chainType: 'evm', broadcaster: 'test' })), + text: () => Promise.resolve(JSON.stringify({ status: 'Success', txHash: evmTxHash(body.signedTransaction), chainType: 'evm', broadcaster: 'test' })), }); } return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); @@ -1659,13 +1775,14 @@ describe('Privy execute support', () => { vi.stubGlobal('fetch', vi.fn().mockImplementation((url, opts) => { const urlStr = typeof url === 'string' ? url : url.toString(); if (urlStr.includes('trading-api') && urlStr.endsWith('/execute')) { - executeBodies.push(JSON.parse(opts.body)); + const body = JSON.parse(opts.body); + executeBodies.push(body); // The broadcast revoke lands on-chain, so the post-revoke allowance // verification reads back 0. currentAllowance = 0n; return Promise.resolve({ ok: true, - text: () => Promise.resolve(JSON.stringify({ status: 'Success', txHash: '0xRevokeHash', chainType: 'evm', broadcaster: 'test' })), + text: () => Promise.resolve(JSON.stringify({ status: 'Success', txHash: evmTxHash(body.signedTransaction), chainType: 'evm', broadcaster: 'test' })), }); } if (urlStr.includes('privy.io') && opts?.method === 'GET') { @@ -1676,7 +1793,7 @@ describe('Privy execute support', () => { } if (urlStr.includes('privy.io') && opts?.method === 'POST') { privyPostCount++; - const data = privyPostCount === 1 ? { signed_transaction: '0xSignedRevoke' } : {}; + const data = privyPostCount === 1 ? { signed_transaction: '0xdeadbeef03' } : {}; return Promise.resolve({ ok: true, json: () => Promise.resolve({ data }) }); } if (urlStr.includes('base') || urlStr.includes('mainnet')) { @@ -3032,6 +3149,374 @@ describe('Relay aggregator: empty approvalAddress', () => { }); }); +describe('confirmEvmBroadcast: binds receipt confirmation to the locally-derived tx hash', () => { + it('rejects a broadcaster txHash that does not match the signed transaction', async () => { + createWallet('default', 'testpass'); + process.env.NANSEN_WALLET_PASSWORD = 'testpass'; + + const executeBodies = []; + const wrongHash = '0x' + 'de'.repeat(32); + vi.stubGlobal('fetch', vi.fn().mockImplementation((url, opts) => { + const urlStr = typeof url === 'string' ? url : url.toString(); + const body = opts?.body ? (() => { try { return JSON.parse(opts.body); } catch { return {}; } })() : {}; + if (body.method === 'eth_getTransactionCount') { + return Promise.resolve({ text: () => Promise.resolve(JSON.stringify({ jsonrpc: '2.0', id: body.id, result: '0x5' })) }); + } + if (body.method === 'eth_getCode') { + return Promise.resolve({ text: () => Promise.resolve(JSON.stringify({ jsonrpc: '2.0', id: body.id, result: '0x6080604052' })) }); + } + if (body.method === 'eth_call') { + return Promise.resolve({ text: () => Promise.resolve(JSON.stringify({ jsonrpc: '2.0', id: body.id, result: '0x' })) }); + } + if (body.method === 'eth_getTransactionReceipt') { + // A misbehaving/compromised broadcaster: the receipt "confirms" for + // ANY hash we ask it about, including one we never signed. + return Promise.resolve({ text: () => Promise.resolve(JSON.stringify({ jsonrpc: '2.0', id: body.id, result: { status: '0x1', blockNumber: '0x100' } })) }); + } + if (urlStr.includes('trading-api') && urlStr.endsWith('/execute')) { + executeBodies.push(body); + return Promise.resolve({ + ok: true, + text: () => Promise.resolve(JSON.stringify({ status: 'Success', txHash: wrongHash, chainType: 'evm', broadcaster: 'test' })), + }); + } + return Promise.resolve({ text: () => Promise.resolve(JSON.stringify({ jsonrpc: '2.0', id: body.id || 1, result: null })) }); + })); + + const quoteId = saveQuote({ + success: true, + quotes: [ + { + aggregator: 'lifi', + inputMint: BASE_USDC, + outputMint: OUT_TOKEN, + inAmount: '10000000', + outAmount: '50000000', + approvalAddress: '', // no approval needed — go straight to the swap broadcast + transaction: { to: LIFI_ROUTER, data: '0x12345678', value: '0', gas: '300000', maxFeePerGas: '5000000', maxPriorityFeePerGas: '1000000' }, + }, + { + aggregator: 'lifi', + inputMint: BASE_USDC, + outputMint: OUT_TOKEN, + inAmount: '10000000', + outAmount: '50000000', + approvalAddress: '', + transaction: { to: LIFI_ROUTER, data: '0x87654321', value: '0', gas: '300000', maxFeePerGas: '5000000', maxPriorityFeePerGas: '1000000' }, + }, + ], + }, 'base', 'local', null, null, { + swapMode: 'exactIn', + request: evmIntent({ walletAddress: showWallet('default').evm, fromToken: BASE_USDC, toToken: OUT_TOKEN, amount: '10000000', maxInputAmount: '10000000' }), + }); + + const logs = []; + const cmds = buildTradingCommands({ log: (m) => logs.push(m), exit: () => {} }); + await expect(cmds.execute([], null, {}, { quote: quoteId })) + .rejects.toMatchObject({ code: 'TXHASH_MISMATCH' }); + + // TXHASH_MISMATCH is a broadcaster-integrity failure, not a bad quote: + // fail closed immediately instead of trying the next quote. + expect(executeBodies).toHaveLength(1); + expect(logs.some(l => l.includes('Trying next quote'))).toBe(false); + expect(logs.some(l => l.includes('Transaction successful'))).toBe(false); + + delete process.env.NANSEN_WALLET_PASSWORD; + vi.unstubAllGlobals(); + }); + + it('main EVM swap polls and logs the locally-derived hash when /execute returns no txHash', async () => { + createWallet('default', 'testpass'); + process.env.NANSEN_WALLET_PASSWORD = 'testpass'; + + const executeBodies = []; + const queriedHashes = []; + vi.stubGlobal('fetch', vi.fn().mockImplementation((url, opts) => { + const urlStr = typeof url === 'string' ? url : url.toString(); + const body = opts?.body ? (() => { try { return JSON.parse(opts.body); } catch { return {}; } })() : {}; + if (body.method === 'eth_getTransactionCount') { + return Promise.resolve({ text: () => Promise.resolve(JSON.stringify({ jsonrpc: '2.0', id: body.id, result: '0x5' })) }); + } + if (body.method === 'eth_getCode') { + return Promise.resolve({ text: () => Promise.resolve(JSON.stringify({ jsonrpc: '2.0', id: body.id, result: '0x6080604052' })) }); + } + if (body.method === 'eth_call') { + return Promise.resolve({ text: () => Promise.resolve(JSON.stringify({ jsonrpc: '2.0', id: body.id, result: '0x' })) }); + } + if (body.method === 'eth_getTransactionReceipt') { + const asked = body.params[0]; + queriedHashes.push(asked); + const localHash = executeBodies[0]?.signedTransaction + ? evmTxHash(executeBodies[0].signedTransaction) + : null; + return Promise.resolve({ text: () => Promise.resolve(JSON.stringify({ + jsonrpc: '2.0', + id: body.id, + result: localHash && asked.toLowerCase() === localHash.toLowerCase() + ? { status: '0x1', blockNumber: '0x100' } + : null, + })) }); + } + if (urlStr.includes('trading-api') && urlStr.endsWith('/execute')) { + executeBodies.push(body); + return Promise.resolve({ + ok: true, + text: () => Promise.resolve(JSON.stringify({ status: 'Success', chainType: 'evm', broadcaster: 'test' })), + }); + } + return Promise.resolve({ text: () => Promise.resolve(JSON.stringify({ jsonrpc: '2.0', id: body.id || 1, result: null })) }); + })); + + const quoteId = saveQuote({ + success: true, + quotes: [{ + aggregator: 'lifi', + inputMint: BASE_USDC, + outputMint: OUT_TOKEN, + inAmount: '10000000', + outAmount: '50000000', + approvalAddress: '', + transaction: { to: LIFI_ROUTER, data: '0x12345678', value: '0', gas: '300000', maxFeePerGas: '5000000', maxPriorityFeePerGas: '1000000' }, + }], + }, 'base', 'local', null, null, { + swapMode: 'exactIn', + request: evmIntent({ walletAddress: showWallet('default').evm, fromToken: BASE_USDC, toToken: OUT_TOKEN, amount: '10000000', maxInputAmount: '10000000' }), + }); + + const logs = []; + const cmds = buildTradingCommands({ log: (m) => logs.push(m), exit: () => {} }); + await cmds.execute([], null, {}, { quote: quoteId }); + + const localHash = evmTxHash(executeBodies[0].signedTransaction); + expect(queriedHashes.length).toBeGreaterThan(0); + expect(queriedHashes.every(h => h.toLowerCase() === localHash.toLowerCase())).toBe(true); + expect(logs.some(l => l.includes(`Tx Hash: ${localHash}`))).toBe(true); + expect(logs.some(l => l.includes(`${resolveChain('base').explorer}${localHash}`))).toBe(true); + expect(logs.some(l => l.includes('Tx Hash: undefined'))).toBe(false); + expect(logs.some(l => l.includes(`${resolveChain('base').explorer}undefined`))).toBe(false); + + delete process.env.NANSEN_WALLET_PASSWORD; + vi.unstubAllGlobals(); + }); + + it('confirms a gasless swap on the broadcaster hash without a mismatch error', async () => { + createWallet('default', 'testpass'); + process.env.NANSEN_WALLET_PASSWORD = 'testpass'; + + const executeBodies = []; + // Legitimate for gasless: the Relay solver broadcasts its OWN tx, so this + // is never evmTxHash(signedTransaction) — must NOT trip TXHASH_MISMATCH. + const solverHash = '0x' + 'ab'.repeat(32); + vi.stubGlobal('fetch', vi.fn().mockImplementation((url, opts) => { + const urlStr = typeof url === 'string' ? url : url.toString(); + const body = opts?.body ? (() => { try { return JSON.parse(opts.body); } catch { return {}; } })() : {}; + if (body.method === 'eth_getTransactionCount') { + return Promise.resolve({ text: () => Promise.resolve(JSON.stringify({ jsonrpc: '2.0', id: body.id, result: '0x5' })) }); + } + if (body.method === 'eth_getCode') { + return Promise.resolve({ text: () => Promise.resolve(JSON.stringify({ jsonrpc: '2.0', id: body.id, result: '0x6080604052' })) }); + } + if (body.method === 'eth_call') { + return Promise.resolve({ text: () => Promise.resolve(JSON.stringify({ jsonrpc: '2.0', id: body.id, result: '0x' })) }); + } + if (body.method === 'eth_getTransactionReceipt') { + return Promise.resolve({ text: () => Promise.resolve(JSON.stringify({ jsonrpc: '2.0', id: body.id, result: { status: '0x1', blockNumber: '0x100' } })) }); + } + if (urlStr.includes('trading-api') && urlStr.endsWith('/execute')) { + executeBodies.push(body); + return Promise.resolve({ + ok: true, + text: () => Promise.resolve(JSON.stringify({ status: 'Success', txHash: solverHash, chainType: 'evm', broadcaster: 'relay' })), + }); + } + return Promise.resolve({ text: () => Promise.resolve(JSON.stringify({ jsonrpc: '2.0', id: body.id || 1, result: null })) }); + })); + + const quoteId = saveQuote({ + success: true, + quotes: [{ + aggregator: 'relay', + inputMint: BASE_USDC, + outputMint: OUT_TOKEN, + inAmount: '10000000', + outAmount: '50000000', + approvalAddress: '', + transaction: { to: RELAY_ROUTER, data: '0x12345678', value: '0', gas: '300000', maxFeePerGas: '5000000', maxPriorityFeePerGas: '1000000' }, + metadata: { requestId: 'relay-gasless-req', steps: [{ kind: 'evm-tx' }] }, + }], + }, 'base', 'local', null, null, { + swapMode: 'exactIn', + request: evmIntent({ walletAddress: showWallet('default').evm, fromToken: BASE_USDC, toToken: OUT_TOKEN, amount: '10000000', maxInputAmount: '10000000' }), + }); + + const logs = []; + const cmds = buildTradingCommands({ log: (m) => logs.push(m), exit: () => {} }); + await cmds.execute([], null, { gasless: true }, { quote: quoteId }); + + expect(executeBodies).toHaveLength(1); + expect(logs.some(l => l.includes('Transaction successful'))).toBe(true); + + delete process.env.NANSEN_WALLET_PASSWORD; + vi.unstubAllGlobals(); + }); + + it('polls OUR locally-derived hash — not the broadcaster hash — when the broadcaster returns none (guarantee #2)', async () => { + // A signed tx whose bytes are ours; the broadcaster returns no hash at all. + const signedTx = '0x02' + 'ab'.repeat(96); + const localHash = evmTxHash(signedTx); + // A tx we never signed: it HAS a valid receipt on-chain. If confirmEvmBroadcast + // polled anything other than our own hash, it could confirm this one by mistake. + const foreignHash = '0x' + 'cd'.repeat(32); + + const queriedHashes = []; + vi.stubGlobal('fetch', vi.fn().mockImplementation((url, opts) => { + const body = opts?.body ? (() => { try { return JSON.parse(opts.body); } catch { return {}; } })() : {}; + if (body.method === 'eth_getTransactionReceipt') { + const asked = body.params[0]; + queriedHashes.push(asked); + // ONLY our locally-derived hash has a receipt. The foreign hash also has + // one, to prove we never fall back to querying it. + const known = asked.toLowerCase() === localHash.toLowerCase() + || asked.toLowerCase() === foreignHash.toLowerCase(); + return Promise.resolve({ text: () => Promise.resolve(JSON.stringify({ + jsonrpc: '2.0', id: body.id, result: known ? { status: '0x1', blockNumber: '0x100' } : null, + })) }); + } + return Promise.resolve({ text: () => Promise.resolve(JSON.stringify({ jsonrpc: '2.0', id: body.id || 1, result: null })) }); + })); + + // No broadcaster hash supplied — guarantee #2 path. + const { receipt, hash } = await confirmEvmBroadcast('base', signedTx, undefined); + expect(parseInt(receipt.blockNumber, 16)).toBe(256); + // The confirmed-against hash returned to callers is OUR local hash (so the + // success log shows the tx we actually verified, not the broadcaster's). + expect(hash.toLowerCase()).toBe(localHash.toLowerCase()); + // Every receipt poll was for OUR hash; the foreign hash was never queried. + expect(queriedHashes.length).toBeGreaterThan(0); + expect(queriedHashes.every(h => h.toLowerCase() === localHash.toLowerCase())).toBe(true); + + vi.unstubAllGlobals(); + }); + + it('does not raise a false TXHASH_MISMATCH when the broadcaster reports a bare (0x-less) hash', async () => { + // evmTxHash always emits a 0x-prefixed hash, but a broadcaster may legitimately + // report the same hash without the prefix. A prefix-sensitive comparison would + // hard-abort (TXHASH_MISMATCH is fatal) on a transaction we did in fact sign. + const signedTx = '0x02' + 'ab'.repeat(96); + const localHash = evmTxHash(signedTx); + const bareHash = localHash.replace(/^0x/, ''); // same hash, no prefix + + vi.stubGlobal('fetch', vi.fn().mockImplementation((url, opts) => { + const body = opts?.body ? (() => { try { return JSON.parse(opts.body); } catch { return {}; } })() : {}; + if (body.method === 'eth_getTransactionReceipt') { + const asked = body.params[0]; + const known = asked.toLowerCase() === localHash.toLowerCase(); + return Promise.resolve({ text: () => Promise.resolve(JSON.stringify({ + jsonrpc: '2.0', id: body.id, result: known ? { status: '0x1', blockNumber: '0x100' } : null, + })) }); + } + return Promise.resolve({ text: () => Promise.resolve(JSON.stringify({ jsonrpc: '2.0', id: body.id || 1, result: null })) }); + })); + + // The bare-hex hash matches once prefixes are normalized — no throw, and the + // confirmed-against hash returned to callers is still OUR 0x-prefixed hash. + const { hash } = await confirmEvmBroadcast('base', signedTx, bareHash); + expect(hash).toBe(localHash); + + vi.unstubAllGlobals(); + }); + + it('tags a receipt-not-found timeout with code RECEIPT_TIMEOUT (distinct from a confirmed revert)', async () => { + // Small timeout so the poll loop exhausts quickly. A never-found receipt must + // surface as RECEIPT_TIMEOUT, NOT the "Transaction reverted" error — the two + // are treated differently by the swap path (timeout = pending, do not retry). + vi.stubGlobal('fetch', vi.fn().mockImplementation((url, opts) => { + const body = opts?.body ? (() => { try { return JSON.parse(opts.body); } catch { return {}; } })() : {}; + // Receipt is never available. + return Promise.resolve({ text: () => Promise.resolve(JSON.stringify({ jsonrpc: '2.0', id: body.id || 1, result: null })) }); + })); + + await expect(waitForReceipt('base', '0x' + '11'.repeat(32), 20, 5)) + .rejects.toMatchObject({ code: 'RECEIPT_TIMEOUT' }); + + vi.unstubAllGlobals(); + }); + + it('aborts the whole swap on a receipt timeout — never retries the next quote (no duplicate broadcast)', async () => { + createWallet('default', 'testpass'); + process.env.NANSEN_WALLET_PASSWORD = 'testpass'; + + const executeBodies = []; + vi.stubGlobal('fetch', vi.fn().mockImplementation((url, opts) => { + const urlStr = typeof url === 'string' ? url : url.toString(); + const body = opts?.body ? (() => { try { return JSON.parse(opts.body); } catch { return {}; } })() : {}; + if (body.method === 'eth_getTransactionCount') { + return Promise.resolve({ text: () => Promise.resolve(JSON.stringify({ jsonrpc: '2.0', id: body.id, result: '0x5' })) }); + } + if (body.method === 'eth_getCode') { + return Promise.resolve({ text: () => Promise.resolve(JSON.stringify({ jsonrpc: '2.0', id: body.id, result: '0x6080604052' })) }); + } + if (body.method === 'eth_call') { + return Promise.resolve({ text: () => Promise.resolve(JSON.stringify({ jsonrpc: '2.0', id: body.id, result: '0x' })) }); + } + if (body.method === 'eth_getTransactionReceipt') { + // Broadcast succeeded but the receipt never lands — a pending tx, NOT a + // confirmed revert. Retrying would broadcast a second swap on the same nonce. + return Promise.resolve({ text: () => Promise.resolve(JSON.stringify({ jsonrpc: '2.0', id: body.id, result: null })) }); + } + if (urlStr.includes('trading-api') && urlStr.endsWith('/execute')) { + executeBodies.push(body); + return Promise.resolve({ + ok: true, + text: () => Promise.resolve(JSON.stringify({ status: 'Success', txHash: evmTxHash(body.signedTransaction), chainType: 'evm', broadcaster: 'test' })), + }); + } + return Promise.resolve({ text: () => Promise.resolve(JSON.stringify({ jsonrpc: '2.0', id: body.id || 1, result: null })) }); + })); + + // Two quotes: a swallowed timeout would visibly fall through and broadcast the second. + const quoteId = saveQuote({ + success: true, + quotes: [ + { + aggregator: 'lifi', inputMint: BASE_USDC, outputMint: OUT_TOKEN, + inAmount: '10000000', outAmount: '50000000', approvalAddress: '', + transaction: { to: LIFI_ROUTER, data: '0x12345678', value: '0', gas: '300000', maxFeePerGas: '5000000', maxPriorityFeePerGas: '1000000' }, + }, + { + aggregator: 'lifi', inputMint: BASE_USDC, outputMint: OUT_TOKEN, + inAmount: '10000000', outAmount: '50000000', approvalAddress: '', + transaction: { to: LIFI_ROUTER, data: '0x87654321', value: '0', gas: '300000', maxFeePerGas: '5000000', maxPriorityFeePerGas: '1000000' }, + }, + ], + }, 'base', 'local', null, null, { + swapMode: 'exactIn', + request: evmIntent({ walletAddress: showWallet('default').evm, fromToken: BASE_USDC, toToken: OUT_TOKEN, amount: '10000000', maxInputAmount: '10000000' }), + }); + + const logs = []; + const cmds = buildTradingCommands({ log: (m) => logs.push(m), exit: () => {} }); + + // Shrink the receipt-poll window so the timeout fires fast instead of at 180s. + vi.useFakeTimers(); + const p = cmds.execute([], null, {}, { quote: quoteId }); + const settle = expect(p).rejects.toMatchObject({ code: 'RECEIPT_TIMEOUT' }); + await vi.advanceTimersByTimeAsync(200000); // past the 180s waitForReceipt window + await settle; + vi.useRealTimers(); + + // Exactly one broadcast — the timeout aborted rather than trying the next quote. + expect(executeBodies).toHaveLength(1); + expect(logs.some(l => l.includes('Trying next quote'))).toBe(false); + // A timeout is not a revert: the misleading "REVERTED on-chain" banner must not appear. + expect(logs.some(l => l.includes('REVERTED on-chain'))).toBe(false); + expect(logs.some(l => l.includes('Transaction successful'))).toBe(false); + + delete process.env.NANSEN_WALLET_PASSWORD; + vi.unstubAllGlobals(); + }); +}); + describe('Swap target validation blocks a poisoned quote (security hardening)', () => { it('does not broadcast when the swap target is an EOA (no contract code)', async () => { createWallet('default', 'testpass'); @@ -3501,7 +3986,11 @@ describe('ERC-20 excessive allowance handling', () => { if (urlStr.includes('trading-api') && urlStr.endsWith('/execute')) { const response = executeResponses[executeBodies.length] ?? executeResponses.at(-1); executeBodies.push(body); - sequence.push({ type: 'execute', txHash: response.txHash, signedTransaction: body.signedTransaction }); + // Echo the real hash of the signed bytes, like a correct broadcaster would. + const txHash = response.status === 'Success' && body.signedTransaction + ? evmTxHash(body.signedTransaction) + : response.txHash; + sequence.push({ type: 'execute', txHash, signedTransaction: body.signedTransaction }); const selectorIdx = body.signedTransaction?.indexOf(approveSelector); if (response.status === 'Success' && selectorIdx >= 0) { const amountStart = selectorIdx + approveSelector.length + 64; @@ -3509,7 +3998,7 @@ describe('ERC-20 excessive allowance handling', () => { } return Promise.resolve({ ok: true, - text: () => Promise.resolve(JSON.stringify(response)), + text: () => Promise.resolve(JSON.stringify({ ...response, txHash })), }); } @@ -3570,18 +4059,67 @@ describe('ERC-20 excessive allowance handling', () => { expect(executeBodies[1].signedTransaction).toContain(approveSelector); expect(executeBodies[1].signedTransaction).toContain(amountWord(100000n)); expect(executeBodies[2].signedTransaction).not.toContain(approveSelector); + const [revokeHash, approvalHash, swapHash] = executeBodies.map(b => evmTxHash(b.signedTransaction)); expect(sequence.map(e => `${e.type}:${e.txHash}`)).toEqual([ - 'execute:0xRevokeHash', - 'receipt:0xRevokeHash', - 'execute:0xApprovalHash', - 'receipt:0xApprovalHash', - 'execute:0xSwapHash', - 'receipt:0xSwapHash', + `execute:${revokeHash}`, + `receipt:${revokeHash}`, + `execute:${approvalHash}`, + `receipt:${approvalHash}`, + `execute:${swapHash}`, + `receipt:${swapHash}`, ]); expect(logs.some(l => l.includes('Existing allowance (2000000)'))).toBe(true); expect(logs.some(l => l.includes('Allowance revoked in block'))).toBe(true); }); + it('aborts (does not reapprove, swap, or try the next quote) when the revoke receipt times out', async () => { + // A revoke broadcast whose receipt never lands is uncertain post-broadcast + // state, NOT a confirmed failure. Retrying (reapprove/swap/next quote) risks a + // duplicate broadcast, so a RECEIPT_TIMEOUT on the revoke must fail closed. + createWallet('default', 'testpass'); + process.env.NANSEN_WALLET_PASSWORD = 'testpass'; + const walletAddress = showWallet('default').evm; + const quoteId = saveLocalErc20Quote(walletAddress); + + const executeBodies = []; + vi.stubGlobal('fetch', vi.fn().mockImplementation((url, opts) => { + const urlStr = typeof url === 'string' ? url : url.toString(); + const body = opts?.body ? JSON.parse(opts.body) : {}; + if (urlStr.includes('trading-api') && urlStr.endsWith('/execute')) { + executeBodies.push(body); + // Correct broadcaster: echo the real hash of the signed bytes. + return Promise.resolve({ ok: true, text: () => Promise.resolve(JSON.stringify({ status: 'Success', txHash: evmTxHash(body.signedTransaction), chainType: 'evm', broadcaster: 'test' })) }); + } + if (body.method === 'eth_getCode') return Promise.resolve({ text: () => Promise.resolve(JSON.stringify({ jsonrpc: '2.0', id: body.id, result: '0x6080604052' })) }); + if (body.method === 'eth_getTransactionCount') return Promise.resolve({ text: () => Promise.resolve(JSON.stringify({ jsonrpc: '2.0', id: body.id, result: '0x5' })) }); + // Oversized existing allowance (2 USDC) → triggers revoke-then-reapprove. + if (body.method === 'eth_call') { + const data = body.params?.[0]?.data || ''; + return Promise.resolve({ text: () => Promise.resolve(JSON.stringify({ jsonrpc: '2.0', id: body.id, result: data.startsWith('0xdd62ed3e') ? hexResult(2000000n) : '0x' })) }); + } + // The revoke's receipt NEVER lands → waitForReceipt times out. + if (body.method === 'eth_getTransactionReceipt') return Promise.resolve({ text: () => Promise.resolve(JSON.stringify({ jsonrpc: '2.0', id: body.id, result: null })) }); + return Promise.resolve({ ok: true, text: () => Promise.resolve(JSON.stringify({ jsonrpc: '2.0', id: body.id || 1, result: null })) }); + })); + + const logs = []; + const cmds = buildTradingCommands({ log: (m) => logs.push(m), exit: () => {} }); + + vi.useFakeTimers(); + const p = cmds.execute([], null, { 'no-simulate': true }, { quote: quoteId }); + const settle = expect(p).rejects.toMatchObject({ code: 'RECEIPT_TIMEOUT' }); + await vi.advanceTimersByTimeAsync(200000); // past the 180s waitForReceipt window + await settle; + vi.useRealTimers(); + + // Only the revoke was broadcast — no reapproval, no swap, no next quote. + expect(executeBodies).toHaveLength(1); + expect(executeBodies[0].signedTransaction).toContain(approveSelector); + expect(executeBodies[0].signedTransaction).toContain(amountWord(0n)); // the revoke (approve to 0) + expect(logs.some(l => l.includes('Trying next quote'))).toBe(false); + expect(logs.some(l => l.includes('Transaction successful'))).toBe(false); + }); + it('fails closed when reapproval fails after a successful revoke', async () => { createWallet('default', 'testpass'); process.env.NANSEN_WALLET_PASSWORD = 'testpass'; @@ -3618,7 +4156,7 @@ describe('ERC-20 excessive allowance handling', () => { executeBodies.push(body); return Promise.resolve({ ok: true, - text: () => Promise.resolve(JSON.stringify({ status: 'Success', txHash: '0xRevokeHash', chainType: 'evm', broadcaster: 'test' })), + text: () => Promise.resolve(JSON.stringify({ status: 'Success', txHash: evmTxHash(body.signedTransaction), chainType: 'evm', broadcaster: 'test' })), }); } if (body.method === 'eth_getCode') { @@ -3664,7 +4202,7 @@ describe('ERC-20 excessive allowance handling', () => { ok: true, text: () => Promise.resolve(JSON.stringify({ status: 'Success', - txHash: executeCount === 1 ? '0xRevokeHash' : '0xApprovalHash', + txHash: evmTxHash(body.signedTransaction), chainType: 'evm', broadcaster: 'test', })), @@ -3709,7 +4247,7 @@ describe('ERC-20 excessive allowance handling', () => { executeBodies.push(body); return Promise.resolve({ ok: true, - text: () => Promise.resolve(JSON.stringify({ status: 'Success', txHash: '0xRevokeHash', chainType: 'evm', broadcaster: 'test' })), + text: () => Promise.resolve(JSON.stringify({ status: 'Success', txHash: evmTxHash(body.signedTransaction), chainType: 'evm', broadcaster: 'test' })), }); } if (body.method === 'eth_getCode') { diff --git a/src/trading.js b/src/trading.js index 3506462a..2caa4229 100644 --- a/src/trading.js +++ b/src/trading.js @@ -565,6 +565,30 @@ export function signEvmTransaction(txData, privateKeyHex, chain, nonce) { return signLegacyTransaction({ ...common, gasPrice: toHex(txData.gasPrice) }, privateKeyHex); } +/** + * Canonical EVM transaction hash: keccak256 over the raw signed tx bytes. + * + * Works for legacy (RLP) and typed (0x02-prefixed EIP-1559) transactions alike, + * because the tx hash is defined over exactly the bytes that get broadcast. + * + * NB: this is NOT the signing hash. signEvmTransaction/signLegacyTransaction hash + * the *unsigned* payload to produce the message that gets signed; this hashes the + * fully *signed* transaction to produce its on-chain identifier. + * + * @param {string} signedTxHex - 0x-prefixed (or bare) hex of the signed transaction + * @returns {string} 0x-prefixed transaction hash + */ +export function evmTxHash(signedTxHex) { + if (typeof signedTxHex !== 'string') { + throw new Error('evmTxHash: signed transaction must be a hex string'); + } + const hex = signedTxHex.startsWith('0x') ? signedTxHex.slice(2) : signedTxHex; + if (hex.length === 0 || hex.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(hex)) { + throw new Error('evmTxHash: signed transaction is not valid hex'); + } + return '0x' + keccak256(Buffer.from(hex, 'hex')).toString('hex'); +} + // How many queued-but-unmined transactions we are willing to sign past. // // `pending` counts mempool-queued transactions as well as mined ones, and that is @@ -658,7 +682,108 @@ export async function waitForReceipt(chain, txHash, timeoutMs = 180000, pollMs = // Receipt not yet available — wait and retry await new Promise(r => setTimeout(r, pollMs)); } - throw new Error(`Transaction receipt not found after ${timeoutMs}ms. Tx: ${txHash}`); + // A timeout is NOT a confirmed revert: the tx may still be pending under our + // nonce. Tag it so callers can distinguish "reverted" (safe to try the next + // quote) from "unconfirmed" (retrying may broadcast a second tx that races + // the first for the same nonce). See the swap-path receipt catch. + const timeoutErr = new Error(`Transaction receipt not found after ${timeoutMs}ms. Tx: ${txHash}`); + timeoutErr.code = 'RECEIPT_TIMEOUT'; + throw timeoutErr; +} + +/** + * Post-broadcast failures that must abort the whole `execute` rather than fall + * through to the next quote. Once a transaction is broadcast we hold no evidence + * about what landed on-chain, so "try the next quote" would sign and broadcast a + * second transaction — the one thing we must not do. Covers every path (swap, + * approval, revoke; Privy/WalletConnect/local-key). Each code is thrown with a + * rationale at its throw site: + * - TXHASH_MISMATCH — broadcaster reported a tx we did not sign + * - INVALID_SIGNED_TX — we cannot even derive a hash for what we broadcast + * - RECEIPT_TIMEOUT — receipt never landed; the tx may still be pending, so + * retrying would race a second tx against the same nonce + * (a confirmed on-chain revert is NOT this — it may retry) + * + * @param {Error} err + * @returns {boolean} + */ +function isFatalBroadcastError(err) { + return err?.code === 'TXHASH_MISMATCH' + || err?.code === 'INVALID_SIGNED_TX' + || err?.code === 'RECEIPT_TIMEOUT'; +} + +/** + * Assert the broadcaster reported the transaction we actually signed, and return + * our locally-derived hash. + * + * Fails closed (TXHASH_MISMATCH) when the broadcaster's returned hash differs + * from keccak256 of our signed bytes: a mismatch means its receipt would confirm + * a transaction we never signed, so nothing has been verified. When the + * broadcaster returns no hash we cannot compare, so the returned local hash is + * what callers must poll for a receipt — a substituted transaction then times + * out rather than falsely confirming. + * + * @param {string} signedTxHex - the raw signed tx we sent to /execute + * @param {string} broadcasterTxHash - the txHash /execute returned (may be empty) + * @param {string} [label] - describes the tx for the error, e.g. "allowance-revoke" + * @returns {string} our locally-derived transaction hash + */ +function assertTxHashMatch(signedTxHex, broadcasterTxHash, label = '') { + const what = label ? `the ${label} transaction this CLI signed` : 'the transaction this CLI signed'; + // A derivation failure here happens AFTER the tx was broadcast, so it must be + // fatal (INVALID_SIGNED_TX), never swallowed into "try the next quote": we + // hold no hash for the transaction we just sent. + let localHash; + try { + localHash = evmTxHash(signedTxHex); + } catch (hashErr) { + throw new CommandError( + `Aborting: cannot derive a local hash for ${what}: ${hashErr.message}. ` + + `The transaction may already have been broadcast, so nothing further will run — ` + + `check your wallet before retrying.`, + 'INVALID_SIGNED_TX', + ); + } + // Normalize both sides through the same bare-hex form before comparing. + // evmTxHash always emits 0x-prefixed, but a broadcaster may report bare hex; + // comparing 0x-prefixed against bare would be a false mismatch on the prefix + // alone — and TXHASH_MISMATCH is fatal, so that would wrongly abort. + if (broadcasterTxHash) { + const norm = h => h.toLowerCase().replace(/^0x/, ''); + if (norm(localHash) !== norm(broadcasterTxHash)) { + throw new CommandError( + `Aborting: the broadcaster reported transaction ${broadcasterTxHash}, but ${what} ` + + `hashes to ${localHash}. These must match — a mismatch means the receipt would confirm ` + + `a transaction you did not sign, so nothing has been verified and no further steps will ` + + `run. Check both hashes on a block explorer to see what was actually broadcast before retrying.`, + 'TXHASH_MISMATCH', + ); + } + } + return localHash; +} + +/** + * Confirm a broadcast EVM transaction against the hash we derived locally from + * the signed bytes — not the hash the broadcaster reported. See + * {@link assertTxHashMatch} for the two guarantees (fail closed on mismatch; + * poll our own hash so a silent substitution times out rather than confirms). + * + * @param {string} chain + * @param {string} signedTxHex - the raw signed tx we sent to /execute + * @param {string} broadcasterTxHash - the txHash /execute returned + * @param {string} [label] - describes the tx for a mismatch error, e.g. + * "allowance-revoke" — the least useful moment to lose context is a revoke + * mismatch with the allowance sitting at 0, so callers should pass it + * @returns {Promise<{receipt: object, hash: string}>} the receipt and the + * locally-derived hash it was confirmed against (log THIS, not the + * broadcaster's hash — it is the transaction we actually verified landed) + */ +export async function confirmEvmBroadcast(chain, signedTxHex, broadcasterTxHash, label = '') { + const hash = assertTxHashMatch(signedTxHex, broadcasterTxHash, label); + const receipt = await waitForReceipt(chain, hash); + return { receipt, hash }; } /** @@ -2254,9 +2379,10 @@ EXAMPLES: } log(` Waiting for allowance revoke confirmation...`); try { - const receipt = await waitForReceipt(chain, revokeResult.txHash); - log(` ✓ Allowance revoked in block ${parseInt(receipt.blockNumber, 16)}: ${revokeResult.txHash}`); + const { receipt, hash: revokeHash } = await confirmEvmBroadcast(chain, signedRevoke, revokeResult.txHash, 'allowance-revoke'); + log(` ✓ Allowance revoked in block ${parseInt(receipt.blockNumber, 16)}: ${revokeHash}`); } catch (receiptErr) { + if (isFatalBroadcastError(receiptErr)) throw receiptErr; log(` ❌ Allowance revoke may not have confirmed for ${quoteName}: ${receiptErr.message}.${allowanceRevokeRecoveryHint(revokeResult.txHash)}`); if (qi + 1 < endIndex) log(` Trying next quote...`); lastQuoteError = `${quoteName} allowance revoke unconfirmed`; @@ -2312,9 +2438,10 @@ EXAMPLES: } log(` Waiting for approval confirmation...`); try { - const receipt = await waitForReceipt(chain, approvalResult.txHash); - log(` ✓ Approval confirmed in block ${parseInt(receipt.blockNumber, 16)}: ${approvalResult.txHash}`); + const { receipt, hash: approvalHash } = await confirmEvmBroadcast(chain, signedApproval, approvalResult.txHash, 'allowance-approval'); + log(` ✓ Approval confirmed in block ${parseInt(receipt.blockNumber, 16)}: ${approvalHash}`); } catch (receiptErr) { + if (isFatalBroadcastError(receiptErr)) throw receiptErr; log(` ❌ Approval may not have confirmed${shouldRevoke ? ' after revoking the prior allowance (now 0)' : ''}: ${receiptErr.message}`); if (qi + 1 < endIndex) log(` Trying next quote...`); lastQuoteError = `${quoteName} approval unconfirmed`; @@ -2567,12 +2694,13 @@ EXAMPLES: if (broadcastResult.status !== 'Success') { throw new Error(broadcastResult.error || 'broadcast failed'); } - revokeTxHash = broadcastResult.txHash; + revokeTxHash = assertTxHashMatch(revokeResult.signedTransaction, broadcastResult.txHash, 'allowance-revoke'); } if (!revokeTxHash) { throw new Error('Allowance revoke returned no transaction hash and no signed transaction; cannot confirm allowance was cleared'); } } catch (revokeErr) { + if (isFatalBroadcastError(revokeErr)) throw revokeErr; log(` ❌ Allowance revoke failed for ${quoteName}: ${revokeErr.message}.${allowanceRevokeRecoveryHint(revokeTxHash)}`); if (qi + 1 < endIndex) log(` Trying next quote...`); lastQuoteError = `${quoteName} allowance revoke failed`; @@ -2583,6 +2711,7 @@ EXAMPLES: const receipt = await waitForReceipt(chain, revokeTxHash); log(` ✓ Allowance revoked in block ${parseInt(receipt.blockNumber, 16)}: ${revokeTxHash}`); } catch (receiptErr) { + if (isFatalBroadcastError(receiptErr)) throw receiptErr; log(` ❌ Allowance revoke may not have confirmed for ${quoteName}: ${receiptErr.message}.${allowanceRevokeRecoveryHint(revokeTxHash)}`); if (qi + 1 < endIndex) log(` Trying next quote...`); lastQuoteError = `${quoteName} allowance revoke unconfirmed`; @@ -2621,7 +2750,7 @@ EXAMPLES: if (broadcastResult.status !== 'Success') { throw new Error(broadcastResult.error || 'broadcast failed'); } - approvalTxHash = broadcastResult.txHash; + approvalTxHash = assertTxHashMatch(approvalResult.signedTransaction, broadcastResult.txHash, 'allowance-approval'); } if (!approvalTxHash) { // Fail closed: the wallet returned neither a hash nor a @@ -2632,6 +2761,7 @@ EXAMPLES: throw new Error('returned no transaction hash and no signed transaction; cannot confirm approval landed'); } } catch (approvalErr) { + if (isFatalBroadcastError(approvalErr)) throw approvalErr; const revokedMsg = shouldRevoke ? ' after revoking the prior allowance (now 0)' : ''; @@ -2645,6 +2775,7 @@ EXAMPLES: const receipt = await waitForReceipt(chain, approvalTxHash); log(` ✓ Approval confirmed in block ${parseInt(receipt.blockNumber, 16)}: ${approvalTxHash}`); } catch (receiptErr) { + if (isFatalBroadcastError(receiptErr)) throw receiptErr; const revokedMsg = shouldRevoke ? ' after revoking the prior allowance (now 0)' : ''; @@ -2730,6 +2861,14 @@ EXAMPLES: try { await waitForReceipt(chain, wcResult.txHash); } catch (receiptErr) { + // A timeout here is uncertain post-broadcast state, not a + // confirmed revert — fail closed rather than retry (which would + // broadcast a second swap). Applies even though this path has no + // locally-derived hash to bind to. + if (receiptErr.code === 'RECEIPT_TIMEOUT') { + 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'); + } + if (isFatalBroadcastError(receiptErr)) throw receiptErr; log(`\n ⚠ Transaction was broadcast but REVERTED on-chain!`); log(` Tx Hash: ${wcResult.txHash}`); log(` Explorer: ${chainConfig.explorer}${wcResult.txHash}`); @@ -2892,9 +3031,10 @@ EXAMPLES: log(` Waiting for allowance revoke confirmation...`); try { - const receipt = await waitForReceipt(chain, revokeResult.txHash); - log(` ✓ Allowance revoked in block ${parseInt(receipt.blockNumber, 16)}: ${revokeResult.txHash}`); + const { receipt, hash: revokeHash } = await confirmEvmBroadcast(chain, revokeTxHex, revokeResult.txHash, 'allowance-revoke'); + log(` ✓ Allowance revoked in block ${parseInt(receipt.blockNumber, 16)}: ${revokeHash}`); } catch (receiptErr) { + if (isFatalBroadcastError(receiptErr)) throw receiptErr; log(` ❌ Allowance revoke may not have confirmed for ${quoteName}: ${receiptErr.message}.${allowanceRevokeRecoveryHint(revokeResult.txHash)}`); if (qi + 1 < endIndex) log(` Trying next quote...`); lastQuoteError = `${quoteName} allowance revoke unconfirmed`; @@ -2943,9 +3083,10 @@ EXAMPLES: log(` Waiting for approval confirmation...`); try { - const receipt = await waitForReceipt(chain, approvalResult.txHash); - log(` ✓ Approval confirmed in block ${parseInt(receipt.blockNumber, 16)}: ${approvalResult.txHash}`); + const { receipt, hash: approvalHash } = await confirmEvmBroadcast(chain, approvalTxHex, approvalResult.txHash, 'allowance-approval'); + log(` ✓ Approval confirmed in block ${parseInt(receipt.blockNumber, 16)}: ${approvalHash}`); } catch (receiptErr) { + if (isFatalBroadcastError(receiptErr)) throw receiptErr; log(` ❌ Approval may not have confirmed${shouldRevoke ? ' after revoking the prior allowance (now 0)' : ''}: ${receiptErr.message}`); if (qi + 1 < endIndex) log(` Trying next quote...`); lastQuoteError = `${quoteName} approval unconfirmed`; @@ -3065,17 +3206,51 @@ EXAMPLES: const result = await executeTransaction(execParams); if (result.status === 'Success') { - const txId = result.signature || result.txHash; - const explorerUrl = chainConfig.explorer + txId; + let txId = result.signature || result.txHash; + let explorerUrl = chainConfig.explorer + txId; // For EVM: verify the tx actually succeeded on-chain - if (chainType === 'evm' && result.txHash) { + if (chainType === 'evm') { log(' Verifying on-chain status...'); + // Non-gasless: derive our local hash up front, OUTSIDE the receipt-poll + // try below. A hex-validation failure here means no poll ever ran, so it + // must surface as itself — not as the "REVERTED on-chain" diagnostic that + // catch is reserved for. (Gasless has no local hash to bind to: the Relay + // solver wraps and broadcasts its own tx, so result.txHash legitimately is + // not the hash of the bytes we signed.) + if (!gasless) { + try { + txId = evmTxHash(signedTransaction); + } catch (hashErr) { + throw new CommandError(`Cannot derive local tx hash for ${quoteName}: ${hashErr.message}`, 'INVALID_SIGNED_TX'); + } + explorerUrl = chainConfig.explorer + txId; + } try { - await waitForReceipt(chain, result.txHash); + if (gasless) { + // If the solver reported no hash there is nothing to poll — skip + // rather than block on eth_getTransactionReceipt(undefined). + if (result.txHash) await waitForReceipt(chain, result.txHash); + } else { + const { hash } = await confirmEvmBroadcast(chain, signedTransaction, result.txHash); + txId = hash; + explorerUrl = chainConfig.explorer + txId; + } } catch (receiptErr) { + // A receipt TIMEOUT is not a confirmed revert: the tx was + // broadcast and may still be pending under our nonce. Retrying + // the next quote would sign and broadcast a SECOND swap racing + // the first for that nonce — the duplicate-broadcast this PR + // exists to prevent. (It's also exactly how guarantee #2's + // silent-substitution case surfaces: a 180s timeout polling our + // own hash.) Fail closed with a clearer banner than the generic + // rethrow, then let isFatalBroadcastError handle the rest. + if (receiptErr.code === 'RECEIPT_TIMEOUT') { + 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'); + } + if (isFatalBroadcastError(receiptErr)) throw receiptErr; log(`\n ⚠ Transaction was broadcast but REVERTED on-chain!`); - log(` Tx Hash: ${result.txHash}`); + log(` Tx Hash: ${txId || result.txHash}`); log(` Explorer: ${explorerUrl}`); log(` Error: ${receiptErr.message}`); if (qi + 1 < endIndex) { @@ -3083,7 +3258,7 @@ EXAMPLES: lastQuoteError = `${quoteName} reverted on-chain`; continue; } - 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'); + 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'); } } @@ -3141,6 +3316,11 @@ EXAMPLES: } } catch (quoteErr) { + // Post-broadcast failures abort the whole execute — never retry the + // next quote once a transaction is already out and its outcome is + // unknown (mismatch, underivable local hash, or an unconfirmed + // receipt timeout). See isFatalBroadcastError. + if (isFatalBroadcastError(quoteErr)) throw quoteErr; const msg = quoteErr.message || ''; log(` ❌ Quote ${quoteName} failed: ${msg}`); if (msg.includes('AccountNotFound') && chainType === 'solana') {