An on-chain attestation registry with two parallel write paths into a
single isWhitelisted[a] bit:
- Owner-direct (
whitelist/unwhitelist) — a trusted multisig flips bits without signatures, as the always-available escape hatch. - Quorum-signed (
whitelistBySig/unwhitelistBySig) — a quorum of off-chain validators attests via EIP-712 batches; any submitter can relay them. This is the distributed automation lane.
Consumers call isWhitelisted(address) as the single source of truth.
A compliance role can freeze the registry as a circuit breaker; while
paused, isWhitelisted never returns the trusted Whitelisted value,
but validator / quorum / compliance rotation paths remain reachable so
the breaker cannot strand the recovery it was built to enable.
The contract is factory- and proxy-agnostic: deploy the implementation once,
put it behind any ERC-1967 proxy, and call initialize through the proxy.
flowchart LR
V["off-chain validators<br/>sign EIP-712 WhitelistRequest"]
O(["Owner (phase 1 only)<br/>trusted multisig"])
R(["any submitter<br/>(relayer / validator)"])
C(["Compliance officer"])
WL["RequestWhitelist<br/>isWhitelisted[a]<br/>pausedUntil"]
D(["downstream protocols<br/>/ dApps"])
O -- "whitelist(targets)<br/>unwhitelist(targets)" --> WL
V -- "quorum of sigs" --> R
R -- "whitelistBySig(targets, deadline, sigs)<br/>unwhitelistBySig(targets, deadline, sigs)" --> WL
C -- "pauseFor / unpause" --> WL
WL -- "isWhitelisted(a)<br/>(WhitelistStatus enum)" --> D
The owner-direct path is reserved for the trusted multisig and is
permanently unreachable after renounceOwnership() (Phase 2 is sig-only).
The sig path is open to any submitter regardless of phase — the validator
quorum is the access control, not the caller. Whitelisting is gated by
whenNotPaused on both paths; unwhitelisting is never gated by
pause on either path (fail-safe: attestations must always be revocable).
Every property below holds across every reachable state transition. The A/B/C/D/E grouping is reused in the invariant test harness so a failing run points straight at the clause that broke.
A. Validator set & quorum structure
- A1.
1 <= quorum < validators.length <= MAX_VALIDATORS(256). The strict inequality betweenquorumandvalidators.lengthis a one-step anti-capture rail: a quorum-sized coalition cannot in a single transaction remove every dissenting validator, because each removal still has to leavelength > quorum. Iterative capture is not prevented — a coalition that controls quorum can lowerquorumfirst and then remove non-coalition members one by one; the contract-level rule only buys time and emits on-chain observability (ValidatorRemoved,QuorumSet). Defense against coordinated capture is governance-level. - A2. The validator set never contains duplicates and never contains
address(0)._addValidatorrejects both; the set itself is a soladyEnumerableSetLib.AddressSetso duplicates are structurally impossible. - A3. The three public views over the validator set
(
validators(),validatorAt(i),isValidator(a)) agree on every element.validatorsLength()equalsvalidators().length.
B. Nonce discipline
- B1.
validatorNonceFloor[v]is monotonically non-decreasing.setValidatorNonceFlooris strict-increase;_removeValidatorandrevokeAllRolessaturate totype(uint256).max; no other writer exists. - B2. Once a validator has ever been removed, their nonce floor is
type(uint256).maxforever — even if the same address is later re-added. Combined with the bitmap's permanent-set property andQuorumSigLib's explicit rejection ofnonce == type(uint256).max, this closes the re-add replay window: no signature the retired address ever produced can pass. - B3. Each validator signature carries a signer-chosen nonce. The
nonce is recorded in a per-validator bitmap on successful verify and
permanently single-use — a bit set to
1never returns to0. Sigs at any unused nonce at or above the validator's current floor can be submitted in any order.
C. Pause state machine
- C1.
pausedUntilmoves forward viapauseFor(extend-only, revertsPauseNotExtendedon any call that wouldn't strictly extend the current window) and drops to0viaunpause. It never shrinks to a non-zero value. - C2.
isPaused()is a pure function ofpausedUntilandblock.timestamp— no cached pause state anywhere. - C3. While paused, every whitelisting path
(
whitelist,whitelistBySig) reverts withContractPaused. The Phase-1 owner admin paths (addValidator,removeValidator,setQuorum,setComplianceOfficer) are also gated bywhenNotPaused. The recovery surface that does stay reachable during pause is: compliancepauseFor/unpause, both unwhitelist paths (unwhitelist,unwhitelistBySig— revoking attestations is a fail-safe and must not be blocked by the breaker), the validator-self leversetValidatorNonceFloor, the post-renounce*BySigadmin twins, and the owner-onlyrevokeAllRolesrecovery hook (intentionally pause-callable as the only owner-side path available when the rogue actor is the compliance officer who triggered the pause). The breaker buys the quorum time to rotate via the*BySigpaths or wait the pause out and use Phase-1 rotations; it does not freeze rotation, and it does not freeze revocation.
D. isWhitelisted semantics
- D1. While paused,
isWhitelisted(a)never returnsWhitelistedorNotWhitelisted— the return is always one of thePaused*variants, regardless of the underlying attestation bit. - D2. While live (not paused),
isWhitelisted(a)returns exactly one ofWhitelisted/NotWhitelisted, matching theisWhitelistedstorage bit.
E. Ownership irreversibility
- E1. Once
owner()becomesaddress(0), it stays there.renounceOwnership()is the only writer to zero and has no inverse; the Phase-1 admin surface cannot be re-opened after handover to the validator quorum.
isWhitelisted returns a four-way WhitelistStatus:
| Value | Meaning | Consumer action |
|---|---|---|
NotWhitelisted |
live registry, never attested or revoked | do not authorise |
Whitelisted |
live registry, currently attested | authorise |
PausedNotWhitelisted |
paused, not attested at pause time | do not authorise |
PausedWhitelisted |
paused, was attested before the pause | do not authorise, but can be surfaced as "temporarily gated" rather than "never listed" |
Only Whitelisted is safe to trust; every other value should be treated as
"do not authorise". The richer enum lets integrators distinguish
"permanently not listed" from "circuit breaker active" when surfacing the
state to end users.
The contract is designed to start under an owner and later transition to pure quorum-governed operation:
| Phase | owner() |
Admin path | Whitelist write paths |
|---|---|---|---|
| Owned (bootstrap) | EOA / multisig | addValidator, removeValidator, setQuorum, setComplianceOfficer, revokeAllRoles — onlyOwner |
whitelist(address[]) / unwhitelist(address[]) (owner-direct) and whitelistBySig / unwhitelistBySig (any submitter, quorum sigs) |
| Renounced (steady state) | address(0) |
*BySig twins of each admin action — gated by a quorum of validator sigs + _requireRenounced |
whitelistBySig / unwhitelistBySig only — owner-direct paths revert via onlyOwner |
Calling renounceOwnership() is irreversible; after that point, every
admin mutation and every whitelist mutation must be authorized by a
validator quorum, and anyone can submit.
The owner-direct and sig-based whitelist paths converge on the same
underlying isWhitelisted[a] bit and emit the same
Whitelisted / Unwhitelisted events. Off-chain consumers therefore do
not need to disambiguate the source of a state change.
revokeAllRoles(a) is an owner-only incident-response tool: it strips the
validator role and the compliance flag from a in a single transaction,
which matters because the two roles are orthogonal mappings — removing
someone as a validator does not strip their compliance flag, and vice
versa. Post-renounce, the equivalent is
removeValidatorBySig(a, ...) followed by
setComplianceOfficerBySig(a, false, ...).
All sigs are EIP-712 typed data signed over (name: "3fRequestWhitelist", version: "1", chainId, verifyingContract). Three typehashes:
WhitelistRequest(address[] targets, uint256 deadline, address validator, uint256 nonce)UnwhitelistRequest(address[] targets, uint256 deadline, address validator, uint256 nonce)AdminRequest(bytes32 actionId, bytes data, uint256 deadline, address validator, uint256 nonce)
Both ECDSA (EOA) and ERC-1271 (contract) signers are accepted transparently
via solady's SignatureCheckerLib. A single per-validator index bitmap
de-duplicates signers within a call — the MAX_VALIDATORS = 256 cap is
dictated by this dedup word.
Two pieces of per-validator state guard replay:
validatorNonceBitmap[v][word]— a packed 256-bit word per 256-nonce range. Bitn & 0xffof wordn >> 8is set on successful verify of a sig at noncen. Any attempt to consume an already-set bit revertsNonceConsumed. Because the bitmap records consumption by position rather than by sequence, sigs can be submitted in any order — there is no "expected next nonce" and therefore no out-of-order relayer race.validatorNonceFloor[v]— a single scalar below which every sig is rejectedNonceBelowFloor. This is the mass-invalidate lever. The validator raises it themselves viasetValidatorNonceFloor(newFloor)(one SSTORE, no per-sig bookkeeping). The floor is strict-increase: it cannot be lowered once raised. Removal of a validator saturates the floor totype(uint256).max, which permanently retires that address from ever signing again —QuorumSigLibseparately rejectsnonce == type(uint256).maxas the retired-validator sentinel, so the saturated floor leaves no usable nonce. Combined with the strict-increase rule, a removed validator cannot be re-admitted at the same address with a lower floor, so any pre-signed sig is unusable after removal (closes the re-add replay window).
Validators are free to allocate nonces sequentially (simplest), time-based, random, or sparse — only the "not below floor, not already consumed" rule is enforced. The suggested default for operators is sequential from 0, since that minimises bitmap storage cost: only the words actually touched by consumed nonces are written to.
setValidatorNonceFloor(newFloor) remains reachable while the registry
is paused. Pausing is the signal that something is wrong, so the
mass-invalidate lever must be usable under pause.
SignatureCheckerLib.isValidSignatureNowCalldata forwards the full
remaining gas to the signer contract's isValidSignature staticcall.
Solady caps the return-data copy at 32 bytes (no return-bomb surface),
but a malicious or buggy ERC-1271 signer can still burn up to ~63/64 of
remaining gas and return 0, causing the enclosing batch to revert
after the submitter has paid near-block-gas-limit execution cost.
Operational requirement: every contract admitted as a validator must
be vetted. Only admit ERC-1271 signers whose isValidSignature
implementation is known, auditable, and bounded in gas — multisigs with
simple recovery logic are safe; arbitrary proxy or plugin-style signers
are not.
Because the bitmap accepts any unconsumed nonce at or above the floor, the relayer-ordering races that a strict-sequential nonce model would produce are absent:
- Any submission order works: a batch signed at nonce 7 and a batch signed at nonce 3 can be submitted in either order; each consumes its own bit independently.
- Same-nonce collision: if two distinct batches accidentally share
a nonce from the same validator (e.g. because the signer's off-chain
counter raced), the first to mine consumes; the second reverts
NonceConsumedand must be re-signed at a fresh nonce. This is the only residual race and it is self-announcing. - Nonce burn by quorum: a quorum-sized coalition can still consume
arbitrary bits in validators' bitmaps by producing quorum-signed
admin actions at those nonces, but each such burn costs a real
signature per validator per bit, and the strict
quorum < validators.lengthinvariant means at least one non-coalition validator must have pre-signed at the burned nonce for the burn to consume a useful bit. Operational impact is minor (off-chain counters can skip burned bits), not a protocol break.
Validators managing many in-flight sigs can simply maintain a local incrementing counter; the protocol has no sequencing requirement.
All mutable state lives in a single ERC-7201 namespaced struct at
keccak256(abi.encode(uint256(keccak256("3f.storage.RequestWhitelist")) - 1)) & ~bytes32(uint256(0xff)).
Ownable and Initializable use solady's hardcoded high-slot constants,
which cannot collide.
Storage upgrade rule (append-only): future versions must never
reorder, resize, or remove fields in StorageLayout. Slot 0's packing of
uint16 quorum + uint48 pausedUntil leaves 26 bytes for forward-
compatible flags and must not be rearranged. Dynamic members (the
validator set and the mappings) each anchor their own base slot, so
appending additional dynamic fields after the existing members is
safe.
- Recommended proxy: solady's
ERC1967Factory. Usefactory.deployAndCall(impl, admin, initCalldata)so the proxy is deployed andinitialized atomically — this closes the one-shot front-running window oninitialize. A separate EOA-less deployment followed by a secondinitializetx is discouraged; if used, the operator must accept the front-run risk. - Proxy admin is not
owner(). The ERC-1967 admin slot controls upgrades and is set at deploy time (theproxyAdminparameter todeployAndCall); the application-levelowner()role controls Phase-1 admin mutations. Keep the two roles separate. - Upgrade policy: out of scope for this contract; the proxy admin controls upgrades at the ERC-1967 layer. Operators deploying for production should decide explicitly whether upgrades are gated by a timelock, renounced after a settling period, or locked behind a governance contract — and document that decision alongside the deployment.
src/
RequestWhitelist.sol -- the registry
IWhitelist.sol -- minimal read interface for consumers
lib/
QuorumSigLib.sol -- EIP-712 / EIP-1271 quorum verification
WhitelistEvents.sol -- every event declared here
WhitelistErrors.sol -- every contract-level revert declared here
test/
RequestWhitelist.t.sol -- full suite
mocks/MockERC1271.sol
# build
forge build
# run the test suite
forge test
# format
forge fmtDeployment is proxy-based and must be atomic to close the one-shot
initialize front-run window:
- Deploy
RequestWhitelist(implementation). Its constructor calls_disableInitializers()so it can never be initialized directly. - Deploy the proxy and call
initializein a single transaction viaERC1967Factory.deployAndCall(impl, proxyAdmin, initData)whereinitData = abi.encodeCall(RequestWhitelist.initialize, (owner, validators, quorum, complianceOfficers)). Do not deploy the proxy and callinitializein separate transactions — anyone watching the mempool can front-run the second call and burn the proxy. - Consumers import
IWhitelist, callisWhitelisted(address)on the proxy address, and gate strictly onstatus == IWhitelist.WhitelistStatus.Whitelisted.
script/Deploy.s.sol wraps the three steps above into one atomic
broadcast. Every artefact lives at a cross-chain-deterministic address:
ERC1967Factoryis always the canonical solady address0x0000000000006396FF2a80c067f99B3d2Ab4Df24— not a config knob the caller can get wrong. If that factory is not yet deployed on the target chain, the script bootstraps it via 0age'sImmutableCreate2Factory(0x0000000000FFe8B47B3e2130213B802212439497) with the pinned SALT/INITCODE fromERC1967FactoryConstants.RequestWhitelistimplementation is CREATE2'd throughImmutableCreate2FactoryatIMPLEMENTATION_SALT(defaultbytes32(uint256(0x3f))). Its address is a pure function of(create2-factory, salt, creationCode)and is therefore identical on every chain that runs this script. Override the salt if you want a different vanity prefix, or passIMPLEMENTATIONdirectly to reuse an existing deployment.- Proxy is CREATE2'd through
ERC1967Factoryat the user-providedSALT, initialised atomically in the same tx.
The script reverts with guidance if ImmutableCreate2Factory itself is
missing — a fully cold chain needs one manual bootstrap the script
cannot do for you. It is idempotent at the artefact level: if the
factory or the implementation already has code at its predicted
address, that step is skipped.
Required env:
| Var | Type | Purpose |
|---|---|---|
SALT |
bytes32 |
CREATE2 salt for the proxy (see Mining a vanity proxy salt). |
PROXY_ADMIN |
address |
ERC-1967 admin — controls upgrades, distinct from owner(). |
OWNER |
address |
Phase-1 application owner. |
VALIDATORS |
address[] (comma-sep) |
Initial validator set. |
QUORUM |
uint |
1 <= QUORUM < VALIDATORS.length. |
Optional env:
| Var | Type | Purpose |
|---|---|---|
IMPLEMENTATION |
address |
Explicit impl override. Skips CREATE2 deployment entirely. |
IMPLEMENTATION_SALT |
bytes32 |
CREATE2 salt for the impl. Default 0x000...003f. Ignored if IMPLEMENTATION is set. |
COMPLIANCE_OFFICERS |
address[] (comma-sep) |
Empty if unset. |
SALT=0x0000000000000000000000000000000000000000....cafe \
PROXY_ADMIN=0x... \
OWNER=0x... \
VALIDATORS=0xaaa,0xbbb,0xccc \
QUORUM=2 \
COMPLIANCE_OFFICERS=0xddd \
forge script script/Deploy.s.sol:DeployRequestWhitelist --rpc-url <RPC> --broadcastThe proxy is deployed via factory.deployDeterministicAndCall, which is
a CREATE2 call keyed on (factory, salt, initCodeHash). Pick a salt
whose resulting proxy address starts with a chosen hex prefix (e.g.
0x3f...) by grinding candidates offline:
-
Use the canonical factory address. Mining is always against
ERC1967FactoryConstants.ADDRESS = 0x0000000000006396FF2a80c067f99B3d2Ab4Df24— the same CREATE2 deployer on every chain. No per-chain bookkeeping, no config knob. -
Use the pinned
initCodeHash. The proxy init code embeds the factory address, so the hash is fixed for the canonical factory on every chain:0x4435a5963c29bc1c221d8cfc9c546a167425b5e1f4b5017cc0a0dd4ccaac27d1(Sanity-check from any chain that has the factory deployed:
cast call 0x0000000000006396FF2a80c067f99B3d2Ab4Df24 "initCodeHash()(bytes32)".) -
Mine the salt with foundry's
cast create2(sufficient up to ~4–5 hex chars on a laptop):cast create2 \ --deployer 0x0000000000006396FF2a80c067f99B3d2Ab4Df24 \ --init-code-hash 0x4435a5963c29bc1c221d8cfc9c546a167425b5e1f4b5017cc0a0dd4ccaac27d1 \ --starts-with 3f \ --no-random
Output includes both the mined address and the salt. The factory requires the top 20 bytes of the salt to be either
address(0)or the caller ofdeployDeterministic*.--no-randomis load-bearing: without it,cast create2seeds the search with a random 32-byte salt, leaving random bytes in the top 20 and causing the factory to revertSaltDoesNotStartWithCaller. With--no-randomthe search starts at0x0…0and increments, so the top 20 bytes stay zero (permissionless claim) and the factory accepts from any caller.To bind the salt to a specific deployer EOA, replace
--no-randomwith--caller <EOA>:castpins the top 20 bytes to that address and grinds only the trailing 12. In that case the EOA must be the actual transaction sender during both simulation (--sender <EOA>or matching--private-key) and broadcast, or the factory reverts with the same error. -
Verify the mined salt on-chain before deploying:
cast call 0x0000000000006396FF2a80c067f99B3d2Ab4Df24 \ "predictDeterministicAddress(bytes32)(address)" <SALT> --rpc-url <RPC>
must equal the address
cast create2printed. The deploy script also assertsproxy == predictDeterministicAddress(salt)post-deploy. -
Deploy with the mined salt:
SALT=<mined-salt> \ PROXY_ADMIN=0x... \ OWNER=0x... \ VALIDATORS=0xaaa,0xbbb,0xccc \ QUORUM=2 \ COMPLIANCE_OFFICERS=0xddd \ IMPLEMENTATION_SALT=0x000000000000000000000000000000000000000000000000000000000000003f \ forge script script/Deploy.s.sol:DeployRequestWhitelist \ --rpc-url <RPC> \ --broadcast
IMPLEMENTATION_SALTandCOMPLIANCE_OFFICERSare optional — omit them to take the defaults (0x000...003fand empty, respectively).IMPLEMENTATIONcan be set instead to reuse an existing impl and skip the CREATE2 deploy for it.
For deeper prefixes (6+ hex chars) or leading-zero vanity, use a GPU
miner such as create2crunch;
it consumes the same (deployer, initCodeHash) inputs. Mining
difficulty scales by 16^k per extra hex char, so plan accordingly.