Advanced Solidity Patterns

Events, Logging & Contract Interactions

Emit events for off-chain listeners and learn how contracts communicate with each other.

20 min

Understanding Events and Logs

Events in Solidity are the primary mechanism for smart contracts to communicate with the outside world. When a contract emits an event, the data is stored in the transaction's log — a special data structure on the blockchain that is not accessible to contracts but can be efficiently queried by off-chain applications. Events cost significantly less gas than storage operations, making them the preferred method for recording historical data that contracts do not need to read.

Declaring an event uses the "event" keyword: "event Transfer(address indexed from, address indexed to, uint256 value)". The "indexed" keyword on a parameter creates a topic — a searchable field that external applications can filter on. You can have up to three indexed parameters per event. Non-indexed parameters are stored in the event's data section and are cheaper but cannot be filtered directly.

To emit an event, use the "emit" keyword: "emit Transfer(msg.sender, recipient, amount)". Every ERC-20 token transfer emits this event, which is how wallet applications and block explorers track token movements. Without events, there would be no efficient way to know that a transfer occurred without replaying every transaction.

Under the hood, each event becomes a LOG opcode in the EVM. LOG0 through LOG4 correspond to events with zero to four topics. The first topic is always the keccak256 hash of the event signature (unless the event is declared as anonymous). Indexed value-type parameters become additional topics, while indexed reference-type parameters are stored as their keccak256 hash. Understanding this encoding helps when you need to decode raw log data or build custom event indexing solutions.

Listening to Events from Front-End Applications

Events become truly powerful when combined with front-end applications. Using Ethers.js, you can subscribe to real-time events and query historical events efficiently. To listen for new events: "contract.on('Transfer', (from, to, value, event) => { console.log(from, to, value.toString()); })". This callback fires every time the contract emits a Transfer event, enabling real-time UI updates without polling.

For historical data, use event filters: "const filter = contract.filters.Transfer(userAddress, null)". This creates a filter for all transfers from a specific address. You then query past blocks: "const events = await contract.queryFilter(filter, fromBlock, toBlock)". Each event object contains the decoded arguments, block number, transaction hash, and log index. This is how analytics dashboards reconstruct complete transaction histories.

Indexed parameters make filtering efficient. Without indexing, the node would need to decode every event to check if it matches your criteria. With indexed topics, the node can filter at the storage level using bloom filters — a probabilistic data structure that each block stores for rapid log lookup. This is why choosing which parameters to index is an important design decision. Index parameters that users will commonly filter by, like addresses in transfer events.

Best practices for events include emitting events for every significant state change, including enough data for off-chain reconstruction, and maintaining consistent event signatures across contract upgrades. Some projects define events in interfaces to ensure compatibility. Remember that event data is not available to other contracts — if a contract needs to know about a state change in another contract, it must use function calls, not events.

Contract-to-Contract Interactions

Smart contracts frequently need to call functions on other contracts. This inter-contract communication is fundamental to composability — the ability to combine existing protocols like building blocks. There are several ways contracts can interact, each with different trade-offs for safety, gas cost, and flexibility.

The safest approach is calling functions through a typed interface. First, define an interface: "interface IERC20 { function transfer(address to, uint256 amount) external returns (bool); function balanceOf(address account) external view returns (uint256); }". Then use it: "IERC20 token = IERC20(tokenAddress); bool success = token.transfer(recipient, amount);". The compiler verifies that you call valid functions with correct parameters, and reverts propagate automatically.

Low-level calls using address.call provide more flexibility but less safety: "bytes memory data = abi.encodeWithSignature('transfer(address,uint256)', recipient, amount); (bool success, bytes memory returnData) = tokenAddress.call(data);". With low-level calls, the calling contract does not revert if the called contract reverts — you must check the success boolean manually. This is useful when you need to handle failures gracefully or when the target contract's interface is not known at compile time.

The delegatecall function is a special variant that executes another contract's code in the context of the calling contract. The called contract's code runs with the caller's storage, msg.sender, and msg.value. This is the foundation of proxy patterns and upgradeable contracts. However, delegatecall is dangerous if misused — the called contract can modify any storage slot in the calling contract, so you must ensure storage layouts are compatible. Never delegatecall to untrusted contracts, as they could overwrite your contract's critical state variables.

Events, Logging & Contract Interactions — Advanced Solidity Patterns | Crypto Hawking