Kerf is a first release and may still have small bugs. Our team is building it around the clock - follow updates and new features onEarly release - small bugs possible. Updates onx.com/UseKerf
Kerf
Chapter 05 · Vaults

Vaults

ERC-4626 over one Uniswap position: pricing, the TWAP guard, caps, harvest and rebalance.

LPVault is an ERC-4626 vault around exactly one Uniswap v3 position. You deposit one token - WETH or USDG in the seed vaults - and the vault holds a single concentrated range on one pool, harvests it, re-centres it and hands you shares in the whole thing. Its share price is the position valued at the pool's own spot price, and the one fact to carry out of this chapter is that every call which mints or burns shares at that price first runs TwapGuard: spot must sit within 300 bps of a mean measured over at least ten minutes of real history, or the call reverts. The cap is set once at creation and no key can raise it.

Shares and pricing

The vault implements the standard surface - deposit, mint, withdraw, redeem and the four preview* views the app quotes from - on top of OpenZeppelin's ERC4626, with the storage-backed overrides a clone needs: every vault is an EIP-1167 clone of one implementation, so asset(), decimals(), name(), symbol() and totalAssets() read the clone's own storage rather than an immutable. Shares carry the asset's decimals exactly (decimals() returns _assetDecimals, the decimals offset stays zero), so one share of the ETH vault starts life worth one WETH and one share of the USDG vault worth one USDG.

totalAssets() is everything the vault holds, in asset, at the pool's current sqrtPriceX96:

holdings  = loose token0 + loose token1
          + the principal the position would return if closed at sqrtPriceX96
            (LiquidityAmounts.getAmountsForLiquidity)
value     = assetLeg + otherLeg × price × (1 − poolFee)

Two choices in that formula are deliberate. The non-asset leg is valued net of the pool fee, because the vault has to sell that leg through the pool to pay anyone out; valued gross, totalAssets() would be a number the vault cannot realise, and the last redeemer would watch the withdrawal revert for a shortfall of exactly the fee tier. And uncollected trading fees are excluded. Counting them would make harvest reduce totalAssets() by the protocol cut and the bounty, which is the opposite of what the accounting invariant should say; leaving them out means a harvest can only ever add.

A first deposit mints one for one (previewDeposit(2 ether) is 2 ether on an empty vault). After a harvest, a later depositor pays more than par for a share and each share converts to more than par - the fees the vault already earned belong to whoever was in it when they were earned.

asset must be one of the pool's two tokens, otherwise initialize reverts NotAsset. The seed vaults quote themselves in WETH or USDG; a vault quoted in the volatile side of its pool would have a share price that moved with that token, which is not what a depositor is buying, and HedgedLPVault goes further and refuses anything but WETH.

Deposit and withdraw

Deposit and withdrawLeft: a deposit is priced, guarded, then put to work. Right: a withdrawal is guarded, sized against the on-chain holdings and paid in the asset. Red chips are the reverts on each step.
_deposit_withdrawnoyesdeposit(assets, receiver)cap checktotalAssets() + assets vs capCapExceededshares = previewDepositassets × supply / totalAssets()ZeroShares_requirePriceSane()waived while totalSupply() == 0TwapWindowTooShortPriceDeviationpull + minttransferFrom · _mint · Depositidle ≥ threshold?max(0.5% NAV, unit/1000)stays idle_deployIdle()open or increase the rangeshares to receiverwithdraw / redeem(shares)shares = previewWithdrawor assets = previewRedeem_requirePriceSane()same guard, same two revertsTwapWindowTooShortPriceDeviationburn sharesspendAllowance if caller ≠ ownerdecrease liquidityassets / _liquidAssets(), rounded upfull exit: all of it, burn the NFTswap the other leg → assetonly if short, or on a full exitminOut = quote − maxSlippageBpsShortfallpay receiver · Withdraw eventThe final redeemerWhen shares == totalSupply the vault unwindscompletely and pays its whole realised balance,not the preview; redeem() returns what was paid.

