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 11 · Fees & referrals

Fees & referrals

Every fee Kerf charges, where each one goes, and the 20% referral share.

Every fee Kerf charges is in the table below, each row verified against the constant that enforces it, and there are no others. The fact not to miss: the 10 % is taken from earned fees and never from principal, so a position that has collected nothing pays nothing and no harvest can reach the liquidity itself. The one fee that touches principal is the structured note's early exit, and it is 50 bps of what you take out before maturity.

The table

FlowFeeConstantPaid toAttributed to
Zap (mint or increase)25 bps of the inputKerfZapV3.ZAP_FEE_BPS, KerfZapV4.ZAP_FEE_BPS = 25FeeRouterthe zapper (msg.sender)
Spot swap in the terminal25 bps of the outputSPOT_FEE_BPS = 25 in apps/web/lib/trade/spot.tsFeeRouter, by the Universal Router's PAY_PORTIONnobody until reported - see below
Keeper harvest or rebalance10 % of the fees collectedPositionKeeper.PROTOCOL_FEE_BPS = 1000FeeRouterthe position's owner
Vault harvest or rebalance10 % of the fees collectedLPVault.PROTOCOL_FEE_BPS = 1000FeeRouterthe vault itself
Range-order fill or cancel10 % of the fees the order earnedRangeOrders.PROTOCOL_FEE_BPS = 1000FeeRouterthe order's owner
Structured early exit50 bps of the gross withdrawn, before maturity onlyStructuredVault.EARLY_EXIT_BPS = 50FeeRouterthe note itself
Pre-market perp, open and close30 bps of the notionalPreMarketPerp.TAKER_FEE_BPS = 30one third to the insurance fund (INSURANCE_SHARE_BPS = 3333), the rest to FeeRouterthe trader
Referral20 % of every FeeRouter inflowFeeRouter.REFERRAL_BPS = 2000the attributed user's referrer-

The bounties are in their own table further down, because they are not fees: they go to whoever spent the gas, and the router never sees them.

The "attributed to" column matters more than it looks. FeeRouter.take is given a user, and that user's referrer earns the 20 %. A zap names the zapper, a keeper action names the position's owner (not the bot that called it), a range-order fill names the order's owner (not the keeper), and a perp fee names the trader. A vault names itself - _feeRouter.take(token, protocolShare, address(this)) - and a vault can never bind a referral code, so a vault's or a note's whole cut lands on the treasury. Depositors into a vault are not referred through it.

Two rules

The 10 % is on earned fees, never on principal. In PositionKeeper._unwind the fee portion is collect() − decreaseLiquidity(): the NPM reports the principal alone from decreaseLiquidity and pays principal plus fees from collect, and the protocol cut and the bounty apply to the difference only. LPVault does the same in both harvest and rebalance (_splitFees(_token0, total0 − principal0)), and RangeOrders._settleFees takes PROTOCOL_FEE_BPS of fees0/fees1, which are computed the same way. There is no withdrawal fee on a vault, no fee on a cancelled range order's principal, and no fee on a rebalance's principal.

Bounties are not protocol revenue. They are paid by the contract to msg.sender out of the same collected fees (or, for a fill, out of the output) before the owner's share is computed, and they exist because a permissionless action nobody is paid to take does not get taken. The keeper bot's own rule is to send only when the bounty is at least three times the gas, so on a quiet position the bounty simply accrues untaken until it is worth someone's while - the position loses nothing meanwhile.

Fee flowsEvery protocol fee is transferred to the FeeRouter first and reported with take(); the report is believed only while the balance covers everything already owed. The user named in the report decides whose referrer earns 20 %. Bounties never touch the router.
Bounties · to the callerunaccountedyesno80 %Zap · 25 bps of inputZAP_FEE_BPS · V3 / V4Spot swap · 25 bps of outputPAY_PORTION · plain transferKeeper harvest · 10 % of feesPROTOCOL_FEE_BPS · user = ownerRange fill · 10 % of feesearned fees only · user = ownerVault harvest · 10 % of feesuser = the vault → no referrerEarly exit · 50 bps of grossEARLY_EXIT_BPS · user = the notePerp taker · 30 bpsTAKER_FEE_BPS · open and closeFeeRouter.take(token, amt, user)or takeNative(user) with msg.valueFeeNotReceived · balance shortreferrerOf(user)bound?20 % → claimable[referrer]REFERRAL_BPS 2000rest → claimable[treasury]100 % when unboundclaim(token) · own balance onlyinsuranceFundINSURANCE_SHARE_BPS 3333 of feeKeeper · policy bountyBpsMAX_BOUNTY_BPS 300Vault · HARVEST_BOUNTY_BPS50 of collected feesRange fill · 1 % of outputKEEPER_BOUNTY_BPS 100Liquidation · 1 % of marginLIQ_BOUNTY_BPS 100 · remainingGraduated vault policyLaunchPipeline BOUNTY_BPS 50Not revenueBounties pay whoever spent the gas.The keeper bot sends only when thebounty is at least 3 × the gas cost.

