Tracking some Bored Apes: The Ethereum event logs tutorial

Introduction

Imagine that you are holding a meeting to discuss the progress of a project. During the meeting, you touch upon various topics, make decisions, assign tasks, and, maybe even partake in some friendly banter. Once the session is adjourned, a minute of the meeting will be prepared and it will serve as a record of all the important things that were discussed and decided during the meeting. This will include information such as the date and time of the meeting, the attendees, the agenda, and the minutes of each discussion point (no, you don't include the banter).

In programming, logs have a similar purpose. They are used to record important events and activities that occur within the program. For example, when a customer makes a purchase from an online store, the program can record the event by writing a log entry to a file or database. This log entry would contain information such as the date and time of the purchase, the customer's information, the item purchased, and the price. These entries will act as valuable sources of information for debugging, troubleshooting, and understanding how the program works. And just like meeting minutes, logs in programming can also be used to look back at the history of events and activities that have occurred within the program.

In Web3, people who have dabbled in the art of writing Solidity programs know that Solidity doesn't support any explicit form of logging. You can’t print or display the data from within a Solidity program. So how do you keep track of the happenings within a Solidity smart contract? Well, the workaround for this comes in the form of events.

You see, Ethereum (or the Ethereum Virtual Machine (EVM), to be exact) supports the storage of specifically indexed data structures and this feature is conveniently called logs. The EVM logs allow for the recording and storage of events and activities on the Ethereum network. These logs can be accessed and analyzed by developers, allowing them to gain a deeper understanding of the activities taking place on the Ethereum network. This logging feature is leveraged by Solidity in order to implement events.

Just like the print statements in other languages, events in Solidity are a way for smart contracts to communicate with the outside world and to record important information on the Ethereum network. Events are useful for a variety of purposes, such as tracking the execution of transactions, monitoring the state of a contract, and providing notifications to other contracts or external applications. Understanding how to fetch and utilize these events will enable developers to better audit and monitor the behavior of smart contracts on the network and this article is essentially a guide that will help you do just that. So, LFG!!!

Understanding event logs

In Solidity, an event is a way to log information or data that can be read by external clients. Events are declared using the event keyword, and they allow smart contracts to send asynchronous notifications to clients. Events can be used to notify users of changes in the contract state, and they can be filtered, searched, and archived for future reference.

An event can have parameters, which are typed variables that provide additional information about the event. To emit an event in a Solidity function, use the emit keyword followed by the event name and its parameters. Event logs are stored in the blockchain, and they can be accessed through APIs or block explorers. The following code demonstrates the declaration and emission of events in Solidity:

pragma solidity ^0.8.0;

contract Example {
    event LogSomethingHappened(string message);

    function doSomething() public {
        emit LogSomethingHappened("Something happened!");
    }
}

Event logs are a type of log entry in the Ethereum blockchain that contains information about events that have been triggered by smart contracts. Event logs are created when a contract emits an event in Solidity.

The following are the major components of an event log:

  • Event signature — a unique identifier for the event, which is generated by hashing the event name and its parameter types using the Keccak-256 hash function.
  • Contract address — address of the smart contract that emitted the event.
  • Topics — an array of indexed parameters that provide additional information about the event. Each event can have up to 4 topics in the array, which can be used to filter and search for specific events. The first topic is always the event signature.
  • Data — the field where every non-indexed parameter in an event is added to. It can contain any type of information, including strings, numbers, or arrays. The data is encoded as hexadecimal values.
  • Block number — the number of blocks in the blockchain where the event was emitted.
  • Transaction hash — the hash of the transaction that triggered the event.

To understand these components better, we can refer to an actual contract event. Now, for this, we might as well go for one of the most eventful contracts out there, The Bored Ape Yacht Club (BAYC) contract.

Prerequisites

Before you start writing the code, make sure you have the following components installed on your system:

Once you have everything, the next step is to set up a node project and install the web3.js library.

  1. Create a new directory.

  2. Open a terminal in the directory.

  3. Run the following command to initialize a Node project:

    npm init
    
  4. Provide the details for the project, as per the prompt.

  5. Once the project is initialized, use the following command to install the web3.js package:

    npm install web3
    

Decoding the logs using Bored Apes

The Bored Ape Yacht Club (BAYC) is a non-fungible token (NFT) collectible based on the Ethereum blockchain. The BAYC contract is the smart contract that manages the creation, transfer, and ownership of the Bored Ape Yacht Club NFTs.

Each Bored Ape Yacht Club NFT is unique and can be bought, sold, and traded on various NFT marketplaces. The BAYC contract enforces the rules of ownership and transfer of the NFTs, ensuring that each NFT can only be owned by one person at a time.

The BAYC contract also includes events that are emitted whenever a transfer of an NFT occurs. These events can be monitored by external clients to track the state of the BAYC contract and keep track of transfers that have taken place. To understand the event logs better, let’s take a look at the BAYC contract’s Transfer event :

/**
 * @dev Emitted when `tokenId` token is transferred from `from` to `to`.
 */
event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);