A deposit is checked against the cap in deposit() itself, before ERC4626.deposit runs its own maxDeposit check, so overshooting reports CapExceeded rather than the generic ERC4626ExceededMaxDeposit. _deposit then refuses a zero share count (ZeroShares), runs the price guard, checks the cap once more against the freshly priced totalAssets(), pulls the tokens by transferFrom, mints, and calls _deployIdle().

_deployIdle puts loose balances into the position once they are worth the gas. The threshold is the larger of DEPLOY_THRESHOLD_BPS (50, half a percent of totalAssets()) and one thousandth of a unit (_assetUnit / MIN_DEPLOY_DIVISOR, MIN_DEPLOY_DIVISOR = 1000, which is 0.001 WETH for an 18-decimal asset and 0.001 USDG for a 6-decimal one). Below it the deposit simply sits idle, still counted in totalAssets(), until the next one tips the balance over. Above it, a vault with no position opens one centred on the current tick with the policy's widthTicks (Ranges.centred, snapped to the pool's spacing) through ZapExec.balanceAndMint, and a vault that already has one increases it with ZapExec.balanceAndIncrease. Both swap whatever the range cannot use and mint the balanced pair; the bounds on that are in Rebalance and the mint tolerance.

A withdrawal runs the same guard first. The share of the position removed is assets / _liquidAssets(), rounded up, which over-removes whenever the vault also holds idle asset - deliberately, because the leftover simply stays idle and the alternative is a withdrawal that lands one wei short. The decrease and collect happen with no fee split: the protocol's cut on trading fees is harvest's job, and taking it again on the way out would bill the same fees twice; whatever fees ride along with the principal stay in the vault, where they belong to everyone still in it. If the asset balance is still short after the decrease, the non-asset leg is sold through the pool with amountOutMinimum set maxSlippageBps under the quote at spot. If it is short even after that, the call reverts Shortfall rather than paying less than the preview promised.

withdraw and redeem can be called on someone else's shares with an allowance, exactly as in the standard; _spendAllowance runs before the burn.

The TWAP guard

Pricing a position needs a price, and the obvious source - the pool's sqrtPriceX96 - is the one an attacker controls within a block. So every deposit, withdrawal and rebalance runs TwapGuard.check(pool) before it moves value, and the library lives in contracts/src/libraries/TwapGuard.sol rather than in the vault so that the vaults, StructuredVault and PositionKeeper cannot drift apart on what "the price is sane" means.

TwapGuard.check(pool)Two questions, two reverts. The window has a floor because a mean over history the attacker wrote is spot with extra steps; the band is measured on prices, not ticks.
noyesnoyesTwapGuard.check(pool)read slot0 + the ringoldestObservationSecondsAgowindow ≥ 10 min?MIN_TWAP_WINDOWrevert TwapWindowTooShortwindow = min(30 min, avail)TWAP_WINDOW caps the lagtwap = sqrtPrice(meanTick)Oracle.consult over the window|spot − twap| ≤ 3 %?MAX_TWAP_DEVIATION_BPS = 300revert PriceDeviationSane · (spot, twap) returnedWhy the window has a floorA fresh pool has a ring of one slot, rewritten everyblock, so its oldest observation is the manipulatingswap itself and a mean over it is spot. The guardrefuses rather than average over what the attacker wrote.Who runs itLPVault: deposit, withdraw, rebalance (waived whilethe vault has no supply). Not harvest. StructuredVault:the SGOV pool on every entry, exit and settle. VaultFactorycalls prepare() to grow the ring to 60 slots.Verdictsread() returns Sane, WindowTooShort or Deviated; isSane()is what shouldRebalance() polls, so a bot is never offereda call the guard will refuse.
ConstantValueMeaning
TWAP_WINDOW30 minutesThe window the mean is measured over, when the ring reaches back that far.
MIN_TWAP_WINDOW10 minutesThe shortest history the guard will average over. Below it the verdict is WindowTooShort.
MAX_TWAP_DEVIATION_BPS300How far spot may sit from the mean, compared as prices (the sqrt prices are squared first), so the band means three percent of the quoted price.
MIN_OBS_CARDINALITY60The observation ring prepare grows a pool to.

read(pool) is the one body of maths: it takes spot from slot0, asks the ring how long ago its oldest observation was written (Oracle.oldestObservationSecondsAgo, which reads the slot after the current index, or slot zero if the ring has not wrapped yet), refuses below MIN_TWAP_WINDOW, caps the window at TWAP_WINDOW, consults the tick cumulatives for the arithmetic mean tick over that window (flooring, so a negative-tick pool does not read one tick rich), and turns it into a sqrt price. check reverts TwapWindowTooShort or PriceDeviation on the two bad verdicts and hands back both prices, so a caller that wants to value something at the mean rather than at spot can. isSane is the boolean form that shouldRebalance() polls, so a bot is never offered a call the guard is going to refuse.

The minimum window is the part that costs something, and it is there because the alternative was no defence at all. A pool's observation ring starts at cardinality one, and every write in a new block overwrites that single slot, so "the oldest observation" on a fresh pool is the attacker's own manipulating swap and a mean over it is spot. Averaging over "whatever history exists" therefore protects nothing on exactly the pool LaunchPipeline.graduate puts a vault on. The guard refuses to answer until the ring reaches back ten minutes; VaultFactory calls prepare at every creation, which grows the ring to MIN_OBS_CARDINALITY and is a no-op on a pool that is already deeper. The ring grows one slot per writing block from there, so the window opens gradually, and sixty slots cover ten minutes only if the pool sees fewer than one writing block every ten seconds. A busier pool outruns its ring, and because increaseObservationCardinalityNext is permissionless on the pool, anyone stuck behind TwapWindowTooShort can pay to deepen it - test_aBusyPoolOutrunsItsRingUntilSomeoneDeepensIt is the test of that.

The guard is waived in exactly one state: while the vault has no supply. There is nobody to steal from in an empty vault, and without the waiver the first depositor into a vault on a fresh pool could never get in. Everyone after them waits until the ring reaches back; _requirePriceSane() is two lines, if (totalSupply() == 0) return; TwapGuard.check(_pool);. An earlier version also let a ring that reached back zero seconds through, and that escape was removed on purpose: on a ring of one that "zero" was the attacker's own swap, and the check it skipped was the only one there was.

Two consequences follow. A vault refuses business during a genuine violent move - a real 5 % jump leaves spot outside the band until the thirty-minute mean catches up, and a vault knocked out of range waits up to half an hour before it may rebalance. That is the intended trade: a vault that cannot price itself honestly should not price itself at all. And harvest is the one value-moving call that skips the guard, for a reason set out below.

Caps

cap is a field of Init, measured in asset, written once by _initializeBase and never again; CapUpdated(cap) is emitted exactly once, at initialisation, and there is no setter. The header of the contract says why: a mutable cap is an admin key by another name, and the whole point of the beta caps is that nobody can lift them under pressure. Raising one means deploying a new vault.

AssetCapConstant
WETH5 WETHWETH_VAULT_CAP = 5 ether in KerfWiring
USDG15 000 USDGUSDG_VAULT_CAP = 15_000e6 in KerfWiring

The cap follows the asset, not the vault kind (_betaCap in the deploy script), so a USDG-denominated structured note, if one is ever seeded, cannot inherit a WETH-sized number. maxDeposit() answers cap − totalAssets() (zero once full) and maxMint() converts that to shares, so an integrator that asks before acting gets an honest number; a deposit that would take the vault past the cap reverts CapExceeded. The card on /yield draws the cap as a bar, the deposit drawer refuses to offer more than the room left, and its blocker text says "Over the vault cap" before you sign anything.

Harvest

