Back to blogs
Written by
Valentina Rivas
Published on
May 9, 2025

Secure dApps Against UI Spoofing (Part 2): Simulating Transactions

Use Foundry and Python to simulate Ethereum transactions and verify smart contract behavior, protecting users from deceptive wallet interfaces.

Table of Contents

In this tutorial, we explain how to programmatically verify transaction calldata using Python. We walk through decoding ERC-20 approve transactions, simulating their effects via a local mainnet fork, and detecting UI spoofing attacks by comparing the expected and actual calldata.

In Part 1, we learned how to decode Ethereum calldata to understand transaction intent. Now we take verification further by simulating transactions against a live blockchain state. This guide will help you:

  • Set up a local Ethereum mainnet fork using Anvil
  • Simulate ERC-20 approval transactions
  • Detect mismatches between UI displays and actual execution

All code examples build on our previous decoding toolkit - ensure you're familiar with Part 1 before continuing.

Simulating transactions for verification

Why do we want to simulate a transaction

Simulation allows us to: 

  • See the actual state changes that would occur on the blockchain.
  • Verify the transaction does what it claims to do.
  • Detect potential issues before signing a transaction.

Let's create a script with Foundry to simulate the approval transaction we are using and verify its effects.

Setting up a local mainnet fork with Anvil

To simulate transactions, we first need a local copy of the Ethereum mainnet, so you can simulate transactions without spending real ETH. Anvil (part of the Foundry toolkit) makes this easy:

For Linux/macOS:

  1. Install Foundry:
curl -L https://foundry.paradigm.xyz | bash
foundryup

  1. Start a local fork of mainnet:
anvil --fork-url https://mainnet.infura.io/v3/YOUR_INFURA_KEY

For Windows:

Windows users need to use Windows Subsystem for Linux (WSL) to run Anvil:

  1. Install WSL by following this step-by-step video guide
  2. With WSL installed, open the terminal and follow the Linux instructions above.
  3. By default, your Anvil instance will be accessible from Windows at http://localhost:8545.

Setting up the environment

Once you have Anvil (or another local fork solution) running, we can connect to our local fork and define the ERC-20 ABI:

from web3 import Web3
import json
from eth_abi import decode
From eth_utils import to_checksum_address

# Connect to a local fork of mainnet (using Anvil, Hardhat, or Ganache)
w3 = Web3(Web3.HTTPProvider('http://localhost:8545'))

# ERC-20 ABI (minimal for approval/allowance checks)
ERC20_ABI = [
    {
        "constant": False,
        "inputs": [
            {"name": "spender", "type": "address"},
            {"name": "value", "type": "uint256"}
        ],
        "name": "approve",
        "outputs": [{"name": "", "type": "bool"}],
        "type": "function"
    },
    {
        "constant": True,
        "inputs": [
            {"name": "owner", "type": "address"},
            {"name": "spender", "type": "address"}
        ],
        "name": "allowance",
        "outputs": [{"name": "", "type": "uint256"}],
        "type": "function"
    },
    {
        "constant": True,
        "inputs": [],
        "name": "decimals",
        "outputs": [{"name": "", "type": "uint8"}],
        "type": "function"
    }
]

Building the simulation function

Now, let's build our simulation, breaking it down into modular functions for better readability and maintainability:

1. Setting up addresses and creating a contract

def setup_addresses(user_address, token_address, spender_address):
    """Convert addresses to checksum format and create contract instance"""
    user = Web3.to_checksum_address(user_address)
    token = Web3.to_checksum_address(token_address)
    spender = Web3.to_checksum_address(spender_address)
    contract = w3.eth.contract(address=token, abi=ERC20_ABI)
    
    return user, token, spender, contract


This function handles the initial setup, converting all addresses to the proper checksum format and creating a contract instance for interacting with the token.

2. Getting token information

def get_token_info(contract, token_address, token_decimals=None):
    """Get token decimals and calculate normalized amount"""
    if token_decimals is None:
        try:
            token_decimals = contract.functions.decimals().call()
        except:
            # Use appropriate fallback based on token address
            if token_address.lower() == "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48".lower():  # USDC
                token_decimals = 6
            else:
                token_decimals = 18  # Default fallback
    
    return token_decimals


