Description
According to the ERC20 standard (EIP-20), the Transfer event MUST be emitted when tokens are transferred, including when new tokens are created (minted) and when tokens are destroyed (burned):
"A token contract which creates new tokens SHOULD trigger a Transfer event with the _from address set to 0x0 when tokens are created."
"A token contract which destroys tokens SHOULD trigger a Transfer event with the _to address set to 0x0 when tokens are destroyed."
The _mint and _burn functions in ERC20Internals.sol successfully update balances and total supply but do not emit the required Transfer events. This breaks ERC20 compliance and prevents external systems from tracking token creation and destruction.
function _mint(address account, uint256 value) internal {
assembly ("memory-safe") {
let accountBalanceSlot := keccak256(ptr, 0x40)
let accountBalance := sload(accountBalanceSlot)
sstore(accountBalanceSlot, add(accountBalance, value))
}
}
function _burn(address account, uint256 value) internal {
assembly ("memory-safe") {
let accountBalanceSlot := keccak256(ptr, 0x40)
let accountBalance := sload(accountBalanceSlot)
sstore(accountBalanceSlot, sub(accountBalance, value))
}
}
Risk
Likelihood: High
-
Every call to _mint and _burn fails to emit the required event
-
This occurs in normal protocol operation, not edge cases
-
Any derived contract using these internal functions inherits this issue
Impact: Medium
-
DeFi protocols, block explorers, and indexers cannot track mints/burns
-
Wallet applications cannot display accurate transaction history
-
The Graph subgraphs and similar indexing services miss mint/burn events
-
Accounting and audit trails are incomplete
-
Token contract fails ERC20 compliance checks
-
Integration with protocols requiring event monitoring (bridges, yield aggregators) will fail
Proof of Concept
pragma solidity ^0.8.24;
import {Test, console} from "forge-std/Test.sol";
import {Token} from "./Token.sol";
contract MissingEventsTest is Test {
event Transfer(address indexed from, address indexed to, uint256 value);
Token public token;
function setUp() public {
token = new Token();
}
function test_Mint_NoTransferEvent() public {
address alice = makeAddr("alice");
vm.recordLogs();
token.mint(alice, 100e18);
assertEq(token.balanceOf(alice), 100e18);
console.log("VULNERABILITY: _mint succeeded but emitted NO Transfer event");
console.log("Expected: Transfer(address(0), alice, 100e18)");
console.log("Actual: No events emitted");
}
function test_Burn_NoTransferEvent() public {
address alice = makeAddr("alice");
token.mint(alice, 100e18);
vm.recordLogs();
token.burn(alice, 50e18);
assertEq(token.balanceOf(alice), 50e18);
console.log("VULNERABILITY: _burn succeeded but emitted NO Transfer event");
console.log("Expected: Transfer(alice, address(0), 50e18)");
console.log("Actual: No events emitted");
}
function test_Transfer_DoesEmitEvent() public {
address alice = makeAddr("alice");
address bob = makeAddr("bob");
token.mint(alice, 100e18);
vm.expectEmit(true, true, false, true);
emit Transfer(alice, bob, 50e18);
vm.prank(alice);
token.transfer(bob, 50e18);
}
}
Test Output:
[PASS] test_Mint_NoTransferEvent() (gas: 61084)
Logs:
VULNERABILITY: _mint succeeded but emitted NO Transfer event
[PASS] test_Burn_NoTransferEvent() (gas: 63239)
Logs:
VULNERABILITY: _burn succeeded but emitted NO Transfer event
Recommended Mitigation
Add the Transfer event emission to both _mint and _burn functions:
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) per ERC20 standard
+ mstore(ptr, value)
+ log3(ptr, 0x20, 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef, 0x00, account)
}
}
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) per ERC20 standard
+ mstore(ptr, value)
+ log3(ptr, 0x20, 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef, account, 0x00)
}
}