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.
| Parameter | Range | Default | Meaning |
|---|---|---|---|
base | LPVault.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. |
hedger | address | the deploy HEDGER env | The only caller of fundHedge and reportHedge. No other powers. |
usdg | address | - | The collateral token the Lighter account is margined in. |
usdgPool | a v3 pool of asset and usdg | WETH/USDG 0.05 % (seed) | The pool fundHedge sells WETH through. Any other pair reverts NotAsset. |
lighterDeposit | address | - | The Lighter deposit contract. The only address fundHedge can pay. |
ethUsdFeed | AggregatorV3 | - | Chainlink ETH/USD, used to turn equityUsd into WETH. |
maxHedgeAge | uint32 seconds | 6 hours (MAX_HEDGE_AGE) | How old the last report may be before paused() is true with no transaction. |
maxEquityJumpBps | uint16 bps | 2000 (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 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:
- 01Read the positionTwo multicalls:
positionTokenId()andpool()on the vault, thenpositions(tokenId)on the NonfungiblePositionManager andslot0()on the pool. A vault with no position, or no liquidity, is skipped. - 02Size the target shortCore's
lpDeltais 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. - 03Find the market and the current shortThe market comes from
VAULT_MARKETSif 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. - 04Decide whether this tick actsDrift 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 aboveHEDGE_DRIFT_BPS(200). Otherwise it logs "within band" and does nothing. - 05Place at most one orderCore's
hedgeOrdersapplies 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 isreduceOnlyso reducing a short can never flip the account long by accident. - 06ReportPositions and account are read back from the venue; notional is signed (a short is negative), pnl summed, equity floored at zero,
asOfthe bot's clock. The write goes through the same simulate-then-send path as every bot transaction, withexpectedBountyWei: 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 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
| Error | Raised by | When |
|---|---|---|
NotAsset | initializeHedged | base.asset is not WETH, or usdgPool is not the asset/USDG pair. |
NotHedger | fundHedge, reportHedge | The caller is not the hedger. |
EquityJump | fundHedge | The last report was outside the band and no in-band report has followed. |
HedgePaused_ | fundHedge, deposit, withdraw | paused() is true. On fundHedge, specifically the stale case. |
StaleReport | reportHedge | asOf is in the future or not later than the last report. |
Shortfall | withdraw | The exit needs more than the on-chain holdings can realise; the hedge equity is not reachable from here. |
| everything in Vaults | inherited | The guard, the cap, the keeper calls. |
Events
| Event | When |
|---|---|
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. |
contracts/src/HedgedLPVault.solthe header on what the hedge is on chain, fundHedge, reportHedge, paused, hedgeEquityInAsset, _liquidAssetscontracts/src/interfaces/lighter/ILighterDeposit.solthe one assumed Lighter call and why it is unverifiedcontracts/script/KerfWiring.solMAX_HEDGE_AGE, MAX_EQUITY_JUMP_BPS and the two seed hedged vaultspackages/core/src/hedge.tslpDelta and hedgeOrders, the dead band and the lotapps/api/src/bots/hedger.tsthe tick: read, size, order, reportapps/api/src/config.tsHEDGER_INTERVAL_MS, HEDGE_REPORT_INTERVAL_MS, HEDGE_DRIFT_BPS, HEDGE_MIN_NOTIONAL_USD, HEDGE_LOT_SIZE, HEDGED_VAULTS, VAULT_MARKETScontracts/test/HedgedLPVault.t.solevery gate: the three pause cases, the zero-equity report, funding while paused, the broken feedapps/web/lib/yield/view.tshedgeStatus and hedgeDelta, the chip on the card