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.
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); }
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"); }
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.
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); } }
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.
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); } }
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.
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); } }
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.
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)); }
The randomNum()
function above generates a random and unique integer by hashing the block number (block.number
, which is a variable on the blockchain).
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 } }
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.
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); } }
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
.
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"); }
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
.
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); } }
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.
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; } }
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.
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)} } } }
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.
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; } }
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
.
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); }
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.
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 } }
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 } }
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.
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.
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.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
Bypass anti-bot measures in Node.js with curl-impersonate. Learn how it mimics browsers to overcome bot detection for web scraping.
Handle frontend data discrepancies with eventual consistency using WebSockets, Docker Compose, and practical code examples.
Efficient initializing is crucial to smooth-running websites. One way to optimize that process is through lazy initialization in Rust 1.80.