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.
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))
@>
}
}
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))
@>
}
}
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");
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");
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)