Introduction
Efficiency is still essential when developing smart contracts in the dynamic blockchain world, particularly in the peculiar setting of the Celo blockchain. An in-depth discussion of Solidity Assembly’s role in optimizing gas consumption—a crucial component of blockchain interactions—is provided in this article. It reveals a range of methods for performance improvement, from investigating inline assembly peculiarities to creating gas-efficient data structures and putting in place efficient algorithms. This article offers the necessary information to overcome development difficulties and fully take advantage of smart contract optimization on the Celo network, regardless of your level of blockchain expertise.
Prerequisites
Prior to using Solidity Assembly for gas optimization, it’s crucial to be knowledgeable about:
- Programming language Solidity.
- Smart contracts and how they are used.
- Ethereum and its gas computation system.
- Basic knowledge of how the Ethereum Virtual Machine (EVM) works.
Requirements
To put these strategies into practice, you would require:
- Solidity compiler: You have the option of installing the Solidity compiler on your computer or using Remix, an online Solidity IDE.
- Celo test network: Celo Alfajores
Understanding Gas and Ethereum’s Importance
It is crucial to comprehend what “gas” means in the context of Celo before delving into Solidity Assembly and its efficiency techniques. Simply explained, gas is a unit used to represent the amount of processing needed to complete a given operation on the Celo network or other EVM-compatible network. Every action, from straightforward transactions to intricate smart contract interactions, needs a specific quantity of gas to be successful. In order to lower transaction costs and improve smart contract performance, gas utilization must be optimized.
The Need for Assembly-Level Optimization and Solidity
The most popular language for creating smart contracts on Ethereum is called Solidity. It is a high-level language that offers a simple syntax, simplifying the creation and deployment of smart contracts for developers.
Solidity abstracts away a lot of the difficulties that come with low-level languages, yet this convenience can result in inefficient gas utilization. To fix this, programmers can use Solidity Assembly, a low-level language that provides direct access to operations on the Ethereum Virtual Machine (EVM). The fine-grained optimization made possible by this degree of control has the potential to drastically lower gas use.
Step 1: Environment Setup and smart contract development
The first step is to create an environment for our smart contract development. For simplicity, let’s use Remix IDE as our development environment. In the contracts
directory on your REMIX IDE, create a new file called Solidity.sol
and paste the following code:
Solidity.sol
// SPDX-License-Identifier: MIT
pragma solidity >=0.7.0 <0.9.0;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract Solidity {
uint256 public sumOfNumbers;
struct Employee {
address token;
uint128 salary;
uint64 nextPayTimestamp;
uint64 timePeriod;
}
mapping(address => Employee) public employeeDetails;
bool public paused; // slot 1
event PlanPaused(bool);
function sumPure(
uint256[] memory _data
) public pure returns (uint256 result) {
for (uint256 i; i < _data.length; ++i) {
result += _data[i];
}
}
function sum(uint256[] memory _data) public {
uint256 result;
uint256 len = _data.length;
for (uint256 i; i < len; ++i) {
result += _data[i];
}
sumOfNumbers = result;
}
function pause() external {
paused = true;
emit PlanPaused(true);
}
function addEmployees(
address _employeeAddress,
address _token,
uint128 _employeeSalary,
uint64 _firstPayTimestamp,
uint64 _timePeriod
) external {
Employee storage emp = employeeDetails[_employeeAddress];
emp.salary = _employeeSalary;
emp.token = _token;
emp.nextPayTimestamp = _firstPayTimestamp;
emp.timePeriod = _timePeriod;
}
function editEmployees(
address _employeeAddress,
address _token,
uint128 _employeeSalary,
uint64 _nextPayTimestamp,
uint64 _timePeriod
) external {
Employee storage emp = employeeDetails[_employeeAddress];
emp.salary = _employeeSalary;
emp.token = _token;
emp.nextPayTimestamp = _nextPayTimestamp;
emp.timePeriod = _timePeriod;
}
function withdrawToken(address _tokenAddress, uint256 _amount) external {
IERC20(_tokenAddress).transfer(msg.sender, _amount);
}
}
This contract is a straightforward personnel management system with some extra features. The contract is broken into as follows:
State Variables
The contract includes the following state variables:
sumOfNumbers
is a public variable that stores the total of a set of numbers that were passed to a particular function.Employee
struct: This data structure contains all of an employee’s crucial information. It includes the timing for the next payment, the employee’s wage, the token address for the payment method, and the frequency of payments.employeeDetails
: This mapping establishes a connection between an employee’s Ethereum address and the details that go with it by pairing the employee’s address with the relevant Employee struct.paused
: This boolean variable represents the system’s functioning status. If this is the case, the system is inactive or halted.
Events
A second event, PlanPaused, is also declared in the contract. Events enable light clients to effectively respond to changes. When the pause function is used, the PlanPaused event is generated, informing any listeners that the system has been paused.
Functions
To interact with the state variables and implement the intended functionality of the contract, several functions are developed.
sumPure
adds the elements of an array of unsigned integers (uint256) and returns the result. This is a pure function, which means it doesn’t alter or even read the state of the contract.sum
: In addition to updating thesumOfNumbers
state variable to hold the sum of the input numbers, this function, which is similar tosumPure
, modifies thesumOfNumbers
state variablepause
: This function’s purpose is to stop the contract’s operations. It causes the PlanPaused event to be raised and sets the paused state variable to true.addEmployees
: With the help of theaddEmployees
function, a new employee’s information can be added to theemployeeDetails
mapping.editEmployees
: Editing an existing employee’s details in theemployeeDetails
mapping is possible with the help of the functioneditEmployees
.WithdrawToken
: This feature enables the withdrawal of a certain quantity of an ERC20 token. It makes use of the IERC20 interface’s transfer technique.
For the IERC20 interface, this contract makes use of the OpenZeppelin contracts library. A reputable library called OpenZeppelin provides a selection of safe and peer-reviewed smart contracts.
Step 2: Gas Optimization Techniques Using Solidity Assembly
Next, in the same contracts
directory, create a new file called Assembly.sol
which would contain a gas-optimized version of our previous smart contract.
Assembly.sol
// SPDX-License-Identifier: MIT
pragma solidity >=0.7.0 <0.9.0;
contract Assembly {
uint256 public sumOfNumbers; // slot 0
struct Employee {
address token;
uint128 salary;
uint64 nextPayTimestamp;
uint64 timePeriod;
}
mapping(address => Employee) public employeeDetails;
bool public paused; // slot 1
event PlanPaused(bool);
function sumPure(
uint256[] memory _data
) public pure returns (uint256 result) {
assembly {
// because array is in memory we use mload, for calldata CALLDATALOAD
// yul interprents the _data array as a memory address / 32B word
let len := mload(_data)
// 0x20 refers to 32 bytes
let data := add(_data, 0x20)
for {
let end := add(data, mul(len, 0x20))
} lt(data, end) {
data := add(data, 0x20)
} {
result := add(result, mload(data))
}
}
}
function sum(uint256[] memory _data) public {
assembly {
let len := mload(_data)
let data := add(_data, 0x20)
let result
for {
let end := add(data, mul(len, 0x20))
} lt(data, end) {
data := add(data, 0x20)
} {
result := add(result, mload(data))
}
sstore(0, result)
}
}
function pause() external {
assembly {
sstore(paused.slot, true)
// emitting event
mstore(0x80, true)
// the hash is just the event PlanPaused hashed
log1(
0x80,
0x01,
0x3b52531264dffb2eb5a1cb50b4adb7d62109b880fd7615400f7d32fc1bb315a2
)
}
}
// slot x - [token]
// slot x+1 - [salary,nextPayTimestamp,timePeriod]
function addEmployees(
address _employeeAddress,
address _token,
uint128 _employeeSalary,
uint64 _firstPayTimestamp,
uint64 _timePeriod
) external {
assembly {
// Calculating which slot to be stored in
mstore(0x00, _employeeAddress)
mstore(0x20, employeeDetails.slot)
// generate unique storage slot
let slot := keccak256(0, 0x40)
let w := sload(slot)
// Clearing 20 bytes and loading token address
w := and(w, not(0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF))
w := or(w, and(_token, 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF))
sstore(slot, w)
// Clearing and loading the first 16 bytes
let s := sload(add(slot, 1))
s := and(s, not(0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF))
s := or(s, and(_employeeSalary, 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF))
// // clearing and loading middle 8 bytes
// s := and(s,0xFFFFFFFFFFFFFFFF0000000000000000FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF)
s := and(s, not(shl(128, 0xFFFFFFFFFFFFFFFF)))
s := or(s, shl(128, and(_firstPayTimestamp, 0xFFFFFFFFFFFFFFFF)))
// // clearing and loading last 8 bytes
s := and(s, not(shl(192, 0xFFFFFFFFFFFFFFFF)))
s := or(s, shl(192, and(_timePeriod, 0xFFFFFFFFFFFFFFFF)))
sstore(add(slot, 1), s)
}
}
function editEmployees(
address _employeeAddress,
address _token,
uint128 _employeeSalary,
uint64 _nextPayTimestamp,
uint64 _timePeriod
) external {
assembly {
// Calculating which slot to be stored in
mstore(0x00, _employeeAddress)
mstore(0x20, employeeDetails.slot)
let slot := keccak256(0, 0x40)
let w := sload(slot)
// Clearing 20 bytes and loading token address
w := and(w, not(0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF))
w := or(w, and(_token, 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF))
sstore(slot, w)
// Clearing and loading the first 16 bytes
let s := sload(add(slot, 1))
s := and(s, not(0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF))
s := or(s, and(_employeeSalary, 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF))
// // clearing and loading middle 8 bytes
// s := and(s,0xFFFFFFFFFFFFFFFF0000000000000000FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF)
s := and(s, not(shl(128, 0xFFFFFFFFFFFFFFFF)))
s := or(s, shl(128, and(_nextPayTimestamp, 0xFFFFFFFFFFFFFFFF)))
// // clearing and loading last 8 bytes
s := and(s, not(shl(192, 0xFFFFFFFFFFFFFFFF)))
s := or(s, shl(192, and(_timePeriod, 0xFFFFFFFFFFFFFFFF)))
sstore(add(slot, 1), s)
}
}
function withdrawToken(address _tokenAddress, uint256 _amount) external {
bytes4 sig = 0xa9059cbb;
assembly {
let data := mload(0x40)
mstore(data, sig)
mstore(add(data, 0x04), caller())
mstore(add(data, 0x24), _amount)
let result := call(
2000000,
_tokenAddress,
0,
data,
0x52,
data,
0x01
)
switch result
case 0 {
revert(0, 0)
}
default {
}
}
}
}
This contract uses inline assembly to increase control and efficiency over the prior Solidity smart contract you gave and is a gas-optimized version of that contract. An explanation of the contract, concentrating on the building blocks, is provided below:
-
Function
sumPure
ThesumPure
function uses assembly to add up all the numbers in a memory-based array. Here, assembly is being used since reading straight from memory can be less expensive than accessing variables through Solidity. The array data is accessed by computing the position in memory (add(_data, 0x20), as arrays have their length at the first 32-byte slot), and the array length is loaded using mload(_data). The data is then iterated over in a loop, adding each value to the final result. -
sum
FunctionIn addition to storing the outcome in the sumOfNumbers state variable at slot 0 of the contract’s storage (sstore(0, result)), the sum function is comparable to sumPure.
-
the
pause
Function
The system is paused using the pause function, which also utilizes assembly to send the PlanPaused event. It creates a log1 call (which corresponds to emitting an event) and saves true in the paused state variable slot of the state machine. The log1 arguments specify the topic (which is the hashed event signature) and the data to log, which in this case is true. In high-level Solidity, calling emit is less effective than doing this. -
function
addEmployees
and functioneditEmployees
TheemployeeDetails
mapping can be updated or expanded with the use of the addEmployees and editEmployees functions. They efficiently cram the data of each employee into two 32-byte storage slots using assembly to create a one-of-a-kind storage slot for each employee. By shifting bits, the shl instruction effectively positions each piece of data in the slot at the proper location. This densely packed construction uses less storage, which helps to conserve gas. -
the
withdrawToken
feature
To transfer tokens, the withdrawToken method uses assembly to make a call to an ERC20 token contract. In many cases, this inline assembly approach uses less gas than communicating with contracts via Solidity’s high-level syntax. The call guarantees atomicity by reverting the transaction in the event of failure.
Inline assembly is used in this contract to demonstrate a number of methods for reducing gas prices in Solidity smart contracts. These methods enable more cost-effective interaction with other contracts, more effective memory and storage consumption, and more direct control over Ethereum’s EVM. They should only be used sparingly because they complicate the EVM and make it more difficult to comprehend and reason about the code.
Finally, compile and deploy both the Solidity.sol
and Assembly.sol
smart contracts and compare the gas used by the addEmployees
functions that both contracts consumed while being executed. You can go through this article for detailed steps on how to deploy smart contracts to the Celo network using the Remix IDE.
From the two images above, we can see that our addEmployees
function in Solidity.sol
smart contract used a gas of 78901 but that of the assembly gas-optimized version of it, Assembly.sol
used a gas of 78187 which is way lesser.
Conclusion
The creation of successful and economical smart contracts requires the optimization of gas. Developers can use Solidity Assembly to communicate with the Celo blockchain directly and use techniques that increase the gas efficiency of their smart contracts. It’s crucial to remember that while assembly provides more control, it also necessitates precise attention to detail in order to prevent errors and security flaws.
Next Steps
- Start incorporating these strategies into your smart contracts and monitor the change in gas consumption. You may write, test, and deploy your contracts on the Celo network using Remix, an online IDE for Solidity.
- Make an effort to rework and optimize current contracts to increase their gas efficiency. For Celo development, tools like Ganache can offer a private blockchain where you may test your contracts.
- Continue to research other approaches and tactics for gas optimization. A plethora of material is available on websites like Solidity Docs and ConsenSys Best Practices.
- Learn about and comprehend EVM opcode prices to help you choose better while optimizing your code. Information on gas prices and EVM operations may be found in depth in the Ethereum Yellow Paper and other Etherscan resources.
About the Author
Israel Okunaya is an ace writer with a flair for simplifying complexities and a knack for storytelling. He leverages over four years of experience to meet the most demanding writing needs in different niches, especially food and travel, blockchain, and marketing. He sees blockchain as a fascinating yet tricky affair. So, he is given to simplifying its complexities with text and video tutorials.
References
- Solidity Documentation: Assembly
- Ethereum Yellow Paper: Ethereum: A Secure Decentralised Generalised Transaction Ledger
- ConsenSys: Ethereum Smart Contract Best Practices
- Etherscan: Ethereum Gas Chart
- Celoscan
- Remix Codebase
- Github Repo