For the complete documentation index, see llms.txt. This page is also available as Markdown.

Earn Vaults

Overview

Earn Vaults are ERC4626-style vaults implemented by LendingOptimizer.sol. Each vault accepts one underlying ERC20 asset and allocates that asset across a configured list of Curvance BorrowableCToken markets that use the same underlying.

Users deposit the underlying asset into the vault and receive optimizer shares. The vault deposits the underlying into approved cToken markets as lending liquidity. Borrowers inside those isolated markets can use that liquidity, and interest earned by the cToken positions increases the vault's tracked assets and share price after accrual.

Earn Vaults do not borrow, post collateral, or choose a new target allocation on each user deposit. Deposits and exits route pro-rata across the vault's current market balances. Allocation percentages change through rebalance(), which can be called by an authorized harvester or market-permissioned address.

Core Architecture

LendingOptimizer combines three main responsibilities:

  • ERC4626-style accounting for deposits, mints, withdrawals, redemptions, share balances, previews, and share-to-asset conversion.

  • Multi-market allocation across up to MAX_MARKETS approved BorrowableCToken markets for the same underlying asset.

  • Authorized operations for market onboarding, cap updates, rebalancing, deposit pausing, fee updates, and idle-asset recovery.

After deployment, deposits are unavailable until initializeDeposits() is called. Initialization deposits a small base reserve into one approved market and mints dead shares to address(0) so the first live depositor cannot manipulate the initial exchange rate.

Key Integrations

  • IERC20: The underlying asset accepted by the vault. Users approve the optimizer before deposit() or mint().

  • BorrowableCToken: Each approved market receives the same underlying asset from the optimizer. The optimizer validates that each cToken has the matching underlying, is borrowable, has a registered Market Manager, and is listed in that Market Manager.

  • MarketManagerIsolated: The optimizer reads cToken mint pause state through actionsPaused(cToken) and reads market-wide redemption pause state through redeemPaused().

  • ICentralRegistry: Permission checks come from the central registry. Market permissions govern setup and market management, harvester or market permissions govern rebalancing, DAO permissions govern skim(), and the DAO address receives performance fee shares.

  • OptimizerReader: Offchain and frontend integrations can use this reader for market data, user data, estimated optimizer APY, and proposed rebalance actions plus allocation bounds.

State Management

Important optimizer state:

State
Unit
Meaning

approvedCTokensList

addresses

Ordered list of cToken markets approved for allocation.

allocationCaps(cToken)

WAD

Maximum post-rebalance allocation for a market. Constructor and admin inputs use BPS, then the contract stores the cap as WAD.

fee

BPS

Performance fee charged on yield above the exchange-rate high watermark.

exchangeRateHighWatermark

WAD

Highest share price used for performance fee accounting.

mintPaused

enum-like uint8

0 means uninitialized, 1 means deposits active, and 2 means deposits paused.

_totalAssets

underlying asset units

Cached total assets tracked across approved markets.

Allocation caps are configured in BPS and stored in WAD. 10000 BPS equals 100%, and 1e18 WAD equals 100%. The sum of allocation caps must be at least 100% when the optimizer is deployed, when a cap decrease would reduce total caps, and when a market is removed.

Data Flow

Deployment and Activation

  1. The optimizer is deployed with one underlying asset, a central registry, initial approved cTokens, initial allocation caps in BPS, and a performance fee in BPS.

  2. The constructor validates that the approved market list is non-empty, has at most MAX_MARKETS entries, has no duplicates, and has a matching cap for each market.

  3. Each cToken must use the optimizer's underlying asset, be borrowable, point to a registered Market Manager, and be listed in that Market Manager.

  4. The initial caps must each be greater than 0, no more than 10000 BPS, and sum to at least 10000 BPS.

  5. A market-permissioned address calls initializeDeposits(targetMarket).

  6. initializeDeposits() pulls the base underlying reserve from the caller, deposits it into the target market, mints dead shares to address(0), and changes mintPaused from 0 to 1.

