Solidity cheatsheet

Solidity Cheatsheet

Chuck Chen
 · 
Categories: Development

Introduction

In the world of blockchain development, Solidity is the language of choice for creating smart contracts on Ethereum and other EVM-compatible platforms. Unlike traditional programming, where code can be easily updated or patched, smart contracts are immutable once deployed. This permanence means that every line of code carries significant weight, and there are no second chances for fixing bugs on-chain.

This guide provides a comprehensive overview of the most common patterns, syntax, and best practices learned from real-world development of DeFi protocols, NFT platforms, and token systems. Each section is designed to help you navigate the challenges of smart contract development, from optimizing gas usage to implementing robust security measures.

The Anatomy of a Smart Contract

Every Solidity file starts with a pragma declaration, which specifies the compiler version to be used. This is followed by the contract definition, which encapsulates the state and logic of the smart contract. A well-organized contract structure is essential for security and readability. Including an SPDX license identifier is also a best practice, as it helps with open-source verification on platforms like Etherscan.

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

contract MyContract {
    // State variables are stored on the blockchain
    uint256 public myNumber;
    address public owner;

    // Events are used to log significant actions
    event NumberUpdated(uint256 newNumber);

    // The constructor is called only once, during deployment
    constructor(uint256 _initialNumber) {
        myNumber = _initialNumber;
        owner = msg.sender;
    }

    // Functions define the contract's behavior
    function updateNumber(uint256 _newNumber) public {
        myNumber = _newNumber;
        emit NumberUpdated(_newNumber);
    }
}

Mastering Solidity’s Data Types

If you’re coming from a language like Python or JavaScript, Solidity’s strict type system can be a hurdle. But on the blockchain, where every byte of storage has a cost, this strictness is a feature, not a bug. Choosing the right data type isn’t just about correctness; it’s about gas optimization. Using a uint8 instead of a uint256 for a value you know will be small can translate into real-world savings on deployment and transaction fees.

Solidity’s data types are broadly categorized into value types and reference types.

Value types store their data directly. Think of them as holding the value itself. This includes bool for true/false values, int and uint for signed and unsigned integers of various sizes, address for Ethereum addresses, and bytes for fixed-size byte arrays. A common pitfall is underestimating the scale a contract might reach. A uint8 might seem sufficient for a counter, but if your dApp goes viral, that counter could overflow, leading to disastrous consequences. Always consider the potential for growth.

// Value Types Examples
bool public isActive = true;
uint256 public largeNumber = 100;
int8 public smallSignedNumber = -5;
address public owner;
bytes32 public someHash;

Reference types, on the other hand, don’t store the data directly but rather a reference to its location. This category includes arrays, structs, and mappings. The distinction is crucial. If you pass a storage array to a function and modify it, you’re modifying the original data. If you create a copy in memory, you’re working with a temporary version. Understanding this is key to avoiding bugs where state changes seem to disappear. For example, mappings are often preferred over arrays for storing user balances because they offer more efficient lookups and storage patterns.

// Reference Types Examples
uint256[] public balances;

struct User {
    string name;
    address wallet;
}
mapping(uint => User) public users;

Controlling Access with Function Visibility

In Solidity, function visibility determines who can call a function. This is a critical aspect of smart contract security, as improper visibility can expose sensitive functions to attackers. The Parity Wallet hack is a stark reminder of what can happen when visibility is not handled correctly.

Solidity provides four visibility specifiers:

  • public: Functions are accessible from anywhere, both externally and internally.
  • external: Functions can only be called from outside the contract.
  • internal: Functions are only accessible from within the contract and its derived contracts.
  • private: Functions are only accessible from within the contract itself.
contract VisibilityExample {
    function publicFunction() public pure returns (string memory) {
        return "Accessible from anywhere";
    }

    function externalFunction() external pure returns (string memory) {
        return "Only callable from outside the contract";
    }

    function internalFunction() internal pure returns (string memory) {
        return "Only accessible from this contract and derived contracts";
    }

    function privateFunction() private pure returns (string memory) {
        return "Only accessible from this contract";
    }
}

Simplifying Logic with Function Modifiers

Modifiers are a powerful feature in Solidity for enforcing conditions on functions. They allow you to run code before or after a function call, making them ideal for handling checks like permissions, input validation, and reentrancy guards. Using modifiers helps keep your code clean, readable, and secure.