The event consists of three parameters—from, to, and tokenId—and all of them are preceded by the indexed keyword.

Upon emitting this event, an event log will be created and within that log, the event signature will be generated by hashing (using Keccak-256) the name (Transfer) and parameter types (address, address,uint256) of the event. Below is the web3.js code for generating the event signature for the Transfer event:

const Web3 = require('web3');
keccakHash = Web3.utils.keccak256("Transfer(address,address,uint256)")
//output : 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef

A much simpler way to get the event signature is to use a block explorer, for example, Etherscan. When you view the contract details using the Etherscan explorer, there is a specific section dedicated to events and this section contains the latest event logs. You can use these logs to get the event signature—check topic[0] of every log.

The topics array represents all the event parameters that are marked using the indexed keyword: from, to, and tokenId, in our case. These topics help query and identify particular event logs. The topics array can include a maximum of 4 elements (topics) and the first element in the array will always be the event signature.

Each topic (parameter data) in the array can be represented using a maximum of 32 bytes of data and while representing the data, if the data falls short of 32 bytes, you can add filler data to cover the size specification.

Any topic carrying data exceeding the prescribed size limit will be hashed before storing. Due to the size limit, arrays or string type data are not given as indexed parameters in order to avoid them from being part of the topics array.

To explain the topics array better, let’s look at an example. Suppose a Bored Ape NFT with token ID: 8009 was transferred from address 0xdafce4acc2703a24f29d1321adaadf5768f54642 to the address 0xdbfd76af2157dc15ee4e57f3f942bb45ba84af24. The topics array of the generated Transfer event log then will look like this:

topics: [
        //event signature
    '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef',
        // from address
    '0x000000000000000000000000dafce4acc2703a24f29d1321adaadf5768f54642',
        // to address
    '0x000000000000000000000000dbfd76af2157dc15ee4e57f3f942bb45ba84af24',
        // hex representation of the token id
    '0x0000000000000000000000000000000000000000000000000000000000001f49'
  ]

📘

To get the token ID of a particular Bored Ape NFT, you can use NFT marketplaces like OpenSea.

Here, you can see that some of the topics have a lot of zeros in them. These zeros are the fillers used to fill up the 32-byte size specification of the topic fields. I.e. an address is represented using 20 bytes of data, so while we add any address type data to the topics array, zeros amounting to 12 bytes (20 + 12 = 32 bytes) will be added in between to cover the rest of the size specification. The number of zeros in between will defer based on the type of data that is being added.

📘

Why you see more than 32 characters

If you are confused as to why the elements in the topics array have 64 characters (omit 0x) instead of 32, know that they are in hex format and 2 hex digits make up one byte.

All the non-indexed parameters (ones without the indexed keyword) are added to the data section of the log. Unlike topics, the data section cannot be queried. In the case of our Transfer event, since all the parameters are indexed, the data section will be empty and it will simply contain 0x as a placeholder.

All the other fields, like the contract address, block number, and transaction hash contain the respective information. As a whole, the log for the Transfer event will look something like this:

{
  address: '0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D', //contract address
  topics: [ //topics array
    '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef',
    '0x000000000000000000000000dafce4acc2703a24f29d1321adaadf5768f54642',
    '0x000000000000000000000000dbfd76af2157dc15ee4e57f3f942bb45ba84af24',
    '0x0000000000000000000000000000000000000000000000000000000000001f49'
  ],
  data: '0x', //data
  blockNumber: 16547382,
  transactionHash: '0x4bb12bc228be6a9cbe40a7f45d803a15be2cc8b48cc7dd07848e40fed9406950',
  transactionIndex: 116,
  blockHash: '0x1a5d5d08da3ade3e35bff2bebaf0216cb096f4179e34f984e92383d63ab87f1a',
  logIndex: 214,
  removed: false, //true, if the log was removed
  id: 'log_cd90ebee' //log id
}

Alright, now all this looks nice, but how do we actually capture these logs? Well, let’s find out.

Ways to capture the logs

📘

Get an Ethereum node

One of the most crucial components that we need in order to capture the event logs is access to an Ethereum node. To set up your own node:

  1. Head over to Chainstack and set up an account.
  2. Once you have your account, deploy an Ethereum mainnet node in Chainstack.

As with many other functionalities of Ethereum, the method for capturing the event logs includes a curl way and a code way.

Using curl to capture event logs

To get the event logs using curl, we need to invoke the eth_getLogs RPC method. This
method retrieves the logs that match certain filter criteria defined by the user. The filter criteria can include things like the address of the contract emitting the logs, the topics of the logs, and the range of blocks to search. Here is the sample curl request for fetching the Transfer event logs from the BAYC contract:

curl <HTTP://chainstack-node-url>   -X POST \
-H "Content-Type: application/json" \
--data '{"method":"eth_getLogs","params":[{"fromBlock": "0xFC7FA4","toBlock":"latest","address": "0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D","topics":["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"]}],"id":1,"jsonrpc":"2.0"}'

