beskay

Smart Contract Engineer

Understanding Voting Escrows

Overview

What exactly is a Voting Escrow contract? Basically, it allows users to lock tokens for a chosen period of time to gain voting power, which can be used for protocol governance or other incentives. The voting power is proportional to both the deposited amount and the lock's duration, incentivizing longer commitments. At the time of locking, the initial voting power w is calculated as:

w = a × t / tmax

where a is the amount of tokens locked, t is the lock duration (from creation to expiration), and tmax is the maximum lock duration (typically 2 or 4 years).

ve

The voting power decreases linearly over time until the lock expires, resembling a graph that starts at w and slopes downward. The voting power (balance) at any time can be calculated as:

(a / tmax) × (lockEndTime - block.timestamp)

Users can interact with their locks via:

Each of these actions triggers the _checkpoint function, which updates the contract's state to reflect changes in voting power. But how are locks represented in the contract? How does the contract keep track of the voting power of every lock at every single point in time?

Implementation Details

The voting escrow contract tracks each lock's voting power using a combination of user-specific and global data structures:

struct Point {
    int128 bias; // Current voting power 
    int128 slope; // Rate of voting power decay (tokens per second) 
    uint256 ts; // Timestamp of the point 
    uint256 blk; // Block number of the point 
  }

User-Specific Voting Power

When a user creates or modifies a lock, the contract calculates:

These values are stored in a new Point in user_point_history[tokenId][user_epoch], where user_epoch is incremented for each update to the user's lock. To calculate the voting power of a lock at any time t:

  1. Find the latest Point in user_point_history where point.ts <= t (via binary search).
  2. Compute the voting power: w = point.bias - point.slope × (t - point.ts)
  3. If balance < 0 or t >= lockEndTime, set balance = 0.

Note: In some implementations (e.g., Velodrome v1), the balanceOfNFTAt function simply gets the latest entry in user_point_history[tokenId] and extrapolates the voting power from that point. This means the returned voting power is only accurate if the user did not extend or add to their lock in the past. This limitation was fixed in Velodrome v2, where a binary search is performed to find the latest entry before or at timestamp t.

Total Voting Power

Tracking the total voting power is more complex, as it must account for all users' locks, each with different amounts, durations, and end times. A naive approach would require iterating over all users' locks to sum their balances, which is impractical on-chain due to gas costs. Instead, the contract uses a global Point in point_history to represent the aggregate voting power, updated incrementally via the _checkpoint function.

Here's how it works:

  1. When a user creates, modifies, or withdraws a lock, _checkpoint records the user's new slope and bias in user_point_history, as explained above.
  2. It then updates the global Point in point_history by adjusting the global slope and bias to reflect the user's changes (i.e., adding the difference between the new and old slopes and biases).
  3. When a lock's end time is set, _checkpoint schedules a slope reduction in slope_changes at that timestamp to account for the lock's expiration.
  4. To optimize gas usage, lock end times are rounded to the end of a week (lockEndTime = (lockEndTime / WEEK) * WEEK, where WEEK = 604800 seconds). This limits the number of unique timestamps in slope_changes, making updates proportional to the number of weeks rather than the number of users.
  5. To calculate the total supply at time t, the contract retrieves the latest Point in point_history where point.ts <= t, then iterates over weekly timestamps up to t, applying slope_changes to update the slope and bias for each week.

This was a high-level overview of what the _checkpoint function does. In the next chapter, we dive deeper into how exactly the function works.

Checkpoint Math

The _checkpoint function is the heart of the Voting Escrow contract. It updates and records both global and per-user voting power whenever a lock is created, modified, or withdrawn.

Function arguments:

Detailed breakdown:

  1. Calculate Slopes and Biases
    For both the old and new locks (u_old, u_new), the function calculates:
    • slope: The rate of voting power decay (amount / MAXTIME).
    • bias: The current voting power (slope × (lock_end - now)).

  2. Retrieve Scheduled Slope Changes
    The function checks the slope_changes mapping for any existing scheduled slope adjustments at the old and new lock end times.
    • If new_locked.end == old_locked.end, reuse the same slope change.
    • Otherwise, get the new slope from slope_changes[new_locked.end].

  3. Update Global Voting Power History
    • Get the latest global Point from point_history[epoch].
    • Compute a block_slope to extrapolate block numbers for historical points.
    • Iterate over weekly timestamps (t_i = (last_checkpoint / WEEK) * WEEK + WEEK) from the last checkpoint time to block.timestamp (capped at 255 iterations, around ~5 years):
      • Update global slope: slope += slope_changes[t_i]
      • Update global bias: bias -= slope × (t_i - last_checkpoint)
      • Update the block number using the calculated block_slope from above
      • Increment the epoch counter: _epoch += 1
      • If t_i = block.timestamp, set blk = block.number for accuracy and exit the loop
      • If not, store the updated point in point_history[epoch] and repeat.

  4. Apply User Changes to Global Point
    Now point_history is filled until t = block.timestamp, but the changes from the new user lock aren't reflected yet:
    • Add u_new.slope - u_old.slope to last_point.slope.
    • Add u_new.bias - u_old.bias to last_point.bias.
    • Store the updated global Point in point_history[epoch]

  5. Schedule Future Slope Changes
    The slope_changes mapping is updated to reflect when a user's voting power will stop contributing:
    • If it's not a newly created lock (old_locked.end > block.timestamp):
      • Add the original slope u_old.slope back to slope_changes[old_locked.end] (canceling its earlier subtraction).
      • If the user added to their existing lock (new_locked.end == old_locked.end), subtract u_new.slope from slope_changes[old_locked.end].
    • If it's a newly created lock or an extension of an existing lock:
      • Subtract the new slope u_new.slope from slope_changes[new_locked.end]

  6. Store User Lock Data
    • Increment the user's epoch (user_point_epoch[tokenId]).
    • Save the new user point (u_new) at the current block and timestamp in user_point_history.

Conclusion

The Voting Escrow model is a mechanism for aligning long-term incentives in decentralized protocols. By locking tokens over time, users gain voting power that decays linearly until the lock expires, encouraging sustained commitment and active participation.

In this article, we explored the technical foundations of Voting Escrows and how they are implemented in practice, with a focus on how the contract efficiently tracks both individual and total voting power. At the core of this system is the _checkpoint function, which updates and records global and per-user voting power whenever a lock is created or modified.

Understanding the vote-escrow architecture is important for developers working on governance systems, ve-tokenomics, or any protocol using time-based staking. It lays the groundwork for further developments like gauge voting, reward distribution, or on-chain governance, which build directly on top of the ve-model.

References