Root + Impact
Description
-
The ERC-20 specification (EIP-20) requires that every burn operation emit a Transfer event with the sender as the from address and the zero address (address(0)) as the to address. This serves as the canonical on-chain signal that tokens were destroyed and the token supply was reduced.entences
-
The _burn function reduces the account balance and total supply via inline assembly but does not emit the corresponding event. As a result, external indexers, block explorers, and monitoring systems cannot detect supply reductions or token destruction events.
function _burn(address account, uint256 value) internal {
assembly ("memory-safe") {
sstore(accountBalanceSlot, sub(accountBalance, value))
@>
}
}
Risk
Likelihood:
-
Every _burn execution changes state but emits zero events.
-
Any function calling _burn (deflationary burns, fee burns, admin burns) exhibits this 100% of the time.
Impact:
-
Invisible supply reduction: Explorers and analytics show outdated totalSupply values that don't match actual contract state.
-
Broken protocol logic: Deflationary mechanisms, fee trackers, or burn-triggered events fail to execute.
-
ERC‑20 non-compliance: Prevents integration with strict EIP‑20 protocols and exchange listings.
Proof of Concept
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {Vm} from "forge-std/Vm.sol";
import {ERC20} from "../src/ERC20.sol";
contract ERC20Mock is ERC20 {
constructor() ERC20("Test", "TST") {}
function mint(address to, uint256 amount) public { _mint(to, amount); }
function burn(address from, uint256 amount) public { _burn(from, amount); }
}
contract BurnEventTest is Test {
ERC20Mock public token;
address public alice = makeAddr("alice");
function setUp() public {
token = new ERC20Mock();
token.mint(alice, 1000);
}
function test_burn_missing_event() public {
vm.recordLogs();
token.burn(alice, 500);
Vm.Log[] memory entries = vm.getRecordedLogs();
assertEq(token.balanceOf(alice), 500);
assertEq(entries.length, 0, "Critical: Transfer event missing on burn");
}
}
Run:
forge test --match-test test_burn_missing_event -vvvv
Output:
[PASS] test_burn_missing_event() (gas: 24401)
├─ [10870] ERC20Mock::burn(alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6], 500)
│ └─ ← [Stop]
├─ [0] VM::getRecordedLogs() [staticcall]
│ └─ ← [Return] []
Recommended Mitigation
// src/helpers/ERC20Internals.sol
// In _burn function
sstore(accountBalanceSlot, sub(accountBalance, value))
+ // Emit Transfer(account, address(0), value)
+ mstore(0x00, value) // Store 'value' in memory for the non-indexed data
+ log3(
+ 0x00,
+ 0x20,
+ 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef, // keccak256("Transfer(address,address,uint256)")
+ account,
+ 0
+ )
}
}