From 9286a9edd6acb3401576dc9a117ff767b51a8220 Mon Sep 17 00:00:00 2001 From: TimNooren Date: Wed, 25 Feb 2026 11:40:58 +0100 Subject: [PATCH] Add EIP-1559 (type 2) transaction signing for EVM swaps signEvmTransaction() now uses type 2 signing when the quote provides maxFeePerGas, falling back to legacy type 0 for gasPrice-only quotes. This avoids overpaying on chains where the base fee drops between quoting and inclusion. Co-Authored-By: Claude Opus 4.6 --- src/__tests__/trading.test.js | 86 +++++++++++++++++++++++++++++++++++ src/trading.js | 71 +++++++++++++++++++++++++++-- 2 files changed, 154 insertions(+), 3 deletions(-) diff --git a/src/__tests__/trading.test.js b/src/__tests__/trading.test.js index afded83a..5ad3c20a 100644 --- a/src/__tests__/trading.test.js +++ b/src/__tests__/trading.test.js @@ -22,6 +22,7 @@ import { signLegacyTransaction, signSolanaTransaction, signEvmTransaction, + signEip1559EvmTransaction, buildApprovalTransaction, stripLeadingZeros, buildTradingCommands, @@ -482,6 +483,91 @@ describe('signEvmTransaction (API response format)', () => { }); }); +// ============= EIP-1559 (Type 2) Transaction Signing ============= + +describe('signEip1559EvmTransaction', () => { + it('should produce valid signed tx hex with 0x02 type prefix', () => { + const wallet = generateEvmWallet(); + const txData = { + nonce: 0, + maxFeePerGas: '30000000000', + maxPriorityFeePerGas: '1000000000', + gas: '21000', + to: '0x' + 'ab'.repeat(20), + value: '0', + data: '0x', + chainId: 8453, + }; + const signedHex = signEip1559EvmTransaction(txData, wallet.privateKey); + expect(signedHex).toMatch(/^0x02[0-9a-f]+$/); + }); + + it('should produce deterministic signatures (RFC 6979)', () => { + const wallet = generateEvmWallet(); + const txData = { + nonce: 0, + maxFeePerGas: '30000000000', + maxPriorityFeePerGas: '1000000000', + gas: '21000', + to: '0x' + 'ab'.repeat(20), + value: '0', + data: '0x', + chainId: 1, + }; + const sig1 = signEip1559EvmTransaction(txData, wallet.privateKey); + const sig2 = signEip1559EvmTransaction(txData, wallet.privateKey); + expect(sig1).toBe(sig2); + }); + + it('should produce different signed tx for different nonces', () => { + const wallet = generateEvmWallet(); + const base = { + maxFeePerGas: '30000000000', + maxPriorityFeePerGas: '1000000000', + gas: '21000', + to: '0x' + 'ab'.repeat(20), + value: '0', + data: '0x', + chainId: 8453, + }; + const sig0 = signEip1559EvmTransaction({ ...base, nonce: 0 }, wallet.privateKey); + const sig1 = signEip1559EvmTransaction({ ...base, nonce: 1 }, wallet.privateKey); + expect(sig0).not.toBe(sig1); + }); +}); + +describe('signEvmTransaction (EIP-1559 dispatch)', () => { + it('should sign as type 2 when maxFeePerGas is present', () => { + const wallet = generateEvmWallet(); + const txData = { + to: '0x' + 'ab'.repeat(20), + data: '0x', + value: '0', + gas: '21000', + maxFeePerGas: '30000000000', + maxPriorityFeePerGas: '1000000000', + }; + const signedHex = signEvmTransaction(txData, wallet.privateKey, 'base', 0); + // Type 2 transactions start with 0x02 + expect(signedHex).toMatch(/^0x02/); + }); + + it('should sign as legacy type 0 when only gasPrice is present', () => { + const wallet = generateEvmWallet(); + const txData = { + to: '0x' + 'ab'.repeat(20), + data: '0x', + value: '0', + gas: '21000', + gasPrice: '1000000000', + }; + const signedHex = signEvmTransaction(txData, wallet.privateKey, 'base', 0); + // Legacy transactions have RLP list prefix (>= 0xc0), NOT 0x02 + const firstByte = parseInt(signedHex.slice(2, 4), 16); + expect(firstByte).toBeGreaterThanOrEqual(0xc0); + }); +}); + // ============= ERC-20 Approval Transaction ============= describe('buildApprovalTransaction', () => { diff --git a/src/trading.js b/src/trading.js index e25a7153..08063def 100644 --- a/src/trading.js +++ b/src/trading.js @@ -271,16 +271,29 @@ export function signSolanaTransaction(transactionBase64, privateKeyHex) { * @returns {string} 0x-prefixed signed transaction hex */ // ⚠️ SECURITY: EVM transaction signing - requires thorough review before production use -// TODO: Always signs as legacy (type 0) transactions. Do we need EIP-1559 (type 2) support? export function signEvmTransaction(txData, privateKeyHex, chain, nonce) { const chainConfig = CHAIN_MAP[chain]; if (!chainConfig || chainConfig.type !== 'evm') { throw new Error(`Unsupported EVM chain: ${chain}`); } + // Use EIP-1559 (type 2) when the quote provides maxFeePerGas + if (txData.maxFeePerGas) { + return signEip1559EvmTransaction({ + nonce, + maxFeePerGas: txData.maxFeePerGas, + maxPriorityFeePerGas: txData.maxPriorityFeePerGas || '0', + gas: txData.gas || txData.gasLimit || '210000', + to: txData.to, + value: txData.value || '0', + data: txData.data || '0x', + chainId: chainConfig.chainId, + }, privateKeyHex); + } + const tx = { nonce, - gasPrice: toHex(txData.gasPrice || txData.maxFeePerGas || '1'), + gasPrice: toHex(txData.gasPrice || '1'), gasLimit: toHex(txData.gas || txData.gasLimit || '210000'), to: txData.to, value: toHex(txData.value || '0'), @@ -482,6 +495,52 @@ export function buildApprovalTransaction(tokenAddress, spenderAddress, privateKe return signLegacyTransaction(tx, privateKeyHex); } +// ============= EIP-1559 (Type 2) EVM Transaction Signing ============= + +/** + * Sign an EIP-1559 (type 2) EVM transaction. + * + * @param {object} txData - { nonce, maxFeePerGas, maxPriorityFeePerGas, gas, to, value, data, chainId } + * @param {string} privateKeyHex - 32-byte private key as hex + * @returns {string} 0x-prefixed signed transaction hex + */ +export function signEip1559EvmTransaction(txData, privateKeyHex) { + const chainId = BigInt(txData.chainId); + const nonce = BigInt(txData.nonce || 0); + const maxPriorityFeePerGas = BigInt(txData.maxPriorityFeePerGas || 0); + const maxFeePerGas = BigInt(txData.maxFeePerGas || 0); + const gasLimit = BigInt(txData.gas || txData.gasLimit || 210000); + const value = BigInt(txData.value || 0); + + const bigIntToHex = (n) => n === 0n ? '0x' : '0x' + n.toString(16); + + const txFields = [ + bigIntToHex(chainId), + bigIntToHex(nonce), + bigIntToHex(maxPriorityFeePerGas), + bigIntToHex(maxFeePerGas), + bigIntToHex(gasLimit), + txData.to, + bigIntToHex(value), + txData.data || '0x', + [], // accessList + ]; + + const unsigned = rlpEncode(txFields); + const txHash = keccak256(Buffer.concat([Buffer.from([0x02]), unsigned])); + const sig = signSecp256k1(txHash, Buffer.from(privateKeyHex, 'hex')); + + const signed = rlpEncode([ + ...txFields, + bigIntToHex(BigInt(sig.v)), + '0x' + sig.r.toString('hex'), + '0x' + sig.s.toString('hex'), + ]); + + const rawTx = Buffer.concat([Buffer.from([0x02]), signed]); + return '0x' + rawTx.toString('hex'); +} + // ============= Legacy (Type 0) EVM Transaction Signing ============= // ⚠️ SECURITY: Legacy EVM transaction signing - requires thorough review before production use @@ -992,8 +1051,14 @@ EXAMPLES: errorOutput(` Nonce: ${nonce}`); errorOutput(' Signing EVM transaction...'); + // EIP-1559 fields are top-level on the quote, not inside .transaction + const signTxData = { ...currentQuote.transaction }; + if (currentQuote.maxFeePerGas) { + signTxData.maxFeePerGas = currentQuote.maxFeePerGas; + signTxData.maxPriorityFeePerGas = currentQuote.maxPriorityFeePerGas; + } signedTransaction = signEvmTransaction( - currentQuote.transaction, + signTxData, exported.evm.privateKey, chain, nonce