Token-0x

First Flight #54
Beginner FriendlyDeFi
100 EXP
Submission Details
Impact: medium
Likelihood: high

Missing _burn function event log

Author Revealed upon completion

Root + Impact

Description

  • burning tokens reduces an account balance and totalSupply and emits Transfer(holder, address(0), value) so indexers and wallets detect the burn.

  • but the currently _burn writes storage but does not emit the Transfer event, leaving off‑chain tooling blind to burns trxs.

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))
@> // doesn't have event log
}
}

Risk

Likelihood:

  • Reason 1: Burns executed by any flow that calls _burn occur immediately without emitting the expected event.

  • Reason 2:Indexers/wallets rely on Transfer(..........) to detect burns; absence is visible as soon as a burn happens.

Impact:

  • Impact 1: Off‑chain balances and history remain stale, wallets and explorers continue to show tokens that were burned

  • Impact 2: Auditing, supply monitoring and reconciliations break; users and services may be misled.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test, Vm} from "forge-std/Test.sol";
import {ERC20} from "../src/ERC20.sol";
contract BurnEventHarness is ERC20 {
constructor() ERC20("EVENT", "EVNT") {}
// Expose helpers so tests can set up balances and call the _burn.
function exposedMint(address account, uint256 value) external { _mint(account, value); }
function exposedBurn(address account, uint256 value) external { _burn(account, value); }
}
contract BurnMissingEventTest is Test {
BurnEventHarness internal token;
bytes32 internal constant TRANSFER_TOPIC = keccak256("Transfer(address,address,uint256)");
function setUp() public {
token = new BurnEventHarness();
token.exposedMint(address(0x2033), 1 ether); // preload so burn succeeds
}
function test_burnMissingTransferEvent() public {
address holder = address(0x2033);
// Record logs to capture events emitted by the burn operation.
vm.recordLogs();
token.exposedBurn(holder, 1 ether);
// Gather recorded logs and check for Transfer topic presence.
Vm.Log[] memory entries = vm.getRecordedLogs();
bool foundTransfer;
for (uint256 i = 0; i < entries.length; i++) {
// A Transfer event will have three topics; topic0 equals TRANSFER_TOPIC
if (entries[i].topics.length == 3 && entries[i].topics[0] == TRANSFER_TOPIC) {
foundTransfer = true;
break;
}
}
// PoC expects no Transfer event for burn in the current implementation.
assertFalse(foundTransfer, "expected Transfer event on burn but none was emitted");
}
}

Recommended Mitigation

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))
+ mstore(ptr, value)
+ log3(ptr, 0x20, 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef,account , 0)
}
}

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!