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

Secure dApps Against UI Spoofing (Part 1): Decoding Transactions

Learn how to decode Ethereum calldata using Python to detect and prevent UI spoofing attacks before signing malicious dApp transactions.

Table of Contents

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:

  • Manually decode ERC-20 approval transactions
  • Extract critical parameters like recipient addresses and amounts
  • Use a Python script to automate ERC-20 calldata analysis


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.

What are UI spoofing attacks?

UI spoofing attacks are a category of cyberattacks where adversaries manipulate user interfaces to trick victims into performing unintended actions. 

UI spoofing in blockchain

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:

  • A dApp might display "Approve 100 USDC to Uniswap" but encode a transaction approving funds to an attacker’s address.
  • Malware could alter data displayed in a wallet interface to hide the true recipient or amount.


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.

Recent UI spoofing attacks

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:

  1. Bybit’s $1.4B Heist (2025): Attackers compromised Safe Wallet’s infrastructure to inject malicious JavaScript into the UI, tricking Bybit’s multi-sig signers into approving transactions that drained funds. This remains the largest recorded UI spoofing attack (combined with social engineering) in crypto history.
  2. Radiant Capital $50M Hack (2025): Attackers compromised the devices of three developers using malware disguised as a legitimate PDF file.The malware injected malicious code into the Safe Wallet’s interface, displaying benign transaction details while secretly sending malicious calldata to hardware wallets. This allowed attackers to bypass multi−sig verification and drain 50M+ from lending pools.
  3. Ledger Connect Kit Exploit (2023): A supply-chain attack injected code into Ledger’s library, altering a wallet UIs to prompt users for unlimited approvals, siphoning $600k+ before mitigation.

Why calldata verification matters

Every Ethereum transaction includes calldata—the encoded instructions sent to a smart contract. By verifying calldata, users and developers can:

  1. Confirm the recipient, amount, and function being called.
  2. Validate transactions before they’re signed or executed.

Scope of this tutorial

This guide assumes you have:

  • Basic Python knowledge
  • Familiarity with Ethereum addresses and transactions
  • A basic technical understanding of ERC-20 tokens


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.

Structure of calldata

Calldata is an immutable hexadecimal string structured with two elements:

  • Function selector: The first four bytes of the calldata encode the function selector, which uniquely identifies the function being called (e.g., 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).

  • Encoded parameters: After the function selector, each parameter is encoded into a 32-byte segment according to the ABI specification. Even if the actual data is smaller (such as an address or a smaller integer), it is padded with zeros to fill the 32-byte requirement, following ABI encoding rules.

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. 

MetaMask transaction confirmation screen showing an approval request for 25 USDC, with a highlighted icon to reveal raw calldata for verification.

Example: Decoding ERC-20 approval calldata

Let’s examine how to decode an ERC-20 approval calldata manually. Consider the following hexadecimal calldata:

0x095ea7b30000000000000000000000006a000f20005980200259b80c510200304000106800000000000000000000000000000000000000000000000000000000017d7840


Breaking this down:

  1. Function selector: 0x095ea7b3 identifies the function approve(address,uint256)
  2. Parameter 1: Spender address (next 64 hex characters, padded):
0000000000000000000000006a000f20005980200259b80c510200304000106800

  • The actual address is the last 40 characters: 6a000f20005980200259b80c5102003040001068
  • In checksum format: 0x6A000F20005980200259B80c5102003040001068
  1. Parameter 2: Amount (final 64 hex characters, padded):
00000000000000000000000000000000000000000000000000000000017d7840

  • 0x17d7840 = 25,000,000 in raw units
  • With USDC's six decimals: 25.0 USDC


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.

Prerequisites

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

Basic decoding of calldata with Python

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. 

Conclusion

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:

  • Simulate transactions using a local Ethereum fork
  • Compare expected vs actual state changes
  • Detect malicious calldata discrepancies automatically
  • Analyze real-world UI spoofing attack vectors

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.