Skip to main content

Voting Escrow

The source code of the VotingEscrow.vy contract can be found on GitHub. The contract is written with Vyper version 0.4.3.

Overview

The VotingEscrow contract implements the veYB (vote-escrowed YB) token system. It allows users to lock YB tokens for a specified duration to receive voting power that decays linearly over time. This creates a time-weighted voting mechanism where users are committed to the future of the protocol.

  • Time-Weighted Voting: Voting power decays linearly over time
  • ERC-721 NFTs: Each locked position is represented as an NFT
  • Maximum Lock Duration: 4 years maximum lock time
  • Transfer Restrictions: Transfers only allowed under specific conditions
  • Checkpoint System: Maintains historical voting power data
  • EIP-6372 Compliance: Implements clock() for timestamp-based voting

Related: GaugeController for YB token governance and emission distribution.

Constants

  • WEEK (uint256) - 7 days in seconds (604,800)
  • MAXTIME (int256) - Maximum lock time (4 years)
  • UMAXTIME (uint256) - Maximum lock time as uint256 (4 years)
  • WAD (uint256) - 10^18 for precision calculations

Public Variables

Core Contracts

  • TOKEN() (IERC20) - YB token contract (immutable)

Lock State

  • supply() (uint256) - Total locked token supply
  • locked(address) (LockedBalance) - Get user's locked balance

Checkpoint Data

  • epoch() (uint256) - Current global checkpoint epoch
  • point_history(uint256) (Point) - Get global checkpoint point by epoch
  • user_point_history(address, uint256) (Point) - Get user checkpoint point by epoch
  • user_point_epoch(address) (uint256) - Get user's current epoch
  • slope_changes(uint256) (int256) - Get slope change at specific time

Transfer Control

  • transfer_clearance_checker() (TransferClearanceChecker) - External transfer checker contract

Data Structs

Point

  • bias (int256) - Current voting power value
  • slope (int256) - Rate of voting power decrease (-dweight/dt)
  • ts (uint256) - Timestamp of this point

UntimedPoint

  • bias (uint256) - Current voting power value (unsigned)
  • slope (uint256) - Rate of voting power decrease (unsigned)

LockedBalance

  • amount (int256) - Amount of tokens locked
  • end (uint256) - Lock end timestamp

LockActions (Enum for lock action types)

  • DEPOSIT_FOR - Deposit for another user
  • CREATE_LOCK - Create new lock
  • INCREASE_AMOUNT - Increase locked amount
  • INCREASE_TIME - Extend lock duration

Events

Deposit (Emitted when tokens are locked)

  • _from (address) - Address that initiated the deposit
  • _for (address) - Address that receives the voting power
  • value (uint256) - Amount of tokens locked
  • locktime (uint256) - Lock end timestamp
  • type (LockActions) - Type of lock action
  • ts (uint256) - Transaction timestamp

Withdraw (Emitted when tokens are unlocked)

  • _from (address) - Address that initiated the withdrawal
  • _for (address) - Address that receives the tokens
  • value (uint256) - Amount of tokens unlocked
  • ts (uint256) - Transaction timestamp

Supply (Emitted when total locked supply changes)

  • prevSupply (uint256) - Previous total locked supply
  • supply (uint256) - New total locked supply

SetTransferClearanceChecker (Emitted when transfer clearance checker is updated)

  • clearance_checker (address) - New clearance checker address

Function Documentation

create_lock()

Description:
Creates a new lock by depositing YB tokens and locking them until a specified time. Mints an NFT representing the locked position.

Emits:
Deposit - When tokens are locked. Supply - When total supply changes.

InputTypeDescription
_valueuint256Amount of YB tokens to lock
_unlock_timeuint256Epoch time when tokens unlock

Requirements:

  • Value must be >= UMAXTIME (minimum lock amount)
  • User must not have existing locked tokens
  • Unlock time must be in the future
  • Unlock time cannot exceed 4 years from now
