betterERC
All posts
InternalsJuly 22, 202611 min read

Phases, not super calls

Letting every module override the update path and chain super looks composable. It makes execution order an accident of the inheritance list, and it sends the fee leg through checks written for user transfers.

The obvious way to compose token modules is to have each one override the internal update function and call super. It reads well, it looks like every other mixin pattern in the language, and it is a trap with two floors.

Order becomes an accident

Under super chaining, execution order falls out of C3 linearisation — the order the compiler derives from the inheritance list. A token that lists the fee module before the restriction module behaves differently from one that lists them the other way round, for reasons nobody writing the token is thinking about at the time. Two assemblies with identical modules, identical configuration and identical declarations can disagree about what a transfer does.

And the fee leg goes somewhere it should not

The second failure is worse because you would never look for it. A fee module has to split one transfer into two balance movements: the fee to the vault, the remainder to the recipient. Under super chaining both halves descend through every module below, so a restriction module screens the fee leg as though it were a user transfer — against the vault's address, and against the fee's amount.

Freeze the fee vault and every transfer on the token stops working. Not the fee. Every transfer. A module meant to control who can move value has silently acquired a veto over the whole token, and nothing in either module's source hints that it might.

One override, four phases

So the update function is overridden exactly once, in the registry every module registers with, and modules override phases instead. The order is written down in one readable function. A module cannot change it by being listed first. Each phase receives the arguments it was designed for.

ERC20ExtensionCore.sol
1. restriction checks   // mint and burn arrive with the zero address intact
2. fee collection       // its own raw update, its own Transfer event
3. the transfer itself  // for value - fee
4. the hook             // after balances settle, guarded, under a gas cap

Every position in that list is load-bearing. Restriction goes first because a transfer that is not allowed to happen must not have moved a fee, emitted an event or called anything. The hook goes last because a hook running mid-transfer could observe a half-applied state, and one running before the fee could influence an amount it is only meant to accept or reject.

The ordering that is not obvious

Fee collection runs before the main leg, and that is not an aesthetic choice. Consider a sender holding exactly the amount they are transferring. Debit the main leg first and the balance is already at zero when the fee leg arrives, so the transfer reverts on a token where the arithmetic plainly works out. Run the fee first and the two legs debit the fee and the remainder, which sum to what the sender has. Either order is fine for a sender with slack. Only one is fine for a sender without.

The fee leg also bypasses the pipeline entirely, through a raw update that reaches the base implementation directly. Routing it back through the top would re-run every check against the wrong arguments and then recurse. That path exists for exactly one caller, and the module that uses it is the only one that ever should.

Mint and burn
Reach phase one with the zero address intact, so a module can apply different rules to supply changes than it does to transfers. Phases two and four are skipped for them: a fee on minting invents supply, and a fee on burning is not a burn.
The guard
The reentrancy guard lives in the hook module rather than the core. A token with no external call on its transfer path has no reentrancy to guard against and should not pay for one.
Storage
Each module keeps its state in a namespaced slot derived from a string, so a module can be mixed into a token in any position without anything it has already written moving.