0009. Token extensions replace base selectors; base libraries run no hooks
- Status: Accepted for 0.5.0 (decision D25, option (a), on #234)
- Date: #311 (2026-10-08); recorded 2026-10-09
Context
OpenZeppelin composes token extensions through virtual _update overrides, so Pausable, Votes,
Enumerable and the rest all see every balance movement. Lattice libraries are internal and not
virtual (0001), so an extension cannot hook the base library's
movement. It has to replace the standard's public movement selectors with its own versions. Two such
extensions on one diamond claim the same selectors, and a facet that moves balances through the base
library directly skips them both.
Options considered
- (a) One movement-replacing extension per diamond. Declare each standard's family mutually exclusive and pin that with tests. Cheapest; rules out pairs such as Enumerable with Votes.
- (b) A hook in each base library.
_updatecalls whichever extensions an init enabled, for example through a flag appended to the base namespace. Every path then runs the hooks, at the cost of an SLOAD per movement on every token and base libraries that import their extensions. - (c) Combined facets for each pair. Their number grows combinatorially.
Decision
ERC20Lib._update,ERC721Lib._updateandERC1155Lib._updatemove balances and emit the standard events (ERC-721 also checks authorization and clears the token approval); none of them runs an extension hook.- An extension that must see or gate every transfer replaces all of its standard's movement selectors,
and its recipe cuts it with
Replaceor excludes the base copies with_cutExcept. - Any two extensions of one family are mutually exclusive, and every direct mover (a facet that calls the
base library's
_mint,_burnor_update) is mutually exclusive with every movement-replacing extension of its standard. - A diamond that needs two behaviours on one path gets one combined facet that applies both, as
GovernedVaultdoes for ERC-20 balances and votes. - Option (b) is revisited only with ERC-3643 (#172).
It would append to the released
ERC20Storage.
Consequences
- No token pays a per-movement hook read.
Adding a second family member reverts at the cut, butReplacesilently routes the selectors to the later facet and drops the earlier facet's logic. A direct mover shares no selector with the family, so its cut succeeds with no signal. Both are integrator hazards the guide and NatSpec spell out.- Every new movement-replacing extension (ERC-721 Consecutive next) must replace the full selector set,
join its standard's family test, and pin its exclusions in
CompositionHazardsTest.
Confirmation
CompositionHazardsTestpins the model, for exampletest_MovementReplacingFamilyClaimsTheTransferPair,test_PausableAddedToVotesRevertsAtCut,test_PausableReplacingVotesDesyncsVotes,test_BurnableNextToVotesDesyncsVotesand the ERC-721 and ERC-1155 equivalents.SelectorCompatibilityTestchecks every selector that two release facets share against its hand classification; the movement selectors carry a D25 note in the generated matrix.
References
- Token extension hook model, which has the per-standard tables of movement-replacing extensions, direct movers and exclusions
ERC20Lib.sol- #234, #311, #172