Cover photo

From Logs to Clarity: Unlocking EVM Event Decoding

A practical guide to decoding Scroll's raw logs into meaningful metrics and reports using Dune.

Introduction

Events are crucial in the Ethereum Virtual Machine (EVM) networks. Interestingly, half of the transactions to smart contracts on Scroll are to non-decoded contracts on Dune. To unlock full data insights, you need to submit the ABI (Application Binary Interface) to access decoded tables on Dune but they can still be limited and there handy functions to decode data.

Events are a critical communication mechanism for smart contracts, primarily serving as a bridge between the blockchain and front-end applications. They are logs stored in a specialized data structure within the blockchain, allowing efficient access and filtering by external applications like decentralized apps (dApps) or off-chain services. Each emitted event is intrinsically linked to the smart contract that generated it.

Half of transactions on scroll are not decoded in any way

Dune stands out as one of the most prominent blockchain data platforms. With an account, you can gain access to vast amounts of data at your fingertips. Many important contracts are already decoded, providing human-readable data that can be easily analyzed.

The decoding functions

While you might typically rely on the chain.logs_decoded table to gather logs information, these tables often provide only a superficial overview. They frequently capture just a brief detail about the event being emitted, rather than the complete parameter details.

Anatomy of an EVM transaction

A typical EVM transaction can be sent to two primary types of addresses:

  1. Externally Owned Accounts (EOA) - for simple operations like sending native currency

  2. Smart Contracts - which can execute more complex operations

{
    "to": "0xd29687c813d741e2f938f4ac377128810e217b1b",
    // [...]
}

When a function executes successfully, the contract is likely to emit events (or logs). This data is stored in tables like scroll.logs

Logs Schema Attributes

The logs table typically includes the following key attributes:

  • block_time: Timestamp

  • block_number: Numeric block identifier

  • contract_address: Address of the contract

  • topic0-3: Indexed event parameters

  • data: Additional event data

  • tx_hash: Transaction hash

  • And several other metadata field

A raw log entry might look cryptic:

{
    "contract_address": "0xd29687c813d741e2f938f4ac377128810e217b1b",
    "topic0":"0xdec2bacdd2f05b59de34da9b523dff8be42e5e38e818c82fdb0bae774387a724",
	"topic1":"0x000000000000000000000000588faac6b3aa91bb4cd8127ab357515716541f1b",
	"topic2":"",
	"topic3":"",
"data":"0x00000000000000000000000000000000000000000000000496bda902a80a80000000000000000000000000000000000000000000000000000000000000000000",
}

Decoding Functions

Dune provides two primary decoding functions:

  1. decode_evm_event(): Decodes logs or events

  2. decode_evm_function_call(): Decodes internal functions or traces

The article focus on the event decoding function.

A typical decoding query follows this structure:

SELECT 
    *
FROM TABLE(
    decode_evm_event(
    abi => '{EVENT ABI}',
    input => TABLE(
        SELECT block_date, topic0, topic1, topic2, topic3, data, tx_hash, tx_from, contract_address
        FROM arbitrum.logs
        WHERE
            contract_address IN 
            ({CONTRACTS})
        AND
            topic0 = {EVENT SIGNATURE}
        )
    )
)

To get the event ABI, you need to obtain the contract ABI and find the event functions. You can do this through various methods:

  • Using a blockchain explorer

  • Compiling the contract

A typical event looks like this:

{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"voter","type":"address"},{"indexed":false,"internalType":"uint256","name":"proposalId","type":"uint256"},{"indexed":false,"internalType":"uint8","name":"support","type":"uint8"},{"indexed":false,"internalType":"uint256","name":"weight","type":"uint256"},{"indexed":false,"internalType":"string","name":"reason","type":"string"}],"name":"VoteCast","type":"event"}

Concrete Example: Scroll Token (SCR) Transfer Events

Here's a concrete example of generating a query to return Transfer events for the Scroll Token (SCR):

SELECT
    block_time,
  "from",
  "to",
  value / POWER(10, 18) AS value_
FROM TABLE(
  DECODE_EVM_EVENT(
    abi => '{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"from","type":"address"},{"indexed":true,"internalType":"address","name":"to","type":"address"},{"indexed":false,"internalType":"uint256","name":"value","type":"uint256"}],"name":"Transfer","type":"event"}',
    input => TABLE(
      SELECT
        *
      FROM scroll.logs
      WHERE
        contract_address IN (0xd29687c813d741e2f938f4ac377128810e217b1b) -- SCR Token Address
        AND topic0 = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef -- Event Signature
    ),
    null_on_error => FALSE
  )
)

