Token-0x

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

No Transfer event on mint/burn

Author Revealed upon completion

Description

  • ERC‑20 tokens are expected to emit Transfer(address(0), to, amount) on mint and Transfer(from, address(0), amount) on burn. This is the de‑facto standard followed by OpenZeppelin’s ERC‑20 implementation and relied upon by indexers, bridges, analytics, and many DeFi protocols to track supply changes and token movements.

  • In this codebase, _mint and _burn do not emit any Transfer events. As a result, off‑chain systems that watch for supply changes via the canonical Transfer from/to the zero address will miss mints and burns, leading to broken accounting and desynchronized views of total supply and balances.

// src/helpers/ERC20Internals.sol (excerpts)
// @> _mint: increases storage values but does not emit Transfer(0x0, account, value)
function _mint(address account, uint256 value) internal {
assembly ("memory-safe") {
if iszero(account) {
// revert
}
// @> totalSupply += value
// @> balance[account] += value
// @> Missing: log Transfer(address(0), account, value)
}
}
// @> _burn: decreases storage values but does not emit Transfer(account, 0x0, value)
function _burn(address account, uint256 value) internal {
assembly ("memory-safe") {
if iszero(account) {
// revert
}
// @> totalSupply -= value
// @> balance[account] -= value
// @> Missing: log Transfer(account, address(0), value)
}
}

Risk

Likelihood: High

  • Many downstream systems (indexers, bridges, DEX analytics) rely on Transfer events to detect mints/burns as movements to/from 0x0000000000000000000000000000000000000000.

  • This contract already emits Transfer for normal transfers, so integrators will assume mint/burn also emit the canonical events; they won’t add custom handling.

Impact: Medium

  • Accounting/Indexing breakage: Supply changes are invisible to tools that don’t read storage directly; dashboards, portfolio trackers, bridges, and rewards systems may show incorrect totals.

  • Protocol incompatibility: Automated pipelines that key off Transfer(0x0, …) or Transfer(…, 0x0) will fail to react to mint/burns, potentially causing operational incidents (e.g., unreflected emissions, stuck bridge flows).

Proof of Concept

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

  • Run forge test --mp poc2 -vvvv.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {Vm} from "forge-std/Vm.sol";
import {Token} from "./Token.sol";
contract MintBurnNoTransferEventTest is Test {
Token internal token;
// keccak256("Transfer(address,address,uint256)")
bytes32 constant TRANSFER_TOPIC =
0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef;
function setUp() public {
token = new Token();
}
function test_mint_doesNotEmitTransferZeroAddress() public {
address alice = address(0xA11CE);
vm.recordLogs();
token.mint(alice, 50 ether);
Vm.Log[] memory logs = vm.getRecordedLogs();
// Assert: There is NO Transfer(0x0, alice, 50 ether) event
assertFalse(_hasZeroAddressTransfer(logs, address(0), alice), "BUG: expected no Transfer(0x0,alice)");
}
function test_burn_doesNotEmitTransferZeroAddress() public {
address alice = address(0xA11CE);
token.mint(alice, 50 ether);
vm.recordLogs();
token.burn(alice, 10 ether);
Vm.Log[] memory logs = vm.getRecordedLogs();
// Assert: There is NO Transfer(alice, 0x0, 10 ether) event
assertFalse(_hasZeroAddressTransfer(logs, alice, address(0)), "BUG: expected no Transfer(alice,0x0)");
}
function test_normal_transfer_doesEmitTransfer() public {
address alice = address(0xA11CE);
address bob = address(0xB0B);
token.mint(alice, 5 ether);
vm.prank(alice);
vm.recordLogs();
token.transfer(bob, 1 ether);
Vm.Log[] memory logs = vm.getRecordedLogs();
// Assert: A normal Transfer(alice,bob,1 ether) WAS emitted
assertTrue(_hasTransfer(logs, alice, bob), "expected Transfer(alice,bob)");
}
// --- helpers ---
function _hasZeroAddressTransfer(Vm.Log[] memory logs, address from, address to)
internal
pure
returns (bool found)
{
// Checks for Transfer with from or to equal the zero address
for (uint256 i = 0; i < logs.length; i++) {
Vm.Log memory lg = logs[i];
if (lg.topics.length == 3 && lg.topics[0] == TRANSFER_TOPIC) {
address tFrom = address(uint160(uint256(lg.topics[1])));
address tTo = address(uint160(uint256(lg.topics[2])));
if (tFrom == from && tTo == to) {
found = true;
break;
}
}
}
}
function _hasTransfer(Vm.Log[] memory logs, address from, address to)
internal
pure
returns (bool found)
{
for (uint256 i = 0; i < logs.length; i++) {
Vm.Log memory lg = logs[i];
if (lg.topics.length == 3 && lg.topics[0] == TRANSFER_TOPIC) {
address tFrom = address(uint160(uint256(lg.topics[1])));
address tTo = address(uint160(uint256(lg.topics[2])));
if (tFrom == from && tTo == to) {
found = true;
break;
}
}
}
}
}