As you can see, within the params of the request, we have provided the fromBlock and toBlock fields. They are used to represent the range of blocks to search. While providing the range, keep in mind that the larger the range, the higher the number of logs and thus the higher the response size.

📘

Recommended block range cap

Even though there are no limitations set in stone, in order to avoid node resource wastage and bulky response payloads, it is recommended to cap the block range at 8,000 for Chainstack Ethereum nodes.

While providing block numbers for the range, you can either give the hexadecimal representation or use words like latest to refer to the latest block. You can also use pending and earliest for referring to transactions that are not yet mined.

The address field in the request parameter helps filter the events based on the contract (represented using the address) from which it was emitted. Since a contract can emit multiple events, we can use the topics field to provide additional filtering. Here, within the topics array, we have provided the signature of the Transfer event—topics[0]. This will get us the logs of only the Transfer event from the BAYC contract.

Using JavaScript to fetch the logs

The web3.js library provides a far more convenient way to fetch the event logs. To do this, we make use of the subscribe() function. The function helps us subscribe to incoming logs, which we can filter and process using different options. The following script help subscribe to the Transfer event logs from the BAYC contract:

const Web3 = require('web3');

//node url
const web3 = new Web3("wss://chainstack-node-endpoint");

//contract address
const BAYC_CONTRACT_ADDRESS = '0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D'
//event signature
const BAYC_TRANSFER_SIGN = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'

var subscription = web3.eth.subscribe('logs', {
    fromBlock: '<START_BLOCK>',
    toBlock: 'latest',
    address: BAYC_CONTRACT_ADDRESS,
    topics: [BAYC_TRANSFER_SIGN]
}, function(error, result){
    if (!error)
        console.log(result);
})
.on("connected", function(subscriptionId){
    console.log(subscriptionId);
})
.on("data", function(log){
    console.log(log);
})
.on("changed", function(log){
});

// unsubscribes the subscription
subscription.unsubscribe(function(error, success){
    if(success)
        console.log('Successfully unsubscribed!');
});

Using the above script, we are setting up a subscription channel that will capture all the logs that were generated within the given block range (fromBlock,toBlock). In the script, while providing the block number, you can directly use the decimal representation of the number.

Here, since we are expecting a stream of data, we are using the WSS endpoint of our Chainstack node. Just like our curl request, we have provided the contract address and the event signature within the topics field as a means to filter the events.

To provide more filtering, you can specify more topics in the topics array. To demonstrate this, here is a script that subscribes to the Transfer event log of a particular BAYC NFT based on its token ID:

const Web3 = require('web3');

//node URL
const web3 = new Web3("wss://chainstack-node-endpoint");
    
//contract address
const BAYC_CONTRACT_ADDRESS = '0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D'
//event signature
const BAYC_TRANSFER_SIGN = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'

//token id
var token_id = <token_id>

//get the hex representation of the token id
var hex = web3.utils.toHex(token_id)
// add zeros to cover the 32 byte size limit
var hex_32 = web3.utils.padLeft(hex,64)

var subscription = web3.eth.subscribe('logs', {
    fromBlock: '<START_BLOCK>',
    address: BAYC_CONTRACT_ADDRESS,
    topics: [BAYC_TRANSFER_SIGN,null,null,hex_32]
}, function(error, result){
    if (!error)
        console.log(result);
})
.on("connected", function(subscriptionId){
    console.log(subscriptionId);
})
.on("data", function(log){
    console.log(log);
})
.on("changed", function(log){
});

// unsubscribes the subscription
subscription.unsubscribe(function(error, success){
    if(success)
        console.log('Successfully unsubscribed!');
});

In the above-given script, we have provided the hex value of the token ID in our topics array. This script will only fetch the Transfer event logs that were generated while transferring that particular NFT. When providing topics to the topics array, we can use the null value to represent all the topics whose values we didn't specify (the from and to address in this case). Also, before adding the token ID to the array, we used the padLeft() function to add zeros to the hex value, in order to cover the 32-byte size specification.

And with that, we have covered all the major ways of fetching Ethereum event logs.

Conclusion

Ethereum event logs play a crucial role in providing transparency and accountability in DApps. They serve as a record of all activities taking place on the blockchain, enabling developers and users to retrieve and analyze important information. The process of fetching these logs is relatively straightforward and can be done using various tools and libraries, such as curl or web3.js. By understanding the importance of event logs and learning how to access them, we can gain deeper insights into the inner workings of Ethereum-based DApps and make more informed decisions.

📘

See also

About the author

Sethu Raman Omanakuttan

🥑 Developer Advocate @ Chainstack.
🛠️ BUIDLs on Ethereum, NEAR , Graph Protocol and Oasis.
💻 Majored in computer science and technology.
Sethu Raman | GitHub Sethu Raman | Twitter Sethu Raman | LinkedIN