Token-0x

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

Custom ERC20 implementation is less gas efficient than OpenZeppelin

Author Revealed upon completion

Description

  • A “cheap” ERC‑20 implementation should use fewer gas units than the widely adopted OpenZeppelin (OZ) ERC‑20 for typical operations - balanceOf, approve, transfer, transferFrom, and allowance. The README claims this codebase achieves secure and cheap operations with Solidity+Yul.

  • Empirical measurement shows the custom ERC‑20 consistently consumes more gas than the OpenZeppelin ERC‑20 across common operations. This contradicts the repository’s “cheap operations” claim and hurts UX and protocol economics (higher gas fees for end users and integrators).

// Observation summary (from benchmarks):
// @> balanceOf: custom uses more gas than OZ
// @> approve: custom uses more gas than OZ
// @> transfer: custom uses more gas than OZ
// @> allowance: custom uses more gas than OZ
// @> transferFrom: custom uses more gas than OZ
//
// These results persist under identical test conditions and inputs.

Risk

Likelihood: High

  • The gas overhead occurs on routine ERC‑20 interactions across all users - wallets, DEXes, staking, rewards, and bridges - so it will be observed whenever the token is used in normal flows.

  • Any production deployment that adopts this base will consistently incur extra costs.

Impact: Medium

  • Higher user costs / worse UX: Every token interaction costs more gas, discouraging usage and hurting protocol competitiveness, especially at scale (routers, indexers, liquidity mining, and airdrops).

  • Economic inefficiencies: Protocols paying gas for automated flows (keepers, reward distributors) will spend more, reducing margins or forcing parameter changes (less frequent distributions, larger batches).

Proof of Concept

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

  • Run forge test --mp poc5 -vv.

// 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; // custom Token
Token2 internal oz; // OZ-based Token2
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();
// 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);
}
// -------------------------
// approve gas comparison
// -------------------------
function test_gas_approve() public {
// CUSTOM
vm.startPrank(alice);
uint256 gasBefore = gasleft();
bool okC = custom.approve(spender, 123 ether);
uint256 gasAfter = gasleft();
vm.stopPrank();
require(okC, "custom approve failed");
uint256 gasUsedCustom = gasBefore - gasAfter;
// OZ
vm.startPrank(alice);
gasBefore = gasleft();
bool okO = oz.approve(spender, 123 ether);
gasAfter = gasleft();
vm.stopPrank();
require(okO, "oz approve failed");
uint256 gasUsedOZ = gasBefore - gasAfter;
emit log_named_uint("gas(approve 123e18) - Custom", gasUsedCustom);
emit log_named_uint("gas(approve 123e18) - OpenZeppelin", gasUsedOZ);
}
// -------------------------
// transfer gas comparison
// -------------------------
function test_gas_transfer() public {
// CUSTOM
vm.startPrank(alice);
uint256 gasBefore = gasleft();
bool okC = custom.transfer(bob, 10 ether);
uint256 gasAfter = gasleft();
vm.stopPrank();
require(okC, "custom transfer failed");
uint256 gasUsedCustom = gasBefore - gasAfter;
// Reset balances for consistent comparison
// (bring alice back to full amount on oz side)
vm.prank(bob);
oz.transfer(alice, oz.balanceOf(bob)); // ensure bob has 0 for oz before test
// OZ
vm.startPrank(alice);
uint256 gasBefore2 = gasleft();
bool okO = oz.transfer(bob, 10 ether);
uint256 gasAfter2 = gasleft();
vm.stopPrank();
require(okO, "oz transfer failed");
uint256 gasUsedOZ = gasBefore2 - gasAfter2;
emit log_named_uint("gas(transfer 10e18) - Custom", gasUsedCustom);
emit log_named_uint("gas(transfer 10e18) - OpenZeppelin", gasUsedOZ);
}
// -------------------------
// allowance gas comparison (non-zero addresses)
// -------------------------
function test_gas_allowance_nonzero() public {
// Prepare approvals on both tokens
vm.startPrank(alice);
custom.approve(spender, 50 ether);
vm.stopPrank();
vm.startPrank(alice);
oz.approve(spender, 50 ether);
vm.stopPrank();
// CUSTOM
uint256 gasBefore = gasleft();
uint256 aC = custom.allowance(alice, spender);
uint256 gasAfter = gasleft();
uint256 gasUsedCustom = gasBefore - gasAfter;
assertEq(aC, 50 ether);
// OZ
gasBefore = gasleft();
uint256 aO = oz.allowance(alice, spender);
gasAfter = gasleft();
uint256 gasUsedOZ = gasBefore - gasAfter;
assertEq(aO, 50 ether);
emit log_named_uint("gas(allowance) - Custom", gasUsedCustom);
emit log_named_uint("gas(allowance) - OpenZeppelin", gasUsedOZ);
}
// -------------------------
// transferFrom gas comparison
// -------------------------
function test_gas_transferFrom() public {
// Prepare approvals
vm.startPrank(alice);
custom.approve(spender, 20 ether);
vm.stopPrank();
vm.startPrank(alice);
oz.approve(spender, 20 ether);
vm.stopPrank();
// CUSTOM
vm.startPrank(spender);
uint256 gasBefore = gasleft();
bool okC = custom.transferFrom(alice, bob, 15 ether);
uint256 gasAfter = gasleft();
vm.stopPrank();
require(okC, "custom transferFrom failed");
uint256 gasUsedCustom = gasBefore - gasAfter;
// OZ
vm.startPrank(spender);
uint256 gasBefore2 = gasleft();
bool okO = oz.transferFrom(alice, bob, 15 ether);
uint256 gasAfter2 = gasleft();
vm.stopPrank();
require(okO, "oz transferFrom failed");
uint256 gasUsedOZ = gasBefore2 - gasAfter2;
emit log_named_uint("gas(transferFrom 15e18) - Custom", gasUsedCustom);
emit log_named_uint("gas(transferFrom 15e18) - OpenZeppelin", gasUsedOZ);
}
}