contract ModifiersExample {
    address public owner;
    bool public paused;

    constructor() {
        owner = msg.sender;
    }

    modifier onlyOwner() {
        require(msg.sender == owner, "Not the owner");
        _;
    }

    modifier whenNotPaused() {
        require(!paused, "Contract is paused");
        _;
    }

    function restrictedFunction() public view onlyOwner returns (string memory) {
        return "You are the owner!";
    }

    function sensitiveAction() public whenNotPaused {
        // ...
    }
}

Logging Actions with Events

Events are Solidity’s way of logging significant actions that occur in a smart contract. They are a cost-effective way to store data on the blockchain and are essential for front-end applications to listen for and react to contract events. Indexing event parameters allows for efficient searching and filtering of past events.

contract EventsExample {
    event Transfer(address indexed from, address indexed to, uint256 amount);

    function transfer(address _to, uint256 _amount) public {
        // ...
        emit Transfer(msg.sender, _to, _amount);
    }
}

Handling Errors Effectively

Error handling in Solidity is crucial for ensuring the predictable behavior of your smart contracts. Solidity provides require, assert, and revert for handling errors. require is used for validating inputs and conditions, assert is for checking internal invariants, and revert allows you to trigger a state reversal with a custom error message. Since Solidity 0.8.4, custom errors have become the preferred way to handle errors, as they are more gas-efficient than string-based error messages.

contract ErrorHandlingExample {
    address public owner;
    uint256 public balance;

    error InsufficientBalance(uint256 required, uint256 available);

    constructor() payable {
        owner = msg.sender;
        balance = msg.value;
    }

    function withdraw(uint256 _amount) public {
        if (_amount > balance) {
            revert InsufficientBalance(_amount, balance);
        }
        balance -= _amount;
    }
}

Building on Existing Code with Inheritance

Solidity supports inheritance, allowing you to create modular and reusable code. You can extend existing contracts to add new functionality or override existing behavior. This is a powerful feature for building complex systems, but it’s important to understand how inheritance works to avoid potential issues like the “diamond problem.” OpenZeppelin’s contract library is an excellent example of how to use inheritance to build secure and reusable smart contracts.

contract Base {
    function foo() public pure virtual returns (string memory) {
        return "Base";
    }
}

contract Derived is Base {
    function foo() public pure override returns (string memory) {
        return "Derived";
    }
}

Interacting with Other Contracts using Interfaces and Abstract Contracts

Interfaces are a critical part of the Ethereum ecosystem, enabling different smart contracts to interact with each other in a standardized way. When you integrate with a protocol like Uniswap or Aave, you’ll use their interfaces to call their functions. Defining clear interfaces is a key part of good smart contract architecture and makes testing and integration much easier.

Abstract contracts are similar to interfaces but can also provide base implementations for some functions. They are often used in factory patterns, where you want to enforce a common structure for a set of contracts while leaving some details to be implemented by the derived contracts.

// Interface for an ERC20 token
interface IERC20 {
    function transfer(address recipient, uint256 amount) external returns (bool);
    function balanceOf(address account) external view returns (uint256);
}

// Abstract contract for a base-level token
abstract contract MyTokenBase {
    function _transfer(address from, address to, uint256 amount) internal virtual;
}

Reusing Code with Libraries

Libraries are a powerful feature in Solidity for promoting code reuse and gas optimization. A classic example is the SafeMath library, which was essential for preventing integer overflows before Solidity 0.8.0. By using the using directive, you can make library functions feel like native methods on a data type. Extracting common logic into libraries can also lead to significant gas savings, as libraries can be deployed once and linked to multiple contracts.

// A simple library for math operations
library Math {
    function add(uint256 a, uint256 b) internal pure returns (uint256) {
        return a + b;
    }
}

// Using the library in a contract
contract Calculator {
    using Math for uint256;

    function addTwoNumbers(uint256 a, uint256 b) public pure returns (uint256) {
        return a.add(b);
    }
}

Managing Data with Storage, Memory, and Calldata

Understanding the difference between storage, memory, and calldata is key to optimizing gas usage in your smart contracts.

  • storage: Variables are stored permanently on the blockchain. This is the most expensive type of storage.
  • memory: Variables are temporary and are erased between external function calls.
  • calldata: Similar to memory, but it’s a read-only location for function arguments. It’s the most gas-efficient data location.

A common optimization is to use calldata instead of memory for function arguments when you don’t need to modify them.

contract DataLocationsExample {
    // storage variable
    uint256 public myNumber;

    // memory variable
    function processArray(uint256[] memory _myArray) public {
        // ...
    }

    // calldata variable
    function getMessage(string calldata _message) external pure returns (string memory) {
        return _message;
    }
}

