Token-0x

First Flight #54
Beginner FriendlyDeFi
100 EXP
Submission Details
Impact: low
Likelihood: high

Unnecessary memory write in _balanceOf

Author Revealed upon completion

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.

// src/helpers/ERC20Internals.sol (excerpt)
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)
// @> Unnecessary write:
// @> This zeros the word after the return buffer, but the function returns only 32 bytes.
// @> The free memory pointer region will be reused/overwritten later anyway.
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

  • Create poc4.t.sol under test directory and copy the code below.

  • Run forge test --mp poc4 -vvvv.

// SPDX-License-Identifier: MIT
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(); // custom Token (assembly-heavy)
oz = new Token2(); // OZ-based Token2 above
// Set up balances for transfer tests
custom.mint(alice, amount);
oz.mint(alice, amount);
}
// -------------------------
// balanceOf gas comparison
// -------------------------
function test_gas_balanceOf() public {
// CUSTOM
uint256 gasBefore = gasleft();
uint256 balC = custom.balanceOf(alice);
uint256 gasAfter = gasleft();
uint256 gasUsedCustom = gasBefore - gasAfter;
assertEq(balC, amount);
// OZ
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

  • Remove the redundant memory write.

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)
}
}

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!