Tips And Tricks In Solidity

Tips And Tricks In Solidity

The first installment in a series of articles about Solidity where we give you tips and tricks for working with Solidity

Ruchika Gupta's photo
Ruchika Gupta
·May 25, 2022·

13 min read

Play this article

Table of contents

On the blockchain, everything is immutable and this includes smart contracts; hence every single line of code we write has a higher stake as it involves real finance with billions of dollars attached to it. Each poorly written line of code cost us gas and makes the contracts inefficient. Smart contract programming requires a different engineering mindset than you may be used to. The cost of failure can be high, and change can be difficult, making it in some ways more similar to hardware programming or financial services programming than web or mobile development. Let's go through some basic concepts of Solidity which you may mistake as inconsequential but have a huge impact on overall execution.

Memory management

Optimize the order of variable declaration

Solidity stores data in 256-bit memory slots and data that does not fit in a single slot is spread over several slots. As for data that is smaller than a single slot, Solidity tries to optimize it and pack it together with other data in a single slot. But for this type of optimization to work, we need to have several small data types declared next to each other.

Let’s use integers as an example. One type of integer is unsigned integers (uint). The type is subdivided into several sub-types, based on capacity: uint128 are 128-bit integers, uint256 integers are 256 bit integers, etc…

In the below code, the Solidity compiler is only able to put two uint128 in the same 256-bit memory slots when they are declared next to each other. Otherwise, uint128 variables each occupy a separate uint256 just for themselves, wasting 128 bits for each slot. Exceute the following code:

//Good :)
uint128 a;
uint128 b;
uint256 b;

// Bad :(
uint128 a;
uint256 b;
uint128 b;

Mappings are mostly cheaper than Arrays

Solidity is the first language that I have used in which mappings are less expensive than arrays! This is because of how EVM works, an array is not stored sequentially in memory but as a mapping. You can pack arrays but not mappings though. So, it’s cheaper to use arrays if you are using smaller elements like uint8 which can be packed together. You can’t get the length of a mapping or parse through all its element because of which you might be forced to use an Array even though it might cost you more gas.

Delete variables that you don’t need

In Ethereum, you get a gas refund for freeing up storage space. If you don’t need a variable anymore, you should delete it using the delete keyword provided by Solidity or by setting it to its default value

Avoid changing storage data

Changing storage data costs a lot more gas than changing memory or stack variables so you should update the storage variable after all the calculations rather than updating it on every calculation. Execute the following code to proceed:

contract MemoryCaching {
    uint256 number = 1;

    function getNumber() public {
        for(uint8 i = 0; i < 100; i++) {
            number += 1;
        }
    }

    function getNumberOptimal() public {
        uint256 _number = number;

        for(uint8 i = 0; i < 100; i++) {
            _number += 1;
        }

        number = _number;
    }
}

The difference of gas cost between functions getNumber and getNumberOptimal is referenced in the image given below:

Screenshot 2022-04-27 at 11.27.34 AM.png

Use assert(), require() and revert() properly

The convenience functions assert and require can be used to check for conditions and throw an exception if the condition is not met. The assert function should only be used to test for internal errors, and to check invariants. The require function should be used to ensure valid conditions, such as inputs, to check if contract state variables are met, or to validate return values from calls to external contracts.

A direct revert can be triggered using the revert statement and the revert function and the error data will be passed back to the caller and can be caught there. Using revert() causes a revert without any error data while revert("description") will create an Error(string) error. Execute the following code to understand this better:

contract VendingMachine {
    address owner;
    error Unauthorized();
    function buy(uint amount) public payable {
        if (amount > msg.value / 2 ether)
            revert("Not enough Ether provided.");
        // Alternative way to do it:
        require(
            amount <= msg.value / 2 ether,
            "Not enough Ether provided."
        );
        // Perform the purchase.
    }
    function withdraw() public {
        if (msg.sender != owner)
            revert Unauthorized();

        payable(msg.sender).transfer(address(this).balance);
    }
}

To understand more about this topic, click here

Function modifiers can be inefficient

When you add a function modifier, the code of that function is picked up and put in the function modifier in place of the _ symbol. This can also be understood as ‘The function modifiers are inlined”. In normal programming languages, inlining small code is more efficient without any real drawback but Solidity is no ordinary language. In Solidity, the maximum size of a contract is restricted to 24 KB by EIP 170. If the same code is inlined multiple times, it adds up in size and that size limit can be hit easily.

Internal functions, on the other hand, are not inlined but called separate functions. This means they are very slightly more expensive in run time but save a lot of redundant bytecode in deployment. Internal functions can also help avoid the dreaded “Stack too deep Error” as variables created in an internal function don’t share the same restricted stack with the original function, but the variables created in modifiers share the same stack limit.

Function type

Function types are by default internal, and contract functions are by default public. Even though the wording is a little bit confusing. It says nowhere that they are at the same time both public and internal.

I think that there is some confusion as to what a function type is. In the same way you specify the type for a 256 int variable with the keyword uint256, you can also specify the type of a variable that will hold a function. The only difference is that this type of declaration is a little more complex since functions are more complex and it looks like this:

function (param types) {internal|external} [pure|constant|view|payable] [returns (return types)] varName;

You can then assign this to the variable varName which is a function that has the same type that you defined. This is only possible inside another function. As a very simple example, run the code given below:

contract Example{
    function(uint256) returns (uint256) varName;

    function simpleFunction(uint256 parameter) returns (uint256){
        return parameter;
    }

    function test(){
        varName = simpleFunction;
    }
}

In this example, varName is the variable of function type, while simpleFunction and test are normal contract functions. Just so you have a simple definition for all the access classifiers that are specified, run the following code:

public - Everyone can access these functions (functions all public by default).

external - Cannot be accessed internally (can be accessed by this contract using this keyword and can be accessed externally)

internal - only this contract and contracts deriving from it can access (this contract and derived contracts)

private - can be accessed only from this contract

External calls

The best practice is to use external calls if you expect that the function will only ever be called externally and public if you need to call the function internally. The difference between both is that in public function, the arguments are copied to memory while in external functions, they are read directly from calldata which is cheaper than memory allocation. Internal calls are executed via jumps in the code and array arguments are passed internally by pointers to memory. The internal function expects its arguments to be located in memory when the compiler generates the code for it.

It is important to avoid calling contract functions externally. For example, this.function() is an external call and function() is an internal. Calls to untrusted contracts can lead to several unexpected risks or errors because of which external calls may execute malicious code in that contract or any other contract that it depends upon as shown below:

contract TestContract {
    function testPublic(uint256[20] testArray) public pure returns (uint256 value){
         return testArray[0]*2;
    }

    function testExternal(uint256[20] testArray) external pure returns (uint256 value){
         return testArray[0]*2;
    }
}

The difference in gas cost between functions testPublic and testExternal call is more than twice as displayed in the image below

Screenshot 2022-04-27 at 11.10.16 AM.png

Make fewer external calls. Every call to an external contract costs a decent amount of gas. For optimization of gas usage, it’s better to call one function and have it return all the data you need rather than calling a separate function for every piece of data. This might go against the best coding practices for other languages, but Solidity is special that way

View Function

Functions that will not alter the storage state can be marked as a view function. In simple terms, it is used for viewing the state. To understand this better, run the following code:

pragma solidity ^0.5.0;  

contract Types {  
    function result(uint a, uint b) public view returns (uint) {  
        return a + b + now; //"now" is current block timestamp in 
                            //seconds in unix epoch format  
    }  
}

Here, now is a state variable and we're reading the data by using it. As we haven't changed the state, the function result is view only.

Pure Function

A function that does not modify or read the state, is called a pure function. To install, run the following code:

pragma solidity ^0.5.0;  

contract Types {  
    function result(uint a, uint b) public pure returns (uint) {  
        return a * (b + 42);  
    }  
}

In the example above, we don't have any state variables and ad we even not reading the state anyway, the result function should be marked as a pure function.

Payable Function

Payable Functions allow receiving Ethers while it is being executed, which means that if someone sends some Ethers to the smart contract, and it doesn't have a payable function, then the smart contract won't accept ether and the transaction will fail. To catch that transfer amount, the smart contract needs to have a payable function.

For example, in the below code, the receiveEther function is not payable, so when you deploy this contract and run the method receiveEther, it will give an error:

pragma solidity ^ 0.5 .0;  
contract Types {  
    function receiveEther() public {}  
}

Now, modify receiveEther with payable and to check contract receive the amount or not, also add a state variable that keeps the Ether value.

pragma solidity ^0.5.0;  

contract Types {  
    uint receivedAmount;  

    function receiveEther() payable public {  
        receivedAmount = msg.value;  
    }  

    function getTotalAmount() public view returns (uint){  
        return receivedAmount;  
    }  
}

Address Type

On the Ethereum blockchain, every account and smart contract has an address and it's stored as 20-byte values. It is used to send and receive Ether from one account to another account. You can consider it as your public identity on the Blockchain. To make it more clear, if you want to send some money to me, you need my bank account number, similarly, in the Blockchain, you need an address to send and receive cryptocurrency or make transactions.

Also, when you deploy a smart contract to the blockchain, one address will be assigned to that contract, by using that address you can identify and call the smart contract.

In Solidity, address type comes in two flavors, address and address payable. Both address and address payable store the 20-byte values, but address payable has additional members, transfer, and send.

Address

Address type defines with the address keyword as shown below:

address myAddress;

An address is used to store the value of any address. In the below example, caller is an address type:

pragma solidity ^0.5.0;  

contract Types {  

    address public caller;  

    function getCallerAddress() public returns (address) {  
       caller = msg.sender;  
       return caller;  
    }  
}

Address payable

Address payable has an additional keyword called payable. Run the following code:

address payable caller;

When we want to transfer some funds to the address, we need to use the address payable. There are two members to perform a transfer, send and transfer. Both are doing the same thing, but the difference is that when the transaction fails, send will return a false flag, whereas, transfer throws an exception and stops the execution.

In the below example, there are two functions transferFund and sendFund. When we execute transferFund, it will give an error. However, sendFund will give a false value. Run the following code:

contract Types {  

    function transferFund(address payable _address, uint amount) public {  
        _address.transfer(amount);  
    }  

    function sendFund(address payable _address, uint amount) public returns(bool){  
        _address.send(amount);  
    }  
}

Avoid using Arrays

Avoid arrays of unknown iterations whenever possible. Always prefer fixed size over dynamic arrays (less gas usage). It is not possible to resize memory arrays (e.g. by assigning to the .length member). Keep in mind that dynamically-sized memory arrays cannot be assigned to fixed size memory arrays. If you cannot avoid loops, try to use less costly operations or find alternative ways to structure the contract. You can also create multidimensional arrays.

One of the most common use-cases is when you need to iterate over information in a mapping -, you can create an array of the mapping keys. That is why it is not possible to iterate over the mapping and you need to add value for which you have to push the created ID in the array as shown:

contract Members {
    address owner;
    struct Member {
        bytes32 id;
        string name;
        uint256 createdAt;
        bool available;
    }
    mapping(bytes32 => Member) private members;
    bytes32[] private memberIds;

    modifier onlyOwner() {
      require(msg.sender == owner);
      _;
    }

    constructor() public {
        owner = msg.sender;
    }

    function getMemberIds() public view returns(bytes32[] ids) {
        return memberIds;
    }

    function getMember(bytes32 _id) public view returns(string memberName) {
        require(members[_id].available, "The member doesn't exist!");
        return members[_id].name;
    }

    function addMember(string _name) public onlyOwner returns(bool success) {
        require(bytes(_name).length > 0, "The member's name cannot be empty!");

        bytes32 blockHash = blockhash(block.number - 1);
        bytes32 id = keccak256(abi.encodePacked(msg.sender, _name, now, blockHash));

        members[id] = Member({
            id: id,
            name: _name,
            createdAt: now,
            available: true
        });
        memberIds.push(id);

        return true;
    }
}

Libraries

A good practice is to use libraries in large contracts because they provide clean and smaller code. Avoid using them in small contracts because calling library functions is overhead and it may be cheaper to implement the library functionality in your contract. One of the most used libraries is SafeMath which is written by OpenZeppelin and is designed to support safe math operations that help prevent overflow.

Always keep the contracts simple

If you make your contracts complex, you can expect more potential errors and bugs. Hence, keeping them simple is a sure shot way to reduce the chances of errors. You can keep contracts simple by implementing the following practices:

  • You can make sure that the contract logic is simple.
  • Contracts are also split up for consistency as well as version control.
  • Wherever possible, use code or tools that you have already written before.
  • You can modularize the code to make the contracts and functions small.
  • Use blockchain only for those parts of your system that need decentralization.
  • Wherever possible, give preference to clarity over performance.

Conclusion

There are several best practices that developers and teams can undertake to ensure high quality, highly efficient, and highly secure smart contracts. Paying close attention to all general Solidity, documentation, security tools and token implementation as using best practices is crucial to developing Ethereum smart contracts successfully.

However, it is also important to note that developing smart contracts and maintaining them is not an easy task. It requires a lot of effort, careful work, and expert supervision for seamless proceedings. Hence, it is always helpful to partner with an Ethereum development team or firm that can help you with all the complexities of developing Ethereum smart contracts.

If you want to dig more into best practices, the following links can be useful

This article is part of Research & Development work being done by Pushkar Kumar, Suresh Konakanchi, and Ruchika Gupta. We will be covering a series of articles along with open source projects around blockchain, smart contracts, and web3 in general. Here is the list of all the articles that you can follow to start with Blockchain and write your first contract:

 
Share this