Token-0x

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

Custom ERC20 is not marked abstract, direct deployment is possible with permanent 0 total supply

Author Revealed upon completion

Description

  • For ERC‑20 base implementations, libraries like OpenZeppelin mark the base ERC20 contract as abstract so developers must extend it and implement explicit supply logic (e.g., minting in a derived contract or constructor), preventing accidental deployment of a token with no way to create supply.

  • In this codebase, the base ERC20 contract is not marked abstract, so it can be directly deployed. Since the base contract exposes no minting or initial supply logic, a direct deployment results in a token with a permanent totalSupply = 0 and no mechanism to create supply, effectively bricking the token and potentially causing downstream integration issues (DEX listings, rewards, protocol accounting, etc.).

// src/ERC20.sol (excerpt)
contract ERC20 is IERC20Errors, ERC20Internals {
constructor(string memory name_, string memory symbol_) {
_name = name_;
_symbol = symbol_;
}
// @> No minting constructor or external mint logic in this base.
// @> Contract is not marked `abstract`, so direct deployment is possible.
}

Risk

Likelihood: Medium

  • Teams may accidentally deploy ERC20 directly when prototyping or scripting deployments (copy/paste, quick Forge scripts), because the contract compiles and has a constructor taking name/symbol.

  • The repository README and tests showcase a derived Token that adds mint/burn, but nothing prevents a separate deployment script from instantiating the base ERC20, especially in multi-contract deployments.

Impact: Medium

  • Irreversible zero-supply token: With no mint function and no initial supply, holders and protocols can’t create or receive tokens; the asset is unusable and may still be discoverable/indexed (leading to confusion and reputational damage).

  • Operational fallout: DEX/aggregator listings, reward distributions, or protocol flows that reference the address will fail or stall, causing DoS conditions and wasted deployment funds/time.

Proof of Concept

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

  • Run forge test --mp poc -vvvv.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {ERC20, IERC20Errors} from "../src/ERC20.sol";
contract ERC20BaseDeployTest is Test {
ERC20 internal base;
function setUp() public {
// Directly deploy the base ERC20
base = new ERC20("BaseToken", "BASE");
}
/// @notice Direct deployment compiles and produces a token with totalSupply == 0 and no mint path.
function test_deployBase_hasZeroSupply_andNoMintFunction() public {
// 1) totalSupply is zero
assertEq(base.totalSupply(), 0, "totalSupply should be 0 on base ERC20 deployment");
// 2) There is no external mint function in the base, so a low-level call to 'mint' must fail.
(bool success, bytes memory ret) = address(base).call(
abi.encodeWithSignature("mint(address,uint256)", address(this), 1)
);
assertFalse(success, "mint should not exist on the base ERC20");
assertEq(ret.length, 0, "no revert data expected for non-existent function");
}
/// @notice Any transfer reverts because all balances are zero (no way to create supply).
function test_transferFromZeroBalance_revertsWithInsufficientBalance() public {
address sender = address(0xBEEF);
address receiver = address(0xCAFE);
vm.startPrank(sender);
// Expect revert with custom error: ERC20InsufficientBalance(sender, balance=0, needed=1)
vm.expectRevert(
abi.encodeWithSelector(
IERC20Errors.ERC20InsufficientBalance.selector,
sender,
uint256(0),
uint256(1)
)
);
base.transfer(receiver, 1);
vm.stopPrank();
}
/// @notice Approvals can be set, but transferFrom still reverts because there is no balance to move.
function test_transferFrom_withAllowance_stillRevertsDueToZeroBalance() public {
address owner = address(0xA11CE);
address spender = address(0x5ED);
address to = address(0xF00D);
// Owner approves spender
vm.startPrank(owner);
bool ok = base.approve(spender, 5);
assertTrue(ok, "approve should succeed");
// Confirm allowance
assertEq(base.allowance(owner, spender), 5, "allowance not recorded");
vm.stopPrank();
// Spender attempts transferFrom; spendAllowance will pass, but _transfer reverts due to zero balance.
vm.startPrank(spender);
vm.expectRevert(
abi.encodeWithSelector(
IERC20Errors.ERC20InsufficientBalance.selector,
owner,
uint256(0),
uint256(5)
)
);
base.transferFrom(owner, to, 5);
vm.stopPrank();
// NOTE: In the provided code, spendAllowance happens before _transfer; if it reverts afterwards, state written by spendAllowance is reverted too. So allowance stays 5 here.
assertEq(base.allowance(owner, spender), 5, "allowance should remain unchanged after revert");
}
}

Output:

[⠊] Compiling...
No files changed, compilation skipped
Ran 3 tests for test/poc.t.sol:ERC20BaseDeployTest
[PASS] test_deployBase_hasZeroSupply_andNoMintFunction() (gas: 14645)
Traces:
[14645] ERC20BaseDeployTest::test_deployBase_hasZeroSupply_andNoMintFunction()
├─ [2317] ERC20::totalSupply() [staticcall]
│ └─ ← [Return] 0
├─ [0] VM::assertEq(0, 0, "totalSupply should be 0 on base ERC20 deployment") [staticcall]
│ └─ ← [Return]
├─ [222] ERC20::mint(ERC20BaseDeployTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], 1)
│ └─ ← [Revert] unrecognized function selector 0x40c10f19 for contract 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f, which has no fallback function.
├─ [0] VM::assertFalse(false, "mint should not exist on the base ERC20") [staticcall]
│ └─ ← [Return]
├─ [0] VM::assertEq(0, 0, "no revert data expected for non-existent function") [staticcall]
│ └─ ← [Return]
└─ ← [Stop]
[PASS] test_transferFromZeroBalance_revertsWithInsufficientBalance() (gas: 16545)
Traces:
[16545] ERC20BaseDeployTest::test_transferFromZeroBalance_revertsWithInsufficientBalance()
├─ [0] VM::startPrank(0x000000000000000000000000000000000000bEEF)
│ └─ ← [Return]
├─ [0] VM::expectRevert(custom error 0xf28dceb3: 00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000064e450d38c000000000000000000000000000000000000000000000000000000000000beef0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000)
│ └─ ← [Return]
├─ [5186] ERC20::transfer(0x000000000000000000000000000000000000cafE, 1)
│ └─ ← [Revert] ERC20InsufficientBalance(0x000000000000000000000000000000000000bEEF, 0, 1)
├─ [0] VM::stopPrank()
│ └─ ← [Return]
└─ ← [Stop]
[PASS] test_transferFrom_withAllowance_stillRevertsDueToZeroBalance() (gas: 37386)
Traces:
[51998] ERC20BaseDeployTest::test_transferFrom_withAllowance_stillRevertsDueToZeroBalance()
├─ [0] VM::startPrank(0x00000000000000000000000000000000000A11cE)
│ └─ ← [Return]
├─ [24985] ERC20::approve(0x00000000000000000000000000000000000005Ed, 5)
│ ├─ emit Approval(owner: 0x00000000000000000000000000000000000A11cE, spender: 0x00000000000000000000000000000000000005Ed, value: 5)
│ └─ ← [Return] true
├─ [0] VM::assertTrue(true, "approve should succeed") [staticcall]
│ └─ ← [Return]
├─ [1290] ERC20::allowance(0x00000000000000000000000000000000000A11cE, 0x00000000000000000000000000000000000005Ed) [staticcall]
│ └─ ← [Return] 5
├─ [0] VM::assertEq(5, 5, "allowance not recorded") [staticcall]
│ └─ ← [Return]
├─ [0] VM::stopPrank()
│ └─ ← [Return]
├─ [0] VM::startPrank(0x00000000000000000000000000000000000005Ed)
│ └─ ← [Return]
├─ [0] VM::expectRevert(custom error 0xf28dceb3: 00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000064e450d38c00000000000000000000000000000000000000000000000000000000000a11ce0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000500000000000000000000000000000000000000000000000000000000)
│ └─ ← [Return]
├─ [5853] ERC20::transferFrom(0x00000000000000000000000000000000000A11cE, 0x000000000000000000000000000000000000F00D, 5)
│ └─ ← [Revert] ERC20InsufficientBalance(0x00000000000000000000000000000000000A11cE, 0, 5)
├─ [0] VM::stopPrank()
│ └─ ← [Return]
├─ [1290] ERC20::allowance(0x00000000000000000000000000000000000A11cE, 0x00000000000000000000000000000000000005Ed) [staticcall]
│ └─ ← [Return] 5
├─ [0] VM::assertEq(5, 5, "allowance should remain unchanged after revert") [staticcall]
│ └─ ← [Return]
└─ ← [Stop]
Suite result: ok. 3 passed; 0 failed; 0 skipped; finished in 1.51ms (661.80µs CPU time)
Ran 1 test suite in 16.44ms (1.51ms CPU time): 3 tests passed, 0 failed, 0 skipped (3 total tests)

Recommended Mitigation

  • Add abstract keyword like OpenZeppelin did in version 5:

- contract ERC20 is IERC20Errors, ERC20Internals {
+ abstract contract ERC20 is IERC20Errors, ERC20Internals {
constructor(string memory name_, string memory symbol_) {
_name = name_;
_symbol = symbol_;
}
}

Support

FAQs

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

Give us feedback!