Skip to main content

GaugeController

Overview​

The GaugeController sets the weight of each gauge from veYB votes and distributes YB token emissions to the gauges by weight. A gauge (LiquidityGauge) is the staking contract of a market. veYB holders, who lock YB in the VotingEscrow, vote on gauge weights. The weights set the share of emissions that each gauge receives. The GaugeController handles gauge weights only. Proposals run on the YB Aragon DAO (decentralized autonomous organization), not on the GaugeController.

The contract has these features:

  • Time-decay voting: voting power decreases over time, based on the lock end. A permalock, a veYB lock with no end date, does not decay.
  • Gauge management: the owner adds gauges (add_gauge) and kills or revives them (set_killed).
  • Checkpoint system: the contract keeps historical weight data for the weight calculations.
  • Instant vote effect: vote_for_gauge_weights updates point_weight at once, so gauge weights change in the same transaction. Scheduled slope changes (from lock expirations) and historical checkpoints align to weekly boundaries. The contract does not delay the vote itself.

The owner of the GaugeController is the YB DAO (an Aragon DAO), listed in Contract addresses. The deployment script transfers GaugeController ownership to the DAO. Owner-only functions (add_gauge, set_killed) need a YB DAO vote. To check the owner, call owner().

Constants​

The contract has these constants:

  • WEEK (uint256) - 7 days in seconds (604,800)
  • WEIGHT_VOTE_DELAY (uint256) - Minimum time between votes on the same gauge (10 days = 864,000 seconds)

Public variables​

The contract has these public variables:

Core contracts

  • TOKEN (GovernanceToken) - YB token contract (immutable)
  • VOTING_ESCROW (VotingEscrow) - veYB contract (immutable)

Gauge management

  • n_gauges() (uint256) - Total number of gauges
  • gauges(uint256) (address) - Gauge address by index
  • is_killed(address) (bool) - True if the gauge is killed

Voting state

  • vote_user_slopes(address, address) (VotedSlope) - Vote of an address for a gauge
  • vote_user_power(address) (uint256) - Share of its voting power that an address has allocated to gauges, in basis points (10,000 = 100%)
  • last_user_vote(address, address) (uint256) - Last vote timestamp for an address and a gauge

Weight tracking

  • point_weight(address) (Point) - Current gauge weight point (bias + slope)
  • time_weight(address) (uint256) - Last weight update timestamp for gauge
  • gauge_weight(address) (uint256) - Current gauge weight
  • gauge_weight_sum() (uint256) - Sum of all gauge weights
  • adjusted_gauge_weight(address) (uint256) - Adjusted weight of the gauge: its weight times its adjustment factor. The adjustment factor is get_adjustment() of the gauge, capped at 10^18 (100%).
  • adjusted_gauge_weight_sum() (uint256) - Sum of adjusted weights

Emission tracking

  • specific_emissions() (uint256) - Cumulative emissions per unit of adjusted weight (1e18 scale)
  • specific_emissions_per_gauge(address) (uint256) - Value of specific_emissions at the last checkpoint of the gauge
  • weighted_emissions_per_gauge(address) (uint256) - Cumulative YB that the gauge earned
  • sent_emissions_per_gauge(address) (uint256) - Emissions already sent to gauge

Data structs​

The contract uses these structs:

Point: bias: uint256, slope: uint256.

VotedSlope: slope, bias, power, end. It holds the vote of one address for one gauge:

  • slope: the slope of the vote, the voter's veYB slope × the vote weight ÷ 10,000.
  • bias: the bias of the vote. The contract reads it only for a vote from a permalock.
  • power: the vote weight, in basis points.
  • end: the lock end of the voter at the time of the vote.

Events​

The contract emits these events. No field of these events is indexed.

VoteForGauge: time: uint256, user: address, gauge_addr: address, weight: uint256

NewGauge: addr: address

SetKilled: gauge: address, is_killed: bool


Functions​

Each function lists its own revert strings. Error codes holds the shared table.

add_gauge()​