Handling Ether with Payable Functions

To receive Ether, a function must be marked as payable. This is a critical security feature, as it prevents contracts from accidentally receiving Ether when they are not designed to do so. When sending Ether, it’s important to use the recommended patterns to avoid security vulnerabilities like reentrancy attacks. The call method is now the preferred way to send Ether, as it provides more flexibility and control than the older transfer and send methods.

contract EtherHandler {
    receive() external payable {
        // This function is called when Ether is sent to the contract
    }

    function sendEther(address payable _to, uint256 _amount) public {
        (bool success, ) = _to.call{value: _amount}("");
        require(success, "Failed to send Ether");
    }
}

Implementing Common Design Patterns

The Solidity community has developed a number of design patterns to address common challenges in smart contract development. These patterns have been battle-tested in numerous projects and can help you build more secure and robust contracts.

Access Control

The “Ownable” pattern is a simple and effective way to restrict access to sensitive functions. However, for more complex systems, a role-based access control (RBAC) model is often more appropriate. OpenZeppelin’s AccessControl contract is the industry standard for implementing RBAC.

contract Ownable {
    address public owner;

    constructor() {
        owner = msg.sender;
    }

    modifier onlyOwner() {
        require(msg.sender == owner, "Not the owner");
        _;
    }
}

Pausable

A “Pausable” contract, or circuit breaker, is a valuable safety feature that allows you to pause contract functionality in case of an emergency. This can give you time to address a critical bug or vulnerability. However, it’s important to consider the centralization risks associated with this pattern and to implement appropriate safeguards, such as timelocks or multi-sig requirements.

contract Pausable {
    bool public paused;

    modifier whenNotPaused() {
        require(!paused, "Contract is paused");
        _;
    }

    function pause() public {
        paused = true;
    }

    function unpause() public {
        paused = false;
    }
}

Reentrancy Guard

Reentrancy attacks are one of the most common and dangerous vulnerabilities in smart contracts. The “Reentrancy Guard” pattern prevents these attacks by using a mutex to lock the contract state during a function call. It’s essential to use a reentrancy guard on any function that makes external calls or transfers Ether.

contract ReentrancyGuard {
    bool private locked;

    modifier noReentrancy() {
        require(!locked, "No reentrancy");
        locked = true;
        _;
        locked = false;
    }

    function protectedFunction() public noReentrancy {
        // ...
    }
}

Creating a Token with the ERC20 Standard

The ERC20 standard is a widely adopted blueprint for creating fungible tokens on the Ethereum blockchain. Implementing the ERC20 standard is a great way to learn Solidity, as it covers many of the core concepts of the language. While the standard itself is relatively simple, the implications of creating a token are profound. It’s essential to understand decimal handling, approval mechanisms, and how your token will interact with front-end applications. For production use, always consider using a library like OpenZeppelin’s SafeERC20 to ensure your token is secure and compliant.

contract ERC20Token {
    string public name = "MyToken";
    string public symbol = "MTK";
    uint8 public decimals = 18;
    uint256 public totalSupply;

    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);

    constructor(uint256 _initialSupply) {
        totalSupply = _initialSupply * 10 ** uint256(decimals);
        balanceOf[msg.sender] = totalSupply;
    }

    function transfer(address _to, uint256 _value) public returns (bool success) {
        require(balanceOf[msg.sender] >= _value, "Insufficient balance");
        balanceOf[msg.sender] -= _value;
        balanceOf[_to] += _value;
        emit Transfer(msg.sender, _to, _value);
        return true;
    }

    function approve(address _spender, uint256 _value) public returns (bool success) {
        allowance[msg.sender][_spender] = _value;
        emit Approval(msg.sender, _spender, _value);
        return true;
    }

    function transferFrom(address _from, address _to, uint256 _value)
        public returns (bool success) {
        require(_value <= balanceOf[_from], "Insufficient balance");
        require(_value <= allowance[_from][msg.sender], "Insufficient allowance");
        balanceOf[_from] -= _value;
        balanceOf[_to] += _value;
        allowance[_from][msg.sender] -= _value;
        emit Transfer(_from, _to, _value);
        return true;
    }
}

Accessing Blockchain Data with Global Variables

