Token-0x

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

Token-0x Protocol Security Audit Report Prepared by: NFTeria Inc Lead Auditor: Rensley R.

Author Revealed upon completion
NFTeria Logo

Token-0x Protocol

Security Audit Report

Prepared by: NFTeria Inc
Lead Auditor: Rensley R.
December 10, 2025

Table of Contents


Protocol Summary

Token-0x is a secure and gas-efficient base ERC20 implementation that follows the ERC20 standard. The protocol achieves secure and cheap operations by using a combination of Solidity and Yul (inline assembly) in the base implementation.

Key Features:

  • Custom ERC20 base implementation with Yul optimizations in ERC20Internals.sol

  • Drop-in replacement for OpenZeppelin's ERC20 implementation

  • Designed for use as base tokens in DeFi protocols, reward systems, and protocol-native tokens

Contracts Under Review:

Contract LOC Purpose
Token.sol 17 Example token using Token-0x's custom ERC20
Token2.sol 17 Example token using OpenZeppelin's ERC20
ERC20.sol 47 Token-0x's custom ERC20 base contract
ERC20Internals.sol 175 Yul-optimized internal functions
IERC20Errors.sol 45 Custom error interface
IERC20.sol 21 Standard ERC20 interface

Key Architectural Difference:

The protocol provides two example implementations:

  1. Token.sol - Imports from ../src/ERC20.sol (Token-0x's custom Yul-optimized implementation)

  2. Token2.sol - Imports from @openzeppelin/contracts/token/ERC20/ERC20.sol (Battle-tested OpenZeppelin)

Both example contracts share identical critical vulnerabilities in their public interfaces, but the underlying base implementations differ significantly in security posture, gas efficiency, and battle-testing.


Disclaimer

The NFTeria team makes all effort to find as many vulnerabilities in the code in the given time period, but holds no responsibilities for the findings provided in this document. A security audit by the team is not an endorsement of the underlying business or product. The audit was time-boxed and the review of the code was solely on the security aspects of the Solidity implementation of the contracts.


Risk Classification

Impact
High Medium Low
High H H/M M
Likelihood Medium H/M M M/L
Low M M/L L

We use the CodeHawks severity matrix to determine severity. See the documentation for more details.


Coverage Analysis

Methodology

This audit employed a rigorous coverage-driven security methodology:

  1. Initial Coverage Assessment - Ran forge coverage --report lcov to identify untested code paths

  2. LCOV Analysis - Parsed coverage data to identify missing lines and branches

  3. Targeted Test Development - Created tests specifically targeting uncovered branches

  4. Test Harness Creation - Developed TokenHarness contract to expose internal functions for unreachable branches

  5. Vulnerability Discovery - Coverage testing revealed execution path flaws

  6. Final Verification - Achieved and verified 100% coverage across all metrics

100% Coverage Achievement

Final Coverage Results (60 tests passed, 0 failed):

File Lines Statements Branches Functions
src/ERC20.sol 100.00% (24/24) 100.00% (17/17) 100.00% (0/0) 100.00% (10/10)
src/helpers/ERC20Internals.sol 100.00% (127/127) 100.00% (119/119) 100.00% (10/10) 100.00% (8/8)
test/Token.sol 100.00% (4/4) 100.00% (2/2) 100.00% (0/0) 100.00% (2/2)
test/Token2.sol 100.00% (4/4) 100.00% (2/2) 100.00% (0/0) 100.00% (2/2)
Total 100.00% (167/167) 100.00% (144/144) 100.00% (10/10) 100.00% (26/26)

Branch Coverage Breakdown (ERC20Internals.sol):

Branch ID Function Condition Test Method
BRDA:24 _balanceOf iszero(owner) Direct call with address(0)
BRDA:42 _approve iszero(owner) Test harness
BRDA:47 _approve iszero(spender) Direct approve(address(0))
BRDA:74 _allowance iszero(owner) Direct call with address(0)
BRDA:94 _transfer iszero(from) Test harness
BRDA:100 _transfer iszero(to) transfer(address(0))
BRDA:118 _transfer lt(fromBalance, value) Insufficient balance test
BRDA:136 _mint iszero(account) mint(address(0))
BRDA:160 _burn iszero(account) burn(address(0))
BRDA:194 _spendAllowance lt(currentAllowance, value) Exceed allowance test

Vulnerability Discovery Through Coverage

During the process of achieving 100% branch coverage, we discovered a critical execution path flaw that reveals a dead code vulnerability in the _transfer function.

Discovery Context:

When attempting to test the iszero(from) branch in _transfer via transferFrom(address(0), to, value), our test expected:

vm.expectRevert(
abi.encodeWithSelector(
IERC20Errors.ERC20InvalidSender.selector,
address(0)
)
);
token.transferFrom(address(0), to, 100e18);

Actual Result:

Error != expected error:
ERC20InsufficientAllowance(0x7FA9..., 0, 100e18)
!=
ERC20InvalidSender(0x0000...0000)

Root Cause Analysis:

The transferFrom function executes _spendAllowance BEFORE _transfer:

function transferFrom(address from, address to, uint256 value) external returns (bool) {
_spendAllowance(from, msg.sender, value); // ← REVERTS HERE FIRST
_transfer(from, to, value); // ← NEVER REACHED
return true;
}

When from = address(0):

  1. _spendAllowance checks allowance[address(0)][msg.sender]

  2. This allowance is always 0 (can never be set - no private key for address(0))

  3. 0 < value → reverts with ERC20InsufficientAllowance

  4. _transfer is NEVER called

Implications:

This means the iszero(from) check in _transfer (lines 94-97) is unreachable dead code via the public interface. This has several security implications documented in finding [H-5].


Findings


High


[H-1] Unrestricted mint Function Allows Any Address to Create Unlimited Tokens

Description:

Both Token.sol and Token2.sol expose the mint function as public without any access control. This allows any external address to mint an arbitrary amount of tokens to any address, completely destroying tokenomics.

// Token.sol (Token-0x based) - VULNERABLE
function mint(address account, uint256 value) public {
_mint(account, value);
}
// Token2.sol (OpenZeppelin based) - IDENTICAL VULNERABILITY
function mint(address account, uint256 value) public {
_mint(account, value);
}

Impact:

  • Any attacker can mint unlimited tokens

  • Complete destruction of token value

  • Protocol becomes worthless immediately upon deployment

  • All DeFi integrations are compromised

Proof of Concept:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test, console} from "forge-std/Test.sol";
import {Token} from "../src/Token.sol";
contract MintExploitTest is Test {
Token public token;
address public attacker = makeAddr("attacker");
address public legitimateHolder = makeAddr("holder");
function setUp() public {
token = new Token();
vm.prank(address(this));
token.mint(legitimateHolder, 1000e18);
}
function test_attackerCanMintUnlimitedTokens() public {
// Attacker starts with 0 tokens
assertEq(token.balanceOf(attacker), 0);
// Attacker mints 1 trillion tokens
vm.prank(attacker);
token.mint(attacker, 1_000_000_000_000e18);
// Attacker now owns 1 trillion tokens
assertEq(token.balanceOf(attacker), 1_000_000_000_000e18);
console.log("Attacker balance:", token.balanceOf(attacker));
}
}