Deposits and Mints

  1. The user approves the optimizer to spend the underlying asset.

  2. The user calls deposit(assets, receiver) or mint(shares, receiver).

  3. The optimizer rejects zero-sized deposits or mints, uninitialized vaults, paused vault deposits, and deposits when any approved market has minting paused.

  4. The optimizer accrues all approved markets before routing the user action.

  5. The optimizer pulls underlying from the caller.

  6. The optimizer splits the deposit pro-rata across current market balances and deposits into the approved cToken markets.

  7. The receiver gets optimizer shares.

deposit() mints shares from the recoverable value actually tracked after cToken deposit rounding. mint() computes the required assets for exact shares and uses a conversion roundtrip so the new position can cover the requested share amount.

Withdrawals and Redemptions

  1. The user calls withdraw(assets, receiver, owner) or redeem(shares, receiver, owner).

  2. If the caller is not the owner, the optimizer spends optimizer-share allowance from owner.

  3. The optimizer rejects zero-asset exits and rejects exits while any approved market has redemption paused.

  4. The optimizer accrues all approved markets before routing the exit.

  5. The optimizer withdraws pro-rata across approved markets, capped by each market's idle liquidity.

  6. If total idle liquidity across markets cannot satisfy the requested exit, the transaction reverts.

  7. The optimizer burns shares and transfers underlying assets to receiver.

withdraw() returns the shares burned for a target asset amount. redeem() returns the assets withdrawn for a target share amount. Both paths re-sync _totalAssets from the post-withdrawal cToken balances.

Rebalancing

  1. An offchain keeper or bot computes one ReallocationAction per approved market, usually through OptimizerReader.optimalRebalance().

  2. An authorized harvester or market-permissioned address calls rebalance(actions, bounds).

  3. The optimizer accrues all markets and charges any performance fee before rebalancing.

  4. The optimizer processes withdrawals first. Negative assetsOrBps values are withdrawals.

  5. The optimizer processes deposits second. Positive assetsOrBps values are deposits.

  6. Declared withdrawal assets must equal declared deposit assets.

  7. The optimizer verifies that every post-rebalance market allocation is at or below its allocation cap and within the caller-specified AllocationBound.

  8. The optimizer updates _totalAssets from the post-rebalance balances and emits Rebalanced.

The actions and bounds arrays must match the exact order and length of approvedCTokensList. Include zero-value actions for markets that do not need a move.

Market Removal

  1. A market-permissioned address calls removeApprovedAsset(cTokenToRemove, removeActions, bounds).

  2. The optimizer requires more than one approved market and requires an explicit reallocation plan.

  3. The market being removed must be approved and must not be paused for redemptions.

  4. Remaining allocation caps must still sum to at least 100%.

  5. The optimizer redeems its full cToken position from the removed market.

  6. The optimizer redistributes redeemed assets according to positive BPS values in removeActions; the values must sum to exactly 10000.

  7. Reallocation targets must be approved markets, cannot be the removed market, cannot be duplicated, and cannot have minting paused.

  8. The approved market list uses swap-and-pop removal, so post-removal ordering can change. bounds must match the final list order.

Accrual and Fees

State-changing user and keeper flows call _accrueIfNeeded() before routing. Accrual calls accrueIfNeeded() on every approved cToken, refreshes _totalAssets, and charges performance fees only when the current exchange rate is above exchangeRateHighWatermark.

Performance fees are minted as optimizer shares to centralRegistry.daoAddress(). The fee is configured in BPS and capped by MAX_FEE_BPS, which is 5000.

Security and Risk Considerations

  • Initialization is required. Before initializeDeposits(), mintPaused is 0, maxDeposit() and maxMint() return 0, and deposit paths revert.

  • Deposits are globally blocked for the optimizer when any approved market has cToken minting paused.

  • Withdrawals and redemptions are globally blocked for the optimizer when any approved market has redemptions paused.

  • Withdrawals and redemptions depend on idle liquidity in the underlying cToken markets. A cached maximum value can be higher than what can be withdrawn in the current transaction if market liquidity is insufficient.

  • Previews and conversions use cached _totalAssets. They are estimates, not settlement guarantees. State-changing entrypoints accrue markets before routing.

  • rebalance() is the normal path for changing allocation percentages. User deposits and exits route against the current allocation.

  • Allocation bounds protect rebalances against state changes between offchain computation and onchain execution.

  • The optimizer validates post-rebalance allocations against configured caps.

  • Performance fees are taken only on yield above the high watermark.

  • Optimizer-specific state-changing entrypoints use nonReentrant.

  • Idle underlying sitting directly on the optimizer is treated as excess and can be recovered by the DAO through skim().

Integration Considerations

  • Treat each Earn Vault as a vault for one underlying asset. Do not send a different token to deposit() or mint().

  • Load vault, underlying, cToken, and reader addresses from the deployment registry for the chain you are on. Do not hardcode live addresses in reusable integrations.

  • Check asset() before building a deposit form so the frontend uses the right ERC20 decimals and allowance target.

  • Check maxDeposit(receiver) and maxMint(receiver) before showing deposit availability. Both return 0 when deposits are not available.

  • Check previewDeposit(assets) or previewMint(shares) for estimates, but label them as estimates because state-changing paths accrue and route through cToken markets.

  • Check maxWithdraw(owner) and maxRedeem(owner) before showing exit availability, then handle LendingOptimizer__InsufficientLiquidity() on execution.

  • For fresh share price, use exchangeRateUpdated() or call accrueIfNeeded() before exchangeRate(). Both fresh paths update state.

  • Use OptimizerReader.getOptimizerMarketData() for market-level views when a state update is acceptable. It calls exchangeRateUpdated() on each optimizer.

  • Use OptimizerReader.getOptimizerUserData() when only user share balance and cached redeemable assets are needed.

  • Use OptimizerReader.optimalRebalance(optimizer, slippageBps) to construct rebalance() calldata, then review the returned actions and bounds before submission.

User Interaction Functions

Structs

Contract: LendingOptimizer.sol

ReallocationAction.assetsOrBps has two meanings:

  • In rebalance(), positive values deposit assets and negative values withdraw assets.

  • In removeApprovedAsset(), values are positive BPS percentages and must sum to 10000.

AllocationBound values are BPS percentages and must match the current approved market order required by the function being called.

Deposits

deposit()

Description: Deposits an exact amount of underlying assets and mints optimizer shares to receiver.

Contract: LendingOptimizer.sol

Function signature:

Inputs:

Type
Name
Description

uint256

assets

Amount of underlying assets to deposit.

address

receiver

Account that receives optimizer shares.

Return data:

Type
Name
Description

uint256

shares

Optimizer shares minted to receiver.

Events:

Caller must approve the optimizer to spend at least assets of the underlying token. Reverts if assets == 0, the optimizer is uninitialized, deposits are paused, any approved market has minting paused, or the tracked deposit value mints zero shares. Deposits route pro-rata across current market balances.

mint()

Description: Mints an exact amount of optimizer shares by depositing the required amount of underlying assets.

Contract: LendingOptimizer.sol

Function signature:

Inputs:

Type
Name
Description

uint256

shares

Exact optimizer shares to mint.

address

receiver

Account that receives optimizer shares.

Return data:

Type
Name
Description

uint256

assets

Underlying assets pulled from the caller.

Events:

Caller must approve the optimizer to spend the required underlying assets. Reverts if shares == 0, the optimizer is uninitialized, deposits are paused, any approved market has minting paused, or cToken rounding makes the tracked assets insufficient for the requested shares.

Withdrawals

withdraw()

Description: Withdraws an exact amount of underlying assets to receiver and burns the required optimizer shares from owner.

Contract: LendingOptimizer.sol

Function signature:

Inputs:

Type
Name
Description

uint256

assets

Exact underlying assets to withdraw.

address

receiver

Account that receives the underlying assets.

address

owner

Account whose optimizer shares are burned.

Return data:

Type
Name
Description

uint256

shares

Optimizer shares burned from owner.

Events:

If msg.sender != owner, the caller must have optimizer-share allowance from owner.

Reverts if assets == 0, any approved market has redemptions paused, or total available cToken liquidity cannot satisfy the withdrawal.

redeem()

