Token-0x

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

Non‑standard reverts on balanceOf/allowance zero‑address queries

Author Revealed upon completion

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.

// src/helpers/ERC20Internals.sol (excerpts)
// @> _balanceOf: reverts if owner == address(0)
function _balanceOf(address owner) internal view returns (uint256) {
assembly {
if iszero(owner) {
revert(0, 0) // @> Non-standard: empty-data revert
}
// ... load balance ...
}
}
// @> _allowance: reverts if owner == 0 OR spender == 0
function _allowance(address owner, address spender) internal view returns (uint256 remaining) {
assembly {
if or(iszero(owner), iszero(spender)) {
revert(0, 0) // @> Non-standard: should return 0
}
// ... load allowance ...
}
}

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

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

  • Run forge test --mp poc3 -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 ZeroAddressQueriesRevertTest is Test {
Token internal token;
Token2 internal ozToken;
function setUp() public {
token = new Token(); // custom ERC20 implementation that reverts on zero-address queries
ozToken = new Token2(); // OpenZeppelin ERC20 implementation for comparison
}
function test_balanceOf_zeroAddress_reverts() public {
// Expect an empty-data revert (as implemented via `revert(0,0)` in _balanceOf)
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));
}
// Non-zero queries succeed and return numbers (0 by default)
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");
}
//////////////////////////////////////////////////////////////////////////////////////
// OpenZeppelin ERC20 does not revert on zero-address queries ////////////////////////
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

  • Return 0 for zero‑address queries instead of reverting.

  • Implement either in Solidity (option 1) or in Yul (option 2) with conditional handling.

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

Support

FAQs

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

Give us feedback!