Recommended Mitigation:

import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
contract Token is ERC20, Ownable {
constructor() ERC20("Token", "TKN") Ownable(msg.sender) {}
function mint(address account, uint256 value) public onlyOwner {
_mint(account, value);
}
}

[H-2] Unrestricted burn Function Allows Any Address to Destroy Any User's Tokens

Description:

The burn function accepts an arbitrary account parameter and burns tokens from that address without any authorization check. Any attacker can burn any other user's entire token balance.

// Both Token.sol and Token2.sol - VULNERABLE
function burn(address account, uint256 value) public {
_burn(account, value);
}

This is a fundamental violation of the ERC20 security model. Compare with OpenZeppelin's ERC20Burnable:

// OpenZeppelin's CORRECT implementation
function burn(uint256 value) public virtual {
_burn(_msgSender(), value); // Only burns CALLER's tokens
}
function burnFrom(address account, uint256 value) public virtual {
_spendAllowance(account, _msgSender(), value); // REQUIRES APPROVAL
_burn(account, value);
}

Impact:

  • Any user can burn any other user's complete token balance

  • Complete theft of value through destruction

  • No recourse for victims

  • Griefing attacks are trivially easy

Proof of Concept:

function test_attackerCanBurnAnyoneTokens() public {
address alice = makeAddr("alice");
address mallory = makeAddr("mallory");
// Alice has 1000 tokens
token.mint(alice, 1000e18);
assertEq(token.balanceOf(alice), 1000e18);
// Mallory has ZERO approval
assertEq(token.allowance(alice, mallory), 0);
// Mallory burns ALL of Alice's tokens anyway
vm.prank(mallory);
token.burn(alice, 1000e18);
// Alice lost everything
assertEq(token.balanceOf(alice), 0);
}

