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_MARKETSapprovedBorrowableCTokenmarkets 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 beforedeposit()ormint().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 throughactionsPaused(cToken)and reads market-wide redemption pause state throughredeemPaused().ICentralRegistry: Permission checks come from the central registry. Market permissions govern setup and market management, harvester or market permissions govern rebalancing, DAO permissions governskim(), 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:
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
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.
The constructor validates that the approved market list is non-empty, has at most
MAX_MARKETSentries, has no duplicates, and has a matching cap for each market.Each cToken must use the optimizer's underlying asset, be borrowable, point to a registered Market Manager, and be listed in that Market Manager.
The initial caps must each be greater than
0, no more than10000BPS, and sum to at least10000BPS.A market-permissioned address calls
initializeDeposits(targetMarket).initializeDeposits()pulls the base underlying reserve from the caller, deposits it into the target market, mints dead shares toaddress(0), and changesmintPausedfrom0to1.
Deposits and Mints
The user approves the optimizer to spend the underlying asset.
The user calls
deposit(assets, receiver)ormint(shares, receiver).The optimizer rejects zero-sized deposits or mints, uninitialized vaults, paused vault deposits, and deposits when any approved market has minting paused.
The optimizer accrues all approved markets before routing the user action.
The optimizer pulls underlying from the caller.
The optimizer splits the deposit pro-rata across current market balances and deposits into the approved cToken markets.
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
The user calls
withdraw(assets, receiver, owner)orredeem(shares, receiver, owner).If the caller is not the
owner, the optimizer spends optimizer-share allowance fromowner.The optimizer rejects zero-asset exits and rejects exits while any approved market has redemption paused.
The optimizer accrues all approved markets before routing the exit.
The optimizer withdraws pro-rata across approved markets, capped by each market's idle liquidity.
If total idle liquidity across markets cannot satisfy the requested exit, the transaction reverts.
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
An offchain keeper or bot computes one
ReallocationActionper approved market, usually throughOptimizerReader.optimalRebalance().An authorized harvester or market-permissioned address calls
rebalance(actions, bounds).The optimizer accrues all markets and charges any performance fee before rebalancing.
The optimizer processes withdrawals first. Negative
assetsOrBpsvalues are withdrawals.The optimizer processes deposits second. Positive
assetsOrBpsvalues are deposits.Declared withdrawal assets must equal declared deposit assets.
The optimizer verifies that every post-rebalance market allocation is at or below its allocation cap and within the caller-specified
AllocationBound.The optimizer updates
_totalAssetsfrom the post-rebalance balances and emitsRebalanced.
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
A market-permissioned address calls
removeApprovedAsset(cTokenToRemove, removeActions, bounds).The optimizer requires more than one approved market and requires an explicit reallocation plan.
The market being removed must be approved and must not be paused for redemptions.
Remaining allocation caps must still sum to at least
100%.The optimizer redeems its full cToken position from the removed market.
The optimizer redistributes redeemed assets according to positive BPS values in
removeActions; the values must sum to exactly10000.Reallocation targets must be approved markets, cannot be the removed market, cannot be duplicated, and cannot have minting paused.
The approved market list uses swap-and-pop removal, so post-removal ordering can change.
boundsmust 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(),mintPausedis0,maxDeposit()andmaxMint()return0, 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()ormint().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)andmaxMint(receiver)before showing deposit availability. Both return0when deposits are not available.Check
previewDeposit(assets)orpreviewMint(shares)for estimates, but label them as estimates because state-changing paths accrue and route through cToken markets.Check
maxWithdraw(owner)andmaxRedeem(owner)before showing exit availability, then handleLendingOptimizer__InsufficientLiquidity()on execution.For fresh share price, use
exchangeRateUpdated()or callaccrueIfNeeded()beforeexchangeRate(). Both fresh paths update state.Use
OptimizerReader.getOptimizerMarketData()for market-level views when a state update is acceptable. It callsexchangeRateUpdated()on each optimizer.Use
OptimizerReader.getOptimizerUserData()when only user share balance and cached redeemable assets are needed.Use
OptimizerReader.optimalRebalance(optimizer, slippageBps)to constructrebalance()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 to10000.
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:
uint256
assets
Amount of underlying assets to deposit.
address
receiver
Account that receives optimizer shares.
Return data:
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:
uint256
shares
Exact optimizer shares to mint.
address
receiver
Account that receives optimizer shares.
Return data:
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:
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:
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:
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:
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 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 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?