This function retrieves the token's decimal places, with fallbacks for common tokens or network issues.

3. Checking the initial state

def check_initial_state(contract, user, spender, token_decimals):
    """Check the initial allowance"""
    initial_allowance = contract.functions.allowance(user, spender).call()
    initial_allowance_normalized = initial_allowance / (10 ** token_decimals)
    
    return initial_allowance, initial_allowance_normalized


Before simulating the transaction, we call allowance(owner, spender) on the ERC-20 contract to retrieve the current on-chain allowance. That way, after running our simulated transaction, we can compare “before vs. after” allowances and spot any unexpected change.

4. Building the transaction

  def build_transaction(contract, user, spender, amount):
    """Build the approval transaction"""
    tx = contract.functions.approve(spender, amount).build_transaction({
        'from': user,
        'nonce': w3.eth.get_transaction_count(user),
        'gas': 200000, # initial safe estimate
        'gasPrice': w3.eth.gas_price,
        'chainId': w3.eth.chain_id
    })
    
    # Get gas estimate (will be capped later for safety)
    gas_estimate = w3.eth.estimate_gas(tx)
    tx['gas'] = gas_estimate
    
    return tx, gas_estimate


This function constructs the transaction object with all necessary parameters plus a gas estimate. Note, while we calculate an accurate gas estimate here, we'll apply an additional safety cap during mainnet execution to mitigate scenarios where the contract might behave unexpectedly and require an excessively high amount of gas.

5. Comparing simulated calldata with wallet-provided calldata

def compare_calldata(tx_data, wallet_calldata):
    """Compare generated calldata with wallet calldata"""
    if not wallet_calldata:
        return None
        
    # Normalize both calldata strings for comparison
    norm_generated = tx_data.lower()
    norm_wallet = wallet_calldata.lower()
    if norm_wallet.startswith('0x'):
        norm_wallet = norm_wallet[2:]
    if norm_generated.startswith('0x'):
        norm_generated = norm_generated[2:]
        
    calldata_matches = (norm_generated == norm_wallet)
    
    if not calldata_matches:
        print("WARNING: Wallet calldata doesn't match expected calldata!")
        print("This could indicate a malicious transaction or UI spoofing attack.")
    
    return calldata_matches


This function compares our expected calldata with the raw calldata hex extracted from the wallet’s UI, alerting us to potential spoofing attacks.

6. Executing the transaction 

def execute_transaction(tx, user):
    """Execute the transaction using impersonation with gas safety limits"""
    w3.provider.make_request("anvil_impersonateAccount", [user])
    try:
        # Send transaction with gas cap for security
        safe_tx = {**tx, 'gas': min(tx.get('gas', 0), 300000)}
        tx_hash = w3.eth.send_transaction(safe_tx)
    finally:
        w3.provider.make_request("anvil_stopImpersonatingAccount", [user])
    
    return tx_hash


For simulation purposes, we execute the transaction using account impersonation (a feature available in local development environments). We add a gas limit as a safety measure.

Replay safety note: Impersonation is only for testing on local forks and is not a real-world practice on public networks. On public networks (mainnet), you can’t impersonate accounts you don't control. Attempting to do so would result in a failed transaction because you don’t control the private key necessary to sign transactions.

This simulation approach is strictly for verification purposes in a safe, local environment before interacting with real contracts on public networks.

7. Checking the final on-chain allowance after simulation

def check_final_state(contract, user, spender, amount, token_decimals):
    """Check the final allowance and transaction success"""
    final_allowance = contract.functions.allowance(user, spender).call()
    final_allowance_normalized = final_allowance / (10 ** token_decimals)
    
    # Check for infinite approval
    infinite_approval = amount >= (2**256 - 1) or amount >= (2**64 - 1)
    
    return final_allowance, final_allowance_normalized, infinite_approval


