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 nowrawBalanceOf(user)→ the minted ERC-20 balance, only re-synced when the user next touches the contractlockedBalanceOf(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¶
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
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:
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:
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:
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:
- Pulls the tokens and measures the actual received amount (fee-on-transfer safe).
- Adds it to
pendingRewardAmount[token]; if the 18-decimal-normalised total is still belowminRewardDistributionAmount(default1e18), nothing is distributed yet and the amount keeps accumulating. - 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. - Pushes a
Distributionrecord. 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:
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 |
A lock with a full year left pays 5 % + 50 % = 55 %. A lock one week from expiry pays 5 % + ≈0.96 % ≈ 6 %.
Sequence¶
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.- 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. - A
RagequitRequestis stored with areadyAtofblock.timestamp + 7 days. - 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.
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()andragequitRequests()to build per-lock decay buckets for gauge weight votes, andbalanceOf()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.proposalThresholdis 90,000 veRAAC. - FeeCollector — accrues a
veRAACTokenShareleg per fee type and sends it toveRAACAddress. It attempts anIDistributionTarget.distributeRewards(token, amount)call first, butVeRAACTokendoes not implement ERC-165 and itsdistributeRewardstakes a thirdepochsargument, soisValidDistributionTargetreturnsfalseand the FeeCollector falls back to a plainsafeTransfer. See the warning below. - RAACLiquidLocker — max-locks RAAC on behalf of $leRAAC holders, reads
getGlobalLockInfo()for the lock duration andgetRewardTokens()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 beforeVeRAACToken. SafeERC20for all token movement, including the RAAC leg.ReentrancyGuardon every state-changing external function.- Voting power is stored as
int128bias inLockManager.Pointstructs;MAX_CASTABLE_INT128guards every cast. _syncBalancemints or burns the ERC-20 balance to match the freshly computed bias after every state change.
Additional Features & Notes¶
- Multi-position locks — a user can run many locks with different maturities in parallel; each is topped up and extended independently.
- Non-transferable — no transfers, no approvals in practice, no delegation.
- Multi-token rewards — any ≤18-decimal ERC-20, added and removed by the owner with a 14-day removal cooldown.
- Vesting distributions — each distribution releases over a configurable number of weekly epochs rather than all at once.
- Paginated claims —
claimReward(token, maxDistributions)bounds gas;getMaxUnclaimedDistributionstells a caller how far to go. - Dust accumulation — sub-threshold rewards accumulate in
pendingRewardAmountinstead of creating micro-distributions. - Treasury fallback — rewards distributed when total voting power is zero are redirected to the Treasury rather than stranded.
- Previous-block snapshots — distributions snapshot
block.number - 1, so a lock cannot be created and rewarded in the same block.