Some event attributes are reserved SQL words, such as "from" or "to". To use these, you need to enclose them in double quotes in the attribute name. If you use single quotation marks, you will get the literal attribute.

The results you obtain will provide detailed information about the event.

For each case, you need to consider specific nuances. In this example, it's necessary to divide the "value" attribute by the 18 decimal places of the token to obtain the integer token number.

Now you have all the data for a specific event, which allows you to tweak the query to:

  • Track how a variable evolves over time

  • Explore various aspects of your contract

Query Creation Using Python

After building several dashboards using this technique, I found myself doing repetitive work:

  • Going to the contract address

  • Ensuring the contract is decoded

  • Finding event signatures

  • Copying and pasting into query templates

This process was time-consuming. I realized there must be a better way, so I asked Claude to help me develop a Python script to automate this process.

Key features of the script

  • Load and parse ABI files

  • Generate event signatures

  • Create query templates

  • Support for multiple blockchain analysis scenarios

Before running the script, remember to install the required dependencies:

pip install keccak
python -m pip install "eth-hash[pycryptodome]"

After creating a virtual environment to use Python without conflicts and installing the required packages, copy the provided script and save it with a descriptive name like evm_abi_parser.py.

import json
import os
import sys
from eth_utils import keccak

class EVMABIParser:
    """
    A comprehensive tool for parsing and working with Ethereum ABI files.
    """

    def __init__(self, file_path=None):
        """
        Initialize the ABI parser with an optional file path.

        Args:
            file_path (str, optional): Path to the ABI JSON file.
        """
        self.abi_contents = None
        self.parsed_abi = None

        if file_path:
            self.load_abi_from_file(file_path)

    def load_abi_from_file(self, file_path):
        """
        Load ABI from a JSON file.

        Args:
            file_path (str): Path to the ABI JSON file.

        Returns:
            bool: True if loading was successful, False otherwise.
        """
        try:
            # Expand user home directory if needed
            file_path = os.path.expanduser(file_path)
            
            # Check if file exists
            if not os.path.exists(file_path):
                print(f"Error: File '{file_path}' does not exist.")
                return False
            
            # Check file extension
            if not file_path.lower().endswith(('.json', '.jsonc')):
                print("Error: File must be a JSON or JSONC file.")
                return False
            
            # Read and parse the file
            with open(file_path, 'r', encoding='utf-8') as file:
                try:
                    self.abi_contents = json.load(file)
                    
                    # Validate ABI structure
                    if not isinstance(self.abi_contents, list):
                        print("Error: ABI must be a JSON array.")
                        return False
                    
                    self.parsed_abi = self.parse_abi()
                    return True
                
                except json.JSONDecodeError as e:
                    print(f"JSON Parsing Error: {e}")
                    return False
        
        except Exception as e:
            print(f"Unexpected error reading file: {e}")
            return False

    def parse_abi(self):
        """
        Parse the ABI contents and extract functions and events.

        Returns:
            dict: A dictionary with lists of functions and events.
        """
        functions = []
        events = []

        for item in self.abi_contents:
            # Validate each ABI item
            if not isinstance(item, dict):
                continue

            if item.get("type") == "function":
                functions.append({
                    "name": item.get("name", "Unnamed Function"),
                    "inputs": item.get("inputs", []),
                    "outputs": item.get("outputs", []),
                    "stateMutability": item.get("stateMutability", "Unknown")
                })
            elif item.get("type") == "event":
                events.append({
                    "name": item.get("name", "Unnamed Event"),
                    "inputs": item.get("inputs", []),
                    "anonymous": item.get("anonymous", False)
                })

        return {"functions": functions, "events": events}

    @staticmethod
    def generate_event_signatures(events):
        """
        Generate text and hex signatures for events using Keccak-256.

        Args:
            events (list): List of event dictionaries.

        Returns:
            list: List of dictionaries with event signatures.
        """
        event_signatures = []

        for event in events:
            # Generate text signature
            input_types = [inp.get('type', '') for inp in event.get('inputs', [])]
            text_signature = f"{event['name']}({','.join(input_types)})"
            
            # Generate full topic0 using Keccak-256 of the text signature
            # Use eth_utils.keccak for correct Keccak-256 implementation
            topic0 = '0x' + keccak(text_signature.encode()).hex()

            event_signatures.append({
                'name': event['name'],
                'text_signature': text_signature,
                'topic0': topic0
            })

        return event_signatures

    def display_event_signatures(self):
        """
        Display event signatures in a formatted manner.
        """
        if not self.parsed_abi or not self.parsed_abi['events']:
            print("No events found in the ABI.")
            return

        event_signatures = self.generate_event_signatures(self.parsed_abi['events'])
        
        print("\n--- Event Signatures ---")
        for sig in event_signatures:
            print(f"Event: {sig['name']}")
            print(f"  Text Signature: {sig['text_signature']}")
            print(f"  Topic0: {sig['topic0']}\n")

    def generate_query_template(self, event_name, contract_addresses=None):
        """
        Generate a query template for a specific event.

        Args:
            event_name (str): Name of the event.
            contract_addresses (list, optional): List of contract addresses.

        Returns:
            str: SQL query template or None if event not found.
        """
    # Find the event in the parsed ABI
        target_event = None
        for event in self.abi_contents:  # Use self.abi_contents instead of self.parsed_abi['events']
            if event.get('name') == event_name and event.get('type') == 'event':
                target_event = event
                break

        if not target_event:
            print(f"Event '{event_name}' not found in the ABI.")
            return None

        # Generate text signature to get topic0
        signatures = self.generate_event_signatures([target_event])
        topic0 = signatures[0]['topic0']

        # Construct query template
        contract_address_clause = (
            f"contract_address IN ({', '.join(str(addr) for addr in contract_addresses)})"
            if contract_addresses 
            else "1=1"
        )

        query_template = f"""
    SELECT 
        *
    FROM TABLE(
        decode_evm_event(
            abi => '{json.dumps(target_event)}',
            input => TABLE(
                SELECT block_date, topic0, topic1, topic2, topic3, data, tx_hash, tx_from, contract_address
                FROM scroll.logs
                WHERE
                    {contract_address_clause}
                AND
                    topic0 = {topic0}
            )
        )
    )
    """
        return query_template

