A modular, gas-conscious ERC20. Tiny immutable core, behaviour composed from pluggable modules registered on a live token — gated behind a single SLOAD.
MODULON
Solidity License Build Coverage
A modular, gas-conscious ERC20. The token core is small and immutable; behaviour is composed from pluggable modules you register and remove on a live token.
Most "feature" tokens in 2020 hard-code their quirks — a fee here, a pause there, a blacklist welded straight into _transfer. Every one is a fork of the last and has to be audited from scratch. MODULON flips it: ship a tiny core audited once, then compose behaviour from small, single-purpose modules.
The catch with composition is gas: external modules mean external CALLs, and asking "is there a fee? a pause? a blacklist?" on every transfer would tax even the tokens that register nothing. MODULON's answer is the active-hooks bitmap — one uint256, the OR of every registered module's hook mask. The token reads it once; if it's zero the entire module subsystem is skipped.
A transfer on a MODULON token with no modules registered costs about one cold SLOAD more than a plain ERC20 — that's the whole overhead of the registry when it's empty.
How it works
┌─────────────────────────────┐ transfer() ───────→ │ Modulon │ │ active = _activeHooks (1 SLOAD) │ │ │ active == 0 ? ────────────────→ _move() (plain ERC20) │ │ no │ │ ↓ │ │ BEFORE → _runBefore() ──→ [PausableModule] │ FEE → _quoteFee() ──→ [FeeModule] [BlacklistModule] │ _move() │ ... │ AFTER → _runAfter() ──→ [RewardsModule] └─────────────────────────────┘A module self-describes the hooks it wants through IModule.hooks(), so it can never be wired against a hook it doesn't implement:
| Hook | Bit | Runs | May revert? | Use cases |
|---|---|---|---|---|
BEFORE | 0x1 | before balances move | (veto) | pause, blacklist, per-tx caps |
FEE | 0x4 | quotes a skim from the move | view only | fee-on-transfer, reflect-style tax |
AFTER | 0x2 | after balances settle | avoid | reward accounting, volume meters |
Gas
Measured with solc 0.6.12, optimizer runs=200, EVM Istanbul. Warm-slot transfer, recipient already non-zero.
| Scenario | MODULON | OpenZeppelin 3.x | Δ |
|---|---|---|---|
transfer, no modules | 36,402 | 46,151 | −21% |
transfer, 1 fee module | 64,720 | n/a (hard-fork) | — |
transfer, pause + blacklist + fee | 91,330 | n/a (hard-fork) | — |
transferFrom, infinite allowance | 38,114 | 51,016 | −25% |
Where the savings come from (all standard 2020 technique, no magic):
- Cache-then-check — the sender balance is
SLOAD-ed once into a local, used for both the bound check and the subtraction. SafeMath reads it twice. - Invariant-gated unchecked math —
unchecked {}doesn't exist before 0.8.0, so instead we drop SafeMath only where an invariant proves overflow is impossible, and we prove it in a comment. Afterrequire(amount <= fromBalance)the subtraction can't underflow; sincesum(balances) == totalSupplyno balance can overflow on the add. Net: zero SafeMath calls on the balance move. - Infinite-allowance skip — a
uint256(-1)allowance is treated as permanent and never decremented, saving oneSSTOREon every pull-payment after the first. - The bitmap gate — described above; keeps the module layer off the bill until you opt in.
Layout
contracts/├─ Modulon.sol core token — wires modules into the transfer path├─ ERC20Core.sol gas-tuned EIP-20 base (balances, allowances, mint/burn)├─ ModuleRegistry.sol register/deregister modules, the _activeHooks gate├─ access/│ └─ Ownable.sol minimal single-owner access control├─ interfaces/│ ├─ IERC20.sol│ ├─ IModule.sol base + IBeforeTransfer / IAfterTransfer│ └─ IFeeModule.sol├─ lib/│ ├─ SafeMath.sol trimmed to the 3 ops we use│ └─ Hooks.sol the hook bitmask constants└─ modules/ ├─ PausableModule.sol circuit breaker ├─ BlacklistModule.sol address denylist └─ FeeModule.sol flat bps fee-on-transferUsage
// 1. Deploy the token (1,000,000 units to the treasury, 18 decimals)Modulon token = new Modulon("Modulon", "MOD", 18, 1_000_000e18, treasury); // 2. Deploy and wire a 1% fee routed to the treasuryFeeModule fee = new FeeModule(100 /* 1.00% */, treasury);fee.setExempt(address(ammPair), true); // don't tax LP add/removetoken.registerModule(address(fee)); // 3. Later: a compliance freeze, hot-plugged, no redeployBlacklistModule deny = new BlacklistModule();deny.setDenied(exploiter, true);token.registerModule(address(deny)); // 4. Threat over — unplug it, and its gas leaves the token with ittoken.deregisterModule(address(deny));Build & test
npm installnpx hardhat compilenpx hardhat testnpx hardhat coverageSecurity
- Not audited. This is independent work published for review — do not deploy to mainnet with real value before an audit.
AFTERmodules run after balances settle; a reverting after-module bricks transfers. Keep them infallible and cheap (the interface documents this; the core does not enforce it).- A malicious/buggy
BEFOREorFEEmodule can grief transfers — only the token owner can register modules, so module trust reduces to owner trust. Renounce ownership to freeze the module set. - No re-entrancy guard on the transfer path by design: balances are written before
AFTERhooks run (checks-effects-interactions), andFEEquotes areview.
License
MIT © 2020 c0llback