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 06 · Hedged vaults & trust model

Hedged vaults & trust model

The hedge loop, what the hedger key can and cannot do, and the two conditions that pause a vault.

An LP position is long the asset whether you wanted to be or not. HedgedLPVault is an LPVault whose delta is shorted on Lighter by an off-chain bot, so depositors keep the fee income and lose most of the price exposure. This is the one part of Kerf with a trust assumption in it, and the assumption is exactly this: the perp lives on a venue this chain cannot read, so the vault believes what one named key tells it the hedge is worth, and that number is part of the share price. Three things bound the key - a jump band on each report, a staleness pause that needs no transaction, and one fixed address it can pay - and the beta cap bounds what the three cannot.

What the hedger key can do

The vault names one address as hedger at initialisation. On chain it has two functions and no others; off chain it holds the Lighter order key for the vault's account through the signer. Three things, then:

fundHedge(assetAmount) converts that much of the vault's WETH into USDG and posts it as margin for the vault's own Lighter account. A deposit puts everything to work in the LP position, so there is normally no idle WETH to convert; fundHedge takes what it needs out of the position first, through the same _liquidate a withdrawal uses, sells it on the usdgPool fixed at initialisation with amountOutMinimum set maxSlippageBps under the quote at spot, approves the Lighter deposit contract for exactly usdgOut, calls deposit(usdg, usdgOut), and clears the approval. hedgeCollateralUsd() counts what was delivered and HedgeFunded(usdgOut) reports it. Two things about it belong in the open. It runs no TwapGuard - the LP unwind and the swap are bounded only by maxSlippageBps - and there is no cap on assetAmount beyond the vault's own holdings, so the beta cap is the bound. Nothing in the API calls it: the hedger bot only reports, so funding is an operator action taken with the same key.

Trading the account. The signer holds each account's Lighter order key, Fernet-encrypted, and signs what the bot asks for. An order key can open, close and resize positions in the account. It cannot withdraw from it.

reportHedge(notionalUsd, unrealizedPnlUsd, equityUsd, asOf) tells the vault what the position is worth. Every USD quantity is 6-decimal, like USDG (USD_DECIMALS); notionalUsd is signed and negative for a short; equityUsd is unsigned; asOf is unix seconds. The report is stored and equityUsd, converted through Chainlink ETH/USD, becomes part of totalAssets(). The gate on it is the whole of The report gate.

fundHedge is refused while the vault is paused, because a vault that has stopped believing its hedge reports should not be sending the hedge more collateral. The two pause reasons revert separately there - EquityJump when the last report was out of band, HedgePaused_ when reporting has simply gone stale - because the bot reading the revert needs to know which of its own problems it is looking at. Funding also makes totalAssets() dip by the funded amount until the next report brings the equity back into the count, so the operator funds and reports together.

What it cannot do

It cannot withdraw. There is no function on the vault that moves user funds to the hedger, fundHedge can pay only the one lighterDeposit address fixed at initialisation and only for this vault's own account, and the Lighter side cannot help: a Lighter withdrawal needs a signature from the account's wallet, the account's address is the vault's, and a contract has no private key. When USDG does come back it arrives as a plain ERC-20 transfer to the vault, where it is simply idle asset again - there is nothing to accept, nothing to redirect, and no function that could send it anywhere else.

That cuts both ways. The collateral cannot be stolen by the key, and it also cannot be pulled back on a schedule anyone on chain controls. _liquidAssets() is overridden to return the on-chain value alone - the LP position and the idle balances - so a withdrawal sizes the fraction of the position it removes against that, not against totalAssets(). While a large share of the NAV sits in the hedge, an exit larger than the on-chain holdings reverts Shortfall until the hedger has repatriated USDG to the vault. That is a real limitation and the chapter states it rather than hiding it.

The residual risk is not theft. It is a key that is compromised, or simply broken, losing money by trading badly, and that is bounded by the collateral in the Lighter account. And it is a key that reports dishonestly, which the next two sections are about.

Initialisation

VaultFactory.createHedgedVault(HedgedInit) clones the hedged implementation, calls initializeHedged, grows the LP pool's observation ring, and records the vault with kind 1. The struct is named HedgedInit because LPVault.Init is inherited into the contract's scope and Solidity will not let the two share a name.

