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..efd5c372 --- /dev/null +++ b/.changeset/bind-evm-receipt-to-local-hash.md @@ -0,0 +1,7 @@ +--- +"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. diff --git a/src/__tests__/trading.test.js b/src/__tests__/trading.test.js index b0e6b245..b5e08ae5 100644 --- a/src/__tests__/trading.test.js +++ b/src/__tests__/trading.test.js @@ -22,6 +22,7 @@ import { signLegacyTransaction, signSolanaTransaction, signEvmTransaction, + evmTxHash, buildApprovalTransaction, approvalAmountForSwap, approvalCapForQuote, @@ -602,6 +603,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 +1405,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 +1427,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({}) }); @@ -1492,7 +1539,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 +1565,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 +1707,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 +1725,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 +3081,130 @@ 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' }, + }], + }, '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.toThrow(/did not sign|TXHASH_MISMATCH/); + + expect(executeBodies).toHaveLength(1); + expect(logs.some(l => l.includes('Transaction successful'))).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(); + }); +}); + 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 +3674,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 +3686,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,13 +3747,14 @@ 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); @@ -3618,7 +3796,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 +3842,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 +3887,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..fb70a1b4 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 @@ -661,6 +685,38 @@ export async function waitForReceipt(chain, txHash, timeoutMs = 180000, pollMs = throw new Error(`Transaction receipt not found after ${timeoutMs}ms. Tx: ${txHash}`); } +/** + * Confirm a broadcast EVM transaction against the hash we derived locally from + * the signed bytes — not the hash the broadcaster reported. + * + * Two guarantees: + * 1. If the broadcaster's returned hash differs from ours, fail closed. A + * mismatch means the broadcaster is reporting on a transaction we did not + * sign, so we must not treat its receipt as proof our transaction landed. + * 2. We poll waitForReceipt on OUR hash, so even absent a returned hash a + * substituted transaction times out ("receipt not found") rather than + * falsely confirming. + * + * @param {string} chain + * @param {string} signedTxHex - the raw signed tx we sent to /execute + * @param {string} broadcasterTxHash - the txHash /execute returned + * @returns {Promise} the transaction receipt + */ +async function confirmEvmBroadcast(chain, signedTxHex, broadcasterTxHash) { + const localHash = evmTxHash(signedTxHex); + if (broadcasterTxHash && localHash.toLowerCase() !== broadcasterTxHash.toLowerCase()) { + throw new CommandError( + `Aborting: the broadcaster reported transaction ${broadcasterTxHash}, but the transaction ` + + `this CLI signed 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 waitForReceipt(chain, localHash); +} + /** * Simulate an EVM transaction via eth_call before broadcasting. * Returns { success: true } or { success: false, reason: string }. @@ -2254,9 +2310,10 @@ EXAMPLES: } log(` Waiting for allowance revoke confirmation...`); try { - const receipt = await waitForReceipt(chain, revokeResult.txHash); + const receipt = await confirmEvmBroadcast(chain, signedRevoke, revokeResult.txHash); log(` ✓ Allowance revoked in block ${parseInt(receipt.blockNumber, 16)}: ${revokeResult.txHash}`); } catch (receiptErr) { + if (receiptErr.code === 'TXHASH_MISMATCH') 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 +2369,10 @@ EXAMPLES: } log(` Waiting for approval confirmation...`); try { - const receipt = await waitForReceipt(chain, approvalResult.txHash); + const receipt = await confirmEvmBroadcast(chain, signedApproval, approvalResult.txHash); log(` ✓ Approval confirmed in block ${parseInt(receipt.blockNumber, 16)}: ${approvalResult.txHash}`); } catch (receiptErr) { + if (receiptErr.code === 'TXHASH_MISMATCH') 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 +2625,24 @@ EXAMPLES: if (broadcastResult.status !== 'Success') { throw new Error(broadcastResult.error || 'broadcast failed'); } - revokeTxHash = broadcastResult.txHash; + const localRevokeHash = evmTxHash(revokeResult.signedTransaction); + if (broadcastResult.txHash && localRevokeHash.toLowerCase() !== broadcastResult.txHash.toLowerCase()) { + throw new CommandError( + `Aborting: the broadcaster reported transaction ${broadcastResult.txHash}, but the ` + + `allowance-revoke transaction this CLI signed hashes to ${localRevokeHash}. 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', + ); + } + revokeTxHash = localRevokeHash; } if (!revokeTxHash) { throw new Error('Allowance revoke returned no transaction hash and no signed transaction; cannot confirm allowance was cleared'); } } catch (revokeErr) { + if (revokeErr.code === 'TXHASH_MISMATCH') 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`; @@ -2621,7 +2691,18 @@ EXAMPLES: if (broadcastResult.status !== 'Success') { throw new Error(broadcastResult.error || 'broadcast failed'); } - approvalTxHash = broadcastResult.txHash; + const localApprovalHash = evmTxHash(approvalResult.signedTransaction); + if (broadcastResult.txHash && localApprovalHash.toLowerCase() !== broadcastResult.txHash.toLowerCase()) { + throw new CommandError( + `Aborting: the broadcaster reported transaction ${broadcastResult.txHash}, but the ` + + `allowance-approval transaction this CLI signed hashes to ${localApprovalHash}. 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', + ); + } + approvalTxHash = localApprovalHash; } if (!approvalTxHash) { // Fail closed: the wallet returned neither a hash nor a @@ -2632,6 +2713,7 @@ EXAMPLES: throw new Error('returned no transaction hash and no signed transaction; cannot confirm approval landed'); } } catch (approvalErr) { + if (approvalErr.code === 'TXHASH_MISMATCH') throw approvalErr; const revokedMsg = shouldRevoke ? ' after revoking the prior allowance (now 0)' : ''; @@ -2892,9 +2974,10 @@ EXAMPLES: log(` Waiting for allowance revoke confirmation...`); try { - const receipt = await waitForReceipt(chain, revokeResult.txHash); + const receipt = await confirmEvmBroadcast(chain, revokeTxHex, revokeResult.txHash); log(` ✓ Allowance revoked in block ${parseInt(receipt.blockNumber, 16)}: ${revokeResult.txHash}`); } catch (receiptErr) { + if (receiptErr.code === 'TXHASH_MISMATCH') 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 +3026,10 @@ EXAMPLES: log(` Waiting for approval confirmation...`); try { - const receipt = await waitForReceipt(chain, approvalResult.txHash); + const receipt = await confirmEvmBroadcast(chain, approvalTxHex, approvalResult.txHash); log(` ✓ Approval confirmed in block ${parseInt(receipt.blockNumber, 16)}: ${approvalResult.txHash}`); } catch (receiptErr) { + if (receiptErr.code === 'TXHASH_MISMATCH') 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`; @@ -3072,8 +3156,16 @@ EXAMPLES: if (chainType === 'evm' && result.txHash) { log(' Verifying on-chain status...'); try { - await waitForReceipt(chain, result.txHash); + // Gasless: the Relay solver wraps and broadcasts its OWN on-chain tx, so + // result.txHash legitimately is not the hash of the bytes we signed — poll it + // directly and skip the equality check. Otherwise bind to our local hash. + if (gasless) { + await waitForReceipt(chain, result.txHash); + } else { + await confirmEvmBroadcast(chain, signedTransaction, result.txHash); + } } catch (receiptErr) { + if (receiptErr.code === 'TXHASH_MISMATCH') throw receiptErr; log(`\n ⚠ Transaction was broadcast but REVERTED on-chain!`); log(` Tx Hash: ${result.txHash}`); log(` Explorer: ${explorerUrl}`);