harvest() is permissionless and pays the caller, like the keeper's. It refuses NoPosition before the vault has ever opened a range and TooSoon inside policy.minInterval of the last harvest (six hours on the seed vaults, MIN_INTERVAL), then collects every accrued fee from the position and splits each token separately:

ShareToHow
10 % (PROTOCOL_FEE_BPS, 1000)FeeRouterTransferred, then take(token, share, vault). The vault is its own "user" and can never bind a referral code, so the whole cut lands on the treasury.
0.5 % (HARVEST_BOUNTY_BPS, 50)msg.senderPaid directly, in both tokens.
the restthe positionZapExec.balanceAndIncrease on the existing range.

Only the fee remainder is compounded, never the idle deposits sitting next to it: rolling idle asset into the position costs a swap, and a harvest that happened to run on a quiet pool would then shrink totalAssets(). The vault ignores its policy's bountyBps (and compound) and always pays its own HARVEST_BOUNTY_BPS; a vault's depositors did not agree to an arbitrary bounty, and the field exists for the keeper bots that read policies uniformly - the seed vaults advertise BOUNTY_BPS = 50, which happens to be the same number. The event is Harvest(fees0, fees1, protocolShare0, protocolShare1, bounty0, bounty1), in pool-token units.

harvest() and rebalance()harvest compounds fees and runs no price guard; rebalance moves principal and runs the same band as a deposit. Both are permissionless and both pay the protocol out of fees alone.
noyesharvest()position · intervaltokenId ≠ 0 · minInterval passedNoPositionTooSoonnpm.collect(all)fees0, fees1: every accrued fee_splitFees per token1000 bps → FeeRouter.take50 bps → msg.senderZapExec.balanceAndIncreasethe remainder only, not idlemintToleranceBps 500Harvest(fees, protocol, bounty)No guard hereharvest moves no principal: the compound isbounded by maxSlippageBps, and gating it on theoracle would let a manipulator stop the vaultbeing paid. totalAssets() never falls across it.rebalance()position · intervalminInterval since last rebalanceNoPositionTooSoonout of range?past the edge + hysteresisInRangeTwapGuard.check(pool)the same band as a depositTwapWindowTooShortPriceDeviation_unwindAlldecrease · collect · burn the NFTfee portion split like a harvestmint the new rangeRanges.centred(tick, widthTicks)balanceAndMint · tolerance 500Rebalance(lower, upper, id)Two tolerancesmaxSlippageBps bounds the swap output;MINT_RATIO_TOLERANCE_BPS (500) bounds how far theminted amounts may sit under the balanced holdings.A narrow range plus a big deposit fails the second.

Harvest runs no price guard. It moves no principal - the compound is bounded by maxSlippageBps on the swap it makes - and gating it on the oracle would let a manipulator stop the vault being paid by holding the pool off its mean. That is the reason totalAssets() excludes uncollected fees: with that exclusion a harvest can only add, and invariant_harvestNeverLosesValue runs a handler that deposits, redeems, harvests, rebalances and swaps at random and counts any harvest that left the vault worth less than it found it (with eight wei of slack for the rounding in getAmountsForLiquidity). test_theBountyIsHalfAPercentAndTheProtocolCutIsTen checks the split to the wei.

Rebalance and the mint tolerance

rebalance() unwinds the position and mints a fresh one centred on the price, under the same policy semantics as the keeper: the tick must sit past the range edge by at least hysteresisTicks (Ranges.outOfRange: tick < tickLower − hysteresis or tick ≥ tickUpper + hysteresis), otherwise InRange; and minInterval must have passed since the last rebalance, otherwise TooSoon. shouldRebalance() answers the same questions without reverting, adds "the position has liquidity", and ends with TwapGuard.isSane, so the bot never offers a rebalance the guard will refuse.

