In this tutorial, we break down how to manually decode Ethereum calldata to detect UI spoofing attacks. This foundational skill enables you to verify what a transaction intends to do before signing.
UI spoofing attacks manipulate transaction interfaces to deceive users into approving malicious actions. In this two-part series, we equip developers with tools to detect these attacks by verifying transaction data. Part 1 focuses on decoding raw Ethereum calldata - the hexadecimal instructions at the heart of every transaction. You'll learn to:
By the end of this guide, you'll be able to parse transaction data at the byte level, an important skill for detecting malicious activity.
UI spoofing attacks are a category of cyberattacks where adversaries manipulate user interfaces to trick victims into performing unintended actions.
When applied to web3, UI spoofing attacks involve malicious interfaces that disguise harmful blockchain transactions as legitimate actions. Attackers alter displayed details like recipient addresses, token amounts, and contract functions. For example:
Consider this scenario: Alex opened his wallet app, ready to swap tokens. The interface showed a familiar approval request: “Approve 100 USDC to (address…).” It looked normal, so Alex clicked confirm. Moments later, his funds vanished. What happened?
Alex had just fallen victim to a UI spoofing attack, where an attacker manipulated the wallet’s interface to hide the true recipient of the funds. The transaction Alex signed didn't approve sending USDC for the intended action. It approved unlimited withdrawals to a malicious contract.
These attacks exploit the gap between what users see and what the blockchain executes.
UI spoofing attacks are becoming increasingly common in web3, driven by the rise of decentralized finance (DeFi) and sophisticated social engineering tactics. High-profile incidents include:
Every Ethereum transaction includes calldata—the encoded instructions sent to a smart contract. By verifying calldata, users and developers can:
This guide assumes you have:
This tutorial focuses specifically on ERC-20 token approvals, one of the most common and security-critical transactions in decentralized finance (DeFi). The demonstrated techniques can be adapted for other types of transactions (transfers, swaps, etc.) by modifying the ABI definitions and decoding logic.
The scripts provided are educational examples to demonstrate the concepts of calldata verification and are illustrated in the Radiant Capital link above. For production use, expand these tools to handle a wider range of functions and edge cases.
Calldata is an immutable hexadecimal string structured with two elements:
0x095ea7b3
corresponds to the ERC-20 approve
function). The “0x” prefix indicates a hexadecimal number, and the remaining eight characters are equivalent to four bytes.Tip: To ensure the function selector corresponds to an expected function, you can compare it with entries in a known function signature database (such as 4byte.directory).
Let’s explore a transaction that is supposed to send 25 USDC to a specific address. When you see hex data like this in your wallet’s UI, the goal is to verify if it will actually do what it claims to do.
Let’s examine how to decode an ERC-20 approval calldata manually. Consider the following hexadecimal calldata:
0x095ea7b30000000000000000000000006a000f20005980200259b80c510200304000106800000000000000000000000000000000000000000000000000000000017d7840
Breaking this down:
0x095ea7b3
identifies the function approve(address,uint256)
0000000000000000000000006a000f20005980200259b80c510200304000106800
6a000f20005980200259b80c5102003040001068
0x6A000F20005980200259B80c5102003040001068
00000000000000000000000000000000000000000000000000000000017d7840
0x17d7840
= 25,000,000 in raw units
Now that we've manually decoded the calldata, we can see how this process works at a low level. However, manually parsing hex strings is error-prone and can be impractical for regular use. Let's implement this decoding logic in Python to automate the process and make it more reliable.
Before implementing the Python code in the next section, install the necessary libraries by running the following command on your terminal or IDE of your choice:
pip install web3 eth-abi eth-utils
Below is a Python function to decode ERC-20 approve calldata, converting it to human-readable information.
from eth_abi import decode
from eth_utils import to_checksum_address
def decode_erc20_approve(calldata: str, token_decimals: int = 18) -> dict:
"""
Decode ERC-20 approve calldata with human-readable output
Parameters:
- calldata: The hex calldata string
- token_decimals: Number of decimals for the token (default: 18)
Returns: Dictionary with decoded information
"""
# Remove '0x' prefix if present
hex_data = calldata[2:] if calldata.startswith("0x") else calldata
# Extract function selector and parameters
selector = hex_data[:8]
params_hex = hex_data[8:]
# Decode parameters using eth_abi.decode (equivalent to decode_abi in earlier versions)
# Note: We're explicitly using decode as the ABI decoder function
spender, amount = decode(["address", "uint256"], bytes.fromhex(params_hex))
# Convert to human-readable format
spender_address = to_checksum_address(spender)
amount_raw = amount
amount_normalized = amount / (10 ** token_decimals)
return {
"function": "approve(address,uint256)",
"selector": f"0x{selector}",
"spender": spender_address,
"amount_raw": amount_raw,
"amount_normalized": amount_normalized,
"token_decimals": token_decimals
}
# Example usage
calldata = "0x095ea7b30000000000000000000000006a000f20005980200259b80c510200304000106800000000000000000000000000000000000000000000000000000000017d7840"
result = decode_erc20_approve(calldata, token_decimals=6) # USDC has 6 decimals
print("Calldata Breakdown:")
print(f"Function: {result['function']}")
print(f"Function Selector: {result['selector']}")
print(f"Spender Address: {result['spender']}")
print(f"Raw Amount: {result['amount_raw']}")
print(f"Normalized Amount: {result['amount_normalized']} USDC")
print(f"\nSummary: Approving {result['amount_normalized']} USDC to {result['spender']}")
The full code is also available as a GitHub Gist.
Output:
Calldata Breakdown:
Function: approve(address,uint256)
Function Selector: 0x095ea7b3
Spender Address: 0x6A000F20005980200259B80c5102003040001068
Raw Amount: 25000000
Normalized Amount: 25.0 USDC
Summary: Approving 25.0 USDC to 0x6A000F20005980200259B80c5102003040001068
NOTE: We're explicitly using eth_abi.decode
as the ABI decoder function, the ecosystem’s current standard.
This decoder extracts the essential parameters from an ERC-20 approval transaction, translating them into a human-readable form. It enables users to manually verify whether the transaction matches what the UI claims.
Decoding calldata tells us what a transaction is supposed to do, but simulating the transaction shows us what happens on-chain.
You've now mastered decoding ERC-20 approval transactions by analyzing function selectors and ABI-encoded parameters. While this reveals what a transaction is supposed to do, we still need to programmatically verify what it actually does on-chain. In Part 2, we'll: