C0LLBACK
c0llback/modulon

A modular, gas-conscious ERC20. Tiny immutable core, behaviour composed from pluggable modules registered on a live token — gated behind a single SLOAD.

solidityerc20ethereumgas-optimizationmodulardefismart-contracts
Solidity 0.6.12License MITNetwork Ethereum14 files
Solidity 77.8%Markdown 19.2%Text 3.0%
c0llbacka3f9c2ecore: gate module subsystem behind a single _activeHooks SLOADAug 14, 2020
README.md

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:

HookBitRunsMay revert?Use cases
BEFORE0x1before balances move (veto)pause, blacklist, per-tx caps
FEE0x4quotes a skim from the moveview onlyfee-on-transfer, reflect-style tax
AFTER0x2after balances settle avoidreward accounting, volume meters

Gas

Measured with solc 0.6.12, optimizer runs=200, EVM Istanbul. Warm-slot transfer, recipient already non-zero.

ScenarioMODULONOpenZeppelin 3.xΔ
transfer, no modules36,40246,151−21%
transfer, 1 fee module64,720n/a (hard-fork)
transfer, pause + blacklist + fee91,330n/a (hard-fork)
transferFrom, infinite allowance38,11451,016−25%

Where the savings come from (all standard 2020 technique, no magic):

  1. 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.
  2. Invariant-gated unchecked mathunchecked {} 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. After require(amount <= fromBalance) the subtraction can't underflow; since sum(balances) == totalSupply no balance can overflow on the add. Net: zero SafeMath calls on the balance move.
  3. Infinite-allowance skip — a uint256(-1) allowance is treated as permanent and never decremented, saving one SSTORE on every pull-payment after the first.
  4. 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-transfer

Usage

// 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 treasury
FeeModule fee = new FeeModule(100 /* 1.00% */, treasury);
fee.setExempt(address(ammPair), true); // don't tax LP add/remove
token.registerModule(address(fee));
 
// 3. Later: a compliance freeze, hot-plugged, no redeploy
BlacklistModule deny = new BlacklistModule();
deny.setDenied(exploiter, true);
token.registerModule(address(deny));
 
// 4. Threat over — unplug it, and its gas leaves the token with it
token.deregisterModule(address(deny));

Build & test

npm install
npx hardhat compile
npx hardhat test
npx hardhat coverage

Security

  • Not audited. This is independent work published for review — do not deploy to mainnet with real value before an audit.
  • AFTER modules 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 BEFORE or FEE module 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 AFTER hooks run (checks-effects-interactions), and FEE quotes are view.

License

MIT © 2020 c0llback

← Back to index© 2020 c0llbackMIT