Unlike a harvest, a rebalance is behind the price band, because it swaps and re-centres, which moves real value: a manipulated tick would have the range re-centred on a price nobody honest traded at. test_rebalanceRefusesAManipulatedPriceUntilTheTwapCatchesUp pushes the pool, watches shouldRebalance say no and rebalance revert PriceDeviation, and then sees both change their minds once the mean has moved.

The unwind (_unwindAll) decreases all the liquidity, collects, and burns the NFT. decreaseLiquidity reports the principal alone and collect pays principal plus every accrued fee, so the difference is the fee portion exactly, and _splitFees runs on that alone - the protocol cut and the bounty never touch principal. Then Ranges.centred(tick, spacing, widthTicks) picks the new range and _openPosition mints it from everything the vault holds, emitting PositionOpened and Rebalance(tickLower, tickUpper, tokenId). A rebalance also resets _lastHarvestAt, because it has just collected the fees a harvest would have.

When ZapExec mints, the amounts it can place will not exactly match the amounts it computed: the swap moved the pool a little, and tick snapping moved the ratio a little. Two separate numbers bound that, and the vault sets them differently on purpose:

BoundWhereMeasures
policy.maxSlippageBps (seed vaults 100)the router's amountOutMinimumValue lost in the swap, against a quote at spot.
MINT_RATIO_TOLERANCE_BPS (500)the mint's amount0Min / amount1MinHow far the post-swap holdings drifted from the ratio the range wants.

The second quantity grows as the range narrows - the swap moves the price, and a range a few hundred ticks wide notices - and driving both from one number, which is what ZapExec used to do, made a narrow range reject mints that had lost nothing. Five percent is loose enough that ordinary pools pass and tight enough that a rebalance cannot quietly leave a large fraction of the position idle; a narrow range plus a big deposit that fails the ratio check fails by design, and the answer is a wider range, not a looser bound.

The seed vaults all run the same policy from KerfWiring: a CURVE range of CURVE_HALF_TICKS (2231, which is ±25 %) snapped down to the pool's spacing on each side, hysteresis of HYSTERESIS_SPACINGS (10) tick spacings, MIN_INTERVAL of six hours and MAX_SLIPPAGE_BPS of 100.

Deploying one

VaultFactory.createV3Vault(Init) clones the LPVault implementation, initialises it in the same transaction - the only thing standing between a fresh clone and a stranger initialising it with their own parameters - grows the pool's observation ring with TwapGuard.prepare, records the vault, and emits VaultCreated(vault, pool, asset, kind, name) with kind 0. The implementation itself cannot be claimed: its constructor sets _initialized, so initialize on it reverts AlreadyInitialized, as does a second call on any clone.

LPVault.Init
ParameterRangeDefaultMeaning
poola Uniswap v3 pool-The one pool the vault provides liquidity to. Its token0, token1, fee and tickSpacing are read at initialisation.
assetpool.token0 or pool.token1-The token depositors bring and shares are priced in. Anything else reverts NotAsset.
namestringKerf ETH Vault (seed)ERC-20 name of the share token.
symbolstringkETH (seed)ERC-20 symbol of the share token.
policy.widthTicks> 0, multiple of spacing ticks2 × 2231 snapped (CURVE)Total width of the range a rebalance mints. Otherwise BadPolicy.
policy.hysteresisTicks≥ 0 ticks10 spacingsHow far past the edge the price must sit before rebalance is allowed.
policy.minIntervalany seconds6 hoursBetween two harvests, and between two rebalances.
policy.maxSlippageBps0 - 9999 bps100Bound on every swap the vault makes. 10 000 or more is BadPolicy.
policy.compoundbooltrueRecorded for the bots; the vault always compounds the fee remainder.
policy.bountyBpsany bps50Recorded for the bots; the vault always pays HARVEST_BOUNTY_BPS.
cap≥ 0 asset units5 ether or 15 000e6Beta deposit cap. Immutable.
feeRouteraddress-Where the protocol share of harvested fees is accounted.
npmaddress-Uniswap v3 NonfungiblePositionManager.
routeraddress-SwapRouter02.
wethaddress-Canonical WETH9. Recorded for consumers; the vault only moves ERC-20s.

