VotingEscrow
Source: VotingEscrow.vy on GitHub
Overview
The VotingEscrow contract implements vote-escrowed YB (veYB). You lock YB tokens and receive voting power. The GaugeController reads this voting power for gauge votes, and the FeeDistributor reads it for fee shares.
Each lock is a term lock or a permalock:
- A term lock has an unlock time at most 4 years ahead. Its voting power decays linearly toward zero at the unlock time. Thus voting is time-weighted: a longer commitment gives more voting power.
- A permalock (infinite lock) has no unlock time: its
endismax_value(uint256). Its voting power does not decay.
The contract also has these features:
- ERC-721 NFTs: each locked position is a non-fungible token (NFT).
balanceOf(owner)is the ERC-721 balance, the number of veYB NFTs that the address holds. It is not voting power; read voting power withgetVotes(). - Restricted transfers: a transfer merges two positions of the same kind.
transferFrom()gives the checks. - Checkpoint system: the contract keeps historical voting power data.
- Ethereum Improvement Proposal (EIP) 6372 compliance: the contract implements
clock()for timestamp-based voting.
Approvals
Before you call create_lock() or increase_amount(), approve the VotingEscrow to spend the YB that you lock. The VotingEscrow pulls the YB from the caller with the YB transferFrom.
Constants
The contract has these constants:
WEEK(uint256) - 7 days in seconds (604,800)MAXTIME(int256) - Maximum lock time: 4 × 365 days = 126,144,000 secondsUMAXTIME(uint256) -MAXTIMEas uint256 (126,144,000)WAD(uint256) - 10^18 for precision calculations
Public variables
The contract has these public variables:
Core contracts
TOKEN()(IERC20) - YB token contract (immutable)
Lock state
supply()(uint256) - Total locked token supplylocked(address)(LockedBalance) - Locked balance of an address
Checkpoint data
epoch()(uint256) - Current global checkpoint epochpoint_history(uint256)(Point) - Global checkpoint point by epochuser_point_history(address, uint256)(Point) - Checkpoint point of an address by epochuser_point_epoch(address)(uint256) - Current epoch of an addressslope_changes(uint256)(int256) - Slope change at specific time
Transfer control
transfer_clearance_checker()(TransferClearanceChecker) - The clearance checker: the external contract that must allow a transfer, or the change of a permalock back to a term lock. The VotingEscrow owner sets the GaugeController in this role.
Data structs
The contract uses these structs and this enum:
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
The contract emits these events. indexed marks an indexed field.
Deposit: _from: address (indexed), _for: address (indexed), value: uint256, locktime: uint256 (indexed), type: LockActions, ts: uint256
Withdraw: _from: address (indexed), _for: address (indexed), value: uint256, ts: uint256
Supply: prevSupply: uint256, supply: uint256
SetTransferClearanceChecker: clearance_checker: address
Functions
Each function lists its own revert strings. Error codes holds the shared table.
create_lock()
Creates a new lock by depositing YB tokens and locking them until a set time. The function mints a veYB NFT that represents the locked position.
The function emits Deposit and Supply.
The function takes these inputs:
| Input | Type | Description |
|---|---|---|
_value | uint256 | Amount of YB tokens to lock |
_unlock_time | uint256 | Unix timestamp when the tokens unlock. The contract rounds it down to a week. |
create_lock() reverts in these cases:
Min value-_valueis belowUMAXTIME(126,144,000 wei of YB, the minimum lock amount).Withdraw old tokens first- The caller has an existing lock, expired or not.Can only lock until time in the future- The unlock time, rounded down to a week, is not after the block time.Voting lock can be 4 years max- The unlock time is after block time + 4 years.erc20: insufficient allowance,erc20: transfer amount exceeds balance- From the YBtransferFrom: the caller's YB allowance for the VotingEscrow, or its YB balance, is below the amount to lock.
Contract source: create_lock()
The contract source gives this function body:
@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()
Increases the amount of locked tokens without changing the unlock time.
The function emits Deposit and Supply.
The function takes these inputs:
| Input | Type | Description |
|---|---|---|
_value | uint256 | Amount of additional tokens to lock |
_for | address | Address whose lock to increase. Defaults to the caller. |
increase_amount() reverts in these cases:
- Without a message -
_valueis belowUMAXTIME. No existing lock found-_forhas no lock.Cannot add to expired lock. Withdraw- The lock of_forhas expired.erc20: insufficient allowance,erc20: transfer amount exceeds balance- From the YBtransferFrom: the caller's YB allowance for the VotingEscrow, or its YB balance, is below the amount to lock.
Contract source: increase_amount()
The contract source gives this function body:
@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()
Extends the unlock time of an existing lock without changing the locked amount.
The function emits Deposit and Supply.
The function takes this input:
| Input | Type | Description |
|---|---|---|
_unlock_time | uint256 | New unlock timestamp. The contract rounds it down to a week. |
increase_unlock_time() reverts in these cases:
Nothing is locked- The caller has no lock.Lock expired- The lock has expired.Can only increase lock duration- The new unlock time, rounded down to a week, is not after the current one. A permalock always fails this check.Voting lock can be 4 years max- The new unlock time is after block time + 4 years.
Contract source: increase_unlock_time()
The contract source gives this function body:
@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()
Turns a term lock into a permalock (end = max_value(uint256)), or turns a permalock back into a term lock. Enabling has no clearance check. When disabling, the clearance checker must allow it: the caller must have no active gauge votes (Not allowed). Disabling sets end to block time + 4 years, rounded down to a week, and the lock starts to decay.
The function emits Deposit and Supply.
infinite_lock_toggle() reverts in these cases:
Lock expired- The lock has expired, or the caller has no lock.Nothing is locked- The lock amount is 0.Not allowed- Disabling only: the clearance checker is set and the caller has active gauge votes.
Contract source: infinite_lock_toggle()
The contract source gives this function body:
@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()
Withdraws all locked tokens after the lock has expired. The function burns the veYB NFT that represents the locked position.
The function emits Withdraw and Supply.
The function takes this input:
| Input | Type | Description |
|---|---|---|
_for | address | Address to receive the withdrawn tokens. Defaults to the caller. |
withdraw() reverts in these cases:
The lock didn't expire- The lock end is after the block time. A permalock always fails this check; turn it off withinfinite_lock_toggle()first.erc721: invalid token ID- The caller holds no veYB NFT, for example after a withdrawal or after a transfer of the position.erc20: transfer to the zero address- From the YBtransfer:_foris the zero address.
Contract source: withdraw()
The contract source gives this function body:
@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()
Returns the current voting power of an address. Voting power decays linearly over time.
The function returns the current voting power, a uint256 in raw veYB units with 18 decimals, the same scale as YB.
For a term lock, the contract stores slope = amount // MAXTIME and bias = slope × (end − ts). For a permalock, slope = 0 and bias = amount. getVotes() returns bias − slope × (now − ts), floored at 0.
The function takes this input:
| Input | Type | Description |
|---|---|---|
account | address | Address to read |
This example locks 1,000 YB (_value = 10**21). The deposit rounds the amount down to a multiple of UMAXTIME, so the lock holds amount = 999,999,999,999,981,504,000 and slope = amount // MAXTIME = 7,927,447,995,941. The voting power at two times to unlock is as follows:
- With 4 × 365 days (126,144,000 s) to the unlock time,
bias = 7,927,447,995,941 × 126,144,000 = 999,999,999,999,981,504,000, or 1,000 veYB after rounding. - With 365 days (31,536,000 s) to the unlock time,
bias = 249,999,999,999,995,376,000, or 250 veYB.
The contract rounds the unlock time down to a whole week. The time to unlock is thus at most these durations, and usually up to one week shorter. Most locks therefore hold a little less voting power than these values.
The function has no revert strings.
Contract source: getVotes()
The contract source gives this function body:
@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()
Returns the voting power that an address had at a past timestamp. It uses binary search over the checkpoints of the address.
The function returns the voting power at timepoint, a uint256.
The function takes these inputs:
| Input | Type | Description |
|---|---|---|
account | address | Address to read |
timepoint | uint256 | Timestamp to check voting power at |
Contract source: getPastVotes()
The contract source gives this function body:
@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()
Returns the current total voting power of all addresses, a uint256.
Contract source: totalVotes()
The contract source gives this function body:
@external
@view
def totalVotes() -> uint256:
return self.total_supply_at(block.timestamp)
getPastTotalSupply()
Returns the total voting power at a timestamp, a uint256. It works with both past and future timestamps.
The function takes this input:
| Input | Type | Description |
|---|---|---|
timepoint | uint256 | Timestamp |
Contract source: getPastTotalSupply()
The contract source gives this function body:
@external
@view
def getPastTotalSupply(timepoint: uint256) -> uint256:
return self.total_supply_at(timepoint)
transferFrom()
Transfers a locked position from owner to to. The transfer merges the position of owner into the position of to. safeTransferFrom(owner, to, token_id, data) runs the same checks and merge. The transfer rule uses the 4-year unlock week. This is the latest unlock week that a lock can have at the block time: block time + 4 years, rounded down to a week.
The receiver must already hold a position of the same kind as the sender. A permalock goes to a permalock. A term lock that ends in the 4-year unlock week goes to a term lock with the same unlock week. A transfer to a wallet with no position fails.
The function runs these steps in order:
- Checks that the caller is the owner of
token_idor has an approval for it. - Checks that
token_idis theowneraddress. The NFT ID of a position is the address of its owner. - Checks that
ownerhas zero allocated gauge vote power. The clearance checker runs this check. - Checks that
owneris notto. - Checks that the two positions are of the same kind, as the warning describes.
- Merges the two positions with
_merge_positions(). The total locked supply does not change. - Burns the source NFT.
safeTransferFrom then calls onERC721Received on to. The contract burns the source NFT before the callback runs.
The function emits the ERC-721 Transfer event.
The function takes these inputs:
| 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) |
transferFrom() reverts in these cases:
erc721: invalid token ID- No veYB NFT has the IDtoken_id.erc721: caller is not token owner or approved- The caller is not the owner oftoken_idand has no approval.Wrong token ID-token_idis not theowneraddress.Not allowed- The clearance checker is set andownerhas active gauge votes.- Without a message -
ownerequalsto. Need max veLock- The two positions are neither both permalocks nor both term locks that end in the same 4-year unlock week.erc721: transfer to non-IERC721Receiver implementer-safeTransferFromonly:tois a contract that does not accept the NFT.
Contract source: transferFrom()
The contract source gives this function body:
@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()
These functions return the latest slope, or the latest full point, of an address from its checkpoint history. The GaugeController uses get_last_user_point to weight votes.
Contract source: get_last_user_slope() / get_last_user_point()
The contract source gives these function bodies:
@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()
Returns the timestamp when the lock of an address ends, a uint256.
The function takes this input:
| Input | Type | Description |
|---|---|---|
_addr | address | Address of the lock holder |
Contract source: locked__end()
The contract source gives this function body:
@external
@view
def locked__end(_addr: address) -> uint256:
return self.locked[_addr].end
checkpoint()
Records a global checkpoint. It does not change the data of any address. Anyone can call it.
Contract source: checkpoint()
The contract source gives this function body:
@external
def checkpoint():
self._checkpoint(empty(address), empty(LockedBalance), empty(LockedBalance))
set_transfer_clearance_checker()
Sets the clearance checker, the external contract that checks if a transfer can go ahead. Only the VotingEscrow owner can call it. The VotingEscrow owner can be different from the YB DAO. To check the owner, call owner(). To check the clearance checker, call transfer_clearance_checker().
The function emits SetTransferClearanceChecker.
The function takes this input:
| Input | Type | Description |
|---|---|---|
transfer_clearance_checker | TransferClearanceChecker | New clearance checker contract |
set_transfer_clearance_checker() reverts with ownable: caller is not the owner when the caller is not the owner.
Contract source: set_transfer_clearance_checker()
The contract source gives this function body:
@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
Voting power decays linearly, as the getVotes() example shows: voting_power = bias - slope * (current_time - point_time). A longer lock gives a higher initial voting power. The maximum lock is 4 years (MAXTIME). The contract rounds all lock times down to whole weeks: unlock_time = (requested_time // WEEK) * WEEK.
The minimum lock amount is UMAXTIME = 126,144,000 wei of YB. The contract rounds each deposit down so that total supply stays a multiple of UMAXTIME. It pulls only the rounded amount.
Transfer restrictions
transferFrom() gives the transfer checks and the receiver requirement. In the rule that follows, the owner of a lock is the address that holds its veYB NFT.
You cannot transfer a term lock that does not end in the 4-year unlock week. The owner must withdraw it, and the new owner must lock it again. You can convert a term lock to a permalock with infinite_lock_toggle(), which sets end to max_value(uint256).
For the transfer rules in user terms, read Transferring a position in veYB.
Checkpoints
The checkpoint system keeps historical voting power data through _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[time]schedules slope changes at lock ends.
getPastVotes() does a binary search over user_point_history. getPastTotalSupply() does a binary search over point_history and applies slope_changes for times after the last checkpoint.
The system updates during deposits (_deposit_for()), withdrawals (withdraw()), and transfers (_merge_positions()). The epoch increments with each checkpoint.
GaugeController as the clearance checker
The VotingEscrow integrates with the GaugeController through the transfer_clearance_checker interface. The GaugeController implements it with ve_transfer_allowed(user).
During vote_for_gauge_weights(), the GaugeController calls get_last_user_point(addr) and locked__end(addr). It uses them to calculate voting power from the locked YB tokens and the lock duration. For voting eligibility, it checks lock expiry with locked__end(addr).
The VotingEscrow owner sets the GaugeController as the clearance checker with set_transfer_clearance_checker(). The GaugeController cannot set itself. Until the owner does this, vote_for_gauge_weights reverts with Vote checker not set. With the checker set, you cannot transfer a position or turn off a permalock while you have active gauge votes.
Delegation
The VotingEscrow contract does not support delegation. The delegates(account) function always returns the account itself. The delegate(delegatee) and delegateBySig() functions revert with "Not supported". You cannot delegate voting power to other addresses. You must vote directly with your own locked tokens.
EIP-6372 compliance
The contract implements EIP-6372 for timestamp-based voting:
clock()returns the current timestamp.CLOCK_MODEreturns"mode=timestamp".getVotes,getPastVotes,totalVotes, andgetPastTotalSupplyare all available.
Related
- DAO: GaugeController: consumes veYB voting power.
- DAO: YB token: the underlying token that gets locked.
- DAO: FeeDistributor: pays admin fees by
getPastVotesshare. - veYB: the user-level explanation of locks, voting power, and transfers.
- Lock YB for veYB: the app steps to create a lock.