Output:

[⠊] Compiling...
No files changed, compilation skipped
Ran 5 tests for test/poc5.t.sol:GasComparisonTest
[PASS] test_gas_allowance_nonzero() (gas: 81506)
Logs:
gas(allowance) - Custom: 2621
gas(allowance) - OpenZeppelin: 2498
[PASS] test_gas_approve() (gas: 75487)
Logs:
gas(approve 123e18) - Custom: 32745
gas(approve 123e18) - OpenZeppelin: 31003
[PASS] test_gas_balanceOf() (gas: 29382)
Logs:
gas(balanceOf) - Custom: 10253
gas(balanceOf) - OpenZeppelin: 8321
[PASS] test_gas_transfer() (gas: 95263)
Logs:
gas(transfer 10e18) - Custom: 37853
gas(transfer 10e18) - OpenZeppelin: 27776
[PASS] test_gas_transferFrom() (gas: 144864)
Logs:
gas(transferFrom 15e18) - Custom: 34287
gas(transferFrom 15e18) - OpenZeppelin: 33105
Suite result: ok. 5 passed; 0 failed; 0 skipped; finished in 2.90ms (1.38ms CPU time)
Ran 1 test suite in 15.54ms (2.90ms CPU time): 5 tests passed, 0 failed, 0 skipped (5 total tests)

Recommended Mitigation

- Continue emitting/special-casing events and performing storage reads/writes
- in multiple places with repeated assembly snippets.
+ 1) Factor common transfer/mint/burn logic into a single internal function
+ (similar in spirit to OpenZeppelin’s `_update`), and call it from
+ `transfer`, `transferFrom`, `_mint`, and `_burn`.
+ - Centralize storage slot computation, arithmetic, and event emission.
+ - Reduce duplicated `keccak256` computations and `sload`/`sstore` pairs.
+ - Make it easier to apply micro-optimizations once, everywhere.
+ 2) Remove unnecessary memory writes (e.g., the trailing `mstore(add(ptr, 0x20), 0)`
+ in `_balanceOf`) and avoid zero-address reverts in views (return `0` instead),
+ reducing control-flow and memory churn.
+ 3) Add explicit overflow/underflow guards only where needed, and keep the rest
+ in Solidity (0.8.x checked arithmetic) for better compiler optimization and
+ readability; use Yul selectively where it demonstrably saves gas.
+ 4) Cache frequently reused slots/hashes in memory registers, and avoid recomputing
+ the same `keccak256` mapping slot multiple times within one operation.
+ 5) Emit mint/burn `Transfer` events using the same path as normal transfers
+ (via the centralized `_update`-like function) to avoid duplicated logging code.
  • A centralized _update-style function minimizes duplicated work and enables compiler optimizations across code paths.

  • Reducing redundant memory stores and avoiding unnecessary branching in view functions lowers gas.

  • Consolidation also improves maintainability and makes future gas tuning (e.g., slot caching, event emission layout) straightforward.

Support

FAQs

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

Give us feedback!