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:
All code examples build on our previous decoding toolkit - ensure you're familiar with Part 1 before continuing.
Simulation allows us to:
Let's create a script with Foundry to simulate the approval transaction we are using and verify its effects.
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:
curl -L https://foundry.paradigm.xyz | bash
foundryup
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:
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"
}
]
Now, let's build our simulation, breaking it down into modular functions for better readability and maintainability:
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
In these examples, we're demonstrating how our verification tool works using two different scenarios:
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.
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:
The graphic below illustrates this process:
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:
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:
0x17d7840
= 25,000,000 in raw units), the transaction would approve 1,000 USDC (0x3b9aca00
= 1,000,000,000 in raw units)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.
When implementing calldata verification:
uint256
value (2²⁵⁶-1 or 2⁶⁴-1, which appear as 1157920892.... respectively in raw units). Always verify the actual numeric value in the calldata.
Combining calldata decoding with transaction simulation creates a strong defense against UI spoofing attacks. These techniques enable you to:
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.