DEV Community

Cover image for A developer’s guide to Solidity design patterns
Matt Angelosanto for LogRocket

Posted on • Originally published at blog.logrocket.com

A developer’s guide to Solidity design patterns

Written by Joel Adewole✏️

Due to the continued increasing popularity of blockchain and DApps (decentralized applications), open source DApps are seeing growth in contributions from a wide variety of developers. The heart of most DApps and blockchain applications are smart contracts developed using Solidity.

Contribution to open source projects raises concerns within the Solidity community because these projects have real-world consequences for people’s money, and when developers from different backgrounds collaborate on a project, it is almost certain that there will be errors and code conflicts in the applications. This is why practicing proper standards for DApps is so critical.

To maintain excellent standards, eliminate risks, mitigate conflicts, and construct scalable and secure smart contracts, it is necessary to study and use the correct implementation of design patterns and styles in Solidity.

This article will discuss the Solidity design pattern; you must be familiar with Solidity to follow along.

Contents

What is a Solidity design pattern?

As a developer, you can learn to use Solidity from various resources online, but these materials are not the same, because there are many different ways and styles of implementing things in Solidity.

Design patterns are reusable, conventional solutions used to solve reoccurring design flaws. Making a transfer from one address to another is a practical example of frequent concern in Solidity that can be regulated with design patterns.

When transferring Ether in Solidity, we use the Send, Transfer, or Call methods. These three methods have the same singular goal: to send Ether out of a smart contract. Let's have a look at how to use the Transfer and Call methods for this purpose. The following code samples demonstrate different implementations.

First is the Transfer method. When using this approach, all receiving smart contracts must define a fallback function, or the transfer transaction will fail. There is a gas limit of 2300 gas available, which is enough to complete the transfer transaction and aids in the prevention of reentry assaults:

function Transfer(address payable _to) public payable {     
  _to.transfer(msg.value); 
} 
Enter fullscreen mode Exit fullscreen mode

The code snippet above defines the Transfer function, which accepts a receiving address as _to and uses the _to.transfer method to initiate the transfer of Ether specified as msg.value.

Next is the Call method. Other functions in the contract can be triggered using this method, and optionally set a gas fee to use when the function executes:

function Call(address payable _to) public payable {
    (bool sent) = _to.call.gas(1000){value: msg.value}("");
    require("Sent, Ether not sent");
}
Enter fullscreen mode Exit fullscreen mode

The code snippet above defines the Call function, which accepts a receiving address as _to, sets the transaction status as boolean, and the result returned is provided in the data variable. If msg.data is empty, the receive function executes immediately after the Call method. The fallback runs where there is no implementation of the receive function.

The most preferred way to transfer Ether between smart contracts is by using the Call method.

In the examples above, we used two different techniques to transfer Ether. You can specify how much gas you want to expend using Call, whereas Transfer has a fixed amount of gas by default.

These techniques are patterns practiced in Solidity to implement the recurring occurrence of Transfer.

To keep things in context, the following sections are some of the design patterns that Solidity has regulated.

Behavioral patterns

Guard check

Smart contracts' primary function is to ensure the requirements of transactions pass. If any condition fails, the contract reverts to its previous state. Solidity achieves this by employing the EVM's error handling mechanism to throw exceptions and restore the contract to a working state before the exception.

The smart contract below shows how to implement the guard check pattern using all three techniques:

contract Contribution {
  function contribute (address _from) payable public {
    require(msg.value != 0);
    require(_from != address(0));
    unit prevBalance = this.balance;
    unit amount;

    if(_from.balance == 0) {
      amount = msg.value;
    } else if (_from.balance < msg.sender.balance) {
      amount = msg.value / 2;
    } else {
      revert("Insufficent Balance!!!");
    }

    _from.transfer(amount);
    assert(this.balance == prevBalance - amount);
  }
}
Enter fullscreen mode Exit fullscreen mode

In the code snippet above, Solidity handles error exceptions using the following:

require() declares the conditions under which a function executes. It accepts a single condition as an argument and throws an exception if the condition evaluates to false, terminating the function's execution without burning any gas.

assert() evaluates the conditions for a function, then throws an exception, reverts the contract to the previous state, and consumes the gas supply if the requirements fail after execution.

revert() throws an exception, returns any gas supplied, and reverts the function call to the contract's original state if the requirement for the function fails. The revert() method does not evaluate or require any conditions.

State machine

The state machine pattern simulates the behavior of a system based on its previous and current inputs. Developers use this approach to break down big problems into simple stages and transitions, which are then used to represent and control an application's execution flow.

The state machine pattern can also be implemented in smart contracts, as shown in the code snippet below:

contract Safe {
    Stages public stage = Stages.AcceptingDeposits;
    uint public creationTime = now;
    mapping (address => uint) balances;

    modifier atStage(Stages _stage) {
      require(stage == _stage);
      _;
    }

    modifier timedTransitions() {
      if (stage == Stages.AcceptingDeposits && now >=
      creationTime + 1 days)
      nextStage();
      if (stage == Stages.FreezingDeposits && now >=
      creationTime + 4 days)
      nextStage();
      _;
    }
    function nextStage() internal {
      stage = Stages(uint(stage) + 1);
    }
    function deposit() public payable timedTransitions atStage(Stages.AcceptingDeposits) {
      balances[msg.sender] += msg.value;
    }
    function withdraw() public timedTransitions atStage(Stages.ReleasingDeposits) {
      uint amount = balances[msg.sender];
      balances[msg.sender] = 0;
      msg.sender.transfer(amount);
    }
}
Enter fullscreen mode Exit fullscreen mode

In the code snippet above, the Safe contract uses modifiers to update the state of the contract between various stages. The stages determine when deposits and withdrawals can be made. If the current state of the contract is not AcceptingDeposit, users can not deposit to the contract, and if the current state is not ReleasingDeposit, users can not withdraw from the contract.

Oracle

Ethereum contracts have their own ecosystem where they communicate. The system can only import external data via a transaction (by passing data to a method), which is a drawback because many contract use cases involve knowledge from sources other than the blockchain (e.g., the stock market).

One solution to this problem is to use the oracle pattern with a connection to the outside world. When an oracle service and a smart contract communicate asynchronously, the oracle service serves as an API. A transaction begins by invoking a smart contract function, which comprises an instruction to send a request to an oracle.

Based on the parameters of such a request, the oracle will fetch a result and return it by executing a callback function in the primary contract. Oracle-based contracts are incompatible with the blockchain concept of a decentralized network, because they rely on the honesty of a single organization or group.

Oracle services 21 and 22 address this flaw by providing a validity check with the data supplied. Note that an oracle must pay for the callback invocation. Therefore, an oracle charge is paid alongside the Ether required for the callback invocation.

The code snippet below shows the transaction between an oracle contract and its consumer contract:

contract API {
    address trustedAccount = 0x000...; //Account address
    struct Request {
        bytes data;
        function(bytes memory) external callback;
    }
    Request[] requests;
    event NewRequest(uint);

    modifier onlyowner(address account) {
        require(msg.sender == account);
        _;
    }
    function query(bytes data, function(bytes memory) external callback) public {
        requests.push(Request(data, callback));
        NewRequest(requests.length - 1);
    }
    // invoked by outside world
    function reply(uint requestID, bytes response) public
    onlyowner(trustedAccount) {
    requests[requestID].callback(response);
    }
}
Enter fullscreen mode Exit fullscreen mode

In the code snippet above, the API smart contract sends a query request to a knownSource using the query function, which executes the external callback function and uses the reply function to collect response data from the external source.

Randomness

Despite how tricky it is to generate random and unique values in Solidity, it is in high demand. The block timestamps are a source of randomness in Ethereum, but they are risky because the miner can tamper with them. To prevent this issue, solutions like block-hash PRNG and Oracle RNG were created.

The following code snippet shows a basic implementation of this pattern using the most recent block hash:

// This method is predicatable. Use with care!
function random() internal view returns (uint) {
    return uint(blockhash(block.number - 1));
}
Enter fullscreen mode Exit fullscreen mode

The randomNum() function above generates a random and unique integer by hashing the block number (block.number, which is a variable on the blockchain).

Security patterns

Access restriction

Because there are no built-in means to manage execution privileges in Solidity, one common trend is to limit function execution. Execution of functions should only be on certain conditions like timing, the caller or transaction information, and other criteria.

Here's an example of conditioning a function:

contract RestrictPayment {
    uint public date_time = now;

    modifier only(address account) {
        require(msg.sender == account);
        _;
    }

    function f() payable onlyowner(date_time + 1 minutes){
      //code comes here
    }
}
Enter fullscreen mode Exit fullscreen mode

The Restrict contract above prevents any account different from the msg.sender from executing the payable function. If the requirements for the payable function are not met, require is used to throw an exception before the function is executed.

Check effects interactions

The check effects interaction pattern decreases the risk of malicious contracts attempting to take over control flow following an external call. The contract is likely transferring control flow to an external entity during the Ether transfer procedure. If the external contract is malicious, it has the potential to disrupt the control flow and cause the sender to rebound to an undesirable state.

To use this pattern, we must be aware of which parts of our function are vulnerable so that we can respond once we find the possible source of vulnerability.

The following is an example of how to use this pattern:

contract CheckedTransactions {
    mapping(address => uint) balances;
    function deposit() public payable {
        balances[msg.sender] = msg.value;
    }

    function withdraw(uint amount) public {
        require(balances[msg.sender] >= amount);
        balances[msg.sender] -= amount;
        msg.sender.transfer(amount);
    }
}
Enter fullscreen mode Exit fullscreen mode

In the code snippet above, the require() method is used throw an exception if the condition balances[msg.sender] >= amount fails. This means, a user can not withdraw an amount greater the balance of the msg.sender.

Secure Ether transfer

Although cryptocurrency transfers are not Solidity's primary function, they happen frequently. As we discussed earlier, Transfer, Call, and Send are the three fundamental techniques for transferring Ether in Solidity. It is impossible to decide which method to use unless one is aware of their differences.

In addition to the two methods(Transfer and Call) discussed earlier in this article, transmitting Ether in Solidity can be done using the Send method.

Send is similar to Transfer in that it costs the same amount of gas as the default (2300). Unlike Transfer, however, it returns a boolean result indicating whether the Send was successful or not. Most Solidity projects no longer use the Send method.

Below is an implementation of the Send method:

function send(address payable _to) external payable{
    bool sent = _to.send(123);
    require(sent, "send failed");
}
Enter fullscreen mode Exit fullscreen mode

The send function above, uses the require() function to throw an exception if the Boolean value of sent returned from _to.send(123) is false.

Pull-over-push

