diff --git a/src/components/transactions/index.js b/src/components/transactions/index.js
index f885a52a..07d74fd9 100644
--- a/src/components/transactions/index.js
+++ b/src/components/transactions/index.js
@@ -3,3 +3,4 @@ export * from './erc20';
export * from './erc721';
export * from './erc1155';
export * from './send';
+export * from './swapComparison';
diff --git a/src/components/transactions/swapComparison.js b/src/components/transactions/swapComparison.js
new file mode 100644
index 00000000..9f8d09e7
--- /dev/null
+++ b/src/components/transactions/swapComparison.js
@@ -0,0 +1,447 @@
+import { ethers } from 'ethers';
+import globalContext from '../..';
+
+// Constants
+const UNIVERSAL_ROUTER = '0x66a9893cc07d91d95644aedd05d03f95e1dba8af';
+const USDC_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';
+const DEFAULT_FEE_RECIPIENT = '0x58c51ee8998e8ef06362df26a0d966bbd0cf5113';
+const DEFAULT_ETH_AMOUNT = '0.0003'; // 0.0003 ETH
+const DEFAULT_FEE_PERCENTAGE = 50; // 50%
+const EMPTY_BYTES = '0x';
+
+const Actions = {
+ SWAP_EXACT_IN_SINGLE: 0x06,
+ SETTLE_ALL: 0x0c,
+ TAKE_PORTION: 0x10,
+ TAKE_ALL: 0x0f,
+};
+
+const POOL_KEY_STRUCT =
+ '(address currency0,address currency1,uint24 fee,int24 tickSpacing,address hooks)';
+const SWAP_EXACT_IN_SINGLE_STRUCT = `(${POOL_KEY_STRUCT} poolKey,bool zeroForOne,uint128 amountIn,uint128 amountOutMinimum,bytes hookData)`;
+
+const V4_BASE_ACTIONS_ABI_DEFINITION = {
+ [Actions.SWAP_EXACT_IN_SINGLE]: [
+ {
+ name: 'swap',
+ type: SWAP_EXACT_IN_SINGLE_STRUCT,
+ },
+ ],
+ [Actions.SETTLE_ALL]: [
+ { name: 'currency', type: 'address' },
+ { name: 'maxAmount', type: 'uint256' },
+ ],
+ [Actions.TAKE_PORTION]: [
+ { name: 'currency', type: 'address' },
+ { name: 'recipient', type: 'address' },
+ { name: 'bips', type: 'uint256' },
+ ],
+ [Actions.TAKE_ALL]: [
+ { name: 'currency', type: 'address' },
+ { name: 'minAmount', type: 'uint256' },
+ ],
+};
+
+// Helper functions for input validation and parsing
+function isValidAddress(address) {
+ return ethers.utils.isAddress(address);
+}
+
+function parseEthAmount(input) {
+ const value = input.trim();
+ if (!value) {
+ return null;
+ }
+
+ try {
+ const parsed = parseFloat(value);
+ if (isNaN(parsed) || parsed < 0) {
+ return null;
+ }
+ // Convert ETH to wei and then to hex
+ return ethers.utils.parseEther(value.toString()).toHexString();
+ } catch {
+ return null;
+ }
+}
+
+function parseFeePercentage(input) {
+ const value = input.trim();
+ if (!value) {
+ return null;
+ }
+
+ try {
+ const parsed = parseFloat(value);
+ if (isNaN(parsed) || parsed < 0 || parsed > 100) {
+ return null;
+ }
+ // Convert percentage to basis points (1% = 100 basis points)
+ return Math.round(parsed * 100);
+ } catch {
+ return null;
+ }
+}
+
+function getConfigValues() {
+ const feeRecipientElement = document.getElementById('feeRecipientInput');
+ const ethAmountElement = document.getElementById('ethAmountInput');
+ const feePercentageElement = document.getElementById('feePercentageInput');
+
+ const feeRecipientInput = feeRecipientElement
+ ? feeRecipientElement.value.trim()
+ : '';
+ const ethAmountInput = ethAmountElement ? ethAmountElement.value.trim() : '';
+ const feePercentageInput = feePercentageElement
+ ? feePercentageElement.value.trim()
+ : '';
+
+ // Use defaults if inputs are empty or invalid
+ const feeRecipient =
+ feeRecipientInput && isValidAddress(feeRecipientInput)
+ ? feeRecipientInput
+ : DEFAULT_FEE_RECIPIENT;
+
+ const ethAmountHex =
+ parseEthAmount(ethAmountInput) ||
+ ethers.utils.parseEther(DEFAULT_ETH_AMOUNT).toHexString();
+
+ const feeBips =
+ parseFeePercentage(feePercentageInput) || DEFAULT_FEE_PERCENTAGE * 100;
+
+ return {
+ feeRecipient,
+ ethAmountHex,
+ feeBips,
+ ethAmountDisplay: ethAmountInput || DEFAULT_ETH_AMOUNT,
+ feePercentageDisplay:
+ feePercentageInput || DEFAULT_FEE_PERCENTAGE.toString(),
+ };
+}
+
+function createAction(action, parameters) {
+ const encodedInput = ethers.utils.defaultAbiCoder.encode(
+ V4_BASE_ACTIONS_ABI_DEFINITION[action].map((v) => v.type),
+ parameters,
+ );
+ return { action, encodedInput };
+}
+
+function addAction(type, parameters) {
+ const command = createAction(type, parameters);
+ const newParam = command.encodedInput;
+ const newAction = command.action.toString(16).padStart(2, '0');
+
+ return {
+ newParam,
+ newAction,
+ };
+}
+
+export function swapComparisonComponent(parentContainer) {
+ parentContainer.insertAdjacentHTML(
+ 'beforeend',
+ `
+
+
+
+ Swap Comparison (High Fee Test - Mainnet only)
+
+
+
+ ⚠️ This swap includes a high fee for testing purposes
+
+
+
+ Current Swap Details:
+
+ • From: ${DEFAULT_ETH_AMOUNT} ETH
+ • To: USDC
+ • Fee: ${DEFAULT_FEE_PERCENTAGE}% of output
+
+
+
+
+
+
+
+ Configuration (Optional)
+
+
+
+
+
+ Leave empty for default (${DEFAULT_ETH_AMOUNT} ETH)
+
+
+
+
+
+ Leave empty for default (${DEFAULT_FEE_PERCENTAGE}%)
+
+
+
+
+
+ Leave empty for default address
+
+
+
+
+
+
+
+
+
+ Status: Not executed
+
+
+
+ Transaction Hash: -
+
+
+
+
`,
+ );
+
+ const swapButton = document.getElementById('swapComparisonSwapButton');
+ const statusDisplay = document.getElementById('swapStatus');
+ const txHashDisplay = document.getElementById('swapTxHash');
+ const resetButton = document.getElementById('resetConfigButton');
+ const ethAmountInput = document.getElementById('ethAmountInput');
+ const feePercentageInput = document.getElementById('feePercentageInput');
+ const feeRecipientInput = document.getElementById('feeRecipientInput');
+ const displayEthAmount = document.getElementById('displayEthAmount');
+ const displayFeePercentage = document.getElementById('displayFeePercentage');
+
+ // Function to update the swap details display
+ function updateSwapDetailsDisplay() {
+ const config = getConfigValues();
+ displayEthAmount.textContent = config.ethAmountDisplay;
+ displayFeePercentage.textContent = config.feePercentageDisplay;
+ }
+
+ // Add event listeners for input changes to update display
+ ethAmountInput.addEventListener('input', updateSwapDetailsDisplay);
+ feePercentageInput.addEventListener('input', updateSwapDetailsDisplay);
+
+ // Reset button handler
+ resetButton.addEventListener('click', function () {
+ ethAmountInput.value = '';
+ feePercentageInput.value = '';
+ feeRecipientInput.value = '';
+ updateSwapDetailsDisplay();
+ });
+
+ document.addEventListener('newChainIdInt', function (e) {
+ swapButton.disabled = e.detail.chainIdInt !== 1;
+ });
+
+ document.addEventListener('globalConnectionChange', function (e) {
+ if (e.detail.connected && globalContext.chainIdInt === 1) {
+ swapButton.disabled = false;
+ }
+ });
+
+ document.addEventListener('disableAndClear', function () {
+ swapButton.disabled = true;
+ });
+
+ /**
+ * Build Swap Comparison transaction using working example as template
+ */
+ swapButton.onclick = async () => {
+ try {
+ statusDisplay.innerHTML = 'Building transaction...';
+
+ // Get configuration values from inputs or use defaults
+ const config = getConfigValues();
+
+ console.log('=== Swap Comparison Transaction ===');
+ console.log('Router:', UNIVERSAL_ROUTER);
+ console.log(
+ 'ETH Amount:',
+ config.ethAmountHex,
+ `(${config.ethAmountDisplay} ETH)`,
+ );
+ console.log('Fee Recipient:', config.feeRecipient);
+ console.log('Fee %:', config.feeBips / 100, '%');
+
+ // Use working example as template and modify only necessary values
+ const deadline = Math.floor(Date.now() / 1000) + 1200; // 20 minutes from now
+
+ // Build calldata using template from working example
+ const data = buildCalldata(
+ deadline,
+ config.feeRecipient,
+ config.feeBips,
+ config.ethAmountHex,
+ );
+
+ statusDisplay.innerHTML = 'Sending transaction...';
+
+ // Send the transaction
+ const txParams = {
+ from: globalContext.accounts[0],
+ to: UNIVERSAL_ROUTER,
+ value: config.ethAmountHex,
+ data,
+ };
+
+ console.log('Transaction params:', txParams);
+
+ const txHash = await globalContext.provider.request({
+ method: 'eth_sendTransaction',
+ params: [txParams],
+ });
+
+ console.log('Transaction sent! Hash:', txHash);
+ statusDisplay.innerHTML = 'Transaction sent!';
+ txHashDisplay.innerHTML = txHash;
+ } catch (error) {
+ console.error('swap error:', error);
+ statusDisplay.innerHTML = `Error: ${
+ error.message || 'Transaction failed'
+ }`;
+ txHashDisplay.innerHTML = '-';
+ }
+ };
+}
+
+/**
+ * Build calldata using the working example as a template
+ * Only modifies: deadline, fee recipient, fee bips, eth amount, and user address
+ */
+function buildCalldata(deadline, feeRecipient, feeBips, ethAmount) {
+ // Working example from the original transaction
+ // We'll use ethers to properly encode with our values
+ const commands = '0x10'; // V4_SWAP
+
+ // V4_SWAP input - extracted from working example
+ // This is the complex nested structure that we keep as-is
+ const v4SwapInput = buildV4SwapInputFromExample(
+ feeRecipient,
+ feeBips,
+ ethAmount,
+ );
+
+ // Encode the execute function call
+ const inputs = [v4SwapInput]; //, payPortionInput, sweepInput];
+
+ const iface = new ethers.utils.Interface([
+ 'function execute(bytes commands, bytes[] inputs, uint256 deadline)',
+ ]);
+
+ const calldata = iface.encodeFunctionData('execute', [
+ commands,
+ inputs,
+ deadline,
+ ]);
+
+ return calldata;
+}
+
+/**
+ * Build V4_SWAP input from working example structure
+ * This uses the exact structure from a known working transaction
+ */
+function buildV4SwapInputFromExample(feeRecipient, feeBips, ethAmount) {
+ // From working example: actions = 0x070b0e
+ let v4Actions = EMPTY_BYTES;
+ const v4Params = [];
+
+ // Build the 3 params from the working example structure
+ // These encode the pool configuration and swap parameters
+
+ // Param 1: Pool key and swap amount configuration
+ // Structure from example: complex nested data for the V4 pool
+ const poolKey = {
+ currency0: ethers.constants.AddressZero, // currency0 (ETH)
+ currency1: USDC_ADDRESS, // currency1 (USDC)
+ fee: 500,
+ tickSpacing: 10,
+ hooks: '0x0000000000000000000000000000000000000000',
+ };
+ const amountOutMinimum = '0x0';
+ const swapExactInSingle = addAction(Actions.SWAP_EXACT_IN_SINGLE, [
+ {
+ poolKey,
+ zeroForOne: true, // The direction of swap is ETH to USDC. Change it to 'false' for the reverse direction
+ amountIn: ethAmount,
+ amountOutMinimum, // Change according to the slippage desired
+ hookData: '0x00',
+ },
+ ]);
+ v4Actions = v4Actions.concat(swapExactInSingle.newAction);
+ v4Params.push(swapExactInSingle.newParam);
+
+ const settleAll = addAction(Actions.SETTLE_ALL, [
+ poolKey.currency0,
+ ethAmount,
+ ]);
+ v4Actions = v4Actions.concat(settleAll.newAction);
+ v4Params.push(settleAll.newParam);
+
+ const takePortion = addAction(Actions.TAKE_PORTION, [
+ poolKey.currency1,
+ feeRecipient,
+ feeBips,
+ ]);
+ v4Actions = v4Actions.concat(takePortion.newAction);
+ v4Params.push(takePortion.newParam);
+
+ const takeAll = addAction(Actions.TAKE_ALL, [
+ poolKey.currency1,
+ amountOutMinimum,
+ ]);
+ v4Actions = v4Actions.concat(takeAll.newAction);
+ v4Params.push(takeAll.newParam);
+
+ // Encode the V4_SWAP input: (bytes actions, bytes[] params)
+ const v4SwapInput = ethers.utils.defaultAbiCoder.encode(
+ ['bytes', 'bytes[]'],
+ [v4Actions, v4Params],
+ );
+
+ return v4SwapInput;
+}
diff --git a/src/index.js b/src/index.js
index 8f7bab13..77720150 100644
--- a/src/index.js
+++ b/src/index.js
@@ -19,6 +19,7 @@ import {
erc1155Component,
eip747Component,
erc721Component,
+ swapComparisonComponent,
} from './components/transactions';
import {
ppomMaliciousSendCalls,
@@ -184,6 +185,7 @@ erc721Component(transactionsRow);
erc1155Component(transactionsRow);
eip747Component(transactionsRow);
eip5792Component(transactionsRow);
+swapComparisonComponent(transactionsRow);
const ppomSection = document.createElement('section');
mainContainer.appendChild(ppomSection);
@@ -439,6 +441,12 @@ const handleNewChain = (chainId) => {
if (!scrollToHandled) {
handleScrollTo({ delay: true });
}
+
+ const changeEvent = new CustomEvent('newChainIdInt', {
+ detail: { chainIdInt: globalContext.chainIdInt },
+ });
+ document.dispatchEvent(changeEvent);
+
updateCurrentNetworkDisplay();
updateActiveNetworkInModal();
};