HedgedLPVault.HedgedInit
ParameterRangeDefaultMeaning
baseLPVault.Init-Everything a plain vault needs. base.asset must equal base.weth, otherwise NotAsset: the hedge equity is priced through an ETH/USD feed, so the share unit has to be ETH.
hedgeraddressthe deploy HEDGER envThe only caller of fundHedge and reportHedge. No other powers.
usdgaddress-The collateral token the Lighter account is margined in.
usdgPoola v3 pool of asset and usdgWETH/USDG 0.05 % (seed)The pool fundHedge sells WETH through. Any other pair reverts NotAsset.
lighterDepositaddress-The Lighter deposit contract. The only address fundHedge can pay.
ethUsdFeedAggregatorV3-Chainlink ETH/USD, used to turn equityUsd into WETH.
maxHedgeAgeuint32 seconds6 hours (MAX_HEDGE_AGE)How old the last report may be before paused() is true with no transaction.
maxEquityJumpBpsuint16 bps2000 (MAX_EQUITY_JUMP_BPS)How far one report may move equity before the vault pauses.

maxHedgeAge and maxEquityJumpBps are per-vault parameters, not constants. The seed deploy in KerfWiring sets both hedged vaults to MAX_HEDGE_AGE = 6 hours and MAX_EQUITY_JUMP_BPS = 2000; the unit tests use one hour and 1500 to keep their clocks short; hedgeConfig() on any vault returns the live pair together with the USDG, deposit-contract and feed addresses, and that view, not a constant in any package, is the number to believe. The two seed vaults are Kerf NVDA Hedged (kNVDAh) and Kerf SPY Hedged (kSPYh), each on the deepest WETH pool of its token across the four fee tiers, with the same CURVE policy and 5 WETH cap as the plain vaults; a token or pool that is missing at deploy time is logged and skipped, and the manifest simply omits the vault.

The hedge loop

The hedge loopSolid arrows are calls, dashed are reads. The hedger key funds one fixed address and reports; the dashed path from the Lighter account back to the vault needs a wallet signature the vault cannot produce, so only the hedger can start it and only the vault can receive it.
off chain · the server keysRobinhood Chain 4663eth_callPOST /ordersigned orderpositions · accountexactInputSingleUSDGcredits the accountreportHedgeequityUsdlatestRoundDatawithdraw: wallet sigread the vault positionpositionTokenId · slot0lpDelta · hedgeOrderstoken0 held at this pricedead band · lot · one orderPerpVenue.placeOrdermarket · reduceOnly buyvenue positions + accountcurrent short · pnl · equitysimulate → reportHedge15 min, or 200 bps driftno bounty: sends if it simulatesWhat the key can dofundHedge to one address,trade the account, report.It cannot withdraw, and itcannot send USDG anywherebut the deposit contract.A compromised key can losethe collateral by tradingbadly; that is the bound.fundHedge · hedger keySignerholds the order keyLighter accountaddress = the vaultNPM · poolpositions(tokenId) · slot0()fundHedge(assetAmount)hedger only · not while pausedWETH pulled from the positionWETH/USDG pool → USDGminOut = quote − maxSlippageBpsLighter deposit contractthe only address it can paydeposit(usdg, usdgOut)HedgedLPVault.reportHedgenotional · pnl · equity · asOfhedger only · HedgeReportedtotalAssets()LP + idle + hedge equity in WETHChainlink ETH/USDdead feed → hedge counts as 0USDG back at the vaulta plain transfer · idle asset