Recommended Mitigation:

/// @notice Burns tokens from caller's own account
function burn(uint256 value) public {
_burn(msg.sender, value);
}
/// @notice Burns tokens from another account (requires approval)
function burnFrom(address account, uint256 value) public {
_spendAllowance(account, msg.sender, value);
_burn(account, value);
}

[H-3] _mint Function in ERC20Internals.sol Does Not Emit Transfer Event

Description:

The _mint function in ERC20Internals.sol is missing the required Transfer event emission. According to EIP-20, all token transfers including mints (from address(0)) MUST emit a Transfer event.

// ERC20Internals.sol - MISSING EVENT
function _mint(address account, uint256 value) internal {
assembly ("memory-safe") {
if iszero(account) {
mstore(0x00, shl(224, 0xec442f05))
mstore(add(0x00, 4), 0x00)
revert(0x00, 0x24)
}
let ptr := mload(0x40)
let balanceSlot := _balances.slot
let supplySlot := _totalSupply.slot
let supply := sload(supplySlot)
sstore(supplySlot, add(supply, value))
mstore(ptr, account)
mstore(add(ptr, 0x20), balanceSlot)
let accountBalanceSlot := keccak256(ptr, 0x40)
let accountBalance := sload(accountBalanceSlot)
sstore(accountBalanceSlot, add(accountBalance, value))
// ❌ MISSING: Transfer event from address(0) to account
}
}

Compare with _transfer which correctly emits the event:

// In _transfer - CORRECT
mstore(ptr, value)
log3(ptr, 0x20, 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef, from, to)

Impact:

  • Non-compliant with EIP-20 standard

  • Block explorers (Etherscan) won't track minted tokens

  • Wallet applications won't detect new token balances

  • DeFi protocols relying on events will fail to track mints

  • Subgraphs and indexers will have incorrect data

Recommended Mitigation:

Add event emission to _mint:

// Add to end of _mint function assembly block
mstore(ptr, value)
log3(
ptr,
0x20,
0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef, // Transfer topic
0x00, // from: address(0)
account // to: recipient
)

[H-4] _burn Function in ERC20Internals.sol Does Not Emit Transfer Event

Description:

Similar to _mint, the _burn function is missing the required Transfer event emission. Burns should emit a Transfer event to address(0).

// ERC20Internals.sol - MISSING EVENT
function _burn(address account, uint256 value) internal {
assembly ("memory-safe") {
if iszero(account) {
mstore(0x00, shl(224, 0x96c6fd1e))
mstore(add(0x00, 4), 0x00)
revert(0x00, 0x24)
}
let ptr := mload(0x40)
let balanceSlot := _balances.slot
let supplySlot := _totalSupply.slot
let supply := sload(supplySlot)
sstore(supplySlot, sub(supply, value))
mstore(ptr, account)
mstore(add(ptr, 0x20), balanceSlot)
let accountBalanceSlot := keccak256(ptr, 0x40)
let accountBalance := sload(accountBalanceSlot)
sstore(accountBalanceSlot, sub(accountBalance, value))
// ❌ MISSING: Transfer event from account to address(0)
}
}

