Skip to content

veRAACToken

Overview

VeRAACToken is the vote-escrow contract of the RAAC protocol. Users lock RAAC for a chosen number of weekly epochs and receive veRAAC — a non-transferable ERC-20 whose balance is the caller's current voting power. The longer the remaining lock, the more power a given amount of RAAC carries; power decays linearly and reaches zero the moment the lock expires.

veRAAC is what the rest of the protocol reads when it needs to know "how much say does this address have": gauge weight votes, governance proposals and the veRAAC share of protocol fees are all priced against it.

veRAAC is not a claim on a balance — it is a decaying measurement

balanceOf() is overridden. It does not return a stored ERC-20 balance; it recomputes the caller's decayed voting power at block.timestamp from the lock checkpoints.

  • balanceOf(user) → live voting power, decayed to now
  • rawBalanceOf(user) → the minted ERC-20 balance, only re-synced when the user next touches the contract
  • lockedBalanceOf(user) → the underlying RAAC principal

Between two interactions rawBalanceOf is stale and higher than the real power. Integrators must read balanceOf / balanceOfAt / balanceOfAtTime, never rawBalanceOf. See Design Trade-offs.

veRAAC is non-transferable

_update reverts with TransferNotAllowed() for every transfer between two non-zero addresses. Only mint (from address(0)) and burn (to address(0)) are permitted, and both are driven internally by _syncBalance. There is no delegation, no approval path and no secondary market for veRAAC itself — the liquid wrapper is $leRAAC.


Architecture

INHERITED — STORAGE & MIXINS LINKED LIBRARIES ASSETS CONSUMERS — READ VOTING POWER VeRAACTokenStorage raac · treasuryAddress · rewardTokens _lockState · _checkpointState OpenZeppelin ERC20 · Ownable · AccessControl ReentrancyGuard · Pausable LockManager locks · points · bias decay PowerCheckpoint block-indexed snapshots RagequitLib exit fees · early unlock RewardLib per-epoch claim math VeRAACToken multi-lock vote escrow · non-transferable balanceOf() = decayed voting power RAACToken locked principal · burn(fixed fee) Treasury exit fees · redirected rewards Reward tokens (ERC-20) ≤ 18 decimals · streamed per epoch GaugeController gauge weight votes Governance proposal threshold · votes FeeCollector veRAAC share of protocol fees RAACLiquidLocker max-locks on behalf of leRAAC using for / linked lock in · withdraw out · burn 33 % of variable exit fee claim payouts getLocks · ragequitRequests balanceOfAt veRAAC fee share lock · extend · claimReward

Libraries are linked, not delegate-called by the protocol

LockManager, PowerCheckpoint, RagequitLib and RewardLib are deployed as external Solidity libraries and linked at deploy time. VeRAACToken is a plain (non-upgradeable) contract; there is no proxy and no storage-layout migration path. Redeploying the token means redeploying the whole lock set.


Purpose

  • Convert RAAC into time-weighted governance power via multiple independent lock positions per user
  • Decay that power linearly so influence tracks remaining commitment, not past commitment
  • Provide historical voting power (balanceOfAt, totalSupplyAt) so gauges and proposals can snapshot a past block
  • Distribute protocol revenue to lockers across multiple reward tokens, vesting each distribution over a configurable number of epochs
  • Offer a penalised early exit (ragequit) whose fee is burned, routed to the Treasury, and streamed back to the holders who stayed

Lock Model

Epochs

One epoch is 7 days. epochDuration is fixed at construction and there is no setter. maxLockEpochs is the constructor argument — 52 in every RAAC deployment — giving a maximum lock duration of

\[\text{maxTime} = 52 \times 7\ \text{days} = 31{,}449{,}600\ \text{seconds} \approx 364\ \text{days}\]

maxLockEpochs is immutable once set: no function in the contract can change it.

Multiple positions

Unlike a single-slot vote escrow, a user holds an array of LockedBalance positions, each a (uint112 amount, uint40 unlockTime) pair. Positions are keyed by their epoch-aligned unlockTime: locking twice into the same week tops up one position rather than creating a second.

Every unlock time is rounded up to the next week boundary, then clamped so the lock never exceeds maxTime:

epochEnd = roundUpToWeek(now + epochs × 7 days)
if epochEnd > now + maxTime:   epochEnd -= 7 days      // round back one week

You may get slightly more lock than you asked for

Because the end is rounded up to a week boundary, a lock of n epochs expires at the next Thursday-aligned week boundary after now + n × 7 days — up to 7 days later than a naive reading of epochs. This is deliberate: epoch alignment is what makes the global decay schedule computable. See Design Trade-offs.

Lifecycle

stateDiagram-v2
    [*] --> Active: lock(amount, epochs)
    Active --> Active: increase(amount, epochEnd)
    Active --> Active: extend(fromEpochEnd, addEpochs)
    Active --> Expired: unlockTime reached (power = 0)
    Expired --> [*]: withdraw()
    Active --> Cooldown: ragequitLock(epochEnd) / ragequitAll()
    Cooldown --> [*]: finalizeRagequit(to) after 7 days
    note right of Cooldown
        voting power is zeroed immediately
        lock / increase / extend / withdraw
        and claimReward are all blocked
    end note

Voting Power

Initial power

Creating or topping up a lock adds a bias proportional to the fraction of maxTime still remaining:

\[\text{bias} = \frac{\text{amount} \times \min(\text{epochEnd} - t,\ \text{maxTime})}{\text{maxTime}}\]

A one-year lock of 1,000 RAAC therefore mints ≈ 1,000 veRAAC; a 26-week lock of the same amount mints ≈ 500 veRAAC.

Decay

Power decays linearly toward zero at unlockTime. The contract does not store a per-lock slope; it decays the aggregate bias using the amount still locked, walking week boundary by week boundary so that positions expiring inside the interval stop contributing to the decay rate:

\[\text{bias}_{t_1} = \text{bias}_{t_0} - \sum_{\text{weeks}} \frac{\text{effectiveTotalLocked}_w \times \Delta t_w}{\text{maxTime}}\]

effectiveTotalLocked is reduced at each week boundary by the amount of principal whose locks end there, tracked in totalLockedAtEpochEnd / userTotalLockedAtEpochEnd.

Why decay does not depend on withdraw()

Expired principal stops decaying the bias at its epoch boundary whether or not the user has withdrawn. A user who never calls withdraw() does not keep voting power, and does not distort the global decay rate.

Rounding is asymmetric by design

User-side decay rounds up (the user loses the odd wei), global decay rounds down. As a direct consequence:

\[\sum_{\text{users}} \texttt{balanceOf(user)} \ \ge\ \texttt{totalSupply()}\]

The gap is a few wei. It is deliberate — it guarantees the sum of individual reward shares can never exceed the amount distributed. See Design Trade-offs.

Historical queries

Function Indexed by Notes
balanceOf(user) now live decayed power
balanceOfAtTime(user, ts) timestamp exact; reverts if ts >= block.timestamp
balanceOfAt(user, block) block number estimated — converts blocks to seconds via timePerBlock
totalSupply() now live global power
totalSupplyAtTime(ts) timestamp exact
totalSupplyAt(block) block number returns (power, endTimestamp) — the timestamp it actually decayed to

Block-indexed queries are estimates

balanceOfAt and totalSupplyAt convert a block delta into a time delta using timePerBlock (12 s, owner-configurable), clamped to the next checkpoint so the estimate can never run past real elapsed time. They are exact only when a checkpoint sits on the requested block. Consumers that need an exact figure should use the ...AtTime variants — which is why totalSupplyAt hands back the endTimestamp it settled on, so a caller can price a user's share at the same instant.


Rewards

VeRAACToken is its own reward distributor. Any ERC-20 with 18 or fewer decimals can be registered; RAAC is registered in the constructor.

Distribution

An address holding REWARD_DISTRIBUTOR_ROLE calls distributeRewards(token, amount, epochs). The contract:

  1. Pulls the tokens and measures the actual received amount (fee-on-transfer safe).
  2. Adds it to pendingRewardAmount[token]; if the 18-decimal-normalised total is still below minRewardDistributionAmount (default 1e18), nothing is distributed yet and the amount keeps accumulating.
  3. Snapshots total voting power at block.number - 1 — the previous block, so a distribution cannot be front-run by a lock in the same block.
  4. Pushes a Distribution record. If total voting power at the snapshot is zero, the whole amount is sent to the Treasury instead.
sequenceDiagram
    participant D as Distributor
    participant V as VeRAACToken
    participant U as Locker
    D->>V: distributeRewards(token, amount, epochs)
    V->>V: snapshot totalSupplyAt(block.number - 1)
    V->>V: push Distribution{amount, totalVotingPower, epochs}
    Note over V: share_user = VP_user(endTs) × amount / totalVP
    U->>V: claimReward(token, maxDistributions)
    loop one epoch per week elapsed
        V-->>U: share_user / epochs
    end

Claiming

A user's share of a distribution is fixed the first time they process it:

\[\text{share}_{\text{user}} = \frac{\text{VP}_{\text{user}}(\text{endTs}) \times \text{amount}}{\text{totalVotingPower}}\]

and then vests one epochs-th per week. claimReward(token, maxDistributions) is paginated: distributions are processed from the user's cursor forward, and the cursor only advances past distributions that are fully settled.

A new lock cannot claim past distributions

When a user creates their first lock, lastProcessedDistributionIndex is set to the current number of distributions for every reward token. Distributions that closed before the lock existed are permanently out of reach — which is correct, since the user had no voting power at those snapshots.

Reward token administration

Step Function Gate
Register addRewardToken(token) onlyOwner, reverts if token decimals > 18 or the token was previously removed
Begin removal initiateRemoveRewardToken(token) onlyOwner, starts a 14-day cooldown
Cancel removal initiateRemoveRewardToken(token) again onlyOwner, clears the cooldown
Finish removal finalizeRemoveRewardToken(token) onlyOwner, after the cooldown; unclaimed + pending balances go to the Treasury

Removal is permanent

finalizeRemoveRewardToken de-registers the token but leaves removalInitiatedAt set, and addRewardToken reverts with TokenPreviouslyRemoved whenever that field is non-zero. A finalised removal can never be undone — the token can never be registered again. Cancelling a pending removal does clear the field, so an aborted removal is harmless.

The sweep to the Treasury is unconditional: it does not check whether individual users still had claimable balances. The 14-day cooldown is the only protection users get.


Ragequit — the penalised early exit

A locker who cannot wait out their lock may exit early and pay for it.

Fees

Component Size Destination
Fixed fee 5 % of principal (FIXED_EXIT_FEE = 500 bps), rounded up Burned
Variable fee up to 50 % (MAX_VARIABLE_EXIT_FEE = 5,000 bps), scaled by remaining time Split below
→ Treasury leg 33 % of the variable fee treasuryAddress
→ Holder leg 67 % of the variable fee Streamed to remaining veRAAC holders
\[\text{variableFee} = \text{amount} \times \frac{\text{remainingTime}}{\text{maxTime}} \times 50\%\]

A lock with a full year left pays 5 % + 50 % = 55 %. A lock one week from expiry pays 5 % + ≈0.96 % ≈ 6 %.

Sequence

  1. ragequitLock(epochEnd) — exits one position. The position must be the caller's earliest-expiring active lock (epochEnd == getMinLockEnd(caller)) and must not yet be expired. ragequitAll() — exits every active position at once.
  2. The caller's voting power is zeroed immediately, and a checkpoint recording zero power is written before the holder leg is streamed. The ragequitter is additionally recorded as the distribution's excludedAddress, so they cannot claim from their own penalty.
  3. A RagequitRequest is stored with a readyAt of block.timestamp + 7 days.
  4. After the cooldown, finalizeRagequit(to) deletes the request, recomputes the caller's power from any remaining locks, and transfers the net RAAC.

The cooldown freezes the account

While a RagequitRequest is open, the notInRagequitCooldown modifier blocks lock, increase, extend, withdraw and claimReward. Expired locks that mature during the cooldown cannot be withdrawn until finalizeRagequit is called. getUserTotalLockedAtEpochEnd returns 0 for an account in cooldown.

Expired locks cannot be ragequit

Both entry points require an unlock time strictly in the future. An expired position has no remaining time, hence no variable fee to pay — it is withdrawn with withdraw(), free of charge.


Parameters

Parameter Value Setter Notes
epochDuration 7 days fixed in the constructor
maxLockEpochs 52 constructor argument; immutable thereafter
FIXED_EXIT_FEE 500 bps (5 %) constant, burned
MAX_VARIABLE_EXIT_FEE 5,000 bps (50 %) constant
Ragequit cooldown 7 days constant
ragequitEpochs 4 setRagequitEpochs epochs over which the holder leg streams
REWARD_REMOVAL_COOLDOWN 14 days setRewardTokenRemovalCooldown must be non-zero
minRewardDistributionAmount 1e18 setMinRewardDistributionAmount 18-dec normalised; floor of 1e18
minLockAmount 100 wei (deploy default) setMinLockAmount setter floor is 52
minIncreaseAmount 31,449,600 wei setMinIncreaseAmount setter floor is maxTime
timePerBlock 12 s setTimePerBlock only used by block-indexed views
treasuryAddress deploy config setTreasuryAddress cannot be zero

The two minimum-amount floors differ because the two functions can be called with very different durations, and both exist to enforce the same system property: a position with a non-zero locked amount must never carry zero voting power.

lock always creates a position at least one full epoch away, so duration >= 604,800 and bias = amount × duration / maxTime >= amount / 52. A floor of 52 is therefore exactly what guarantees bias >= 1, and the deployed default of 100 clears it. increase, by contrast, can target a position expiring one second from now, so its floor has to be the full maxTime (31,449,600) to guarantee the same thing — which is why minIncreaseAmount was introduced. The @dev comment on minLockAmount in VeRAACTokenStorage still says "must be >= maxTime"; that text is stale and describes minIncreaseAmount, not the value it annotates.