After the transaction, we verify the new allowance and check if this was an infinite approval (a potential security concern) or other malicious action.

8. Returning the results (main simulation function)

def simulate_approval(
    user_address: str,
    token_address: str,
    spender_address: str,
    amount: int,
    token_decimals: int = None,
    wallet_calldata: str = None
) -> dict:
    """
    Simulate an ERC-20 approval transaction and verify state changes.
    """
    try:
        # Setup addresses and contract (...)
        
        # Get token information (...)
        
        # Check initial state (...)
        
        # Build intended transaction (...)
        
        # Compare calldata and check for infinite approval in wallet data
        calldata_matches = compare_calldata(tx['data'], wallet_calldata)
        
        # Additional security check for wallet calldata
        wallet_amount = None
        wallet_infinite = False
        if wallet_calldata:
            try:
                # Decodes wallet calldata to verify spender and amount
                # Checks for infinite approval and spender mismatch
                # Continues...
        
        # Execute transaction (...)
        
        # Check final state (...)
        
        return {
            "success": True,
            # Continues with other fields...
        }
    except Exception as e:
        return {"success": False, "error": str(e)}


This main function orchestrates the simulation by calling each of our specialized functions sequentially, and returns the results.

Putting it all together


The full code is available as a GitHub Gist. To see it in action, let’s use our complete simulation script to verify the same approval transaction we decoded in Part 1.

Output:


Let’s break down the full output of the simulation script and explain each section:

Simulation Result:
Success: True
Generated Calldata: 0x095ea7b30000000000000000000000006a000f20005980200259b80c510200304000106800000000000000000000000000000000000000000000000000000000017d7840
Wallet Calldata Match: True


This section confirms that our simulation was successful and that the calldata we generated matches exactly what was provided in the wallet. This is a good sign - it means the transaction is doing what it claims to be doing.

Transaction Details:
Spender: 0x6A000F20005980200259B80c5102003040001068
Approval Requested: 25.0 USDC (25000000 raw)
Actual Approval: 25.0 USDC
Gas Estimate: 55901
Infinite Approval: False


Here, we see the key parameters of the transaction. The spender address matches our expected recipient, the amount is 25 USDC as expected, and this is not an infinite approval. The gas estimate gives us an idea of the transaction cost.

State Changes:
Initial Allowance: 0.0 USDC
Final Allowance: 25.0 USDC
Transaction Successful: True
Transaction Hash: 39fc7619e61bb70859b9e41de87821ad069b81c58dd7990b84f1674d60166ab7


This section shows the actual state changes that occur on the blockchain. The allowance started at 0 USDC and increased to 25 USDC after the transaction, confirming that the approval was successful. The transaction hash is provided for reference, which allows you to look up the transaction on a block explorer if this were on a public network.

Detecting UI spoofing attacks

In these examples, we're demonstrating how our verification tool works using two different scenarios:

  1. Legitimate transaction: calldata matches exactly


In the first simulation ("Wallet Calldata Match: True"), we provided our tool with the legitimate calldata that matches what we intend to do: approve a 25 USDC transaction to our expected recipient.

  1. Spoofed UI: calldata differs from what the user expects

In the following example, we're simulating what happens during an attack by providing a different calldata in the wallet_calldata parameter. This parameter represents what would be shown in a compromised wallet UI. 

In a real-world scenario, you would:

  • Generate the expected calldata using your parameters (recipient, amount, etc.).
  • Extract the actual calldata from your wallet UI (by clicking the data field icon).
  • Compare them to detect any manipulation.

The graphic below illustrates this process:

A visual flowchart showing the six-step transaction validation process to detect UI spoofing by comparing and simulating Ethereum calldata.
Figure 1: Verifying a transaction step-by-step


Our tool does this comparison automatically, alerting you when the calldata from your wallet doesn't match what you're expecting. 

Let's see how our tools would detect a UI spoofing attack. Imagine a wallet UI showing:

  •  "Approving 25 USDC to ParaSwap (0x6A000F20005980200259B80c5102003040001068)"