Impact:

  • Non-compliant with EIP-20 standard

  • Burns are invisible to off-chain systems

  • Token supply tracking breaks

Recommended Mitigation:

Add event emission to _burn:

// Add to end of _burn function assembly block
mstore(ptr, value)
log3(
ptr,
0x20,
0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef, // Transfer topic
account, // from: account being burned
0x00 // to: address(0)
)

[H-5] Unreachable Dead Code in _transfer Creates False Sense of Security

Discovered During: 100% Branch Coverage Testing

Description:

The _transfer function contains a zero-address sender check (lines 94-97) that is unreachable via the public interface. This was discovered when our test for transferFrom(address(0), to, value) reverted with ERC20InsufficientAllowance instead of the expected ERC20InvalidSender.

// ERC20Internals.sol - UNREACHABLE CODE
function _transfer(address from, address to, uint256 value) internal {
assembly ("memory-safe") {
// This check is NEVER reached via public interface
if iszero(from) {
mstore(0x00, shl(224, 0x96c6fd1e)) // ERC20InvalidSender
mstore(add(0x00, 4), 0x00)
revert(0x00, 0x24)
}
// ...
}
}

Execution Path Analysis:

transferFrom(address(0), to, value)
├─► \_spendAllowance(address(0), msg.sender, value)
│ │
│ └─► allowance[address(0)][msg.sender] = 0 (always)
│ │
│ └─► 0 < value → REVERT ERC20InsufficientAllowance ❌
└─► \_transfer(address(0), to, value) // NEVER REACHED

Why This Matters:

  1. False Security Assumption - Developers may believe _transfer validates the from address, but this validation never executes for transferFrom

  2. Inconsistent Error Messages - Users see ERC20InsufficientAllowance when the real issue is an invalid sender address

  3. Defense-in-Depth Failure - The layered security check provides no actual protection

  4. Inherited Contract Risk - Contracts inheriting ERC20 and calling _transfer directly could bypass allowance checks entirely

Proof of Concept:

function test_transferFromZeroAddressHitsAllowanceFirst() public {
address to = makeAddr("to");
// Expected: ERC20InvalidSender (the logical error)
// Actual: ERC20InsufficientAllowance (allowance check fails first)
vm.expectRevert(
abi.encodeWithSelector(
IERC20Errors.ERC20InsufficientAllowance.selector,
address(this), // msg.sender
0, // allowance from address(0)
100e18 // requested amount
)
);
token.transferFrom(address(0), to, 100e18);
}

Also Affects: The iszero(owner) check in _approve (lines 42-45) is similarly unreachable since approve() uses msg.sender which can never be address(0).

Recommended Mitigation:

Option 1 - Reorder checks in transferFrom:

function transferFrom(address from, address to, uint256 value) external returns (bool) {
// Validate addresses FIRST
if (from == address(0)) revert ERC20InvalidSender(address(0));
if (to == address(0)) revert ERC20InvalidReceiver(address(0));
_spendAllowance(from, msg.sender, value);
_transfer(from, to, value);
return true;
}

Option 2 - Document as intentional defense-in-depth:

/// @dev This check is unreachable via transferFrom() due to _spendAllowance
/// failing first. It exists as defense-in-depth for inherited contracts
/// that may call _transfer() directly.
if iszero(from) { ... }

Option 3 - Remove dead code to reduce bytecode size and avoid confusion.


Medium


[M-1] _mint Function Does Not Check for Integer Overflow on totalSupply

Description:

The _mint function in ERC20Internals.sol uses raw Yul assembly which bypasses Solidity 0.8+'s built-in overflow protection. The addition to totalSupply does not check for overflow.

function _mint(address account, uint256 value) internal {
assembly ("memory-safe") {
// ...
let supply := sload(supplySlot)
sstore(supplySlot, add(supply, value)) // ❌ No overflow check
// ...
let accountBalance := sload(accountBalanceSlot)
sstore(accountBalanceSlot, add(accountBalance, value)) // ❌ No overflow check
}
}

