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
| Flow | Fee | Constant | Paid to | Attributed to |
|---|---|---|---|---|
| Zap (mint or increase) | 25 bps of the input | KerfZapV3.ZAP_FEE_BPS, KerfZapV4.ZAP_FEE_BPS = 25 | FeeRouter | the zapper (msg.sender) |
| Spot swap in the terminal | 25 bps of the output | SPOT_FEE_BPS = 25 in apps/web/lib/trade/spot.ts | FeeRouter, by the Universal Router's PAY_PORTION | nobody until reported - see below |
| Keeper harvest or rebalance | 10 % of the fees collected | PositionKeeper.PROTOCOL_FEE_BPS = 1000 | FeeRouter | the position's owner |
| Vault harvest or rebalance | 10 % of the fees collected | LPVault.PROTOCOL_FEE_BPS = 1000 | FeeRouter | the vault itself |
| Range-order fill or cancel | 10 % of the fees the order earned | RangeOrders.PROTOCOL_FEE_BPS = 1000 | FeeRouter | the order's owner |
| Structured early exit | 50 bps of the gross withdrawn, before maturity only | StructuredVault.EARLY_EXIT_BPS = 50 | FeeRouter | the note itself |
| Pre-market perp, open and close | 30 bps of the notional | PreMarketPerp.TAKER_FEE_BPS = 30 | one third to the insurance fund (INSURANCE_SHARE_BPS = 3333), the rest to FeeRouter | the trader |
| Referral | 20 % of every FeeRouter inflow | FeeRouter.REFERRAL_BPS = 2000 | the 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.
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
| Name | Kind | Raised by | When |
|---|---|---|---|
FeeNotReceived | error | take | token is the zero address, or the router's balance does not cover accounted + amount |
NothingToClaim | error | claim | the caller's claimable for that token is zero |
NativeTransferFailed | error | claim | the recipient rejected the ETH |
ZeroAddress | error | constructor | a zero treasury or registry |
Taken(token, user, amount, referrer, referrerShare) | event | take, takeNative | every accounted inflow; the indexer folds referrerShare into referral.earned |
Claimed(token, who, amount) | event | claim | every payout; not indexed, because it changes nothing about what was earned |
Bounties
| Action | Bounty | Constant | Out of | Paid to |
|---|---|---|---|---|
PositionKeeper.harvest / rebalance | the policy's bountyBps, at most 3 % | MAX_BOUNTY_BPS = 300 (BadPolicy above it) | the collected fees, after the 10 % | msg.sender |
LPVault.harvest / rebalance | 0.5 % | HARVEST_BOUNTY_BPS = 50 | the collected fees, after the 10 % | msg.sender |
RangeOrders.fill | 1 % | KEEPER_BOUNTY_BPS = 100 | the order's output | the keeper |
PreMarketPerp.liquidate | 1 % | LIQ_BOUNTY_BPS = 100 | the margin remaining after the loss and the fee | the liquidator |
| a graduated vault's advertised bounty | 0.5 % | LaunchPipeline.BOUNTY_BPS = 50 | the policy the pipeline writes; the vault still pays its own HARVEST_BOUNTY_BPS | msg.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 formsg.sender. It revertsZeroCodeforbytes8(0),CodeTakenif another address owns the code, andAlreadyRegisteredif the caller already has one. A referrer owns exactly one code for life. EmitsRegistered(referrer, code).bind(bytes8 code)bindsmsg.senderto the code's owner. It revertsAlreadyBoundif the caller is already bound,UnknownCodeif nobody owns the code, andSelfReferralif the owner is the caller. A user is bound to exactly one referrer for life. EmitsBound(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.
contracts/src/FeeRouter.soltake, takeNative, claim, the balance checkcontracts/src/ReferralRegistry.solregister, bind, the six errorscontracts/test/FeeRouter.t.solevery split, the thin-air take, the conserving fuzzcontracts/src/PositionKeeper.sol_split and _unwind: fees only, protocol then bountycontracts/src/LPVault.sol_splitFees with the vault as its own usercontracts/src/RangeOrders.sol_settleFees on fees0/fees1 onlycontracts/src/PreMarketPerp.sol_affordableFee, _settleFee, the insurance thirdapps/web/lib/trade/universalRouter.tsPAY_PORTION and the net minOutapps/web/app/(app)/theses/ReferralPanel.tsxregister, the ?ref= bind, the bytes8 encodingapps/indexer/src/kerf/feeRouter.tsTaken → referral.earnedapps/api/src/points.tscomputePoints and the thesis window