Solidity Attack Vectors: #9 - Denial Of Service With Block Gas Limit
Table of contents
The concept of gas and gas fees on Ethereum is not new. These are concepts that the blockchain uses to control excessive computational work on the Ethereum Virtual Machine (EVM).
For every block in the blockchain, there is a block gas limit. This limit varies in different blockchains. Ethereum has a block gas limit of around 30 million and Binance has around 140 million at the time of writing this article. If the gas limit of a block is reached, the following transactions are added to a new block. No transaction is expected to surpass the block gas limit at once, as that would require a lot of computational work.
Exceeding The Block Gas Limit
Sometimes developers can write really amazing code without considering the implications that the code may consume a lot of gas. Smart contracts need gas to run, and things like making loops on dynamic arrays and using loops on storage can get very expensive, depending on their size.
For example, consider the following smart contract:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.9;
contract Payroll {
address payable[] workers;
mapping(address => uint) workerAmount;
function addWorkers(
address payable _worker,
uint256 _workerAmount
) external {
workerAmount[_worker] = _workerAmount;
workers.push(_worker);
}
function payAllWorkers() external {
for (uint i; i < workers.length; i++) {
workers[i].transfer(workerAmount[workers[i]]);
}
}
}
This is a simple "Payroll" contract that accepts worker addresses through the "addWorkers" function and pays all the workers in the array of "workers" addresses. Now, imagine if the number of workers were up to 60,000 or even 5 million addresses? And if we are looping through these addresses directly from storage, this can take a lot of gas depending on the size and length of the array and can even exceed the block gas limit. It is generally a bad practice to do expensive loops on arrays of unknown sizes.
How To Prevent This
It is better to use the "pull over push" design pattern, where users come to withdraw their balance instead of having it sent to them. When possible, use a mapping
of address to boolean so that when a user tries to withdraw, we just check if their address returns a "true" to indicate they are eligible to receive payments, then they can withdraw.
For example:
contract PayrollTwo {
mapping(address => bool) workersMapping;
mapping(address => Worker) workers;
struct Worker {
address payable workerAddress;
uint256 workerAmount;
}
uint256 currentIndex;
function addWorkers(
address payable _worker,
uint256 _workerAmount
) external {
workersMapping[msg.sender] = true;
workers[msg.sender] = Worker(_worker, _workerAmount);
}
function payAllWorkers() external {
require(workersMapping[msg.sender], "Not a worker");
uint amount = workers[msg.sender].workerAmount;
(bool success, ) = payable(msg.sender).call{value: amount}("");
require(success);
}
receive() external payable {}
}
Instead of using arrays, we now have a mapping that adds workers and another mapping that sets their status to true, indicating that they are eligible to receive payments. In the "payAllWorkers" function, the worker must come themselves to withdraw their amount and we have a "require" statement that checks if their address returns a "true". If it does, they are paid; otherwise, the function reverts.
Conclusion
As developers, it is worth knowing how resource-consuming some operations in our contracts can be, and we should be mindful, especially if the contract is used by a lot of people other than us. Encourage methods that reduce gas consumption and write better code.
Thank you for reading. See you in the next article.