JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

Modifiers, require, events, and custom errors: guarding and logging

Four small tools turn a contract from a toy into something you'd trust with money: modifiers that guard who can call, require/revert that reject bad input, custom errors that say why cheaply, and events that announce what happened to the outside world.

A bouncer and a logbook

Picture a members-only club. At the door stands a bouncer who checks every arrival against one rule — *are you on the list?* — and turns away anyone who isn't. Inside, a logbook records every meaningful event: who entered, who paid, who was promoted to manager. The bouncer decides who may act; the logbook is the permanent, public record of what was done.

A Solidity contract needs both. By now you can declare state, write functions, and choose their visibility; you can model data with mappings and structs. But an open function anyone can call, with no checks and no record, is how contracts get drained. This guide adds the guard rails. Four tools: modifiers (the reusable bouncer), require/revert (reject bad calls), custom errors (say why, cheaply), and events (the logbook the outside world reads).

Modifiers: a reusable guard and the magic underscore

A function modifier is a snippet of code you can attach to many functions to run a check before (or after) the function body. The body of the function is spliced in wherever you write the placeholder `_;`. Think of it as a wrapper: everything before `_;` runs first, then the real function, then anything after `_;`.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract Vault {
    address public owner;

    constructor() {
        owner = msg.sender;          // whoever deploys becomes the owner
    }

    // The bouncer: run this check, then (at _;) the function body
    modifier onlyOwner() {
        require(msg.sender == owner, "Not the owner");
        _;
    }

    uint256 public threshold;

    // Reuse the same guard on any privileged function
    function setThreshold(uint256 v) external onlyOwner {
        threshold = v;
    }
}
onlyOwner is written once and reused. The compiler effectively inlines the modifier's code around the function body at _;.

Two subtleties matter. First, order is left to right: `function f() onlyOwner whenNotPaused {}` runs `onlyOwner`'s checks, then `whenNotPaused`'s, then the body — each modifier's `_;` expands into the next. Second, a modifier is syntactic sugar: it is inlined, so a modifier used on ten functions copies its bytecode ten times. Keep modifiers small; if the logic is large, have the modifier call an `internal` function so only a tiny call is duplicated.

require, revert, assert: rejecting bad calls

Inside the bouncer we used `require`. When its condition is false, it triggers a revert: the call stops, every state change made so far is rolled back, and the unused gas is returned to the caller (the gas already burned is not). Reverting is the EVM's all-or-nothing guarantee — a transaction either fully succeeds or leaves the world exactly as it found it.

// require: validate inputs / external conditions (the common case)
require(amount > 0, "amount must be positive");
require(msg.sender == owner, "Not the owner");

// revert: identical effect, handy with branching or complex conditions
if (recipient == address(0)) {
    revert("zero address");
}

// assert: check an invariant that should NEVER be false.
// A failing assert means a BUG in your code, not bad user input.
assert(totalSupply == sumOfAllBalances);
Use require for expected input checks, revert for branchy logic, assert only for invariants that should be unreachable.

`require` and `revert` raise a standard `Error(string)`. `assert` is different: since Solidity 0.8.0 a failing `assert` (or an overflow, or division by zero) raises a `Panic(uint256)` with a numeric code, and it still reverts and refunds remaining gas. The rule of thumb: **`require` guards against the outside world; `assert` documents an assumption that, if ever false, means you have a bug.** Never use `assert` for ordinary input validation — a panic signals 'this should be impossible'.

Custom errors: say why, for almost no gas

A string reason like `"Not the owner"` is friendly but expensive: the literal bytes are baked into your contract's bytecode (you pay to deploy them) and ABI-encoded into the return data on every revert (you pay again at runtime). Since Solidity 0.8.4, custom errors fix this. You declare an error like a function, then `revert` it.

// Declared once, can carry typed data about what went wrong
error NotOwner(address caller);
error InsufficientBalance(uint256 requested, uint256 available);

function withdraw(uint256 amount) external {
    if (msg.sender != owner) {
        revert NotOwner(msg.sender);
    }
    uint256 bal = address(this).balance;
    if (amount > bal) {
        revert InsufficientBalance(amount, bal);   // tells the caller the numbers
    }
    // ... proceed
}
A custom error reverts with a 4-byte selector plus ABI-encoded arguments — far cheaper than a string, and machine-readable.

On the wire, `revert NotOwner(0xAbc...)` encodes as the 4-byte selector `bytes4(keccak256("NotOwner(address)"))` followed by the ABI-encoded address — exactly like a function call. That is why it is cheap: no string sits in your bytecode, and the revert payload is tiny. The bonus is that the arguments are structured: a front end or audit tool can decode `InsufficientBalance(100, 40)` and show the user the real numbers, instead of parsing English. As of Solidity 0.8.26+ you can even write `require(cond, CustomError(args))`; before that, the universal pattern is `if (!cond) revert CustomError(args);`.

Events: the chain's public logbook

Modifiers and errors control and reject calls. Events do the opposite job: they announce that something happened so the outside world can react. An event writes an entry to the transaction's log, a special area that is part of the receipt but is not contract storage. Logs are dramatically cheaper than storage, but they come with a hard rule: a contract can never read its own (or anyone's) logs. Logs exist purely for off-chain consumers — wallets, indexers, and dashboards.