Adds a new gauge to the controller. Only the owner (YB DAO) can call it. A gauge receives votes and emissions only after this call.

The function emits NewGauge.

The function takes this input:

InputTypeDescription
gaugeaddressAddress of the gauge to add

add_gauge() reverts in these cases:

  • ownable: caller is not the owner - The caller is not the owner.
  • Gauge already added - The gauge is in the controller already.
Contract source: add_gauge()

The contract source gives this function body:

@external
def add_gauge(gauge: address):
ownable._check_owner()
assert self.time_weight[gauge] == 0, "Gauge already added"

n: uint256 = self.n_gauges
self.n_gauges = n + 1
self.gauges[n] = gauge
self.time_weight[gauge] = block.timestamp
self.specific_emissions_per_gauge[gauge] = self.specific_emissions

log NewGauge(addr=gauge)

vote_for_gauge_weights()​

You call it from an address with a veYB lock to vote on gauge weights. Your voting power depends on the locked token amount and the lock duration. Votes apply instantly.

The function emits VoteForGauge for each gauge voted on.

The function takes these inputs:

InputTypeDescription
_gauge_addrsDynArray[address, 50]Array of gauge addresses to vote for
_user_weightsDynArray[uint256, 50]Array of weights in basis points (0.01% units, max 10000 total)

vote_for_gauge_weights() reverts in these cases:

  • Vote checker not set - The VotingEscrow transfer_clearance_checker is not this GaugeController.
  • Mismatch in lengths - _gauge_addrs and _user_weights have different lengths.
  • Expired - The lock end is not in the future. A wallet without a lock fails this check.
  • Weight too large - A weight is above 10,000.
  • Killed - A non-zero weight goes to a killed gauge.
  • Gauge not added - The owner has not added a gauge.
  • Cannot vote so often - The caller voted for the same gauge less than 10 days ago.
  • Used too much power - The caller's total vote power after the call is above 10,000 basis points (100%).
Contract source: vote_for_gauge_weights()

The contract source gives this function body:

@external
def vote_for_gauge_weights(_gauge_addrs: DynArray[address, 50], _user_weights: DynArray[uint256, 50]):
# Check if transfer_clearance_checker is set to GC
assert staticcall VOTING_ESCROW.transfer_clearance_checker() == self, "Vote checker not set"

n: uint256 = len(_gauge_addrs)
assert len(_user_weights) == n, "Mismatch in lengths"
pt: Point = staticcall VOTING_ESCROW.get_last_user_point(msg.sender)
slope: uint256 = pt.slope
bias: uint256 = pt.bias
lock_end: uint256 = staticcall VOTING_ESCROW.locked__end(msg.sender)
assert lock_end > block.timestamp, "Expired"

power_used: uint256 = self.vote_user_power[msg.sender]

for i: uint256 in range(50):
if i >= n:
break
_user_weight: uint256 = _user_weights[i]
_gauge_addr: address = _gauge_addrs[i]
assert _user_weight <= 10000, "Weight too large"
if _user_weight != 0:
assert not self.is_killed[_gauge_addr], "Killed"
assert self.time_weight[_gauge_addr] > 0, "Gauge not added"
assert block.timestamp >= self.last_user_vote[msg.sender][_gauge_addr] + WEIGHT_VOTE_DELAY, "Cannot vote so often"

# Prepare slopes and biases in memory
old_slope: VotedSlope = self.vote_user_slopes[msg.sender][_gauge_addr]
old_bias: uint256 = 0
if old_slope.end == max_value(uint256):
old_bias = old_slope.bias
else:
old_dt: uint256 = max(old_slope.end, block.timestamp) - block.timestamp
old_bias = old_slope.slope * old_dt
new_slope: VotedSlope = VotedSlope(
slope = slope * _user_weight // 10000,
bias = 0,
power = _user_weight,
end = lock_end
)
new_bias: uint256 = 0
if lock_end == max_value(uint256):
new_bias = bias * _user_weight // 10000
else:
new_bias = new_slope.slope * (lock_end - block.timestamp) # dev: raises when expired
new_slope.bias = new_bias

