The `ERC20Internals::_mint` function updates both the total supply and the recipient’s balance when new tokens are created, but it does not emit the required ERC20 Transfer(address(0), account, value) event. This omission breaks ERC20 standard behavior, leaving token creation unobservable to off‑chain systems such as wallets, explorers, and monitoring tools that depend on event logs to track supply changes. As a result, tokens can be minted silently, undermining transparency and interoperability across the ecosystem.
Description
The `ERC20Internals::_mint` function correctly updates the total supply and the recipient’s balance when new tokens are created, but it fails to emit the mandatory ERC20 Transfer(address(0), account, value) event. This omission breaks ERC20 compliance and causes minted tokens to be invisible to off‑chain systems such as wallets, block explorers, and analytics tools that rely on event logs to track token creation. As a result, tokens can be minted silently without triggering monitoring alerts, creating opportunities for stealth inflation and undermining trust in the token’s supply integrity.
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))
}
}
Risk
Likelihood:
High — the issue occurs every time `ERC20Internals::_mint` is called, since the event emission is completely absent.Impact:
Proof of Concept
The suite demonstrates ERC20 compliance failure (missing event) and safety failure (unchecked arithmetic).
-
Case 1 (Event): Confirms balances and supply update correctly, but fails because no Transfer(address(0), account, value) event is emitted.
-
Case 2 (Overflow): Shows that minting with type(uint256).max silently wraps supply and balances instead of reverting.
Add below code into a test file
contract TestToken is ERC20Internals {
function mint(address account, uint256 value) external {
_mint(account, value);
}
function balanceOf(address account) external view returns (uint256) {
return _balanceOf(account);
}
function totalSupply() external view returns (uint256) {
return totalSupply_();
}
}
contract MintEventTest is Test {
event Transfer(address indexed from, address indexed to, uint256 value);
TestToken token;
function setUp() public {
token = new TestToken();
}
function testMintUpdatesBalanceAndSupply() public {
address alice = address(1);
uint256 amount = 100;
token.mint(alice, amount); /
assertEq(token.balanceOf(alice), amount);
assertEq(token.totalSupply(), amount);
}
function testMintDoesNotEmitTransferEvent() public {
address alice = address(1);
uint256 amount = 100;
vm.expectEmit(true, true, false, true);
emit Transfer(address(0), alice, amount);
token.mint(alice, amount);
}
}
Recommended Mitigation
Emit the required ERC20 event inside _mint() after updating supply and balances.
function _mint(address account, uint256 value) internal {
assembly ("memory-safe") {
// Revert if minting to the zero address
if iszero(account) {
mstore(0x00, shl(224, 0xec442f05)) // Error selector
mstore(add(0x00, 4), 0x00)
revert(0x00, 0x24)
}
let ptr := mload(0x40)
let balanceSlot := _balances.slot
let supplySlot := _totalSupply.slot
// Update total supply
let supply := sload(supplySlot)
sstore(supplySlot, add(supply, value))
// Update account balance
mstore(ptr, account)
mstore(add(ptr, 0x20), balanceSlot)
let accountBalanceSlot := keccak256(ptr, 0x40)
let accountBalance := sload(accountBalanceSlot)
sstore(accountBalanceSlot, add(accountBalance, value))
+ let topic := 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef
+ mstore(0x00, value)
+ log3(0x00, 0x20, topic, 0, account)
}
}