Description: Burns an exact amount of optimizer shares from owner and sends the withdrawn underlying assets to receiver.

Contract: LendingOptimizer.sol

Function signature:

Inputs:

Type
Name
Description

uint256

shares

Exact optimizer shares to burn.

address

receiver

Account that receives the underlying assets.

address

owner

Account whose optimizer shares are burned.

Return data:

Type
Name
Description

uint256

assets

Underlying assets sent to receiver.

Events:

If msg.sender != owner, the caller must have optimizer-share allowance from owner. Reverts if the redeemed shares convert to zero assets, any approved market has redemptions paused, or total available cToken liquidity cannot satisfy the redemption.

Preview and Limit Views

Contract: LendingOptimizer.sol

Function
Description

function previewDeposit(uint256 assets) public view returns (uint256 shares);

Returns the cached-share estimate for an asset deposit.

function previewMint(uint256 shares) public view returns (uint256 assets);

Returns the cached-asset estimate for exact share minting.

function previewWithdraw(uint256 assets) public view returns (uint256 shares);

Returns the cached-share estimate for an asset withdrawal.

function previewRedeem(uint256 shares) public view returns (uint256 assets);

Returns the cached-asset estimate for share redemption.

function maxDeposit(address) public view returns (uint256);

Returns type(uint256).max when deposits are active and no approved market has minting paused, otherwise 0.

function maxMint(address) public view returns (uint256);

Returns type(uint256).max when deposits are active and no approved market has minting paused, otherwise 0.

function maxWithdraw(address owner) public view returns (uint256);

Returns convertToAssets(balanceOf(owner)) unless any approved market has redemptions paused, then returns 0.

function maxRedeem(address owner) public view returns (uint256);

Returns balanceOf(owner) unless any approved market has redemptions paused, then returns 0.

Preview and conversion functions read cached _totalAssets. maxWithdraw() and maxRedeem() do not fully model cToken idle liquidity. Execution can still revert if the approved markets cannot supply the exit.

Accounting and Market Views

Contract: LendingOptimizer.sol

Function
Description

function asset() public view returns (address);

Returns the underlying ERC20 asset.

function name() public view returns (string memory);

Returns the optimizer share token name.

function symbol() public view returns (string memory);

Returns the optimizer share token symbol.

function balanceOf(address owner) public view returns (uint256);

Returns an account's optimizer share balance.

function totalAssets() public view returns (uint256);

Returns cached _totalAssets.

function convertToShares(uint256 assets) public view returns (uint256 shares);

Converts assets to optimizer shares using cached accounting.

function convertToAssets(uint256 shares) public view returns (uint256 assets);

Converts optimizer shares to assets using cached accounting.

function exchangeRate() public view returns (uint256);

Returns cached share price in WAD. Returns WAD if supply is zero.

function exchangeRateUpdated() public returns (uint256);

Accrues approved markets, charges fees if applicable, and returns the updated share price in WAD.

function accrueIfNeeded() external;

Accrues approved markets and absorbs yield into optimizer accounting.

function numApprovedMarkets() external view returns (uint256);

Returns the approved market count.

function getApprovedMarkets() external view returns (address[] memory);

Returns the approved market list.

function approvedCTokensList(uint256 index) external view returns (address);

Returns the approved market at index.

function allocationCaps(address cToken) external view returns (uint256);

Returns the market allocation cap in WAD.

function fee() external view returns (uint256);

Returns the performance fee in BPS.

function exchangeRateHighWatermark() external view returns (uint256);

Returns the fee high watermark in WAD.

function mintPaused() external view returns (uint8);

Returns 0, 1, or 2 for uninitialized, active, or paused deposit state.

function MAX_FEE_BPS() external view returns (uint256);

Returns the maximum allowed performance fee in BPS.

function MAX_MARKETS() external view returns (uint256);

Returns the maximum number of approved markets.

function centralRegistry() external view returns (ICentralRegistry);

Returns the central registry used by the optimizer.

Events:

exchangeRateUpdated() and accrueIfNeeded() are state-changing. Use exchangeRate() when a cached view is acceptable.

Last updated

Was this helpful?