// Up to 3 parameters may be 'indexed' (searchable); the rest go in 'data'
event Transfer(address indexed from, address indexed to, uint256 value);

function _transfer(address from, address to, uint256 value) internal {
    balanceOf[from] -= value;
    balanceOf[to]   += value;
    emit Transfer(from, to, value);   // writes a log entry, not storage
}
emit appends a log. The indexed fields become searchable 'topics'; non-indexed fields are packed into the data blob.

Under the hood the EVM has `LOG0`–`LOG4` opcodes. Each log carries up to 4 topics plus a data blob. For a normal (non-anonymous) event, topic 0 is fixed: it is `keccak256("Transfer(address,address,uint256)")`, the event signature hash. Each parameter you mark `indexed` becomes one of the remaining topics — that is what lets a client filter *'every Transfer where to == my address'* without downloading the whole chain. Non-indexed parameters are ABI-encoded into the data blob: cheaper, but only retrievable, not filterable. Note: indexing a dynamic type (string, bytes, array) stores its keccak hash as the topic, not the value, so you can match on it but not recover it.

{
  "jsonrpc": "2.0",
  "method": "eth_getLogs",
  "params": [{
    "address": "0xTokenContractAddress",
    "topics": [
      "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
      null,
      "0x000000000000000000000000YourAddressPaddedTo32Bytes"
    ],
    "fromBlock": "0x0",
    "toBlock": "latest"
  }],
  "id": 1
}
An eth_getLogs query: topic 0 pins the Transfer signature, topic 1 (from) is any, topic 2 (to) is filtered to your address.

This is exactly how a block explorer shows a token's transfer history, how a wallet notices you received funds, and how indexers like The Graph build queryable databases — all by reading logs over JSON-RPC. Because storage reads are blind to logs, the discipline is: store the minimum the contract logic needs; emit an event for everything the outside world needs to follow.

Putting it together: an Ownable treasury

Here is a small contract that uses all four tools the way real code does: a modifier for the guard, custom errors for cheap typed rejections, and events on every privileged action so the world can audit it. This is the seed of OpenZeppelin's `Ownable`, the most common access-control base in production.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract Treasury {
    address public owner;
    uint256 public feeBps;   // fee in basis points: 100 = 1%

    // ---- Events: the public logbook ----
    event OwnershipTransferred(address indexed from, address indexed to);
    event FeeUpdated(uint256 oldFeeBps, uint256 newFeeBps);

    // ---- Custom errors: cheap, typed revert reasons ----
    error NotOwner(address caller);
    error ZeroAddress();
    error FeeTooHigh(uint256 requested, uint256 maxBps);

    constructor() {
        owner = msg.sender;
        emit OwnershipTransferred(address(0), msg.sender);  // mint of ownership
    }

    modifier onlyOwner() {
        if (msg.sender != owner) revert NotOwner(msg.sender);
        _;
    }

    function setFee(uint256 newFeeBps) external onlyOwner {
        if (newFeeBps > 1000) revert FeeTooHigh(newFeeBps, 1000);  // cap at 10%
        emit FeeUpdated(feeBps, newFeeBps);   // emit BEFORE overwriting state
        feeBps = newFeeBps;
    }

    function transferOwnership(address newOwner) external onlyOwner {
        if (newOwner == address(0)) revert ZeroAddress();
        emit OwnershipTransferred(owner, newOwner);  // owner is still the old one
        owner = newOwner;
    }
}
Guard (modifier) + reject (custom errors) + announce (events). Note: emit the event while the OLD value is still readable, then update state.

Trace `transferOwnership` when a non-owner calls it: the modifier runs `msg.sender != owner`, hits `revert NotOwner(msg.sender)`, the whole call unwinds, and nothing changes. When the owner calls it with a real address, the zero-address guard passes, `OwnershipTransferred(old, new)` is logged while `owner` is still the old value (so the log reads correctly), and only then does state change. Off-chain, anyone watching `OwnershipTransferred` learns instantly that control moved — no need to poll the `owner` slot.

Sharp edges, and what comes next

  1. Events are advisory, not authoritative. Any contract can emit a `Transfer` event with the same signature, so an off-chain consumer must filter by the contract address, never trust a log just because its topic 0 matches.
  2. A modifier with no `_;` silently blocks the function forever; a modifier with two `_;` runs the body twice. The placeholder is load-bearing — write it deliberately.
  3. Don't put state-changing side effects or external calls inside a modifier if you can avoid it — it muddies the order of execution and can open a revert- or reentrancy-shaped surprise. Keep modifiers to pure checks.
  4. Always validate against the zero address on owner/recipient setters. Handing ownership to `address(0)` (with no recovery) is a permanent footgun unless you mean to renounce.

These four tools are the grammar of safe Solidity, but they are building blocks, not a finished defense. A single `owner` is a single point of failure; real systems need role-based access control (many roles, not one key), and they need to order their state changes and external calls carefully to avoid reentrancy. That ordering discipline — checks-effects-interactions — plus pull payments and upgradeable proxies is exactly the next guide in this rung. After that, you'll write a full ERC-20 where `Transfer` and `Approval` events, `onlyOwner`-style guards, and custom errors all come together in a standard the whole ecosystem reads.