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) - Global checkpoint point by epochuser_point_history(address, uint256)(Point) - User checkpoint point by epochuser_point_epoch(address)(uint256) - User's current epochslope_changes(uint256)(int256) - Slope change at specific time
Transfer Control
transfer_clearance_checker()(TransferClearanceChecker) - External transfer checker contract
Data Structs
Point: bias: int256, slope: int256, ts: uint256.
UntimedPoint: bias: uint256, slope: uint256.
LockedBalance: amount: int256, end: uint256.
LockActions enum: DEPOSIT_FOR, CREATE_LOCK, INCREASE_AMOUNT, INCREASE_TIME.
Events
Deposit — _from, _for, value, locktime, type, ts
Withdraw — _from, _for, value, ts
Supply — prevSupply, supply
SetTransferClearanceChecker — clearance_checker
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, Supply.
| 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 an existing lock
- 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, Supply.
| Input | Type | Description |
|---|---|---|
_value | uint256 | Amount of additional tokens to lock |
_for | address | Address to increase lock for (defaults to msg.sender) |
📄 View Source Code
@external
@nonreentrant
def increase_amount(_value: uint256, _for: address = msg.sender):
_locked: LockedBalance = self.locked[_for]
assert _value >= UMAXTIME
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.
| Input | Type | Description |
|---|---|---|
_unlock_time | uint256 | New unlock timestamp |
📄 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
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 (end = max_value(uint256)). Infinite locks can only be transferred to other infinite locks. When enabling, the clearance checker must allow the toggle.
Emits:
Deposit.
📄 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, Supply.
| Input | Type | Description |
|---|---|---|
_for | address | Address to receive withdrawn tokens (defaults to msg.sender) |
📄 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. 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 over user checkpoints.
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):
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: Current total voting power across all users.
Returns:
uint256.
📄 View Source Code
@external
@view
def totalVotes() -> uint256:
return self.total_supply_at(block.timestamp)
getPastTotalSupply()
Description: Total voting power at a specific moment. Works with both past and future timestamps.
Returns:
uint256.
| Input | Type | Description |
|---|---|---|
timepoint | uint256 | Timestamp |
📄 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: both wallets must have max-locked or infinite-locked positions, the sender must have zero allocated gauge vote power (via clearance checker), and the token_id must equal the owner address.
Emits:
Transfer - ERC-721.
| 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) |
📄 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() / get_last_user_point()
Description: Return the user's most recent slope / full point from history. Used by GaugeController for vote weighting.
📄 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
@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.
| 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. Public.
📄 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.
| 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: voting_power = bias - slope * (current_time - point_time). Longer locks → higher initial voting power; maximum lock is 4 years (MAXTIME), minimum lock amount is UMAXTIME (4 years worth of tokens). All lock times are rounded down to whole weeks: unlock_time = (requested_time // WEEK) * WEEK.
Transfer Restrictions
Regular (term) locks cannot be transferred; they must be withdrawn and re-locked by the new owner. Users can convert term locks to infinite locks via infinite_lock_toggle() (end set to max_value(uint256)).
Infinite-to-infinite transfers work via transferFrom / safeTransferFrom, subject to clearance-checker approval (the sender must have zero allocated gauge vote power). Transfers merge the two positions using _merge_positions() and burn the source NFT while maintaining the total locked supply.
Implementation detail: The receiving wallet must already hold a max-locked position (or empty-but-then-max-lock at least 1 YB there first) — merging into an empty position fails. Max-lock 1 YB in the destination before transfer if in doubt.
Checkpoint System
The checkpoint system maintains historical voting power data via _checkpoint(). Global checkpoints store total voting power in point_history[epoch] (bias, slope); user checkpoints store individual voting power in user_point_history[addr][epoch]. Slope changes are tracked in slope_changes[time] for efficient historical calculation via binary search in getPastVotes() / getPastTotalSupply(). The system updates during deposits (_deposit_for()), withdrawals (withdraw()), and transfers (_merge_positions()); epochs increment per checkpoint.
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". Users cannot delegate voting power to other addresses — they must vote directly with their own locked tokens.
EIP-6372 Compliance
Implements EIP-6372 for timestamp-based voting: clock() returns current timestamp; CLOCK_MODE returns "mode=timestamp"; full complement of getVotes / getPastVotes / totalVotes / getPastTotalSupply.
Related
- DAO: GaugeController — consumes veYB voting power.
- DAO: YB Token — the underlying token that gets locked.
- User: veYB — user-level explainer.
- User: Lock YB for veYB — task walkthrough.