Token-0x

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

_mint and _burn do not emit Transfer events, breaking ERC-20 event semantics

Author Revealed upon completion

ERC20Internals._mint and ERC20Internals._burn update _totalSupply and _balances but never emit the corresponding events.

Description

  • Normal behavior

    In a standard ERC20 token, minting and burning must emit Transfer events:

    • Mint:

      • totalSupply increases.

      • balance[account] increases.

      • Event: Transfer(address(0), account, value).

    • Burn:

      • totalSupply decreases.

      • balance[account] decreases.

      • Event: Transfer(account, address(0), value)

    Many tools (explorers, indexers, DeFi dashboards) reconstruct balances and supply purely by streaming Transfer events, assuming that:

    • Every balance change is reflected in a Transfer.

    • Mints and burns are encoded as transfers from/to the zero address.


  • Issue

    This implementation correctly updates _totalSupply and balances in _mint and _burn, but it never emits the Transfer events that represent those changes. As a result:


    • Minted tokens appear in balances and totalSupply() but no corresponding Transfer(0, account, value) log exists.

    • Burned tokens disappear from balances and totalSupply() but no Transfer(account, 0, value) log exists.


    This silently breaks event-based accounting and any contract or off-chain system that relies on Transfer logs to track token flows.

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)
@> // totalSupply is increased
//@> sstore(supplySlot, add(supply, value))
mstore(ptr, account)
mstore(add(ptr, 0x20), balanceSlot)
let accountBalanceSlot := keccak256(ptr, 0x40)
let accountBalance := sload(accountBalanceSlot)
@> // account balance is increased
//@> sstore(accountBalanceSlot, add(accountBalance, value))
@> // No Transfer(address(0), account, value) event is emitted here
}
}
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)
@> // totalSupply is decreased
//@> sstore(supplySlot, sub(supply, value))
mstore(ptr, account)
mstore(add(ptr, 0x20), balanceSlot)
let accountBalanceSlot := keccak256(ptr, 0x40)
let accountBalance := sload(accountBalanceSlot)
@> // account balance is decreased
//@> sstore(accountBalanceSlot, sub(accountBalance, value))
@> // No Transfer(account, address(0), value) event is emitted here
}
}

Risk

Likelihood:

  • Whenever a privileged minter (or any derived contract) calls mint(account, value), totalSupply and balanceOf(account) change without any Transfer(address(0), account, value) event being emitted.

  • Whenever a burner function calls burn(account, value), totalSupply and balanceOf(account) change without any Transfer(account, address(0), value) event being emitted.

Impact:

  • Indexers, explorers, and accounting tools that reconstruct balances and supply from Transfer events will show incorrect data: minted tokens may appear “out of nowhere” or not at all, and burned tokens may still appear as circulating.

  • DeFi protocols that rely on event-based audits will be unable to track or validate supply changes, increasing the risk of undetected inflation or mis-accounting.

Proof of Concept

These tests show that calls to mint and burn do not emit the expected Transfer events. You can paste them into Token.t.sol.

function test_mintShouldEmitTransferFromZero() public {
address account = makeAddr("mintReceiver");
uint256 amount = 100e18;
// Expect a standard ERC20 mint event:
// Transfer(address(0), account, amount)
vm.expectEmit(true, true, false, true);
emit Transfer(address(0), account, amount);
token.mint(account, amount);
}
function test_burnShouldEmitTransferToZero() public {
address account = makeAddr("burnAccount");
uint256 amount = 50e18;
token.mint(account, amount);
assertEq(token.balanceOf(account), amount);
assertEq(token.totalSupply(), amount);
// Expect a standard ERC20 burn event:
// Transfer(account, address(0), amount)
vm.expectEmit(true, true, false, true);
emit Transfer(account, address(0), amount);
token.burn(account, amount);
}

Recommended Mitigation

Emit standard Transfer events in _mint and _burn using the same topic and layout as _transfer. The Transfer topic used in _transfer is already correct:

+ // Emit Transfer(address(0), account, value)
+ // Reuse ptr as data pointer for "value"
+ mstore(ptr, value)
+ log3(
+ ptr,
+ 0x20,
+ 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef,
+ 0, // from = address(0)
+ account // to = account
+ )

Support

FAQs

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

Give us feedback!