Skip to content

veRAACToken — Design Decisions & Accepted Trade-offs

The behaviours below are properties of the veRAACToken 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 governance scope — v3 (2026-04, VeRAAC), v2 (2026-03, VeRAAC) and v1 (2026-02, VeRAAC) — and to the internal invariant suite at test/InvariantSuite/VeRAACToken, whose recorded breaks are cited as IB-n. Findings that were accepted and fixed — ragequit bias immutability (v3 H-01, M-03, M-04, M-05), reward cursor advancement (v3 M-01, M-02), dust voting power at expiry (v2 L-12), claimable rounding parity (v2 L-02) and the getTotalLocked boundary iteration (v3 L-10) — are not listed here; they are no longer present in the code.

Raised in the most recent review

rawBalanceOf is allowed to drift above the live voting power

withdraw() does not call _syncBalance and writes no checkpoint. The ERC-20 balance minted to a user is only re-synced when they next call a checkpointing function (lock, increase, extend, a ragequit entry point, or finalizeRagequit). Between two interactions rawBalanceOf(user) is therefore higher than balanceOf(user), and over time rawTotalSupply() drifts above totalSupply().

Bias decay is handled entirely inside LockManager._decayBias, which already discounts principal that expired at each week boundary — so the authoritative reading, balanceOf, is correct at all times without a checkpoint on withdrawal. Adding _syncBalance to withdraw would force the function to evaluate balanceOf and totalSupply, both of which walk week-by-week loops, purely to correct a number the protocol does not read. The operational requirement is that integrators read balanceOf, balanceOfAt or balanceOfAtTime; rawBalanceOf and rawTotalSupply are diagnostic views of the minted ERC-20 supply and are not a power measurement.

The reward claim cursor advances lazily

lastProcessedDistributionIndex only advances across the leading run of fully processed distributions. A partially vested distribution at the front of the queue holds the cursor in place, so later distributions are re-scanned on each call until it settles.

This is accepted with no known incorrect behaviour: re-scanning a settled distribution is cheap, since the epochsClaimed >= totalEpochs check short-circuits it, and an out-of-order cursor would need per-distribution bookkeeping that the pagination bound already substitutes for. getMaxUnclaimedDistributions tells callers how far to page.

ragequitLock only accepts the earliest-expiring position

ragequitLock(epochEnd) reverts unless epochEnd == getMinLockEnd(caller).

A user's locks array is not sorted by unlock time, and nextUnlockIndex only moves forward. Allowing an arbitrary position to be exited — and advancing the cursor to match — would leave valid positions with non-zero amounts stranded behind the cursor, invisible to getLocks, withdraw and every fee computation. Restricting the exit to the minimum epoch end keeps the cursor and the array consistent. A user who needs to exit a later position uses ragequitAll().

Topping up a lock close to expiry mints short-lived voting power

increase(amount, epochEnd) values the added principal over the position's remaining duration, so a large top-up one week before expiry mints a small amount of power that decays away within the week.

This is the system working as specified, not a griefing vector. The same shape arises from creating a one-epoch lock with a large amount, or simply from normal decay. Power is priced by remaining commitment; a short commitment buys short power.

Sub-threshold ragequit fees are redirected to the Treasury

When the holder leg of a variable exit fee is below minRewardDistributionAmount, it goes to the Treasury rather than into pendingRewardAmount.

This is a correctness requirement rather than an optimisation. A distribution funded by a ragequit records the ragequitter as excludedAddress so they cannot claim from their own penalty. If the amount were accumulated into the pending bucket instead, it would be folded into the next distribution — which carries no exclusion — and the ragequitter would end up claiming a share of their own exit fee. Redirecting to the Treasury is what keeps the exclusion meaningful.

Reward payouts round down and can settle to a zero-value transfer

Every step of the reward path rounds in the protocol's favour: the per-epoch split truncates, and _scaleFrom18 truncates when converting back to a token with fewer than 18 decimals. A user whose voting power was very small at a distribution's snapshot can end up with a payout of 0 and a zero-value Transfer event.

The amounts involved are sub-wei in the reward token's own precision — a user in this position was owed less than one indivisible unit. Rounding the other way would let the sum of claims exceed the distributed amount. claimable() mirrors the same rounding exactly: it clamps userShare to the full distribution amount, derives the per-epoch amount the same way, applies _scaleFrom18 before accumulating and caps the result at dist.amount - dist.totalClaimed, so the view can never overstate what claimReward() will pay. Getter/actual equivalence is covered by invariant 8 of the veRAAC invariant suite.

Exit fees can round to zero and never consume the principal

