Summary
Timeline: From 2025-08-19 To 2025-08-27
Languages: Solidity
Total Issues: 40 (0 resolved)
Critical Severity Issues: 1 (0 resolved)
High Severity Issues: 1 (0 resolved)
Medium Severity Issues: 3 (0 resolved)
Low Severity Issues: 12 (0 resolved)
Notes & Additional Information: 23 (0 resolved)
Scope
OpenZeppelin audited the camconrad/compensator repository at commit fd69763.
In scope were the following files:
contracts
├── Compensator.sol
└── CompensatorFactory.sol
System Overview
The Compensator
system introduces a standardized way for COMP token holders to delegate voting power, earn rewards, and participate in governance-aligned staking around Compound governance proposals. The design supports two primary components:
Compensator
(ERC-20, non-transferable): A per-owner delegate contract that aggregates delegated COMP voting power, streams rewards to depositors, casts votes on Compound proposals, and manages proposal-related staking and resolution.CompensatorFactory
: A factory and registry contract that deploys newCompensator
instances, tracks ownership, and ensures that each owner can only control a single Compensator at a time.
The Compensator
contract, which holds delegated COMP, locks user deposits for a minimum period, and updates a reward index to distribute COMP rewards deposited by the owner. Rewards are distributed over time at a configurable rate, capped to prevent over-distribution. In parallel, users may stake COMP “for” or “against” active Compound governance proposals. Once a proposal gets resolved, stakes from the losing side are reclaimable by users, while winning-side stakes are awarded to the Compensator owner if their recorded vote matched the outcome, creating direct performance incentives.
The CompensatorFactory
contract allows delegates to deploy Compensator
instances and tracks their addresses and owners.
Security Model and Trust Assumptions
The following assumptions about roles, external dependencies, and timing are critical to preserving the safety of the delegators and the integrity of proposal resolution:
- The Compensator owner is trusted to manage rewards, cast votes, configure parameters, and control the
onlyOwner
-guarded functions. Delegators assume that the Compensator owner will behave honestly in voting and reward management. - The system assumes that the provided COMP token and Compound Governor addresses are correct and immutable. It trusts their ERC-20 behavior, delegation mechanics, and governance state transitions (
Active
,Succeeded
,Defeated
, etc.). - Deposits are subject to a minimum 7-day lock, extended automatically if proposals are active or pending. This ensures that voting power remains stable during governance windows and prevents short-term delegation churn.
- Depositors trust the Compensator owner not to act maliciously by withdrawing unaccrued rewards or reducing the reward rate shortly after the users deposit.
- Proposal outcomes are determined by the Compound Governor. Auto-resolution after a 30-day timeout treats unresolved proposals as
Defeated
. Proposal-resolution logic assumes that the Governor correctly enforces proposal state transitions. - If the delegate’s vote matches the winning outcome, the owner may claim 100% of the winning stakes. If not, stakes remain with users. This introduces strong incentives but also shifts risk to delegators. Users must trust that the owner will aim to vote correctly.
- Reward streaming is capped by available deposits and calculated through an index system. The system prevents over-distribution by halting accruals once pending rewards meet available balances.
- Only the Compensators deployed by the Factory can call back into
onOwnershipTransferred
. This ensures registry integrity, prevents arbitrary contracts from corrupting the mapping, and enforces aone-owner-one-compensator
rule.
Privileged Roles
The Compensator system defines three important roles: Owner, Delegator, and Staker.
- Owner: Sole privileged operator. Manages rewards (funding, withdrawal, and rate changes), casts votes on Compound proposals, and controls configuration via the
onlyOwner
modifier. Delegators must trust the owner not to manipulate rewards after deposits. - Delegators: Deposit COMP tokens to delegate voting power and earn rewards. They are subject to a 7-day minimum lock period and can later withdraw and claim rewards. Their participation is incentivized by the reward distribution.
- Stakers: Commit COMP tokens to active proposals. After resolution, losing stakes are reclaimable while winning stakes will be awarded to the owner if the delegate’s staking direction and the owner’s voting direction align with the proposal outcome.
Critical Severity
Incorrect Reward Accounting Can Lead to Compensator
Insolvency
The Compensator
contract tracks the rewards available for distribution among the delegators in the availableRewards
state variable. Its value should correspond to the amount of COMP tokens the contract is holding for this purpose. This variable is increased by the ownerDeposit
function and decreased by the ownerWithdraw
function. However, it is not decreased when rewards are claimed by delegators.
The availableRewards
value not decreasing when rewards are claimed leads to a misaccounting of the COMP balance that is actually available for distribution. This discrepancy causes several issues:
- The
rewardsUntil
view
function may return an inaccurate timestamp, as the contract will not have sufficient COMP tokens to pay rewards until the projected time. - The owner would be able to withdraw tokens using the
ownerWithdraw
function that should be reserved for delegator rewards. - The reward distribution cap will not function correctly. It may allow for the distribution of more rewards than are available in the contract’s balance, causing
claimRewards
transactions to revert due to insufficient funds, or it may allow delegators to claim COMP tokens as rewards that should account for another part of the system. - The
_getCurrentRewardsIndex
private
function could calculate an incorrectly large reward index, which in turn affects the value returned by thegetPendingRewards
view
function.
To ensure the correct accounting of distributable rewards and to prevent the Compensator
contract from becoming insolvent, consider updating the availableRewards
state variable within the claimRewards
function.
High Severity
Winning Stakers Lose Funds if the Owner Votes Incorrectly
A flaw exists in the proposal resolution logic which surfaces when the owner of the Compensator
contract, votes against the winning outcome of a proposal or fails to vote at all. In such a scenario, the _resolveProposalInternal
function correctly withholds the reward from the owner but fails to create a mechanism to return the staked funds to the stakers who correctly staked on the winning side. The corresponding reclaimStake
function is exclusively designed to return funds to stakers on the losing side of a proposal. This creates a situation where there is no code path for stakers who correctly staked on the winning side to withdraw their funds, as reclaimStake
will revert their transaction, effectively trapping their assets in the contract.
The primary consequence of this issue is that stakers permanently lose their funds if the owner of the Compensator
contract, casts an incorrect vote, regardless of the users having correctly staked on the winning outcome. This flaw severely undermines the trust and economic incentive of the staking mechanism, as it punishes the accurate stakers for a mistake made by the owner, over whom they have no direct control.
Consider modifying the reclaimStake
function to allow stakers to reclaim their funds if they staked the losing side, or if the owner voted against the winning side.
Medium Severity
Queued
State Not Treated as a Resolved Outcome in resolveProposal
The resolveProposal
function only proceeds when the governor state is either Succeeded, Defeated, Expired, Canceled, or Executed
and reverts otherwise. It omits Queued
, which, in Compound-style governance, indicates that the proposal has passed voting and is in the timelock period, awaiting execution. As a result, proposals in Queued
state are incorrectly considered as not resolved, causing ProposalNotResolvedYet
reverts despite their voting outcome being finalized.
Excluding the Queued
state blocks timely stake resolution and reward flows after voting concludes. Users cannot reclaim according to the final vote outcome, and the owner cannot receive winning-side distributions until execution or later state changes. This creates unnecessary delays during the timelock period and can degrade user experience, liquidity, and accounting clarity, especially for proposals that remain queued for extended windows.
Consider including the Queued
state in the allowlist of resolvable states so that stake distribution and reclaim paths can proceed.
Voting Power Accounting Mismatch in castVote
The Compensator
contract records voting power using the getCurrentVotes
function on COMP_TOKEN
at call time and stores/emits that value in the VoteInfo
and delegateInfo
variables. The Compound Governor counts votes using getPriorVotes(account, proposalSnapshot(proposalId))
at the proposal’s snapshot block (block at which the proposal voting starts). Vote delegations, COMP token transfers, or vote re-delegations in the Compensator
contract, between the proposal snapshot
and the castVote transaction
, can cause these two values to diverge, making the contract’s internal record and emitted data inconsistent with what the Governor actually counted and voted with.
This mismatch can inflate the VoteInfo.votingPower
, delegateInfo.totalVotingPowerUsed
, and averageVotingPowerPerVote
values, misleading dashboards and off-chain tools that rely on these them. In addition, it breaks the voting power state maintained by the Compensator
contract in its VoteInfo
and DelegateInfo
structs.
Consider aligning the Compensator
contract’s accounting of the voting power with that of the Governor by fetching the snapshot block via the COMPOUND_GOVERNOR
contract and then getting the voting power at that block. In addition, consider using this aligned voting power for the VoteInfo.votingPower
and DelegateInfo
computations performed in the _castVote
function.
Missing one owner → one compensator
Invariant in CompensatorFactory
During Ownership Transfer
The createCompensator
implementation enforces that each user gets their own Compensator instance, deploys a new Compensator instance for each user, and makes sure that the transaction reverts with OwnerAlreadyHasCompensator
if a user that already has a Compensator tries to deploy a second one. Therefore, the contract is expected to preserve the invariant (“one owner → one compensator”).
However, the onOwnershipTransferred
function updates ownerToCompensator[newOwner]
without first verifying that newOwner
does not already have a compensator. As a result, a legitimate transfer can assign a compensator to a newOwner
who is already mapped to another compensator, silently overwriting the registry entry and leaving multiple live compensators owned by the same address while the factory’s single-pointer mapping no longer reflects reality.
As a result:
- the
compensatorToOriginalOwner
mapping will have two compensators with the same owner - the
ownerToCompensator
mapping will map the new owner to the compensator which changed the ownership
This breaks the CompensatorFactory
contract’s core uniqueness invariant and corrupts the state used by off-chain services to discover an owner’s compensator, leading to unexpected behavior.
Consider enforcing the “one owner → one compensator” invariant during ownership transfers in onOwnershipTransferred
, similar to how it is enforced in createCompensator
.
Low Severity
Premature Resolution of Proposals in Succeeded
State
The Compensator
contract currently treats a proposal in the Succeeded
state as finalized by setting winningSupport = 1
and distributing outcomes. However, in the Compound governance flow, Succeeded
is not terminal: a proposal can later be queued and executed, but it may also be canceled or expire during the timelock. Resolving stakes at the Succeeded
stage is therefore premature, as the ultimate outcome of the proposal is not yet guaranteed.
By resolving proposals in the Succeeded
state, Compensator
risks distributing rewards and allowing stake reclaims incorrectly if the proposal is later canceled or expired. This can lead to situations where the owner is rewarded as though the proposal succeeded, even if it never does. This discrepancy undermines fairness, breaks alignment with the proposal lifecycle, and may result in permanently incorrect accounting for staked funds.
Consider restricting resolution to truly terminal states only: Executed
(map to “For won”), and Defeated
, Canceled
, or Expired
(map to “Against won”). Exclude Succeeded
from the resolution logic, or only resolve it if the contract’s intended behavior is to reward vote outcomes irrespective of later execution. This change ensures that distributions reflect final governance outcomes and prevents misaligned stake handling.
VerifyVote
Fails After 256 Blocks Due to Reliance on blockhash
The verifyVote
function depends on comparing stored block hashes against blockhash(blockNumber - 1)
. However, the EVM only makes block hashes accessible for the most recent 256 blocks. Once this limit is exceeded, blockhash
returns 0x0
, causing the verification to fail for older proposals. This design guarantees that all vote verifications will start returning false negatives after ~256 blocks. As a result, any governance checks that rely on verifyVote
will become unusable over time, limiting the function’s effectiveness for verifying votes after a delay.
Consider removing the block hash comparison from verifyVote
or replacing it with alternative verification mechanisms that do not expire.
getContractVotingPowerAt
Returns Incorrect Historical Voting Power
The getContractVotingPowerAt
function is intended to return the voting power of the Compensator
contract at a specific, historical block number. However, the function’s implementation incorrectly retrieves the current voting power by calling getCurrentVotes
on the COMP token contract instead of the voting power at the specified block. This results in the function returning an incorrect value.
Consider using the getPriorVotes
function of the COMP token contract. It will correctly fetch the historical voting power at the given block number, aligning the function’s behavior with its intended purpose.
Inconsistent Proposal State Check in _castVote
The _castVote
function verifies that a proposal’s state is either Pending
or Active
before allowing the execution to proceed. A similar check is performed in the verifyVote
function. However, the subsequent external call to the COMPOUND_GOVERNOR.castVote
function includes a more restrictive check, only allowing votes to be cast on proposals in the Active
state. This inconsistency means that any transaction for a Pending
proposal will pass the internal check but will always revert due to the external call’s requirement, potentially causing unexpected reverts.
It is advisable to remove the check for the Pending
state from both the _castVote
and verifyVote
functions. This would align the contract’s internal logic with the external requirements and prevent misleading validations.
rewardsUntil
Calculation Uses Incorrect Precision
The rewardsUntil
function is used to determine the remaining time for reward distribution. However, the resulting remainingRewardsTime
value is incorrectly scaled by a factor of 1e18. This causes the final units to be seconds * 1e18 instead of just seconds.
Consider removing the unnecessary scaling factor from the calculation to ensure that the resulting time value is correctly represented in seconds.
Incorrect handling of Pending
proposals as Active
in _updateLatestProposalId
The _updateLatestProposalId
function currently marks proposals as active when the governor reports them as either Active
or Pending
. This conflates two different states and causes the contract’s activeProposals
mapping to include proposals that have not yet reached the voting period. As a result, the internal tracking does not distinguish between proposals that are already open for voting and those that are only scheduled to begin.
By treating pending proposals as active, the contract introduces ambiguity. This causes premature activation events and incorrect lock period extensions for users, and it misleads external systems that rely on emitted events. Furthermore, since Pending
proposals are already considered active
in the activeProposals
mapping, the separate pendingProposals
mapping becomes redundant in _hasUserActiveStakes
and _hasRelevantActiveProposals
, where it adds no unique functionality and only complicates the logic.
The pendingProposals
and activeProposals
mappings are only used in the _hasUserActiveStakes
and _hasRelevantActiveProposals
functions. Since using internal accounting is error-prone and can give stale results, consider fetching the proposal state directly from the Governor contract on-demand when required.
Incorrect Default blocksPerDay
Narrows the “within 1 day” Pending Window on Ethereum
The Compensator
contract marks a proposal as about to start when startBlock > block.number && (startBlock - block.number) < blocksPerDay
. The default blocksPerDay
value is set to 6,500, which reflects a pre-Merge estimate. Presently, the Ethereum mainnet averages roughly 7,100–7,200 blocks/day. This means that the intended 24-hour window is effectively ~22 hours with the current configuration. Hence, proposals starting between ~22–24 hours from now may not be flagged in pendingProposals
.
The underestimated window can cause proposals that are less than a day away to be missed by pendingProposals
, which, in turn, slightly skews logic that references it (e.g., _hasUserActiveStakes
and _hasRelevantActiveProposals
) for lock extensions and withdrawal gating.
Consider setting the blocksPerDay
variable to a default value that more accurately represents the full 24 hours (e.g., 7,100 for Ethereum mainnet). In addition, consider documenting that blocksPerDay
must be explicitly configured at deployment for each network.
Misleading Documentation
Throughout the codebase, multiple instances of misleading documentation were identified:
- The
txHash
member of theVoteInfo
struct is described as the transaction hash of the vote. However, it is set to the blockhash of the previous block in the_castVote
function. - The
delegateInfo
variable is described as a mapping in the NatSpec docstring. - The comments in lines 1039 and 1063 state that caching storage variables is a reentrancy-prevention technique. However, this is a gas-optimization technique.
- The NatSpec for the
lastRewarded
variable states that the timestamp is updated when the rewards are claimed. However, the timestamp is updated when the rewards are accrued. - The NatSpec for the
getPendingRewards
function states that it accounts for both “claimed and unclaimed rewards”, whereas, it only accounts for unclaimed rewards. - The NatSpec for the
canUserWithdraw
function states that the returnedreason
variable denotes “the reason why withdrawal is blocked”. However, it denotes the reason when the withdrawal is allowed as well. - The NatSpec for the
userWithdraw
function states that the function claims rewards. However, it only accrues them by calling_updateUserRewards
. Users may wrongly assume rewards are automatically transferred during withdrawal.
Consider correcting the aforementioned instances of misleading documentation to improve the clarity and maintainability of the codebase.
Possible Duplicate Event Emissions
When a setter function does not check if the value being set is different from the existing one, it becomes possible to set the same value repeatedly, creating a possibility for event spamming. Repeated emission of identical events can also confuse off-chain clients.
The _updateRewardsIndex
function sets the rewardIndex
variable and emits an event without checking if the value has changed. In addition, the emit
statement in line 1085 emits a RewardIndexUpdated
event without updating the rewardIndex
.
Consider adding a check that reverts the transaction if the value being set is the same as the existing one.
Undocumented Code
In the getContractVotingPowerAt
function, the input
parameter is not documented.
Consider thoroughly documenting all functions/events (and their parameters or return values) that are part of a contract’s public API. When writing docstrings, consider following the Ethereum Natural Specification Format (NatSpec).
Missing Docstring
The voteCount
state variable in the Compensator.sol
contract does not have a docstring.
Consider thoroughly documenting all state variables to improve the overall clarity and maintainability of the codebase.
Factory Ownership Tracking Can Desynchronize
Within Compensator.sol
, in line 1224, the overridden transferOwnership
function is meant to keep the factory’s ownerToCompensator
mapping in sync with the actual owner of the Compensator
contract. After calling super.transferOwnership(newOwner)
, the function tries to notify the ownership transference to the factory. If the factory call reverts (due to a fault in the logic), the catch-all clause silently swallows the error.
At that moment, the Compensator
has already changed hands, while the factory still believes that the old owner controls it. Any system component that relies on the factory’s mapping (e.g., front-ends, other contracts, permission checks, etc.) will now operate on stale data, leading to inconsistent or insecure behavior. Since the stated purpose of the call is to guarantee synchronization, letting it fail without reverting is a logical error.
Consider avoiding architectures where the state of one contract is tracked in another. Instead, any system that needs to ascertain the owner of a Compensator instance should query the contract directly. This practice eliminates data redundancy and removes the possibility of synchronization errors.