🌿 Intermediate ⏱️ 25 min

Events & Logs

Events are Ethereum's notification system. When a contract changes state, it should announce that change through events so that off-chain systems — frontends, indexers, monitoring tools, and security alert services — know what happened. A contract that silently changes state without emitting events is both a transparency failure and a security red flag. This lesson covers event mechanics, emission best practices, and how events power security monitoring in production protocols.

📖 Events vs Storage — The Key Distinction

Events are stored in the transaction receipt's log bloom filter — not in contract storage. This means contracts cannot read their own events. The EVM's LOG0LOG4 opcodes write to logs, not to state. This makes events far cheaper than storage (375 gas for a basic log vs 22,100 for a cold SSTORE) but completely inaccessible to on-chain logic. Events are for off-chain consumers only.

Event Declaration and Emission

📡 Declaring and Emitting Events
contract TokenWithEvents { // Event declaration — outside functions, at contract level event Transfer( address indexed from, // indexed = stored as topic (searchable) address indexed to, // max 3 indexed params uint256 value // non-indexed = stored in data ); event Approval( address indexed owner, address indexed spender, uint256 value ); event OwnershipTransferred( address indexed previousOwner, address indexed newOwner ); mapping(address => uint256) public balances; // Emitting an event with the 'emit' keyword function transfer(address to, uint256 amount) external { balances[msg.sender] -= amount; balances[to] += amount; emit Transfer(msg.sender, to, amount); // emit AFTER state changes } }

Indexed Parameters: Topics vs Data

When a contract emits an event, the EVM writes a log entry with up to four topics and a data field. Topics are hashed and stored in the bloom filter — making them searchable. Data is cheaper but requires full decoding to filter:

📊 How Event Logs Are Structured On-Chain
// emit Transfer(0xAlice, 0xBob, 1000e18); // Creates a log entry with: // topics[0] = keccak256("Transfer(address,address,uint256)") // = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef // topics[1] = 0x000000000000000000000000Alice... (from — padded address) // topics[2] = 0x000000000000000000000000Bob.... (to — padded address) // data = abi.encode(1000e18) // = 0x0000000000000000000000000000000000000000000000056BC75E2D630FFFFF // Gas cost breakdown: // LOG1 (1 topic): 375 + 375 = 750 gas // LOG3 (3 topics): 375 + 375*3 = 1500 gas // + 8 gas per byte of data // Total Transfer event: ~1,500 + (32 * 8) = ~1,756 gas // Compare: SSTORE cold = 22,100 gas — events are ~12x cheaper

Reading Events Off-Chain

💻 Querying Events with ethers.js
// Using ethers.js v6 to query events: const filter = token.filters.Transfer( fromAddress, // filter by 'from' topic (null = any) null, // any 'to' address ); const events = await token.queryFilter(filter, startBlock, endBlock); for (const event of events) { console.log( event.args.from, // decoded from topic event.args.to, // decoded from topic event.args.value // decoded from data ); } // Indexed parameters enable efficient server-side filtering: // Ethereum nodes use bloom filters to skip blocks with no matching logs // Non-indexed params require full data decoding — cannot be filtered by node // Using The Graph for production indexing: // Define event handlers in a subgraph schema // The Graph indexes events into a GraphQL API

Events in Security: Audit Trails and Monitoring

🚨 Security Event Patterns — What to Always Emit
contract SecurityAwareContract { // ALWAYS emit events for access control changes event OwnershipTransferred(address indexed from, address indexed to); event RoleGranted(bytes32 indexed role, address indexed account); event RoleRevoked(bytes32 indexed role, address indexed account); // ALWAYS emit for parameter changes (governance-critical) event FeeUpdated(uint256 oldFee, uint256 newFee); event PriceFeedUpdated(address indexed oldFeed, address indexed newFeed); event Paused(address indexed account); event Unpaused(address indexed account); // ALWAYS emit for large fund movements event EmergencyWithdraw(address indexed to, uint256 amount); function setFee(uint256 newFee) external onlyOwner { uint256 oldFee = fee; fee = newFee; emit FeeUpdated(oldFee, newFee); // log both old and new values } }

Common Event Emission Bugs

⚠️ Bug 1: Missing emit After State Change

A contract that modifies balances, roles, or configuration without emitting an event is invisible to off-chain indexers. The frontend will show stale data. Security monitoring services like OpenZeppelin Defender and Forta will miss the event entirely. This is an audit finding in every ERC20 token — always emit events for every state change that matters to external parties.

⚠️ Bug 2: Emitting Wrong Values (Old vs New)

A common mistake when logging a value change is emitting the variable's value before the update rather than after — or vice versa. For example, emitting FeeUpdated(fee, newFee) before executing fee = newFee is correct. But if the line order is reversed, the "old" value in the event is actually the new one. Always double-check that event arguments match what was actually written to storage.

🚨 Bug 3: Events That Don't Match Actual State Changes

Emitting a misleading event is arguably worse than emitting no event, because it actively deceives monitoring systems. A classic pattern: a function emits Transfer(from, to, amount) but only transfers a fraction of that amount, or the transfer conditionally fails. Security monitoring tools trigger on events, not on raw state diffs — a false event can cover up an exploit or make a rug pull look like normal activity.

Reentrancy and Event Emission Order

💡 Emit AFTER State Changes — The Correct Pattern
// CEI (Checks-Effects-Interactions) applies to events too // Emit AFTER state changes — not before // WRONG: emitting before state change function badWithdraw(uint256 amount) external { emit Withdrawal(msg.sender, amount); // emits BEFORE transfer balances[msg.sender] -= amount; // state change payable(msg.sender).call{value: amount}(""); // If call reenters and reverts state, event is already emitted! // Off-chain shows a withdrawal that may be reversed } // CORRECT: state change first, then event function goodWithdraw(uint256 amount) external { balances[msg.sender] -= amount; // 1. state change emit Withdrawal(msg.sender, amount); // 2. event payable(msg.sender).call{value: amount}(""); // 3. external // If reentrancy occurs here, state is already updated }

Testing Events with Foundry

✏️ Try It — vm.expectEmit() in Foundry Tests
contract TokenTest is Test { MyToken token; address alice = address(0xA1); address bob = address(0xB0); function setUp() public { token = new MyToken(); token.mint(alice, 1000e18); } function test_TransferEmitsEvent() public { vm.startPrank(alice); // vm.expectEmit(checkTopic1, checkTopic2, checkTopic3, checkData) // true = check this parameter matches vm.expectEmit(true, true, false, true); emit MyToken.Transfer(alice, bob, 100e18); // expected event token.transfer(bob, 100e18); // actual call // Test fails if Transfer(alice, bob, 100e18) is not emitted } function test_NoEventMeansNoTransfer() public { // Verify missing event = bug detector vm.recordLogs(); token.transfer(bob, 100e18); Vm.Log[] memory logs = vm.getRecordedLogs(); assertEq(logs.length, 1); // exactly 1 event expected } }

Security Monitoring with Events

Event TypeSecurity ValueTool to MonitorAlert Priority
OwnershipTransferredAccess control changeOpenZeppelin Defender, FortaCritical
Paused / UnpausedCircuit breaker triggeredDefender SentinelHigh
Upgraded (proxy)Logic replacedDefender, custom botCritical
Large TransferUnusual fund movementForta detection botHigh
RoleGrantedPermission escalationDefender SentinelCritical
EmergencyWithdrawFunds leaving in emergency modeCustom webhookCritical

Key Takeaways

  • Events are stored in transaction receipts (not contract storage) via LOG opcodes — they are about 12x cheaper than SSTORE and completely inaccessible to on-chain code.
  • Indexed parameters are stored as topics in a bloom filter, making them searchable by node infrastructure. Non-indexed parameters go into the data field — cheaper but not filterable by nodes.
  • A maximum of 3 parameters can be indexed per event (topics[1], [2], [3]) — topics[0] is always the event signature hash.
  • Always emit events after state changes, not before — following the CEI pattern prevents stale or misleading event data if a subsequent revert rolls back state.
  • Security monitoring services (Defender, Forta) operate on events, not storage diffs — missing events means missing security alerts.
  • Test event emission with Foundry's vm.expectEmit() — verify not just that events fire but that they carry the correct values.