Description
-
Reading a value from storage in an ERC‑20 balanceOf implementation should load the value and return it, without performing extra memory writes that do not affect the return data or any subsequent state.
-
In the Yul implementation of _balanceOf, after loading the account’s balance and mstore(ptr, amount), the code performs mstore(add(ptr, 0x20), 0). This zeros out 32 bytes next to the return buffer but serves no purpose: the function already returns exactly 32 bytes from ptr, and the free memory region will be overwritten by future allocations anyway. This extra write wastes gas on every balanceOf call.
function _balanceOf(address owner) internal view returns (uint256) {
assembly {
if iszero(owner) {
revert(0, 0)
}
let baseSlot := _balances.slot
let ptr := mload(0x40)
mstore(ptr, owner)
mstore(add(ptr, 0x20), baseSlot)
let dataSlot := keccak256(ptr, 0x40)
let amount := sload(dataSlot)
mstore(ptr, amount)
mstore(add(ptr, 0x20), 0)
return(ptr, 0x20)
}
}
Risk
Likelihood: High
-
balanceOf is one of the most frequently called ERC‑20 functions by wallets, indexers, and dApps.
-
Every call runs the extra memory write, so the gas waste is systematic and occurs under routine usage.
Impact: Low
-
Gas inefficiency: Unnecessary mstore increases gas cost per query. Across many calls, this becomes material (especially for indexers and large dApps).
-
No functional benefit: The write does not change correctness, returned data, or memory hygiene - the free memory pointer is unchanged and future calls will overwrite memory as needed.
Proof of Concept
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {Token} from "./Token.sol";
import {Token2} from "./Token2.sol";
contract GasComparisonTest is Test {
Token internal custom;
Token2 internal oz;
address internal alice = address(0xA11CE);
address internal bob = address(0xB0B);
address internal spender = address(0x5ED);
uint256 internal amount = 1_000 ether;
function setUp() public {
custom = new Token();
oz = new Token2();
custom.mint(alice, amount);
oz.mint(alice, amount);
}
function test_gas_balanceOf() public {
uint256 gasBefore = gasleft();
uint256 balC = custom.balanceOf(alice);
uint256 gasAfter = gasleft();
uint256 gasUsedCustom = gasBefore - gasAfter;
assertEq(balC, amount);
gasBefore = gasleft();
uint256 balO = oz.balanceOf(alice);
gasAfter = gasleft();
uint256 gasUsedOZ = gasBefore - gasAfter;
assertEq(balO, amount);
emit log_named_uint("gas(balanceOf) - Custom", gasUsedCustom);
emit log_named_uint("gas(balanceOf) - OpenZeppelin", gasUsedOZ);
}
}
Output:
[⠊] Compiling...
No files changed, compilation skipped
Ran 1 test for test/poc4.t.sol:GasComparisonTest
[PASS] test_gas_balanceOf() (gas: 29404)
Logs:
gas(balanceOf) - Custom: 10253
gas(balanceOf) - OpenZeppelin: 8321
Traces:
[29404] GasComparisonTest::test_gas_balanceOf()
├─ [2720] Token::balanceOf(0x00000000000000000000000000000000000A11cE) [staticcall]
│ └─ ← [Return] 1000000000000000000000 [1e21]
├─ [0] VM::assertEq(1000000000000000000000 [1e21], 1000000000000000000000 [1e21]) [staticcall]
│ └─ ← [Return]
├─ [2850] Token2::balanceOf(0x00000000000000000000000000000000000A11cE) [staticcall]
│ └─ ← [Return] 1000000000000000000000 [1e21]
├─ [0] VM::assertEq(1000000000000000000000 [1e21], 1000000000000000000000 [1e21]) [staticcall]
│ └─ ← [Return]
├─ emit log_named_uint(key: "gas(balanceOf) - Custom", val: 10253 [1.025e4])
├─ emit log_named_uint(key: "gas(balanceOf) - OpenZeppelin", val: 8321)
└─ ← [Stop]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.02ms (424.80µs CPU time)
Ran 1 test suite in 19.28ms (2.02ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Recommended Mitigation
function _balanceOf(address owner) internal view returns (uint256) {
assembly {
if iszero(owner) {
revert(0, 0)
}
let baseSlot := _balances.slot
let ptr := mload(0x40)
mstore(ptr, owner)
mstore(add(ptr, 0x20), baseSlot)
let dataSlot := keccak256(ptr, 0x40)
let amount := sload(dataSlot)
mstore(ptr, amount)
- mstore(add(ptr, 0x20), 0) // unnecessary
return(ptr, 0x20)
}
}