Solidity best practices

Solidity Best Practices: Security, Gas Optimization, and Code Quality

Chuck Chen
 · 

Introduction

In the world of smart contract development, the stakes are exceptionally high. Unlike traditional software, blockchain code is immutable, meaning mistakes can be permanent and costly. With significant financial assets on the line, smart contracts are a prime target for attackers, making security and rigor paramount. This guide offers a comprehensive overview of best practices, not as theoretical concepts, but as battle-tested lessons drawn from real-world production incidents, security audits, and expensive failures across the Solidity ecosystem.

The practices detailed here have been forged in the crucible of DeFi protocols, NFT platforms, and token systems that secure millions of dollars in value. Whether you are architecting your first smart contract or refining an established protocol, these patterns provide a foundation for writing code that is more secure, efficient, and maintainable.

Foundational Security Principles

In smart contract development, security is not just a feature—it’s the bedrock of the entire system. A single vulnerability can lead to a catastrophic loss of funds, a fact underscored by numerous high-profile exploits throughout blockchain’s history. Adhering to established security patterns is essential for protecting user assets and maintaining trust.

1. Preventing Reentrancy with the Check-Effects-Interactions Pattern

A primary defense against reentrancy attacks is the Check-Effects-Interactions (CEI) pattern. This model prescribes a specific order for operations within a function to prevent malicious contract calls from creating unintended behavior. By structuring your code properly, you can ensure that your contract’s state is updated before any external interactions occur, mitigating a major attack vector.

The structure is as follows:

  1. Checks: First, validate all inputs and conditions (e.g., require(balance >= amount)).
  2. Effects: Next, update all relevant state variables (e.g., balances[msg.sender] -= amount).
  3. Interactions: Finally, make any external calls to other contracts (e.g., recipient.call{value: amount}("")).
contract CEIPattern {
    mapping(address => uint256) public balances;

    function withdraw(uint256 amount) external {
        // 1. CHECKS
        require(balances[msg.sender] >= amount, "Insufficient balance");

        // 2. EFFECTS
        balances[msg.sender] -= amount;

        // 3. INTERACTIONS
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");
    }
}

Why it matters: The infamous DAO hack in 2016 exploited reentrancy to drain $60M by calling back into the contract before state updates completed.1 This pattern ensures state changes happen before any external interaction.

Placing the external call before the state update is a critical mistake, as it opens the door for an attacker to recursively call the function and drain funds before the balance is decremented.

2. Bolstering Defense with Reentrancy Guards

While the CEI pattern is a powerful first line of defense, it’s wise to add another layer of protection with a reentrancy guard. This is typically implemented as a modifier that locks the contract, preventing the same function from being called again until the first execution is complete.

While you can write a simple one, it is highly recommended to use the audited and battle-tested ReentrancyGuard from the OpenZeppelin library.

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract MyContract is ReentrancyGuard {
    function withdraw(uint256 amount) external nonReentrant {
        // Your withdrawal logic using the CEI pattern
    }
}

3. Safely Transferring Ether with call()

When sending Ether, it’s crucial to use the correct method. The once-common transfer() and send() functions are no longer recommended because they forward a fixed, minimal amount of gas (2300). This stipend is often insufficient for modern smart contract wallets or multisigs to execute their fallback functions, which can cause transactions to fail.

The current best practice is to use .call{value: amount}(""). This method forwards all available gas, making it compatible with a wider range of recipient contracts. However, since it’s a low-level call, you must explicitly check the return value to handle potential failures.

contract EtherTransfers {
    // âś… RECOMMENDED: call() - forwards all available gas and is compatible with modern contracts.
    function transferGood(address payable recipient, uint256 amount) external {
        (bool success, ) = recipient.call{value: amount}("");
        require(success, "Transfer failed");
    }

    // ❌ AVOID: transfer() and send() - fixed gas stipend can cause failures.
}

Why it matters: The Istanbul hard fork (EIP-1884) increased the gas cost of certain operations, breaking many contracts that relied on the 2300 gas stipend. Using .call() is a more forward-compatible and robust approach.

4. Preventing Integer Overflows and Underflows

Since Solidity version 0.8.0, the compiler automatically includes checks for integer overflows and underflows, which were a common source of critical vulnerabilities. These checks revert the transaction if an arithmetic operation exceeds the bounds of the data type (e.g., a uint256 wrapping from its maximum value back to zero).