Output:

[⠊] Compiling...
No files changed, compilation skipped
Ran 3 tests for test/poc2.t.sol:MintBurnNoTransferEventTest
[PASS] test_burn_doesNotEmitTransferZeroAddress() (gas: 57687)
Traces:
[57687] MintBurnNoTransferEventTest::test_burn_doesNotEmitTransferZeroAddress()
├─ [45004] Token::mint(0x00000000000000000000000000000000000A11cE, 50000000000000000000 [5e19])
│ └─ ← [Stop]
├─ [0] VM::recordLogs()
│ └─ ← [Return]
├─ [1270] Token::burn(0x00000000000000000000000000000000000A11cE, 10000000000000000000 [1e19])
│ └─ ← [Stop]
├─ [0] VM::getRecordedLogs()
│ └─ ← [Return] []
├─ [0] VM::assertFalse(false, "BUG: expected no Transfer(alice,0x0)") [staticcall]
│ └─ ← [Return]
└─ ← [Stop]
[PASS] test_mint_doesNotEmitTransferZeroAddress() (gas: 55501)
Traces:
[55501] MintBurnNoTransferEventTest::test_mint_doesNotEmitTransferZeroAddress()
├─ [0] VM::recordLogs()
│ └─ ← [Return]
├─ [45004] Token::mint(0x00000000000000000000000000000000000A11cE, 50000000000000000000 [5e19])
│ └─ ← [Stop]
├─ [0] VM::getRecordedLogs()
│ └─ ← [Return] []
├─ [0] VM::assertFalse(false, "BUG: expected no Transfer(0x0,alice)") [staticcall]
│ └─ ← [Return]
└─ ← [Stop]
[PASS] test_normal_transfer_doesEmitTransfer() (gas: 85567)
Traces:
[85567] MintBurnNoTransferEventTest::test_normal_transfer_doesEmitTransfer()
├─ [45004] Token::mint(0x00000000000000000000000000000000000A11cE, 5000000000000000000 [5e18])
│ └─ ← [Stop]
├─ [0] VM::prank(0x00000000000000000000000000000000000A11cE)
│ └─ ← [Return]
├─ [0] VM::recordLogs()
│ └─ ← [Return]
├─ [25293] Token::transfer(0x0000000000000000000000000000000000000B0b, 1000000000000000000 [1e18])
│ ├─ emit Transfer(from: 0x00000000000000000000000000000000000A11cE, to: 0x0000000000000000000000000000000000000B0b, value: 1000000000000000000 [1e18])
│ └─ ← [Return] true
├─ [0] VM::getRecordedLogs()
│ └─ ← [Return] [([0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef, 0x00000000000000000000000000000000000000000000000000000000000a11ce, 0x0000000000000000000000000000000000000000000000000000000000000b0b], 0x0000000000000000000000000000000000000000000000000de0b6b3a7640000, 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f)]
├─ [0] VM::assertTrue(true, "expected Transfer(alice,bob)") [staticcall]
│ └─ ← [Return]
└─ ← [Stop]
Suite result: ok. 3 passed; 0 failed; 0 skipped; finished in 8.17ms (4.09ms CPU time)
Ran 1 test suite in 125.67ms (8.17ms CPU time): 3 tests passed, 0 failed, 0 skipped (3 total tests)

Recommended Mitigation

  • Emit the standard Transfer events inside _mint and _burn.

  • In this codebase, events are already emitted via log3 in assembly for _transfer; replicate that pattern for mint/burn.

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))
+ // Emit Transfer(address(0), account, value)
+ mstore(ptr, value)
+ log3(
+ ptr,
+ 0x20,
+ 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef, // keccak256("Transfer(address,address,uint256)")
+ 0x0000000000000000000000000000000000000000000000000000000000000000, // from: zero address
+ account // to
+ )
}
}
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))
+ // Emit Transfer(account, address(0), value)
+ mstore(ptr, value)
+ log3(
+ ptr,
+ 0x20,
+ 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef, // Transfer topic
+ account, // from
+ 0x0000000000000000000000000000000000000000000000000000000000000000 // to: zero address
+ )
}
}

Support

FAQs

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

Give us feedback!