power_used = power_used + new_slope.power - old_slope.power

pt = self._checkpoint_gauge(_gauge_addr) # Contains old_weight_bias and old_weight_slope

## Remove old and schedule new slope changes
# Remove slope changes for old slopes
# Schedule recording of initial slope for next_time
self.point_weight[_gauge_addr].bias = max(pt.bias + new_bias, old_bias) - old_bias
if old_slope.end > block.timestamp:
self.point_weight[_gauge_addr].slope = max(pt.slope + new_slope.slope, old_slope.slope) - old_slope.slope
else:
self.point_weight[_gauge_addr].slope += new_slope.slope
if old_slope.end > block.timestamp:
# Cancel old slope changes if they still didn't happen
self.changes_weight[_gauge_addr][old_slope.end] -= old_slope.slope
# Add slope changes for new slopes
self.changes_weight[_gauge_addr][new_slope.end] += new_slope.slope

self.vote_user_slopes[msg.sender][_gauge_addr] = new_slope

# point_weight has changed, so we are doing another checkpoint to enact the vote
self._checkpoint_gauge(_gauge_addr)

# Record last action time
self.last_user_vote[msg.sender][_gauge_addr] = block.timestamp

log VoteForGauge(time=block.timestamp, user=msg.sender, gauge_addr=_gauge_addr, weight=_user_weight)

# Check and update powers (weights) used
assert power_used <= 10000, 'Used too much power'
self.vote_user_power[msg.sender] = power_used

_checkpoint_gauge() (internal)​

Updates gauge weights and distributes emissions. Voting and gauge operations call it.

The function returns the updated gauge weight point, a Point.

The function takes this input:

InputTypeDescription
gaugeaddressAddress of the gauge to checkpoint

_checkpoint_gauge() reverts with Gauge not alive when the owner has not added the gauge.

Contract source: _checkpoint_gauge()

The contract source gives this function body:

@internal
def _checkpoint_gauge(gauge: address) -> Point:
assert self.time_weight[gauge] > 0, "Gauge not alive"

adjustment: uint256 = min(staticcall Gauge(gauge).get_adjustment(), 10**18)
t: uint256 = self.time_weight[gauge]

w: uint256 = self.gauge_weight[gauge]
aw: uint256 = self.adjusted_gauge_weight[gauge]
w_sum: uint256 = self.gauge_weight_sum
aw_sum: uint256 = self.adjusted_gauge_weight_sum

pt: Point = self._get_weight(gauge)
self.point_weight[gauge] = pt
w_new: uint256 = pt.bias
aw_new: uint256 = w_new * adjustment // 10**18

self.gauge_weight[gauge] = w_new
self.gauge_weight_sum = w_sum + w_new - w
self.adjusted_gauge_weight[gauge] = aw_new
self.adjusted_gauge_weight_sum = aw_sum + aw_new - aw

d_emissions: uint256 = extcall TOKEN.emit(self, unsafe_div(aw_sum * 10**18, w_sum))
self.time_weight[gauge] = block.timestamp

specific_emissions: uint256 = self.specific_emissions + unsafe_div(d_emissions * 10**18, aw_sum)
if d_emissions > 0:
self.specific_emissions = specific_emissions

if block.timestamp > t:
self.weighted_emissions_per_gauge[gauge] += (specific_emissions - self.specific_emissions_per_gauge[gauge]) * aw // 10**18
self.specific_emissions_per_gauge[gauge] = specific_emissions

return pt

get_gauge_weight()​

Returns the current weight of a gauge (raw, unadjusted), a uint256.

The function takes this input:

InputTypeDescription
addraddressGauge address
Contract source: get_gauge_weight()

The contract source gives this function body:

@external
@view
def get_gauge_weight(addr: address) -> uint256:
return self._get_weight(addr).bias

gauge_relative_weight()​

