Skip to main content

Net pressure (PID)

A proportional–integral–derivative (PID) controller measures the aggregate crvUSD net pressure across YieldBasis markets and pays crvUSD incentives to liquidity in a sink pool. A share of the admin fees of LT, the vault contract of each market, funds it. The YieldBasis DAO (YB DAO) sets that share. Six contracts measure the pressure, route the fees, and pay the incentives. For each contract, this reference gives its role, its main functions, and its revert strings.

The page uses these terms:

  • Net pressure: for each market, the debt of the LEVAMM, the market's leveraged AMM, minus the crvUSD inside the Curve LP that the LEVAMM holds. A positive value means that crvUSD must be bought to repay the debt (buy pressure).
  • Half-TVL (half_tvl, where TVL is total value locked): the market-size measure that YBNetPressure returns in crvUSD with the net pressure. The controller divides pressure by the summed half-TVL to normalize it by market size.
  • Sink pool: the Curve pool whose liquidity the incentives pay for. The sink is the size of that liquidity.
  • Reserve share: the share of each LT admin-fee balance that the FeeSplitter sends to the controller. The YB DAO sets it as split_fraction. Admin fees arrive as yb-LP shares, which this page calls LT shares.

The FeeSplitter sends the reserve share to the address that FeeSplitter.pid() returns. Two receivers exist:

  • MerklPIDDriver: Merkl is the offchain platform that runs the incentive campaign and distributes its rewards. Merkl also runs the control loop offchain. The YB DAO or the manager of the driver opens Merkl campaigns through the driver. The driver funds them from its crvUSD reserve.
  • PID.vy with FastGauge.vy: the onchain controller streams crvUSD to a FastGauge, a staking gauge for the LP token of the sink pool.

To find the live receiver, call pid() on the FeeSplitter. For the addresses of the deployed net-pressure contracts, read Net pressure in Contract addresses.

The controller output has three terms. The first is proportional to the current error. The second integrates past error, and the third tracks how fast the error rises.

Components​

Each of the six contracts has one role:

ContractRole
YBNetPressure.vyPressure oracle. Returns the net pressure and the half-TVL of each market. Aggregates over a set of LTs that the YB DAO selects.
PID.vyOnchain controller. Converts incoming LT fees to a crvUSD reserve. Runs the control loop on aggregate pressure and sets the FastGauge's reward_rate. Receives the reserve share only if FeeSplitter.pid() is its address.
FastGauge.vyERC-4626 staking gauge over the sink pool's LP token. Streams a single reward (crvUSD), which it pulls from the PID's reserve at every checkpoint. Its add_liquidity, remove_liquidity, and remove_liquidity_one_coin zaps give one-call entry and exit.
FeeSplitter.vyInstalled as Factory.fee_receiver(). Routes split_fraction of each incoming LT fee balance to pid(), and forwards the rest to the FeeDistributor, the contract that pays veYB holders. Then it calls trigger() on pid() if pid() is set, and fill_epochs() on the FeeDistributor. The YB DAO sets split_fraction (1e18 = 100%). A fraction above 1e18 reverts. The splitter reads the LT token set from the FeeDistributor.
MerklPIDDriver.vyStateless version of the PID for Merkl. Merkl stores the PID state and calls preview_target_apr() each step. The contract converts LT fees into a crvUSD reserve. It creates or overrides Merkl campaigns funded from that reserve. The owner (YB DAO) or an optional manager can set gains and campaigns.
MarketRateGetter.vyThe comparator rate that the controller prices its bonus against. The implementation in yb-core reads the Sky Savings Rate (sUSDS). The YB DAO can replace it. Only rate() is part of the interface.

The control loop​

PID.vy applies the control law at each step. In the law, dt is in years, and all values are 1e18 fixed point. MerklPIDDriver uses the same law with two differences:

  • sink is the TVL that Merkl measures.
  • The output is target_apr, not a rate.

This simplified form of the PID.vy source shows the control law:

pressure     = max(0, sum of net pressure) / sum of half-TVL   (oracle-priced)
sink = sink-pool TVL / sum of half-TVL
error = pressure - sink (coverage gap)
integral += error * dt clamped to [0, max_integral]
d_pressure = (Tf*d_pressure + Δpressure) / (Tf + dt) (filtered; Tf = d_filter_time, default 6 h)
target = min(feedforward_gain*pressure + kp*error
+ ki*integral + kd*max(0, d_pressure), sink_cap)
offer = max(1, dead_band + target / sink_per_offer) (APR multiple, floored at 1x)
bonus_apr = (offer - 1) * market_rate (0 when no sink is wanted)
rate = bonus_apr * staked_value / seconds_per_year (crvUSD/sec)

Gains and parameters are storage that the YB DAO can set, not constants. The setters are set_gains, set_execution_params, set_sources, set_pressure_lts, and set_gauge. set_gains also takes d_filter_time and checks bounds. The reserve is the crvUSD balance of the controller contract.

Controller behavior and safeguards​

These rules govern when the controller runs, how it pays, and how it resists manipulation.

Permissionless trigger​

Anyone can call trigger() to advance the loop. The system does not depend on a keeper for liveness.

Connection gate​

In PID.vy, the controller runs only when Factory.fee_receiver() is a FeeSplitter and that FeeSplitter's pid() is this PID. Otherwise trigger() updates last_ts and returns. If the controller was active, it first sets the gauge rate to 0. At the first connected call, the controller starts with integral = 0 and d_pressure = 0.

Controller state reads​

preview_signals() returns (pressure, sink). To read the controller state, call integral(), d_pressure(), prev_pressure(), and last_ts().

Reserve depletion​

The FastGauge pulls owed crvUSD from the PID at each checkpoint. The PID's balance caps each pull. An empty reserve slows and stops the stream. Nothing reverts.

Sink measurement​

The PID reads the sink size through the FastGauge's tvl_ema(), an exponential moving average (EMA) of staked value. Flash deposits therefore cannot spike the measured sink inside one block.

Minimum outputs of the fee conversion​

To convert fees, the controller withdraws each LT's asset and then swaps the asset in that LT's pool. Each step has a minimum output:

  • The withdrawal minimum is the oracle value of the shares, less the same discount as the swap minimum below. If the withdrawal cannot meet it, trigger() reverts.
  • The swap minimum is price_oracle · (1 − swap_fee_multiplier · pool fee). If a swap cannot meet this minimum, the controller skips it and keeps the asset.

dust_floor skips small balances. It does not bound the swap.

Differences from LiquidityGauge​

FastGauge is not a LiquidityGauge. It has no CRV emissions, no boost, and a single reward token. Its ERC-4626 accounting mirrors Curve's reward logic.

Fee flow​

With the FeeSplitter installed as fee_receiver, veYB distributions receive 1 − split_fraction of admin fees. The YB DAO sets split_fraction. To read it, call split_fraction() on the FeeSplitter. For how LT splits fees into admin fees, read Fees and veYB revenue.

Pool policy and the swap minimum​

When the pool policy returns a fee that is not 0, pool.fee() includes that policy fee. So the swap minimum of the fee conversion depends on the policy that the pool runs:

  • On a pool that runs the oracle-report policy (YBOraclizedPolicy), the policy sets the fee. While the policy is not killed, pool.fee() returns config().fee.fallback_fee. The reason is that the current balances equal the policy snapshot.
  • On a pool that runs YBTwocryptoPolicy, get_fee() returns 0, and the pool uses its native fee.

To see which policy a pool runs, call POLICY() on the pool. For the fee rules of the oracle-report policy, read Fee in Pool policy.

Activation proposal​

A YB DAO proposal activated the system. The proposal transaction did these steps:

  1. It recovered the fee balances of the deprecated BTC markets from the FeeDistributor (recover_token).
  2. It converted them into the PID reserve through LTSwapZap.convert.
  3. It transferred initial crvUSD funding to the MerklPIDDriver.
  4. It installed the FeeSplitter as Factory.fee_receiver().