The hedger is one of the three bots that run inside the API process when BOTS names it, ticking every HEDGER_INTERVAL_MS (30 s by default). It works every manifest vault whose name contains "Hedged" plus anything in HEDGED_VAULTS, and for each one:

  1. 01
    Read the position
    Two multicalls: positionTokenId() and pool() on the vault, then positions(tokenId) on the NonfungiblePositionManager and slot0() on the pool. A vault with no position, or no liquidity, is skipped.
  2. 02
    Size the target short
    Core's lpDelta is the position's current token0 balance. token1 is the numeraire in every Kerf pool (WETH or USDG), so all price exposure sits in the token0 leg; shorting exactly that many token0 on the perp neutralises it at this instant. Below the range the position is all token0 and the target is the whole of it; above the range it is all token1 and the target is zero.
  3. 03
    Find the market and the current short
    The market comes from VAULT_MARKETS if it names this vault, otherwise from the Lighter symbol for token0. The venue's open positions give the short already held; a long leg is clamped to zero, because the hedger never opens longs and a long simply means under-hedged.
  4. 04
    Decide whether this tick acts
    Drift is the gap between target and current short, in bps of the target. The tick acts if the report cadence is due (HEDGE_REPORT_INTERVAL_MS, 15 minutes) or drift is above HEDGE_DRIFT_BPS (200). Otherwise it logs "within band" and does nothing.
  5. 05
    Place at most one order
    Core's hedgeOrders applies the dead band (HEDGE_MIN_NOTIONAL_USD, 50, converted at the mark) and rounds down to the lot (HEDGE_LOT_SIZE, 0.001), and returns one market order or none: sell to grow the short, buy to shrink it, and every buy is reduceOnly so reducing a short can never flip the account long by accident.
  6. 06
    Report
    Positions and account are read back from the venue; notional is signed (a short is negative), pnl summed, equity floored at zero, asOf the bot's clock. The write goes through the same simulate-then-send path as every bot transaction, with expectedBountyWei: null: reporting pays nothing, it is the price of running the vault, so it sends whenever it simulates.

The delta of a concentrated position is not a constant. As the price walks through the range the position converts between token0 and token1 continuously, so the short is always a little behind the target, and what the delta chip on the vault card shows is that residual: hedgeDelta in lib/yield/view.ts reads lpValueUsd + notionalUsd over lpValueUsd, green at 5 % or under. The header comment in bots/hedger.ts still says the staleness bound is one hour; the deployed vaults say six, and hedgeConfig() is the source.

The report gate

reportHedge and paused()Every report that passes the two reverts is stored, jump or not. A jump pauses until an in-band report follows; staleness pauses with no transaction at all. The same tree is StructuredVault.reportSleeve, minus notional.
noyesnoyesyesnoyesnoyesnoyesnoreportHedge(notional, pnl, equity, asOf)msg.sender == hedger?revert NotHedgerasOf in (last, now]?forward, not futurerevert StaleReportstore · HedgeReportedevery report is storedfirst report?lastReportAt == 0no band · not paused|Δequity| × 10 000 >previous × maxEquityJumpBps?HedgePaused("jump")_hedgePaused = true, oncein bandHedgeResumed if it was pausedThe deploy valuesmaxHedgeAge = 6 h andmaxEquityJumpBps = 2000,per vault (HedgedInit).hedgeConfig() returnsthe live pair; the testsuite uses 1 h / 1500.paused()_hedgePaused?paused: no entry or exitreported at least once andnow − last > maxHedgeAge?openStaleness needs no txA vault never reportedon is not stale. A reportof zero equity is a realreport: it must still berenewed, and every non-zero equity after it isa jump against zero.

reportHedge has exactly two reverts and never refuses a number. NotHedger if the caller is not the hedger. StaleReport if asOf is later than block.timestamp or not later than the previous report's asOf, so reports move forward in time and cannot be dated into the future. Everything else is stored - all four fields and asOf - and HedgeReported is emitted, whether or not the report is a jump. Hiding a real loss would be worse than showing it.

The jump test runs against the previous stored equity, and only when there is one:

jumped = lastReportAt != 0
      && abs(equityUsd − previous) × 10 000 > previous × maxEquityJumpBps

Whether a previous report exists is lastReportAt, never lastEquityUsd. A reported equity of zero is a real answer - a sleeve that was closed or liquidated - and treating it as "no report yet" would let a hedger disarm the band by reporting zero and then reporting any number at all. Measured against zero, every non-zero equity is a jump, and test_aZeroEquityReportDoesNotDisarmTheJumpBand holds the vault to that. On the first report there is nothing to measure against and nothing pauses.

A jump sets _hedgePaused and emits HedgePaused("jump"), once; a report inside the band while paused clears it and emits HedgeResumed. paused() is then:

paused() = _hedgePaused
        || (lastReportAt != 0 && block.timestamp − lastReportAt > maxHedgeAge)

Staleness needs no transaction to take effect, which is the point: a hedger that stops reporting cannot leave a stale price standing. A vault that has never been reported on is not stale; one whose only report said "the sleeve is worth zero" is, because that was still a report and it still has to be renewed. hedgeState() returns the four stored fields and paused() together.

