MacBobby Chibuzor Go, Solidity, and Haskell developer interested in the cloud native world and blockchain technology. A fanatic for technical writing and open source contribution.

Smart contract development: Common mistakes to avoid

11 min read 3221

Blockchain Logo Over Checkered Background

Building smart contracts for the blockchain is very different from building applications for Web2. First, smart contracts are immutable after they are deployed on the blockchain, while Web2 applications are scalable and loosely coupled. Second, smart contract code has actual monetary value, making security during development even more critical.

Unfortunately, the Web3 space has suffered numerous attacks and tremendous losses as a result of exploited bugs and vulnerabilities, as well as improperly written code.

Vulnerabilities or bugs that exist in a smart contract will spell trouble for anyone who interacts with it after it is deployed on the mainnet.

In this article, I’ll share critical information for improving the security of your smart contract development. I’ll provide detailed instructions to help you identify and prevent common smart contract vulnerabilities and attack vectors.

Jump ahead:

Reentrancy attacks

The reentrancy attack is one of the earliest smart contract vulnerabilities; it was first detected on the Ethereum blockchain in 2016.

A smart contract must have a fallback function in order to receive Ether. The fallback function is called by the smart contract that is sending the Ether. If the code of the sending smart contract is not written properly, the receiving smart contract could have an opportunity to exploit the sender.

Here’s an example of an insecure sending smart contract:

// INSECURE
mapping (address => uint) private userBalances;

function withdrawBalance() public {
    uint amountToWithdraw = userBalances[msg.sender];
    (bool success, ) = msg.sender.call.value(amountToWithdraw)(""); 
        // At this point, the caller's code is executed, and can call withdrawBalance again
    require(success);
    userBalances[msg.sender] = 0;
}

The code above employs a mapping data structure to keep track of userBalances that have Ether in this contract. The code resets the value of the userBalances back to zero after they call the withdrawBalance function.

This function is vulnerable to reentrancy attacks because it sends Ether to the user before updating the user’s balance in the userBalances mapping. This is unsafe because it gives the receiver an opportunity to call the withdrawBalance function again in its fallback function before the user’s balance is updated. In other words, the receiver could withdraw more than they have in the contract.

We made a custom demo for .
No really. Click here to check it out.

Here’s a better way to write the smart contract:

mapping (address => uint) private userBalances;

function withdrawBalance() public {
    uint amountToWithdraw = userBalances[msg.sender];
    userBalances[msg.sender] = 0;
    // The user's balance is already 0, so future invocations won't withdraw anything
    (bool success, ) = msg.sender.call.value(amountToWithdraw)("");
    require(success);
}

Now let’s look at a more practical example of an insecure smart contract that could fall prey to a reentrancy attack. The code is for an insecure Solidity contract called EtherStore:

// SPDX-License-Identifier: MIT
pragma solidity >=0.4.22 <0.9.0;

contract EtherStore {
        // Mapping Data structure to keep track of balances of users 
    mapping(address => uint256) public balances;

        // As the name suggests, you can use this function to deposit Ether to the contract
    function deposit() public payable {
        balances[msg.sender] += msg.value;
    }

        // For withdrawing the Ether stored by users
    function withdraw() public {

                // Gets the balance of the user calling the withdraw function
        uint256 bal = balances[msg.sender];

                // Checks if the user/caller has Ether in this contract
        require(bal > 0);

                // Transfers the Ether to the user 
                // Vulnerability point
        (bool sent, ) = msg.sender.call{value: bal}("");
        require(sent, "Failed to send Ether");

        balances[msg.sender] = 0;
    }

    // Helper function to check the balance of this contract
    function getBalance() public view returns (uint256) {
        return address(this).balance;
    }
}

This contract enables users to deposit Ether and then withdraw their balance when they please. The contract uses a mapping to track the balances of users, but it makes the mistake of sending Ether out before updating the user’s balance. This makes the contract vulnerable to reentrancy attacks.

A malicious actor could deploy a contract with the address of EtherStore, exploit the reentrancy vulnerability, and then withdraw more Ether than they originally deposited.

Protecting against reentrancy

To mitigate against the risk of reentrancy attacks by malicious actors, follow Solidity’s Check-Effect-Interaction pattern when you write the function that sends out Ether from a contract:

  • Check: confirm if the caller is eligible to call the function
  • Effect: make state changes that the function will trigger before sending out Ether
  • Interaction: send out Ether

Notice that the vulnerable EtherStore contract does something similar but in the wrong order. It checks if the caller is eligible to use the function, but then skips to the interaction phase without implementing the Effects.

Here’s an example of a secure EtherStore contract that follows the Check-Effect-Interaction pattern:

// SPDX-License-Identifier: MIT
pragma solidity >=0.4.22 <0.9.0;

contract EtherStore {
        // Mapping Data structure to keep track of balances of users 
    mapping(address => uint256) public balances;

        // As the name suggests, you can use this function to deposit Ether to the contract
    function deposit() public payable {
        balances[msg.sender] += msg.value;
    }

        // For withdrawing the Ether stored by users
    function withdraw() public {

                // Gets the balance of the user calling the withdraw function
        uint256 bal = balances[msg.sender];

                // Check
        require(bal > 0);
                
                // Effect
                balances[msg.sender] = 0;

                // Interaction 
        (bool sent, ) = msg.sender.call{value: bal}("");
        require(sent, "Failed to send Ether");
    }

    // Helper function to check the balance of this contract
    function getBalance() public view returns (uint256) {
        return address(this).balance;
    }
} 

Arithmetic overflows and underflows

Arithmetic overflows and underflows are issues that stem from Solidity itself. Newer versions of Solidity (0.8.0 and higher) can detect this type of error.

The uint data type in Solidity is used to declare unsigned integers starting at 0. This data type is restricted to certain ranges, including uint8, uint16, uint32, uint64, uint128, and uint256. For example, uint8 represents all unsigned integers starting from 0 to 2^8 – 1 (i.e., 0 to 255).

If we want to use a number higher than 255, we might first consider adopting a uint8 subtype with a wider range. However, this solution would be suboptimal as it would not work for numbers outside the range of the largest uint subtype.

uint256 represents all unsigned integers from 0 to 2^256-1. If we wanted to represent a uint out of its range we’d have no alternative uint type to use. This may not seem like a huge problem. After all, 2^256-1 is a very large number, so the chances of wanting to work with something out of its range are pretty low.

However, if we examine the issue more carefully, we’ll find that the issue is more significant.

Solidity compilers with versions lower than 0.8.0 do not give a warning for using unsigned integers that are out of the range of the uint type that is in use. Instead, the Solidity compiler does something a bit humorous. It converts the out-of-range number to zero – without warning!

Let’s look at an example.

Let’s say we’re using a uint8 type to store the number 256, which is obviously greater than 2^8-1(255);

uint8 num = 256;

The Solidity compiler would react by overflowing the unsigned integer.

In other words, since uint8 only represents integers from 0 to 255, an unsigned integer greater than this range would reset to the difference between its value and the upper limit of the uint type (i.e., 256-255 or 1 in this example). The new integer (1, in this case) “overflows” to the first integer of the uint8 range; the range is 0 to 255, so the first integer is 0.

Again, this only happens in Solidity versions lower than 0.8.0.

Now, let’s take a look at a contract with an arithmetic overflow/underflow vulnerability to get a practical understanding of the threat that this type of vulnerability can pose:

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

contract TimeLock {
    mapping(address => uint) public balances;
    mapping(address => uint) public lockTime;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
        lockTime[msg.sender] = block.timestamp + 1 weeks;
    }

    function increaseLockTime(uint _secondsToIncrease) public {
        lockTime[msg.sender] += _secondsToIncrease;
    }

    function withdraw() public {
        require(balances[msg.sender] > 0, "Insufficient funds");
        require(block.timestamp > lockTime[msg.sender], "Lock time not expired");

        uint amount = balances[msg.sender];
        balances[msg.sender] = 0;

        (bool sent, ) = msg.sender.call{value: amount}("");
        require(sent, "Failed to send Ether");
    }
}

The above code is for a TimeLock smart contract. This contract is meant to lock the user’s Ether for a duration of one week; it also has the functionality to increase the duration. This is a pretty nice utility for a smart contract. Unfortunately, this contract is vulnerable to overflow/underflow attacks because the withdraw function logic is dependent on a uint.

A malicious actor could write code to deposit Ether to the contract and then withdraw a larger amount before the lock period is over by “overflowing” (essentially, resetting) the lock time function to zero.

Protecting against arithmetic overflows and underflows

In Solidity versions higher than 0.8.0, the compiler will detect and adjust for an arithmetic overflow/underflow vulnerability.

If you’re using a Solidity version lower than 0.8.0, you can protect against this vulnerability with the SafeMath library.

Here’s a secure version of our TimeLock contract using SafeMath:

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

library SafeMath {

  function mul(uint256 a, uint256 b) internal pure returns (uint256) {
    if (a == 0) {
      return 0;
    }
    uint256 c = a * b;
    assert(c / a == b);
    return c;
  }

  function div(uint256 a, uint256 b) internal pure returns (uint256) {
    // assert(b > 0); // Solidity automatically throws when dividing by 0
    uint256 c = a / b;
    // assert(a == b * c + a % b); // There is no case in which this doesn't hold
    return c;
  }

  function sub(uint256 a, uint256 b) internal pure returns (uint256) {
    assert(b <= a);
    return a - b;
  }

  function add(uint256 a, uint256 b) internal pure returns (uint256) {
    uint256 c = a + b;
    assert(c >= a);
    return c;
  }
}

