Best Practices for Writing Smart Contracts with Real World Examples

Best Practices for Writing Smart Contracts with Real World Examples https://celo.academy/uploads/default/optimized/2X/1/15d964a2fdb8c9d67b8b353133d2a3af98a2202e_2_1024x576.jpeg
none 0.0 0

Introduction

The backbone of most web3 projects, if not all, is a well-thought-out smart contract architecture that handles the sensitive aspect of the application such as financial transactions, health records of patients in a hospital, game logic, and so on. Being a Celo developer, it is highly necessary to have a good engineering mindset when writing smart contracts to avoid costly mistakes that can put you at the mercy of bad actors.

Prerequisites​

We are going to examine some of the best practices, approaches, and standards for writing smart contracts that are targeted at EVM-compatible networks such as Celo. This tutorial is best suited for anyone with:

  • At least foundational knowledge of smart contract development.
  • Experience with other programming languages like C++ or Python will be an added advantage.

Requirements​

We will leverage the following technologies and tools. Be sure to follow the steps accordingly.

  • VSCode
  • Nodejs >=v14.0.0
  • Hadhat.

Note: We will not cover testing in this tutorial. For testing, please refer to this tutorial

Contents

Some practices to consider when writing smart contracts are listed as follows:

  • Avoid floating pragma.
  • Modular approach.
  • Circuit breaker.
  • External contract interaction.
  • Upgradability.
  • Check, effect, interaction.
  • Adopt libraries where possible.
  • Use Speed breaks where necessary.
  • Proper use of assert(), require() and revert() functions.
  • Use modifiers properly.
  • Integer division.
  • Fallback/receive function.
  • Explicit labeling of function visibility.
  • Use interface type for accepting contract addresses.
  • Use standard interface (S).

General consideration

In programming, it is assumed there is no software that is perfect. As a web3 developer, you should try to be ahead of the bad players. That is, finding, identifying and fixing loopholes in your code before others find it. Without further ado, let’s get started.

We will write fresh match-making contracts that connect and manage the processes of marriage between two opposite sexes. It features the following subjects:

  • HusbandToBe: Will have the following properties.

    • Inlaw.
    • Wife
    • Profile: (type struct)
      • age
      • isMale
      • Bank - e.g Cash in bank balance etc
      • Property: Tangible assets.
      • Religion: What they believe in.
      • Nature: Whether they are drunkards or not.
      • Status: Type “Enum” showing their status as single or taken or married.
  • WifeToBe: This contract will have similar properties such as:

    • Parents: Type address.
    • Husband: address (initially set to address(0)
    • isReady: Boolean - Whether the woman is ready for marriage or not.
    • Acceptance: bytes32.
    • Criteria: struct - (Personal preference of the woman about the kind of man she wants).
    • Status: Type “Enum” showing her status whether single, taken or married.
    • Inlaw: (In this case, the wife’s parents, since in Africa, parents give their daughters to the men).
    • Bride price: Type uint256
    • Bank: address - Account to receive the bride price.
    • Wealth (Husband’s possessions).

Both parties have common attributes such as operating joint accounts together etc.

Note: This is only for tutorial purposes. It may not necessarily display the exact practice. Clone this repository, then navigate to the project folder and install the dependencies.

git clone https://github.com/bobeu/advanced-hardhat-for-celo-dev.git
cd advanced-hardhat-for-celo-dev
yarn install 

After that, navigate into the contracts folder and make a new folder

cd contracts
mkdir match-making

You should have a file structure that looks like this “contracts/match-making”.

Avoid floating pragma

Often, many solidity developers ignorantly or unknowingly declare a list of compiler versions (solc) in contract files using the caret symbol “^” or a range of compiler versions with “>=”. Declaring a version like this “pragma solidity ^0.8.9;” tells the compiler to make a decision for you by simply selecting a version starting from 0.8.9 but not including 0.9.0. If there is no version specified in the hardhatconfig file, then it defaults to the version that was preinstalled during the project setup. If you declare a list or range of versions with a version declared in the config file, the compiler resolves to use a version that is specified in the configuration file. Although external libraries like the openzeppelin’s mostly adopt the float style, that’s acceptable since you will mostly inherit them as they serve as base contracts and not the final deployable contracts.

The best practice is to always specify a single compiler version at the top of your contract file. Always lock your pragma to one specific tested compiler version so that your contracts are not deployed using versions that introduce undiscovered bugs.

Overview of example contracts

Before a marriage takes place, there have to be two parties who are opposite sex expressing love to each other. So create a file under the “match-making” folder named “WifeToBe.sol”, then paste the following code.

 pragma solidity 0.8.9;

Modular approach

Sometimes, one may need to write more code than expected. Having code with fewer lines may be easily maintained, but the case is different with lines that run into hundreds to thousands. Breaking your code into smaller modules i.e. sub-contracts makes it easy to detect and fix bugs quickly. Also, modularity improves readability and has many more advantages. In our “match-making” contract, each of the subjects will be represented by a standalone deployable contract and they can interact by interfacing with one another. Now, create contract files representing each of the subjects. Since they will have to interact with one another, then it makes sense to have a separate folder for interfaces such as a husband-to-be proposing to a lady of his choice, to the lady, acceptance is optional. Such action should forward a response to the intended party. Populate each of the files with their respective code here

  • contracts
    • match-making
      • interfaces
        • Common.sol
        • IHusbandToBe.sol
        • IWifeTobe.sol
        • IParent.sol
      • wealth
        • CashInBank.sol
        • Properties.sol
      • HusbandToBe.sol
      • WifeToBe.sol
      • Parent.sol

Our contract code size is a bit bigger and we have successfully broken them down to implement a modular style.

Circuit breaker

  • This acts as a stopper or a switch that breaks executions.

  • In the event that bugs are detected in your contract or a potential vulnerability has adversely affected the dApp, you need a way to mitigate or prevent a huge loss from further happening. Halting contract execution may be very effective. Usually, this would be a function that when invoked, would simultaneously prevent execution of other functions.

  • The openzeppelin “Pausable” module is an example of a circuit breaker. When your contract inherits from it, you will need to activate it by overriding the “_pause()” and “_unpause()” functions so that only authorized accounts e.g. the “owner” can call it. In our case, we will need each of the subjects to be able to have some level of control over their respective profiles soon as they subscribed in order to prevent reserved functions from being invoked by anyone. By doing so, we will have a reasonable approval mechanism among the parties that regard and respect each party’s opinion such as the WifeToBe can express her personal feelings even while the parents feel certain things are right for her.

  • In Africa, we’ve had countless cases where parents force their daughters to marry men they do not wish to marry. Having such a tamper-proof system as the Celo blockchain to balance, checkmate preferences and respect opinions would go a long way to make the world a better place. We are going to leverage the openzeppelin’s “security” and “access” modules to implement a circuit breaker that pauses the contract when invoked by the authorized account i.e. “owner”. Copy/paste the following code at the top of each standalone deployable contract file.

Your files should look like these:

HusbandToBe.sol

WifeToBe.sol

Inlaw.sol renamed to Parent.sol

Bank.sol

The openzeppelin library makes available for us two internal functions to activate the breaker. They hold so much power that care must be taken who calls them.

  • pause()
  • unpause()

Paste the following code at the bottom of each of the 4 files.

   // …. 
   function pause() public onlyOwner {
    _pause();
  }

  function unpause() public onlyOwner {
    _unpause();
  }

We have successfully made the functions public and only the authorized owner account can call them but at this point, nothing happens when they are invoked. To effect the breaker, we need to invoke it on relevant functions. From the “Pausable.sol”, there are two utilities that are capable of modifying functions where they are present. In our case, we only need the latter.

  • whenPaused
  • whenNotPaused

In “WifeToBe.sol”, invoke the “whenNotPaused” modifier on :

  • meetYourWife()
  • tryPropose()
  • setMarriageStatus()

Example:

Parent.sol

  • getMarriageApproval()

When “pause()” is invoked, all functions with the “whenNotPaused” modifier will fail.

External contract interaction/External call

In solidity, contracts can be configured as standalone or dependent on other contracts. In either context, they can be made to interact with other contracts either in a high-level or low-level way. Each of these methods has its pros and cons, and the modus operandi is always to consider safety first whichever best fits your need.

High-level calls are proven to be more secure compared with low-level interactions since much control is not given to the message receiver, unlike its counterpart where a call from one contract to another could change the flow. It is always advisable to consider a low-level call last unless you know what you’re doing. In our case, we could use low-level interaction since we own the code and can always attest to its genuineness.

A good practice is to always tag or name that contracts we do not trust as “unsafe” when making external calls. Another thing to note is that low-level functions: “.call()”, “.staticcall()” and “.delegatecall()” do not throw an exception when the call stack is depleted, instead, they return false. This is very unsafe and so it is mostly discouraged.

In Line 78, we perform a high-level callback to whoever is calling to read their profile. In this case, we expect that the caller should be a contract of type “HusbandToBe”. In our case, the “_msgSender()” is a trusted party so we do not have to worry about malicious code hijacking control flow. The flow terminates as soon as the execution of “getProfile()” completes, and the result is tracked in the “_p” variable (user-defined type from an elementary type ‘struct’). Assume we are using a low-level call, we could do something like:

 bytes memory _calldata = abi.encodeWithSignature("getProfile()");
 (bool done, bytes memory returndata) = _msgSender().call(_calldata);

If “_msgSender()” is a bad actor, they could execute malicious code that might cause serious damage to the calling contract. As much as possible, avoid low-level calls especially with in-line assembly except if you know what you’re doing. And if you need to make an external call,
Avoid state changes after the call.
Avoid delegating to code you do not trust.
Below is an example of how “delegatecall()” can cause a loss of funds and the contract destroyed.

 contract BadActor {
  function performActionOnMyBehalf() external {
   selfdestruct(0);
  }
 }

 contract Enquirer {
  function getProfile(address proxy) public returns(string memory) {
   // Unsafe call
   proxy.delegatecall(bytes4(keccak256("performActionOnMyBehalf()")))
  }
 }

The potential danger here is allowing users to supply an argument to the “getProfile” function. This should be discouraged.

Let’s fix an issue in the WifeToBe.sol contract.

Before

After

Upgradability

Writing 100% secure smart contracts is nearly impossible, especially with complex code. The immutability attribute of blockchain is one kind that makes it stand out from other technologies. What if a potential bug that could lead to a loss of millions of dollars is detected? Does it mean we will have no way to fix it? Fortunately, over time, the developer communities have developed strategies to fix issues in contracts through smart contract upgrades which come at a cost. However, while some upgrade methods do not alter code already deployed to the blockchain but provide subtle ways to deploy several versions of the same software while retaining users’ trust and confidence, others may require a complete software upgrade with a new deployment address each time an upgrade is performed. From the description, the disadvantages are not far-fetched. A smart contract upgrade is a wide topic on its own that is not covered in this tutorial. To learn more, please refer to this link:
How to create upgradeable smart contract on Celo

Check, effect, interaction

This largely depends on the design of the code and what it tends to achieve. But the concept of the check, effect pattern means you make the necessary check and effect necessary changes to a state variable (s) before initiating an external call. An example is determining if a user is entitled to receive funds in a contract. Consider the following code:


1    // SPDX-License-Identifier: MIT
2
3    pragma solidity 0.8.9;
4
5    contract Reward {
6       mapping(address=>uint256) public rewardBalances;
7
8       function claimReward() external returns(bool) {
9         uint bal = rewardBalances[msg.sender]; 
10        require(bal > 0, "No reward");
11       (bool s, ) = msg.sender.call{value: bal}("");
12       require(s, "Failed");
13       rewardBalances[msg.sender] -= bal;
14      }
15     }
16
17
18    contract BadActor {
19      Reward reward;
20      uint receiveCounter;
21
22      constructor (address rewardContract) {
23        reward = rewardContract;
24      }
25
26      receive() external payable {
27        receiveCounter ++;
28        drainBalance();
29      }
30
31      function drainBalance() public {
32        reward.claimReward();
33      }
34   }

The essence of the “claimReward” function is to send a reward to the caller if they have balances registered in their favor. Unfortunately, the bad actor is one of the beneficiaries who had submitted a contract address as the sole beneficiary for the reward.

In the bad actor’s contract, he had written a function called “drainBalance” which calls back into “Reward.claimReward()” even before line 12 is reached.
Here is how the flow goes:

  • The attacker invokes “drainBalance()” which then calls “Reward.claimReward()”.
  • Reward extracts the caller’s balance, and checks if the balance is greater than the minimum withdrawal amount.
  • It sends payment to the BadActor by calling the “receive()” function which then invokes “drainBalance” again. The circle is restarted while waiting for line 11 to finish execution. The balance of the Bad actor contract in “Reward” will always remain the same while it withdraws the same amount over and over until the balance in the Reward contract is completely drained.

An effective solution is using the check, effect, interaction pattern or the “@openzeppelin/contracts/security/ReentrancyGuard.sol” module.

1    // SPDX-License-Identifier: MIT
2
3    pragma solidity 0.8.9;
4
5    contract Reward {
6     mapping(address=>uint256) public rewardBalances;
7
8     function claimReward() external returns(bool) {
9       // check
9          uint bal = rewardBalances[msg.sender]; 
10        require(bal > 0, "No reward");
11
12      // effect
13      rewardBalances[msg.sender] -= bal;
14
15      // interaction
16      (bool s, ) = msg.sender.call{value: bal}("");
17      require(s, "Failed");
18      
19     }
20    }

In “Bank.sol”, we implement this pattern.

Adopt libraries where possible

While writing your own code is encouraged, it’s a good practice to leverage battle-tested code such as the openzeppelin library. Even at that, merely inheriting from the library is not enough, you should have a full understanding of any of the contract modules you are using as base contracts. From the image below, we import a few modules from the openzeppelin library.

Use speed break where necessary

Using method (s) that delay action for a set period of time before execution is finalized can be very helpful. It is useful in situations where the unplanned happens so there is enough time to react and/or recover loss (es) that should have occurred. For example, where a substantial amount of funds is involved, you could set a waiting period of say 14 days before a withdrawal is effected so there is room for action to be taken in the event of an authorized action being performed. Below on line 73, we implemented a speed break.

Proper use of assert(), require() and revert()

Use “assert()” function only for internal conditional tests and check invariants such as comparing the balance in a contract after a transaction has been effected. So this means that a properly affected transaction should never reach the assert code. The “assert()” statement should never be used to validate input.

The “require()” function should be used to validate conditions that must evaluate to be true such as checking inputs from users. For example, if you’re expecting users to supply a number between 1 and 255, you should either use the “require” function or an “if” statement with “revert” function.
Lines 101, 102, and many other places display how we implement “require()” and “revert()”.

Use modifiers properly

Modifiers work similarly to functions except that they don’t have visibility and the code inside modifiers is checked and executed before proceeding to the function body. Be careful when making an external call inside a modifier as it can lead to reentrancy attack. A proper use of a modifier is to make it run a condition check that should have been duplicated in many places but where you have one conditional check, you should use either “require()” or “revert()” statement. The essence of this is to make your code more readable and avoid redundancy.

“isPartnerAOrB”** function modifier contains code used in almost all the functions, so it makes a lot of sense to have them in a modifier and simply reference them by a variable “isPartnerAOrB”.

Integer division

Solidity does not provide a proper way to account for the remainder when making integer division e.g. x = 9 / 4. All integer divisions always round down to the nearest integer and the remainder is lost. This is not ideal especially when dealing with payment split or computation that has to do with funds. If you need to achieve precision, you should consider using a multiplier but be mindful that any number you use as a multiplier must be accounted for in the future when dealing with the result. Alternatively, you could switch to using percentages if you can quantify the denominator in percentages. Use a multiplier to avoid rounding to zero.

Fallback/receive function

In solidity, the fallback function impliedly is similar to an escape function which is reserved for an event where the smart contract is called with empty data or a function that does not exist in the contract is invoked. Fallback is used in versions of solidity before 0.6.x in the following scenarios:
Ether is sent to a contract with no data.
Data is sent to a contract but no match for function in the data.

Sending ether to a contract without data via “.transfer()” or “.send()” triggers the contract’s fallback function. To receive ether, fallback must be payable. The syntax is often written as:

 fallback () external payable { }

The fallback function also serves as a good use case in implementing upgradable contracts.

Example:

 pragma solidity 0. 5.4;
 
 contract DelegateToProxy {
  // contract address where the logic resides
  address internal implemetationAddress;
  
  function ( ) external payable {
   // Retrieve the implementation address
   address impl = implemetationAddress;
   
   assembly {
    calldatacopy(0, 0, calldatasize( ))
    let result := delegatecall(gas( ), impl, 0, calldatasize( ), 0, 0)
    returndatacopy(0, 0, returndatasize( ))

    Switch result
    case 0 { revert(0, returndatasize( )) }
    default { return(0, returndatasize( )) }
   }
  } 
 }

Warning! This is for example purpose, do not use in production

From version 0.6.x an improvement was released that splitted the fallback function into two separate functions:

  • fallback() external payable { }
  • receive external payable { }

Using payable with “fallback()” is now optional, and the function is used when no other function in the contract matches the call data. It always receives data and it is declared without the “function” keyword. If you intend to only use the fallback function for logging received Ether, you should always check if the call contains any data or not.

 // …
 fallback() external payable  { 
  require(msg.data.length = = 0, "");
  emit DepositConfirmed(msg.sender); 
 }

The receive function should be used where call data is empty and any value is sent via “.send()” or “.transfer()”. A contract cannot have more than one “receive” function, and it is declared without the “function” keyword with its visibility marked as “external”.

In our contracts, we also implement the receive function. As an exercise, try to implement a fallback function in each of the contracts that ensure a call to it does not include any data.

Explicit labeling of function visibility

You should always endeavor to explicitly label function visibility including state variables.

Different Kinds of visibility in solidity are :

  • external
  • public
  • internal
  • private

Facts about visibility in solidity

  • You cannot label state variables as private.

  • Explicit labeling corrects assumptions about who can call certain functions or access state variables.

  • Functions in the interface are always labeled “external”.

  • Functions or variables marked as public can be accessed by anyone.

  • External functions cannot be called in internal functions.

  • Private variables and functions are not visible to other contracts but only to contracts where they’re defined.

  • External functions may be more efficient when they receive large arrays of data.

  • Internal functions and state variables are only available internally without the “this” keyword.

Use interface type for accepting contract addresses

When accepting a contract addresses arguments to functions, it is advisable to use the interface type rather than accepting an address type so as to enable the compiler guarantee type safety throughout the input lifecycle.

Example:

 contract Somecontract {
  string openLetter = "LETTER";

   function verify(string memory letter) external returns(bool) {
   return letter = = openLetter;
  }
 }

 contract Verifier {
  function checkCorrectness(Somecontract addr, string  memory _letter) public 
  {
   addr.verify(_letter);
  }
 }

On line 84 we accept input of type contract for the proposer and proposedTo arguments.

Use standard interface (S)

If you’re creating token (s) for your project, ensure they follow acceptable and stable token standards by implementing corresponding token interface such as EIP20 or ERC20, EIP721

Example:

In the code above, we create a new ERC20 compatible token by inheriting the openzeppelin ERC20 module which already has the interface implemented.

Let’s compile the code. Run :

 npx hardhat compile

Deploy locally.

 npx hardhat run scripts/deploy.js

Deploying to Celo’s testnet (Alfajores)

 npx hardhat run scripts/deploy.js – testnet alfajores

Conclusion​

We learned some of the best ways to write smart contracts even though there are a couple of them we might not have talked about but you’ll mostly need the ones we discussed.

Next Steps​

The example we adopted in this tutorial is a real-world case. It can be extended to make meaningful projects. As an exercise, practice what you have learned by improving the contracts. Firstly, deploy locally, then to Celo testnet. Share your deployment details with us on Celo’s discord server. Happy reading.

About the Author​

Isaac Jesse, aka Bobelr is a full-stack Web3 developer with proficiency in creating smart contracts. He has been in the field since 2018. He has worked as an ambassador with several projects like Algorand and so on. He has also contributed to Web3 projects.

References​

Source code

13 Likes

This article deserve flowers :innocent:

2 Likes

Lmao, Thank you.

10 Likes

This is a nice article. I checked article linked to certify my knowledge of upgradability but I found a proposal topic as a reference. If the article are yet to be written I don’t see any point referencing it.

1 Like

I wrote this tutorial some months back. Over 4 months or so. But this page it takes you to was recently proposed, so no way I could have that. The issue came from the migration done from the previous Celo tutorial page to the Celo Academy. This is a link collision. Obviously, the new proposer didn’t know there is a similar tutorial existing. I might have to remove the link. I could remember the link I used was a post made by @0xviral on Upgradable contracts.

9 Likes

Haha @bobelr the example you used is really hilarious. I was imagining if this can really come to become true. I’d love to experimenwith it. Good work man.

1 Like

Thank you Bright. That was one of the ideas I had. I could not even remember what I was thinking when I put that in writing Lol.

10 Likes

Great article. Picked up a couple of things along the way

1 Like

I don’t use speed breaks that often. Probably should use it more