Token-0x

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

L01. _mint and _burn don't emit transfer event

Author Revealed upon completion

Root + Impact

Description

  • In a standard ERC20 token, minting new tokens must emit a Transfer event from the zero address to the recipient, and burning tokens must emit a Transfer event from the sender to the zero address. These events allow wallets, explorers, and DeFi protocols to track token movements and total supply changes.

  • The ERC20 specification explicitly states:
    “A token contract which creates new tokens SHOULD trigger a Transfer event with the _from address set to 0x0 when tokens are created.”

  • In the current implementation, the _mint and _burn functions update balances and the total supply using inline assembly but do not emit any Transfer events, breaking ERC20 compliance. This can lead to inconsistencies in external applications that rely on events to track token balances.

// Root cause in the codebase with @> marks to highlight the relevant section
function _mint(address account, uint256 value) internal {
assembly ("memory-safe") {
if iszero(account) {
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(address(0), account, value)
}
}
function _burn(address account, uint256 value) internal {
assembly ("memory-safe") {
if iszero(account) {
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(account, address(0), value)
}
}

Risk

Likelihood:

  • This occurs whenever _mint or _burn is executed, including during token distribution, rewards, or supply adjustments.

  • Any integration relying on ERC20 events rather than direct state reads will encounter this behavior.

Impact:

  • Wallets, explorers, and indexers may fail to display correct balances or supply changes.

  • DeFi protocols relying on Transfer events for accounting or hooks may malfunction or reject the token.

Proof of Concept

Explanation:
This test shows that minting and burning update balances and total supply correctly, but no Transfer event is emitted. Off-chain systems observing only events cannot detect these supply changes.

function test_mintBurn_noTransferEvent() public {
address user = makeAddr("user");
// Mint without Transfer event
vm.recordLogs();
token.mint(user, 100);
Vm.Log[] memory logs = vm.getRecordedLogs();
bool found;
for (uint256 i = 0; i < logs.length; i++) {
if (logs[i].topics[0] == keccak256("Transfer(address,address,uint256)")) {
found = true;
}
}
assertFalse(found, "Transfer event emitted during mint");
// Burn without Transfer event
vm.recordLogs();
token.burn(user, 50);
logs = vm.getRecordedLogs();
found = false;
for (uint256 i = 0; i < logs.length; i++) {
if (logs[i].topics[0] == keccak256("Transfer(address,address,uint256)")) {
found = true;
}
}
assertFalse(found, "Transfer event emitted during burn");
}

Recommended Mitigation

Emit ERC20-compliant Transfer events inside _mint and _burn.

Function _mint(address account, uint256 value)
+ log3(0x00, 0x20, TRANSFER_TOPIC, 0x0, account)
Function _burn(address account, uint256 value)
+ log3(0x00, 0x20, TRANSFER_TOPIC, account, 0x0)

Support

FAQs

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

Give us feedback!