While these built-in checks provide essential safety, there are rare, performance-critical scenarios where you might need to disable them using an unchecked block. This should only be done when you are absolutely certain, through mathematical proof or logical constraints, that an overflow is impossible. A common example is incrementing a loop counter that is strictly bounded by an array’s length.

function calculateSum(uint256[] calldata numbers) external pure returns (uint256 total) {
    uint256 length = numbers.length;
    for (uint256 i = 0; i < length;) {
        total += numbers[i];
        // This is safe because 'i' can never realistically overflow.
        unchecked { ++i; }
    }
}

5. Implementing Robust Input Validation

Never trust external input. Every parameter passed to your public or external functions should be treated as potentially malicious until validated. Implementing comprehensive checks at the contract boundary is a critical security measure.

contract InputValidation {
    address public owner;
    uint256 public constant MAX_AMOUNT = 1_000_000 ether;

    function transferOwnership(address newOwner) external {
        require(msg.sender == owner, "Not authorized");
        require(newOwner != address(0), "Invalid address");
        require(newOwner != owner, "Already owner");

        owner = newOwner;
    }
}

Key validation patterns:

  • Zero-Address Checks: Ensure address parameters are not address(0).
  • Range Checks: Validate that numerical inputs fall within expected minimum and maximum bounds.
  • Value Matching: For payable functions, verify that msg.value matches the expected payment amount.
  • Array Lengths: Ensure arrays are not empty or do not exceed size limits.

6. Establishing Clear Access Control

Proper access control is fundamental to ensuring that only authorized actors can perform sensitive operations. For simple contracts, a basic ownership pattern where a single address has administrative privileges can be sufficient.

For more complex systems, a Role-Based Access Control (RBAC) model is more appropriate. RBAC allows you to define multiple roles (e.g., ADMIN_ROLE, OPERATOR_ROLE) and grant them to different addresses, providing granular control over your contract’s functions. OpenZeppelin’s AccessControl contract is the industry standard for implementing RBAC.

Best practice: For production systems handling significant value, administrative functions should be controlled by a multi-signature wallet with a timelock. This decentralizes power and provides a delay for users to react to proposed changes.

7. Ensuring Safe External Contract Calls

Interacting with other contracts is common, but it introduces risks. When calling an external contract, especially an ERC20 token, it’s important to handle failures gracefully. Not all tokens revert on failure; some return false. Without checking the return value, your contract might proceed as if the call succeeded, leading to an inconsistent state.

The most robust solution is to use OpenZeppelin’s SafeERC20 library. It provides wrappers for all standard ERC20 functions that handle return values and other non-standard token behaviors, ensuring that external calls behave predictably.

import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

contract SafeTokenCalls {
    using SafeERC20 for IERC20;

    function bestTransfer(IERC20 token, address to, uint256 amount) external {
        token.safeTransfer(to, amount); // Handles all edge cases and reverts on failure.
    }
}

8. Adopting the Pull-Over-Push Payment Pattern

When distributing funds to multiple recipients, a “push” pattern (looping through an array and sending funds) is risky. If one of the transfers fails (e.g., the recipient is a contract that reverts), the entire transaction will fail, blocking payments for everyone else.

A much safer and more robust approach is the “pull” pattern. In this model, you update the internal balances of the recipients but do not send the funds directly. Instead, you provide a withdraw function that allows each user to claim their funds in a separate transaction. This isolates any potential failures and prevents one user from blocking others.

contract PullPayment {
    mapping(address => uint256) public balances;

    // Credit the user's balance internally
    function creditAccount(address account, uint256 amount) external {
        balances[account] += amount;
    }

    // Allow the user to withdraw their funds
    function withdraw() external {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "No balance");

        balances[msg.sender] = 0;

        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");
    }
}

Gas Optimization Strategies

In the Ethereum ecosystem, gas is the fuel that powers transactions, and every operation has a cost. Writing gas-efficient code is not a premature optimization—it’s a critical aspect of good smart contract design that directly impacts user experience. High gas costs can make a decentralized application unusable, so understanding how to minimize them is essential.

1. Choosing the Right Data Location: Storage, Memory, and Calldata

Understanding the difference between data locations is fundamental to gas optimization. storage is the most expensive, as it’s persisted permanently on the blockchain. memory is temporary and cheaper, but calldata is the most cost-effective option for external function parameters that do not need to be modified.

For function parameters involving large structs or arrays, specifying calldata instead of memory can lead to significant gas savings because it avoids copying the data from the transaction into memory.

2. Reducing Costs with Custom Errors

Introduced in Solidity 0.8.4, custom errors are a dramatically more gas-efficient way to communicate failures than the traditional require statements with string messages. A require string stores the entire message on-chain, which can be costly. Custom errors, on the other hand, use a simple error selector, saving gas on both deployment and transaction execution.

contract Errors {
    // âś… CHEAP: Custom errors are highly gas-efficient.
    error ValueTooLow(uint256 provided, uint256 minimum);

    function newStyleError(uint256 value) external pure {
        if (value == 0) {
            revert ValueTooLow(value, 1);
        }
    }

    // ❌ EXPENSIVE: String messages in require statements consume more gas.
}

3. Leveraging Immutable and Constant Variables

If a variable’s value is known at compile time and will never change, it should be declared as a constant. If a variable is set only once in the constructor, it should be immutable. In both cases, the compiler replaces reads of these variables with their actual values in the bytecode, avoiding an expensive SLOAD operation from storage, which can save thousands of gas units per read.

4. Efficiently Packing Storage Variables

The EVM operates on 32-byte (256-bit) slots. When you declare storage variables, the Solidity compiler attempts to pack them together to minimize the number of slots used. You can facilitate this by ordering your variables from smallest to largest. For example, placing two uint128 variables next to each other allows them to be packed into a single slot, whereas placing a uint256 between them would force the contract to use three separate slots.

contract StoragePacking {
    // âś… EFFICIENT: x and y are packed into a single 32-byte slot.
    uint128 public x;
    uint128 public y;

    // ❌ INEFFICIENT: Each variable occupies its own 32-byte slot.
    uint256 public a;
    uint256 public b;
}

5. Using Events for Off-Chain Data

Storing data on-chain is expensive. For historical data or information that doesn’t need to be accessed by other smart contracts, a much cheaper alternative is to use events. Emitting an event costs significantly less gas than writing to storage. This data can then be indexed and queried by off-chain services, such as a dApp front-end or The Graph, providing a cost-effective way to store and retrieve transaction history.

6. Applying Short-Circuit Evaluation

When using logical operators like && or || in require statements, the EVM uses short-circuit evaluation. This means it stops evaluating as soon as the outcome is determined. You can leverage this by ordering your conditions from least to most expensive. Place cheaper checks (like reading from memory or simple comparisons) before more expensive ones (like reading from storage), as this can save gas if the first condition fails.

7. Implementing Batch Operations

A base cost of 21,000 gas is applied to every Ethereum transaction. If users need to perform the same action multiple times (e.g., multiple token transfers), allowing them to batch these actions into a single transaction can provide substantial savings. A function that accepts arrays of recipients and amounts, for example, can process many transfers for the cost of a single base transaction fee.

Writing Clean and Maintainable Code

Beyond security and gas optimization, writing high-quality, maintainable code is crucial for the long-term health of a project. Clear and well-documented code is easier to audit, debug, and extend.

1. Build on Proven Foundations with Established Libraries

One of the most effective ways to improve code quality and security is to use audited, battle-tested libraries like OpenZeppelin. Reinventing the wheel for common patterns like ERC20, Ownable, or ReentrancyGuard is not only inefficient but also introduces unnecessary risk. By leveraging these standard implementations, you build on a foundation that has been scrutinized by experts and proven in countless production environments.

2. Ensure Robustness with Comprehensive Testing

Thorough testing is non-negotiable in smart contract development. Every function, modifier, and contract interaction should be covered by a suite of tests that includes:

  • Unit Tests: To verify the logic of individual functions.
  • Integration Tests: To ensure that contracts work together as expected.
  • Edge Case Testing: To check behavior with zero values, maximum values, and other boundary conditions.
  • Fuzz Testing: To uncover unexpected failures by feeding random inputs to your functions.
  • Invariant Testing: To verify that key properties of your system always hold true, no matter what actions are performed.

A high level of test coverage is a strong indicator of a mature and reliable codebase.

3. Improve Clarity with NatSpec Documentation

Solidity supports the Natural Language Specification (NatSpec) format for documenting code. This allows you to write clear, human-readable comments that explain the purpose of your contracts and functions. Good documentation is invaluable for auditors, developers, and even your future self. It clarifies intent, explains complex logic, and makes the codebase more approachable.

4. Prepare for the Unexpected with Circuit Breakers

For complex systems, especially those managing user funds, implementing a circuit breaker or an emergency pause mechanism is a vital safety feature. Using a Pausable pattern (like the one provided by OpenZeppelin) allows a trusted administrator to temporarily halt critical functions in the event of a suspected vulnerability or attack. This provides a window to investigate and mitigate the issue, protecting user funds from further harm.