Returns the relative weight of a gauge, normalized to 1e18 (1.0 = 1e18): the proportion of total emissions that the gauge receives. The value is adjusted_gauge_weight(gauge) × 10^18 / adjusted_gauge_weight_sum(), and 0 when the sum is 0.

The function returns a uint256. For example, a return value of 250,000,000,000,000,000 (2.5 × 10^17) gives the gauge 25% of the YB that the controller emits.

The function takes this input:

InputTypeDescription
gaugeaddressGauge address

The function has no revert strings.

Contract source: gauge_relative_weight()

The contract source gives this function body:

@external
@view
def gauge_relative_weight(gauge: address) -> uint256:
return unsafe_div(self.adjusted_gauge_weight[gauge] * 10**18, self.adjusted_gauge_weight_sum)

ve_transfer_allowed()​

Checks if an address can transfer its veYB. The function returns a bool: True only if vote_user_power[user] == 0 (no active gauge votes).

The function takes this input:

InputTypeDescription
useraddressAddress to check
Contract source: ve_transfer_allowed()

The contract source gives this function body:

@external
@view
def ve_transfer_allowed(user: address) -> bool:
return self.vote_user_power[user] == 0

checkpoint()​

Public wrapper over _checkpoint_gauge(). Updates gauge weights and distributes emissions. Anyone can call it.

The function takes this input:

InputTypeDescription
gaugeaddressAddress of the gauge to checkpoint

checkpoint() reverts with Gauge not alive when the owner has not added the gauge.

Contract source: checkpoint()

The contract source gives this function body:

@external
def checkpoint(gauge: address):
self._checkpoint_gauge(gauge)

preview_emissions()​

Previews the emissions that a gauge receives by a specific time, without state changes.

The function returns the preview of emissions for the gauge, a uint256 in raw YB units (18 decimals). It returns 0 for a gauge that the owner has not added.

The function takes these inputs:

InputTypeDescription
gaugeaddressGauge address
at_timeuint256Timestamp to preview emissions at
Contract source: preview_emissions()

The contract source gives this function body:

@external
@view
def preview_emissions(gauge: address, at_time: uint256) -> uint256:
if self.time_weight[gauge] == 0:
return 0

w: uint256 = self.gauge_weight[gauge]
aw: uint256 = self.adjusted_gauge_weight[gauge]
w_sum: uint256 = self.gauge_weight_sum
aw_sum: uint256 = self.adjusted_gauge_weight_sum

d_emissions: uint256 = 0
if at_time > self.time_weight[gauge]:
d_emissions = staticcall TOKEN.preview_emissions(at_time, unsafe_div(aw_sum * 10**18, w_sum))
specific_emissions: uint256 = self.specific_emissions + unsafe_div(d_emissions * 10**18, aw_sum)
weighted_emissions_per_gauge: uint256 = self.weighted_emissions_per_gauge[gauge] + (specific_emissions - self.specific_emissions_per_gauge[gauge]) * aw // 10**18

return weighted_emissions_per_gauge - self.sent_emissions_per_gauge[gauge]

emit()​

A gauge calls it to claim its allocated emissions. The controller identifies the gauge by msg.sender, so only a gauge that the owner has added can call it.

The function returns the amount of tokens transferred to the gauge, a uint256 in raw YB units (18 decimals).

emit() reverts with Gauge not alive when the caller is not a gauge that the owner has added.

Contract source: emit()

The contract source gives this function body:

@external
def emit() -> uint256:
self._checkpoint_gauge(msg.sender)
emissions: uint256 = self.weighted_emissions_per_gauge[msg.sender]
to_send: uint256 = emissions - self.sent_emissions_per_gauge[msg.sender]
self.sent_emissions_per_gauge[msg.sender] = emissions
if to_send > 0:
extcall TOKEN.transfer(msg.sender, to_send)
return to_send

set_killed()​

Sets the kill status of a gauge. While a gauge is killed, these rules apply:

  • The gauge cannot receive new positive vote weight: vote_for_gauge_weights reverts with "Killed" for any weight above zero.
  • A weight of 0 is still permitted, so existing voters can remove weight from a killed gauge.
  • Killing does not by itself zero existing votes.
  • Killing does not remove the gauge's weight from gauge_weight_sum.
  • Killing does not stop emit().