def main():
    """
    Main function to interact with the EVM ABI Parser.
    """
    print("EVM ABI Parser and Query Generator")
    
    while True:
        print("\nChoose an option:")
        print("1. Load ABI file")
        print("2. Display Event Signatures")
        print("3. Generate Query Template")
        print("4. Exit")
        
        choice = input("Enter your choice (1-4): ").strip()
        
        if choice == '1':
            file_path = input("Enter the path to your ABI JSON file: ").strip()
            parser = EVMABIParser(file_path)
        
        elif choice == '2':
            if not parser.parsed_abi:
                print("Please load an ABI file first.")
                continue
            parser.display_event_signatures()
        
        elif choice == '3':
            if not parser.parsed_abi:
                print("Please load an ABI file first.")
                continue
            
            event_name = input("Enter the event name: ").strip()
            include_addresses = input("Do you want to specify contract addresses? (y/n): ").strip().lower()
            
            contract_addresses = None
            if include_addresses == 'y':
                addresses_input = input("Enter contract addresses (comma-separated): ").strip()
                contract_addresses = [addr.strip() for addr in addresses_input.split(',')]
            
            query = parser.generate_query_template(event_name, contract_addresses)
            if query:
                print("\n--- Query Template ---")
                print(query)
        
        elif choice == '4':
            print("Exiting the script.")
            break
        
        else:
            print("Invalid choice. Please try again.")

if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        print("\nOperation cancelled by user.")
        sys.exit(0)

Before running the Python script:

  1. Copy the contract's ABI to your clipboard

  2. Save the ABI as a JSON file

  3. Load this file when executing the script

If the contract you are calling is a proxy you still need to use the underlying contract ABI for the proxy, not the proxy ABI.

The script is running and showing event signatures
Query Generation

When we run our query, we can now see the detailed results:

With the base query in place, we now have a powerful starting point to extract useful information about blockchain events. For example, we can modify the query to analyze the distribution of SCR token claims during the Scroll airdrop over time:

Final Thoughts

This script represents an initial proof of concept and requires further refinement as it's not battle-tested. I found that some contracts couldn't be properly decoded like the dmail contract, in which I wasn’t able to get the events as the query generated returned an error on Dune.

Still, I think this script will be helpful, as these functions have been instrumental in my analysis of the Scroll airdrop and the dashboard for governance in Scroll. Among the things that need to be added is support for internal functions using the decode_evm_function_call().

Subscribe to Wild Tales and never miss a post.
#scroll#evm#ethereum#logs