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-freesafe_to_deposit()view to check this before sending a transaction, but the actual enforcement happens at the AMM layer — deposits outside safe bounds revert inAMM._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 depositsAMM_MAX_SAFE_DEBT(uint256) - Maximum safe debt/collateral ratio for deposits
Immutable Variables
FACTORY(Factory) - YB Factory contractCRVUSD(IERC20) - crvUSD tokenVAULT_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 positionsstablecoin_allocation(HashMap[uint256, uint256]) - Tracked allocation per poolpersonal_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
SetPersonalLimit — pool_id, limit
SetCrvusdVault — vault
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.
| Input | Type | Description |
|---|---|---|
user | address | Address that will own this vault |
crvusd_vault | IERC4626 | crvUSD 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).
| Input | Type | Description |
|---|---|---|
pool_id | uint256 | Market pool identifier |
assets | uint256 | Amount of asset token to deposit |
debt | uint256 | Amount of crvUSD debt to take on |
min_shares | uint256 | Minimum LT shares to receive (slippage protection) |
stake | bool | If True, auto-stake LT shares in gauge (default: False) |
deposit_stablecoins | bool | If 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=Truewill revert if any pool's oracle is failing (returnsmax_value). If you encounter this, usewithdraw_stablecoins=Falseand fall back toemergency_withdraw()if needed.
Returns:
uint256 - Amount of assets withdrawn.
| Input | Type | Description |
|---|---|---|
pool_id | uint256 | Market pool identifier |
shares | uint256 | LT shares to withdraw (max_value(uint256) for all) |
min_assets | uint256 | Minimum assets to receive (slippage protection) |
unstake | bool | If True, unstake from gauge first (default: False) |
receiver | address | Address to receive withdrawn assets (default: msg.sender) |
withdraw_stablecoins | bool | If 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:
- If both per-pool oracle values are available, allocation is reduced based on pool value change.
- If oracles are unavailable, allocation is estimated from the crvUSD consumed during withdrawal and the stablecoin fraction.
- 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 — callunstake()first.
| Input | Type | Description |
|---|---|---|
pool_id | uint256 | Market pool identifier |
shares | uint256 | LT shares to withdraw (max_value(uint256) for all) |
crvusd_from_wallet | bool | If 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.
| Input | Type | Description |
|---|---|---|
pool_id | uint256 | Market pool identifier |
pool_shares | uint256 | Amount 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.
| Input | Type | Description |
|---|---|---|
pool_id | uint256 | Market pool identifier |
gauge_shares | uint256 | Amount 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.
| Input | Type | Description |
|---|---|---|
token | IERC20 | The 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.
| Input | Type | Description |
|---|---|---|
token | IERC20 | The 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.
| Input | Type | Description |
|---|---|---|
assets | uint256 | Amount 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.
| Input | Type | Description |
|---|---|---|
shares | uint256 | Amount 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.
| Input | Type | Description |
|---|---|---|
new_vault | IERC4626 | New crvUSD vault to use (must be in allowed list) |
redeem | bool | If 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.
| Input | Type | Description |
|---|---|---|
pool_id | uint256 | Market pool identifier |
limit | uint256 | Personal 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.
| Input | Type | Description |
|---|---|---|
token | IERC20 | The 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.
| Input | Type | Description |
|---|---|---|
pool_id | uint256 | Market pool identifier |
assets | uint256 | Amount of assets to deposit (0 to check current state) |
debt | uint256 | Amount 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.
Related
- Core: HybridVaultFactory — deploys and governs this vault.
- Core: Factory — YB core markets.
- Core: LT, Core: AMM — target markets.
- User: Hybrid Vaults — user-level explainer.
- User: Use Hybrid Vaults — task walkthrough.