But the actual approve calldata is:

0x095ea7b30000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc000000000000000000000000000000000000000000000000000000003b9aca00

Using our simulation decoder script:

result = simulate_approval(
    user_address="0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",  # Anvil Account 0
    token_address="0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",  # USDC
    spender_address="0x6A000F20005980200259B80c5102003040001068",  # Example spender
    amount=25 * 10**6,  # 25 USDC (6 decimals)
    token_decimals=6,  # USDC has 6 decimals
    wallet_calldata="0x095ea7b30000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc000000000000000000000000000000000000000000000000000000003b9aca00" # here copy-pasting the hex data shown in UI to see if it matches
)

Output:

WARNING: Wallet calldata doesn't match expected calldata!
This could indicate a malicious transaction or UI spoofing attack.
CRITICAL: Spender address in wallet calldata doesn't match expected spender!


Simulation Result:
Success: True
Generated Calldata: 0x095ea7b30000000000000000000000006a000f20005980200259b80c510200304000106800000000000000000000000000000000000000000000000000000000017d7840
Wallet Calldata Match: False


Transaction Details:
(...)
Approval Requested: 25.0 USDC (25000000 raw)
Actual Approval: 1000.0 USDC
Gas Estimate: 55901
Infinite Approval: False


This output reveals a security issue. The warning indicates that the calldata from the wallet UI doesn't match the calldata generated by the simulated transaction. Looking at the two pieces of calldata:

1. What we expected (approving 25 USDC):

0x095ea7b30000000000000000000000006a000f20005980200259b80c510200304000106800000000000000000000000000000000000000000000000000000000017d7840

2. What the wallet is attempting to execute (approve 1,000 USDC):

0x095ea7b30000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc000000000000000000000000000000000000000000000000000000003b9aca00

The difference is in both the amount and the recipient’s address:

  • Rather than approving 25 USDC (0x17d7840 = 25,000,000 in raw units), the transaction would approve 1,000 USDC (0x3b9aca00 = 1,000,000,000 in raw units)
  • Instead of approving to our intended recipient (0x6A000F...), it would approve a different address (0x3C44Cd...)


This type of attack can lead to a loss that far exceeds the amount the user intended to risk, and may redirect funds to an attacker-controlled address. Our verification tool successfully caught the discrepancy and warned us that the calldata doesn’t match, saving financial loss.

Security best practices

When implementing calldata verification:

  1. Always verify transactions, especially approvals, transfers, and swaps.
  2. Check both the recipient and amount: Many attacks change only one parameter.
  3. Be wary of infinite approvals: Many wallets display infinite approvals differently - some show them as 'Unlimited' while others display the raw maximum uint256 value (2²⁵⁶-1 or 2⁶⁴-1, which appear as 1157920892.... respectively in raw units). Always verify the actual numeric value in the calldata.

MetaMask approval screen showing a spender address and a large value in scientific notation, indicating an unusually high token approval.
Figure 2: MetaMask UI showing raw max value (1.1579...e+77)

MetaMask confirmation screen warning that the user is granting unlimited USDC spending permission to another address.
Figure 3: MetaMask UI showing "Unlimited" approval

  1. Compare calldata from multiple sources: If possible, generate the expected calldata and compare it with what your wallet shows.
  2. Simulate before signing: For high-value transactions, simulate them first.
  3. Use hardware wallets that display transaction details for manual verification.

Conclusion

Combining calldata decoding with transaction simulation creates a strong defense against UI spoofing attacks. These techniques enable you to:

  • Verify transactions before signing.
  • Detect hidden parameter changes.
  • Prevent simple and sophisticated attacks.

While focused on ERC-20 approvals, these methods adapt to any Ethereum transaction type by modifying the ABI definitions and simulation logic.

Remember
: The few seconds spent verifying calldata could save you billions.

Secure your protocol today

Join some of the biggest protocols and companies in creating a better internet. Our security researchers will help you throughout the whole process.
Stay on the bleeding edge of security
Carefully crafted, short smart contract security tips and news freshly delivered every week.