FeeRouter

FeeRouter is the single sink for every protocol fee and the place referrers and the treasury pull their share from. It has no owner, no admin function and two immutables: treasury, which receives everything not owed to a referrer, and registry, the ReferralRegistry it consults. There is one design decision that explains the whole contract:

take is accounting only. take(token, amount, user) never pulls tokens. The calling contract transfers the fee to the router first and then reports it, which lets any Kerf contract settle a fee with a plain safeTransfer and no allowance dance. Because take is permissionless - anyone can call it - the report has to be checked against reality, or a stranger could credit themselves a fee they never paid and then claim it out of a pot that is shared across every beneficiary of that token. So the router keeps accounted[token], the sum of every unclaimed claimable for that token, and believes a report only while balanceOf(this) ≥ accounted[token] + amount; otherwise it reverts FeeNotReceived. Every Kerf caller transfers first, so the check is invisible to them; a stranger reporting thin air reverts. take with amount == 0 returns silently, and take with token == address(0) reverts FeeNotReceived, because there is no ERC-20 balance to check native ETH against - ETH must come through takeNative(user), which accounts msg.value and has nothing to verify since the value arrived with the call. KerfZapV4 uses it when the zap is paid in native ETH.

_account does the split: referrerOf(user) from the registry; if it is set, referrerShare = amount × REFERRAL_BPS / 10 000 is added to claimable[token][referrer]; amount − referrerShare is added to claimable[token][treasury]; accounted[token] grows by amount; Taken(token, user, amount, referrer, referrerShare) is emitted, with a zero referrer and a zero share for an unreferred user. The fuzz test testFuzz_splitIsExactAndConserving pins that the two shares always sum to the amount.

claim(token) pays only the caller's own balance: it reads claimable[token][msg.sender], reverts NothingToClaim on zero, zeroes it, reduces accounted, and transfers - a call for ETH that reverts NativeTransferFailed if the recipient rejects it, safeTransfer otherwise - then emits Claimed(token, who, amount). It is nonReentrant. Neither share is claimable by anyone but its owner, and the treasury is just another beneficiary of claim.

The spot skim is unaccounted until someone reports it

The terminal's spot swap does not call a Kerf contract. It runs through the Uniswap Universal Router, and the 25 bps is a PAY_PORTION command that sends that share of the output to the manifest's feeRouter address as a plain transfer before SWEEP forwards the rest to the user - which is why minOut in the swap builder is the net the user must receive. Nothing calls take for it. That tokens sit on the router as balance above accounted, where nobody can claim them, until someone calls take(token, amount, user) for that amount - and since take is permissionless, whoever reports it names the user, and so the referrer, that is credited. No code in the web, the API or the bots does this today. In a build with no manifest the skim is off and the swap pays the user directly.

Errors and events

NameKindRaised byWhen
FeeNotReceivederrortaketoken is the zero address, or the router's balance does not cover accounted + amount
NothingToClaimerrorclaimthe caller's claimable for that token is zero
NativeTransferFailederrorclaimthe recipient rejected the ETH
ZeroAddresserrorconstructora zero treasury or registry
Taken(token, user, amount, referrer, referrerShare)eventtake, takeNativeevery accounted inflow; the indexer folds referrerShare into referral.earned
Claimed(token, who, amount)eventclaimevery payout; not indexed, because it changes nothing about what was earned

Bounties

ActionBountyConstantOut ofPaid to
PositionKeeper.harvest / rebalancethe policy's bountyBps, at most 3 %MAX_BOUNTY_BPS = 300 (BadPolicy above it)the collected fees, after the 10 %msg.sender
LPVault.harvest / rebalance0.5 %HARVEST_BOUNTY_BPS = 50the collected fees, after the 10 %msg.sender
RangeOrders.fill1 %KEEPER_BOUNTY_BPS = 100the order's outputthe keeper
PreMarketPerp.liquidate1 %LIQ_BOUNTY_BPS = 100the margin remaining after the loss and the feethe liquidator
a graduated vault's advertised bounty0.5 %LaunchPipeline.BOUNTY_BPS = 50the policy the pipeline writes; the vault still pays its own HARVEST_BOUNTY_BPSmsg.sender

The order inside a split is fixed: protocol cut first, then the caller's bounty, then whatever is left to the owner (PositionKeeper._split, LPVault._splitFees). The seed vaults advertise bountyBps 50 in their policy so a bot reading the policy sees the same figure the vault pays.

The perp fee

PreMarketPerp charges TAKER_FEE_BPS (30) of the notional on open - before the position is stored, and TooSmall if the fee would reach the collateral - and again on close and on a liquidation. The exit fee is capped at what the position can actually pay: _affordableFee returns zero when the gross result is at or below zero, because charging a fee on a position closing into bad debt would only deepen the hole the insurance fund has to fill. _settleFee then sends INSURANCE_SHARE_BPS (3333, one third) of the fee to insuranceFund with an InsuranceChanged event, and transfers the remaining two thirds to the router with take(usdg, protocolShare, trader). That is the "10 insurance / 20 FeeRouter" of the design spec, rounded: the contract works in thirds of the fee, not in bps of the notional. How the fund is spent is in Launch & pre-market perp.

Referrals

ReferralRegistry is on chain and small: two write-once links, no owner, no admin, no upgrade path, and FeeRouter as its only consumer.

  • register(bytes8 code) claims a code for msg.sender. It reverts ZeroCode for bytes8(0), CodeTaken if another address owns the code, and AlreadyRegistered if the caller already has one. A referrer owns exactly one code for life. Emits Registered(referrer, code).
  • bind(bytes8 code) binds msg.sender to the code's owner. It reverts AlreadyBound if the caller is already bound, UnknownCode if nobody owns the code, and SelfReferral if the owner is the caller. A user is bound to exactly one referrer for life. Emits Bound(user, referrer).

Both directions are one-way on purpose: a code cannot be re-registered and a binding cannot be changed, so nobody can be re-attributed later and no fee can be moved from one referrer to another after the fact. You cannot refer yourself.

The app's side of it is the referral panel on /theses. A code is up to eight ASCII characters, upper-cased so links are case-proof, and encoded as stringToHex(code.toUpperCase(), { size: 8 }) - the bytes8 the contract stores. Registering is one register write through the same write path as every other button. Sharing kerf.trade/theses?ref=CODE turns into a one-click bind for whoever opens it: the panel reads ?ref= from the URL and offers the bind. The indexer keeps one referral row per address with the code it registered, the referrer it is bound to, how many addresses are bound to it, and earned, a per-token running total of every Taken.referrerShare it has been credited. GET /referral/:address returns those priced into one dollar figure at the current marks; the earnings themselves are claimed on chain with FeeRouter.claim(token), one token at a time, by the referrer's own wallet.

Points

Points are off chain, in the API's Postgres, and are a ledger rather than a token: POINTS_PER_USD_VOLUME 1 per dollar of volume, POINTS_PER_USD_FEES 5 per dollar of fees earned, and volume counts double (THESIS_MULTIPLIER 2) while the address has a thesis less than THESIS_LIVE_SECONDS (seven days) old. Totals are always recomputed from the points_events ledger rather than incremented in place, which makes a replay idempotent and a rule change a recompute away; POST /points/recompute behind ADMIN_SECRET does that for every address and the comparison is constant-time. GET /points/:address returns the total and the breakdown: volume, fees and thesisBonus, the boosted volume counted again.

Two things are true today and should be read plainly. Nothing in the API writes a points event yet - the ingestion job that would read collects, swaps and vault events per address and convert them to USD is on the launch list, so every live total is zero - and rank is always null. Nothing is promised about points. If they ever convert into anything, that will be a decision made later and stated then.

Read the code
  • contracts/src/FeeRouter.sol take, takeNative, claim, the balance check
  • contracts/src/ReferralRegistry.sol register, bind, the six errors
  • contracts/test/FeeRouter.t.sol every split, the thin-air take, the conserving fuzz
  • contracts/src/PositionKeeper.sol _split and _unwind: fees only, protocol then bounty
  • contracts/src/LPVault.sol _splitFees with the vault as its own user
  • contracts/src/RangeOrders.sol _settleFees on fees0/fees1 only
  • contracts/src/PreMarketPerp.sol _affordableFee, _settleFee, the insurance third
  • apps/web/lib/trade/universalRouter.ts PAY_PORTION and the net minOut
  • apps/web/app/(app)/theses/ReferralPanel.tsx register, the ?ref= bind, the bytes8 encoding
  • apps/indexer/src/kerf/feeRouter.ts Taken → referral.earned
  • apps/api/src/points.ts computePoints and the thesis window