Real-time Updates in Your dApp: Leveraging Smart Contract Events
Decentralized applications (dApps) are powered by smart contracts and blockchain technology. To ensure the data displayed in a dApp accurately reflects the underlying smart contract’s state at all times, a mechanism is needed for real-time updates. Smart contract events provide a cheap and efficient way to achieve this.
What are Smart Contract Events?
Think of smart contract events as messages, notifications, or logs emitted by smart contracts for external applications to use. These events are not directly accessible on the blockchain, not even to the smart contract that emitted them.
Events are typically used to notify interested parties that something has changed within the smart contract. When emitted, events often include relevant state values or information related to the change. Values can be either indexed or non-indexed. Indexed values are recorded as topics, allowing for efficient searching based on those specific values. Non-indexed values are stored in the event’s data field.
In Solidity, up to four parameters can be indexed in an event. However, for non-anonymous events, the first slot is always occupied by the event signature, leaving a maximum of three parameters for indexing. This signature is useful because it allows filtering for all instances of that specific event whenever it’s emitted. Anonymous events, lacking the event signature in their topics, allow indexing up to four parameters but are generally less preferred.
Where are Emitted Events Stored?
Events are not stored directly on the blockchain, making them cheaper to work with compared to writing to storage slots. Instead, they are recorded in the receipt of the transaction that emitted them. This ensures accessibility regardless of how long ago the event was emitted. If you can access the transaction receipt, you can retrieve the emitted events.
The image above depicts a terminal window interacting with the blockchain through Ethers.js’ JsonRpcProvider to retrieve logs (emitted events) from a USDT transfer transaction receipt. The “topics” array contains the event signature alongside the indexed parameters (sender and receiver). Since the amount is not indexed, it’s found in the “data” field. Both topics and data are ABI-encoded, requiring decoding to obtain the actual values.
Event Use Cases in dApp Frontends
Since events notify the outside world about changes within a smart contract, dApp frontends can rely on them to know when to update their state. Additionally, because events are recorded in transaction receipts, dApp frontends can query for past events whenever needed.
For example, dApps that want to display token transfer history can retrieve past transfer events from the token contract and display them to users.
How to Work with Smart Contract Events in Your dApp
- Through the Contract Instance: This involves using the instance of the contract that emits the events.
- Through a Provider Instance: This approach allows listening to events emitted by any contract on the blockchain network.
Ethers.js enables listening for events in real-time or retrieving past events. Technically, this is achieved by searching past block transaction logs for events where any of their topics match the topics specified in the event filter object. This can be done using either the smart contract instance or a provider object.
What is an Event Filter?
Whether you want to listen for real-time events or retrieve past events, you’ll need an event filter object. This object has two optional properties:
- address: The address of the contract that emits the events you’re interested in.
- topics: An array containing topics to filter by. Each topic-set within the array represents a condition that must be met by the corresponding indexed log topic (essentially an AND operation between conditions).
For example, filter for Transfer event of USDT token on ethereum mainnet will be
const filter = {
address: "0xdAC17F958D2ee523a2206206994597C13D831ec7",
topics: [
“0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef”
]
}
This filter only considers the event signature itself (hashed using ethers.id("Transfer(address,address,uint256)"
) and doesn't filter by the addresses involved in the transfer. Consequently, it would match every transfer between any addresses.
To be more specific, let’s say you want to get transfer events from address 0xd5E4484326EB3Dd5FBbd5Def6d02aFE817fD4684
to address 0x9702ddCD32d351A378639eA4e0F25Cf820c0BC7E
. You would need to ABI-encode the addresses and include them in their respective places in the topics array. Here's how to achieve this using Ethers.js:
const fromAddressTopic = ethers.AbiCoder.defaultAbiCoder().encode(["address"], ["0xd5E4484326EB3Dd5FBbd5Def6d02aFE817fD4684"]);
const toAddressTopic = ethers.AbiCoder.defaultAbiCoder().encode(["address"], ["0x9702ddCD32d351A378639eA4e0F25Cf820c0BC7E"]);
const filter = {
address: "0xdAC17F958D2ee523a2206206994597C13D831ec7",
topics: [
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
fromAddressTopic,
toAddressTopic
]
};
The filter above will only match the events emitted for USDT transfers sent from 0xd5E4484326EB3Dd5FBbd5Def6d02aFE817fD4684
to 0x9702ddCD32d351A378639eA4e0F25Cf820c0BC7E
.
Additional Notes on Filter Objects:
- The
address
property is optional. If not specified, it matches any address and therefore listens for transfer events from any contract. - The
topics
property is also optional. If not specified, any log matches, and therefore your event handler is fired for every event emitted by the contract. - Each topic-set in the topic array refers to a condition that must match the indexed log topic in that position (i.e. each condition is AND-ed together).
- Filters can also be generated using the Contract or Interface API provided by Ethers.js. Here’s an example:
const contract = new ethers.Contract(...);
const filter = contract.filters.Transfer; // Listens for all Transfer events
- You can further filter events by specifying the sender or receiver addresses as arguments to the filter function. For instance:
const filter = contract.filters.Transfer("0xd5E4484326EB3Dd5FBbd5Def6d02aFE817fD4684"); // Only this sender matches
const filter = contract.filters.Transfer(null, "0x9702ddCD32d351A378639eA4e0F25Cf820c0BC7E"); // Only this receiver matches
const filter = contract.filters.Transfer("0xd5E4484326EB3Dd5FBbd5Def6d02aFE817fD4684", "0x9702ddCD32d351A378639eA4e0F25Cf820c0BC7E"); // Both sender and receiver match
Getting Past Events
To retrieve past events using a Provider instance, there are two additional properties you can use in the filter object:
fromBlock
: The block number at which to start searching for events. Defaults to 0 (genesis block) if not specified.toBlock
: The block number at which to stop searching for events. Defaults to'latest'
block if not specified.
Be mindful of the limitations imposed by your node provider when setting a large range for fromBlock
and toBlock
. It's generally recommended to search within a reasonable timeframe.
Using a Provider Instance
Here’s an example of retrieving past USDT transfer events between blocks 4634748 and 4634848:
const filter = {
address: "0xdAC17F958D2ee523a2206206994597C13D831ec7",
topics: [
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
],
fromBlock: 4634748,
toBlock: 4634848
};
const pastUsdtTransferEvents = await provider.getLogs(filter);
In the snippet above,pastUsdtTransferEvents
will be an array containing all transfer logs emitted between the specified block range.
Using a Contract Instance
The queryFilter
function of the contract instance allows you to retrieve past events. It takes the following arguments:
- eventName (string or “*”): The name of the event to filter by, or a wildcard to match all events.
- fromBlock (optional): The block number at which to start searching for events. Defaults to 0 if not provided.
- toBlock (optional): The block number at which to stop searching for events. Defaults to ‘latest’ if not provided.
Here’s an example of retrieving all Transfer events emitted between blocks 19453311 and 19453326:
const pastEvents = await contract.queryFilter("Transfer", 19453311, 19453326);
In the snippet above,pastEvents
will be an array containing all Transfer events emitted within the specified block range.
Listening for Real-time Events
We can also listen for real-time events using either the provider instance or the contract instance.
Using a Provider Instance
ethers Provider has an on
method that can be used to listen for real-time events by passing it a filter object and a function.
const filter = { ... }; // Same filter object from previous examples
provider.on(filter, (eventLog) => {
console.log("USDT transfer:", eventLog);
});
This code snippet will log details whenever a USDT transfer event occurs.
Using a Contract Instance
This approach is generally preferred as it leverages the contract’s ABI for type safety and easier decoding. Here’s how to achieve it:
const filter = contract.filters.Transfer; // filters for all Transfer events
contract.on(filter, (sender, receiver, amount) => {
console.log(`Transfer of ${amount} USDT from ${sender} to ${receiver}`);
});
The code snippet above listens for all Transfer events and logs the sender, receiver, and transfer amount.
Alternatively, you can listen for events by name (without a filter object):
contract.on("Transfer", (sender, receiver, amount) => {
console.log(`Transfer of ${amount} USDT from ${sender} to ${receiver}`);
});
If you want to listen for absolutely all events emitted by a contract, you can use a wildcard character (*
) in place of the event name:
contract.on("*", (...args) => {
console.log("Event:", args); // Array containing event data
});
Decoding Event Data
When using the provider instance to listen for events, the retrieved data is encoded. To extract meaningful values, you’ll need to decode them. Ethers.js provides the ethers.AbiCoder.defaultAbiCoder().decode
function for this purpose.
Here’s how to decode the data from a retrieved event log:
- Identify the value types included in the event (refer to the contract’s ABI).
- Create a type array containing the data types in the same order they appear in the event.
- Use the
decode
function to extract the actual values:
const sender = ethers.AbiCoder.defaultAbiCoder().decode(["address"], event.topics[1]);
const receiver = ethers.AbiCoder.defaultAbiCoder().decode(["address"], event.topics[2]);
const amount = ethers.AbiCoder.defaultAbiCoder().decode(["uint256"], event.data);
console.log(`Transfer of ${amount} USDT from ${sender} to ${receiver}`);
This code snippet decodes the sender, receiver, and amount from a Transfer event log.
Conclusion
Understanding how to work with smart contract events is essential for building interactive dApp frontends. By effectively using filters, listening for real-time or past events, and decoding event data, you can ensure your dApp stays up-to-date with the underlying smart contract’s state.
This article has covered the fundamentals of working with smart contract events in dApps. Remember to refer to the Ethers.js documentation for better understanding of smart contract events.
And That does it for this article.
Don’t forget to drop a clap and share if you find it helpful. Also do well to drop a comment if you have any question or would like to discuss more on this topic
Resouces
https://medium.com/coinmonks/learn-solidity-lesson-27-events-f47070b55851
https://www.youtube.com/watch?v=ClOATp_GuM4