Best Practices for Writing Secure Smart Contract Code

Security

October 17, 2024

Intro

Smart contract security refers to the measures and practices that protect smart contracts from malicious attacks and vulnerabilities.

Smart contracts are self-executing programs with the behavior directly enforced by the code. Once deployed on a blockchain network, the code is immutable, meaning it can not be changed. While this ensures transparency and eliminates the need for intermediaries, it also introduces risks due to the immutable nature of the code.

In this guide, we’ll give you practical smart contract security tips to help you write secure smart code.

Why is Smart Contract Security Important?

The decentralized nature of blockchain means that smart contracts, once deployed, cannot be changed.

Malicious actors can exploit loopholes or manipulate smart contracts to steal assets if the code is not secured. A single line of code can lead to millions of dollars in losses.

According to defiLlama, around $114m was lost to DeFi hacks in September 2024, and smart contract (protocol logic) exploits accounted for $45 million, roughly 40% of this figure. These figures underscore the critical importance of smart contract security.

Best Practices for Writing Secure Smart Contract Codes

1. Use Well-tested and Secure Libraries/Functions

External dependencies can introduce vulnerabilities in your smart contracts if they are not properly tested. You can minimize this risk with well-tested and established libraries, such as OpenZeppelin.

It’s also a good idea to conduct a security review and test any external code you intend to use in your contract to ensure it is free from vulnerabilities.

2. Implement Development Security Patterns

Security patterns are standardized ways to defend against common attack vectors like reentrancy in your code. These patterns provide a structured approach to handling security risks. So, it is essential to include these security patterns in your code. Here are some important ones to keep in mind.

2.1. CEI Pattern (Checks-Effects-Interaction)

The CEI (Checks-Effects-Interaction) pattern prevents unexpected smart contract executions by ensuring that all necessary checks are performed before any interactions occur.

The function’s operations typically follow this order:

  • Checks: Validate all conditions and prerequisites (e.g., sufficient balance).
  • Effects: Update state variables (e.g., deduct balance).
  • Interaction: Transfer funds or interact with external contracts.

The CEI pattern can help prevent risks associated with reentrancy attacks.

For example, the following code is vulnerable to reentrancy risk because it sends Ether to the user before updating their balance.


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

    function deposit() public payable {
        require(msg.value > 0, "Deposit amount must be greater than zero");
        balances[msg.sender] += msg.value;
    }

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

        //@audit this external call should be made after deducting the `amount` 
        //from the user's balance
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Withdrawal failed");

        // Effects: Update the user's balance
        balances[msg.sender] -= amount;
    }
}

A malicious actor can exploit this vulnerability by calling the withdraw function repeatedly before the balance is updated. The CEI pattern mitigates reentrancy risks by updating the contract’s state before making external calls.

Here is a modified version with the CEI pattern:


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

    function deposit() public payable {
        require(msg.value > 0, "Deposit amount must be greater than zero");
        balances[msg.sender] += msg.value;
    }

    function withdraw(uint256 amount) public {
        // Checks: Ensure that the user has enough balance to withdraw the requested amount
        require(balances[msg.sender] >= amount, "Insufficient balance");

        // Effects: Update the user's balance
        balances[msg.sender] -= amount;

        // Interactions: Transfer the requested amount to the user
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Withdrawal failed");
    }
}

In this version, the withdraw function follows CEI because:

  • Check: makes sure there is sufficient balance to withdraw the requested amount.
  • Effect: Updates the balance before removing the requested amount.
  • Interaction: Sends the requested amount with a low-level call function, and the success of the transfer is checked with the “require” statement.

Because the balance is updated before transferring the tokens out, even if a user tries to reenter, the function will revert because their balance will not be sufficient anymore.

2.2. Emergency Stop Pattern

The Emergency Stop pattern (also known as a “circuit breaker”) allows for halting certain contract functions in case of an emergency. This pattern introduces a kill switch to stop critical functions if a vulnerability is detected.

A good way to monitor your smart contract for vulnerabilities is to use monitoring solutions like Forta bots. These type of bots look for certain transaction characteristics or state changes to detect anomalies or vulnerabilities.

Here is a sample of a code showing the Emergency Stop Pattern:

contract EmergencyStop is Owned {
    // State variable to track whether the contract is stopped
    bool public contractStopped = false;

    // Modifier to allow functions only when contract is running (not stopped)
    modifier haltInEmergency() {
        require(!contractStopped, "Contract is stopped");
        _;
    }

    // Modifier to allow functions only when contract is paused (in emergency)
    modifier enableInEmergency() {
        require(contractStopped, "Contract is running");
        _;
    }

    // Function to toggle the stopped state of the contract (only the owner can call)
    function toggleContractStopped() public onlyOwner {
        contractStopped = !contractStopped;
    }

    // Example deposit function that works when contract is running (not stopped)
    function deposit() public payable haltInEmergency {
        // Deposit logic here
    }

    // Example withdraw function that works only when contract is paused (in emergency)
    function withdraw() public view enableInEmergency {
        // Withdrawal logic here
    }
}

