Skip to content

RAACLiquidLocker — Design Decisions & Accepted Trade-offs

The behaviours below are properties of the RAACLiquidLocker 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 locker scope — v3 (2026-07), v2 (2026-05, LiquidLocker + VeRAAC) and v1 (2026-04) — and to the internal review that preceded them. Findings that were accepted and fixed — notably the permanently stuck epoch index (v2 M-03) and the post-unpause health check (v2 M-11) — are not listed here; they are no longer present in the code.

Raised in the most recent review

Depositors who arrive between a distribution and the harvest dilute the stream

v3 M-02, originally internal review M-03

Rewards accrue inside veRAAC continuously, but they only reach locker depositors when someone calls harvest. accRewardPerShare is then increased against totalLockedGlobal as it stands at that moment — so an account that deposited after the rewards were earned, but before the harvest landed, receives a share of them.

This is a fundamental property of a pooled accumulator rather than a divergence from the design. Pricing each depositor against the exact locked total at the instant every reward was earned would require per-reward checkpointing of the whole pool. The mitigation is operational: harvests are run frequently by incentivised keepers, which shrinks the window in which the effect is worth exploiting to the point where the harvest bounty exceeds the gain.

A non-zero repay fee would make full manual repayment impossible

v3 L-08, v2 L-13

repay takes repayFeePercentage out of the repayment rather than charging it on top: the fee is transferred to the treasury as leRAAC and only amount - fee is burned and deducted from the debt. With a non-zero fee, each repayment leaves a residue, and no finite sequence of repay calls clears a debt.

The team's settled position is to hold repayFeePercentage at 0 in production, which is why the behaviour is accepted rather than restructured. The reasoning is that repay is not the primary repayment path in the first place: debt is designed to be retired by the reward stream, automatically, on every interaction — which is what makes leRAAC self-repaying. A user who does hit a residue under a non-zero fee still has it cleared by the next distribution, provided their position is still locked.

Operational requirement: repayFeePercentage stays at 0; raising it re-introduces a debt residue that only the reward stream can clear.

Intermediate bad debt is inherent, and withdrawal is blocked rather than allowed

v3 L-11, related to v2 M-11

A user may borrow freely after calling requestUnlock. Reserved collateral is netted out of the health check at both mutation points, so the position is healthy when each call is made — but once the reserved buckets mature, the account can sit at totalLocked × reserveRate < totalDebt. That intermediate undercollateralised state is deliberate: the user stays locked between the request and maturity, so rewards keep accruing against the position and keep repaying the debt.

withdrawUnlocked is where it resolves. Rather than paying out and leaving persistent bad debt, the function relocks whatever portion is needed to keep the debt backed and pays out the rest.

There is a known edge in that resolution. When requestUnlock rounds a reservation up to a whole bucket to avoid stranding a sub-minLockAmount remainder, the amount that later has to be relocked can itself be smaller than minLockAmount — in which case the inner veRAAC.lock reverts and the withdrawal fails entirely. The position is not stuck: the amount involved is dust by construction, the reward stream shrinks the debt every epoch, and repaying manually clears it at once. Given the alternative, the choice was explicit — block the withdrawal rather than let a user exit leaving bad debt behind.

Reserving an unlock can schedule more than was requested

v3 L-09

_scheduleUnlock fills buckets soonest-first. If taking only part of the boundary bucket would leave an open remainder below veRAAC's minLockAmount, it takes the whole bucket instead, because a remainder that small could not be relocked at maturity.

The overshoot is bounded and checked: requestUnlock reverts with InsufficientToUnlock unless the scheduled amount lands within [_amount, _amount + minLockAmount]. The consequence is that no open expired position can ever be left below the minimum lock amount.

Rewards reach the bot locker atomically at harvest time

v3 M-01, v1 M-10

The harvest transfers the bot-locker leg in one call, so any NFT locked before that call is eligible for the whole distribution regardless of how recently it was locked.

This would be exploitable if an attacker could lock, claim and exit atomically — the flash-loan shape. They cannot: every bot-locker lock carries a minimum four-week term before withdrawal. Capturing a meaningful share would also mean accumulating a large fraction of RAACNFT supply, which is fully distributed across the community with no holder above 70 NFTs, from sellers who may simply decline.

New maturity-vault deposits participate in streams that began before them

v3 M-03, v3 L-05

A distribution funded into the Maturity Vault is not fenced off to the deposits that were present when it was funded. New deposits join an in-flight stream, and _updateUserInfo runs before the new amount is added to totalUnrealised, so the elapsed portion of the current stream is settled against existing depositors first.

This is the intended repayment model, not a leak. Likewise, a withdrawal reduces totalUnrealised and subsequent rewards target the updated figure; once a stream fully covers it, the leftover is recorded as freeRAAC and repays future leRAAC deposits.

Acknowledged in earlier reviews

The table lists every finding on the locker 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-16 v2 · 2026-05 A 52-epoch lock can resolve to 51 epochs at the week boundary
M-02, M-05, M-06 v2 · 2026-05 Auto-relock of an open bucket assumes minLockAmount never changes
M-13 v2 · 2026-05 veRAAC ragequit griefing of the reward stream is deterred by cost
M-01 v2 · 2026-05 Locker debt and leRAAC repayment are not expected to stay in lockstep
L-15 v2 · 2026-05 Front-running a veRAAC distribution is uneconomical and net-beneficial
L-14 v2 · 2026-05 Dust reward shares truncate to zero and are swept on token retirement
L-12 v2 · 2026-05 A blacklisted reward recipient keeps their NFTs locked
L-11 v2 · 2026-05 Retiring a veRAAC reward token leaves per-user accounting inconsistent
L-10 v2 · 2026-05 The claim cursor is deliberately not advanced on relock
L-05 v2 · 2026-05 Rewards accrued during a pause are applied on the next interaction
L-04 v2 · 2026-05 Owner renunciation is not on any operational path
L-03 v2 · 2026-05 The ragequit fee needs no max-fee bound
L-01 v2 · 2026-05 Relockable excess cannot fall below the minimum lock amount
M-04 v2 · 2026-05 rescueTokens is not called on reward tokens while NFTs are locked
M-15 v1 · 2026-04 Harvested rewards accumulate to accRewardPerShare before the vault distribution
M-14 v1 · 2026-04 Dust deposits are impossible, and small deposits share donations
M-13 v1 · 2026-04 No invariant ties totalDebt to leRAAC supply
M-12 v1 · 2026-04 Reward tokens that revert on transfer will not be registered
M-07 v1 · 2026-04 Expired unreserved locks are relocked by keepers, not on user interaction

A 52-epoch lock can resolve to 51 epochs

Two invariants hold simultaneously: lock ends snap to 7-day epoch boundaries, and every epoch end must be <= ts + maxTime. When ts + maxTime does not fall exactly on a week boundary, the largest boundary not exceeding it is used — so a lock intended as 52 epochs can land at 51. Preserving both invariants is worth more than the exact epoch count.

Auto-relock of an open bucket assumes minLockAmount never changes

An open bucket is relocked unconditionally at maturity, with no re-check that the amount still clears veRAAC's minLockAmount. That is safe because every position was created above the minimum in the first place, and the minimum is not intended to move: veRAAC.setMinLockAmount exists for flexibility and is not expected to be called in production.

Should it ever be raised, the mitigation is an upgrade of the locker implementation rather than a per-relock check, since the locker is behind a proxy and the condition is a configuration change rather than user input. The same reasoning covers the relockable-excess case in processVaultExpiredLocks (v2 L-01).

Locker debt and leRAAC repayment are not expected to stay in lockstep

There is no invariant requiring totalDebtGlobal to track leRAAC supply. leRAAC is minted on borrow and can be converted to RAAC through several venues; debt is retired mostly by reward distributions rather than by repay. When no depositor has an active lock — everything expired and reserved without relock — no one is eligible for debt repayment, so totalDebtGlobal stays put while leRAAC repayment in the maturity vault continues. The invariants that do hold are narrower: leRAAC cannot be minted without an active lock, debt cannot exceed 50 % of locked collateral, and repay is the only path that burns leRAAC against debt (v1 M-13).

veRAAC ragequit griefing of the reward stream is deterred by cost

A third party ragequitting in veRAAC can disturb the locker's reward accounting. Doing so costs the attacker the full ragequit penalty — 5 % burned plus up to 50 % time-weighted — and a minimum of one week with zero voting power, while the keeper's regular harvests keep advancing the locker's distribution cursor. The cost is disproportionate to the disruption.

Rewards accrued during a pause are applied on the next interaction

A pause does not discard an in-flight reward period. When the contract is unpaused, the next user interaction reduces that user's debt by everything accrued in the interim. A lock that expires during a pause does stop accruing, which is expected: an expired position is no longer locked collateral.

A blacklisted reward recipient keeps their NFTs locked

If a reward-token transfer fails while the contract holds sufficient balance, the realistic cause is the recipient being blacklisted by the token issuer. In that case the NFTs stay locked. This is accepted, and treated as an implicit blacklist that is arguably desirable given why such an address would be flagged.

Retiring a veRAAC reward token leaves per-user accounting inconsistent

veRAAC.finalizeRemoveRewardToken sweeps unclaimed rewards before de-registering a token, but per-user claimable state is not reconciled, which can let a user claim against fresh rewards afterwards. Token retirement is a safety measure for a compromised token, not a routine operation, and a retired token is never re-added — see the corresponding veRAAC trade-off.

The claim cursor is deliberately not advanced on relock

Advancing lastProcessedDistributionIndex when a user creates a new lock after withdrawing would skip every distribution they were eligible for but had not yet claimed, making those rewards permanently unclaimable. Leaving the cursor alone costs a re-scan and keeps the rewards reachable.

Front-running a veRAAC distribution is uneconomical

Capturing a meaningful share of a distribution by locking just before it requires locking a large amount for a long duration. Distributions are periodic and individually modest, so the captured share is small relative to the capital committed — and the long lock the attacker creates is itself beneficial to the protocol.

Dust reward shares truncate to zero

A user's share truncates to zero only when userPower × distAmount18 < totalVotingPower, which would round to zero in token units regardless. The suggested per-user redirection to the treasury would itself round to zero and recover nothing. The remainder is not lost: it stays counted in totalDistributed as unclaimed and is swept to the treasury if the token is ever retired.

Operational assumptions carried by trusted roles

Three acknowledged items reduce to the same argument — the action is reachable only by a trusted role and is not on any operational path:

  • Owner renunciation (v2 L-04) is never invoked and would require a deliberate call.
  • rescueTokens on registered reward tokens while NFTs are locked (v2 M-04) is not done, so no explicit guard was added.
  • Reward tokens that revert on transfer despite sufficient balance (v1 M-12) will not be registered.

The ragequit fee needs no max-fee bound

The fee is fully determined by public on-chain state, so any caller can compute it exactly before signing, and it only decreases as time passes — a user can never be charged more than they calculated. There is no slippage or MEV vector for an on-chain bound to protect against.

Expired unreserved locks are relocked by keepers, not on user interaction

_updateGlobals moves locked to unlocked at each scheduled epoch end, but relocking the locker's own expired veRAAC position happens in processVaultExpiredLocks, which keepers call at predetermined intervals. Doing it inside _updateUnlocked would mean looping every locker position on an ordinary user call to perform work a keeper already handles promptly.