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 supplylocked(address)
(LockedBalance
) - Get user's locked balance
Checkpoint Data
epoch()
(uint256
) - Current global checkpoint epochpoint_history(uint256)
(Point
) - Get global checkpoint point by epochuser_point_history(address, uint256)
(Point
) - Get user checkpoint point by epochuser_point_epoch(address)
(uint256
) - Get user's current epochslope_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 valueslope
(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 lockedend
(uint256
) - Lock end timestamp
LockActions (Enum for lock action types)
DEPOSIT_FOR
- Deposit for another userCREATE_LOCK
- Create new lockINCREASE_AMOUNT
- Increase locked amountINCREASE_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 powervalue
(uint256
) - Amount of tokens lockedlocktime
(uint256
) - Lock end timestamptype
(LockActions
) - Type of lock actionts
(uint256
) - Transaction timestamp
Withdraw (Emitted when tokens are unlocked)
_from
(address
) - Address that initiated the withdrawal_for
(address
) - Address that receives the tokensvalue
(uint256
) - Amount of tokens unlockedts
(uint256
) - Transaction timestamp
Supply (Emitted when total locked supply changes)
prevSupply
(uint256
) - Previous total locked supplysupply
(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.
Input | Type | Description |
---|---|---|
_value | uint256 | Amount of YB tokens to lock |
_unlock_time | uint256 | Epoch 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.
Input | Type | Description |
---|---|---|
_value | uint256 | Amount of additional tokens to lock |
_for | address | Address 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.
Input | Type | Description |
---|---|---|
_unlock_time | uint256 | New 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.
Input | Type | Description |
---|---|---|
_for | address | Address 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.
Input | Type | Description |
---|---|---|
account | address | User 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.
Input | Type | Description |
---|---|---|
account | address | User address |
timepoint | uint256 | Timestamp 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.
Input | Type | Description |
---|---|---|
timepoint | uint256 | Timestamp 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.
Input | Type | Description |
---|---|---|
owner | address | Current owner of the locked position |
to | address | New owner of the locked position |
token_id | uint256 | NFT 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).
Input | Type | Description |
---|---|---|
addr | address | User 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.
Input | Type | Description |
---|---|---|
addr | address | User 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.
Input | Type | Description |
---|---|---|
_addr | address | User 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.
Input | Type | Description |
---|---|---|
transfer_clearance_checker | TransferClearanceChecker | New 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()
).