Solidity Master Cheatsheet
Welcome to the Solidity Master Cheatsheet—created especially for new Solidity developers! Whether you’re just starting to explore the fundamentals of smart contract programming or need a convenient reference while building your DApps, this guide has you covered.
This cheatsheet is based on version 0.8.29
Table of Contents
- Solidity Master Cheatsheet
- Table of Contents
- Getting Started
- Specifying compiler version
- Basic Data Types
- Variables \& Visibility
- Functions
- State Mutability: view, pure, and payable
- Control Flow
- Error Handling
- Arrays, Mappings, Structs, Enums
- Modifiers
- Events
- Contract Inheritance \& Interfaces
- Libraries
- References
Getting Started
// SPDX-License-Identifier: MIT
// Filename: HelloWorld.sol
// The function `greet()` will return the message "Hello, Solidity!"
pragma solidity ^0.8.29;
contract HelloWorld {
string public message = "Hello, Solidity!";
function greet() external view returns (string memory) {
return message;
}
}
Specifying compiler version
pragma solidity 0.8.29; // The contract must be compiled with exactly version 0.8.29
pragma solidity ^0.8.29; // Any version greater than or equal to 0.8.29, but strictly less than 0.9.0
pragma solidity >=0.8.0 <0.9.0; // Any version greater than or equal to 0.8.0, but strictly less than 0.9.0
Best Practice
- When releasing production contracts, you may want to pin to a narrower range or exact version for complete determinism.
- During development, using ^0.8.x can be more convenient.
Basic Data Types
Summary
Type | Description | Example |
---|---|---|
bool | Boolean type, true or false | bool isReady = true; |
uint | Unsigned integer (by default 256 bits, i.e. uint256 ) |
uint256 count = 10; |
int | Signed integer (by default 256 bits, i.e. int256 ) |
int256 temperature = -5; |
address | 20-byte Ethereum address (e.g. 0xABC123... ), non-payable |
address owner = msg.sender; |
address payable | Same as address, but can receive Ether | address payable wallet = payable(msg.sender); |
bytes | Dynamically sized byte array | bytes data = hex"001122"; |
bytesN | Fixed-size byte array of length N (1 ≤ N ≤ 32) | bytes32 hash = keccak256(...); |
string | Dynamically sized UTF-8 data | string name = "Alice"; |
bool
- Stores a single bit of information (true or false).
- Default value is false if not initialized.
Integers: uint
and int
- uint stands for unsigned integer and does not allow negative values.
- Range for uint256 is 0 to 2^256 - 1.
- You can also specify smaller sizes like uint8, uint16, …, uint256 (increments of 8 bits).
- int stands for signed integer and allows negative values.
- Range for int256 is -(2^255) to (2^255 - 1).
- Similarly, you can specify int8, int16, …, int256.
- Default value for both uint256 and int256 is 0.
// Unsigned integer
uint256 public totalSupply = 10000;
// Signed integer
int256 public temperature = -25;
Best Practice
- Use uint256 unless you have a specific reason to use a smaller size (like uint128, etc.).
- Smaller types can save storage (gas) if you can tightly pack multiple variables in a struct or the same storage slot.
- Avoid using signed integers if you only deal with non-negative values.
address
and address payable
- An address type holds a 20-byte value.
- address has built-in attributes like:
- balance (returns the account’s balance in wei),
- code and codehash (get contract code or its hash),
- and methods like call, delegatecall, staticcall (low-level).
- address payable is a special variant that allows sending Ether via methods like transfer or send.
Important
In Solidity ^0.8.0, you must explicitly convert an address to address payable if you want to send Ether:
address payable receiver = payable(someAddress);
bytes
and bytesN
bytes: dynamically sized array of bytes.
- Good for arbitrary-length binary data.
- More expensive to store than fixed-size arrays due to dynamic nature.
bytesN: fixed-size array of length N (where 1 <= N <= 32).
- Commonly used for storing hashes (bytes32) or other fixed-length data (e.g., signatures).
- bytes32 is a popular choice for storing keccak256 hashes.
// Dynamically sized
bytes public data = hex"DEADBEEF";
// Fixed-size, exactly 32 bytes
bytes32 public myHash = keccak256(abi.encodePacked("Solidity"));
string
- A dynamically sized UTF-8 encoded data type typically used for text.
- In practice, string is very similar to bytes (both are dynamically sized), but string is meant for text, while bytes is better for raw binary data.
- Default value is an empty string “”.
string public greeting = "Hello, World!";
Variables & Visibility
In Solidity, variables are categorized based on where they are declared and how they can be accessed:
- State Variables
- Local Variables
- Global (Built-in) Variables
- Visibility Keywords
State Variables
- Declared inside a contract but outside of any function.
- Stored permanently on the blockchain as part of the contract’s state (in storage).
- Gas cost: Writing and updating state variables costs gas. Reading is cheaper but not free.
- Initialization: If not explicitly initialized, they are given default values (e.g., 0 for integers, false for booleans, address(0) for addresses).
pragma solidity ^0.8.29;
contract MyContract {
// State variables
uint256 public count; // defaults to 0
bool public isActive = true;
// ...
}
Constants
- Constants are declared using the
constant
keyword.
uint256 public constant constantVariable = 10;
Immutable Variables
- Immutable variables are declared using the
immutable
keyword. - They are initialized once and cannot be changed after that.
uint256 public immutable immutableVariable = 10;
Best Practice
- Mark state variables as
public
only if you need external read access. - Use
private
orinternal
for variables that should not be directly accessible outside the contract. - Use
immutable
for variables that should not be changed after initialization.
Local Variables
- Declared and used within function scope (including function parameters).
- Stored in memory or stack, not in contract storage (unless explicitly specified otherwise).
- Cheaper than state variables because they’re only used temporarily during function execution.
function multiplyByTwo(uint256 _x) public pure returns (uint256) {
// Local variable
uint256 result = _x * 2;
return result; // This value is not saved on-chain
}
Note
- Local variables are destroyed after the function call ends.
- For arrays, structs, or strings passed as function parameters, you often must specify memory or calldata (in external functions) to define the data location.
Global (Built-in) Variables
These are pre-defined variables and functions that give information about the blockchain, transaction, or message context. Examples include:
msg.sender
— the address that called the function.msg.value
— how much Ether (in wei) was sent with the call.msg.data
— the data sent with the call.block.timestamp
— the current block timestamp (a.k.a. Unix epoch time).block.number
— the current block number.block.chainid
— the chain ID of the blockchain.tx.gasprice
— the gas price of the transaction.
These variables are read from the environment and cannot be directly overwritten. They do not require a declaration like normal variables.
For more global variables, see here.
Example:
function whoCalledMe() public view returns (address) {
// msg.sender is a global variable
return msg.sender;
}
Visibility Keywords
In Solidity, visibility determines which parts of the contract or external entities can access a function or state variable.
Visibility Accessible By Common Use Cases
Visibility | Accessible By | Common Use Cases |
---|---|---|
public | - Externally (via transactions or other contracts) - Internally within the contract itself |
Functions/variables that need to be read or called externally |
external | - Externally only (cannot be called internally without this. ) |
Functions intended solely for external interaction (e.g., an API for Dapp users) |
internal | - Only within this contract or inheriting contracts | Helper functions and state variables used by derived contracts |
private | - Only within this specific contract | Sensitive logic or state variables that shouldn’t be accessed even by child contracts |
Note
public
andprivate
keywords are used to define the visibility of state variables and functions.external
andinternal
keywords are used to define the visibility of functions only.
public
- A
public
state variable automatically generates a getter function. For example:
uint256 public count;
This allows reading count
externally. The contract ABI will have a function count()
that returns the variable’s value.
function getCount() public view returns (uint256) {
return count;
}
- A
public
function can be called from:- Outside via a transaction or another contract
- Inside by other functions within the same contract
external
- Functions only callable from outside the contract (or via
this.functionName(...)
). - Typically used to indicate a function is part of the contract’s external interface.
- Slightly more gas-efficient if you don’t plan to call that function from within the contract.
function externalFunction() external view returns (uint256) {
return address(this).balance;
}
// Inside another function in the same contract, you'd have to call: (but not recommended)
this.externalFunction();
internal
- Accessible only within the contract and child contracts that inherit from it.
- Not part of the public ABI, so cannot be called externally.
- Useful for shared logic across parent-child relationships.
function internalHelper() internal pure returns (uint256) {
return 42;
}
private
- Only accessible within the same contract.
- Not accessible in derived contracts.
- Typically used for sensitive or low-level logic that you don’t want child contracts to override or manipulate directly.
bool private privateVariable = true;
function privateHelper() private pure returns (uint256) {
return 123;
}
Best Practices for Visibility
-
Explicitly Specify Visibility
- In Solidity, the default function visibility is internal if not specified.
- Always define visibility (public, external, internal, private) for every function and state variable to avoid confusion and ensure clarity in your code.
-
Use external for Functions Called Externally Only
- If a function is never intended to be called internally, mark it external.
- external functions can be slightly more gas-efficient than public because Solidity handles arguments differently for external calls.
-
Restrict Access Whenever Possible
- Follow the principle of least privilege.
- Use private or internal whenever you don’t need external or inherited access.
- This minimizes the contract’s attack surface and reduces the likelihood of unintended behavior.
Functions
Solidity functions define the behavior of your smart contract. They can be used to read or modify the contract’s state, interact with other contracts, or perform computations.
Basic Syntax
function functionName(Type param1, Type param2) [visibility] [stateMutability] returns (ReturnType) {
// function body
}
Where:
functionName
is the identifier (name) of the function.param1, param2
are parameters with specified types.visibility
can bepublic
,external
,internal
, orprivate
.stateMutability
includesview
,pure
,payable
, or can be omitted if the function modifies state.returns (ReturnType)
specifies the output type(s) (can be multiple).
Visibility
As covered in Visibility Keywords, a function’s visibility determines who can call it. The most common visibilities for functions are:
public
: callable from outside and inside the contractexternal
: callable from outside only (or viathis.functionName()
inside)internal
: callable only inside this contract and derived contractsprivate
: callable only inside this contract
State Mutability: view, pure, and payable
view
- The function can read state variables but cannot modify them.
function getCount() public view returns (uint256) {
return count; // reading a state variable
}
pure
- The function cannot read or modify state variables (nor use
this.balance
orblock.number
etc.). - Ideal for pure math or utility functions.
function addNumbers(uint256 a, uint256 b) public pure returns (uint256) {
return a + b;
}
payable
- The function can accept Ether sent to it.
- Without
payable
, the function will reject any Ether transfer.
function deposit() public payable {}
Return Values
You can return one or more values from a function. There are multiple ways to do so:
- Return Single Value
function getNumber() public pure returns (uint256) {
return 42;
}
- Return Multiple Values
function getValues() public pure returns (uint256, bool) {
return (100, true);
}
- Named Returns
- You can name your return variables for clarity.
function namedReturn() public pure returns (uint256 count, bool status) {
count = 10;
status = true;
}
- This can sometimes make the code more readable but is optional.
Function Parameters and Data Location
For parameters of reference types (e.g., string
, bytes
, arrays
, structs
), you must specify the data location (memory, storage, or calldata):
memory
: non-persistent, used for local copies within a function.calldata
: non-persistent, immutable/read-only, only used in external function parameters. It’s more gas-efficient than memory for external calls because it doesn’t copy the data.storage
: persists in the contract’s state storage (rarely used as a parameter location, mostly used for state variables). Can be used for internal or private function if you want to pass a reference to an existing storage variable.
Example using calldata:
function concatStrings(
string calldata str1,
string calldata str2
) external pure returns (string memory) {
return string(abi.encodePacked(str1, str2));
}
Overloading and Overriding
- Overloading: Defining multiple functions with the same name but different parameters.
function setValue(uint256 _value) public {
// ...
}
function setValue(uint256 _value, bool _flag) public {
// ...
}
- Overriding: When a function in a child contract overrides a function from a parent contract.
- The parent function must be marked as
virtual
. - The child function must use
override
.
- The parent function must be marked as
contract Parent {
function greet() public virtual pure returns (string memory) {
return "Hello from Parent";
}
}
contract Child is Parent {
function greet() public pure override returns (string memory) {
return "Hello from Child";
}
}
Internal vs External Calls
- When you call a function internally in Solidity, it uses jump instructions without creating a new message call. This is more gas-efficient.
- When you call an external function from within the same contract (e.g., this.myExternalFunction()), it triggers a new contract call. This is less gas-efficient and changes msg.sender to the contract itself.
Gas Considerations
-
Function Complexity:
- Avoid excessive loops or large data copy operations within a single function.
- If possible, break down large operations into smaller functions or use off-chain solutions for heavy computations.
-
Function Parameters:
- For external functions, using calldata for parameters instead of memory is cheaper in many cases.
- Passing large arrays around increases gas due to data copy overhead.
Best Practices for Functions
- Use public or external only if the function needs to be called from outside. Otherwise, consider internal or private.
- Separate reading (view/pure) and state-changing functions for clarity and potential performance benefits.
- Overloading can be handy, but use it sparingly; it can cause confusion if the parameter differences are subtle.
- Overriding is essential for inheritance hierarchies. Make sure to mark parent functions as virtual and child overrides as override.
Control Flow
Control flow in Solidity is largely similar to other languages like JavaScript, C, or Python. You can use if/else, for, while, and do-while loops to direct program execution.
If / Else Statements
Syntax:
function checkValue(uint256 _x) public pure returns (string memory) {
if (_x > 100) {
return "Greater than 100";
} else if (_x == 100) {
return "Exactly 100";
} else {
return "Less than 100";
}
}
require
require
statements revert the transaction immediately if a condition is not met:
require(condition, "Error message");
This is often used for input validation or access control checks.
For Loops
function sumArray(uint256[] memory _arr) public pure returns (uint256) {
uint256 total = 0;
for (uint256 i = 0; i < _arr.length; i++) {
total += _arr[i];
}
return total;
}
While Loops
function decrement(uint256 _x) public pure returns (uint256) {
while (_x > 0) {
_x--;
}
return _x; // returns 0
}
Do-While Loops
Solidity also supports do-while loops, which execute the loop body at least once before checking the condition:
function doWhileExample(uint256 _x) public pure returns (uint256) {
uint256 counter = _x;
do {
counter--;
} while (counter > 0);
return counter;
}
Break and Continue
Like many languages, Solidity provides break
and continue
statements for early termination or skipping an iteration in loops.
break
: Exits the loop immediately.continue
: Skips the remaining statements in the current iteration and moves to the next iteration.
function loopWithBreak(uint256 _x) public pure returns (uint256) {
for (uint256 i = 0; i < _x; i++) {
if (i == 5) break; // exits loop when i equals 5
if (i == 3) continue; // skips remaining statements when i equals 3
// ...
}
}
Best Practices for Loops
Vanila Loop
Don’t use >= and <= in the condition
// 37975 gas (+347)
function loop_lte() public returns (uint256 sum) {
for(uint256 n = 0; n <= 99; n++) {
sum += n;
}
}
- The EVM has the opcodes LT, GT and EQ for comparison, but there are no convenient LTE or GTE opcodes for the operation we are doing.
- Therefore each time the condition n <= 99 is checked, 3 instructions must be executed: LT n 99,EQ n 99 and OR to check if either one of those returned true, which leads to extra gas cost.
Increment the variable in an unchecked block
// 32343 gas (-5285)
function loop_unchecked_plusplus() public returns (uint256 sum) {
for(uint256 n = 0; n < 100;) {
sum += n;
unchecked {
n++;
}
}
}
- Since version 0.8 Solidity implements safety checks for all integer arithmetic, including overflow and underflow guards
n++
Solidity will insert extra code to handle the case ifn
would overflow after incrementing it- We can skip the overflow check by using
unchecked
block - Use only if you are sure the variables will never overflow
Just write it in assembly
// 26450 gas (-11178)
function loop_assembly() public returns (uint256) {
assembly {
let sum := 0
for {let n := 0} lt(n, 100) {n := add(n, 1)} {
sum := add(sum, n)
}
mstore(0, sum)
return(0, 32)
}
}
- Removed the declaration uint256 sum from the function header in order to escape Solidity’s type system as much as possible
- Using assembly is the most gas-efficient way to write a loop, but it’s also the most complex and harder to read and maintain. So make sure to use it only when necessary.
Array Loop
“Cache” the array’s length for the loop condition
// 25182 gas (-230)
function loopArray_cached(uint256[] calldata ns) public returns (uint256 sum) {
uint256 length = ns.length;
for(uint256 i = 0; i < length;) {
sum += ns[i];
unchecked {
i++;
}
}
}
- We know the length won’t change during execution and we can reduce the number of ns.length calls to just 1 for a modest reduction in gas.
Error Handling
require
vs revert
vs assert
require(condition, "Error Message")
- Verifies input conditions or other external conditions.
- Commonly used at the start of a function to validate function inputs, access control, or other preconditions (e.g., ensuring msg.value is sufficient).
require(msg.sender == owner, "Not the owner");
require(amount > 0, "Amount must be greater than 0");
-
If the condition fails, it reverts with the provided error message.
revert("Error Message")
- Explicitly trigger a revert from any part of the code.
- Often used to handle more complex logic or custom flows.
if (balance < amount) {
revert("Insufficient balance to withdraw");
}
assert(condition)
- Used to check for internal errors and invariants that should never fail.
- If an assert fails, it indicates a bug in your code (e.g., an invariant has been violated).
- From Solidity 0.8.x onwards, failing assert also reverts the transaction but uses a different kind of error—often highlighting a critical code issue.
assert(totalSupply == sumOfAllBalances);
Key Points:
require
is for invalid user inputs or external conditions.revert
is a manual revert trigger.assert
is for internal invariants—if it fails, something is fundamentally wrong.
Custom Errors (>=0.8.4)
- Custom errors allow you to define error types with optional parameters.
- They are more gas-efficient than revert strings because the encoded error data is smaller.
error Unauthorized(address caller);
error InvalidAmount(uint256 requested, uint256 available);
function restrictedAction() public view {
if (msg.sender != owner) {
revert Unauthorized(msg.sender);
}
// ...
}
function withdraw(uint256 amount) public {
if (amount > balances[msg.sender]) {
revert InvalidAmount(amount, balances[msg.sender]);
}
// ...
}
Benefits:
- Saves gas compared to long string messages.
- Provides typed parameters, making debugging easier off-chain.
- Especially useful in large or complex contracts.
try/catch
for External Calls
- When calling external functions or doing contract creations, you can use
try/catch
to handle failures without reverting your entire function:
interface DataFeed { function getData(address token) external returns (uint value); }
contract FeedConsumer {
DataFeed feed;
uint errorCount;
function rate(address token) public returns (uint value, bool success) {
// Permanently disable the mechanism if there are
// more than 10 errors.
require(errorCount < 10);
try feed.getData(token) returns (uint v) {
return (v, true);
} catch Error(string memory /*reason*/) {
// This is executed in case
// revert was called inside getData
// and a reason string was provided.
errorCount++;
return (0, false);
} catch Panic(uint /*errorCode*/) {
// This is executed in case of a panic,
// i.e. a serious error like division by zero
// or overflow. The error code can be used
// to determine the kind of error.
errorCount++;
return (0, false);
} catch (bytes memory /*lowLevelData*/) {
// This is executed in case revert() was used.
errorCount++;
return (0, false);
}
}
}
- You can choose to handle or re-revert. This can be used to do partial rollbacks or alternate logic.
Best Practices for Error Handling
- Use require for Input Validation
- Validate function arguments, check
msg.value
, verify permissions, etc.
- Validate function arguments, check
- Keep Revert Messages Short
- Revert strings add to the bytecode size and runtime cost.
- If you need to pass detailed data, consider custom errors.
- Use custom errors for More Complex Logic
- Save gas and convey structured data (like addresses, amounts) in the revert reason.
- Fail Fast
- Place
require
checks at the top of your functions to prevent unnecessary computations.
- Place
Arrays, Mappings, Structs, Enums
- Arrays: Sequential lists of elements of the same type.
- Mappings: Key-value data structure that does not store keys (only values).
- Structs: Custom types that group different data fields together.
- Enums: Defines a finite set of possible states.
Arrays
Fixed-size Arrays
- Their size must be known at compile time.
- You cannot change their length afterward.
uint256[3] public fixedArray = [1, 2, 3];
Dynamic Arrays
- Size can be modified at runtime using built-in methods like push and pop.
- Declared with empty brackets:
uint256[]
.
uint256[] public dynamicArray;
Declaring and Using Arrays
Storage vs. Memory
- State arrays (declared at the contract level) live in storage (on-chain).
- Function parameters and local arrays can be declared in memory or calldata (for external functions).
calldata
is read-only and more gas-efficient for external function parameters.
Accessing Elements
uint256[] public numbers;
function addNumber(uint256 num) external {
numbers.push(num); // dynamic array
}
function getNumber(uint256 index) external view returns (uint256) {
return numbers[index];
}
Length
- For a dynamic array,
arrayName.length
gives the current length. - You can also set a new length (in storage arrays) by assigning:
numbers.length = newLength;
(this can truncate or extend the array).
Push and Pop (Dynamic Arrays in Storage)
push(value)
appends a new element at the end.pop()
removes the last element (reducing the array length by one).- Time complexity of
push
andpop
is O(1).
function removeLast() external {
numbers.pop();
}
Gas Considerations:
- Each push, pop, and indexing operation in storage arrays incurs gas costs.
- Minimizing on-chain array size or using more efficient data structures can be crucial for large data sets.
Mappings
- A mapping is a reference type that stores key-value pairs.
- Mappings in Solidity:
- Do not allow iteration over keys.
- Do not store or keep track of the keys themselves—only the values and their hashed keys.
- Are often used for fast lookups (constant time access).
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
- KeyType: Usually a built-in type like
address
,uint256
, orbytes32
. (Structs, mappings, or dynamically-sized arrays cannot be used as keys.) - ValueType: Can be any type, including another mapping or a struct.
// Nested mapping
mapping(address => mapping(address => uint256)) public allowance;
function approve(address spender, uint256 amount) external {
allowance[msg.sender][spender] = amount;
}
Structs
- Structs let you define custom data types that group multiple variables (of possibly different types).
- They help in making code more readable and organizing complex data.
struct User {
string name;
uint256 age;
address wallet;
}
User[] public users; // dynamic array of User structs
function createUser(string memory _name, uint256 _age) external {
// Option 1: Direct struct creation
User memory newUser = User(_name, _age, msg.sender);
users.push(newUser);
// Option 2: Struct with named fields
users.push(User({name: _name, age: _age, wallet: msg.sender}));
}
Enums
- Enums define a finite list of constant values, improving code clarity when dealing with limited states.
- Internally, enums map to uint8 by default (or the smallest fitting unsigned integer, though conceptually it can occupy a full storage slot). Each value corresponds to an index, starting at
0
.
enum Status {
Pending, // 0
Shipped, // 1
Delivered, // 2
Canceled // 3
}
Status public currentStatus;
function setStatusShipped() external {
currentStatus = Status.Shipped;
}
function cancelOrder() external {
currentStatus = Status.Canceled;
}
function getStatus() external view returns (Status) {
return currentStatus;
}
- Converting Enums:
- You can cast an integer to an enum if it’s a valid index.
- Example:
Status s = Status(1); // s == Status.Shipped
- Be careful with casting, as invalid casts can lead to out-of-range enum values, which can corrupt state.
Enum Advantages:
- Makes state transitions self-documenting.
- Avoids using magic numbers (
0, 1, 2, 3
) or strings for statuses. - Typically used for order states, workflow stages, or roles with limited states.
Best Practices and Tips
- Use Arrays for Ordered Collections
- Great for sequential data like lists of user IDs.
- Remember that iterating over large arrays on-chain is costly.
- Use Mappings for Fast Lookups
- Ideal for key-value pairs like balances or allowances.
- You cannot iterate over a mapping, so maintain auxiliary data if you need a list of keys.
- Structs for Grouped Data
- Keep related data fields together.
- This improves readability and can reduce storage slot usage if packed correctly (e.g., storing small-size types in one slot).
- Guard Against Invalid Enum Values
- If you do type-casting from integers to enums, ensure you handle out-of-range values.
- In Solidity 0.8.x, you get a revert on overflow, but be explicit about checking valid indices if you do custom casting.
- Avoid Large Arrays in Storage
- If you expect an unbounded number of elements, consider a more efficient approach like indexing or partial off-chain storage.
- Or provide a mechanism to paginate through array elements or handle them in batches.
- Be Aware of Storage Collisions
- When dealing with nested data structures or inherited contracts, be sure you understand how Solidity’s storage layout works.
- Typically, using the standard approach (top-level declarations) avoids collisions.
Modifiers
- Modifiers are a powerful feature in Solidity that allow you to augment or condition the execution of functions.
- Think of them as wrappers around your functions that can run additional code (e.g., access checks, state validations) before (and/or after) the function body is executed.
Syntax
modifier onlyOwner() {
require(msg.sender == owner, "Caller is not owner");
_;
}
function sensitiveAction() public onlyOwner {
// This code runs after the `onlyOwner` checks
// ...
}
- When
sensitiveAction()
is called, Solidity first executes the code in theonlyOwner
modifier. - If all checks (e.g.,
require
) pass, it proceeds to execute the body ofsensitiveAction()
. - If any check fails, it reverts and never calls the function body.
Anatomy of a Modifier
- A modifier can contain code before and after the special
_
(underscore):
modifier checkValue(uint256 _value) {
// Code executed before the function body
require(_value > 0, "Value must be greater than zero");
_; // The function body is inserted here
// Code executed after the function body
emit ValueChecked(_value);
}
function doSomething(uint256 amount) public checkValue(amount) {
// function body
}
the compiled code effectively looks like:
function doSomething(uint256 amount) public {
require(amount > 0, "Value must be greater than zero");
// function body (original code of doSomething)
emit ValueChecked(amount);
}
Multiple Modifiers on One Function
- You can apply multiple modifiers to a single function. The modifiers will execute in order, left to right:
modifier onlyOwner() { /* ... */ _; }
modifier whenNotPaused() { /* ... */ _; }
function specialAction() public onlyOwner whenNotPaused {
// function body
}
- Be mindful of the order, especially if the modifiers change state.
- Also ensure your modifiers don’t conflict or replicate the same checks unnecessarily.
Events
- Events in Solidity are mechanisms that allow smart contracts to emit logs during execution.
- These logs are stored on the blockchain as part of the transaction receipt, but do not directly consume contract storage.
- Off-chain applications (like DApps, block explorers, etc.) can listen for these events to react or update their interfaces.
Key Characteristics
- Logs vs. Storage: Events produce logs that are cheaper than storing data in contract state, because they’re kept in the transaction receipt.
- Indexed Parameters: You can mark up to 3 parameters as indexed, enabling more efficient filtering on the client side.
- Not Accessible On-Chain: Event data (logs) are not accessible to other contracts. You can’t read events from within Solidity; they’re purely for off-chain usage.
Declaring Events
event Transfer(address indexed from, address indexed to, uint256 value);
- Up to three parameters can be labeled as
indexed
. indexed
parameters are indexed in the event log, which makes them searchable by off-chain tools.
Emitting Events
function transfer(address _to, uint256 _amount) external {
// ... perform transfer logic ...
emit Transfer(msg.sender, _to, _amount);
}
Viewing Events Off-Chain
- Web3 Libraries (Viem, ethers.js) allow you to listen for these events or query past events by searching transaction logs.
Example(in Viem):
import { parseAbiItem } from "viem"
import { publicClient } from "./client"
publicClient.watchEvent({
address: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
event: parseAbiItem(
"event Transfer(address indexed from, address indexed to, uint256 value)"
),
args: {
from: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045",
to: "0xa5cc3c03994db5b0d9a5eedd10cabab0813678ac"
},
onLogs: (logs) => console.log(logs)
})
Indexed vs. Non-Indexed Parameters
- Indexed parameters are searchable in the logs and stored as topics.
- Non-indexed parameters are stored in the log’s data section, which is not easily searchable but can be read once you retrieve the log.
For example, in event Transfer(address indexed from, address indexed to, uint256 value)
;:
from
=> topic[1]to
=> topic[2]-
value
=> part of the data payload - You can filter logs by indexed topics like
from = 0x1234...abcd
, but you cannot directly filter byvalue
because it’s not indexed.
const filter = await publicClient.createContractEventFilter({
abi: wagmiAbi,
address: "0xfba3912ca04dd458c843e2ee08967fc04f3579c2",
eventName: "Transfer",
args: {
from: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045",
to: "0xa5cc3c03994db5b0d9a5eedd10cabab0813678ac"
}
})
Common Use Cases
- Token Transfers
- ERC20 tokens emit a
Transfer
event whenever tokens move from one address to another.
- ERC20 tokens emit a
- State Changes
- Logging changes of ownership, updates to config variables, or changes in contract status.
- Debugging
- Emitting certain events during testing can help you trace contract execution.
- Off-Chain Processing
- Subgraphs (e.g., The Graph) or other indexing services use events to build databases of contract activity, enabling complex queries and analytics.
Simple Example
pragma solidity ^0.8.21;
contract Payment {
event Deposit(address indexed sender, uint256 amount);
event Withdraw(address indexed recipient, uint256 amount);
function deposit() external payable {
require(msg.value > 0, "No ether sent");
emit Deposit(msg.sender, msg.value);
}
function withdraw(uint256 amount) external {
require(amount > 0, "Zero withdrawal");
require(address(this).balance >= amount, "Insufficient contract balance");
(bool success, ) = payable(msg.sender).call{value: amount}("");
require(success, "Withdraw failed");
emit Withdraw(msg.sender, amount);
}
}
Best Practices
-
Emit Events for Important State Changes
- Especially for actions that off-chain services or UIs need to track: token transfers, ownership changes, auctions ending, etc.
-
Index Only What’s Needed
- Up to three indexed parameters can be used. Indexing more variables doesn’t exist (the rest must be unindexed).
- Too many indexed parameters or large data can increase log size and gas cost.
-
Avoid Emitting Too Many Events in a Single Transaction
- Each event adds to the gas cost. If you’re in a loop, consider alternative approaches or batch events if possible.
-
Be Aware of Event Ordering
- The order in which you emit events is the order they appear in the logs. Off-chain listeners often rely on log order to interpret data.
Contract Inheritance & Interfaces
- Solidity supports an object-oriented programming model where contracts can inherit from one or more base contracts.
- This allows for code reuse and hierarchical organization of functionality.
- Interfaces in Solidity define the required function signatures (and optionally events) without providing an implementation, allowing for standardized interaction between different contracts.
Contract Inheritance
Inheritance is achieved using the is
keyword. A derived (child) contract can access and override the functions and state variables of its parent (base) contract(s).
Single Inheritance
contract Parent {
string public parentName = "Parent";
function greet() public pure returns (string memory) {
return "Hello from Parent";
}
}
contract Child is Parent {
function sayHello() public view returns (string memory) {
// Access parent's state variable
return parentName;
}
}
Child
inherits everything fromParent
except constructor, private state variables/functions, and internal data can only be accessed withinChild
.
Multiple Inheritance
A contract can inherit from multiple base contracts using comma separation:
contract A {
function foo() public pure returns (string memory) {
return "A";
}
}
contract B {
function bar() public pure returns (string memory) {
return "B";
}
}
// Child inherits both A and B
contract C is A, B {
// C now has both foo() from A and bar() from B
}
Overriding Functions
- Parent functions must be marked
virtual
to allow them to be overridden. - Child overrides must use
override
.
contract Parent {
function greet() public pure virtual returns (string memory) {
return "Hello from Parent";
}
}
contract Child is Parent {
// Overriding greet()
function greet() public pure override returns (string memory) {
return "Hello from Child";
}
}
- If you have multiple levels (e.g.,
Grandparent -> Parent -> Child
), andChild
wants to override a function fromGrandparent
, you typically chainoverride(Base, Grandparent)
if needed.
contract Child is Parent, Grandparent {
function greet() public pure override(Parent, Grandparent) returns (string memory) {
return "Hello from Child";
}
}
Constructors in Inheritance
- If a parent contract has a constructor, the child must explicitly call it (unless it has a parameterless constructor).
- If multiple parents have constructors, each must be called with the needed arguments in the child’s constructor.
contract Parent1 {
uint256 public x;
constructor(uint256 _x) {
x = _x;
}
}
contract Parent2 {
uint256 public y;
constructor(uint256 _y) {
y = _y;
}
}
contract Child is Parent1, Parent2 {
// Must call Parent1,2 constructor
constructor(uint256 _childValue) Parent1(_childValue) Parent2(_childValue) {
// additional child init
}
}
Interfaces
An interface in Solidity is like a contract but with these restrictions:
- No state variables or constructor definitions.
- All functions must be
external
(or public in older versions of Solidity, but typically we useexternal
). - No function implementations—only signatures.
- Usually includes events that implementing contracts should emit.
Purpose: They define a standard for other contracts to implement, ensuring compatibility without forcing an implementation approach.
interface ICounter {
function increment() external;
function getCount() external view returns (uint256);
event Counted(address indexed caller, uint256 newCount);
}
Implementing an Interface
- A contract implements an interface by providing all the functions declared in the interface and matching their signatures exactly.
contract Counter is ICounter {
uint256 private count;
function increment() external override {
count++;
emit Counted(msg.sender, count);
}
function getCount() external view override returns (uint256) {
return count;
}
}
Using Interfaces
Contracts can call interface functions to interact with external contracts that implement them:
contract Caller {
function doIncrement(ICounter _counter) external {
_counter.increment();
}
function readCount(ICounter _counter) external view returns (uint256) {
return _counter.getCount();
}
}
Combining Inheritance and Interfaces
You can mix inheritance and interfaces. For example, you could have a base abstract contract that implements some parts of an interface and leaves others for child contracts.
interface IVault {
function deposit(uint256 amount) external;
function withdraw(uint256 amount) external;
}
abstract contract VaultBase is IVault {
// partially implement deposit logic
// but keep withdraw abstract or add some logic
function deposit(uint256 amount) external virtual override {
// partial logic
}
}
contract MyVault is VaultBase {
// Must override deposit if not fully implemented
// Must implement withdraw
function deposit(uint256 amount) external override {
// full deposit logic
}
function withdraw(uint256 amount) external override {
// implement withdraw logic
}
}
VaultBase
is an abstract contract because it might not fully implement all methods ofIVault
.MyVault
completes the implementation.
Abstract Contracts
Abstract contracts are those that cannot be deployed directly because they have at least one function without an implementation (virtual
function). They serve as base contracts.
- Typically contain shared logic and placeholders for functions to be defined in child contracts.
- Marked with
abstract
keyword in Solidity 0.6.x+.
abstract contract Animal {
function speak() public virtual returns (string memory);
}
contract Dog is Animal {
function speak() public pure override returns (string memory) {
return "Woof!";
}
}
Animal
is abstract becausespeak()
has no implementation.Dog
provides an implementation forspeak()
.
Diamond Inheritance and the “Linearization of Base Contracts”
- Solidity uses the C3 linearization algorithm to resolve conflicts in multiple inheritance.
- If multiple parent contracts have the same function name, you must explicitly override and specify which base contract’s implementation to use.
contract A {
function foo() public pure virtual returns (string memory) {
return "A";
}
}
contract B is A {
function foo() public pure virtual override returns (string memory) {
return "B";
}
}
contract C is A {
function foo() public pure virtual override returns (string memory) {
return "C";
}
}
// D inherits from both B and C
contract D is B, C {
// Must override foo() again
function foo() public pure override(B, C) returns (string memory) {
// decide which parent's implementation to call, or write new logic
// it will return "C"
return super.foo(); // picks the rightmost parent's override by default (C) in linearization
}
}
- The
override(B, C)
explicitly states you are overridingfoo()
from bothB
andC
. super.foo()
calls the next-most derived override in the linearization.
Best Practices
-
Use Inheritance for Shared Logic
- Common functionality (e.g., access control, utility functions) can be placed in base contracts for easy reuse.
-
Favor Composition over Deep Inheritance
- Avoid extremely deep inheritance chains—they can become confusing and error-prone.
- Sometimes, libraries or composition patterns are clearer.
-
Mark Functions as
virtual
andoverride
- Always use
virtual
in the base contract to signal it can be overridden. - Always use
override
in child contracts to signal you’re overriding a parent function.
- Always use
-
Use Interfaces for Inter-Contract Communication
- Define an interface that external contracts must implement.
- This reduces dependencies, as your contract does not rely on the internal details of the other contract.
-
Separate Interfaces into Their Own Files
- This aids clarity and reusability.
- Keep your interface definitions minimal—only the required function signatures and events.
-
Avoid Conflicting State in Multiple Inheritance
- Overlapping storage layouts from multiple parents can cause confusion or even storage collisions.
- Ensure each parent contract uses distinct state variable slots (this usually happens naturally if you’re just inheriting standard contracts without complicated overrides).
Libraries
- In Solidity, libraries are special types of contracts intended to provide reusable code.
- They can be thought of as utility or helper modules that other contracts can call without needing to implement the same logic repeatedly.
- Libraries help keep code DRY (Don’t Repeat Yourself), save gas, and improve readability.
Key Characteristics of Libraries
-
No Ether
- Libraries cannot hold Ether (i.e., they cannot have a balance).
- Any attempt to send Ether to a library will fail.
-
No State Variables
- Libraries cannot store or modify their own state; they are stateless.
-
No Inheritance
- Libraries cannot be inherited or extend other contracts (nor can you inherit from a library).
-
Linked at Compile Time or Deployment Time
- Internal library functions are typically inlined or statically called.
- External library functions can be deployed and then “linked” to your contract.
Library Function Types
Solidity libraries support two ways of using their functions:
-
Internal Functions
- If a library function is declared
internal
, it is inlined into the calling contract’s bytecode at compile time (similar to macros). - This reduces overhead since no external call is made.
- If a library function is declared
-
External Functions
- If a library function is declared
public
orexternal
, the calling contract will delegatecall into the library at runtime. - This can save bytecode size in the calling contract but introduces a small overhead for each external call.
- You must deploy the library contract first and link it before deploying the final contract.
- If a library function is declared
Example: Internal Library
Most libraries are used as internal because it’s more gas-efficient (no external call) and simpler to manage.
// MathLib.sol
pragma solidity ^0.8.29;
library MathLib {
function add(uint256 a, uint256 b) internal pure returns (uint256) {
return a + b;
}
function multiply(uint256 a, uint256 b) internal pure returns (uint256) {
return a * b;
}
}
// TestMath.sol
pragma solidity ^0.8.21;
import "./MathLib.sol";
contract TestMath {
function testAdd(uint256 x, uint256 y) public pure returns (uint256) {
return MathLib.add(x, y);
}
function testMultiply(uint256 x, uint256 y) public pure returns (uint256) {
return MathLib.multiply(x, y);
}
}
MathLib.add
andMathLib.multiply
are called fromTestMath
without an external call because they’reinternal
functions in a library.- The compiler will inline or statically link these calls, increasing
TestMath
’s bytecode size, but saving on runtime gas costs.
Example: External Library
You can define a library with public or external functions. In that case, your contract calls the library via delegatecall at runtime.
// ExternalLib.sol
pragma solidity ^0.8.29;
library ExternalLib {
function externalAdd(uint256 a, uint256 b) external pure returns (uint256) {
return a + b;
}
}
To use this library in another contract:
pragma solidity ^0.8.29;
import "./ExternalLib.sol";
contract UseExternalLib {
// The compiler inserts a reference that must be linked to the ExternalLib deployed address
function compute(uint256 x, uint256 y) public pure returns (uint256) {
return ExternalLib.externalAdd(x, y);
}
}
- Deployment: You must first deploy
ExternalLib
and then link its address intoUseExternalLib
’s bytecode. - At runtime, calls to
ExternalLib.externalAdd
happen via DELEGATECALL.
Library for Struct Extensions
- A popular pattern is to write libraries that extend built-in types or custom structs with
using for
. - This adds library functions as if they were member functions of the type.
pragma solidity ^0.8.29;
library ArrayUtils {
function findIndex(uint256[] storage arr, uint256 value) internal view returns (int256) {
for (uint256 i = 0; i < arr.length; i++) {
if (arr[i] == value) {
return int256(i);
}
}
return -1; // Not found
}
}
contract MyArray {
using ArrayUtils for uint256[]; // "using for" directive
uint256[] private data;
function addValue(uint256 value) external {
data.push(value);
}
function findValue(uint256 value) external view returns (int256) {
// We can now call findIndex() as if it's a member of data
return data.findIndex(value);
}
}
- The
using ArrayUtils for uint256[];
directive allows you to calldata.findIndex(value)
directly. - This improves readability and encapsulates utility logic for arrays.
Best Practices
-
Keep Libraries Focused
- A library should do one thing well—e.g., math operations, array helpers, address utilities, etc.
-
Avoid Large External Libraries for Single Contracts
- If you’re only using a library for a single contract and that library has few functions, it might be simpler and cheaper to make them internal.
-
Mark Functions
pure
orview
Where Possible- Avoid unintentional state modifications in libraries unless that’s the library’s explicit purpose.
- This also ensures your library is safer and can be reasoned about more easily.
-
using for
Pattern- Great for readability and a more object-oriented feel.
- Clarifies which types are extended by the library.
-
Security
- Libraries can mutate the caller’s storage if called with
delegatecall
. Ensure your library code is trusted and thoroughly reviewed.
- Libraries can mutate the caller’s storage if called with
-
Solady, OpenZeppelin and Other Standard Libraries
- Before writing your own, check if established libraries (e.g., Solady, OpenZeppelin) cover your needs. This reduces risk and leverages well-audited code.