Functions​

The tables list the main external functions of each contract, with their own revert strings. Where the source types a parameter as an interface (for example IERC20 or FeeDistributor), the tables show its ABI type, address. If another address calls an owner function, the call reverts with ownable: caller is not the owner, from the ownable module of the snekmate library. For the revert strings of all six contracts in one place, read Net pressure in Error codes.

FeeSplitter​

The FeeSplitter has these functions:

SignatureAccessNotes
split_fraction() -> uint256ViewShare of each LT fee balance that goes to pid(), 1e18 = 100%. Example: 25 * 10**16 sends 25% to pid() and 75% to the FeeDistributor.
pid() -> address, fee_distributor() -> addressViewThe two destinations.
trigger()Anyone, @nonreentrantFor each LT in the current token set of the FeeDistributor, calls withdraw_admin_fees() and ignores a failure. Splits the LT balance of the splitter. Then calls trigger() on pid() if it is set, and fill_epochs() on the FeeDistributor. Reverts from those two calls pass through.
fill_epochs()Anyone, @nonreentrantAlias of trigger() for callers that use the FeeDistributor ABI.
set_split_fraction(fraction: uint256)OwnerReverts with fraction > 1 if fraction is above 10**18. The constructor has the same check.
set_destinations(pid: address, fee_distributor: address)OwnerCalls fill_epochs() on the new FeeDistributor as a check.
recover(token: address, amount: uint256, to: address)OwnerSends amount of token to to.
owner() -> address, transfer_ownership(new_owner: address)View, ownersnekmate ownable.

PID​

The PID controller has these functions:

SignatureAccessNotes
trigger()Anyone, @nonreentrantConverts fees and runs one control step. If the controller is not connected, it skips the step, as Connection gate describes. Reverts with Slippage (from LT.withdraw) when an LT withdrawal returns less than its fair-value floor. The controller skips a swap that misses its minimum output (min_dy) instead. Reverts with No pools if the summed half-TVL of pressure_lts is 0. Reverts with AMM reentrancy from YBNetPressure.
preview_signals() -> (uint256, uint256)View(pressure, sink), each relative to the summed half-TVL, 1e18 = 100%. Example: pressure = 2 * 10**16 is a net pressure of 2% of the summed half-TVL. Reverts with No pools.
integral() -> int256, d_pressure() -> int256, prev_pressure() -> uint256, last_ts() -> uint256, active() -> boolViewController state. integral, d_pressure, and prev_pressure are 1e18 fixed point. last_ts is a Unix time in seconds.
feedforward_gain(), kp(), ki(), kd(), max_integral(), sink_cap() → int256; dead_band(), sink_per_offer() → uint256ViewGains and clamps, 1e18 = 1.0. Example: kp() = 2 * 10**18 is a gain of 2.0.
d_filter_time() -> uint256ViewDerivative filter time in seconds. Example: 21,600 is 6 hours.
swap_fee_multiplier() -> uint256, dust_floor() -> uint256ViewFee-conversion settings. swap_fee_multiplier is 1e18 = 1.0. dust_floor is in LT share units.
net_pressure(), market_rate_getter(), fee_distributor(), gauge(), sink_pool() → address; pressure_lts(i: uint256) -> address; CRVUSD(), FACTORY() → addressViewSources, targets, and immutables.
set_gains(feedforward_gain: int256, kp: int256, ki: int256, kd: int256, max_integral: int256, sink_cap: int256, dead_band: uint256, sink_per_offer: uint256, d_filter_time: uint256)OwnerReverts without a message if a bound fails. Each of the first eight parameters must be at most 10**24 in absolute value. max_integral and sink_cap must not be negative. sink_per_offer must be above 0. d_filter_time must be from 1 to 10**9 seconds.
set_execution_params(swap_fee_multiplier: uint256, dust_floor: uint256)OwnerReverts without a message if swap_fee_multiplier is above 10**24.
set_pressure_lts(lts: DynArray[address, 20])OwnerSets the LTs whose net pressure the controller sums, at most 20.
set_gauge(gauge: address, sink_pool: address)OwnerAlso approves the gauge to pull crvUSD from the PID without a limit.
set_sources(net_pressure: address, market_rate_getter: address, fee_distributor: address)OwnerSets the three data sources.
recover(token: address, amount: uint256, to: address)OwnerSweeps the reserve or any token.

FastGauge​

The FastGauge has these functions:

SignatureAccessNotes
deposit(assets: uint256, receiver: address) -> uint256, mint(shares: uint256, receiver: address) -> uint256, withdraw(assets: uint256, receiver: address, owner: address) -> uint256, redeem(shares: uint256, receiver: address, owner: address) -> uint256Anyone, @nonreentrantERC-4626 over the sink-pool LP token. Reverts with erc4626: deposit more than maximum (and the mint, withdraw, redeem forms), and with Below min supply if the total supply after the call is not 0 and below MIN_TOTAL_SUPPLY() (10 * 10**18).
totalAssets(), convertToShares(), convertToAssets(), previewDeposit(), previewMint(), previewWithdraw(), previewRedeem(), maxDeposit(), maxMint(), maxWithdraw(), maxRedeem(), asset(), decimals(), name(), symbol(), totalSupply(), balanceOf(), allowance()ViewERC-4626 and ERC-20 views.
approve(spender: address, amount: uint256) -> bool, transfer(to: address, amount: uint256) -> bool, transferFrom(owner: address, to: address, amount: uint256) -> boolAnyone; transfer and transferFrom are @nonreentranttransfer and transferFrom settle the rewards of both parties first.
claim(user: address = msg.sender) -> uint256Anyone, @nonreentrantPays the claimable crvUSD of user to user.
claimable_reward(user: address) -> uint256ViewcrvUSD that a claim(user) call in the same block pays, capped by what the PID can supply.
tvl_ema() -> uint256ViewEMA of the staked LP, in LP-token units. The PID reads it as the sink.
reward_rate() -> uint256ViewcrvUSD per second, 18 decimals. Example: 10**15 streams 0.001 crvUSD per second, or 31,536 crvUSD in a 365-day year.
set_reward_rate(rate: uint256)pid() onlyReverts with Only PID.
set_pid(pid: address)OwnerSets the controller that the gauge pulls crvUSD from.
set_ema_time(ema_time: uint256)OwnerEMA time constant in seconds. Reverts with ema_time if the value is 0.
add_liquidity(amounts: DynArray[uint256, 2], min_mint_amount: uint256, receiver: address) -> uint256Anyone, @nonreentrantAdds both coins to the sink pool and stakes the LP for receiver. Reverts with Bad amounts length if amounts does not have two items.
remove_liquidity(shares: uint256, min_amounts: DynArray[uint256, 2], receiver: address) -> DynArray[uint256, 2]Anyone, @nonreentrantBurns the gauge shares of the caller and removes both coins. Reverts with Bad min_amounts length if min_amounts does not have two items.
remove_liquidity_one_coin(shares: uint256, i: int128, min_received: uint256, receiver: address) -> uint256Anyone, @nonreentrantBurns the gauge shares of the caller and removes coin i.

The three zaps also revert with these strings:

  • No zap: LP is not a pool: the staked LP token is not a two-coin Curve pool.
  • Below min supply: the same supply check as in the ERC-4626 functions.

MerklPIDDriver​

The MerklPIDDriver has these functions:

SignatureAccessNotes
preview_target_apr(sink_tvl: uint256, integral_in: int256, prev_pressure_in: uint256, d_pressure_in: int256, dt: uint256) -> (uint256, int256, uint256, int256, uint256, uint256)ViewOne stateless control step. Returns (target_apr, integral, prev_pressure, d_pressure, pressure, sink). sink_tvl is crvUSD with 18 decimals, and dt is in seconds. target_apr is 1e18 = 100% per year. Example: target_apr = 5 * 10**16 is a bonus of 5% per year. Reverts with No pools.
raw_signals() -> (uint256, uint256, uint256)View(pressure, half_tvl, market_rate). pressure and market_rate are 1e18 = 100%. half_tvl is crvUSD with 18 decimals. pressure is 0 if half_tvl is 0; this view does not revert with No pools.
reserve() -> uint256ViewcrvUSD balance of the driver.
connected() -> boolViewTrue if Factory.fee_receiver() is a contract whose pid() is this driver.
trigger()Anyone, @nonreentrantConverts held LT fees into crvUSD. It does not run the control loop. Reverts with Slippage (from LT.withdraw) when an LT withdrawal returns less than its fair-value floor. The driver skips a swap that misses its min_dy instead.
set_gains(...), set_execution_params(...)Owner or managerSame signatures and bounds as in PID. Reverts with Not manager.
create_campaign(amount: uint256, campaign_type: uint256, start_timestamp: uint256, duration: uint256, campaign_data: Bytes[4096]) -> bytes32Owner or managerOpens a Merkl campaign with a budget of amount crvUSD. start_timestamp = 0 starts it in the current block. Returns the campaign ID.
override_campaign(campaign_id: bytes32, campaign_type: uint256, start_timestamp: uint256, duration: uint256, campaign_data: Bytes[4096])Owner or managerChanges the data, duration, or start of a campaign.
approve_merkl(amount: uint256)Owner or managerSets the crvUSD allowance of the reward wrapper. Reverts with No wrapper if no wrapper is set.
accept_conditions()Owner or managerAccepts the Merkl terms on the DistributionCreator.
set_manager(manager: address), set_merkl(creator: address, wrapper: address), set_pressure_lts(lts: DynArray[address, 20]), set_sink_pool(sink_pool: address), set_sources(net_pressure: address, market_rate_getter: address, fee_distributor: address), recover(token: address, amount: uint256, to: address)OwnerConfiguration and sweep. set_sink_pool only records the address; the driver does not read it.
manager(), merkl_creator(), reward_wrapper(), sink_pool() → addressViewRoles and Merkl contracts.

YBNetPressure and MarketRateGetter​

YBNetPressure and MarketRateGetter have these view functions:

SignatureAccessNotes
YBNetPressure.net_pressure_and_tvl(lt: address, agg_price: uint256 = 0) -> (int256, uint256)View(net_pressure, half_tvl) in crvUSD, 18 decimals. Positive net pressure is buy pressure. With agg_price = 0, the oracle reads lt.agg().price().
YBNetPressure.net_pressure_oracle(lt: address, agg_price: uint256 = 0) -> int256, YBNetPressure.half_tvl_oracle(lt: address, agg_price: uint256 = 0) -> uint256ViewEach part of net_pressure_and_tvl alone.
YBNetPressure.net_pressure_naive(lt: address) -> int256ViewNet pressure from spot balances. A swap can move it in the same block, so use it for monitoring only.
YBNetPressure.crvusd_value_fraction(lt: address) -> uint256ViewcrvUSD value fraction of the LP at price_oracle, 1e18 = 100%.
MarketRateGetter.rate() -> uint256ViewSimple annual rate, 1e18 = 100%. Example: 354 * 10**14 is 3.54% per year. Returns 0 if ssr() of the sUSDS source is at or below 10**27.

Each YBNetPressure view reverts with AMM reentrancy if the LEVAMM of lt is in a nonreentrant call. The MarketRateGetter constructor reverts with Bad ssr if ssr() of the sUSDS source is below 10**27.

  • Net pressure and the PID controller: the plain-language explanation, and how LPs earn the incentives.
  • FeeDistributor: the contract that receives the admin fees that the FeeSplitter does not send to the controller.
  • LT: the source of the fee balances that fund the reserve.
  • LEVAMM: the debt side of the net-pressure calculation.
  • Pool policy: the fee rules that set the swap minimum of the fee conversion.