computeFeesForLock rounds the fixed fee up and the variable fee down. Both are strict fractions of the principal — 5 % and at most 50 % — so their sum can never reach 100 % and netAmount = amount - fixedFee - variableFee cannot underflow.

For a small enough principal both fees truncate to zero and the user exits free. A position small enough for a 5 % fee to round to zero is smaller than the gas required to exit it.

Reward-token administration is trusted and deliberately unconstrained

Three related behaviours follow from addRewardToken, initiateRemoveRewardToken, finalizeRemoveRewardToken, setRewardTokenRemovalCooldown and distributeRewards all being gated by onlyOwner or REWARD_DISTRIBUTOR_ROLE:

  • distributeRewards does not check removalInitiatedAt, so a token pending removal can still receive a distribution. The protocol simply does not distribute into a token it is removing; the check is left out rather than enforced in code.
  • finalizeRemoveRewardToken reads REWARD_REMOVAL_COOLDOWN at call time, so the owner can lengthen an in-flight removal window. The intent is only ever to extend it, giving users more time to claim. The realistic reason to remove a reward token at all is that the token itself has been compromised, in which case that flexibility is useful.
  • minRewardDistributionAmount is owner-settable above a 1e18 floor so that a reward token whose price has moved can open a distribution immediately rather than sitting in the pending bucket.

RAAC itself is registered in the constructor and is the destination of every ragequit holder leg; removing it is not something the protocol intends to do (v2 L-18).

maxLockEpochs is fixed at 52 and there is no setter

The constructor takes _maxLockEpochs and every RAAC deployment passes 52. No function in VeRAACToken or LockManager can change it afterwards, which is why setMinLockAmount and setMinIncreaseAmount can reason about maxTime as a constant.

Unfinalised ragequits do not accumulate into a denial of service

A user who initiates a ragequit and never finalises it leaves a stored request and a frozen account. Because the request is the only way to receive the net RAAC, abandoning it means abandoning the funds; and the arrays a stale request touches are pruned on every interaction. The volume of abandoned requests required to push any loop past the block gas limit is not reachable in practice.

Several checkpoints can share a block number and timestamp

IB-1

A single transaction can push more than one Point onto pointHistory and onto a user's userPointHistoryragequitLock, for instance, writes two — so the arrays contain consecutive entries carrying an identical ts and blk.

A change that collapsed those duplicates was implemented and then reverted: the intermediate points turn out to be load-bearing for ragequit execution, which reads the pre-exit point while writing the post-exit one. Removing them would require reworking the checkpoint flow for no behavioural gain. Binary searches over both arrays select the last entry satisfying the predicate, so a duplicated (ts, blk) resolves deterministically to the final state of that block.

The global bias can trail the sum of user bias deltas by 1 wei

IB-4

_checkpointLock takes one of two paths: when the global and user effective totals coincide it sets gNew.bias = uNew.bias, and otherwise it sets gNew.bias = gDecayedBias + bias. The second path decays the global bias independently, and its rounding can underestimate the added bias by 1 wei — so globalBiasAfter can come out one wei below globalBiasBefore + userBiasDiff.

This is the same asymmetry described under the dust gap between summed user power and totalSupply(), observed from the delta side rather than the total side. The invariant suite encodes it as an explicit tolerance (TOLERANCE_INV2) rather than an equality.

The sum of claimable rewards can exceed the amount distributed

IB-5, IB-7

A distribution caches one totalVotingPower figure at its snapshot. When each user's power is later recomputed individually at dist.endTimestamp, the sum of those readings can come out a few wei above the cached total — so the sum of every user's claimable() can exceed dist.amount.

Over-payment itself is prevented: RewardLib clamps each userShare to the full distribution amount and caps every payout at dist.amount - dist.totalClaimed. The accepted consequence is on the other side — the last user to claim from a fully drained distribution can be paid slightly less than their computed share, the shortfall being the accumulated dust of everyone who claimed before them.

Closing the gap entirely would mean rebuilding veRAAC's reward accounting on the GaugeController's model, a change disproportionate to a sub-wei drift in a component that has been through three external reviews in its current shape. Instead the drift is bounded and measured: invariant 12 asserts it stays under CLAIM_TOLERANCE, and invariant_12OptimizationMode runs in Foundry's optimization mode to report the maximum drift observed across a campaign. IB-7 narrowed it further by pricing each user's share at dist.endTimestamp through getVotingPowerAtTime rather than at the snapshot block.

finalizeRagequit is blocked by the pause while withdraw is not

finalizeRagequit restores the caller's voting power from their surviving locks and writes both a user and a global checkpoint. Those are exactly the state mutations a pause is meant to stop, so the function is gated.

