Skip to content

Latest commit

 

History

9 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

3f Request Whitelist

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.

How it works

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
Loading

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).

Key invariants

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 between quorum and validators.length is 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 leave length > quorum. Iterative capture is not prevented — a coalition that controls quorum can lower quorum first 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). _addValidator rejects both; the set itself is a solady EnumerableSetLib.AddressSet so duplicates are structurally impossible.
  • A3. The three public views over the validator set (validators(), validatorAt(i), isValidator(a)) agree on every element. validatorsLength() equals validators().length.

B. Nonce discipline

  • B1. validatorNonceFloor[v] is monotonically non-decreasing. setValidatorNonceFloor is strict-increase; _removeValidator and revokeAllRoles saturate to type(uint256).max; no other writer exists.
  • B2. Once a validator has ever been removed, their nonce floor is type(uint256).max forever — even if the same address is later re-added. Combined with the bitmap's permanent-set property and QuorumSigLib's explicit rejection of nonce == 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 1 never returns to 0. Sigs at any unused nonce at or above the validator's current floor can be submitted in any order.

C. Pause state machine

  • C1. pausedUntil moves forward via pauseFor (extend-only, reverts PauseNotExtended on any call that wouldn't strictly extend the current window) and drops to 0 via unpause. It never shrinks to a non-zero value.
  • C2. isPaused() is a pure function of pausedUntil and block.timestamp — no cached pause state anywhere.
  • C3. While paused, every whitelisting path (whitelist, whitelistBySig) reverts with ContractPaused. The Phase-1 owner admin paths (addValidator, removeValidator, setQuorum, setComplianceOfficer) are also gated by whenNotPaused. The recovery surface that does stay reachable during pause is: compliance pauseFor / unpause, both unwhitelist paths (unwhitelist, unwhitelistBySig — revoking attestations is a fail-safe and must not be blocked by the breaker), the validator-self lever setValidatorNonceFloor, the post-renounce *BySig admin twins, and the owner-only revokeAllRoles recovery 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 *BySig paths 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 returns Whitelisted or NotWhitelisted — the return is always one of the Paused* variants, regardless of the underlying attestation bit.
  • D2. While live (not paused), isWhitelisted(a) returns exactly one of Whitelisted / NotWhitelisted, matching the isWhitelisted storage bit.

E. Ownership irreversibility

  • E1. Once owner() becomes address(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.

Return-value enum

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.

Two-phase admin surface

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, revokeAllRolesonlyOwner 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, ...).

Signature scheme

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.

Nonce model (bitmap + floor)

Two pieces of per-validator state guard replay:

  • validatorNonceBitmap[v][word] — a packed 256-bit word per 256-nonce range. Bit n & 0xff of word n >> 8 is set on successful verify of a sig at nonce n. Any attempt to consume an already-set bit reverts NonceConsumed. 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 rejected NonceBelowFloor. This is the mass-invalidate lever. The validator raises it themselves via setValidatorNonceFloor(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 to type(uint256).max, which permanently retires that address from ever signing again — QuorumSigLib separately rejects nonce == type(uint256).max as 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.

Trust model for ERC-1271 signers

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.

Relayer considerations

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 NonceConsumed and 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.length invariant 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.

Storage layout

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.

Proxy & upgrades

  • Recommended proxy: solady's ERC1967Factory. Use factory.deployAndCall(impl, admin, initCalldata) so the proxy is deployed and initialized atomically — this closes the one-shot front-running window on initialize. A separate EOA-less deployment followed by a second initialize tx 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 (the proxyAdmin parameter to deployAndCall); the application-level owner() 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.

Layout

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

Usage

# build
forge build

# run the test suite
forge test

# format
forge fmt

Deployment is proxy-based and must be atomic to close the one-shot initialize front-run window:

  1. Deploy RequestWhitelist (implementation). Its constructor calls _disableInitializers() so it can never be initialized directly.
  2. Deploy the proxy and call initialize in a single transaction via ERC1967Factory.deployAndCall(impl, proxyAdmin, initData) where initData = abi.encodeCall(RequestWhitelist.initialize, (owner, validators, quorum, complianceOfficers)). Do not deploy the proxy and call initialize in separate transactions — anyone watching the mempool can front-run the second call and burn the proxy.
  3. Consumers import IWhitelist, call isWhitelisted(address) on the proxy address, and gate strictly on status == IWhitelist.WhitelistStatus.Whitelisted.

Deploying with script/Deploy.s.sol

script/Deploy.s.sol wraps the three steps above into one atomic broadcast. Every artefact lives at a cross-chain-deterministic address:

  • ERC1967Factory is always the canonical solady address 0x0000000000006396FF2a80c067f99B3d2Ab4Df24 — 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's ImmutableCreate2Factory (0x0000000000FFe8B47B3e2130213B802212439497) with the pinned SALT/INITCODE from ERC1967FactoryConstants.
  • RequestWhitelist implementation is CREATE2'd through ImmutableCreate2Factory at IMPLEMENTATION_SALT (default bytes32(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 pass IMPLEMENTATION directly to reuse an existing deployment.
  • Proxy is CREATE2'd through ERC1967Factory at the user-provided SALT, 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> --broadcast

Mining a vanity proxy salt

The 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:

  1. 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.

  2. 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)".)

  3. 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 of deployDeterministic*. --no-random is load-bearing: without it, cast create2 seeds the search with a random 32-byte salt, leaving random bytes in the top 20 and causing the factory to revert SaltDoesNotStartWithCaller. With --no-random the search starts at 0x0…0 and 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-random with --caller <EOA>: cast pins 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.

  4. Verify the mined salt on-chain before deploying:

    cast call 0x0000000000006396FF2a80c067f99B3d2Ab4Df24 \
      "predictDeterministicAddress(bytes32)(address)" <SALT> --rpc-url <RPC>

    must equal the address cast create2 printed. The deploy script also asserts proxy == predictDeterministicAddress(salt) post-deploy.

  5. 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_SALT and COMPLIANCE_OFFICERS are optional — omit them to take the defaults (0x000...003f and empty, respectively). IMPLEMENTATION can 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.

About

On-chain attestation registry: quorum-signed whitelist with compliance circuit-breaker (post-audit)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages