Principles and Best Practices to Design Solidity Events in Ethereum and EVM
Solidity events are a crucial feature of Ethereum and EVM blockchains, with a vast number of use cases within the ecosystem. Primary use cases, for example, include but not limited to
- Logging: where events provide a mechanism to log critical actions and state changes inside smart contract, for track contract behaviors and debugging
- Off-Chain Notification: where offchain applications can listen to and monitor specific onchain actions of smart contracts, and trigger downstream logic
- Data Indexing, Processing and Analytics: where events can be indexed, stored, processed, aggregated and analyzed, empowering developers and analysts to gain data points, insights and patterns of smart contract
- Cross-Contract Communication: where smart contract can use events as a lightweight mechanism to communicate with each other
- Security and Monitoring: where events provide a detailed record of actions taken by smart contracts, that can be monitored in real time for emergency and security
Solidity events are critical to EigenLayer. The protocol emits a wide range of events on Ethereum, their use cases span across the protocol, stack and ecosystem. For example
- EigenLayer smart contracts log events for testing and debugging
- Several offchain applications listen to onchain events and trigger event driven logic, like subscribing to Beacon chain state oracle in order to process EigenPods related logic
- Events are indexed into data store and data lake, to power analytics both internally like internal BI dashboards and externally like community boards on Dune, answering wide range of questions from all angles about EigenLayer, like how many restakers are there and how much have they delegated, how many Operators and AVSs are registered and what’s their registration mapping relationship
- A variety of events are processed to support critical protocol features like calculating reward, and soon slashing
- Withdrawal events are being monitored in real time to alert team on any unexpected behaviors and potential risks
Thus, designing Solidity events in an efficient, scalable, cost-effective way and establishing a set of guidance and best practices, are critical for EigenLayer, as well as other protocols on Ethereum and EVM.
There has been a lot of content online of best practices and how-to of designing smart contracts, but little to no content on best practices of designing events.
Today I present the methodologies and best practices of designing Solidity events, so you can maximize their value as a foundation layer for empowering web3.
1/ Descriptive
Events describe what have happened onchain, thus should be self descriptive. Others should be able to immediately understand what the event is about by reading its name and schemas. When defining a new event, be deliberately specific. Avoid acronyms in naming.
DONT:
URE {
id
}
what is URE? is the id a user id, transaction id, or some other id?
DO:
UserRegisteredEvent {
user_wallet_address
}
I immediately understand what this event is entirely about, thanks to full event name and its fields in schema.
2/ Factual, by semantics
Events should match exactly what happened onchain. No mask, avoid any unconscious intention, be brutally honest of what the event means by semantics.
Ambiguous and mismatched semantics will result in disasters in downstream applications.
Think of an example where a system allows users to request deletion of their account. The safest way is to let users submit a request to delete their account, enqueue the request, and schedule actual deletion 7 days (or some other time period) later as buffer in case the user regrets and withdraws the request.
DONT:
Developers may unconsciously think the initial request is an actual deletion action and come up with an event like:
UserDeletionEvent {
user_id
}
But when downstream application developers see the event, they may highly likely interpret it as the account should be deleted ASAP or already been deleted, and doing unintended actions like delete user data, which will result in user data loss.
DO:
Match events exactly as what they mean by semantics, by clarifying it’s only a “request” event
UserDeletionRequestedEvent {
user_id
}
3/ Atomic and Composable
Actions and behaviors sometimes can consist of sub actions. Event design should maintain the fine granularity of behaviors by breaking down these complicated actions into smaller, atomic behaviors which can’t be further broken down. These atomic events together can then restore the overall picture of history.
Continue with the example above.
DONT:
The developer takes a shortcut by combining the two steps in a single deletion action and event, trying to save some time and effort
UserDeletionEvent {
user_id
request_time
deletation_time // which is request time plus 7 days
}
However, there are obvious flaws:
- the 2nd steps may not happen, or may happen at a different time due to whatever reason, eg, system failure and retry, so the event will actually be inaccurate
- the end-to-end “deletion” process actually consist of several events rather than two. e.g. request may be withdrawn later which will result in a 3rd event type as “UserDeletionWidthdrawnEvent” while the above monolithic event has not considered or covered
DO:
Break the seemingly-simple action into actual atomic sub steps
UserDeletionRequestedEvent {
user_id
request_time
}
UserDeletionWithdrawnEvent {
user_id
withdraw_request_time
}
UserDeletionCompletedEvent {
user_id
deletation_time
}
4/ Self-contained
An event should define itself well and contain all necessary information to interpret it, without requiring external information or as minimal external dependency as possible.
Plus, events are usually used by downstream applications and developers, thus making events self-contained is key to simplify downstream developers’ life and overall system complexity, without requiring extra development and maintenance of pipelines to join with external data via database or rpc calls.
DONT:
UserDepositEvent {
user_id
deposit
}
Wait! Where does it deposit from and to? Deposit what? I need to join other events to get this information?!
DO:
UserDepositEvent {
user_id
erc20_token
amount
from_address
to_address
}
Whereas with above event schema, you got everything you need to interpret this event!
DONT 2:
externalize data by requiring retrieving them via external database or rpc calls, making it self-incomplete.
XEvent {
external_url
}
there are so many problems with this event:
- Content inside the external url is not accessible or verifiable onchain, subject to fraud and security breach if anyone consumes the url directly
- Content inside the url may change overtime and you have no visibility of what has changed or know what it was originally
- The url may be even inaccessible from offchain anymore if the owner brings it down, so what was actually in there? We lost the entire context and facts about it
So DO NOT put external link onchain or in an event!
DO:
For whatever you needed onchain, put them onchain and into an event directly!
5/ Symmetric
Many onchain actions are symmetric, for example, users can register and deregister, deposit and withdraw, numbers can increase and decrease.
When mapping these actions to events, developers should do their best to make them symmetric and keep the beauty of engineering to reflect real world situations.
Think of an example of a user interacting with a wallet.
DONT 1:
WalletBalanceChangedEvent {
wallet_address
balance_changed_amount // the delta
// ...
}
First, this doesn’t describe what the actual action is, only describes a side effect of the balance is changed.
Second, the source action is unclear since it can come from a large set of potential actions like deposit, withdrawal, refund, reimburse, penalty, etc. Downstream users of the event do not know which exactly it is. It violates principle number 2 “factual” that an event should be factual.
Last but not the least, such event design creates a lot of frictions for downstream developers to use it. Eg. they can’t tell if the balance increases (positive delta) or decreases (negative delta), unless actually reading the value and comparing it with 0, which always comes with extra cost and complexity. In very common use case of “I wanna check how much I have withdrawn this week”, developers would have to go thru all events one by one, rather than just a subset of events explicitly named `Withdrawal`
DONT 2:
WalletDepositEvent {
wallet_address
from_address
amount
// ...
}
WalletWithdrawEvent {
wallet_address
[]to_addresses
[]amounts
// ...
}
It is bad because:
First, it violates principle 3 “Atomic” because each withdraw is an independent action/event itself regardless of whether your bank or dex or protocol allows batching
Second the asymmetry makes processing this data much harder - processing, storing, and retrieving these events requires different handling logic throughout your codebase, with one handling one value V.S. the other handling an array of values
DO:
WalletDepositEvent {
wallet_address
amount
from_address
}
WalletWithdrawEvent {
wallet_address
amount
to_address
}
Definition of the events are symmetric, which makes the E2E data handling easy.
6/ Flat, not deeply nested
Another anti pattern in above DONTs example is the event contains nested fields.
WalletWithdrawEvent {
wallet_address
[]to_addresses // nested fields
[]amounts // nested fields
// ...
}
It’s not the worst until you see even more deeply nested fields like
X {
[]Y
}
Y {
[]Z
}
This is a nightmare for downstream developers to work with!
Nested raw events are not usable at all, 10 out of 10 times have to be flattened before it can be used, whether in SQL or any programming language. Believe it or not, flattening nested objects is not a fun job anywhere, especially SQL. Not to mention you have to figure out different SQL dialects for flattening. Plus, in order to work with the flattened results efficiently, they usually have to be materialized and stored somewhere as intermediate results, with additional burden of operating a processing pipeline and extra compute/storage cost.
To make it easier for your developer community, make the event flat.
WalletWithdrawEvent {
wallet_address
to_address
amount
// ...
}
Some may argue that emitting flat events are too costly on Ethereum, and nested events will help save cost. In my honest opinion, if developers see the event cost is a major blocker, they should probably consider moving to an L2 and flatten your events there!
7/ Entities and Domain Oriented
Categorize events into entities or domains. There might be actors like “users”, “stakers”, or “operators”, or domains like “servers”, “clients”. They are usually the subjects to initiate actions.
It’s highly recommended to evaluate and design your domains and entities, before actually programming anything. It helps with overall design and coming up with event names, e.g. for a “staker”, you can then think of all valuable actions or informations to capture around them.
Naming would be easier as well, a simple naming convention is “<Entity/Domain><Action>Event”, like “UserLoginEvent” and “UserRequestDeletionEvent”
Defining events around entities and domains make downstream usage much easier. Events can be organized and stored together by entities/domains, to provide domain/entity specific dataset and services; data discovery is easier by nature hierarchies; associations and relationships to link different events are simple, etc.
8/ Other Technical considerations
- Control event size and frequency - don’t blow it up
- Consider cost - emit from onchain vs offchain
- etc
Conclusion
In summary, Solidity events are mission-critical to EigenLayer and its ecosystem, as well as any protocol on Ethereum and EVM, and serve many primary use cases.
We described a variety of design principles and best practices, with concrete examples, to design Solidity events in an efficient, scalable, cost-effective and developer friendly way.