While paused, _deposit and _withdraw both revert HedgePaused_. Pausing withdrawals as well as deposits is the uncomfortable half and it is the right way round: a vault that cannot price its hedge cannot work out what a redeeming share is worth, and letting people out at a made-up price is how the last person left holding shares gets robbed. Note that maxDeposit() is not overridden here and keeps answering the cap headroom while paused; the deposit reverts anyway, and the card and the drawer say why first (Paused: stale report or Paused: hedge report out of bounds, from hedgeStatus in lib/yield/view.ts).

totalAssets() is the plain vault's figure plus hedgeEquityInAsset():

hedgeEquityInAsset = equityUsd × 1e18 × 10^feedDecimals / (answer × 1e6)

A feed that does not answer, or answers with a non-positive price, values the hedge at zero rather than reverting. Reverting would freeze withdrawals on an oracle outage; under-counting only makes exits cheaper for whoever leaves first, and the staleness pause is the thing that actually stops people trading against a broken hedge. The feed's own updatedAt is not checked - a stale Chainlink round is believed at face value.

The slow walk

The band bounds how far one report may move the equity, not how far a sequence of reports may. A hedger reporting a drifting equity that stays inside 20 % on every step can walk the share price anywhere over enough reports - four in-band steps of 20 % are a doubling - and a report only has to carry an asOf later than the last one, so there is no rate limit on the steps. The contract header says so in as many words, and says where the answer is: not in a larger contract, but in the caps. With 5 WETH in a vault, the most a dishonest or broken key can misprice is 5 WETH of shares, and no key can raise that number. It is also why the caps are immutable: raising one means a new vault, not a signature.

The Lighter interface is unverified

fundHedge makes one call against Lighter, ILighterDeposit.deposit(address token, uint256 amount), which is the signature the design spec assumes. The interface file and the contract header both say it has not been checked against the deposit contract deployed on Robinhood Chain, and it must be before any mainnet wiring: a mismatch would make fundHedge revert in the best case and send USDG somewhere it cannot be recovered from in the worst. The vault approves the exact amount and clears the approval afterwards, so a reverting deposit leaves nothing standing. The unit tests run against MockLighterDeposit.

Errors

ErrorRaised byWhen
NotAssetinitializeHedgedbase.asset is not WETH, or usdgPool is not the asset/USDG pair.
NotHedgerfundHedge, reportHedgeThe caller is not the hedger.
EquityJumpfundHedgeThe last report was outside the band and no in-band report has followed.
HedgePaused_fundHedge, deposit, withdrawpaused() is true. On fundHedge, specifically the stale case.
StaleReportreportHedgeasOf is in the future or not later than the last report.
ShortfallwithdrawThe exit needs more than the on-chain holdings can realise; the hedge equity is not reachable from here.
everything in VaultsinheritedThe guard, the cap, the keeper calls.

Events

EventWhen
HedgeFunded(usdgAmount)fundHedge, with the USDG that actually reached the deposit contract.
HedgeReported(notionalUsd, unrealizedPnlUsd, equityUsd, asOf)Every accepted report, jump or not.
HedgePaused(reason)The first jump after an in-band state; reason is "jump". Staleness emits nothing, because it is a view.
HedgeResumed()The first in-band report after a jump.
Read the code
  • contracts/src/HedgedLPVault.sol the header on what the hedge is on chain, fundHedge, reportHedge, paused, hedgeEquityInAsset, _liquidAssets
  • contracts/src/interfaces/lighter/ILighterDeposit.sol the one assumed Lighter call and why it is unverified
  • contracts/script/KerfWiring.sol MAX_HEDGE_AGE, MAX_EQUITY_JUMP_BPS and the two seed hedged vaults
  • packages/core/src/hedge.ts lpDelta and hedgeOrders, the dead band and the lot
  • apps/api/src/bots/hedger.ts the tick: read, size, order, report
  • apps/api/src/config.ts HEDGER_INTERVAL_MS, HEDGE_REPORT_INTERVAL_MS, HEDGE_DRIFT_BPS, HEDGE_MIN_NOTIONAL_USD, HEDGE_LOT_SIZE, HEDGED_VAULTS, VAULT_MARKETS
  • contracts/test/HedgedLPVault.t.sol every gate: the three pause cases, the zero-equity report, funding while paused, the broken feed
  • apps/web/lib/yield/view.ts hedgeStatus and hedgeDelta, the chip on the card