All articles
Shadow ArenaMay 26, 20269 min read

Velodrome's Reward System: Three Permanent-Lock Paths in One Subsystem

Velodrome (Solidly fork on Optimism) has three documented permanent-lock bugs in its bribe-distribution code. Same subsystem, three different mechanisms. Every Solidly descendant inherits them.

By Carlos (Bloqarl)

TL;DR

  • Velodrome is the canonical Solidly fork (ve(3,3) AMM) running on Optimism. Zealynx Academy's Shadow Arena has it documented at 1,914 SLOC with 13 findings.
  • Three of those findings (H-02, M-07, M-08) are permanent-lock paths in the same bribe-distribution subsystem. Three different mechanisms, one shared root cause: state writes that don't happen on the path that should trigger them.
  • H-02 strands bribes via a missing claim-window check. M-07 strands them via a wrong-order state update during epoch transitions. M-08 accumulates a rounding-induced lock across many epochs.
  • Every Solidly fork (Aerodrome, Thena, Equalizer, Ramses, dozens more) inherits the same patterns unless they explicitly patched them. Most haven't.
  • The teaching value isn't the specific bugs. It's the pattern: one piece of business logic, multiple branches, four distinct bug classes. Velodrome's bribe code and Basin's pump-encoding code both demonstrate this clustering. When you find a bug in a non-trivial subsystem, expect more.

Why this matters

Solidly's ve(3,3) model has been forked into more DEXs than almost any other AMM design. The architecture is genuinely innovative: vote-escrow tokens (ve) that direct emissions toward selected pools, plus bribes that other actors pay to influence the vote. The composability is what made it spread.

Unfortunately, the original Solidly implementation shipped with subtle bugs in its reward-distribution math. Andre Cronje's first version had several. Velodrome's reimplementation fixed some, introduced new ones. By the time Aerodrome, Thena, and the long tail of Solidly forks deployed, the bug surface had compounded.

If you're auditing a Solidly fork in 2026, you are auditing a codebase whose reward-distribution subsystem has been a known bug magnet for three years. Knowing where the lock paths cluster is half the battle. Knowing why they cluster there (epoch transitions, rounding, missing window checks) is the other half.

The Shadow Arena documents three. The actual count across Solidly forks at large is higher. This article walks through the three to give you the pattern, then points you at where to look in any fork you encounter.

The bribe-distribution model

Brief refresher so the bugs make sense.

Solidly-style protocols pay liquidity providers in two streams. The base stream is emissions: the protocol mints its own token and distributes it to pools weighted by the share of vote-escrow holders who voted for that pool. The bribe stream is external: third parties pay tokens (any tokens) into a per-pool bribe contract, and those bribes get distributed to the voters who voted for that pool, proportional to their voting weight.

Each "epoch" (typically one week) is the unit of distribution. At the end of an epoch, each voter can claim their share of bribes for the pools they voted on. The protocol holds the bribes in a contract until claimed.

This creates three temporal couplings that the bugs exploit:

  1. Claim window: bribes for epoch N are claimable only after epoch N ends. Before that, the contract holds them but claim() reverts or returns 0.
  2. Epoch transitions: when an epoch ends and the next begins, vote weights are snapshotted, bribes from the previous epoch are finalized, and new bribes start accruing for the next round.
  3. Per-voter accounting: each voter has a record of how much they're owed across all (pool, epoch) tuples. This record updates lazily (only when they claim or vote), which means a stale record can persist for many epochs.

Three temporal couplings, three places where state updates can desync from intent. That's why three bugs.

H-02: missing claim-window check

The first lock path is in claim() itself. The function takes a list of (pool, epoch) tuples to claim and transfers the corresponding bribe amounts. The intended behavior is: claim bribes from epochs that have ended, refuse to claim from epochs that are still in progress.

The implementation has a check, but it's on the wrong variable. It verifies that the epoch has started (a precondition that's trivially true for any current or past epoch), not that it has ended. If a voter calls claim((pool, currentEpoch)), the function happily transfers their pro-rata share of the in-progress bribes.

That's bad in two ways. First, it lets voters extract bribes before the epoch closes, which breaks the bribe-sender's expectation that funds are held until distribution finalizes. Second, and worse: once a voter has claimed for the in-progress epoch, the per-voter record marks the (pool, currentEpoch) tuple as claimed. When the epoch actually ends and the bribe-sender adds more bribes mid-epoch, those additional bribes are never distributable to that voter, because the contract's accounting believes they already claimed.

Result: any bribe added to a pool after at least one voter has prematurely claimed for that pool's current epoch is permanently locked. The contract holds the funds, no claim path can reach them, no admin function can recover them.

M-07: wrong-order state update at epoch boundary

The second lock path is in the epoch-transition logic. When an epoch ends, the protocol does several things in sequence: finalizes the vote tally, snapshots vote weights, distributes emissions, and "rolls forward" the bribe accounting state.

The roll-forward is where M-07 lives. The intended flow is:

  1. Compute the per-voter bribe entitlements for the just-ended epoch.
  2. Add those entitlements to each voter's claimable balance.
  3. Reset the per-pool bribe-pool counter to zero so the next epoch's bribes accrue cleanly.

The bug is that step 3 happens before step 2 in a specific code path triggered when the epoch ends with no votes for a particular pool. The reset zeroes out the bribe-pool counter before the entitlement computation has run. The computation, which reads the now-zeroed counter, produces zero entitlements. The next-epoch bribes accrue correctly, but the just-ended-epoch bribes are gone from the contract's perspective.

The funds are still in the contract balance. The accounting that would let any voter claim them is permanently zero. That's a permanent lock.

This bug is particularly insidious because it only fires on pools that received bribes but no votes during a given epoch. In active pools, votes are always nonzero, so the wrong-order path is never taken. The bug surfaces only on pools with low voter engagement, which are also the pools where the lost amounts are smallest. Easy to miss in cursory review.

M-08: rounding accumulation across epochs

The third lock path is the slowest-burning. Bribe distributions involve fractional math: voter weight divided by total weight, multiplied by total bribe pool. Solidity's integer arithmetic rounds down. Each claim leaves a wei or two of "dust" in the contract that the claimer's record marks as claimed.

In a normal financial system, that dust would be negligible. In a system with thousands of voters across hundreds of pools across hundreds of epochs, the dust accumulates. Each (voter, pool, epoch) tuple potentially loses 1-2 wei. Multiply by the total tuple count and you get a meaningful aggregate balance permanently stranded in the contract.

M-08 is documented in Shadow Arena as a Medium because the per-incident loss is small, but the accumulation makes it a real long-tail bug. After two years of operation, a Solidly fork can have 10-100 ETH worth of locked dust depending on bribe activity.

The fix isn't elegant. Either you switch to a representation that doesn't lose precision (which requires deeper changes to the math), or you provide an admin sweep function that can recover the stranded dust periodically and re-distribute it (which introduces a centralization vector). Most forks ship without either.

The shared pattern: write-not-happening

All three bugs are instances of the same meta-pattern: a state write that should happen on a particular path doesn't.

  • H-02: the bug is the absence of a "this epoch isn't claimable yet" guard and the absence of a state-rewind on claim. The write that's missing is the one that would prevent the per-voter record from marking the in-progress epoch as claimed.
  • M-07: the bug is the wrong execution order of two writes. The "compute entitlements" write should happen before the "reset counter" write. It doesn't, on one specific branch.
  • M-08: the bug is the absence of a write that captures the rounding remainder. The dust gets neither distributed nor recorded as residual; it just sits.

This is the core of why we classify these as "missed state update" findings rather than "reward distribution" or "Solidly inheritance". The unifying root cause is structural, not domain-specific.

It also explains why so many Solidly forks ship with these bugs. The forks copy the code, fix or pretend to fix the headline issues from the Solidly retrospectives, and ship. The deeper structural issues (the missed writes) only surface when an auditor traces every state variable through every branch of every function in the bribe subsystem.

What this teaches you about Solidly forks

Three takeaways for anyone auditing a ve(3,3) codebase:

  1. The bribe subsystem is the bug magnet. If you have one weekend with a Solidly fork, spend it on claim(), the epoch-transition code, and the per-voter accounting structures. Don't spend it on the swap router, which is well-trodden territory.

  2. Trace every state variable in the bribe subsystem through every branch. If you see a function with conditional logic, list the writes performed in each branch. Compare. The bugs live in the deltas.

  3. Test rounding behavior at scale. Run a 100-epoch simulation with 1000 voters and check the contract's residual balance. If it's growing monotonically and isn't recoverable, you have an M-08-class bug.

The first point is by far the highest-leverage. Most auditors who review Solidly forks spend the bulk of their time on swaps, then sprinkle a half-day on bribes at the end. Reverse the priority and your finding rate goes up substantially.

Related questions

What's a Solidly fork? A protocol forked from Andre Cronje's ve(3,3) design, which combines vote-escrow governance with directed-emissions and external bribes. Velodrome, Aerodrome, Thena, Equalizer, Ramses, Pearl, and many others.

Are all Solidly forks vulnerable to these three bugs? Most are vulnerable to some subset. Aerodrome (Velodrome's Base-chain sibling) inherits Velodrome's mitigations, so it's better. Newer forks built without auditing the upstream history tend to ship with at least one of the three.

Why do bribes get stuck instead of just under-counted? Solidity has no garbage collection. ERC-20 balances on a contract that no function can move are permanent. Once the accounting state desyncs from the actual balance, the funds are functionally lost.

How can I check if a Solidly fork I'm using has these bugs? Read the bribe contract's claim function and look for: (1) a check that compares claimable epoch against the current epoch boundary, (2) the order of writes in the epoch-end transition function, (3) any handling of rounding dust. If any of those are absent or implemented differently than you'd expect, you have a finding to investigate.

Are these bugs exploitable for profit, or just for protocol harm? They're mostly protocol-harm: funds get stuck, not stolen. H-02 has a partial profit angle (a voter can claim bribes early), but most of the harm is the permanent lock that follows. M-07 and M-08 are pure value destruction, no attacker capture.

Where to dig deeper

If you want hands-on practice, Velodrome in Shadow Arena (1,914 SLOC, 13 findings, advanced difficulty) is the right target. Three of the findings are these lock paths; the other ten cover access control, oracle integrity, MEV, and other classes. Working through the full target gives you broad coverage of ve(3,3) audit patterns.

For broader context, the original Solidly retrospective from Andre Cronje (and the subsequent post-mortems from Velodrome and Aerodrome teams) is worth reading. The retrospectives explain why the design ended up the way it did, which makes the audit work easier when you encounter another fork later.

Tagged

Shadow ArenaVelodromeSolidlySmart Contract Security