Skip to main content

HybridVault

The source code of the HybridVault.vy contract can be found on GitHub. The contract is written with Vyper version 0.4.3.

Overview

The HybridVault combines YB market positions with a crvUSD savings vault (e.g., scrvUSD). Each user gets a personal vault instance (minimal proxy deployed by the HybridVaultFactory) that holds both their staked/unstaked LT positions across up to 16 markets and their crvUSD backing in a single contract.

The vault enforces a stablecoin backing requirement: the crvUSD held in the savings vault must cover a configurable fraction (stablecoin_fraction) of the total crvUSD-equivalent value of all active pool positions. This links pool access to peg-supporting crvUSD deposits.

Key mechanics:

  • Deposit flow: User deposits assets + debt into a YB market through the vault. The vault verifies sufficient crvUSD backing, temporarily raises the market's stablecoin allocation cap to allow the LT deposit, then tightens it back down.
  • Stablecoin allocation tracking: The vault tracks how much stablecoin allocation it has contributed to each market (stablecoin_allocation[pool_id]), and reduces it proportionally on withdrawal.
  • crvUSD vault integration: crvUSD is deposited into an ERC-4626 vault (e.g., scrvUSD). The vault reads previewRedeem() to determine available crvUSD backing. Users can deposit/withdraw crvUSD or transfer vault shares directly.
  • Safe limits: Before depositing, the AMM enforces that its debt/collateral ratio stays within safe bounds (via AMM._deposit()get_x0(safe_limits=True)). The vault exposes a gas-free safe_to_deposit() view to check this before sending a transaction, but the actual enforcement happens at the AMM layer — deposits outside safe bounds revert in AMM._deposit().

Deployed by the HybridVaultFactory: Vault instances are deployed as minimal proxies of the implementation contract, then initialized with initialize(user, crvusd_vault).


System Architecture

┌──────────────────────────────────────────────────────────────────┐
│ HybridVaultFactory │
│ (Deploys vaults, manages global limits & allowed crvUSD vaults) │
└──────────────────────────────────────────────────────────────────┘
│ create_vault()

┌──────────────────────────────────────────────────────────────────┐
│ HybridVault (per user) │
│ │
│ ┌─────────────┐ ┌──────────────────────────────────────────┐ │
│ │ crvUSD │ │ YB Market Positions (up to 16) │ │
│ │ Vault │ │ ┌──────────┐ ┌──────────┐ │ │
│ │ (scrvUSD) │ │ │ Pool 0 │ │ Pool 1 │ ... │ │
│ │ │ │ │ LT+Staker│ │ LT+Staker│ │ │
│ └─────────────┘ │ └──────────┘ └──────────┘ │ │
│ └──────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
│ │
▼ ▼
scrvUSD / ERC4626 YB Factory Markets
(crvUSD savings) (LT, AMM, Staker)

Function Documentation

Public Variables

Constants

  • MAX_VAULTS (uint256) - Maximum simultaneous pool positions per vault (16)
  • AMM_MIN_SAFE_DEBT (uint256) - Minimum safe debt/collateral ratio for deposits
  • AMM_MAX_SAFE_DEBT (uint256) - Maximum safe debt/collateral ratio for deposits

Immutable Variables

  • FACTORY (Factory) - YB Factory contract
  • CRVUSD (IERC20) - crvUSD token
  • VAULT_FACTORY (VaultFactory) - HybridVaultFactory contract

State Variables

  • owner (address) - Vault owner (set once at initialization)
  • crvusd_vault (IERC4626) - Current crvUSD savings vault (e.g., scrvUSD)
  • used_vaults (DynArray[uint256, 16]) - Pool IDs with active positions
  • stablecoin_allocation (HashMap[uint256, uint256]) - Tracked allocation per pool
  • personal_limit (HashMap[uint256, uint256]) - Per-pool personal limit boost

Data Structs

Market (read from Factory): asset_token, cryptopool, amm, lt, price_oracle, virtual_pool, staker.

OraclizedValue: p_o (oracle price), value (USD value).

LiquidityValues: admin, total, ideal_staked, staked.

Events

SetPersonalLimitpool_id, limit

SetCrvusdVaultvault


initialize()

Description: Initialize a cloned vault instance for a user. Sets the vault owner and approves crvUSD spending on the savings vault. One-shot — reverts if owner is already set.

Returns: bool - True if initialization succeeded.

InputTypeDescription
useraddressAddress that will own this vault
crvusd_vaultIERC4626crvUSD vault to use (e.g., scrvUSD)
📄 View Source Code
@external
def initialize(user: address, crvusd_vault: IERC4626) -> bool:
assert self.owner == empty(address), "Already initialized"
self.owner = user
self.crvusd_vault = crvusd_vault
extcall CRVUSD.approve(crvusd_vault.address, max_value(uint256))
return True

deposit()

Description: Deposit assets into a YB market through this vault. The vault verifies sufficient crvUSD backing (pulls additional crvUSD from sender if deposit_stablecoins=True), checks the position stays within pool limits, temporarily raises the market's stablecoin allocation to allow the LT deposit, calls LT.deposit() and optionally stakes the resulting shares, then tightens the allocation back down.

A debt sanity check enforces debt ≤ 110% of additional_crvusd — the debt taken cannot exceed 110% of the position's crvUSD-equivalent value. Choosing a debt value above this cap will revert with "Debt made too high". Only callable by the owner.

Returns: uint256 - LT shares received (or staked gauge shares if stake=True).

InputTypeDescription
pool_iduint256Market pool identifier
assetsuint256Amount of asset token to deposit
debtuint256Amount of crvUSD debt to take on
min_sharesuint256Minimum LT shares to receive (slippage protection)
stakeboolIf True, auto-stake LT shares in gauge (default: False)
deposit_stablecoinsboolIf True, pull additional crvUSD from sender if backing is insufficient (default: False)
📄 View Source Code
@external
def deposit(pool_id: uint256, assets: uint256, debt: uint256, min_shares: uint256, stake: bool = False, deposit_stablecoins: bool = False) -> uint256:
assert self.owner == msg.sender, "Access"

market: Market = staticcall FACTORY.markets(pool_id)
assert market.lt.address != empty(address), "Bad pool_id"
if not self.pool_approved[pool_id]:
assert extcall market.asset_token.approve(market.lt.address, max_value(uint256), default_return_value=True)
extcall market.lt.approve(market.staker.address, max_value(uint256))
self.pool_approved[pool_id] = True
self.token_in_use[market.lt.address] = True
self.token_in_use[market.staker.address] = True

# Trigger checkpoint_staker_rebase() by staking 0 tokens
extcall market.staker.deposit(0, self)

pool_value: uint256 = 0
additional_crvusd: uint256 = 0
pool_value, additional_crvusd = self._required_crvusd_for(market, assets, debt)
crvusd_available: uint256 = self._crvusd_available()
crvusd_required: uint256 = self._downscale(self._required_crvusd() + additional_crvusd)
if crvusd_available < crvusd_required:
if deposit_stablecoins:
self._deposit_crvusd(crvusd_required - crvusd_available)
else:
raise "Not enough crvUSD"
assert pool_value + additional_crvusd <= self._pool_limits(pool_id), "Beyond pool limit"

# Temporarily make the cap bigger than necessary
assert debt <= 11 * additional_crvusd // 10, "Debt made too high"
previous_allocation: uint256 = staticcall market.lt.stablecoin_allocation()
self._allocate_stablecoins(market.lt, max((pool_value + additional_crvusd) * 22 // 10, previous_allocation))

assert extcall market.asset_token.transferFrom(msg.sender, self, assets, default_return_value=True)
lt_shares: uint256 = extcall market.lt.deposit(assets, debt, min_shares)

assert lt_shares > 0, "No liquidity given"

self._add_to_used(pool_id) # Only after the deposit confirms lt_shares > 0

# Reduce cap to what it should be
self._allocate_stablecoins(market.lt, previous_allocation + 2 * additional_crvusd)
self.stablecoin_allocation[pool_id] += 2 * additional_crvusd

extcall VAULT_FACTORY.update_vault_required(self.crvusd_vault.address, crvusd_required, True)

if not stake:
return lt_shares
else:
return extcall market.staker.deposit(lt_shares, self)

withdraw()

Description: Withdraw assets from a YB market. Pass max_value(uint256) for shares to withdraw all. If withdraw_stablecoins=True, excess crvUSD backing is returned to the receiver. On withdrawal, the vault reduces stablecoin allocation proportionally and updates the factory's tracked required crvUSD. Only callable by owner.

Note: Setting withdraw_stablecoins=True will revert if any pool's oracle is failing (returns max_value). If you encounter this, use withdraw_stablecoins=False and fall back to emergency_withdraw() if needed.

Returns: uint256 - Amount of assets withdrawn.

InputTypeDescription
pool_iduint256Market pool identifier
sharesuint256LT shares to withdraw (max_value(uint256) for all)
min_assetsuint256Minimum assets to receive (slippage protection)
unstakeboolIf True, unstake from gauge first (default: False)
receiveraddressAddress to receive withdrawn assets (default: msg.sender)
withdraw_stablecoinsboolIf True, return excess crvUSD to receiver (default: False)
📄 View Source Code
@external
def withdraw(pool_id: uint256, shares: uint256, min_assets: uint256, unstake: bool = False, receiver: address = msg.sender, withdraw_stablecoins: bool = False) -> uint256:
assert self.owner == msg.sender, "Access"

market: Market = staticcall FACTORY.markets(pool_id)
assert market.lt.address != empty(address), "Bad pool_id"

required_before: uint256 = self._required_crvusd()
pool_crvusd_before: uint256 = self._pool_crvusd(market)

lt_shares: uint256 = shares
if unstake:
staker_shares: uint256 = shares
if staker_shares == max_value(uint256):
staker_shares = staticcall market.staker.balanceOf(self)
lt_shares = extcall market.staker.redeem(staker_shares, self, self)
elif lt_shares == max_value(uint256):
lt_shares = staticcall market.lt.balanceOf(self)
lt_supply: uint256 = staticcall market.lt.totalSupply()

assets: uint256 = extcall market.lt.withdraw(lt_shares, min_assets, receiver)

removed: bool = False
if staticcall market.lt.balanceOf(self) == 0 and staticcall market.staker.balanceOf(self) == 0:
self._remove_from_used(pool_id)
removed = True

required_after: uint256 = self._required_crvusd()

if not removed:
previous_allocation: uint256 = staticcall market.lt.stablecoin_allocation()
reduction: uint256 = 0

if required_before == max_value(uint256) or required_after == max_value(uint256):
pool_crvusd_after: uint256 = self._pool_crvusd(market)
assert pool_crvusd_before != max_value(uint256) and pool_crvusd_after != max_value(uint256), "Oracle is broken"
assert not withdraw_stablecoins, "Cannot withdraw stables"
if pool_crvusd_before > pool_crvusd_after:
reduction = min(2 * (pool_crvusd_before - pool_crvusd_after), self.stablecoin_allocation[pool_id])
else:
if required_before > required_after:
if withdraw_stablecoins:
if required_after > 0:
self._withdraw_crvusd(self._downscale(required_before - required_after), receiver, True)
else:
self._redeem_crvusd(staticcall self.crvusd_vault.balanceOf(self), receiver)
reduction = min(2 * (required_before - required_after), self.stablecoin_allocation[pool_id])

if reduction > 0:
if reduction > previous_allocation:
reduction = previous_allocation
self._allocate_stablecoins(market.lt, previous_allocation - reduction)
self.stablecoin_allocation[pool_id] -= reduction

else:
if withdraw_stablecoins:
assert required_after != max_value(uint256), "Cannot withdraw stables"
if required_after > 0:
available: uint256 = self._crvusd_available()
needed: uint256 = self._downscale(required_after)
if available > needed:
self._withdraw_crvusd(available - needed, receiver, True)
else:
self._redeem_crvusd(staticcall self.crvusd_vault.balanceOf(self), receiver)

if required_after != max_value(uint256):
extcall VAULT_FACTORY.update_vault_required(self.crvusd_vault.address, self._downscale(required_after), False)

return assets

emergency_withdraw()

Description: Emergency withdraw from a YB market. Uses fallback accounting when oracle-dependent paths fail:

  1. If both per-pool oracle values are available, allocation is reduced based on pool value change.
  2. If oracles are unavailable, allocation is estimated from the crvUSD consumed during withdrawal and the stablecoin fraction.
  3. If oracles are fully working, normal required-crvUSD reduction applies.

Use when withdraw() is blocked due to oracle failure. If crvusd_from_wallet=False, the vault redeems all its scrvUSD to cover potential debt repayment, then re-deposits any remaining crvUSD back to the savings vault. If crvusd_from_wallet=True, the caller provides crvUSD directly and receives any excess back. Only callable by owner.

Important: You must unstake LT tokens before calling emergency_withdraw(). Staked shares are not accessible to this function — call unstake() first.

InputTypeDescription
pool_iduint256Market pool identifier
sharesuint256LT shares to withdraw (max_value(uint256) for all)
crvusd_from_walletboolIf True, pull crvUSD from caller instead of redeeming from savings vault (default: False)
📄 View Source Code (condensed)
@external
def emergency_withdraw(pool_id: uint256, shares: uint256, crvusd_from_wallet: bool = False):
assert self.owner == msg.sender, "Access"
# ... (resolves lt_shares, handles crvusd source)
assets: uint256 = (extcall market.lt.emergency_withdraw(lt_shares, self, self))[0]
# ... (restores allocation, updates factory required)

See source for full oracle-fallback branching.


stake()

Description: Stake LT shares in the market's gauge to earn YB emissions. Only callable by owner.

Returns: uint256 - Amount of staked (gauge) shares received.

InputTypeDescription
pool_iduint256Market pool identifier
pool_sharesuint256Amount of LT shares to stake
📄 View Source Code
@external
def stake(pool_id: uint256, pool_shares: uint256) -> uint256:
assert self.owner == msg.sender, "Access"
market: Market = staticcall FACTORY.markets(pool_id)
assert market.lt.address != empty(address), "Bad pool_id"
return extcall market.staker.deposit(pool_shares, self)

unstake()

Description: Unstake shares from the market's gauge, receiving LT shares back. Only callable by owner.

Returns: uint256 - Amount of LT shares received.

InputTypeDescription
pool_iduint256Market pool identifier
gauge_sharesuint256Amount of staked shares to unstake
📄 View Source Code
@external
def unstake(pool_id: uint256, gauge_shares: uint256) -> uint256:
assert self.owner == msg.sender, "Access"
market: Market = staticcall FACTORY.markets(pool_id)
assert market.lt.address != empty(address), "Bad pool_id"
return extcall market.staker.redeem(gauge_shares, self, self)

claim_reward()

Description: Claim rewards from all staked positions and send to the vault owner. Iterates over all used_vaults and calls claim() on each staker contract. Anyone can call this function — no owner check. Rewards are always sent to the vault owner, making this safe for keeper/bot automation.

Returns: uint256 - Total amount claimed and transferred to owner.

InputTypeDescription
tokenIERC20The reward token to claim
📄 View Source Code
@external
def claim_reward(token: IERC20) -> uint256:
total: uint256 = 0
for pool_id: uint256 in self.used_vaults:
market: Market = staticcall FACTORY.markets(pool_id)
success: bool = False
res: Bytes[32] = empty(Bytes[32])
success, res = raw_call(
market.staker.address,
abi_encode(token.address, self, method_id=method_id("claim(address,address)")),
max_outsize=32,
revert_on_failure=False)
if success:
total += convert(res, uint256)
if total > 0:
assert extcall token.transfer(self.owner, total, default_return_value=True)
return total

preview_claim_reward()

Description: Preview total claimable rewards across all staked positions. View function.

Returns: uint256 - Total claimable amount of the reward token.

InputTypeDescription
tokenIERC20The reward token to query
📄 View Source Code
@external
@view
def preview_claim_reward(token: IERC20) -> uint256:
total: uint256 = 0
for pool_id: uint256 in self.used_vaults:
market: Market = staticcall FACTORY.markets(pool_id)
success: bool = False
res: Bytes[32] = empty(Bytes[32])
success, res = raw_call(
market.staker.address,
abi_encode(token.address, self, method_id=method_id("preview_claim(address,address)")),
max_outsize=32,
is_static_call=True,
revert_on_failure=False)
if success:
total += convert(res, uint256)
return total

deposit_crvusd()

Description: Deposit crvUSD into the crvUSD savings vault. Pulls crvUSD from the caller via transferFrom. Anyone can call to add backing.

Returns: uint256 - Amount of crvUSD vault shares received.

InputTypeDescription
assetsuint256Amount of crvUSD to deposit
📄 View Source Code
@external
def deposit_crvusd(assets: uint256) -> uint256:
return self._deposit_crvusd(assets)

redeem_crvusd()

Description: Redeem crvUSD vault shares for crvUSD. Reverts if withdrawal would leave insufficient backing for active positions. Only callable by owner.

Returns: uint256 - Amount of crvUSD withdrawn.

InputTypeDescription
sharesuint256Amount of crvUSD vault shares to redeem
📄 View Source Code
@external
def redeem_crvusd(shares: uint256) -> uint256:
assert self.owner == msg.sender, "Access"
return self._redeem_crvusd(shares, msg.sender)

deposit_scrvusd() / withdraw_scrvusd()

Description: Deposit or withdraw crvUSD vault shares (e.g., scrvUSD) directly via transferFrom / transfer. Withdraw reverts if it would leave insufficient backing. Deposit is open to anyone; withdraw is owner-only.

📄 View Source Code
@external
def deposit_scrvusd(shares: uint256):
extcall self.crvusd_vault.transferFrom(msg.sender, self, shares)

@external
def withdraw_scrvusd(shares: uint256):
assert self.owner == msg.sender, "Access"
extcall self.crvusd_vault.transfer(msg.sender, shares)
assert self._crvusd_available() >= self._downscale(self._required_crvusd()), "Not enough crvUSD left"

set_crvusd_vault()

Description: Change the crvUSD vault used by this HybridVault. Only possible when there are no active positions. Old vault shares are returned to the owner (redeemed as crvUSD by default, or transferred as shares if redeem=False). The new vault must be on the governance-approved allowlist. Only callable by owner.

Emits: SetCrvusdVault.

InputTypeDescription
new_vaultIERC4626New crvUSD vault to use (must be in allowed list)
redeemboolIf True, redeem old vault shares for crvUSD; if False, transfer shares directly (default: True)
📄 View Source Code
@external
def set_crvusd_vault(new_vault: IERC4626, redeem: bool = True):
assert msg.sender == self.owner, "Only owner"
assert staticcall VAULT_FACTORY.allowed_crvusd_vaults(new_vault.address), "Vault not allowed"
assert len(self.used_vaults) == 0, "Has active positions"
old_vault: IERC4626 = self.crvusd_vault
if old_vault != empty(IERC4626):
extcall VAULT_FACTORY.update_vault_required(old_vault.address, 0, False)
shares: uint256 = staticcall old_vault.balanceOf(self)
if shares > 0:
if redeem:
extcall old_vault.redeem(shares, msg.sender, self)
else:
extcall old_vault.transfer(msg.sender, shares)
extcall CRVUSD.approve(old_vault.address, 0)
self.crvusd_vault = new_vault
extcall CRVUSD.approve(new_vault.address, max_value(uint256))
log SetCrvusdVault(vault=new_vault)

set_personal_limit()

Description: Set a personal pool limit for this vault, added to the global pool limit. Only callable by ADMIN via VAULT_FACTORY.ADMIN().

Emits: SetPersonalLimit.

InputTypeDescription
pool_iduint256Market pool identifier
limituint256Personal limit to set
📄 View Source Code
@external
def set_personal_limit(pool_id: uint256, limit: uint256):
assert msg.sender == staticcall VAULT_FACTORY.ADMIN(), "Only admin"
self.personal_limit[pool_id] = limit
log SetPersonalLimit(pool_id=pool_id, limit=limit)

recover_tokens()

Description: Recover accidentally sent tokens. Cannot recover LT or staker tokens that are actively in use, nor the crvUSD vault shares. Only callable by owner.

InputTypeDescription
tokenIERC20The token to recover
📄 View Source Code
@external
def recover_tokens(token: IERC20):
assert self.owner == msg.sender, "Access"
assert not self.token_in_use[token.address] and token.address != self.crvusd_vault.address, "Token not allowed"
assert extcall token.transfer(msg.sender, staticcall token.balanceOf(self), default_return_value=True)

safe_to_deposit()

Description: Check whether it is safe to deposit into a given market. Returns False if the AMM's debt/collateral ratio would be outside safe bounds after the deposit. View function.

Returns: bool - True if within safe limits.

InputTypeDescription
pool_iduint256Market pool identifier
assetsuint256Amount of assets to deposit (0 to check current state)
debtuint256Amount of debt to take on (0 to check current state)
📄 View Source Code
@external
@view
def safe_to_deposit(pool_id: uint256, assets: uint256, debt: uint256) -> bool:
return self._check_safe_limits(staticcall FACTORY.markets(pool_id), assets, debt)

View helpers

pool_limits(pool_id) — effective limit (personal + global factory).

required_crvusd() — total downscaled crvUSD required across all positions.

raw_required_crvusd_for(pool_id, assets, debt, downscale=True) → uint256 — crvUSD required for a potential deposit. Returns a single uint256 (the post-downscale amount when downscale=True, raw amount when False). The internal helper _required_crvusd_for returns the (pool_value, additional_crvusd) pair; this external view exposes only the second leg.

crvusd_for_deposit(pool_id, assets, debt) — additional crvUSD user must provide.

withdrawable_crvusd_for(pool_id, shares, is_staked) — crvUSD freed up by withdrawing shares.

assets_for_crvusd(pool_id, crvusd_amount) — corresponding assets amount for a crvUSD input.