Description
-
For ERC‑20 tokens, balanceOf(address(0)) and allowance(owner, spender) queries with either parameter equal to address(0) are expected to return 0. This is the de‑facto behavior (e.g., OpenZeppelin ERC‑20) and many off‑chain tools and on‑chain integrations safely probe the zero address.
-
In this codebase, the internal view helpers _balanceOf and _allowance revert on zero‑address queries instead of returning 0. This deviation can cause DoS‑style incompatibility for consumers that expect non‑throwing zero results.
function _balanceOf(address owner) internal view returns (uint256) {
assembly {
if iszero(owner) {
revert(0, 0)
}
}
}
function _allowance(address owner, address spender) internal view returns (uint256 remaining) {
assembly {
if or(iszero(owner), iszero(spender)) {
revert(0, 0)
}
}
}
Risk
Likelihood: Medium
-
Wallets, indexers, relayers, bridges, and DeFi protocols frequently probe balanceOf(address(0)) or allowance(owner, spender) with zero address parameters for defensive checks and defaults during setup and routing.
-
The public balanceOf/allowance functions call these internal Yul helpers directly, so any zero‑address probe will revert.
Impact: Medium
-
Integration breakage / DoS: Off‑chain services and on‑chain contracts that expect a safe 0 return value will revert unexpectedly, breaking flows like route discovery, approvals introspection, portfolio scans, and UI queries.
-
Inconsistent developer experience: Returning 0 is widely expected; deviating creates confusion and hidden edge cases that are hard to diagnose.
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 ZeroAddressQueriesRevertTest is Test {
Token internal token;
Token2 internal ozToken;
function setUp() public {
token = new Token();
ozToken = new Token2();
}
function test_balanceOf_zeroAddress_reverts() public {
vm.expectRevert();
token.balanceOf(address(0));
}
function test_allowance_ownerZero_reverts() public {
address spender = address(0xB0B);
vm.expectRevert();
token.allowance(address(0), spender);
}
function test_allowance_spenderZero_reverts() public {
address owner = address(0xA11CE);
vm.expectRevert();
token.allowance(owner, address(0));
}
function test_nonZeroQueries_returnZero() public view {
address owner = address(0xA11CE);
address spender = address(0xB0B);
assertEq(token.balanceOf(owner), 0, "default balance should be zero");
assertEq(token.allowance(owner, spender), 0, "default allowance should be zero");
}
function test_oz_balanceOf_zeroAddress_returnsZero() public view {
uint256 balance = ozToken.balanceOf(address(0));
assertEq(balance, 0, "OpenZeppelin ERC20 balanceOf zero address should return zero");
}
function test_oz_allowance_zeroAddressOwner_returnsZero() public view {
address spender = address(0xB0B);
uint256 allowance = ozToken.allowance(address(0), spender);
assertEq(allowance, 0, "OpenZeppelin ERC20 allowance with zero address owner should return zero");
}
}
Output:
[⠊] Compiling...
No files changed, compilation skipped
Ran 6 tests for test/poc3.t.sol:ZeroAddressQueriesRevertTest
[PASS] test_allowance_ownerZero_reverts() (gas: 9437)
Traces:
[9437] ZeroAddressQueriesRevertTest::test_allowance_ownerZero_reverts()
├─ [0] VM::expectRevert(custom error 0xf4844814)
│ └─ ← [Return]
├─ [824] Token::allowance(0x0000000000000000000000000000000000000000, 0x0000000000000000000000000000000000000B0b) [staticcall]
│ └─ ← [Revert] EvmError: Revert
└─ ← [Stop]
[PASS] test_allowance_spenderZero_reverts() (gas: 9437)
Traces:
[9437] ZeroAddressQueriesRevertTest::test_allowance_spenderZero_reverts()
├─ [0] VM::expectRevert(custom error 0xf4844814)
│ └─ ← [Return]
├─ [824] Token::allowance(0x00000000000000000000000000000000000A11cE, 0x0000000000000000000000000000000000000000) [staticcall]
│ └─ ← [Revert] EvmError: Revert
└─ ← [Stop]
[PASS] test_balanceOf_zeroAddress_reverts() (gas: 8931)
Traces:
[8931] ZeroAddressQueriesRevertTest::test_balanceOf_zeroAddress_reverts()
├─ [0] VM::expectRevert(custom error 0xf4844814)
│ └─ ← [Return]
├─ [502] Token::balanceOf(0x0000000000000000000000000000000000000000) [staticcall]
│ └─ ← [Revert] EvmError: Revert
└─ ← [Stop]
[PASS] test_nonZeroQueries_returnZero() (gas: 17210)
Traces:
[17210] ZeroAddressQueriesRevertTest::test_nonZeroQueries_returnZero()
├─ [2720] Token::balanceOf(0x00000000000000000000000000000000000A11cE) [staticcall]
│ └─ ← [Return] 0
├─ [0] VM::assertEq(0, 0, "default balance should be zero") [staticcall]
│ └─ ← [Return]
├─ [3312] Token::allowance(0x00000000000000000000000000000000000A11cE, 0x0000000000000000000000000000000000000B0b) [staticcall]
│ └─ ← [Return] 0
├─ [0] VM::assertEq(0, 0, "default allowance should be zero") [staticcall]
│ └─ ← [Return]
└─ ← [Stop]
[PASS] test_oz_allowance_zeroAddressOwner_returnsZero() (gas: 12466)
Traces:
[12466] ZeroAddressQueriesRevertTest::test_oz_allowance_zeroAddressOwner_returnsZero()
├─ [3245] Token2::allowance(0x0000000000000000000000000000000000000000, 0x0000000000000000000000000000000000000B0b) [staticcall]
│ └─ ← [Return] 0
├─ [0] VM::assertEq(0, 0, "OpenZeppelin ERC20 allowance with zero address owner should return zero") [staticcall]
│ └─ ← [Return]
└─ ← [Stop]
[PASS] test_oz_balanceOf_zeroAddress_returnsZero() (gas: 11922)
Traces:
[11922] ZeroAddressQueriesRevertTest::test_oz_balanceOf_zeroAddress_returnsZero()
├─ [2850] Token2::balanceOf(0x0000000000000000000000000000000000000000) [staticcall]
│ └─ ← [Return] 0
├─ [0] VM::assertEq(0, 0, "OpenZeppelin ERC20 balanceOf zero address should return zero") [staticcall]
│ └─ ← [Return]
└─ ← [Stop]
Suite result: ok. 6 passed; 0 failed; 0 skipped; finished in 1.47ms (842.50µs CPU time)
Ran 1 test suite in 12.99ms (1.47ms CPU time): 6 tests passed, 0 failed, 0 skipped (6 total tests)
Recommended Mitigation
Option 1:
function _balanceOf(address owner) internal view returns (uint256) {
- assembly {
- if iszero(owner) { revert(0, 0) }
- // load and return balance...
- }
+ if (owner == address(0)) {
+ return 0;
+ }
+ // load mapping and return
+ uint256 amount;
+ assembly {
+ let baseSlot := _balances.slot
+ let ptr := mload(0x40)
+ mstore(ptr, owner)
+ mstore(add(ptr, 0x20), baseSlot)
+ let dataSlot := keccak256(ptr, 0x40)
+ amount := sload(dataSlot)
+ }
+ return amount;
}
function _allowance(address owner, address spender) internal view returns (uint256 remaining) {
- assembly {
- if or(iszero(owner), iszero(spender)) { revert(0, 0) }
- // load and return allowance...
- }
+ if (owner == address(0) || spender == address(0)) {
+ return 0;
+ }
+ assembly {
+ let ptr := mload(0x40)
+ let baseSlot := _allowances.slot
+ mstore(ptr, owner)
+ mstore(add(ptr, 0x20), baseSlot)
+ let initialHash := keccak256(ptr, 0x40)
+ mstore(ptr, spender)
+ mstore(add(ptr, 0x20), initialHash)
+ let allowanceSlot := keccak256(ptr, 0x40)
+ remaining := sload(allowanceSlot)
+ }
}
Option 2:
function _balanceOf(address owner) internal view returns (uint256) {
assembly {
- if iszero(owner) { revert(0, 0) }
+ // If owner == 0, return 0 (no revert)
+ if iszero(owner) {
+ mstore(0x00, 0)
+ return(0x00, 0x20)
+ }
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)
}
}
function _allowance(address owner, address spender) internal view returns (uint256 remaining) {
assembly {
- if or(iszero(owner), iszero(spender)) { revert(0, 0) }
+ // If owner==0 or spender==0, return 0 (no revert)
+ if or(iszero(owner), iszero(spender)) {
+ remaining := 0
+ // fall through and return via function ABI
+ }
+ if and(iszero(remaining), and(iszero(owner), iszero(spender))) {
+ // already handled above; ensure no slot reads
+ } else {
+ let ptr := mload(0x40)
+ let baseSlot := _allowances.slot
+ mstore(ptr, owner)
+ mstore(add(ptr, 0x20), baseSlot)
+ let initialHash := keccak256(ptr, 0x40)
+ mstore(ptr, spender)
+ mstore(add(ptr, 0x20), initialHash)
+ let allowanceSlot := keccak256(ptr, 0x40)
+ remaining := sload(allowanceSlot)
+ }
}
}