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.vywithFastGauge.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:
| Contract | Role |
|---|---|
YBNetPressure.vy | Pressure oracle. Returns the net pressure and the half-TVL of each market. Aggregates over a set of LTs that the YB DAO selects. |
PID.vy | Onchain 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.vy | ERC-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.vy | Installed 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.vy | Stateless 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.vy | The 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:
sinkis 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()returnsconfig().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:
- It recovered the fee balances of the deprecated BTC markets from the FeeDistributor (
recover_token). - It converted them into the PID reserve through
LTSwapZap.convert. - It transferred initial crvUSD funding to the MerklPIDDriver.
- 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:
| Signature | Access | Notes |
|---|---|---|
split_fraction() -> uint256 | View | Share 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() -> address | View | The two destinations. |
trigger() | Anyone, @nonreentrant | For 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, @nonreentrant | Alias of trigger() for callers that use the FeeDistributor ABI. |
set_split_fraction(fraction: uint256) | Owner | Reverts with fraction > 1 if fraction is above 10**18. The constructor has the same check. |
set_destinations(pid: address, fee_distributor: address) | Owner | Calls fill_epochs() on the new FeeDistributor as a check. |
recover(token: address, amount: uint256, to: address) | Owner | Sends amount of token to to. |
owner() -> address, transfer_ownership(new_owner: address) | View, owner | snekmate ownable. |
PID
The PID controller has these functions:
| Signature | Access | Notes |
|---|---|---|
trigger() | Anyone, @nonreentrant | Converts 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() -> bool | View | Controller 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() → uint256 | View | Gains and clamps, 1e18 = 1.0. Example: kp() = 2 * 10**18 is a gain of 2.0. |
d_filter_time() -> uint256 | View | Derivative filter time in seconds. Example: 21,600 is 6 hours. |
swap_fee_multiplier() -> uint256, dust_floor() -> uint256 | View | Fee-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() → address | View | Sources, 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) | Owner | Reverts 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) | Owner | Reverts without a message if swap_fee_multiplier is above 10**24. |
set_pressure_lts(lts: DynArray[address, 20]) | Owner | Sets the LTs whose net pressure the controller sums, at most 20. |
set_gauge(gauge: address, sink_pool: address) | Owner | Also approves the gauge to pull crvUSD from the PID without a limit. |
set_sources(net_pressure: address, market_rate_getter: address, fee_distributor: address) | Owner | Sets the three data sources. |
recover(token: address, amount: uint256, to: address) | Owner | Sweeps the reserve or any token. |
FastGauge
The FastGauge has these functions:
| Signature | Access | Notes |
|---|---|---|
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) -> uint256 | Anyone, @nonreentrant | ERC-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() | View | ERC-4626 and ERC-20 views. |
approve(spender: address, amount: uint256) -> bool, transfer(to: address, amount: uint256) -> bool, transferFrom(owner: address, to: address, amount: uint256) -> bool | Anyone; transfer and transferFrom are @nonreentrant | transfer and transferFrom settle the rewards of both parties first. |
claim(user: address = msg.sender) -> uint256 | Anyone, @nonreentrant | Pays the claimable crvUSD of user to user. |
claimable_reward(user: address) -> uint256 | View | crvUSD that a claim(user) call in the same block pays, capped by what the PID can supply. |
tvl_ema() -> uint256 | View | EMA of the staked LP, in LP-token units. The PID reads it as the sink. |
reward_rate() -> uint256 | View | crvUSD 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() only | Reverts with Only PID. |
set_pid(pid: address) | Owner | Sets the controller that the gauge pulls crvUSD from. |
set_ema_time(ema_time: uint256) | Owner | EMA 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) -> uint256 | Anyone, @nonreentrant | Adds 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, @nonreentrant | Burns 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) -> uint256 | Anyone, @nonreentrant | Burns 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:
| Signature | Access | Notes |
|---|---|---|
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) | View | One 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() -> uint256 | View | crvUSD balance of the driver. |
connected() -> bool | View | True if Factory.fee_receiver() is a contract whose pid() is this driver. |
trigger() | Anyone, @nonreentrant | Converts 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 manager | Same 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]) -> bytes32 | Owner or manager | Opens 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 manager | Changes the data, duration, or start of a campaign. |
approve_merkl(amount: uint256) | Owner or manager | Sets the crvUSD allowance of the reward wrapper. Reverts with No wrapper if no wrapper is set. |
accept_conditions() | Owner or manager | Accepts 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) | Owner | Configuration and sweep. set_sink_pool only records the address; the driver does not read it. |
manager(), merkl_creator(), reward_wrapper(), sink_pool() → address | View | Roles and Merkl contracts. |
YBNetPressure and MarketRateGetter
YBNetPressure and MarketRateGetter have these view functions:
| Signature | Access | Notes |
|---|---|---|
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) -> uint256 | View | Each part of net_pressure_and_tvl alone. |
YBNetPressure.net_pressure_naive(lt: address) -> int256 | View | Net 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) -> uint256 | View | crvUSD value fraction of the LP at price_oracle, 1e18 = 100%. |
MarketRateGetter.rate() -> uint256 | View | Simple 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.
Related
- 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.