Root + Impact
Description
-
According to EIP-20, the _burn() function must emit a Transfer event with the to address set to address(0) to signal token destruction. This allows external systems to track supply decreases and update circulating supply metrics in real-time.
-
The assembly implementation updates storage slots for balance and total supply but does not include the log3 instruction to emit the Transfer event. This creates a silent burning operation where tokens disappear from balances without any event trail, breaking compatibility with all ERC20-dependent infrastructure that monitors deflationary mechanisms.
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))
@>
}
}
Risk
Likelihood:
-
Every burning operation triggers this vulnerability whenever tokens are destroyed, affecting all deflationary mechanisms and buyback-and-burn programs from deployment onwards.
-
Analytics platforms and token trackers rely on Transfer events to calculate circulating supply, and missing burn events cause permanent inaccuracies in supply metrics displayed across all aggregators.
Impact:
-
DeFi protocols using the token for governance or collateral cannot accurately track voting power or liquidation thresholds, as circulating supply calculations diverge from actual on-chain state.
-
Market cap calculations become permanently incorrect on aggregators like CoinGecko and CoinMarketCap, displaying inflated supply numbers that misrepresent token value and mislead investors.
Proof of Concept
pragma solidity ^0.8.24;
import {Test, console} from "forge-std/Test.sol";
import {Vm} from "forge-std/Vm.sol";
import {Token} from "./Token.sol";
contract BurnEventTest is Test {
Token public token;
address public account;
event Transfer(address indexed from, address indexed to, uint256 value);
function setUp() public {
token = new Token();
account = makeAddr("account");
}
function test_BurnMissingTransferEvent() public {
uint256 amount = 100e18;
token.mint(account, amount);
vm.recordLogs();
token.burn(account, amount);
Vm.Log[] memory logs = vm.getRecordedLogs();
console.log("Number of events emitted during burn:", logs.length);
assertEq(logs.length, 0, "Expected zero events but got some");
assertEq(token.balanceOf(account), 0);
console.log("Vulnerability confirmed: Burn succeeded but no Transfer event emitted");
console.log("ERC20 requires Transfer(account, address(0), amount) on burn");
}
}
Result:
forge test --match-path test/BurnEvent.t.sol -vvv
[⠆] Compiling...
No files changed, compilation skipped
Ran 1 test for test/BurnEvent.t.sol:BurnEventTest
[PASS] test_BurnMissingTransferEvent() (gas: 48408)
Logs:
Number of events emitted during burn: 0
Vulnerability confirmed: Burn succeeded but no Transfer event emitted
ERC20 requires Transfer(account, address(0), amount) on burn
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 319.55µs (82.60µs CPU time)
Ran 1 test suite in 8.17ms (319.55µs CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Recommended Mitigation
dd the Transfer event emission at the end of the burn function to comply with ERC20 standard requirements. The event signals token destruction by using address(0) as the recipient.
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)
+ mstore(ptr, value)
+ log3(ptr, 0x20, 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef, account, 0x00)
}
}