The asymmetry with withdraw is intentional: withdraw mutates no power and writes no checkpoint, finalizeRagequit does both. The cost is that a ragequit cooldown expiring during a pause is settled late; the funds are escrowed and not at risk.

Acknowledged in earlier reviews

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

Ref Review Behaviour
L-17 v2 · 2026-03 lock and extend place no bound on the rounded-up epoch end
M-02, L-07 v2 · 2026-03 timePerBlock is configurable; block-indexed views are estimates
L-03 v2 · 2026-03 Two identical block-indexed queries can differ across a checkpoint write
L-06 v2 · 2026-03 Zeroed lock slots are never reused
L-11 v2 · 2026-03 The previous-block snapshot makes same-block capture uneconomical
L-13 v2 · 2026-03 Epoch-end arrays are pruned on every interaction, bounding the loops
L-14 v2 · 2026-03 Only one ragequit may be in flight per account
L-16 v2 · 2026-03 Expired positions cannot be ragequit
L-19 v2 · 2026-03 Exit fees are scaled against maxTime, not the lock's own term
L-20 v2 · 2026-03 Distributions vest one epoch at a time, not linearly
L-05, L-10 v2 · 2026-03 Dust rewards are truncated by protocol-favourable rounding
L-01 v2 · 2026-03 getPastTotalSupply is a global query, not a per-caller one
L-04 v2 · 2026-03 PowerCheckpoint.setProposalSnapshot is unused and out of scope
M-06 v1 · 2026-02 Expired principal stops decaying the bias without a withdraw()
L-03 v1 · 2026-02 Expired locks stay withdrawable while the contract is paused
L-08, L-14 v1 · 2026-02 Bias clamps at zero; user and global rounding are asymmetric
L-13 v1 · 2026-02 Expired-bias variables are immutable
L-12 v1 · 2026-02 uint112 lock amounts cannot overflow at RAAC's max supply
L-11 v1 · 2026-02 timePerBlock kept configurable for future network changes
L-04 v1 · 2026-02 The reward-removal cooldown setter is intended for extension only

The sum of user voting power can exceed totalSupply() by dust

_decayBias rounds up when decaying a user's bias (user != address(0)) and down when decaying the global bias. Summing every user's balanceOf can therefore exceed totalSupply() by a few wei.

The direction is deliberate and protocol-favourable in the one place it matters: a reward share is computed as VP_user × amount / totalVotingPower, and RewardLib additionally clamps userShare to the full distribution amount and clamps each payout to dist.amount - dist.totalClaimed. The dust can never cause a distribution to over-pay. Negative intermediate biases are clamped to zero and carried as a remainder, which is folded back into the global point so the global figure stays consistent — _decayBias never returns a negative value.

Expired principal stops decaying the bias without anyone calling withdraw()

A lock's contribution to the decay rate ends at its epoch boundary, not at the moment its owner withdraws. _checkpointExpiredLocksCumulative walks the user's and the global epochEnds arrays and moves matured principal into accExpiredLocks, which is subtracted from totalLocked before decay is applied.

The protocol cannot depend on users calling withdraw() promptly, and a user who never withdraws must not keep voting power or distort the global decay rate. The cost is the week-boundary loop inside _decayBias; the arrays it walks are pruned on every user interaction, which bounds them.

Expired-bias variables are immutable

Once an epoch boundary is in the past, every variable feeding the bias computation at that boundary — totalLockedAtEpochEnd, userTotalLockedAtEpochEnd, ragequitLockIgnore — is treated as frozen. finalizeRagequit recomputes the caller's power from their surviving locks and writes a fresh point rather than retroactively editing an expired epoch's accounting.

This is the invariant that makes historical queries answerable at all. Mutating an expired epoch would double-count the principal: once in _checkpointRecomputeFromLocks at finalization, and again in the _decayBias walk of any later query.

Block-indexed queries are estimates, not exact readings

balanceOfAt(user, block) and totalSupplyAt(block) convert a block delta into a time delta using timePerBlock (12 seconds), then decay the last checkpoint at or before that block. The estimate is clamped two ways: it can never exceed the real time elapsed since the checkpoint, and where a later checkpoint exists it is clamped to that checkpoint's timestamp instead.

Exact readings are available through balanceOfAtTime and totalSupplyAtTime. totalSupplyAt returns the endTimestamp it actually settled on precisely so a caller can price a user's share at the same instant, which is what distributeRewards and RewardLib do.

