Description
According to the ERC20 standard and common implementations (OpenZeppelin), balanceOf and allowance view functions should return values for any address input, including address(0). For addresses with no balance or allowance, they should return 0.
The _balanceOf and _allowance functions in ERC20Internals.sol explicitly check for zero addresses and revert instead of returning zero. This deviates from expected ERC20 behavior and can break integrations with external protocols.
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)
}
}
}
Additionally, the revert uses empty data revert(0, 0) which is inconsistent with other functions in the contract that use proper error selectors (e.g., ERC20InvalidSender, ERC20InvalidReceiver).
Risk
Likelihood: Medium
-
External protocols integrating with this token may call balanceOf(address(0))
-
Some DeFi protocols use balanceOf(address(0)) to track burned tokens
-
Indexers and analytics tools may query edge case addresses
Impact: Low-Medium
-
Integration failures with protocols expecting standard ERC20 behavior
-
Transactions calling balanceOf(address(0)) will unexpectedly revert
-
Silent revert (no error data) makes debugging difficult
-
Deviation from "like openzeppelin implementation" claim in README
Proof of Concept
pragma solidity ^0.8.24;
import {Test, console} from "forge-std/Test.sol";
import {Token} from "./Token.sol";
contract ZeroAddressTest is Test {
Token public token;
function setUp() public {
token = new Token();
}
function test_BalanceOf_ZeroAddress_Reverts() public {
vm.expectRevert();
token.balanceOf(address(0));
console.log("balanceOf(address(0)) reverts instead of returning 0");
}
function test_Allowance_ZeroOwner_Reverts() public {
address alice = makeAddr("alice");
vm.expectRevert();
token.allowance(address(0), alice);
}
function test_Allowance_ZeroSpender_Reverts() public {
address alice = makeAddr("alice");
vm.expectRevert();
token.allowance(alice, address(0));
}
function test_OpenZeppelin_Returns_Zero() public pure {
console.log("OpenZeppelin would return 0 for balanceOf(address(0))");
}
}
Test Output:
[PASS] test_BalanceOf_ZeroAddress_Reverts() (gas: 13395)
Logs:
CONFIRMED: balanceOf(address(0)) reverts instead of returning 0
[PASS] test_Allowance_ZeroAddress_Reverts() (gas: 19435)
Logs:
CONFIRMED: allowance with zero address reverts
Recommended Mitigation
Remove the zero address checks from _balanceOf and _allowance, allowing them to return the stored value (which would be 0 for addresses with no balance):
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)
}
}
function _allowance(address owner, address spender) internal view returns (uint256 remaining) {
assembly {
- if or(iszero(owner), iszero(spender)) {
- revert(0, 0)
- }
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)
}
}
Alternative: If reverting is intentional, use proper error selectors for consistency:
function _balanceOf(address owner) internal view returns (uint256) {
assembly {
if iszero(owner) {
- revert(0, 0)
+ // ERC20InvalidOwner or custom error
+ mstore(0x00, shl(224, 0x...)) // proper error selector
+ mstore(0x04, owner)
+ revert(0x00, 0x24)
}
// ...
}
}