Solidity provides a number of global variables that give you access to information about the blockchain and the current transaction. These can be useful for a variety of purposes, such as getting the address of the sender (msg.sender), the amount of Ether sent with a transaction (msg.value), or the current block number (block.number). However, it’s important to be aware of the security implications of using these variables. For example, block.timestamp can be manipulated by miners to some extent, so it should not be used for critical applications that require precise timing.

contract GlobalVariablesExample {
    function getBlockInfo() public view returns (uint256, uint256) {
        return (block.number, block.timestamp);
    }

    function getTransactionInfo() public view returns (address, uint256) {
        return (msg.sender, msg.value);
    }
}

Working with Time Units

Solidity provides a number of time units to make it easier to work with time-based logic in your smart contracts. You can use seconds, minutes, hours, days, and weeks to specify durations in a readable way. However, it’s important to remember that time on the blockchain is not always precise. As mentioned earlier, block.timestamp can be manipulated by miners, so it’s best to build in some flexibility when designing time-dependent logic.

contract TimeUnitsExample {
    uint256 public lockTime = 1 hours;

    function isLockExpired(uint256 _startTime) public view returns (bool) {
        return block.timestamp >= _startTime + lockTime;
    }
}

Handling Different Ether Units

Solidity also provides units for working with Ether. You can use wei, gwei, and ether to specify amounts of Ether in a clear and concise way. This is particularly useful for avoiding errors when dealing with the large numbers that are common in Ethereum transactions. Always be mindful of the units you are working with to prevent costly mistakes.

contract EtherUnitsExample {
    uint256 public oneWei = 1 wei;
    uint256 public oneGwei = 1 gwei;
    uint256 public oneEther = 1 ether;
}

Using Assembly for Low-Level Operations

For advanced use cases, Solidity allows you to use inline assembly, which is a low-level language called Yul. This gives you more direct control over the EVM and can be useful for optimizing gas usage or implementing complex logic that is not possible in plain Solidity. However, assembly should be used with caution, as it bypasses many of Solidity’s safety checks and can introduce security vulnerabilities if not used correctly.

contract AssemblyExample {
    function getFreeMemoryPointer() public pure returns (uint256) {
        uint256 freeMemoryPointer;
        assembly {
            freeMemoryPointer := mload(0x40)
        }
        return freeMemoryPointer;
    }
}

Best Practices

Security, gas optimization, and code quality are critical for production Solidity development. This cheatsheet covers the fundamentals, but for comprehensive guidance on writing secure and efficient smart contracts, see the dedicated Solidity Best Practices guide.

Key principles to remember:

  • Security first: Follow the Check-Effects-Interactions pattern and use reentrancy guards
  • Gas efficiency: Use calldata over memory, custom errors over strings, and pack storage variables
  • Code quality: Use OpenZeppelin libraries, write comprehensive tests, and document thoroughly
  • Access control: Implement role-based permissions for complex systems
  • Testing: Aim for 100% coverage with unit, integration, fuzz, and invariant tests

Testing Your Contracts with Foundry

Thorough testing is an essential part of the smart contract development lifecycle. Foundry is a popular testing framework that allows you to write your tests in Solidity, which can make for a more streamlined and intuitive testing experience. Foundry also includes powerful features like fuzzing, which can help you find edge cases and vulnerabilities that might be missed by traditional testing methods.

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

import "forge-std/Test.sol";
import "../src/MyContract.sol";

contract MyContractTest is Test {
    MyContract public myContract;

    function setUp() public {
        myContract = new MyContract(100);
    }

    function test_InitialValue() public {
        assertEq(myContract.myNumber(), 100);
    }

    function test_UpdateNumber() public {
        myContract.updateNumber(200);
        assertEq(myContract.myNumber(), 200);
    }
}

Resources

Conclusion

Solidity development is a journey of continuous learning. Each deployment teaches new lessons, each audit reveals blind spots, and each exploit in the ecosystem provides cautionary tales. This cheatsheet represents knowledge accumulated through building production systems, responding to incidents, and participating in the vibrant Ethereum development community.

The immutability of smart contracts demands more thoughtfulness, security-consciousness, and thoroughness than traditional software development. Every line of code is a commitment that will live forever on the blockchain. Test extensively, audit rigorously, and never stop learning.

Remember: In Solidity, security isn’t a feature—it’s a requirement. Gas optimization isn’t premature—it’s user experience. And testing isn’t optional—it’s survival. Build carefully, deploy confidently, and always respect the weight of putting code on an immutable ledger.

The blockchain space evolves rapidly. Stay curious, engage with the community, and contribute to the collective knowledge. Together, we’re building the financial infrastructure of the future.