📄 View Source Code
@external
@nonreentrant
def create_lock(_value: uint256, _unlock_time: uint256):
unlock_time: uint256 = (_unlock_time // WEEK) * WEEK # Locktime is rounded down to weeks
_locked: LockedBalance = self.locked[msg.sender]

assert _value >= UMAXTIME, "Min value"
assert _locked.amount == 0, "Withdraw old tokens first"
assert unlock_time > block.timestamp, "Can only lock until time in the future"
assert unlock_time <= block.timestamp + UMAXTIME, "Voting lock can be 4 years max"

self._deposit_for(msg.sender, _value, unlock_time, _locked, LockActions.CREATE_LOCK)
erc721._mint(msg.sender, convert(msg.sender, uint256))

increase_amount()

Description:
Increases the amount of locked tokens without changing the unlock time.

Emits:
Deposit - When additional tokens are locked. Supply - When total supply changes.

InputTypeDescription
_valueuint256Amount of additional tokens to lock
_foraddressAddress to increase lock for (defaults to msg.sender)

Requirements:

  • Value must be >= UMAXTIME
  • User must have existing locked tokens
  • Lock must not be expired
📄 View Source Code
@external
@nonreentrant
def increase_amount(_value: uint256, _for: address = msg.sender):
_locked: LockedBalance = self.locked[_for]

assert _value >= UMAXTIME # dev: need non-zero value
assert _locked.amount > 0, "No existing lock found"
assert _locked.end > block.timestamp, "Cannot add to expired lock. Withdraw"

self._deposit_for(_for, _value, 0, _locked, LockActions.INCREASE_AMOUNT)

increase_unlock_time()

Description:
Extends the unlock time for an existing lock without changing the locked amount.

Emits:
Deposit - When lock time is extended.

InputTypeDescription
_unlock_timeuint256New unlock timestamp

Requirements:

  • User must have locked tokens
  • Lock must not be expired
  • New unlock time must be greater than current unlock time
  • New unlock time cannot exceed 4 years from now
📄 View Source Code
@external
@nonreentrant
def increase_unlock_time(_unlock_time: uint256):
_locked: LockedBalance = self.locked[msg.sender]
unlock_time: uint256 = (_unlock_time // WEEK) * WEEK # Locktime is rounded down to weeks

assert _locked.amount > 0, "Nothing is locked"
assert _locked.end > block.timestamp, "Lock expired"
assert unlock_time > _locked.end, "Can only increase lock duration"
assert unlock_time <= block.timestamp + UMAXTIME, "Voting lock can be 4 years max"

self._deposit_for(msg.sender, 0, unlock_time, _locked, LockActions.INCREASE_TIME)

infinite_lock_toggle()

Description:
Toggles between a regular lock and an infinite lock (max_value end time). Infinite locks can only be transferred to other infinite locks.

Emits:
Deposit - When lock type is changed.

Requirements:

  • User must have locked tokens
  • Lock must not be expired
  • For infinite locks: transfer clearance checker must allow transfer
📄 View Source Code
@external
@nonreentrant
def infinite_lock_toggle():
_locked: LockedBalance = self.locked[msg.sender]
assert _locked.end > block.timestamp, "Lock expired"
assert _locked.amount > 0, "Nothing is locked"
unlock_time: uint256 = 0

if _locked.end == max_value(uint256):
checker: TransferClearanceChecker = self.transfer_clearance_checker
if checker.address != empty(address):
assert staticcall checker.ve_transfer_allowed(msg.sender), "Not allowed"
unlock_time = ((block.timestamp + UMAXTIME) // WEEK) * WEEK
else:
unlock_time = max_value(uint256)

self._deposit_for(msg.sender, 0, unlock_time, _locked, LockActions.INCREASE_TIME)

withdraw()

Description:
Withdraws all locked tokens after the lock has expired. Burns the NFT representing the locked position.

Emits:
Withdraw - When tokens are withdrawn. Supply - When total supply changes.

InputTypeDescription
_foraddressAddress to receive withdrawn tokens (defaults to msg.sender)

Requirements:

  • Lock must be expired (block.timestamp >= lock.end)
📄 View Source Code
@external
@nonreentrant
def withdraw(_for: address = msg.sender):
_locked: LockedBalance = self.locked[msg.sender]
assert block.timestamp >= _locked.end, "The lock didn't expire"
value: uint256 = convert(_locked.amount, uint256)

old_locked: LockedBalance = _locked
_locked.end = 0
_locked.amount = 0
self.locked[msg.sender] = _locked
supply_before: uint256 = self.supply
new_supply: uint256 = supply_before - value
self.supply = new_supply

self._checkpoint(msg.sender, old_locked, _locked)
erc721._burn(convert(msg.sender, uint256))
assert extcall TOKEN.transfer(_for, value)

log Withdraw(_from=msg.sender, _for=_for, value=value, ts=block.timestamp)
log Supply(prevSupply=supply_before, supply=new_supply)

getVotes()

Description:
Returns the current voting power for a user account. Voting power decays linearly over time.

Returns:
uint256 - Current voting power.

InputTypeDescription
accountaddressUser address
📄 View Source Code
@external
@view
def getVotes(account: address) -> uint256:
_epoch: uint256 = self.user_point_epoch[account]
if _epoch == 0:
return 0
else:
last_point: Point = self.user_point_history[account][_epoch]
last_point.bias -= last_point.slope * convert(block.timestamp - last_point.ts, int256)
if last_point.bias < 0:
last_point.bias = 0
return convert(last_point.bias, uint256)

getPastVotes()

Description:
Returns the voting power a user had at a specific moment in the past using binary search.

Returns:
uint256 - Voting power at the specified time.

InputTypeDescription
accountaddressUser address
timepointuint256Timestamp to check voting power at
📄 View Source Code
@external
@view
def getPastVotes(account: address, timepoint: uint256) -> uint256:
_min: uint256 = 0
_max: uint256 = self.user_point_epoch[account]

if timepoint < self.user_point_history[account][0].ts:
return 0

for i: uint256 in range(128): # Will be always enough for 128-bit numbers
if _min >= _max:
break
_mid: uint256 = (_min + _max + 1) // 2
if self.user_point_history[account][_mid].ts <= timepoint:
_min = _mid
else:
_max = _mid - 1

upoint: Point = self.user_point_history[account][_min]
upoint.bias -= upoint.slope * convert(timepoint - upoint.ts, int256)

if upoint.bias >= 0:
return convert(upoint.bias, uint256)
else:
return 0

totalVotes()

Description:
Returns the current total voting power across all users.

Returns:
uint256 - Total voting power.

📄 View Source Code
@external
@view
def totalVotes() -> uint256:
return self.total_supply_at(block.timestamp)

getPastTotalSupply()

Description:
Returns the total voting power at a specific moment in the past. Works with both past and future timestamps.

Returns:
uint256 - Total voting power at the specified time.

InputTypeDescription
timepointuint256Timestamp to check total voting power at
📄 View Source Code
@external
@view
def getPastTotalSupply(timepoint: uint256) -> uint256:
return self.total_supply_at(timepoint)

transferFrom()

Description:
Transfers a locked position from one user to another. Only allowed under specific conditions (infinite locks, clearance checker approval).

Emits:
Transfer - When NFT is transferred.

InputTypeDescription
owneraddressCurrent owner of the locked position
toaddressNew owner of the locked position
token_iduint256NFT token ID (must equal owner address)

Requirements:

  • Caller must be approved or owner of the NFT
  • Token ID must equal owner address
  • Transfer must be allowed by clearance checker
  • Both users must have infinite locks with same end time
📄 View Source Code
@external
def transferFrom(owner: address, to: address, token_id: uint256):
assert erc721._is_approved_or_owner(msg.sender, token_id), "erc721: caller is not token owner or approved"
assert token_id == convert(owner, uint256), "Wrong token ID"
assert self._ve_transfer_allowed(owner, to), "Need max veLock"
self._merge_positions(owner, to)
erc721._burn(token_id)

get_last_user_slope()

Description:
Returns the most recently recorded rate of voting power decrease for a user.

Returns:
int256 - Slope value (negative rate of voting power decrease).

InputTypeDescription
addraddressUser address
📄 View Source Code
@external
@view
def get_last_user_slope(addr: address) -> int256:
uepoch: uint256 = self.user_point_epoch[addr]
return self.user_point_history[addr][uepoch].slope

get_last_user_point()

Description:
Returns the most recently recorded point of voting power for a user.

Returns:
UntimedPoint - User's current voting power point.

InputTypeDescription
addraddressUser address
📄 View Source Code
@external
@view
def get_last_user_point(addr: address) -> UntimedPoint:
uepoch: uint256 = self.user_point_epoch[addr]
return UntimedPoint(
bias=convert(self.user_point_history[addr][uepoch].bias, uint256),
slope=convert(self.user_point_history[addr][uepoch].slope, uint256)
)

locked__end()

Description:
Returns the timestamp when a user's lock finishes.

Returns:
uint256 - Lock end timestamp.

InputTypeDescription
_addraddressUser address
📄 View Source Code
@external
@view
def locked__end(_addr: address) -> uint256:
return self.locked[_addr].end

checkpoint()

Description:
Records global data to checkpoint without affecting any specific user.

Returns:
None.

📄 View Source Code
@external
def checkpoint():
self._checkpoint(empty(address), empty(LockedBalance), empty(LockedBalance))

set_transfer_clearance_checker()

Description:
Sets the external contract that checks if transfers are allowed. Only callable by owner.

Emits:
SetTransferClearanceChecker - When clearance checker is set.

InputTypeDescription
transfer_clearance_checkerTransferClearanceCheckerNew clearance checker contract
📄 View Source Code
@external
def set_transfer_clearance_checker(transfer_clearance_checker: TransferClearanceChecker):
ownable._check_owner()
self.transfer_clearance_checker = transfer_clearance_checker
log SetTransferClearanceChecker(clearance_checker=transfer_clearance_checker.address)

Voting Power Mechanics

Voting power decays linearly over time using the formula voting_power = bias - slope * (current_time - point_time), where bias is the initial voting power value, slope is the rate of decrease (negative value), and point_time is the timestamp when the point was recorded. Longer locks result in higher initial voting power, with a maximum lock duration of 4 years (MAXTIME) and minimum lock amount of UMAXTIME (4 years worth of tokens). All lock times are rounded down to whole weeks using unlock_time = (requested_time // WEEK) * WEEK.

Transfer Restrictions

Regular locks cannot be transferred until expired and must be withdrawn and re-locked by the new owner. Users can convert regular locks to infinite locks using infinite_lock_toggle(), which sets the lock end time to max_value(uint256). Infinite locks can be transferred to other infinite locks via transferFrom() or safeTransferFrom(), but require clearance checker approval and both parties must have the same lock end time. The clearance checker validates transfers by calling ve_transfer_allowed(owner) to ensure the source user has zero voting power. Transfers merge the locked positions using _merge_positions() and burn the source NFT while maintaining the total locked supply.

Checkpoint System

The checkpoint system maintains historical voting power data through _checkpoint() which records both global and per-user data. Global checkpoints store total voting power in point_history[epoch] with bias and slope values, while user checkpoints store individual voting power in user_point_history[addr][epoch]. Slope changes are tracked in slope_changes[time] to enable efficient historical calculations using binary search in getPastVotes() and getPastTotalSupply(). The system updates during deposits (_deposit_for()), withdrawals (withdraw()), and transfers (_merge_positions()), with epochs incrementing for each checkpoint operation.

Integration with GaugeController

The VotingEscrow integrates with the GaugeController through the transfer_clearance_checker interface, which the GaugeController implements via ve_transfer_allowed(user). The GaugeController calls get_last_user_slope(addr) and get_last_user_point(addr) during vote_for_gauge_weights() to calculate voting power based on locked YB tokens and lock duration. The GaugeController can prevent veYB transfers by setting itself as the clearance checker via set_transfer_clearance_checker(), ensuring users cannot transfer voting power while actively participating in governance. Lock validation occurs through locked__end(addr) to verify lock expiration status for voting eligibility.

Delegation

The VotingEscrow contract does not support delegation. The delegates(account) function always returns the account itself, and delegate(delegatee) and delegateBySig() functions revert with "Not supported". This means users cannot delegate their voting power to other addresses - they must vote directly with their own locked tokens.

EIP-6372 Compliance

The contract implements EIP-6372 for timestamp-based voting with clock() returning current timestamp, CLOCK_MODE returning "mode=timestamp", and functions for current and historical voting power (getVotes(), getPastVotes(), totalVotes(), getPastTotalSupply()).