In Solidity 0.8+, this would be safe:

_totalSupply += value; // Would revert on overflow

But in Yul, add(supply, value) silently wraps on overflow.

Impact:

  • If totalSupply + value > type(uint256).max, the value wraps to a small number

  • Could result in accounting inconsistencies

  • Breaks invariant: totalSupply == sum(all balances)

Recommended Mitigation:

Add overflow checks in Yul:

let supply := sload(supplySlot)
let newSupply := add(supply, value)
// Check for overflow
if lt(newSupply, supply) {
revert(0, 0)
}
sstore(supplySlot, newSupply)

[M-2] _burn Function Does Not Check for Integer Underflow

Description:

The _burn function performs subtraction without checking for underflow. The totalSupply subtraction happens before balance validation.

function _burn(address account, uint256 value) internal {
assembly ("memory-safe") {
// ...
let supply := sload(supplySlot)
sstore(supplySlot, sub(supply, value)) // ❌ No underflow check - happens FIRST
// ...
let accountBalance := sload(accountBalanceSlot)
sstore(accountBalanceSlot, sub(accountBalance, value)) // ❌ No underflow check
}
}

Impact:

  • If burning more than totalSupply, the value wraps to a massive number

  • State corruption before potential revert

Recommended Mitigation:

Add underflow checks - validate balance FIRST:

// Check balance first
let accountBalance := sload(accountBalanceSlot)
if lt(accountBalance, value) {
// Revert: insufficient balance
mstore(0x00, shl(224, 0xe450d38c))
mstore(add(0x00, 4), account)
mstore(add(0x00, 0x24), accountBalance)
mstore(add(0x00, 0x44), value)
revert(0x00, 0x64)
}
// Then safely subtract
sstore(accountBalanceSlot, sub(accountBalance, value))
sstore(supplySlot, sub(supply, value))

[M-3] _spendAllowance Does Not Handle type(uint256).max Infinite Approval Pattern

Description:

The ERC20 standard convention allows setting allowance to type(uint256).max to indicate infinite approval (allowance that doesn't decrease on spending). The _spendAllowance function in ERC20Internals.sol does not implement this pattern.

function _spendAllowance(address owner, address spender, uint256 value) internal virtual {
assembly ("memory-safe") {
// ... compute allowance slot ...
let currentAllowance := sload(allowanceSlot)
if lt(currentAllowance, value) {
revert(0, 0x64)
}
sstore(allowanceSlot, sub(currentAllowance, value)) // Always decreases
}
}

Compare with OpenZeppelin's implementation:

function _spendAllowance(address owner, address spender, uint256 value) internal virtual {
uint256 currentAllowance = allowance(owner, spender);
if (currentAllowance != type(uint256).max) { // Skip deduction for infinite approval
if (currentAllowance < value) {
revert ERC20InsufficientAllowance(spender, currentAllowance, value);
}
unchecked {
_approve(owner, spender, currentAllowance - value, false);
}
}
}

Impact:

  • Gas inefficiency for infinite approvals (unnecessary storage writes)

  • Deviation from expected ERC20 behavior

  • May break DeFi protocol integrations expecting infinite approval pattern

Recommended Mitigation:

// Skip if infinite approval
if iszero(eq(currentAllowance, not(0))) { // not(0) == type(uint256).max
if lt(currentAllowance, value) {
revert(0, 0x64)
}
sstore(allowanceSlot, sub(currentAllowance, value))
}

[M-4] Inconsistent Error Semantics Between Public and Internal Functions

Discovered During: 100% Branch Coverage Testing

Description:

The error thrown when calling transferFrom(address(0), to, value) is semantically incorrect. The user receives ERC20InsufficientAllowance when the actual problem is an invalid sender address.

Expected Behavior:

transferFrom(address(0), to, 100e18) → ERC20InvalidSender(address(0))

Actual Behavior:

transferFrom(address(0), to, 100e18) → ERC20InsufficientAllowance(msg.sender, 0, 100e18)

Impact:

  • Confusing error messages for developers and users

  • Debugging difficulty when integrating with the token

  • Potential for incorrect error handling in calling contracts

Recommended Mitigation:

Add explicit address validation at the start of transferFrom:

function transferFrom(address from, address to, uint256 value) external returns (bool) {
if (from == address(0)) revert ERC20InvalidSender(address(0));
if (to == address(0)) revert ERC20InvalidReceiver(address(0));
_spendAllowance(from, msg.sender, value);
_transfer(from, to, value);
return true;
}

Low


[L-1] ERC20.sol Does Not Explicitly Implement IERC20 Interface

Description:

The ERC20.sol contract implements all ERC20 functions but does not explicitly inherit from IERC20. This means there's no compile-time guarantee of interface compliance.

// Current implementation
contract ERC20 is IERC20Errors, ERC20Internals {
// Implements ERC20 functions but doesn't declare IERC20
}
// Should be
contract ERC20 is IERC20, IERC20Errors, ERC20Internals {
// Now compiler enforces interface compliance
}

Recommended Mitigation: Explicitly inherit from IERC20.


[L-2] Token Contracts Hardcode Name and Symbol

Description:

Both Token.sol and Token2.sol hardcode "Token" and "TKN" as name and symbol, limiting reusability.

constructor() ERC20("Token", "TKN") {}

Recommended Mitigation:

constructor(string memory name_, string memory symbol_) ERC20(name_, symbol_) {}

[L-3] No Maximum Supply Cap

Description:

Neither token implementation enforces a maximum supply cap, allowing unlimited minting (after access control is fixed).

Recommended Mitigation:

uint256 public constant MAX_SUPPLY = 1_000_000_000e18;
error ExceedsMaxSupply();
function mint(address account, uint256 value) public onlyOwner {
if (totalSupply() + value > MAX_SUPPLY) revert ExceedsMaxSupply();
_mint(account, value);
}

[L-4] Silent Reverts in _balanceOf and _allowance

Description:

The _balanceOf and _allowance functions in ERC20Internals.sol revert with empty error data when given zero addresses:

function _balanceOf(address owner) internal view returns (uint256) {
assembly {
if iszero(owner) {
revert(0, 0) // ❌ Silent revert - no error message
}
// ...
}
}

Recommended Mitigation: Use custom error selectors consistently for better debugging.


Informational


[I-1] Token-0x Base Lacks Meta-Transaction Support (Context)

Token-0x's ERC20 uses msg.sender directly instead of _msgSender(), preventing ERC2771 meta-transaction compatibility.


[I-2] Missing NatSpec Documentation

All contracts lack comprehensive NatSpec documentation (@notice, @dev, @param, @return) which is important for code readability and automated documentation generation.


[I-3] Consider Implementing ERC20Permit (EIP-2612)

ERC20Permit allows approvals via signatures, enabling gasless approvals and better UX through meta-transactions.


[I-4] No Pausability Mechanism

Neither implementation includes emergency pause functionality which could be useful for emergency situations.


[I-5] Storage Layout Documentation Missing

For upgradeable patterns and Yul code verification, the storage layout should be documented.


[I-6] Dead Code Should Be Documented or Removed

Discovered During: 100% Branch Coverage Testing

The unreachable branches identified in [H-5] represent dead code that:

  • Increases bytecode size unnecessarily

  • May confuse auditors and developers

  • Provides false security assurance

If kept as defense-in-depth, these branches should be clearly documented with NatSpec explaining their purpose and why they are unreachable via the public interface.


Gas


[G-1] Use unchecked Block for Arithmetic Where Overflow Is Impossible

After verifying sufficient balance, the subtraction cannot underflow. Using unchecked saves gas.

Gas Savings: ~20 gas per transferFrom


[G-2] Consider Caching Storage Reads in Allowance Checks

Consider caching storage reads when checking and updating allowances.


[G-3] Optimize Storage Reads in _transfer

Ensure no redundant storage reads exist in the transfer logic.


Appendix A: Test Coverage Analysis

Initial Coverage (Before Enhancement)

File Lines Statements Branches Functions
src/ERC20.sol 100.00% (24/24) 100.00% (17/17) 100.00% (0/0) 100.00% (10/10)
src/helpers/ERC20Internals.sol 93.70% (119/127) 93.28% (111/119) 60.00% (6/10) 100.00% (8/8)

Test Results: 24 tests passed, 0 failed

Final Coverage (After Enhancement)

File Lines Statements Branches Functions
src/ERC20.sol 100.00% (24/24) 100.00% (17/17) 100.00% (0/0) 100.00% (10/10)
src/helpers/ERC20Internals.sol 100.00% (127/127) 100.00% (119/119) 100.00% (10/10) 100.00% (8/8)
Total 100.00% (167/167) 100.00% (144/144) 100.00% (10/10) 100.00% (26/26)

Test Results: 60 tests passed, 0 failed

Gas Comparison (Token-0x vs OpenZeppelin)

Operation Token-0x OpenZeppelin Savings
mint 57,871 gas 60,448 gas 4.3%
burn 45,980 gas 50,051 gas 8.1%
transfer 92,254 gas 92,529 gas 0.3%
transferFrom 102,119 gas 105,715 gas 3.4%
allowance 90,942 gas 93,603 gas 2.8%

Appendix B: Summary of Issues by Contract

Contract Issue Severity Affects Both?
Token.sol / Token2.sol Unrestricted mint HIGH ✅ Yes
Token.sol / Token2.sol Unrestricted burn (any address) HIGH ✅ Yes
ERC20Internals.sol Missing mint Transfer event HIGH Token.sol only
ERC20Internals.sol Missing burn Transfer event HIGH Token.sol only
ERC20Internals.sol Unreachable dead code in _transfer HIGH Token.sol only
ERC20Internals.sol Mint overflow risk MEDIUM Token.sol only
ERC20Internals.sol Burn underflow risk MEDIUM Token.sol only
ERC20Internals.sol No infinite approval pattern MEDIUM Token.sol only
ERC20.sol Inconsistent error semantics MEDIUM Token.sol only

Appendix C: Files Reviewed

File Status Critical Issues
Token.sol ✅ Reviewed H-1, H-2
Token2.sol ✅ Reviewed H-1, H-2
ERC20.sol ✅ Reviewed L-1, M-4
ERC20Internals.sol ✅ Reviewed H-3, H-4, H-5, M-1, M-2, M-3
IERC20Errors.sol ✅ Reviewed None
IERC20.sol ✅ Reviewed None

Appendix D: Test Harness for Complete Coverage

To achieve 100% branch coverage, a test harness was required to expose internal functions:

/// @title TokenHarness
/// @notice Exposes internal ERC20 functions for complete branch coverage testing
/// @dev Required to test branches unreachable via public interface
contract TokenHarness is Token {
/// @notice Exposes internal _approve for testing zero-owner branch
function exposed_approve(address owner, address spender, uint256 value) external {
_approve(owner, spender, value);
}
/// @notice Exposes internal _transfer for testing zero-from branch
function exposed_transfer(address from, address to, uint256 value) external {
_transfer(from, to, value);
}
}

Tests Added for Previously Unreachable Branches:

function test_approveZeroOwnerReverts() public {
vm.expectRevert(
abi.encodeWithSelector(IERC20Errors.ERC20InvalidApprover.selector, address(0))
);
tokenHarness.exposed_approve(address(0), spender, 100e18);
}
function test_transferZeroFromReverts() public {
vm.expectRevert(
abi.encodeWithSelector(IERC20Errors.ERC20InvalidSender.selector, address(0))
);
tokenHarness.exposed_transfer(address(0), to, 100e18);
}

Audit Completed By</p>
Rensley R.</p>
Solid Vyper-Pilled Security Researcher</p>
Organization</p>
NFTeria Inc</p>
December 10, 2025</p>
✓ Complete End-to-End Audit with 100% Test Coverage</p>

Support

FAQs

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

Give us feedback!