In practice, a killed gauge stops drawing emissions once its stakers leave and its get_adjustment() falls to zero.

The function emits SetKilled.

The function takes these inputs:

InputTypeDescription
gaugeaddressGauge address
is_killedboolWhether to kill the gauge

set_killed() reverts in these cases:

  • ownable: caller is not the owner - The caller is not the owner.
  • Gauge not added - The owner has not added the gauge.
Contract source: set_killed()

The contract source gives this function body:

@external
def set_killed(gauge: address, is_killed: bool):
ownable._check_owner()
assert self.time_weight[gauge] > 0, "Gauge not added"
self.is_killed[gauge] = is_killed
log SetKilled(gauge=gauge, is_killed=is_killed)

_get_weight() (internal)​

Fills historic gauge weights week-over-week for missed checkins and returns the total for the future week. It handles the time decay of gauge weights.

The function returns a Point.

The function takes this input:

InputTypeDescription
gaugeaddressAddress of the gauge
Contract source: _get_weight()

The contract source gives this function body:

@internal
@view
def _get_weight(gauge: address) -> Point:
t: uint256 = self.time_weight[gauge]
current_week: uint256 = block.timestamp // WEEK * WEEK
dt: uint256 = 0
if t > 0:
pt: Point = self.point_weight[gauge]
for i: uint256 in range(500):
if t >= current_week:
dt = block.timestamp - t
if dt == 0:
break
else:
dt = (t + WEEK) // WEEK * WEEK - t
t += dt
pt.bias -= min(pt.slope * dt, pt.bias)
pt.slope -= min(self.changes_weight[gauge][t], pt.slope)
if pt.bias == 0:
pt.slope = 0
return pt
else:
return empty(Point)

Voting rules​

Your voting power depends on these factors:

  • Locked YB amount: more tokens give more voting power
  • Lock duration: longer locks give more voting power
  • Time decay: power decreases over time (except for a permalock)

You split your voting power across gauges by weight, with these limits:

  • Basis points: weights are in basis points (0.01% units)
  • Maximum: 10,000 basis points (100%) of your voting power, in total across all gauges
  • Minimum: 1 basis point (0.01%) for a vote. A weight of 0 removes your vote from the gauge
  • Vote delay: 10 days between votes on the same gauge

The voting system uses a time-decay mechanism with these values:

  • Slope: rate of voting power decrease
  • Bias: current voting power value
  • End time: when voting power expires

Emission distribution​

The contract calculates the rate factor for YB emissions with this formula:

rate_factor = (adjusted_gauge_weight_sum * 10^18) / gauge_weight_sum

For example, with adjusted_gauge_weight_sum at 60% of gauge_weight_sum, rate_factor is 6 × 10^17. YB.emit() then mints at 60% of the rate at rate_factor = 10^18.

The contract distributes emissions in proportion to these factors:

  • Gauge weight: voting power allocated to each gauge
  • Adjustment factor: gauge-specific adjustment (0–100%), get_adjustment() from the gauge, capped at 10^18
  • Time elapsed: since the last checkpoint

On every gauge checkpoint, the GaugeController mints YB from the reserve through TOKEN.emit(). Each gauge gets its share of the minted tokens when it calls emit().

Checkpoints apply pending historical weight changes (vote decay, expired locks) before each distribution. Emissions therefore use current weights without per-block updates.

For how YB.emit() turns rate_factor into minted YB, read Exponential decay formula in YB token.

Gauge lifecycle​

A gauge goes through these stages in order:

  1. The owner adds the gauge with add_gauge().
  2. veYB holders vote on gauge weights with vote_for_gauge_weights(). The votes take effect at once.
  3. Checkpoints update the weights.
  4. The gauge claims emissions with emit().

After add_gauge(), the owner (YB DAO) can kill or revive the gauge with set_killed(). The contract has no function to remove a gauge.