timePerBlock is owner-configurable and is not expected to change in production. It exists because Ethereum block times have changed before — 13–15 s under proof of work, 12 s since the Merge — and a future network change should not require a redeployment. The deployment target is Ethereum mainnet only.

Two identical block-indexed queries can return different values across time

Because the clamp switches from "time elapsed since the checkpoint" to "time until the next checkpoint" as soon as a later checkpoint is written, a totalSupplyAt(N) evaluated before and after that write can differ — but only when the raw block-derived estimate exceeds the clamp in both evaluations.

The precondition requires the block-derived estimate to overshoot real elapsed time, which does not occur while timePerBlock matches the network. Consumers that need a stable historical figure use the timestamp-indexed views.

lock and extend have no slippage protection on the resulting epoch end

Unlock times are rounded up to the next week boundary, so the realised lock can be up to 7 days longer than epochs × 7 days — and the exact boundary depends on when the transaction lands. There is no minEpochEnd / maxEpochEnd argument to bound it.

The maximum divergence is one epoch, the outcome is always more lock rather than less, and a bound would add an argument and a revert path to the two most-used functions for an outcome that is not harmful. Epoch alignment is what makes the global decay schedule computable in the first place.

Zeroed lock slots are never reused

withdrawExpired sets a position's amount to 0 and advances nextUnlockIndex past the leading run of empty slots. _findOrCreateLock skips zero-amount entries and appends a new position rather than recycling one.

Reusing a zeroed slot behind the cursor would create a position with a non-zero amount at an index the cursor has already passed, which would then be invisible to every subsequent scan. The invariant — no active lock sits behind nextUnlockIndex — is worth more than the slot.

Expired positions cannot be ragequit

Both ragequit entry points require an unlock time strictly greater than block.timestamp. An expired position has zero remaining time and therefore zero variable fee; it is withdrawn free of charge with withdraw(). This is not an oversight, it is the reason the fee is time-weighted.

Only one ragequit may be in flight per account

An open RagequitRequest blocks a second ragequit, and blocks lock, increase, extend, withdraw and claimReward for the duration of the 7-day cooldown. The account is frozen until finalizeRagequit.

This was introduced deliberately in response to an earlier review. The cooldown exists to make the exit costly in time as well as in fees; allowing the account to keep operating during it would defeat that.

Exit fees are computed against maxTime, not the lock's original duration

variableFee = amount × min(remaining, maxTime) / maxTime × 50 %. A user exiting a 4-week lock after 1 week pays a fee proportional to the 3 weeks remaining relative to a year, not relative to their own 4-week term, so short locks pay proportionally small exit fees.

This matches the specification provided by the tokenomics team: the penalty prices the governance commitment being withdrawn, and that commitment is measured on the protocol's single maximum-lock scale.

Distributions vest one epoch at a time, not linearly

_getClaimableAmountInternal releases eligibleRewards / epochs per elapsed whole week, not continuously. A distribution with epochs = 4 pays in four steps rather than as a smooth stream. This is the specified mechanism: one claim per epoch over the configured number of epochs.

The distribution snapshot cannot be front-run within a block

distributeRewards snapshots total voting power at block.number - 1. Capturing a distribution therefore requires locking at least one block ahead of it. An attacker timing a large lock against a distribution would have to stuff the intervening block and lock a large amount for an extended period — a cost that exceeds the share they could capture.

Expired locks stay withdrawable while the contract is paused

withdraw() carries nonReentrant and notInRagequitCooldown but not whenNotPaused.

A position that has already expired contributes no voting power and creates no accounting obligation — the bias stopped decaying at its epoch boundary regardless of the pause. Holding that principal during an incident unrelated to it is a cost to the user rather than a protection. Internal discussions settled on allowing these withdrawals during a pause.

int128 bias and uint112 lock amounts are sufficient

A single lock's amount is packed into uint112, whose maximum is ≈ 5.19 × 10³³. The entire RAAC supply is 21,000,000 × 10¹⁸ ≈ 2.1 × 10²⁵ — seven orders of magnitude below the limit, so even a single user locking the whole supply in one position cannot overflow it. Every cast to int128 is additionally guarded by MAX_CASTABLE_INT128, reverting with ValueTooLargeToCast() rather than wrapping.

getPastTotalSupply rejects the current block and is a global query

totalSupplyAt reverts with InvalidBlockNumber() for blockNumber >= block.number. It reads only the global point history and never touches per-user balances, so a per-caller guard would be meaningless; the points[0].blk > blockNumber early return already covers the "no history yet" case. PowerCheckpoint.setProposalSnapshot is present in the library but is not called by VeRAACToken, and will be reviewed if and when the governance module begins using it.