A hybrid optimistic/pessimistic governance system for the Reserve protocol.
Reserve Governor provides two proposal paths through a single timelock:
- Fast (Optimistic): Quick execution after a short veto period, no affirmative voting required
- Slow (Standard): Full voting process with timelock delay
During a fast proposal's veto period, token holders can vote AGAINST. If enough AGAINST votes accumulate to reach the veto threshold, the proposal automatically spawns a full confirmation vote (the slow path) under a new proposal id. This lets routine governance operate efficiently while preserving the community's ability to challenge any proposal.
Fast proposals are protected by a proposer throttle that limits how many optimistic proposals each account can create per 24-hour window.
The system consists of five components:
- StakingVault -- ERC4626 vault with vote-locking, multi-token rewards, and unstaking delay
- UnstakingManager -- Time-locked withdrawal manager created by StakingVault during initialization
- ReserveOptimisticGovernor -- Hybrid governor unifying optimistic/standard proposal flows in shared OZ Governor storage
- OptimisticSelectorRegistry -- Whitelist of allowed
(target, selector)pairs for optimistic proposals - TimelockControllerOptimistic -- Single timelock for execution, with bypass for the optimistic path
┌──────────────────────────────────┐
│ StakingVault │
│ ERC4626 + ERC20Votes │
│ │
│ deposit / withdraw / delegate │
│ claimRewards │
│ ┌────────────────────────────┐ │
│ │ UnstakingManager │ │
│ │ createLock / claimLock │ │
│ │ cancelLock │ │
│ └────────────────────────────┘ │
└───────────────┬──────────────────┘
│ (voting token)
▼
┌─────────────────────────────────────────────────────────────────┐
│ ReserveOptimisticGovernor │
│ ┌─────────────────────┐ ┌─────────────────────────────────┐ │
│ │ Fast (Optimistic) │ │ Slow (Standard) │ │
│ │ │ │ │ │
│ │ proposeOptimistic │ │ propose / vote / queue │ │
│ │ execute │ │ execute │ │
│ │ │ │ cancel │ │
│ └──────────┬──────────┘ └────────────────┬────────────────┘ │
│ │ │ │
│ └────────────────┬───────────────┘ │
└──────────────────────────────┼──────────────────────────────────┘
┌───────────────┤
│ │
▼ ▼
┌────────────────────────────────┐ ┌───────────────────────────────┐
│ OptimisticSelectorRegistry │ │ TimelockControllerOptimistic │
│ │ │ │
│ Allowed (target, │ │ Fast: executeBatchBypass() │
│ selector) pairs │ │ Slow: scheduleBatch() │
└────────────────────────────────┘ └───────────────────────────────┘
The governor checks each call in an optimistic proposal against the OptimisticSelectorRegistry before creating it. Only whitelisted (target, selector) pairs are permitted.
The ReserveOptimisticGovernorDeployer deploys the full system, transfers vault ownership to the timelock, grants governor timelock roles, grants guardian/proposer roles, and renounces admin.
Fast proposals use the standard OZ Governor ProposalState enum. During the veto period, any token holder can vote, but only Against votes are allowed (For and Abstain revert).
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ PENDING │────▶│ ACTIVE │────▶│ SUCCEEDED │────▶│ EXECUTED │
│ (vetoDelay) │ │ (vetoPeriod │ │ (threshold │ │ (via bypass)│
│ │ │ veto votes)│ │ not met) │ │ │
└──────┬──────┘ └──────┬──────┘ └─────────────┘ └─────────────┘
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ CANCELED │ │ DEFEATED │
│ │ │ (threshold │──────▶ confirmation vote
│ │ │ reached) │ (standard flow)
└─────────────┘ └─────────────┘
When AGAINST votes reach the veto threshold, the governor creates a new standard confirmation proposal:
- The original optimistic proposal remains in
Defeatedstate (internally marked with a sentinel veto threshold value) - A confirmation proposal is created with description prefix
"Conf: "and therefore a differentproposalId - The confirmation proposal follows normal standard timing (
PendingforvotingDelay, thenActive) - Voting starts fresh on the confirmation proposal (votes and
hasVoteddo not carry over from veto phase)
| Path | Name | Flow | Outcome |
|---|---|---|---|
| F1 | Uncontested | Pending -> Active -> Succeeded -> Executed | Executes via timelock bypass |
| F2 | Vetoed, Confirmed | Pending -> Active -> Defeated -> (confirmation) Pending -> Active -> Succeeded -> Queued -> Executed | Executes via timelock |
| F3 | Vetoed, Rejected | Pending -> Active -> Defeated -> (confirmation) Pending -> Active -> Defeated | Proposal blocked |
| F4 | Canceled | Any non-final state -> Canceled | Proposer or guardian cancels |
Slow proposals follow the standard OpenZeppelin Governor flow with voting, timelock queuing, and late quorum extension.
ProposalState enum (from OZ Governor): Pending, Active, Canceled, Defeated, Succeeded, Queued, Executed
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ PENDING │────▶│ ACTIVE │────▶│ SUCCEEDED │────▶│ QUEUED │────▶│ EXECUTED │
│ (voting │ │ (voting │ │ (quorum │ │ (timelock │ │ │
│ delay) │ │ open) │ │ met) │ │ delay) │ │ │
└──────┬──────┘ └──────┬──────┘ └─────────────┘ └─────────────┘ └─────────────┘
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ CANCELED │ │ DEFEATED │
│ │ │ (quorum not │
│ │ │ met or vote │
│ │ │ against) │
└─────────────┘ └─────────────┘
| Path | Name | Flow | Outcome |
|---|---|---|---|
| S1 | Success | Pending -> Active -> Succeeded -> Queued -> Executed | Normal governance execution |
| S2 | Voting Defeated | Pending -> Active -> Defeated | Proposal rejected by voters |
| S3 | Cancellation | Pending -> Canceled (or guardian cancel in any non-final state) | Canceled |
During a fast proposal's veto period, any token holder can vote using the standard castVote() interface, but only Against votes are allowed for optimistic proposals.
Veto threshold calculation:
vetoThreshold = ceil(vetoThresholdRatio * pastTotalSupply / 1e18)
Where pastTotalSupply = token.getPastTotalSupply(snapshot) and vetoThresholdRatio is a D18 fraction (e.g. 0.1e18 = 10%).
If the veto threshold is reached, the proposal is Defeated and automatically transitions to a confirmation vote via a new proposal id. If the veto period expires without reaching the threshold, the proposal Succeeds and can be executed immediately via timelock bypass. If the snapshot pastTotalSupply is zero (so computed threshold in tokens is zero), the optimistic proposal resolves to Canceled.
Use isOptimistic(proposalId) to determine if a proposal is optimistic or standard. The result cannot change over the lifetime of a proposal.
| Role | Held By | Permissions |
|---|---|---|
OPTIMISTIC_PROPOSER_ROLE |
Designated proposer EOAs | Create fast proposals (proposeOptimistic) |
PROPOSER_ROLE |
Governor contract | Schedule operations on the timelock (granted automatically by Deployer) |
EXECUTOR_ROLE |
Governor contract | Execute timelock operations for both slow and fast proposal paths |
CANCELLER_ROLE |
Governor contract + Guardian addresses | Cancel proposals (fast or slow), revoke optimistic proposers |
Note: Standard (slow) proposals are created via
propose()by any account meetingproposalThreshold. ThePROPOSER_ROLEon the timelock is held by the governor contract itself -- it allows the governor to schedule operations, not individual users to create proposals.
The OPTIMISTIC_PROPOSER_ROLE is managed on the timelock via standard AccessControl:
- Granted via
timelock.grantRole(OPTIMISTIC_PROPOSER_ROLE, address) - Revoked via
timelock.revokeRole(OPTIMISTIC_PROPOSER_ROLE, address)ortimelock.revokeOptimisticProposer(address)(callable by CANCELLER_ROLE) - Checked via
timelock.hasRole(OPTIMISTIC_PROPOSER_ROLE, address) - Revocation blocks new
proposeOptimistic()calls by that account - Execution of a succeeded optimistic proposal is done via
execute(...)and is not restricted to the original optimistic proposer
The guardian (CANCELLER_ROLE) is expected to revoke the optimistic proposer if they become malicious or otherwise compromised. This includes directly proposing malicious proposals as well as indirect griefing actions such as stuffing a proposal with excess data to increase the gas cost of veto actions.
The main hybrid governor contract.
Fast Proposal Functions:
proposeOptimistic(targets, values, calldatas, description)-- Create a fast proposal (requiresOPTIMISTIC_PROPOSER_ROLE)execute(targets, values, calldatas, descriptionHash)-- Execute a succeeded fast proposal (bypass path, no queue step)
Standard Proposal Functions (inherited from OZ Governor):
propose(targets, values, calldatas, description)-- Create a standard proposal (requiresproposalThreshold)castVote(proposalId, support)-- Cast a vote (works on both fast and slow proposals; optimistic proposals only allowsupport = 0/Against)queue(targets, values, calldatas, descriptionHash)-- Queue a succeeded standard proposal (optimistic proposals cannot be queued)execute(targets, values, calldatas, descriptionHash)-- Execute a queued standard proposal or a succeeded optimistic proposalcancel(targets, values, calldatas, descriptionHash)-- Cancel a proposal
Proposal Creation Rules:
proposeOptimistic()consumes proposer throttle chargepropose()rejects non-empty calldata calls to EOAs (InvalidCall) but allows pure ETH transfers to EOAs with empty calldataproposeOptimistic()requires each target to be a deployed contract and each calldata entry to include at least a selector (>=4 bytes)proposeOptimistic()requiresOPTIMISTIC_PROPOSER_ROLEand each(target, selector)to be allowlisted inOptimisticSelectorRegistry
State Query:
isOptimistic(proposalId)-- Returns whether proposal is optimistic metadatastate(proposalId)-- ReturnsProposalState(unified for both types)vetoThreshold(proposalId)-- Returns the veto threshold for an optimistic proposal (0 if standard)selectorRegistry()-- The OptimisticSelectorRegistry contract addressproposalNeedsQueuing(proposalId)-- Alwaysfalsefor optimistic proposals
Configuration:
setOptimisticParams(params)-- Update optimistic governance parameters (onlyGovernance)setProposalThrottle(capacity)-- Update proposals-per-24h throttle capacity (onlyGovernance)proposalThrottleCapacity()-- Read current throttle capacity
Whitelist of allowed (target, selector) pairs for optimistic proposals. Controlled by the timelock (governance-controlled).
Management (onlyTimelock):
registerSelectors(SelectorData[])-- Add allowed(target, selector)pairsunregisterSelectors(SelectorData[])-- Remove allowed pairs
Query:
isAllowed(target, selector)-- Check if a(target, selector)pair is whitelistedtargets()-- List all targets that have at least one registered selectorselectorsAllowed(target)-- List all allowed selectors for a given target
Constraints:
- Cannot register itself as a target
- The governor, timelock, and StakingVault (token) are additionally blocked as targets
Extended timelock supporting both flows.
- Slow proposals use standard
scheduleBatch()+executeBatch() - Fast proposals use
executeBatchBypass()for immediate execution (governor must holdPROPOSER_ROLEandEXECUTOR_ROLE) revokeOptimisticProposer(account)-- Revoke an optimistic proposer (requiresCANCELLER_ROLE)
ERC4626 vault with vote-locking and multi-token rewards. Users deposit tokens to receive vault shares that carry voting power.
IMPORTANT: StakingVault should only be deployed with an underlying token that has a STRONG value relationship to the system being governed. The token should not derive value from many sources. It is important that withdrawals that occur AFTER a malicious proposal executes do not recoup much value.
User Functions:
depositAndDelegate(assets)-- Deposit tokens and self-delegate voting powerclaimRewards(rewardTokens[])-- Claim accumulated rewards for specified reward tokenspoke()-- Trigger reward accrual without performing an action
Admin Functions (onlyOwner):
addRewardToken(rewardToken)-- Add a new reward token for distributionremoveRewardToken(rewardToken)-- Remove a reward token from distributionsetUnstakingDelay(delay)-- Set the delay before unstaked tokens can be claimedsetRewardRatio(rewardHalfLife)-- Set the exponential decay half-life for reward distribution
Other:
getAllRewardTokens()-- Return all active reward tokens
Properties:
- UUPS upgradeable (owner-authorized)
- Clock: timestamp-based (ERC5805)
- Creates an
UnstakingManagerduring initialization
| Feature | Supported |
|---|---|
| Multiple Entrypoints | ❌ |
| Pausable / Blocklist | ❌ |
| Fee-on-transfer | ❌ |
| ERC777 / Callback | ❌ |
| Upward-rebasing | ❌ |
| Downward-rebasing | ❌ |
| Revert on zero-value transfers | ✅ |
| Flash mint | ✅ |
| Missing return values | ✅ |
| No revert on failure | ✅ |
StakingVault asset tokens are assumed to be maximum 1e36 supply and up to 27 decimals.
If governance removes a reward token via removeRewardToken(), that token is disallowed from being re-added. Users can still claim already accrued rewards for removed/disallowed reward tokens via claimRewards().
Time-locked withdrawal manager, created by StakingVault during initialization.
Functions:
createLock(user, amount, unlockTime)-- Create a new unstaking lock (vault only)claimLock(lockId)-- Claim tokens after unlock time is reached (anyone can call; tokens go to lock owner)cancelLock(lockId)-- Cancel a lock and re-deposit tokens into the vault (lock owner only)
Lock Struct:
user-- Receiver of unstaked tokensamount-- Amount of tokens lockedunlockTime-- Timestamp when tokens become claimableclaimedAt-- Timestamp when claimed (0 if not yet claimed)
| Parameter | Type | Description |
|---|---|---|
vetoDelay |
uint48 |
Delay before veto voting starts (seconds) |
vetoPeriod |
uint32 |
Duration of veto window (seconds) |
vetoThreshold |
uint256 |
Fraction of supply needed to trigger confirmation (D18) |
| Parameter | Type | Description |
|---|---|---|
votingDelay |
uint48 |
Delay before voting snapshot |
votingPeriod |
uint32 |
Duration of voting window |
voteExtension |
uint48 |
Late quorum time extension |
proposalThreshold |
uint256 |
Fraction of supply needed to propose (D18) |
quorumNumerator |
uint256 |
Fraction of supply needed for quorum (D18) |
proposalThrottleCapacity |
uint256 |
Max proposals per proposer per 24h |
| Parameter | Constraint | Constant |
|---|---|---|
vetoDelay |
>= 1 second and < MAX_OPTIMISTIC_DELAY |
MIN_OPTIMISTIC_VETO_DELAY, MAX_OPTIMISTIC_DELAY |
vetoPeriod |
>= 15 minutes | MIN_OPTIMISTIC_VETO_PERIOD |
vetoThreshold |
> 0 and <= 100% | |
votingDelay |
< MAX_OPTIMISTIC_DELAY |
MAX_OPTIMISTIC_DELAY |
proposalThreshold |
> 0 and <= 100% | |
proposalThrottleCapacity |
>= 1 and <= 10 proposals/day | MAX_PROPOSAL_THROTTLE_CAPACITY |
- Throttle is tracked per proposer account for
proposeOptimistic() - Capacity is measured as proposals per 24 hours
- Each optimistic proposal consumes one unit of capacity
- Capacity recharges linearly over time (full recharge over 24 hours)
| Parameter | Constraint | Constant |
|---|---|---|
unstakingDelay |
<= 4 weeks | MAX_UNSTAKING_DELAY |
rewardHalfLife |
1 day to 2 weeks | MIN_REWARD_HALF_LIFE, MAX_REWARD_HALF_LIFE |
Fast (optimistic) proposals can only call (target, selector) pairs registered in the OptimisticSelectorRegistry. In addition, the following targets are always blocked at registration time (hardcoded in OptimisticSelectorRegistry):
- The
StakingVaultcontract (token) - The
ReserveOptimisticGovernorcontract - The
TimelockControllerOptimisticcontract - The
OptimisticSelectorRegistryitself
Any governance changes to the system itself must go through the slow proposal path with full community voting.
Additional optimistic validations:
- Every optimistic target must be a contract (no EOAs)
- Every optimistic calldata entry must be non-empty (>= 4 bytes selector)
Three contracts are UUPS upgradeable:
| Contract | Upgrade Authorization |
|---|---|
StakingVault |
Owner-authorized (onlyOwner) |
ReserveOptimisticGovernor |
Via governance (timelock must call upgradeToAndCall) |
TimelockControllerOptimistic |
Self-administered (only the timelock itself can upgrade) |
Fast Proposal:
proposeOptimistic() -> [vetoDelay: PENDING] -> [vetoPeriod: ACTIVE]
|
+-- threshold not met --> SUCCEEDED -> execute() -> EXECUTED (bypass)
|
+-- threshold reached --> DEFEATED (original)
-> Confirmation Proposal (new id)
-> [voting delay: PENDING]
-> [voting period: ACTIVE]
-> queue() -> [timelock] -> execute()
Slow Proposal:
propose() -> [voting delay] -> [voting period] -> queue() -> [timelock] -> execute()