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 12 · Security model

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

Trust boundariesEvery gated call in the system, by role; "Anyone" is permissionless. The frame on the right has no caller at all: those paths do not exist in the bytecode, so no key can open them.
nobody can · no such code pathAnyonezapMint · zapIncrease · approveOnceplace · fill · harvest · rebalancetrack · graduate · create*VaultcreateMarket · liquidate · pokeFundingsettle · claimDeferred · takePosition ownerenroll · updatePolicy · unenrollcancel (order) · close (perp)setApprovalForAll on the NPMVault depositordeposit · mint · withdraw · redeemHedger keyfundHedge → the Lighter deposit onlyreportHedge · reportSleeve (Income)trades the vault's Lighter accountTreasury · referrerclaim(token) · own claimable onlyDeployernothing after deployImmutable wiringUniswap, WETH, the feeds and theLighter deposit are set at construction;a clone is wired once, in initialize().withdraw a vault to a non-redeemermove a kept position off its ownerraise a cap once a vault existspause a zap, an order or a keeperupgrade any contractwithdraw from Lighter by contractchange a live vault's policyrebind a referral or reclaim a code
  • Withdraw a vault to anyone but where its shares send it. LPVault._withdraw burns owner's shares (spending an allowance if the caller is not the owner, which is ERC-4626) and pays receiver; there is no other transfer out of a vault. HedgedLPVault.fundHedge moves 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.rebalance mints the replacement NFT to Enrollment.owner, harvest pays 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 at MAX_BOUNTY_BPS (300), and the FeeRouter. _sweep hands the owner anything left over. Both calls revert with OwnerChanged if the NFT has been transferred since enrolment, so a stale enrolment cannot pay a stale owner.
  • Raise a cap. cap is set in initialize and emitted once as CapUpdated. 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, LaunchPipeline or PreMarketPerp. The only pauses in the system are the ones a hedged vault and an Income note 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. ILighterDeposit has 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-intent returns an unsigned payload and nothing in services/signer submits one.
  • Change a live vault's policy. LPVault.policy() is what initialize was given. A PositionKeeper policy is per enrolment and only the position's owner may updatePolicy it.
  • Credit a fee that was not paid. FeeRouter.take is permissionless and accounting-only, so it is believed only while the router's balance covers accounted[token] + amount; a stranger reporting thin air gets FeeNotReceived. ReferralRegistry links 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 bounds on every value-moving pathLeft to right: the check, the revert it raises, and the paths that run behind it. Every path that moves value at a pool price sits behind at least one row; the note lists the only waivers.
TwapGuard.checkspot ≤ 300 bps off a ≥ 10 min meanTwapWindowTooShort · PriceDeviationvault deposit · withdraw · rebalancekeeper rebalance (before the unwind)SGOV buy · sell · settle (Protected)router amountOutMinimumquoteAtSpot × (1 − maxSlippageBps)Too little received (router)ZapExec.balance: zap, vault, keeperfundHedge · exit swap · SGOV legmint amount0Min · amount1MinmintToleranceBps under balancedPrice slippage check (NPM)zapMint · zapIncrease (= slippage)vault deploy · compound (500 bps)keeper compound · rebalance mintround trip valued at the TWAPafter ≥ before × (1 − slippage)Slippage (PositionKeeper)PositionKeeper.rebalance, end to endcap, set once at initializetotalAssets() + assets ≤ capCapExceededdeposit · mint on every vaulthedge report bandΔequity ≤ jumpBps · age ≤ maxAgeHedgePaused_ · EquityJumphedged vault deposit · withdrawIncome note deposit · withdrawfundHedge, refused while pausedPreMarketPerp bounds3x · OI ≤ 5 × depth · fund · haircutLeverage · OiCap · TooSmallSlippage · deferred, no revertopen: leverage, OI, fee vs collateralclose · liquidate · claimDeferredFeeRouter.takebalance ≥ accounted + amountFeeNotReceivedevery protocol fee, every contractWaived on purposeTwapGuard: a vault with no supply (the first depositor in), harvest, and the SGOV band once a note holds no SGOV.Nothing else has a bypass, and no key can add one.

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.

  1. Leverage and open interest. MAX_LEVERAGE_X is 3, and a market's maxOpenInterestUsd is OI_MULTIPLE (5) times the virtual depth it was created with, which is itself at least MIN_VIRTUAL_LIQ_USD (10 000 USDG). A market can never owe more than a known multiple of the depth it advertises; open reverts Leverage or OiCap.
  2. The insurance fund. INSURANCE_SHARE_BPS (3333) of every TAKER_FEE_BPS (30) fee accrues to insuranceFund, the rest to the FeeRouter. A liquidation pays the caller LIQ_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).
  3. The deficit haircut. What the fund cannot absorb becomes deficit, and the first profitable closers pay it: _payOut burns min(profit, deficit) off the payout, never touching the closer's own collateral. It is deliberately unfair in a documented direction, and haircutBps() shows how unfunded the book currently is.
  4. 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 a deferred claim collected with claimDeferred() as losses are realised. The ordering is first come: _payableNow does not reserve totalDeferred, so a later closer can be paid ahead of an earlier deferred claimant. That is what makes invariant_perpCoversItsObligations a 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.

InvariantStatement
invariant_zapHoldsNothingAfter 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_zapKeepsNoStandingApprovalsThe v3 zap's allowances to the position manager and the router are zero for both tokens.
invariant_zapV4HoldsNothingAfter 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_harvestNeverLosesValueNo 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_noSharesMeansNoAssetsWhenever totalSupply() is zero, totalAssets() is below 1e12 and positionTokenId() is zero: nothing is stranded in an empty vault.
invariant_perpCoversItsObligationsThe perp's USDG balance is at least totalCollateral() + insuranceFund() − deficit().
invariant_handlerActuallyZaps, invariant_v4HandlerActuallyZapsOnce 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_handlerActuallyTradesDeposits, 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 (with VaultFactory), HedgedLPVault, StructuredVault, LaunchPipeline, PreMarketPerp - plus ZapMath and V3Harness, which proves the local Uniswap v3 deployment behaves like the real one before anything else trusts it. They run against KerfFixture: a WETH / mock-USDC pool at the 0.05% tier priced at 3000 per ETH, with a FeeRouter and its registry; VaultFixture adds 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 canonical POOL_INIT_CODE_HASH, which build-v3.sh asserts 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 live SwapRouter02, a vault deposit and withdraw through a real factory clone against a real observation ring, and the perp's prepare and indexPrice reading a real TWAP times the real ETH/USD feed. They are gated on ROBINHOOD_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. ZapMath must stay bit-identical to packages/core's zapMath.ts; contracts/fixtures/zap.json is the contract between them, read by both suites. StructuredVault.payoffCurve pins eight points of each curve to fixtures/payoff.json, which the payoff maths in @kerf/core mirrors.
  • Drift guards off chain. apps/api/src/indexer/queries.schema.test.ts checks every GraphQL field the API asks for against apps/indexer/ponder.schema.ts; apps/api/src/routes/types/web.types.test.ts fails when a wire type is renamed on either side of the API/web boundary. In the web, lib/abi/abi.test.ts fails on any functionName a page uses that the generated ABI does not have, lib/providers.split.test.ts fails on any import of @privy-io/* outside lib/providers.privy.tsx, and components/ui/surface.test.ts fails 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 makes fundHedge revert at best and sends USDG somewhere unrecoverable at worst. The vault approves the exact amount and clears it, so a reverting deposit leaves nothing standing.
  • Graduation is flash-loanable. LaunchPipeline.graduate reads liquidity() at one instant, so a flash-loaned position can lift a thin token over MIN_LIQUIDITY for one block. That creates an empty, capped vault identical to one the attacker could deploy through the factory anyway, and vaultOf is write-once, so the Graduated event is a signal, not a guarantee.
  • Oracle heartbeat. The Chainlink feeds carry a 24-hour heartbeat and the contracts read answer only. 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.
Read the code
  • contracts/src/libraries/TwapGuard.sol the one price guard: window, band, prepare, the verdicts
  • contracts/src/HedgedLPVault.sol the hedger's two calls and the bounds on them
  • contracts/src/PreMarketPerp.sol the four bounds, _payOut, _payableNow, _absorb
  • contracts/test/invariants/ZapHoldsNothing.t.sol zero balances, no standing approvals
  • contracts/test/invariants/VaultAccounting.t.sol harvest never loses value, no shares means no assets
  • contracts/test/invariants/PerpSolvency.t.sol the perp covers its obligations
  • contracts/test/fork/ForkBase.sol how the fork suites are gated
  • contracts/foundry.toml isolate, fail_on_revert, runs and depth
  • services/signer/README.md the signer trust model and the order bands