A Levee vault takes your tokens, runs a liquidity position for you and gives you shares that track your part of it. This chapter covers the vaults that do only that: LPVault, which manages one Uniswap v3 position and takes a single token, and the two vaults on Levee's own pools, DlmmVault and StockVault, which take both tokens of their pool. Hedged vaults and structured notes are built on LPVault and have chapters of their own.
Choosing a vault
The /earn page lists every kind in one grid, with a filter per kind. The three in this chapter differ in what you bring and in who keeps the position centred.
LPVault | DlmmVault | StockVault | |
|---|---|---|---|
| Position | One Uniswap v3 range | activeId ± halfWidth bins of a Levee DLMM pair | A base range and a one-sided limit range in a stock hook pool |
| You bring | One token, the vault's asset | Both tokens in the vault's mix, or one token through the zap | Both tokens in the vault's mix |
| Share price | Holdings valued at pool spot | Active bin price for the first deposit, then none: each deposit is an exact slice | Oracle price for the first deposit, then none |
| Re-centred by | Anyone, through rebalance() | The factory's keeper or the protocol owner | The factory's keeper or the protocol owner |
| Fee to Levee | 10 % of harvested fees | None of its own | None of its own |
| Created by | Anyone, through VaultFactory | The protocol owner | The protocol owner |
Depositing one token
You call deposit(assets, receiver) with the vault's asset. The vault checks its cap, if it has one, works out your shares with previewDeposit, runs the price check, takes the tokens and mints. Shares carry three more decimals than the asset, and the first deposit into an empty vault mints 1 000 share units per asset unit, so 2 ether of WETH buys 2 000 ether of share units, which a wallet shows as 2 shares. The offset is OpenZeppelin's defence against share inflation: an attacker who donates to a near-empty vault to round the next deposit down loses about a thousand times what they can take.
Your tokens do not go into the position straight away. _deployIdle adds the vault's idle balance only once it is worth at least the larger of 0.5 % of totalAssets() and one thousandth of a unit (0.001 WETH, or 0.001 USDG). Below that, the balance waits and still counts in the share price. Above it, a vault with no position opens one centred on the current tick, policy.widthTicks wide; a vault that has one adds to it. Both paths swap the part the range cannot use and mint the balanced pair through ZapExec.
What a share is worth
totalAssets() adds up everything the vault holds, in asset, at the pool's current price:
held = idle token0 + idle token1
+ what the position would return if closed now
value = asset leg + other leg × spot price × (1 − pool fee)
The other leg is discounted by the pool fee because the vault must sell it through the pool to pay anyone out. Valued gross, the last person to leave would find a shortfall of exactly the fee tier. Uncollected trading fees are left out on purpose: counting them would make a harvest lower the share price by the protocol cut and the bounty. Leaving them out means a harvest can only add value.
After a harvest, a new depositor pays more than one unit per share. The fees the vault earned before you arrived belong to the people who were in it.
Withdrawing
withdraw(assets, ...) and redeem(shares, ...) run the same price check, burn your shares (spending an allowance if you act for someone else) and pull liquidity out of the position. The fraction removed is assets / _liquidAssets(), rounded up, so the vault takes slightly too much rather than paying one wei short; the spare stays idle. If the vault still holds too little asset, it sells the other leg through the pool with a floor of the spot quote less policy.maxSlippageBps. If even that is not enough, the call reverts Shortfall rather than pay less than the preview.
A withdrawal does not take the protocol's cut. Fees that come out with the principal stay in the vault for the remaining holders; the cut is taken only by harvest, so the same fees are never billed twice.
The price check
A position needs a price to be valued, and the pool's own spot price is the one an attacker can move inside a block. So LPVault runs TwapGuard.check(pool) before every deposit, withdrawal and rebalance. The same library guards a structured note's SGOV pool and the swaps in a hedged vault's two margin calls, so every Levee vault uses one definition of a sane price.
The check has two parts. First, the pool's observation ring must reach back at least MIN_TWAP_WINDOW (10 min); otherwise it reverts TwapWindowTooShort. Second, spot must sit within MAX_TWAP_DEVIATION_BPS (300, that is 3 % of the price) of the mean over min(30 min, available history); otherwise it reverts PriceDeviation.
The minimum window exists because a new pool's ring has one slot, rewritten in every block, so its oldest observation can be the attacker's own swap. VaultFactory grows each pool's ring to 60 slots when it creates a vault. The ring then fills one slot per block that writes to it, so 60 slots cover ten minutes only if the pool sees fewer than one such block every ten seconds. On a busier pool anyone can deepen the ring with the pool's permissionless increaseObservationCardinalityNext.
The check is skipped in one state: while the vault has no shares. Nobody can be robbed in an empty vault, and without the exception the first depositor into a vault on a fresh pool could never get in. Everyone after them waits until the ring reaches back ten minutes.
Harvest and rebalance
Two calls keep an LPVault working, and anyone may make either.
harvest() collects every fee the position has earned and splits each token separately: PROTOCOL_FEE_BPS (1000, 10 %) goes to the FeeRouter, where it all lands on the treasury because a vault never has a referrer, and HARVEST_BOUNTY_BPS (50, 0.5 %) goes to the caller. The remainder is added back to the position; idle deposits are not. It reverts NoPosition before the vault has opened a range and TooSoon within policy.minInterval of the last harvest. It runs no price check: it moves no principal, its swap is bounded by maxSlippageBps, and a guard here would only let a manipulator stop the vault being paid. Levee's keeper bot simulates harvest on each vault in the deployment manifest once per VAULT_HARVEST_INTERVAL_MS (6 h) and sends it when the simulation passes.
rebalance() closes the position and mints a new one centred on the current tick. It needs the tick past a range edge by the hysteresis, tick < tickLower − hysteresisTicks or tick ≥ tickUpper + hysteresisTicks (otherwise InRange), minInterval since the last rebalance (otherwise TooSoon), and a passing price check, because it swaps and re-centres real value. The fee part of what it collects is split exactly like a harvest, and it resets the harvest clock. shouldRebalance() answers the same questions without reverting, including the price check, so a caller can ask before paying gas.
When the vault mints, two separate bounds apply. policy.maxSlippageBps (100 in the seed vaults) limits value lost in the swap, against a spot quote. MINT_RATIO_TOLERANCE_BPS (500) limits how far the minted amounts may fall short of the balanced holdings, which drifts further the narrower the range. A narrow range with a large deposit can fail the second bound by design; the fix is a wider range.
Caps and who can create a vault
cap is set in initialize and has no setter. CapUpdated is emitted once, at creation. A mutable cap would be an admin key under another name, so a different cap means deploying a new vault. type(uint256).max (NO_CAP) means no cap: maxDeposit() and maxMint() answer type(uint256).max and the check is skipped. Any other value is a limit, maxDeposit() returns the room left under it, and the deposit drawer on /earn refuses an amount over it ("More than the vault cap allows") before you sign. The seed vaults, the hedged vaults and the graduated vaults are created with no cap, and /earn shows them as "No cap".
VaultFactory.createV3Vault is open to anyone, because a vault has no admin and can touch only its depositors' funds. It clones the implementation, initialises the clone in the same transaction so nobody else can, grows the pool's observation ring and emits VaultCreated with kind 0. Being in the factory's list is therefore not an endorsement. The app shows the vaults named in the deployment manifest and those that graduation created, and treats any other address as unverified. The seed deploy creates Levee ETH Vault (lvETH) and Levee USDG Vault (lvUSDG) on the WETH/USDG 0.05 % pool.
Vaults on Levee pools
DlmmVault and StockVault sit on Levee's own pools. After the first deposit, both take each deposit as an exact slice of everything the vault already holds, in both tokens and in the vault's current mix, so a share needs no price and moving the pool before you deposit cannot dilute anyone. Neither ever swaps, neither charges a fee of its own (the pool's swap fees compound), and both lock MIN_SHARES (1000) at the dead address on the first deposit to block share inflation. Deposits and rebalances stop while the protocol is paused; withdrawals never do.
DLMM vaults. A DlmmVault holds bins activeId ± halfWidth, filled in one of three shapes: Spot (0, even), Curve (1, deepest at the active bin) or Bid-Ask (2, deepest at the edges). deposit takes activeIdDesired and idSlippage and reverts if the active bin has moved further than that; the slice goes into each bin in the vault's own proportions, and its share of the active bin is cut to that bin's own X:Y mix (what does not fit stays idle in the vault, inside the slice you paid for), so a deposit never pays the pool a composition fee. The first deposit opens the range around the active bin and is valued at that bin's price. withdraw burns a slice of every bin and of the idle balance, with your minimum amounts.
Stock vaults. A StockVault centres a base range on the stock hook's oracle price: openHalfWidth ticks while the market is open, and the wider closedHalfWidth while it is closed and the oracle is frozen. What the base cannot take at the current price waits in a one-sided limit range next to it, on the side where it only fills at or beyond the oracle. The first deposit needs a fresh oracle and a pool price within maxDeviation of it, and is valued at the oracle price. A session change makes a rebalance due, so the range is re-laid at least twice per trading day.
Only the factory's keeper() or the protocol owner can rebalance either vault, at most once per MIN_REBALANCE_INTERVAL (5 min). A DLMM vault is due once the active bin is more than halfWidth / 2 bins from the range centre (needsRebalance()); Levee's bot also waits until that drift has held for DLMM_SUSTAIN_S (120 s) and sends with an idSlippage of 2 bins. A stock vault reports its own readiness through rebalanceStatus(), and the bot sends only on READY.
One token into a DLMM vault
DlmmVaultZap turns one token, native ETH included, into a DLMM vault deposit in a single transaction, and a withdrawal back into one token. It has no owner, holds nothing between calls and accepts only vaults the DLMM vault factory made. You choose the swap amount and the route; the contract checks that the route connects the vault's two tokens, swaps through the Levee Router, deposits both sides with your minShares, and returns what the vault did not take. zapOut redeems your shares (they need an approval), swaps the side you do not want and enforces minOut.
The app plans the split in lib/amm/zap.ts. It swaps s = A · tO / (tO + tT · r) of an input A, where tT and tO are the vault's holdings of the input and the other token and r is the route's rate at that size, quoting three times to settle r. Routes use other Levee pools between the two tokens, never the vault's own pair, whose price the split depends on. Every DLMM and stock vault write from the app carries 0.5 % minimums and a 20 min deadline.
Reference
LPVault settings
| Name | Allowed | Default | Effect |
|---|---|---|---|
pool | Uniswap v3 pool | - | The one pool the vault provides liquidity to. |
asset | token0 or token1 of the pool | - | What you deposit and what shares are priced in. Anything else reverts NotAsset. |
name / symbol | strings | Levee ETH Vault / lvETH | The share token. |
policy.widthTicks | positive multiple of the spacing ticks | 2 × 2231, snapped | Full width of each range the vault mints: 2231 ticks (a 1.25x price factor) each side, snapped down to the spacing. |
policy.hysteresisTicks | 0 or more ticks | 10 spacings | How far past an edge the tick must sit before rebalance is allowed. |
policy.minInterval | any seconds | 6 h | Least time between two harvests, and between two rebalances. |
policy.maxSlippageBps | 0 - 9999 bps | 100 | Floor on every swap the vault makes, against a spot quote. |
policy.compound / bountyBps | bool / bps | true / 50 | Recorded for bots; the vault always compounds and always pays HARVEST_BOUNTY_BPS. |
cap | asset units, or type(uint256).max | none (NO_CAP) | Deposit cap. Cannot change. type(uint256).max means no cap. |
feeRouter, npm, router, weth | addresses | - | Where the protocol cut goes, the position manager, SwapRouter02 and WETH9. |
| Name | Allowed | Default | Effect |
|---|---|---|---|
halfWidth | 1 - 50 bins | 10 (local seed) | DlmmVault range is activeId ± halfWidth; a rebalance is due past halfWidth / 2. |
shape | 0, 1, 2 | 1, Curve (local seed) | DlmmVault: Spot, Curve or Bid-Ask, for every deposit into the range and every rebalance. |
openHalfWidth | tick spacing - 10 000 ticks | 120 (local seed) | StockVault half width while the market is open. |
closedHalfWidth | tick spacing - 10 000 ticks | 300 (local seed) | StockVault half width while the market is closed. |
maxDeviation | 1 - half of each half width ticks | 50 (local seed) | StockVault waits while the pool is further than this from the oracle. |
One DLMM vault exists per pair, half width and shape, and one stock vault per stock pool; the stock vault factory refuses a pool that is not the stock hook's or that quotes in native ETH.
Reverts
| Revert | Raised by | Meaning |
|---|---|---|
CapExceeded | LPVault deposit, mint | The deposit would take totalAssets() past the cap. Never raised by a vault with no cap. |
ZeroShares | LPVault deposit, mint | The deposit would mint no shares. |
TwapWindowTooShort | LPVault deposit, withdraw, rebalance | Less than 10 min of pool history. Not raised while the vault has no shares. |
PriceDeviation | LPVault deposit, withdraw, rebalance | Spot is more than 3 % from the mean. |
Shortfall | LPVault withdraw | The unwind raised less than the withdrawal owes. Never on a full exit. |
NoPosition, TooSoon, InRange | harvest, rebalance | No range opened yet, inside minInterval, or the tick has not left the range by the hysteresis. |
NotAsset, BadPolicy, AlreadyInitialized | initialize | Wrong asset, a malformed policy, or a second initialisation. |
DlmmVault__ActiveIdSlippage | DLMM deposit, rebalance | The active bin moved more than idSlippage bins. |
DlmmVault__InsufficientShares, __AmountSlippage | DLMM deposit, withdraw | Below your minShares or minimum amounts. |
DlmmVault__NotNeeded, __TooSoon, __NotKeeper | DLMM rebalance | No drift past half the range, inside 5 min, or the wrong caller. |
StockVault__PriceDeviation, __StaleOracle | stock first deposit, rebalance | The pool is too far from the oracle, or the oracle is stale. |
DlmmVaultZap__BadRoute, __TooLittleReceived | zapIn, zapOut | The route does not connect the vault's tokens, or the output is under minOut. |
Events
LPVault emits CapUpdated(cap) once, PositionOpened(tokenId, tickLower, tickUpper) on each new range, Harvest(fees0, fees1, protocolShare0, protocolShare1, bounty0, bounty1) in pool-token units, Rebalance(tickLower, tickUpper, tokenId), and the standard ERC-4626 Deposit and Withdraw, where Withdraw reports the amount actually paid. VaultFactory emits VaultCreated(vault, pool, asset, kind, name).
contracts/src/LPVault.soltotalAssets, _deposit, _deployIdle, _withdraw, _liquidate, harvest, rebalancecontracts/src/libraries/TwapGuard.solthe two checks, the constants and why the window has a floorcontracts/src/libraries/ZapExec.solbalanceAndMint and balanceAndIncrease, and the two boundscontracts/src/VaultFactory.solcreateV3Vault, prepare at creation, vaultKindcontracts/script/LeveeWiring.solthe seed policy, NO_CAP and the two plain seed vaultscontracts/src/venue/vaults/DlmmVault.soldeposit, withdraw, rebalance, needsRebalance, the three shapescontracts/src/venue/stockvaults/StockVault.solthe base and limit ranges, rebalanceStatus, deposit and withdrawcontracts/src/venue/vaults/DlmmVaultZap.solzapIn, zapOut and the route checkapps/api/src/bots/keeper.tsthe vault harvest sweepapps/api/src/bots/dlmmVaultRebalancer.tsthe sustain wait before a DLMM rebalanceapps/web/lib/amm/zap.tshow the app plans a one-token depositapps/web/lib/earn/view.tscapPct and depositBlocker, behind the cap bar and the drawer