2.3. Delayed Withdrawal Logic

The Delayed Withdrawal Logic or Speed Bump pattern helps prevent unapproved withdrawals in malicious activities by setting a delayed period in the withdrawal process. Speed bump sets a waiting period by setting a time interval or maximum amount on withdrawals.

This pattern is helpful when dealing with irreversible actions that can be exploited, like fund transfers, and governance decisions. The idea is to give user/contract administrators enough time to react to malicious activities.

For example, the code below introduces a wait period of three days before a user can withdraw their funds. When the withdraw function is called, it first checks it’s been 3 days since the withdrawal request by calling the requestWithdrawal.

contract SpeedBump { 
  struct Withdrawal { 
    uint amount; 
    uint requestedAt; 
  } 
  mapping (address => uint) public balances; 
  mapping (address => Withdrawal) public withdrawals; 
  uint constant WAIT_PERIOD = 3 days;
  
function deposit() public payable { 
      balances[msg.sender] += msg.value;
  }
function requestWithdrawal() public { 
    if (balances[msg.sender] > 0) { 
      uint amountToWithdraw = balances[msg.sender]; 
      balances[msg.sender] = 0; 
      withdrawals[msg.sender] = Withdrawal({ amount: amountToWithdraw, requestedAt: block.timestamp}); 
    } 
  }
function withdraw() public {
    if(withdrawals[msg.sender].amount > 0 && block.timestamp > withdrawals[msg.sender].requestedAt + WAIT_PERIOD) { 
      uint amount = withdrawals[msg.sender].amount; 
      withdrawals[msg.sender].amount = 0; 
      msg.sender.transfer(amount); 
     } 
     else {
     revert()
     }
  } 
}

2.4. Rate Limit Pattern

The Rate Limit pattern limits the actions that users can make. The limits can be imposed on token transfer amounts or can time-bound certain actions. This helps prevent spamming or denial-of-service attacks (DoS).

This makes it useful for preventing attacks like Denial of Service (DoS), spamming, and abuse. Below is a code sample that limits the amount that can be withdrawn by a user. Any attempts to withdraw more tokens after the limit is reached will be rejected.

contract RateLimited {
    uint256 public constant AMOUNT_LIMIT = 100;
    mapping (address => uint) public amountsWithdrawn; 

    modifier rateLimited(uint amount) {
        if (amountsWithdrawn[msg.sender] > AMOUNT_LIMIT) {
            revert();
        }
        _;
    }

    function performAction() public rateLimited {
        // Your logic here
    }
}

3. Check for  Integer Overflows/Underflows (If you’re using a Solidity version smaller than 0.8.0.)

Note that this is only an issue if you’re using earlier versions of Solidity. This is not an issue starting from Solidity version v0.8.0 and above since these versions automatically check for overflows and underflows.

An overflow occurs when a number exceeds the maximum value that a unit variable can store, while an underflow happens when a number drops below the minimum limit and it will wrap around.

// Vulnerable to Overflow
contract Overflow {
    uint8 public maxUint = 255; // Max value for uint8

    function increment() public {
        maxUint += 1;  // This will cause an overflow, resetting maxUint to 0
    }
}


// Vulnerable to Underflow
contract Underflow {
    uint8 public minUint = 0;  // Min value for uint8 is 0

    function decrement() public {
        minUint -= 1;  // This will cause an underflow, wrapping minUint to 255
    }
}

Both cases can lead to unexpected behavior which a malicious actor can potentially exploit.

Pro tip: Use SafeMath to prevent overflows and underflows. This aims to ensure that the operations throw an error if something goes wrong. For example, the code below ensures that if an overflow or underflow occurs, the operation will revert, keeping the contract safe.

// SafeMath Example
import "@openzeppelin/contracts/utils/math/SafeMath.sol";

contract SafeMathExample {
    using SafeMath for uint256;
    uint256 public maxUint = 255;

    function increment() public {
        maxUint = maxUint.add(1);  // SafeMath prevents overflow
    }

    function decrement() public {
        maxUint = maxUint.sub(1);  // SafeMath prevents underflow
    }
}

4. Use the Latest Version of the Solidity Compiler

Always use the most recent version of the Solidity compiler. Solidity regularly releases updates that include security patches.

For example, Solidity 0.8.x introduced built-in overflow/underflow protection.

5. Keep Smart Contract Simple