Creation is permissionless - anyone may point a vault at any pool - because there is nothing to gate: a vault has no admin, holds only what its own depositors put in, and cannot touch anything else. What that means for the product is that the factory's list is a list, not a recommendation. allVaults() returns everything the factory ever made, oldest first, vaultCount() its length, and vaultKind(vault) whether it made a given address and what kind it is (0 plain, 1 hedged, 2 structured). The web shows the vaults named in the deployment manifest plus the ones LaunchPipeline graduated through the same createV3Vault, and treats the rest of the list as it would any unverified contract.

The three implementations are deployed separately and passed to the factory's constructor (ZeroAddress if any is missing) rather than created inside it: their combined creation code is past EIP-3860's 49 152-byte initcode limit, so a constructor that deployed all three could not be deployed at all. lpVaultImplementation() and its two siblings let anyone check a vault's implementation against the manifest. The seed deploy creates the ETH and USDG vaults on the canonical WETH/USDG 0.05 % pool, so the two vaults and the rest of the product agree on price.

Errors

ErrorRaised byWhen
AlreadyInitializedinitializeA second initialisation, or any initialisation of the implementation itself.
NotAssetinitializeasset is neither of the pool's tokens.
BadPolicyinitializewidthTicks not a positive multiple of the spacing, negative hysteresis, or maxSlippageBps of 10 000 or more.
CapExceededdeposit, minttotalAssets() plus the deposit would pass cap.
ZeroSharesdeposit, mintThe deposit would mint nothing.
TwapWindowTooShortdeposit, withdraw, rebalanceThe ring does not reach back MIN_TWAP_WINDOW. Waived while the vault has no supply.
PriceDeviationdeposit, withdraw, rebalanceSpot is more than MAX_TWAP_DEVIATION_BPS from the mean.
Shortfallwithdraw, redeemThe unwind realised less asset than the withdrawal owes. Never on a full exit.
NoPositionharvest, rebalanceThe vault has never opened a position.
TooSoonharvest, rebalanceInside minInterval of the last one.
InRangerebalanceThe tick is not past the edge plus the hysteresis.

Events

EventWhen
CapUpdated(cap)Once, at initialisation.
PositionOpened(tokenId, tickLower, tickUpper)The first deploy of idle funds, and every replacement range a rebalance mints.
Harvest(fees0, fees1, protocolShare0, protocolShare1, bounty0, bounty1)Every harvest, in pool-token units.
Rebalance(tickLower, tickUpper, tokenId)Every rebalance, after PositionOpened.
Deposit / WithdrawThe ERC-4626 events; Withdraw reports what was paid, not the preview.
VaultCreated(vault, pool, asset, kind, name)On the factory, at creation.
Read the code
  • contracts/src/LPVault.sol the header comment, _deposit, _withdraw, _liquidate, _deployIdle, harvest, rebalance, totalAssets
  • contracts/src/libraries/TwapGuard.sol read, check, isSane, prepare, the four constants and the reasoning for the minimum window
  • contracts/src/libraries/ZapExec.sol balanceAndMint, balanceAndIncrease, the two separate bounds
  • contracts/src/VaultFactory.sol createV3Vault, vaultKind, why the implementations are constructor arguments
  • contracts/script/KerfWiring.sol the seed policy, the caps, the two plain seed vaults
  • contracts/test/LPVault.t.sol shares, the cap, the guard on a fresh and a busy pool, harvest and rebalance
  • contracts/test/invariants/VaultAccounting.t.sol harvestNeverLosesValue and noSharesMeansNoAssets, with the random driver
  • contracts/test/fork/VaultFork.t.sol a deposit and withdraw round trip on the live WETH/USDG pool
  • apps/web/lib/yield/view.ts capPct, depositBlocker, the offline preview ratios used without a manifest