Skip to content

Gauges — Design Decisions & Accepted Trade-offs

The behaviours below are properties of the gauge system and are intentional. Each has been raised in a previous security review and accepted as design rather than fixed, and each is recorded here so that reviewers can tell a deliberate trade-off from a defect. References are to the Pashov Audit Group reviews of the gauge scope — v3 (2026-08), v2 (2026-07) and v1 (2026-06) — and to the internal gauge review that preceded them. Findings that were accepted and fixed are not listed here; they are no longer present in the code.

Some earlier acknowledgements no longer describe the code

The pause model was redesigned after the v3 review. Three findings that were acknowledged under the old coupling of pause and deactivation — v1 L-02, v2 L-05 and v2 L-06 — have been resolved by that redesign rather than by argument. They are recorded below under Pausing a gauge no longer deactivates it so that a reviewer reading the older assessments is not misled by them.

Raised in the most recent review

Working balances are snapshots, so boosts drift between updates

v3 L-06, v3 L-07, v1 L-14

A gauge accrues rewards as rate × duration / workingSupply, evaluated over the interval since the last touch. Both sides of that ratio are cached: a user's working balance uses the boost that applied when they were last updated, and the working supply is the sum of those cached balances.

Neither input is static in reality. A user's boost derives from their veRAAC bias, which decays continuously, and from the gauge's raw total supply, which moves as others stake and withdraw. So over any interval some stakers are over-boosted and others under-boosted relative to a continuously-evaluated ideal.

Tracking every staker's decaying bias in real time is not implementable on-chain at this cost. The accepted answer is an approximation plus a correction: the cached working supply stands in for the interval, and updateUserRewards(user) is permissionless, so anyone can force a stale staker — including a temporarily inflated one — to be re-settled and written down. Since a kick is profitable for every other staker in the gauge, the correction is expected to be applied without the protocol having to pay for it.

The same reasoning covers the working-balance formula itself (v2 M-06): capping 0.4 × stake + 0.6 × totalStake × veShare at the stake is the documented spec behaviour, not an artefact.

Ragequitting in the snapshot block still earns that block's distribution

v3 L-03

veRAAC distributions snapshot voting power at block.number - 1. A user who ragequits in the same block as a distribution still held an active lock at the snapshot, so they remain eligible for it.

This is the intended consequence of snapshotting the previous block, which exists to stop a distribution being front-run by a fresh lock. Two cases follow from it and are both accepted: a user ragequitting alongside someone else's ragequit is eligible for that other user's penalty distribution, and a user ragequitting in the same block as distributeRewards is eligible for it. Only the ragequitter's own penalty excludes them, through excludedAddress.

Pausing a gauge no longer deactivates it

supersedes v1 L-02, v2 L-05, v2 L-06

Earlier versions coupled the two: pausing a gauge also deactivated it, and the global pause looped over every active gauge kicking each one. Three findings were acknowledged against that design — pause and activation being separate operational decisions (v1 L-02), a global unpause restoring nothing (v2 L-06), and gauges needing manual reactivation afterwards (v2 L-05).

The coupling has since been removed. The system now has three states — Active, Paused, Removed — and "active" means only "registered". A paused gauge keeps its votes and keeps accruing its share, which is held in the distributor until it is unpaused and pulled. setEmergencyPause flips a single flag: O(1) at any gauge count, no loop, no kick, and unpausing restores the previous state exactly.

The reasoning behind the change is worth stating, because it explains why deactivation was never needed. Deactivation was introduced to fix a real bug — a gauge that kept its weight in the global sum while being unable to claim, starving the others. But a gauge that is paused and still active can claim after unpause: the distributor walks every missed epoch. Its share is held, not stranded. And for a global pause, deactivation achieves nothing at all — redirecting weight only helps if some gauges remain to receive it, and a global pause covers them all. It destroyed every vote and stranded emissions for no benefit.

Two consequences are deliberate. The two switches never touch each other, so lifting the global pause cannot reopen a gauge that was paused individually. And setEmergencyGaugePause(gauge, false) is allowed on an unregistered gauge, because addGauge rejects a paused one — without that exception a gauge removed while paused could be neither unpaused nor re-added.

The batch pause reverts rather than skipping

The redesign proposal called for setEmergencyGaugePauseBatch to skip gauges already in the requested state. The implementation is all-or-nothing instead: a zero address, an unregistered gauge being paused, or any gauge already in its target state reverts the whole call. Operators must build the list from current state.

Acknowledged in earlier reviews

The table lists every finding on the gauge path that an earlier review raised and the protocol accepted as design. The ones with material consequences are expanded below it.

Ref Review Behaviour
M-01 v2 · 2026-07 Kick gas stays under the block limit even for a 52-lock voter
M-04, M-05 v2 · 2026-07 A kick does not reset the voter's per-gauge cooldown
L-04 v2 · 2026-07 Nothing prevents registering the main reward token as an extra token
L-06 v2 · 2026-07 Which vote path a user takes depends on lock count and configuration
L-07 v2 · 2026-07 Clearing phantom votes relies on keepers and aligned voters
L-08, L-09 v2 · 2026-07 Rewards accrued at zero working supply go to the next staker
L-10 v2 · 2026-07 Per-gauge decay remainders are not pre-settled across all gauges
L-11, L-12 v2 · 2026-07 Reward pulls are permissionless and their timing is part of the game
L-13 v2 · 2026-07 The Liquid Locker is not permissionlessly kickable
L-14 v2 · 2026-07 Sub-threshold veRAAC rewards route to the treasury
L-15 v2 · 2026-07 Ragequit gas-griefing of reward claims is uneconomical
M-06 v1 · 2026-06 Leftover rewards are freed by the next deposit, not swept
M-08 v1 · 2026-06 The lastUpdateTime freeze at zero working supply is intentional
L-04 v1 · 2026-06 Boost amplification is enabled on every registration
L-06 v1 · 2026-06 A ragequitter is kickable only until finalizeRagequit
L-11 v1 · 2026-06 A zero vote with no voting power is a no-op
L-13 v1 · 2026-06 RAAC autolock on claim has no unexpected revert path
L-01 v1 · 2026-06 Working balances are always refreshed after settlement, never before

Rewards accrued at zero working supply go to the next staker

v1 M-08, v2 L-08, v2 L-09

If every staker leaves mid-period, or rewards are pulled into a gauge while nothing is staked, the elapsed time belongs to nobody. lastUpdateTime is deliberately frozen at the last moment working supply was positive, and the next staker to arrive is credited for the interval.

The freeze is what makes the accounting closeable. Without it there would be no way to measure how long the gauge sat empty, and the unattributed amount could not be computed — at period end, periodFinish - lastUpdateTime is exactly that span, and it is banked in leftoverRewards. The incoming staker is not taking anything from anyone: no one else had a working balance during that interval.

Reward pulls are permissionless, and their timing is part of the game

v2 L-11, v2 L-12

pullRewardsFromDistributor can be called by anyone, and pulling early rather than late changes who benefits: rewards already inside the gauge stream to whoever is staked from that moment.

This is treated as normal gauge game theory rather than a flaw. Participants are incentivised to pull promptly precisely so that late entrants do not catch a large backlog, and because vote assigns weights to the next epoch, everyone can see which gauges will be heaviest before the epoch turns and position accordingly. Making the pull permissionless gives every participant control over that timing instead of concentrating it in a keeper.

A kick does not reset the voter's per-gauge cooldown

v2 M-04, v2 M-05

kick clears a ragequitter's votes without touching lastVoteTime, so the freed allocation cannot immediately be re-voted onto the same gauge — the 10-day WEIGHT_VOTE_DELAY still applies from their original vote.

That is the intended asymmetry: a kick is a cleanup performed by a third party, not a vote by the user, so it should not refresh their cooldown. It also closes the reallocation route the finding describes, since a user who sacrifices a small early lock to clear an allocation is in the 7-day ragequit cooldown with zero voting power and cannot vote anywhere until finalizeRagequit.

Clearing phantom votes relies on keepers and aligned voters

v2 L-07

A ragequitter's votes keep decaying gauge aggregates until someone calls kick. There is no automatic trigger.

The 7-day ragequit cooldown is the buffer, and kick is permissionless for exactly this reason: every other voter in the silo gains from removing a ragequitter's phantom weight, and an off-chain keeper covers the rest. The combination is judged sufficient over a week-long window.

Kick gas stays bounded even for a maximal voter

v2 M-01

A single voter can hold at most 52 veRAAC locks. With maxVoteBuckets = 0 (exact per-lock scheduling), kicking that voter off one fully-allocated gauge measured at ~171.5k gas. With MIN_VOTE_WEIGHT at 1 %, one user can hold allocations on at most 100 gauges, bounding a full kick at roughly 17.2M gas — under the mainnet block limit.

maxVoteBuckets will be set non-zero in production anyway, which caps the per-vote work at O(K) and removes the scenario.

Per-gauge decay remainders are not pre-settled

v2 L-10

Each gauge's weekly bias decay rounds down independently, so the sum of the gauges' decays can differ slightly from the global decay. Settling this would require checkpointing every active gauge before reading any one of them.

That does not scale: the cost of a sweep over all gauges grows with the silo, which is the property the whole lazy-checkpoint design exists to avoid. The alternative recommendation was a restatement of what the code already does. The rounding difference is accepted as the price of O(1) routine operations.

Leftover rewards are freed by the next deposit, not swept

v1 M-06

Rewards banked in leftoverRewards are rolled into the next period's rate, so they are released by the next deposit rather than recovered by a dedicated path. They are only stranded if no further deposit ever arrives.

For the RAAC silo that means after the minter's emission schedule ends — roughly 15 years — and for the RWA silo, if rental income stopped permanently. In either case the protocol can deposit into the distributor to release them, since deposit is permissionless.

The main reward token is not blocked from being registered as an extra token

v2 L-04

addRewardToken does not reject the gauge's own main reward token. Doing so would be self-defeating in practice and is not expected to happen: extra tokens and the main token are not meant to interleave, and a silo only ever distributes its own token, so no gauge outside the RAAC silo handles RAAC at all. Were it to happen, an extra-token stream could not start without a deliberate depositRewardToken, which the team would catch first.

Which vote path a user takes depends on lock count and configuration

v2 L-06

Whether a vote is scheduled per-lock or through the K-bucket approximation depends on how many locks the voter has and on the current maxVoteBuckets. Two voters with identical positions can therefore take different paths if setMaxVoteBuckets was called between their votes.

Normalising this would mean filtering locks that expire before the next epoch out of an already loop-heavy path, costing gas on every vote to remove a difference with no user-visible impact. With K = 0 every vote takes the exact path regardless.

The Liquid Locker is not permissionlessly kickable

v2 L-13, v1 L-01

updateUserRewards reverts with NotKickable for the Liquid Locker. Its pooled working balance is already refreshed through stake, withdraw and claimOnBehalfOf, all of which it calls in normal operation, so a third-party kick adds nothing.

More generally, no path in BaseGauge refreshes a working balance before settling rewards: _updateUserRewards settles at the cached balance first and updates afterwards, so a kick can never retroactively reprice an interval.

Sub-threshold veRAAC rewards route to the treasury

v2 L-14

When accumulated veRAAC rewards stay below minRewardDistributionAmount, they are sent to the treasury rather than held. Those amounts are not lost — they can be folded into a later distribution — and with the threshold at 1e18 the amounts involved are negligible. The underlying reason is described under the veRAAC trade-offs.

Ragequit gas-griefing of reward claims is uneconomical

v2 L-15

Forcing many small veRAAC distributions, to inflate the number a claimer must walk, requires locking enough RAAC across enough accounts that each ragequit's variable fee clears 1e18, and repeating it a hundred times or more. The attacker pays the full ragequit penalty each time. The payoff is a delay, not a denial: claimReward takes a maxDistributions bound, so a claimer can always page through.

Smaller acknowledgements

  • Boost amplification is enabled on registration (v1 L-04) — every gauge starts with the flag on regardless of prior state; setBoostAmplificationEnabled toggles it deliberately.
  • A ragequitter stops being kickable at finalizeRagequit (v1 L-06) — voting power is restored at that point, so there is nothing phantom left to clear.
  • A zero vote with no voting power is a no-op (v1 L-11) — there is no impact in refusing it, and the system correctly zeroes a prior vote when a new lock is created before revoting.
  • RAAC autolock on claim has no unexpected revert path (v1 L-13) — the gauge holds ON_BEHALF_OF_ROLE, approves before depositing, and raacAutolockMin is always above veRAAC's minLockAmount.