Keep your smart contract code and logic as simple as possible. Note that complexity increases the chances of vulnerabilities and may make it harder to detect security vulnerabilities.

In the case of complex logic, try to break down the logic into smaller functions with specific responsibilities.

Here are some ways to help keep your smart contract simple:

  • Try to use well-trusted libraries where possible.
  • Break down the contract logic into smaller, modular components to keep the contract small. That way it is easier to read, understand, and maintain the contract’s logic
  • Plan the contract’s behavior by defining the logic clearly with schema diagrams, pseudocodes, and clear variable names.

6. Define Visibility Specifiers

When designing your system, take some time to think about visibility specifiers. This is because visibility specifiers, such as public, private, external, and internal, dictate who can call functions within your protocol’s smart contracts.

Below is a quick overview of who can call the code with different specifiers.

  • Public: accessible by the contract itself, child contracts, and external contracts.
  • Private: can only be called by the main contract itself. Child contracts do not have access to them.
  • External: can only be called from an external contract.
  • Internal: can be called by the main contract and its child contracts who inherit from it, but they can not be called by external contracts.

Make sure you define the visibility according to your protocol’s design. For example, functions that handle sensitive logic should typically be marked as internal or private.

7. Add Extra Layers of Fail-Safe Protection

Extra layers of fail-safe mechanisms can provide a final line of defense against malicious attacks

  • Time-locking: Add delays before executing high-stakes functions, giving the community or developers time to review transactions.
  • Upgradeability: Include upgradability features in your code. That way, you can fix bugs and add new features.
  • Decentralization: distribute the control of the contract to minimize the risk of a single point of failure. Use multi-signature wallets to sign off on important transactions.

8. Test Your Smart Contract in a Simulated Environment

Before deploying your smart contract, ensure you run it in a simulated environment (Testnets) like Sepolia, Georli, and Holesky.

The immutable nature of smart contracts after deployment makes it essential to thoroughly test the codes in a simulated environment.

Testing can help identify potential vulnerabilities and help ensure that your contract performs as expected under various scenarios.

Below are some testing techniques you can consider to test your smart contract.

8.1 Unit Testing

Unit tests are great for isolating and testing specific functions within your contract. For unit testing, you have to break your code into smaller units with a single function and test each one rigorously.

A good way to carry unit tests is to create assertions (simple statements that explain the function’s intended behaviors) and test if these assertions are true or false.  Unit testing is also better conducted early when writing your codes to save time later. Regardless of when you do unit testing, it should come before integration testing.

8.2. Integration Testing

With Integration testing, you want to examine how components of your contract interact or how your smart contract interacts with another part of the system like other contracts or APIs.

Integration testing can help detect issues arising from cross-contact calls or interactions between different functions within the code. You can also check if inheritances or dependencies work properly.

8.3. Fuzz Testing

In Fuzz testing, the contract is tested with random data like extreme values as inputs to see how the code behaves under various conditions based on the input value.

Fuzz Testing or Fuzzing can help you detect vulnerabilities that unit testing may not detect in some cases. It is especially useful if your code relies on a lot of math since it is useful for evaluating a smart contract's input validation mechanism.

9. Get a Smart Contract Audit

A smart contract audit is probably the most important security measure a protocol can take to secure its codebase. A third-party audit aims to identify security vulnerabilities you might have overlooked.

The audit process typically involves:

  • Manual code review;
  • Automated vulnerability scanning;
  • Code testing, and
  • A detailed report that highlights the vulnerabilities that were found.

When choosing an audit partner, look for firms with deep expertise in blockchain, a comprehensive audit process, transparent reporting, and strong client testimonials.

Pro Tip: Take it a step further with formal verification. Formal verification uses mathematical models to prove the correctness of your contract’s logic and check that it behaves as intended in different scenarios.

Conclusion

While there is no such thing as a completely risk-free contract, following these best practices can significantly minimize vulnerabilities.

The Nethermind Security team offers industry-leading smart contract audits tailored to your project’s needs. In addition to practical expertise in security/auditing, the team is also capable of highly advanced engineering and mathematic work like formal verification and ZK research. The team also has a strong academic background, with many team members holding PhD or actively involved in blockchain research community.

But what sets the team aside is the client-centric approach to all audits. We provide audits tailored to client needs with transparency and ongoing client communication - ensuring thorough and comprehensive reviews.

Disclaimer: This article has been prepared for the general information and understanding of the readers. No representation or warranty, express or implied, is given by Nethermind as to the accuracy or completeness of the information or opinions contained in the above article. No third party should rely on this article in any way, including without limitation as financial, investment, tax, regulatory, legal, or other advice, or interpret this article as any form of recommendation.

Latest articles