5. Plan for the Future with Upgradability

While immutability is a core feature of blockchain, some projects require the ability to upgrade their contract logic over time. If upgradability is a requirement, it’s essential to use established, secure patterns like the UUPS (Universal Upgradeable Proxy Standard). Upgradeable contracts introduce additional complexity and risk, such as the need to maintain storage layout compatibility, so they should be used judiciously and with a clear governance process.

Common Anti-Patterns to Avoid

Just as important as following best practices is knowing which common pitfalls to avoid.

1. Timestamp Dependence

Using block.timestamp for critical logic, especially for generating randomness, is a significant vulnerability. Miners have a degree of control over the timestamps of the blocks they produce, which allows them to manipulate outcomes. While block.timestamp can be acceptable for long-term time checks (e.g., an unlocking period of several days), it should never be the sole determinant in sensitive operations.

2. Unbounded Loops

Loops that iterate over an array of unknown size can be dangerous. If the array grows too large, the gas cost of executing the loop can exceed the block gas limit, causing the transaction to fail permanently. This can effectively trap a contract in an unusable state. If you need to iterate over an array of user data, always use a paginated approach where each transaction processes a fixed-size batch.

3. Unsafe delegatecall

The delegatecall opcode is a powerful but extremely dangerous feature. It allows a contract to execute code from another contract within its own context, meaning the called code can modify the calling contract’s storage. Using delegatecall with an untrusted or unverified contract is one of the most severe security risks in Solidity, as it can lead to a complete takeover of your contract. It should only be used with libraries that you have deployed and control.

Security Audit Checklist

Before deploying to mainnet, verify:

Pre-Deployment

  • All tests pass with 100% coverage
  • Fuzz tests run without failures
  • External security audit completed
  • Formal verification for critical functions (if applicable)
  • Testnet deployment and testing completed
  • Bug bounty program prepared

Smart Contract Security

  • No reentrancy vulnerabilities
  • CEI pattern followed consistently
  • No integer overflow/underflow risks
  • Input validation on all external functions
  • Access control properly implemented
  • No delegatecall to untrusted contracts
  • Safe handling of ERC20 tokens (using SafeERC20)
  • Pull over push payment pattern used
  • Events emitted for all state changes

Gas Optimization

  • Storage variables optimally packed
  • Constant and immutable used where appropriate
  • Custom errors instead of string messages
  • Calldata used for external function parameters
  • No unbounded loops
  • Batch operations available where useful

Code Quality

  • Comprehensive NatSpec documentation
  • OpenZeppelin libraries used where possible
  • Emergency pause functionality (if needed)
  • Upgrade mechanism documented and tested (if applicable)
  • No compiler warnings
  • Consistent naming conventions
  • Code follows style guide

Deployment

  • Constructor parameters verified
  • Initial ownership transferred to multi-sig
  • Contract verified on Etherscan
  • Monitoring and alerting configured
  • Incident response plan documented
  • Timelock on administrative functions (if applicable)

Tools and Resources

Development and Testing

  • Foundry: Fast Solidity testing framework
  • Hardhat: Comprehensive development environment
  • Remix: Browser-based IDE for quick prototyping

Security Analysis

  • Slither: Static analysis tool by Trail of Bits
  • Mythril: Security analysis tool for EVM bytecode
  • Echidna: Fuzzing tool for Ethereum smart contracts
  • Manticore: Symbolic execution tool

Audit and Review

  • OpenZeppelin: Audited contract libraries
  • Consensys Diligence: Security best practices
  • Immunefi: Bug bounty platform
  • Code4rena: Community audit platform

Monitoring

  • Tenderly: Real-time monitoring and alerting
  • Defender: OpenZeppelin’s operations platform
  • Forta: Decentralized monitoring network

Conclusion

Writing secure and efficient Solidity code is a discipline that requires constant vigilance, a commitment to continuous learning, and a deep respect for the immutable nature of blockchain systems. Every contract you deploy is a permanent commitment that can hold significant value and trust.

The best practices outlined here are not exhaustive, as the blockchain security landscape is constantly evolving with new attack vectors and defensive patterns. The most effective way to protect your projects is to stay engaged with the security community, participate in audits, and learn from both the successes and failures across the ecosystem. By internalizing these principles, you can build contracts that are not only functional but also robust, secure, and worthy of user trust.

Footnotes

  1. A detailed explanation of the 2016 DAO hack can be found on Chainlink. ↩