Description:
The ERC20 standard requires Transfer events to be emitted both when tokens are minted (from the zero address) and when they are burned (to the zero address). The Token-0x implementation correctly emits Transfer in _transfer, but does not emit any events in _mint and _burn.
This breaks the ERC20 event semantics: indexers, wallets, and DeFi protocols often rely on Transfer events to track token movements, including minting and burning.
Impact:
Block explorers and indexers will not see mint/burn operations, making supply tracking inaccurate.
Protocols that rely on Transfer events (e.g., to compute rewards, monitor emissions, or verify supply caps) will behave incorrectly or consider the token non‑compliant.
This significantly harms composability and integration with the existing ERC20 ecosystem.
It does not directly allow theft, but it breaks compatibility and can cause serious issues in production integrations.
Proof of Concept:
We use Foundry’s vm.recordLogs() to show that Token-0x fails to emit Transfer on mint, while OpenZeppelin does.
Token using Token-0x ERC20:
function test_mintDoesNotEmitTransferEvent() public {
address account = makeAddr("account");
vm.recordLogs();
token.mint(account, 100e18);
Vm.Log[] memory entries = vm.getRecordedLogs();
bytes32 transferTopic = keccak256("Transfer(address,address,uint256)");
bool found;
for (uint256 i = 0; i < entries.length; i++) {
if (entries[i].topics.length > 0 && entries[i].topics[0] == transferTopic) {
found = true;
break;
}
}
assertFalse(found, "Token-0x unexpectedly emitted Transfer on mint");
}
Token2 using OpenZeppelin ERC20:
function test_mintEmitsTransferEvent_OZ() public {
address account = makeAddr("account");
vm.recordLogs();
token.mint(account, 100e18);
Vm.Log[] memory entries = vm.getRecordedLogs();
bytes32 transferTopic = keccak256("Transfer(address,address,uint256)");
bool found;
for (uint256 i = 0; i < entries.length; i++) {
if (entries[i].topics.length > 0 && entries[i].topics[0] == transferTopic) {
found = true;
break;
}
}
assertTrue(found, "OpenZeppelin ERC20 did not emit Transfer on mint");
}
Equivalent tests can be written for _burn (expecting Transfer(account, address(0), value)), with the same result: Token-0x emits no event, OZ does.
Mitigation:
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);
+ let ptr := mload(0x40)
+ mstore(ptr, value)
+ log3(ptr, 0x20,
+ 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef,
+ 0x0,
+ 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);
+ let ptr := mload(0x40)
+ mstore(ptr, value)
+ log3(ptr, 0x20,
+ 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef,
+ account,
+ 0x0
+ )
}
}