This design pattern shifts the risk of Ether transfer from the contract to the users. During the Ether transfer, several things can go wrong, causing the transaction to fail. In the pull-over-push pattern, three parties are involved: the entity initiating the transfer (the contract's author), the smart contract, and the receiver.

This pattern includes mapping, which aids in the tracking of users' outstanding balances. Instead of delivering Ether from the contract to a recipient, the user invokes a function to withdraw their allotted Ether. Any inaccuracy in one of the transfers has no impact on the other transactions.

The following is an example of pull-over-pull:

contract ProfitsWithdrawal {
    mapping(address => uint) profits;
    function allowPull(address owner, uint amount) private {
        profits[owner] += amount;
    }
    function withdrawProfits() public {
        uint amount = profits[msg.sender];
        require(amount != 0);
        require(address(this).balance >= amount);
        profits[msg.sender] = 0;
        msg.sender.transfer(amount);
    }
}
Enter fullscreen mode Exit fullscreen mode

In the ProfitsWithdrawal contract above, allows users to withdraw the profits mapped to their address if the balance of the user is greater than or equal to profits alloted to the user.

Emergency stop

Audited smart contracts may contain bugs that aren't detected until they're involved in a cyber incident. Errors discovered after the contract launch will be tough to fix. With the help of this design, we can halt a contract by blocking calls to critical functions, preventing attackers until the rectification of the smart contract.

Only authorized users should be allowed to use the stopping functionality to prevent users from abusing it. A state variable is set from false to true to determine the termination of the contract. After terminating the contract, you can use the access restriction pattern to ensure that there is no execution of any critical function.

A function modification that throws an exception if the state variable indicates the initiation of an emergency stop can is used to accomplish this, as show below:

contract EmergencyStop {
    bool Running = true;
    address trustedAccount = 0x000...; //Account address
    modifier stillRunning {
        require(Running);
        _;
    }
    modifier NotRunning {
        require(¡Running!);
        _;
    }
    modifier onlyAuthorized(address account) {
        require(msg.sender == account);
        _;
    }
    function stopContract() public onlyAuthorized(trustedAccount) {
        Running = false;
    }
    function resumeContract() public onlyAuthorized(trustedAccount) {
        Running = true;
    }
}
Enter fullscreen mode Exit fullscreen mode

The EmergencyStop contract above makes use of modifiers to check conditions, and throw exceptions if any of these conditions is met.

The contract uses the stopContract() and resumeContract() functions to handle emergency situations. The contract can be resumed by resetting the state variable to false. This method should be secured against unauthorized calls the same way the emergency stop function is.

Upgradeability patterns

Proxy delegate

This pattern allows upgrading smart contracts without breaking any of their components. A particular message called Delegatecall is employed when using this method. It forwards the function call to the delegate without exposing the function signature.

The fallback function of the proxy contract uses it to initiate the forwarding mechanism for each function call. The only thing Delegatecall returns is a boolean value that indicates whether or not the execution was successful. We're more interested in the return value of the function call. Keep in mind that, when upgrading a contract, the storage sequence must not change; only additions are permitted.

Here's an example of implementing this pattern:

contract UpgradeProxy {
    address delegate;
    address owner = msg.sender;
    function upgradeDelegate(address newDelegateAddress) public {
        require(msg.sender == owner);
        delegate = newDelegateAddress;
    }
    function() external payable {
        assembly {
            let _target := sload(0)
            calldatacopy(0x01, 0x01, calldatasize)
            let result := delegatecall(gas, _target, 0x01, calldatasize, 0x01, 0)
            returndatacopy(0x01, 0x01, returndatasize)
            switch result case 0 {revert(0, 0)} default {return (0, returndatasize)}
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

In the code snippet above, UpgradeProxy handles a mechanism that allows the delegate contract to be upgraded once the owner executes the contract by calling the fallback function that transfers a copy of the the delegate contract data to the new version.

Memory array building

This method quickly and efficiently aggregates and retrieves data from contract storage. Interacting with a contract's memory is one of the most expensive actions in the EVM. Ensuring the removal of redundancies and storage of only the required data can help minimize cost.

We can aggregate and read data from contract storage without incurring further expenses using the view function modification. Instead of storing an array in storage, it is recreated in memory each time a search is required.

A data structure that is easily iterable, such as an array, is used to make data retrieval easier. When handling data having several attributes, we aggregate it using a custom data type such as struct.

Mapping is also required to keep track of the expected number of data inputs for each aggregate instance. The code below illustrates this pattern:

contract Store {
    struct Item {
        string name;
        uint32 price;
        address owner;
    }
    Item[] public items;
    mapping(address => uint) public itemsOwned;
    function getItems(address _owner) public view returns (uint[] memory) {
        uint\[] memory result = new uint[\](itemsOwned[_owner]);
        uint counter = 0;
        for (uint i = 0; i < items.length; i++) {
            if (items[i].owner == _owner) {
                result[counter] = i;
                counter++;
            }
        }
        return result;
    }
}
Enter fullscreen mode Exit fullscreen mode

In the Store contract above, we use struct to design a data structure of items in a list, then we mapped the items to their owners’ address. To get the items owned by an address, we use the getItems function to aggrgate a memory called result.

Eternal storage

This pattern maintains the memory of an upgraded smart contract. Because the old contract and the new contract are deployed separately on the blockchain, the accumulated storage remains at its old location, where user information, account balances, and references to other valuable information are stored.

Eternal storage should be as independent as possible to prevent modifications to the data storage by implementing multiple data storage mappings, one for each data type. Converting the abstracted value to a map of sha3 hash serves as a key-value store.

Because the proposed solution is more sophisticated than conventional value storage, wrappers can reduce complexity and make code legible. In an upgradeable contract that uses eternal storage, wrappers make dealing with unfamiliar syntax and keys with hashes easier.

The code snippets below shows how to use wrappers to implement eternal storage:

function getBalance(address account) public view returns(uint) {
    return eternalStorageAdr.getUint(keccak256("balances", account));
}
function setBalance(address account, uint amount) internal {
    eternalStorageAdr.setUint(keccak256("balances", account), amount);
}
function addBalance(address account, uint amount) internal {
    setBalance(account, getBalance(account) + amount);
}
Enter fullscreen mode Exit fullscreen mode

In the code snippet above, we got the balance of an account from eternal storage using the keccak256 hash function in enternalStorageAdr.getUint(), and likewise for setting the balance of the account.

Memory vs. storage

Storage, memory, or calldata are the methods used when declaring the location of a dynamic data type in the form of a variable, but we'll concentrate on memory and storage for now. The term storage refers to a state variable shared across all instances of smart contract, whereas memory refers to a temporary storage location for data in each smart contract execution instance. Let's look at an example of code below to see how this works:

Example using storage:

contract BudgetPlan {
        struct Expense {
                uint price;
                string item;
        } 
        mapping(address => Expense) public Expenses;
        function purchase() external {
                Expense storage cart = Expenses[msg.sender]
                cart.string = "Strawberry" 
                cart.price = 12
        }
}
Enter fullscreen mode Exit fullscreen mode

In the BudgetPlan contract above, we designed a data structure for an account’s expenses where each expense (Expense) is a struct containing price and item. We then declared the purchase function to add a new Expense to storage.

Example using memory:

contract BudgetPlan {
        struct Expense {
                uint price;
                string item;
        } 
        mapping(address => Expense) public Expenses;
        function purchase() external {
                Expense memory cart = Expenses[msg.sender]
                cart.string = "Strawberry" 
                cart.price = 12
        }
}
Enter fullscreen mode Exit fullscreen mode

Almost like the example using storage, everything is the same, but in the code snippet we add a new Expense to memory when the purchase function is executed.

Closing thoughts

Developers should stick to design patterns because there are different methods to achieve specific objectives or implement certain concepts.

You will notice a substantial change in your applications if your practice these Solidity design patterns. Your application will be easier to contribute to, cleaner, and more secure.

I recommend you use at least one of these patterns in your next Solidity project to test your understanding of this topic.

Feel free to ask any questions related to this topic or leave a comment in the comment section below.


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.

LogRocket Sign Up

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 — Start monitoring for free.

Top comments (0)