Security model
What nobody can do, what the one privileged key can, the invariants and the test suites.
Kerf's security model is built on subtraction: the functions that would let someone move a depositor's money, change a live vault's rules, lift a cap or replace a contract were never written, so there is no key that can be stolen to do them. What remains is one privileged key - the hedger's, which can fund and report a hedge and nothing else - a set of price bounds that every value-moving path runs behind, and an invariant suite that treats an unexpected revert as a failure.
Built on subtraction
Nine contracts, and not one of them has an owner. There is no Ownable, no onlyOwner, no pause switch, no proxy, no implementation slot and no rescue function. That is a design decision rather than an omission: an admin function that can reach a user's position is a key that can be phished, subpoenaed or lost, and a "pause" is an admin function by another name. The beta caps exist so that the blast radius of a bug is bounded by a number in the bytecode, not by how fast someone reacts.
The vaults are the one place this needs care, because they are EIP-1167 clones of three implementations. A clone delegates every call to an implementation address that VaultFactory holds in an immutable; nothing can point it anywhere else, and the implementation itself is inert - its constructor sets _initialized = true, so nobody can claim it as a vault. A clone's own initialize is permissionless, which is why the factory clones and initialises in the same transaction: there is never a block in which an uninitialised clone exists for a stranger to configure. Everything a vault is wired to - pool, asset, policy, cap, FeeRouter, position manager, router, feeds, the Lighter deposit contract - is written once in that call and has no setter.
What nobody can do
- Withdraw a vault to anyone but where its shares send it.
LPVault._withdrawburnsowner's shares (spending an allowance if the caller is not the owner, which is ERC-4626) and paysreceiver; there is no other transfer out of a vault.HedgedLPVault.fundHedgemoves USDG, but only to the one Lighter deposit contract fixed at initialisation, for the vault's own account. - Move a kept position anywhere but to its owner.
PositionKeeper.rebalancemints the replacement NFT toEnrollment.owner,harvestpays the remainder to the owner or compounds it into the owner's position, and the only other recipients on either path are the caller, for a bounty the owner capped atMAX_BOUNTY_BPS(300), and theFeeRouter._sweephands the owner anything left over. Both calls revert withOwnerChangedif the NFT has been transferred since enrolment, so a stale enrolment cannot pay a stale owner. - Raise a cap.
capis set ininitializeand emitted once asCapUpdated. Raising a beta cap means deploying a new vault, and that is the point: nobody can lift a cap under pressure. - Pause a zap, an order or the keeper. There is no pause anywhere in
KerfZapV3,KerfZapV4,RangeOrders,PositionKeeper,LaunchPipelineorPreMarketPerp. The only pauses in the system are the ones a hedged vault and anIncomenote put on themselves when a hedge report is out of band or stale, and they gate only that vault's deposits and withdrawals. - Upgrade anything. No proxies. The clones are minimal proxies to a fixed implementation, which is delegation, not upgradeability.
- Withdraw from Lighter without the wallet.
ILighterDeposithas one function,deposit. A Lighter withdrawal needs a signature from the account's L1 key, which a contract cannot produce and the signer never holds -POST /withdraw-intentreturns an unsigned payload and nothing inservices/signersubmits one. - Change a live vault's policy.
LPVault.policy()is whatinitializewas given. APositionKeeperpolicy is per enrolment and only the position's owner mayupdatePolicyit. - Credit a fee that was not paid.
FeeRouter.takeis permissionless and accounting-only, so it is believed only while the router's balance coversaccounted[token] + amount; a stranger reporting thin air getsFeeNotReceived.ReferralRegistrylinks are write-once in both directions: a code cannot be reclaimed and a user cannot be rebound.
The deployer has no standing power. DeployRobinhood sets treasury and hedger into immutables and initialisation parameters, and after that the deploying key is an ordinary address.
The one privileged key
The hedger's key is the only address any contract treats differently, and it is named in the manifest. It holds exactly three powers.
fundHedge(assetAmount) on a HedgedLPVault takes assetAmount of WETH out of the vault - unwinding part of the LP position if the idle balance does not cover it, exactly as a withdrawal would - swaps it to USDG through the WETH/USDG pool with the router's amountOutMinimum at policy.maxSlippageBps below the spot quote, and calls deposit on the Lighter deposit contract for the vault's own account. It can pay no other address. It is refused while the vault is paused, with two different errors so the bot can tell its own problems apart: EquityJump when the last report was out of band, HedgePaused_ when reporting simply stopped. It is not bounded in size, and the running hedger bot never calls it.
reportHedge(notionalUsd, unrealizedPnlUsd, equityUsd, asOf) tells the vault what the Lighter position is worth, in 6-decimal USD, and equityUsd goes straight into totalAssets() through the ETH/USD feed. That is the sentence to remember: the hedger's key can move the share price. Three things bound it. A report that moves equityUsd by more than maxEquityJumpBps from the previous one is stored - hiding a real loss would be worse than showing it - but it pauses the vault, and nobody enters or leaves until a report inside the band lands. A report older than maxHedgeAge pauses the vault by itself, with no transaction, so a hedger who stops reporting cannot leave a stale price standing. And asOf must move forward and cannot be dated into the future, or the call reverts StaleReport. Whether a previous report exists is judged by _lastReportAt, never by _lastEquityUsd: a reported equity of zero is a real answer, and measuring the next report against zero makes every non-zero equity a jump. Both bounds are per-vault initialisation parameters; hedgeConfig() returns the live values, and the seed vaults were deployed with MAX_HEDGE_AGE of 6 hours and MAX_EQUITY_JUMP_BPS of 2000.
reportSleeve(unrealizedPnlUsd, equityUsd, asOf) is the same call on an Income note, reporting the sleeve's excess equity; it mirrors reportHedge line for line and a Protected note refuses it with BadSleeve, so the hedger key holds no pause over a note that has no off-chain sleeve.
What the bounds do not cover is written into the contract header rather than hidden: a hedger who reports a slowly drifting equity, inside the jump band on every step, can walk the share price anywhere over enough reports. Twenty percent per report is a wide band, and the answer to it is the beta cap, not the contract. Off chain, the short itself is placed through the API's venue and the signer, whose bands apply to every order it signs - LIMIT_PRICE_BAND_BPS, MAX_ORDER_NOTIONAL_USD and the 500 bps floor on market orders - so a compromised hedger key can trade the vault's Lighter account badly, but not sign a price the signer refuses.
Rules every contract keeps
Reentrancy. Every external function that moves tokens or mints a position is nonReentrant: both zaps, place, fill, cancel, harvest, rebalance, every ERC-4626 mutator on all three vault kinds, settle, fundHedge, open, close, liquidate, claimDeferred and FeeRouter.claim. The two report functions, reportHedge and reportSleeve, are the exception: they carry no guard because they make no external call.
Custom errors. Every revert a Kerf contract raises is a named error, and each contract's chapter lists its own. The reverts you will meet that are not Kerf's come from what it calls: the router's Too little received when amountOutMinimum is missed, the position manager's Price slippage check when amount0Min/amount1Min is missed, and OpenZeppelin's ERC-4626 errors on a preview-level refusal. The vaults check their own conditions first - CapExceeded, Matured, HedgePaused_ - so that a refused deposit names its reason rather than the generic ERC4626ExceededMaxDeposit.
Events. Every state change emits, which is what lets the indexer rebuild a portfolio from logs alone. HedgePaused and HedgeResumed share one signature across HedgedLPVault and StructuredVault on purpose, because the indexer unions the vault ABIs by signature. CapUpdated is emitted exactly once per vault, at initialisation.
Immutable references. KerfZapV3, KerfZapV4, RangeOrders, PositionKeeper, LaunchPipeline, PreMarketPerp and FeeRouter hold every Uniswap, WETH, Permit2, feed and registry address as an immutable set in the constructor, from the canonical addresses in packages/abi. The clone vaults cannot use immutables, so they keep the same references in storage written once by initialize. There is no code path that reads an address from calldata and trusts it, with one deliberate exception: a zap resolves the pool from (token0, token1, fee) through the factory, and zapIncrease reads the pool and ticks from the position itself so a caller cannot point a zap at a range they do not own.
Balances. The zaps hold nothing between calls and, for v3, no standing allowance - approvals are set to the exact amount of each mint or swap and cleared afterwards (ZapExec._approve, ZapExec.balance). KerfZapV4 does keep two standing approvals per token after approveOnce, because the v4 position manager pulls through Permit2; that is safe only because of the invariant below, which says there is never a balance for a stolen allowance to move. receive() on the v3 zap accepts ETH from WETH alone, and on the v4 zap from the pool manager and position manager alone.
Value never moves unguarded
The threat model for every contract that moves value at a pool's price is the same: someone moves the pool, has Kerf act at the moved price, and moves it back. TwapGuard is the one answer, shared as a library so the vaults, the keeper and the notes cannot drift apart on what "the price is sane" means. check(pool) reads spot and the arithmetic-mean tick over the last TWAP_WINDOW (30 minutes), compares them as prices rather than as ticks, and reverts PriceDeviation when spot sits more than MAX_TWAP_DEVIATION_BPS (300) from the mean. The band makes manipulation expensive rather than impossible: holding a pool 3% off its mean for half an hour costs whatever arbitrage against the rest of the chain costs, and that scales with the pool's depth, not the vault's.
The subtler rule is the minimum window. A pool's observation ring starts at cardinality one, and every write in a new block overwrites the only slot, so on a fresh pool "the oldest observation" is the attacker's own manipulating swap and a TWAP over it is spot. So the guard refuses to answer at all until the ring reaches back MIN_TWAP_WINDOW (10 minutes), reverting TwapWindowTooShort. VaultFactory calls prepare at every creation to grow the ring to MIN_OBS_CARDINALITY (60), the ring fills one slot per writing block from there, and increaseObservationCardinalityNext is permissionless, so anyone stuck behind the error can pay to deepen it. The cost is availability: on a busy pool sixty slots cover ten minutes only if fewer than one block in ten seconds writes, and until the window opens every deposit, withdrawal and rebalance on that vault waits.
The guard is waived in exactly three places, each for a reason. LPVault._requirePriceSane returns early while totalSupply() == 0, because there is nobody to steal from yet and the first depositor into a vault on a fresh pool - which is what LaunchPipeline.graduate produces - could otherwise never get in. harvest skips it, because it only compounds the fee remainder under maxSlippageBps, and gating it on the oracle would let a manipulator stop the vault being paid. And a Protected note stops checking the SGOV pool once it holds no SGOV and is not about to buy any, which is what settle is for: after maturity a note is a pile of asset and nobody's exit depends on that pool again.
Inside the band there is still room. Spot can sit 3% from the mean and pass, and a fill at a spot the mean has not confirmed costs the position that gap. PositionKeeper.rebalance closes it with a third bound: after TwapGuard.check and after the router's amountOutMinimum, the whole operation is valued in token1 at the TWAP on both sides, and reverts Slippage if what was minted plus what was swept is worth less than maxSlippageBps below what was unwound. Both sides at the same price means the check bounds the cost of the round trip - the pool fee, the impact, and whatever the fill lost to a wrong spot - rather than the price move the swap itself causes, and it catches the paths the router never sees: a rebalance that needed no swap, or a mint that consumed far less than it was offered. shouldRebalance says no while the guard would refuse, so a bot never burns gas finding out. The vault's own rebalance runs behind the band as well, because it swaps and re-centres; a vault knocked out of range waits up to half an hour for the mean to confirm the move. A Protected note goes one step further and prices its SGOV sleeve at the mean rather than at spot, with the swap's minOut derived from the mean too - a minOut from a spot the caller just set is not a bound at all.
The other bounds in the drawing are simpler. The router's amountOutMinimum is quoteAtSpot less maxSlippageBps and measures value lost to the swap; the mint's amount0Min/amount1Min are mintToleranceBps below the balanced holdings and measure how far the post-swap ratio drifted from what the range wants, which is a different quantity and grows as the range narrows, so the vaults set it to MINT_RATIO_TOLERANCE_BPS (500) while the zaps keep it equal to maxSlippageBps. The cap is checked before the ERC-4626 path so it reports CapExceeded. And FeeRouter.take is a bound on a different axis: nobody can put a number into the fee ledger that the balance does not cover.
The perp's four bounds
PreMarketPerp has no counterparty. The curve invents both sides and the contract's USDG balance is the only thing that can pay a winner, so four bounds limit what that can cost.
- Leverage and open interest.
MAX_LEVERAGE_Xis 3, and a market'smaxOpenInterestUsdisOI_MULTIPLE(5) times the virtual depth it was created with, which is itself at leastMIN_VIRTUAL_LIQ_USD(10 000 USDG). A market can never owe more than a known multiple of the depth it advertises;openrevertsLeverageorOiCap. - The insurance fund.
INSURANCE_SHARE_BPS(3333) of everyTAKER_FEE_BPS(30) fee accrues toinsuranceFund, the rest to theFeeRouter. A liquidation pays the callerLIQ_BOUNTY_BPS(100) of the remaining margin and the fund the rest; a liquidated trader keeps nothing. Bad debt goes to the fund first (_absorb). - The deficit haircut. What the fund cannot absorb becomes
deficit, and the first profitable closers pay it:_payOutburnsmin(profit, deficit)off the payout, never touching the closer's own collateral. It is deliberately unfair in a documented direction, andhaircutBps()shows how unfunded the book currently is. - Nothing is paid out of someone else's margin.
_payableNow()is the balance less every open position's collateral and the insurance fund, and every payout is clamped to it; the remainder becomes adeferredclaim collected withclaimDeferred()as losses are realised. The ordering is first come:_payableNowdoes not reservetotalDeferred, so a later closer can be paid ahead of an earlier deferred claimant. That is what makesinvariant_perpCoversItsObligationsa fact rather than a hope, and it is why a profit is not always liquid the moment you close it.
Two further rules keep the oracle out of the money path. The index - a 30-minute TWAP of the token/WETH pool times Chainlink ETH/USD - drives funding only, capped at FUNDING_CAP_X18 (1% per hour); fills and liquidations use the mark, which is the curve. A manipulated index can push funding to its cap and no further, and pokeFunding silently does nothing when the index cannot be read, so an oracle outage cannot freeze close or liquidate. The exit fee is _affordableFee: a position closing into bad debt pays none, because charging one would only deepen the hole.
Invariants
The suites run under fail_on_revert = true, so any revert that escapes a handler fails the run. The handlers therefore catch the reverts that are the design working - a deposit over the cap, a short bigger than the quote reserve, a mint the position manager's minimum rejects - and count what actually landed, which the liveness invariants then assert on. A reverted call rolls its own state back, and catching it is what keeps any leaked wei visible to the assertion.
| Invariant | Statement |
|---|---|
invariant_zapHoldsNothing | After any sequence of zapMint and zapIncrease in ETH and USDC over the three range presets, KerfZapV3 holds zero ETH, zero WETH and zero USDC. |
invariant_zapKeepsNoStandingApprovals | The v3 zap's allowances to the position manager and the router are zero for both tokens. |
invariant_zapV4HoldsNothing | After any sequence of native-ETH and USDC zaps into a v4 pool, KerfZapV4 holds zero ETH and zero USDC. Its Permit2 approvals are allowed to stand. |
invariant_harvestNeverLosesValue | No harvest left totalAssets() lower than it found it, beyond 8 wei of rounding. A harvest pays the protocol and the caller out of fees, never out of principal. |
invariant_noSharesMeansNoAssets | Whenever totalSupply() is zero, totalAssets() is below 1e12 and positionTokenId() is zero: nothing is stranded in an empty vault. |
invariant_perpCoversItsObligations | The perp's USDG balance is at least totalCollateral() + insuranceFund() − deficit(). |
invariant_handlerActuallyZaps, invariant_v4HandlerActuallyZaps | Once the handler has made enough calls, at least half of them succeeded, so the balance invariants are not holding for want of activity. |
invariant_handlerActuallyUsesTheVault, invariant_handlerActuallyTrades | Deposits, redemptions and perp closes or liquidations actually landed during the run. |
foundry.toml sets the invariant profile to 64 runs at depth 32; the suites override that per test with forge-config lines - 24 runs at depth 32 for the perp, 12 runs at depth 24 for the vault and the two zaps - because each call in those handlers is a real swap and mint on a locally deployed Uniswap.
Test coverage
The default profile compiles with solc 0.8.26, cancun, via_ir and 200 optimizer runs, and runs every top-level test call as its own transaction (isolate = true) so EIP-1153 transient state clears between calls. Fuzz tests take 256 runs.
- Twelve unit suites. One per contract -
FeeRouter,KerfZapV3,KerfZapV4,RangeOrders,PositionKeeper,LPVault(withVaultFactory),HedgedLPVault,StructuredVault,LaunchPipeline,PreMarketPerp- plusZapMathandV3Harness, which proves the local Uniswap v3 deployment behaves like the real one before anything else trusts it. They run againstKerfFixture: a WETH / mock-USDC pool at the 0.05% tier priced at 3000 per ETH, with aFeeRouterand its registry;VaultFixtureadds the three implementations and a factory. Uniswap v3 core and periphery are compiled once with solc 0.7.6 at Uniswap's own optimizer settings so the pool creation code keeps the canonicalPOOL_INIT_CODE_HASH, whichbuild-v3.shasserts on every run; v4 is compiled from source and Permit2 is etched at its canonical address. Around 280 test functions, every guard error exact-matched. - Six fork suites under
test/fork/, against Robinhood Chain: a v3 zap on the live WETH/USDG market, a v4 zap on a live v4 pool, a range order placed and cancelled, a keeper harvest from a stranger's account after real volume through the liveSwapRouter02, a vault deposit and withdraw through a real factory clone against a real observation ring, and the perp'sprepareandindexPricereading a real TWAP times the real ETH/USD feed. They are gated onROBINHOOD_RPC_URL; without it they run, assert nothing and say so on stdout, which is a known weakness of the suite rather than a feature. - Four invariant suites, above.
- Fixtures across languages.
ZapMathmust stay bit-identical topackages/core'szapMath.ts;contracts/fixtures/zap.jsonis the contract between them, read by both suites.StructuredVault.payoffCurvepins eight points of each curve tofixtures/payoff.json, which the payoff maths in@kerf/coremirrors. - Drift guards off chain.
apps/api/src/indexer/queries.schema.test.tschecks every GraphQL field the API asks for againstapps/indexer/ponder.schema.ts;apps/api/src/routes/types/web.types.test.tsfails when a wire type is renamed on either side of the API/web boundary. In the web,lib/abi/abi.test.tsfails on anyfunctionNamea page uses that the generated ABI does not have,lib/providers.split.test.tsfails on any import of@privy-io/*outsidelib/providers.privy.tsx, andcomponents/ui/surface.test.tsfails on a literal glass token in a component stylesheet. None of these is a security test on its own; together they are what stops a rename from silently disabling a check.
Known limits
- The Lighter interface is unverified.
ILighterDeposit.deposit(token, amount)is what the design assumed; the deployed contract must be checked against it before any mainnet wiring, because a mismatch makesfundHedgerevert at best and sends USDG somewhere unrecoverable at worst. The vault approves the exact amount and clears it, so a revertingdepositleaves nothing standing. - Graduation is flash-loanable.
LaunchPipeline.graduatereadsliquidity()at one instant, so a flash-loaned position can lift a thin token overMIN_LIQUIDITYfor one block. That creates an empty, capped vault identical to one the attacker could deploy through the factory anyway, andvaultOfis write-once, so theGraduatedevent is a signal, not a guarantee. - Oracle heartbeat. The Chainlink feeds carry a 24-hour heartbeat and the contracts read
answeronly. A feed that reverts or answers non-positive marks a hedge as worth nothing rather than freezing withdrawals; a feed that is frozen but positive is believed. - The first-depositor waiver. While a vault has no supply there is no price check, so the first depositor into a fresh vault prices themselves; everyone after them waits for the window to open.
- Deferred profits. A perp close can leave part of its profit as a claim the venue funds later, in first-come order, after the deficit haircut.
contracts/src/libraries/TwapGuard.solthe one price guard: window, band, prepare, the verdictscontracts/src/HedgedLPVault.solthe hedger's two calls and the bounds on themcontracts/src/PreMarketPerp.solthe four bounds, _payOut, _payableNow, _absorbcontracts/test/invariants/ZapHoldsNothing.t.solzero balances, no standing approvalscontracts/test/invariants/VaultAccounting.t.solharvest never loses value, no shares means no assetscontracts/test/invariants/PerpSolvency.t.solthe perp covers its obligationscontracts/test/fork/ForkBase.solhow the fork suites are gatedcontracts/foundry.tomlisolate, fail_on_revert, runs and depthservices/signer/README.mdthe signer trust model and the order bands