contract TimeLock {
    using SafeMath for uint; // use the library for uint type
    mapping(address => uint256) public balances;
    mapping(address => uint256) public lockTime;

    function deposit() public payable {
        balances[msg.sender] = balances[msg.sender].add(msg.value);
        lockTime[msg.sender] = now.add(1 weeks);
    }

    function increaseLockTime(uint256 _secondsToIncrease) public {
        lockTime[msg.sender] = lockTime[msg.sender].add(_secondsToIncrease);
    }

    function withdraw() public {
        require(balances[msg.sender] > 0);
        require(now > lockTime[msg.sender]);
        uint transferValue = balances[msg.sender];
        balances[msg.sender] = 0;
        msg.sender.transfer(transferValue);
    }
}

Cross-function race conditions

Race conditions are a pretty general problem in programming and they surface in smart contract development in various shapes and forms. the reentrancy vulnerability we talked about previously is, in fact, a type of race condition in Solidity.

Cross-function race conditions occur in situations in which two or more Solidity functions are trying to access the same state variable for their individual computation before either has the chance to make the update.

Let’s have a look at a Solidity smart contract where this vulnerability is observed:

mapping(address => uint256) public userBalances;

/* uses userBalance to transfer funds */
function transfer(address to, uint256 amount) public {
    if (userBalances[msg.sender] >= amount) {
        userBalances[to] += amount;
        userBalances[msg.sender] -= amount;
    }
}

/* uses userBalances to withdraw funds */
function withdrawalBalance() public {
    uint256 amountToWithdraw = userBalances[msg.sender];
        // makes external call to the address receiving the ether
    (bool sent, ) = msg.sender.call{value: amountToWithdraw}("");
    require(sent, "Failed to send Ether");
    userBalances[msg.sender] = 0;
}

In the contract above we have two functions: transfer and withdrawalBalance. There is a cross-function bug that could be triggered with this contract.

A bad actor could write a contract to call withdrawal and transfer functions nearly simultaneously. If successful, the attacker would be able to transfer Ether to another address and also withdraw it at the same time.

To understand this vulnerability better, let’s consider an off-chain example. Imagine you have $100 in your bank account and want to make both a transfer and a withdrawal. An example of a race condition occurs when you transfer $50 and withdraw $50 simultaneously before your account balance is updated. In this instance, you end up (at least temporarily) having $50 in your account even though you spent $100. This is referred to as double spending, and it’s an example of the types of issues the blockchain was intended to solve.

Protecting against cross-function race conditions

As I mentioned earlier, there are some similarities between a cross-function bug and a reentrancy attack. Both of these vulnerabilities are types of race conditions, and both arise from the improper use of external calls.

Another similarity can be found in their solutions. When dealing with race conditions generally in programming, it is advisable to place a lock on the particular variable that is being used for computation until the process is completed. Fortunately, Solidity has the Check-Effect-Interaction pattern which we discussed previously.

To illustrate how the Check-Effect-Interaction pattern works, let’s say a function is supposed to withdraw a particular amount of Ether and the contract in question has a variable to track user balances.

First, we check that the caller is eligible to call this function. Then, in the effect phase, we make changes to the variable before making the external call. Finally, in the interaction phase, we make the external call that sends out Ether.

When implemented correctly, this pattern is enough to stop a cross-function race condition in a smart contract.

Here’s a secure version of the cross-function race conditions contract we examined earlier:

mapping(address => uint256) public userBalances;

/* uses userBalance to transfer funds */
function transfer(address to, uint256 amount) public {
    if (userBalances[msg.sender] >= amount) {
        userBalances[to] += amount;
        userBalances[msg.sender] -= amount;
        return true;
    }
}

/* uses userBalances to withdraw funds */
function withdrawalBalance() public {
    // Not really a check but close enough
    uint256 amountToWithdraw = userBalances[msg.sender];

    // Effect 
    userBalances[msg.sender] = 0;

    // Interaction
    (bool sent, ) = msg.sender.call{value: amountToWithdraw}("");
    require(sent, "Failed to send Ether");
}

Transaction order dependence

Since Ethereum is a public blockchain, some vulnerabilities that arise are related to race conditions in which malicious actors use the information provided by users to run transactions that benefit themselves at the expense of others.