Access Control

Role Held by Powers
owner (Ownable) protocol multisig pause / unpause, all parameter setters, add/remove reward tokens
DEFAULT_ADMIN_ROLE deployer at construction grants and revokes REWARD_DISTRIBUTOR_ROLE
REWARD_DISTRIBUTOR_ROLE deployer at construction, then the protocol ops multisig distributeRewards — must hold and approve the reward tokens it distributes

Deployment hand-over

The constructor grants DEFAULT_ADMIN_ROLE and REWARD_DISTRIBUTOR_ROLE to msg.sender and makes msg.sender the owner. All three must be transferred to the intended multisig as part of the deployment runbook.

What the pause stops

Blocked by whenNotPaused Not blocked
lock, increase, extend withdraw
ragequitAll, ragequitLock, finalizeRagequit all view functions
claimReward, distributeRewards

Leaving withdraw open during a pause is deliberate: a user whose lock has already expired has no voting power and no outstanding obligation, and should not have their principal held hostage by an unrelated incident. Conversely finalizeRagequit is paused, because it writes checkpoints that mutate global voting power. Both are recorded in Design Trade-offs.


Functions

Every external function — locking, exits, rewards, administration and views — is documented with signatures, parameters, errors and source on the dedicated page.

veRAACToken — Functions


Design Trade-offs

Behaviours that a security review raised and the protocol accepted as design — rather than fixed — are documented separately, so that reviewers can tell a deliberate trade-off from a defect.

veRAACToken — Design Trade-offs


Integration Notes

Expand to view consumers
  • GaugeController — reads getLocks() and ragequitRequests() to build per-lock decay buckets for gauge weight votes, and balanceOf() for the voter's total weight. A voter with an open ragequit request has no weight.
  • Governance — reads balanceOfAt() at a proposal's snapshot block. proposalThreshold is 90,000 veRAAC.
  • FeeCollector — accrues a veRAACTokenShare leg per fee type and sends it to veRAACAddress. It attempts an IDistributionTarget.distributeRewards(token, amount) call first, but VeRAACToken does not implement ERC-165 and its distributeRewards takes a third epochs argument, so isValidDistributionTarget returns false and the FeeCollector falls back to a plain safeTransfer. See the warning below.
  • RAACLiquidLocker — max-locks RAAC on behalf of $leRAAC holders, reads getGlobalLockInfo() for the lock duration and getRewardTokens() when harvesting.

Fees transferred directly into the contract are not distributed automatically

distributeRewards pulls its funding with safeTransferFrom(msg.sender, ...). Tokens that arrive by plain transfer — which is how the FeeCollector currently delivers the veRAAC leg — sit in the contract's balance and are never picked up by a distribution. They are also invisible to pendingRewardAmount.

Operational requirement: the FeeCollector's veRAAC target must be the operator address that calls distributeRewards, not the token contract itself, so that the funds sit somewhere the contract can pull from.

Expand to view implementation details
  • Plain, non-upgradeable contract — no proxy, no initializer, no storage gap.
  • Four linked external libraries: LockManager, PowerCheckpoint, RagequitLib, RewardLib. All four must be deployed and linked before VeRAACToken.
  • SafeERC20 for all token movement, including the RAAC leg.
  • ReentrancyGuard on every state-changing external function.
  • Voting power is stored as int128 bias in LockManager.Point structs; MAX_CASTABLE_INT128 guards every cast.
  • _syncBalance mints or burns the ERC-20 balance to match the freshly computed bias after every state change.

Additional Features & Notes

  1. Multi-position locks — a user can run many locks with different maturities in parallel; each is topped up and extended independently.
  2. Non-transferable — no transfers, no approvals in practice, no delegation.
  3. Multi-token rewards — any ≤18-decimal ERC-20, added and removed by the owner with a 14-day removal cooldown.
  4. Vesting distributions — each distribution releases over a configurable number of weekly epochs rather than all at once.
  5. Paginated claimsclaimReward(token, maxDistributions) bounds gas; getMaxUnclaimedDistributions tells a caller how far to go.
  6. Dust accumulation — sub-threshold rewards accumulate in pendingRewardAmount instead of creating micro-distributions.
  7. Treasury fallback — rewards distributed when total voting power is zero are redirected to the Treasury rather than stranded.
  8. Previous-block snapshots — distributions snapshot block.number - 1, so a lock cannot be created and rewarded in the same block.