LiquidityGauge
Source: LiquidityGauge.vy on GitHub
Overview
The LiquidityGauge is the staking contract of a market. This page calls it the gauge. It is an ERC-4626-style vault: you deposit yb-LP shares into it, receive gauge shares, and earn rewards. yb-LP shares are the share tokens that LT, the market's vault contract, mints. This page calls them LT shares. The gauge's integral-based reward system tracks deposits over time. It distributes multiple reward tokens in proportion to deposit duration and amount.
The contract has these features:
- ERC-4626 vault: standard vault functions for deposits of LT shares.
- Multi-reward system: 8 reward slots, with YB in slot 0 and up to 7 extra reward tokens.
- Time-weighted rewards: the gauge distributes rewards by deposit duration.
- YB integration: the primary YB rewards come from the GaugeController, the contract that distributes YB emissions to gauges by veYB vote.
- Emergency controls: when the LT is killed, the emergency admin can redeem the shares of any holder. The LT shares go to that holder.
- Checkpoint system: the gauge keeps historical reward data for reward distribution.
Approvals
Before you call deposit() or mint(), approve the gauge to spend your LT shares. Before you call deposit_reward(), approve the gauge to spend the reward token.
Constants
The contract has these constants:
MAX_REWARDS(uint256) - Number of reward slots (8, including YB in slot 0)MIN_SHARES_DECIMALS(uint8) - Minimum shares decimals (12).MIN_SHARES()returns10**12: a gauge supply above 0 and below this value reverts witherc4626: leave MIN_SHARES.VERSION(String[8]) - Contract version ("v1.0.0")
Gauge shares use the decimals of the LT, which are 18.
Public variables
The contract has these public variables:
Core contracts
GC()(GaugeController) - GaugeController contract (immutable)YB()(IERC20) - YB token contract (immutable)LP_TOKEN()(IERC20) - The staked token: the LT of the market (immutable)FACTORY()(Factory) - Factory contract (immutable)
Reward management
reward_count()(uint256) - Number of active reward tokensreward_tokens(uint256)(IERC20) - Reward token by indexrewards(IERC20)(Reward) - Reward info of a tokenprocessed_rewards(IERC20)(uint256) - Amount of rewards processed for token
Checkpoint data
integral_inv_supply()(Integral) - Global inverse supply integralintegral_inv_supply_4_token(IERC20)(uint256) - Token-specific inverse supply integralreward_rate_integral(IERC20)(Integral) - Reward rate integral per tokenreward_rate_integral_4_user(address, IERC20)(uint256) - Reward rate integral of an address per tokenuser_rewards_integral(address, IERC20)(Integral) - Reward integral of an address per tokenclaimed_rewards(address, IERC20)(uint256) - Claimed rewards of an address per token
Data structs
The contract uses these structs:
Reward
distributor(address) - Address that can distribute rewardsfinish_time(uint256) - When reward distribution endstotal(uint256) - Total reward amount
Integral
v(uint256) - Valuet(uint256) - Timestamp
RewardIntegrals
integral_inv_supply(Integral) - Global inverse supply integralreward_rate_integral(Integral) - Reward rate integraluser_rewards_integral(Integral) - User reward integral
Events
The contract emits these reward events:
AddReward (the owner adds a new reward token)
token(address, indexed) - Address of the reward tokendistributor(address) - Address of the reward distributorid(uint256) - Reward token ID
ChangeRewardDistributor (the owner changes a reward distributor)
token(address, indexed) - Address of the reward tokendistributor(address) - New distributor address
DepositRewards (a distributor or the owner deposits rewards)
token(address, indexed) - Address of the reward tokendistributor(address) - Address of the distributoramount(uint256) - Amount of rewards depositedfinish_time(uint256) - Time when the distribution of this reward ends
Owner
The owner adds extra reward tokens and changes their distributors. At deployment, the gauge stores Factory.admin() as its owner(). The Factory admin changed over time, so the owner differs between gauges. To check the owner of a gauge, call owner() on it.
The owner of a gauge is one of these contracts:
- The YB DAO (decentralized autonomous organization).
- A
HybridFactoryOwnercontract. - A
MigrationFactoryOwnercontract.
The deployed HybridFactoryOwner and MigrationFactoryOwner contracts that own gauges have no function that calls add_reward or change_reward_distributor on a gauge. So a gauge that one of these contracts owns cannot get a new extra reward token or a new reward distributor through its owner.
Functions
Each function lists its own revert strings. Error codes holds the shared table.
deposit()
Deposits LT shares and mints gauge shares. The function updates the checkpoint of receiver and triggers YB emissions.
The function returns the number of gauge shares minted, a uint256.
The function emits Deposit for the deposit.
The function takes these inputs:
| Input | Type | Description |
|---|---|---|
assets | uint256 | Amount of LT shares to deposit |
receiver | address | Address to receive the gauge shares |
deposit() reverts in these cases:
erc4626: deposit more than maximum-assetsis abovemaxDeposit(receiver), which returns theuint256maximum.- Without a message - The LT
transferFromreverts: the caller's LT allowance for the gauge, or its LT balance, is belowassets. erc4626: transferFrom operation did not succeed- The LTtransferFromreturnsFalse.erc20: mint to the zero address-receiveris the zero address.erc4626: leave MIN_SHARES- The gauge supply after the deposit is above 0 and belowMIN_SHARES.Gauge not alive- FromGaugeController.emit(): the GaugeController has not added this gauge.
Contract source: deposit()
The contract source gives this function body:
@external
@nonreentrant
def deposit(assets: uint256, receiver: address) -> uint256:
extcall LT(LP_TOKEN.address).checkpoint_staker_rebase()
assert assets <= erc4626._max_deposit(receiver), "erc4626: deposit more than maximum"
shares: uint256 = erc4626._preview_deposit(assets)
self._checkpoint_user(receiver)
erc4626._deposit(msg.sender, receiver, assets, shares)
erc4626._check_min_shares()
extcall GC.emit()
return shares
mint()
Deposits LT shares for an exact number of gauge shares: mint(shares, receiver). The function returns the LT amount taken. It runs the same checkpoints as deposit().
mint() can revert with these strings:
erc4626: mint more than maximumerc4626: leave MIN_SHARESGauge not alive- The token strings of
deposit()
withdraw()
Withdraws LT shares by burning gauge shares. The function updates the checkpoint of owner and triggers YB emissions.
The function returns the number of gauge shares burned, a uint256.
The function emits Withdraw for the withdrawal.
The function takes these inputs:
| Input | Type | Description |
|---|---|---|
assets | uint256 | Amount of LT shares to withdraw |
receiver | address | Address to receive the LT shares |
owner | address | Address that owns the gauge shares |
withdraw() reverts in these cases:
erc4626: withdraw more than maximum-assetsis abovemaxWithdraw(owner).erc20: insufficient allowance- The caller is notownerand its allowance is below the shares to burn.erc20: burn amount exceeds balance- The shares to burn, rounded up, exceed the balance ofowner.erc4626: transfer operation did not succeed- The LTtransferreturnsFalse.- Without a message - The LT
transferreverts:receiveris the zero address or the LT. erc4626: leave MIN_SHARES- The gauge supply after the withdrawal is above 0 and belowMIN_SHARES.Gauge not alive- FromGaugeController.emit(): the GaugeController has not added this gauge.
Contract source: withdraw()
The contract source gives this function body:
@external
@nonreentrant
def withdraw(assets: uint256, receiver: address, owner: address) -> uint256:
extcall LT(LP_TOKEN.address).checkpoint_staker_rebase()
assert assets <= erc4626._max_withdraw(owner), "erc4626: withdraw more than maximum"
shares: uint256 = erc4626._preview_withdraw(assets)
self._checkpoint_user(owner)
erc4626._withdraw(msg.sender, receiver, owner, assets, shares)
erc4626._check_min_shares()
extcall GC.emit()
return shares
claim()
Claims the accumulated rewards of user. The function updates checkpoints and transfers the reward tokens.
The function returns the amount of rewards claimed, a uint256.
The reward token emits Transfer for the claimed amount.
The function takes these inputs:
| Input | Type | Description |
|---|---|---|
reward | IERC20 | Reward token to claim (defaults to YB) |
user | address | Address to claim for (defaults to the caller) |
claim() reverts in these cases:
No reward-rewardis not YB and the owner has not added it. YB is always registered.Gauge not alive- FromGaugeController.emit(): the GaugeController has not added this gauge. Raised only whenrewardis YB.- Without a message - The reward token
transferreturnsFalse.
If user has nothing to claim, the call transfers 0 and does not revert.
Contract source: claim()
The contract source gives this function body:
@external
@nonreentrant
def claim(reward: IERC20 = YB, user: address = msg.sender) -> uint256:
d_reward: uint256 = self._vest_rewards(reward, False)
r: RewardIntegrals = self._checkpoint(reward, d_reward, user)
self.integral_inv_supply = r.integral_inv_supply
self.integral_inv_supply_4_token[reward] = r.integral_inv_supply.v
self.reward_rate_integral[reward] = r.reward_rate_integral
self.reward_rate_integral_4_user[user][reward] = r.reward_rate_integral.v
self.user_rewards_integral[user][reward] = r.user_rewards_integral
d_reward = r.user_rewards_integral.v - self.claimed_rewards[user][reward]
self.claimed_rewards[user][reward] = r.user_rewards_integral.v
assert extcall reward.transfer(user, d_reward, default_return_value=True)
return d_reward
preview_claim()
Previews the amount of rewards that user can claim, without running the claim.
The function returns the preview of claimable rewards, a uint256 in raw units of the reward token (YB uses 18 decimals).
The function takes these inputs:
| Input | Type | Description |
|---|---|---|
reward | IERC20 | Reward token to preview |
user | address | Address to preview for |
preview_claim() reverts with No reward when reward is not YB and the owner has not added it.
Contract source: preview_claim()
The contract source gives this function body:
@external
@view
def preview_claim(reward: IERC20, user: address) -> uint256:
d_reward: uint256 = 0
if reward == YB:
d_reward = staticcall GC.preview_emissions(self, block.timestamp)
else:
d_reward = self._get_vested_rewards(reward)
r: RewardIntegrals = self._checkpoint(reward, d_reward, user)
return r.user_rewards_integral.v - self.claimed_rewards[user][reward]
add_reward()
Adds a new reward token to the gauge. Only the owner can call it; Owner lists the possible owners. The gauge holds at most 7 extra reward tokens.
The function emits AddReward.
The function takes these inputs:
| Input | Type | Description |
|---|---|---|
token | IERC20 | Reward token to add |
distributor | address | Address that can distribute rewards |
add_reward() reverts in these cases:
YB-tokenis YB.LP_TOKEN-tokenis the LT.- Without a message -
distributoris the zero address, or all 8 reward slots are in use. Already added- The token is in the reward list already.ownable: caller is not the owner- The caller is not the owner.
Contract source: add_reward()
The contract source gives this function body:
@external
@nonreentrant
def add_reward(token: IERC20, distributor: address):
assert token != YB, "YB"
assert token != LP_TOKEN, "LP_TOKEN"
assert distributor != empty(address)
assert self.rewards[token].distributor == empty(address), "Already added"
erc4626.ownable._check_owner()
self.rewards[token].distributor = distributor
reward_id: uint256 = self.reward_count
self.reward_tokens[reward_id] = token
self.reward_count = reward_id + 1
log AddReward(token=token.address, distributor=distributor, id=reward_id)
change_reward_distributor()
Changes the distributor of an extra (non-YB) reward token: change_reward_distributor(token, distributor). Only the owner can call it. The token must already be added.
The function emits ChangeRewardDistributor.
change_reward_distributor() reverts in these cases:
YB-tokenis YB.Not added- The owner has not addedtoken.ownable: caller is not the owner- The caller is not the owner.- Without a message -
distributoris the zero address.
deposit_reward()
Deposits reward tokens into the gauge for distribution. The distributor of token or the owner can call it.
The function emits DepositRewards.
The function takes these inputs:
| Input | Type | Description |
|---|---|---|
token | IERC20 | Reward token to deposit |
amount | uint256 | Amount of tokens to deposit |
finish_time | uint256 | When reward distribution ends. If rewards are left, a value of 0 keeps the current rate and extends the end time. |
deposit_reward() reverts in these cases:
YB-tokenis YB.No rewards-amountis 0.ownable: caller is not the owner- The caller is neither the distributor oftokennor the owner.No reward- The owner calls it for a token that the owner has not added.Finishes in the past-finish_timeis not in the future, and eitherfinish_timeis not 0 or no rewards are left.Rate unknown-finish_timeis 0 and rewards are left, but the current distribution has ended.- Without a message - The token
transferFromreturnsFalse.
Contract source: deposit_reward()
The contract source gives this function body:
@external
@nonreentrant
def deposit_reward(token: IERC20, amount: uint256, finish_time: uint256):
assert token != YB, "YB"
assert amount > 0, "No rewards"
r: Reward = self.rewards[token]
if msg.sender != r.distributor:
erc4626.ownable._check_owner()
d_reward: uint256 = self._vest_rewards(token, False)
ri: RewardIntegrals = self._checkpoint(token, d_reward, empty(address))
self.integral_inv_supply = ri.integral_inv_supply
self.integral_inv_supply_4_token[token] = ri.integral_inv_supply.v
self.reward_rate_integral[token] = ri.reward_rate_integral
unused_rewards: uint256 = r.total - self.processed_rewards[token]
if finish_time > 0 or unused_rewards == 0:
assert finish_time > block.timestamp, "Finishes in the past"
r.finish_time = finish_time
else:
assert r.finish_time > block.timestamp, "Rate unknown"
r.finish_time = block.timestamp + (r.finish_time - block.timestamp) * (unused_rewards + amount) // unused_rewards
r.total += amount
self.rewards[token] = r
assert extcall token.transferFrom(msg.sender, self, amount, default_return_value=True)
log DepositRewards(token=token.address, distributor=msg.sender, amount=amount, finish_time=r.finish_time)
get_adjustment()
Returns the adjustment factor of the gauge, based on the gauge's share of the total LT supply. The GaugeController uses it for weight calculations.
The function returns the adjustment factor, a uint256 where 1e18 = 1.0: the square root of the gauge's share of the LT supply. For example, when the gauge holds 25% of the LT supply, the call returns isqrt(25 * 10**34) = 5 × 10^17, or 0.5. It returns 0 when the LT supply is 0.
The function has no revert strings.
Contract source: get_adjustment()
The contract source gives this function body:
@external
@view
def get_adjustment() -> uint256:
staked: uint256 = staticcall LP_TOKEN.balanceOf(self)
supply: uint256 = staticcall LP_TOKEN.totalSupply()
return isqrt(unsafe_div(staked * 10**36, supply))
redeem()
Redeems gauge shares for LT shares. The function includes an emergency-admin path for a killed LT. The emergency admin can redeem the shares of any holder. The LT shares go to that holder, because receiver must equal owner.
The function returns the amount of LT shares received, a uint256.
The function emits Withdraw for the redemption.
The function takes these inputs:
| Input | Type | Description |
|---|---|---|
shares | uint256 | Number of gauge shares to redeem |
receiver | address | Address to receive the LT shares |
owner | address | Address that owns the gauge shares |
redeem() reverts in these cases:
erc4626: redeem more than maximum-sharesis above the share balance ofowner.receiver- The LT is killed, the caller is the emergency admin, andreceiveris notowner.erc20: insufficient allowance- The caller is notowner, the emergency-admin path does not apply, and the allowance is belowshares.erc4626: transfer operation did not succeed- The LTtransferreturnsFalse.- Without a message - The LT
transferreverts:receiveris the zero address or the LT. erc4626: leave MIN_SHARES- The gauge supply after the redeem is above 0 and belowMIN_SHARES.Gauge not alive- FromGaugeController.emit(): the GaugeController has not added this gauge.
Contract source: redeem()
The contract source gives this function body:
@external
@nonreentrant
def redeem(shares: uint256, receiver: address, owner: address) -> uint256:
extcall LT(LP_TOKEN.address).checkpoint_staker_rebase()
assert shares <= erc4626._max_redeem(owner), "erc4626: redeem more than maximum"
sender: address = msg.sender
if staticcall LT(LP_TOKEN.address).is_killed():
if msg.sender == staticcall FACTORY.emergency_admin():
assert receiver == owner, "receiver"
sender = owner
assets: uint256 = erc4626._preview_redeem(shares)
self._checkpoint_user(owner)
erc4626._withdraw(sender, receiver, owner, assets, shares)
erc4626._check_min_shares()
extcall GC.emit()
return assets
transfer() and transferFrom()
The functions transfer gauge shares between addresses. Both functions update the checkpoints of the sender and the receiver before the ERC-20 transfer, then trigger a GaugeController emit().
The functions return True on success, a bool.
The functions emit Transfer for the shares.
The functions take these inputs:
| Input | Type | Description |
|---|---|---|
to | address | Recipient address |
amount | uint256 | Amount of shares to transfer |
The functions revert in these cases:
erc20: transfer amount exceeds balance- The sender's share balance is belowamount.erc20: transfer to the zero address-tois the zero address.erc20: insufficient allowance-transferFromonly: the caller's allowance is belowamount.Gauge not alive- FromGaugeController.emit(): the GaugeController has not added this gauge.
Contract source: transfer() and transferFrom()
The contract source gives these function bodies:
@external
@nonreentrant
def transfer(to: address, amount: uint256) -> bool:
self._checkpoint_user(msg.sender)
self._checkpoint_user(to)
erc4626.erc20._transfer(msg.sender, to, amount)
extcall GC.emit()
return True
@external
@nonreentrant
def transferFrom(owner: address, to: address, amount: uint256) -> bool:
self._checkpoint_user(owner)
self._checkpoint_user(to)
erc4626.erc20._spend_allowance(owner, msg.sender, amount)
erc4626.erc20._transfer(owner, to, amount)
extcall GC.emit()
return True
Share conversion
convertToAssets(shares) and previewRedeem(shares) return the amount of LT shares for a number of gauge shares. convertToShares(assets) and previewDeposit(assets) go the other way. All four round down; inputs and outputs use 18 decimals.
The conversion uses these views:
convertToAssets(shares: uint256) -> uint256convertToShares(assets: uint256) -> uint256totalAssets() -> uint256: the staked LT balance after the pending rebase thatLT.checkpoint_staker_rebase()applies,LT.updated_balances()[1].
While totalSupply() is above 0, the conversion is shares × (totalAssets() + 1) // (totalSupply() + 1). On the gauges of markets 0 to 2, ERC-4626 compliance gives the difference.
In this example, totalSupply() is 1,000 × 10^18 gauge shares and totalAssets() is 990 × 10^18 LT shares. convertToAssets(10**18) returns 990,000,000,000,000,000. One gauge share redeems for 0.99 LT shares.
Reward distribution
The LiquidityGauge distributes rewards through integrals that _checkpoint() updates. Three integrals track state:
- The global inverse-supply integral (total deposit changes).
- The reward-rate integral (distribution rate per token).
- The user reward integral (each address's accumulated rewards).
YB is the primary reward. The GaugeController supplies it in three ways:
GC.emit()runs on deposits, withdrawals, and transfers.GC.preview_emissions()previews claims.get_adjustment()supplies the weight adjustment.
Deposits and withdrawals also update the gauge checkpoint in the GaugeController.
The gauge supports up to 7 extra reward tokens beyond YB. The owner adds them with add_reward(). Each has a designated distributor and streams over a set period. The gauge recomputes the rate from the remaining rewards and time.
Emergency controls
When the underlying LT is killed (LT.is_killed()), the emergency admin (Factory.emergency_admin()) can call redeem for any holder. The receiver must equal the share owner, so the LT shares go to the holder, not to the emergency admin.
While the LT is not killed, deposit, mint, withdraw, and redeem first call LT.checkpoint_staker_rebase(), which reads AMM.value_oracle(). If value_oracle() reverts, these calls revert, so holders cannot unstake until the oracle works again or the LT is killed. After a kill, checkpoint_staker_rebase() does nothing, and withdraw and redeem work without the oracle. For the calls that work in each market state, read Market states and allowed calls in LT.
ERC-4626 compliance
The contract implements these parts of the ERC-4626 vault standard:
- Standard functions:
deposit(),withdraw(),mint(),redeem() - Preview functions:
previewDeposit(),previewWithdraw(),previewRedeem(). On the gauges of markets 3 and later,previewMint()is not callable, because the source has no@externaldecorator on it. UsepreviewDeposit()orpreviewRedeem(). - Transfer functions:
transfer(),transferFrom()with checkpoint updates - Share calculations: asset/share conversion with minimum shares, as in Share conversion
The gauges of markets 0 to 2 run an older version. On them, previewMint() is callable. Their ERC-4626 views use the stored LT balance of the gauge and do not apply the pending rebase.
Related
- DAO: GaugeController: emits YB to this gauge through
GC.emit(). - LT: the market's vault contract, whose shares the gauge stakes. Its kill switch enables the emergency-admin path.
- StakeZap: deposit and stake, or unstake and withdraw, in one transaction.
- Action flow: stake and unstake: the gauge calls in order.
- Gauges and emissions: how votes route YB emissions to gauges.