Each transaction made on Ethereum is sent to the Mempool before it is mined. Miners select the transactions that they want to add to a block and mine to the blockchain. Miners are incentivized to pick transactions with a higher gas price since these transactions translate to greater rewards. Under this scenario, it’s possible for a transaction that came into the Mempool last to be mined first if the gas price of the last transaction is higher than that of the first transaction.

This loophole gives bad actors access to sensitive information from certain transactions and the opportunity to use that information to their benefit before the initial transaction is mined.

To clarify this further, let’s look at an example.

Imagine a contract that was deployed with a certain amount of Ether, and the first person to pass a particular string to a function of that contract gets the Ether that is stored in the contract. User A discovers the string needed to access the Ether in the contract, so they call the function and pass the string. The transaction of User A calling the function must go to the Mempool first, so the input they pass to the function will be visible to everyone who has access to view the Mempool.

Now, imagine that a malicious actor (User B) can view the string that User A used in their transaction. User B runs the same function with that string but with a higher gas price, causing miners to pick User B’s string first. User B looks like the first person to discover the string, and they get the Ether from the contract.

Transaction order dependence attacks are more commonly referred to as front-running. Front-running can take many forms. In another scenario, a contract could be set up so that whoever solves the function receives all the Ether in the contract, but with the stipulation that to run that function the user has to pass a Require which checks if the string they passed into the function as an input matches the pre-image of the particular hash.

Ordinarily, this would be a good safeguard against an attack, because it’s difficult to calculate the pre-image of a hash. However, a bad actor could monitor the Mempool for any user with the correct solution, copy the user’s input, and then run the function with a higher gas price in order to get the Ether from the contract.

Another example of front-running is where User A approves User B to spend a certain number of their tokens with the Approve and TransferFrom functions, but then later reduces the amount. Now, let’s say User B was monitoring the Mempool and saw User A’s transaction to rescue their allocated number of tokens. User B could send their own transaction (with a higher gas price) to the Mempool, spending the original number of allocated tokens. Since User B’s transaction was mined before User A could reduce their tokens, User B gets the original allocation. Plus, they also receive the reduced number of tokens when User A’s transaction is finally mined!

Protecting against transaction order dependence

Front-running, or transaction order dependence, attacks are difficult, but not impossible, to stop.

One method to prevent front-running is to create an upper bound on gas price in the smart contract so that users can’t alter the gas price. However, this strategy only stops normal users since miners can still bypass the upper bound. In many instances, there may be no cause for alarm, since it is difficult for miners to target a single block.

A second method is for a user to employ a commit/reveal scheme. The first transaction a user sends would be encrypted in a particular way (the commit phase. Then, when the transaction gets mined, the user sends another transaction decrypting the information that was sent in the first (the reveal phase). With this method, neither normal users nor miners can pull a front-running attack.

Timestamp dependence

It may be tempting to use the block.timestamp function to get the current time and then use this data to perform some business logic in your smart contract. However, this is not recommended due to Ethereum’s decentralized nature.

The time provided by the block.timestamp function is inaccurate and can be manipulated by malicious miners.

Protecting against timestamp dependence

Here are some important points to remember about timestamp dependence:

  • Do not rely on block.timestamp or blockhash as a source of randomness, unless you know what you are doing; both of these functions can be influenced by miners to some degree
  • The current block timestamp must be strictly larger than the timestamp of the last block, but the only guarantee is that it will be somewhere between the timestamps of two consecutive blocks in the canonical chain
  • If you must use time in your smart contract’s business logic, consider employing the use of oracles, such as Chainlink, which aggregate timestamp data off-chain

Conclusion

In this article, we reviewed several common smart contract vulnerabilities as well as some smart contract development mistakes to avoid: reentrancy, arithmetic overflows and underflows, cross-function race conditions, transaction order dependence, and timestamp dependence.

All of the smart contract examples reviewed in this article were written in Solidity.

If you find this article helpful, please share it with other smart contract developers. Of course, we can’t predict new vulnerabilities that may arise in the future. But, in the meantime, we can mitigate against threats by learning from recent losses in the Web3 space.

WazirX, Bitso, and Coinsquare use LogRocket to proactively monitor their Web3 apps

Client-side issues that impact users’ ability to activate and transact in your apps can drastically affect your bottom line. If you’re interested in monitoring UX issues, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.https://logrocket.com/signup/

LogRocket is like a DVR for web and mobile apps, recording everything that happens in your web app or site. Instead of guessing why problems happen, you can aggregate and report on key frontend performance metrics, replay user sessions along with application state, log network requests, and automatically surface all errors.

Modernize how you debug web and mobile apps — .

MacBobby Chibuzor Go, Solidity, and Haskell developer interested in the cloud native world and blockchain technology. A fanatic for technical writing and open source contribution.

Leave a Reply