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 04 · Keeper

Keeper

Permissionless harvest and rebalance under a policy you set, and the three bounds on every rebalance.

A concentrated position stops earning the moment the price leaves its range, and its fees sit uncollected until someone collects them. PositionKeeper lets anyone do both jobs on your position - harvest(tokenId) and rebalance(tokenId) are permissionless - under a Policy only you can set, for a bounty you also set, capped at MAX_BOUNTY_BPS (300) of the fees collected. The fact not to miss: the keeper never takes custody. It works through the position manager's operator approval, which you can revoke at any moment, and a rebalance mints the replacement position to you, never to itself or to the caller. Because a rebalance swaps someone else's principal at a pool price, it is bounded three times - by TwapGuard before anything is unwound, by the router on the swap, and by a value check at the TWAP across the whole round trip - and if the position has changed hands since it was enrolled, every action refuses with OwnerChanged rather than pay the wrong person.

The policy

You enroll a position by handing the keeper a Policy. It is the same struct LPVault runs under, mirrored field for field in @kerf/core's policy.ts, so a vault, a self-managed position and the bot all mean the same thing by it.

Policy
ParameterRangeDefaultMeaning
widthTicks> 0, multiple of the pool spacing ticks1200 (Enroll drawer)Total width of the range a rebalance mints, centred on the tick at the time. The Enroll presets are 1000 / 4500 / 8100 for SPOT / CURVE / WIDE.
hysteresisTicks≥ 0 ticks120How far past the edge the tick must sit before a rebalance is allowed. Seed vaults use ten spacings.
minInterval≥ 0 seconds3600Time that must pass since the enrolment or the last action before the next one. Seed vaults use 6 hours.
maxSlippageBps0 - 9 999 bps100Bound on the rebalance swap, on the mint ratio and on the value round trip; 10 000 is BadPolicy. Seed vaults use 100.
compoundtrue · falsetrueWhether harvest puts the remainder back into the position or pays it to you.
bountyBps0 - 300 (MAX_BOUNTY_BPS) bps of fees collected50Your promise to whoever calls. LPVault ignores it and pays its own HARVEST_BOUNTY_BPS.

_validate runs on enroll and on updatePolicy: bountyBps > MAX_BOUNTY_BPS, maxSlippageBps >= 10 000, a negative hysteresisTicks, or a widthTicks that is zero, negative or not a multiple of the pool's tickSpacing are all BadPolicy. The spacing is read from the position's own pool, so the same width can be legal on a 0.05 % pool and illegal on a 0.30 % one.

Hysteresis is what stops a position hovering on its boundary from being churned: without it a price oscillating across the edge would pay for a rebalance every minInterval forever, and the fees would go to gas and to the bounty. minInterval is the only defence against a keeper harvesting dust repeatedly to farm bounties, so it should be set to something meaningful; shouldHarvest will say yes for a single wei of pending fees, and it is the bot's gas rule, not the contract, that declines the small ones.

Enrolling

  1. 01
    Approve the operator
    npm.setApprovalForAll(positionKeeper, true) on the Uniswap position manager. This is the ERC-721 operator approval, revocable at any time with false; the keeper checks it at enroll (NotApproved) because without it every later action would revert inside the position manager, where the error is illegible.
  2. 02
    Enroll
    enroll(tokenId, policy) by the NFT's ownerOf (NotOwner otherwise). The enrolment records the owner, the policy, active = true and lastActionAt = block.timestamp, so the first action is at least minInterval away. Enrolling an already enrolled id replaces the enrolment and restarts the clock. Enrolled(tokenId, owner, policy).
  3. 03
    Change or leave
    updatePolicy(tokenId, policy) replaces the rules (owner only, validated the same way, emits Enrolled again). unenroll(tokenId) sets active = false and emits Unenrolled; it is owner-only, instant and needs no approval change. Revoking the operator approval also stops the keeper, but with a less readable revert.

enrollments(tokenId) returns the Enrollment: owner, policy, lastActionAt and active, where active == false means unmanaged. The keeper holds no NFT and no token balance between calls; everything it does is collect, decreaseLiquidity, burn, increaseLiquidity and mint through the approval you gave, plus one swap of what it is holding mid-transaction.

One tick of the keeper

Both actions start with the same three gates, then diverge.

One tick of the keeperThree gates are shared; then harvest splits what it collected and rebalance runs its three bounds: the TWAP guard before anything is unwound, the router on the swap, and a value check at the TWAP over the whole round trip.
yesyesharvestrebalanceyesnoyessaneyesharvest(tokenId) · rebalance(tokenId)The viewsshouldHarvest and shouldRebalanceanswer these gates without gas;the bot polls them via Multicall3.enrolled?activeNotEnrolledminIntervalelapsed?TooSoonownerOf == owner?OwnerChangedFail closedA sold NFT leaves a staleenrolment naming the old owner;every payout goes to thataddress, so the action refuses.harvest: npm.collect(max)fees0 · fees1 → this contractsplit each token10 % → FeeRouter · bountyBps → callerrest → compound or ownercompound?balanceAndIncreaserouter bound onlydust → ownerrest → ownerHarvested(tokenId, keeper, fees, bounty, …)out of range byhysteresisTicks?InRangeTwapGuard.checkspot within 3 % of TWAP?TwapWindowTooShortPriceDeviationunwind: decrease · collect · burnfees split 10 % + bounty · principal wholevalueBefore at the TWAPin token1 · Ranges.centred(tick, width)ZapExec.balanceAndMint → ownerrouter minOut · mint mins · dust sweptvalue after ≥before − maxSlippageBps?SlippageRebalanced(old, new, keeper, lower, upper)

shouldHarvest(tokenId) and shouldRebalance(tokenId) are views of the same gates plus the conditions the action itself will check, so a bot can poll them in one Multicall3 batch and never spend gas finding out. shouldHarvest is true when the enrolment is active, the interval has elapsed, ownerOf still matches and the position has any uncollected fees - computed by _pendingFees, which mirrors v3-periphery's PositionValue.fees including what is already in tokensOwed. shouldRebalance additionally needs non-zero liquidity, a tick outside the range by the hysteresis, and TwapGuard.isSane(pool), so that the bot is never offered a rebalance the guard is going to refuse.

Harvest

  1. 01
    Gate
    NotEnrolled if inactive, TooSoon if block.timestamp < lastActionAt + minInterval, OwnerChanged if npm.ownerOf(tokenId) is no longer the enrolment's owner. lastActionAt is set to now before anything moves.
  2. 02
    Collect
    npm.collect with both maxima, to the keeper contract. This takes every fee the position has accrued and nothing of its liquidity: a harvest cannot reach the principal.
  3. 03
    Split each token
    _split runs once per token. PROTOCOL_FEE_BPS (1000) of the amount is transferred to the FeeRouter and reported with take(token, protocol, owner), so the owner's referrer is credited; bountyBps of the amount goes to msg.sender; the rest is the owner's.
  4. 04
    Compound or pay
    With policy.compound and a non-zero remainder, ZapExec.balanceAndIncrease swaps the remainder to the position's ratio and adds it back with increaseLiquidity; whatever the ratio could not absorb is swept to the owner. Otherwise both remainders are transferred to the owner as they are.
  5. 05
    Emit
    Harvested(tokenId, keeper, fees0, fees1, bounty0, bounty1, compounded), where fees0/fees1 are the totals before any split.
Harvest splitEvery cut comes out of collected fees, never out of the position. The remainder is compounded through ZapExec or paid to the owner, as the policy says.
yesnofees0 · fees1 collectedprotocol · PROTOCOL_FEE_BPS1000 bps → FeeRouter.take(…)bounty · bountyBps≤ MAX_BOUNTY_BPS = 300 → callerremainderfees − protocol − bountyFeeRouter20 % of it to the referrermsg.senderwhoever called harvestpolicy.compound?increaseLiquidityZapExec · dust swept to ownerownertransferred in both tokensNever on principalBoth cuts come out of collectedfees. A rebalance's unwind splitsonly total − principal the same way.

The compound leg runs the zap's swap-and-increase with maxSlippageBps as both the router bound and the mint tolerance, and nothing else: there is no TWAP guard on a harvest, because what is at stake is the fee remainder of one interval and a guard that refused to compound on every real move would cost more than it saved. The bounty is paid whether or not the remainder is compounded.

Rebalance

  1. 01
    Gate
    The same three: NotEnrolled, TooSoon, OwnerChanged.
  2. 02
    Out of range by the hysteresis
    Ranges.outOfRange(tick, tickLower, tickUpper, hysteresisTicks) is tick < tickLower − h or tick ≥ tickUpper + h - the upper test is non-strict because tickUpper itself is already outside a Uniswap range. Otherwise InRange.
  3. 03
    TwapGuard.check
    Spot must sit within MAX_TWAP_DEVIATION_BPS (300) of the TWAP over the last TWAP_WINDOW (30 minutes), and the pool's observation ring must reach back at least MIN_TWAP_WINDOW (10 minutes). TwapWindowTooShort or PriceDeviation otherwise. The TWAP is kept: the round trip below is valued at it.
  4. 04
    Unwind
    decreaseLiquidity of the whole position, collect, burn. collect − decrease is the fee portion; it is split exactly as a harvest would split it (protocol ten percent, then the bounty to the caller) and the remainder rejoins the principal. The principal is never split.
  5. 05
    Value before
    _valueInToken1(have0, have1, twap): both holdings in token1 at the TWAP price.
  6. 06
    Centre the new range
    Ranges.centred(tick, spacing, widthTicks): the tick floored to the spacing, widthTicks / 2 floored to the spacing below it, and exactly widthTicks above that. When half the width is not itself a multiple of the spacing the extra half-step goes to the upper side, which keeps the tick inside the range for every legal width, including one spacing. Both bounds are clamped to the widest ticks the spacing allows.
  7. 07
    Mint to the owner
    ZapExec.balanceAndMint(route, have0, have1, owner): the excess side is swapped on the same pool with amountOutMinimum at maxSlippageBps below the spot quote, and the position is minted with amount0Min/amount1Min at maxSlippageBps below the balanced holdings. The NFT belongs to the owner from the first block. Every wei the mint did not take is swept to the owner too.
  8. 08
    Value after
    The minted amounts plus the swept dust, valued in token1 at the same TWAP, must be at least valueBefore less maxSlippageBps. Otherwise Slippage, and the whole transaction - unwind included - is undone.
  9. 09
    Move the enrolment
    A new Enrollment with the same owner and policy is written under newTokenId with lastActionAt = now; the old one is deleted. Rebalanced(oldTokenId, newTokenId, keeper, tickLower, tickUpper). The function returns newTokenId.

The three bounds on a rebalance

A rebalance is the one place in the keeper where a stranger moves your principal through a swap, and a swap at a bad price is the classic way to lose it. Three separate checks bound it, in this order, and each catches something the others cannot.

The TWAP guard, before anything is unwound. TwapGuard.check refuses to start while spot is more than three percent off the thirty-minute mean, or while the pool's observation ring is too shallow to have one. Without it a keeper could move the pool, force the position out of range and rebalance it at the price they made; with it, the pushed spot is off the mean and the call reverts PriceDeviation. The minimum window matters as much as the deviation: a pool's ring starts at cardinality 1 and every write in a new block overwrites the only slot, so "the oldest observation" on a fresh pool is the attacker's own manipulating swap and a TWAP over it is spot. The guard therefore refuses (TwapWindowTooShort) rather than average over a window the manipulator wrote. increaseObservationCardinalityNext is permissionless on every pool, so anyone stuck behind that error can pay to deepen the ring; MIN_OBS_CARDINALITY (60) covers ten minutes on a pool that writes fewer than one block every ten seconds, and a busier pool needs more. The VaultFactory calls TwapGuard.prepare at every vault creation; the keeper does not, because it does not know which pools you will enroll.

The router's amountOutMinimum, on the swap. Inside ZapExec.balance, the excess side is sold with a minimum output maxSlippageBps below the constant-price quote. This is the tighter of the three in calm conditions and the one that costs nothing to explain: it bounds how much value the swap itself loses to price impact and to whoever is in the block with you.

The value check at the TWAP, across the whole operation. After the mint, the owner's new position plus the swept dust is valued in token1 at the TWAP the guard returned and compared with the unwound holdings valued at the same TWAP; if it fell by more than maxSlippageBps, Slippage. It is measured at the TWAP and not at spot because a check priced at the same spot the swap fills at cannot see a spot that is wrong. Inside the three percent band a wrong spot is still allowed, and it still costs the owner up to half of the deviation: the test moves spot about 2.7 % above the mean, so half the holdings are bought about 2.7 % dear, a loss of about 1.35 % at the TWAP that is invisible at spot and that only this check catches. The value check also covers the paths the router never sees - a rebalance that needed no swap, a mint that consumed far less than it was offered - because both sides of it are priced the same way and what it bounds is the cost of the round trip: the pool fee, the impact, and whatever the fill lost by happening at a spot the mean has not confirmed.

The price of the third bound is latency. After a real move, spot leads the thirty-minute mean by construction, so under a tight maxSlippageBps a position that has genuinely left its range can take up to half an hour to become rebalanceable; shouldRebalance says no in the meantime so the bot does not burn gas finding out, and the position simply earns nothing for that half hour, exactly as it would have anyway.

Fail closed on transfer

An enrolment names the owner it was created by, and every payout an action makes - the harvest remainder, the swept dust, the replacement NFT - goes to Enrollment.owner. If you sell or transfer an enrolled position without unenrolling, the enrolment goes stale. Usually the position manager stops the next action by itself, because the new owner has not made the keeper their operator; but a new owner who uses Kerf has, and then a stale enrolment would let a harvest collect the new owner's fees and pay them to the old one, and a rebalance would mint the entire position to the address that sold it. So harvest and rebalance read the live ownerOf and revert OwnerChanged when it no longer matches, and shouldHarvest and shouldRebalance return false for the same reason. The stale enrolment is inert, not dangerous: the new owner's position is untouched until they enroll it themselves, which writes a fresh enrolment under the same id and starts a new clock. Both cases are tested, in test_harvestRefusesAfterThePositionChangedHands and test_rebalanceRefusesAfterThePositionChangedHands.

The same principle runs through the contract. Every failure is a revert, never a partial state: a rebalance that cannot mint its new range, or that fails the value check after minting, does not leave you holding two loose balances and no position, because the unwind is undone with it.

Who calls it

Anyone may, and the portfolio page offers Harvest and Rebalance on your own enrolled positions. In practice the keeper bot does: each tick it reads shouldHarvest and shouldRebalance for every enrolment in one Multicall3 batch, prices the bounty as bountyBps of the position's uncollected fees in USD converted to wei with the ETH mark, and sends the action only when the bounty is at least GAS_BOUNTY_MULTIPLE (3) times the estimated gas. When the bounty cannot be priced the action is skipped, not sent blind: a missed harvest costs you nothing, the fees stay in the position for the next tick. An ActionGuard per action puts a cooldown after a success and a backoff after a failure, so a position whose bounty never clears the floor is not retried every minute. The whole loop is in Indexer, API and bots.

Errors

ErrorWhen
NotOwnerenroll by anyone but the NFT's ownerOf; updatePolicy or unenroll by anyone but the enrolment's owner.
NotApprovedenroll before setApprovalForAll(keeper, true).
BadPolicybountyBps > 300; maxSlippageBps >= 10 000; hysteresisTicks < 0; widthTicks <= 0 or not a multiple of the pool spacing.
NotEnrolledupdatePolicy, unenroll, harvest or rebalance on an inactive enrolment.
TooSoonminInterval has not elapsed since the enrolment or the last action.
OwnerChangedthe position's live ownerOf is not the address that enrolled it.
InRangerebalance while the tick is inside the range widened by hysteresisTicks on both sides.
TwapWindowTooShortrebalance while the pool's observation ring reaches back less than 10 minutes (from TwapGuard).
PriceDeviationrebalance while spot is more than 300 bps from the 30-minute TWAP (from TwapGuard).
Slippagethe value of the new position plus dust, at the TWAP, fell more than maxSlippageBps below the unwound value.

Two more come from Uniswap and are shown as slippage by the app: the router's Too little received when the swap bound bites, and the position manager's Price slippage check when the mint tolerance does.

Events

EventFields
EnrolledtokenId (indexed), owner (indexed), policy - on enroll and on updatePolicy
UnenrolledtokenId (indexed)
HarvestedtokenId (indexed), keeper (indexed), fees0, fees1 (totals before the split), bounty0, bounty1, compounded
RebalancedoldTokenId (indexed), newTokenId (indexed), keeper (indexed), tickLower, tickUpper

The indexer folds them into keeperEnrollment and keeperAction, which is where the portfolio's enrolment table and the bot's list of positions to poll come from.

What it does not do

  • It never holds your NFT or your tokens between calls, and it never mints to itself or to the caller. There is nothing to withdraw, pause or upgrade, and no admin.
  • It does not act on a position in range, on one whose interval has not elapsed, on one that has changed hands, or on one nobody enrolled.
  • It does not promise timeliness. A harvest whose bounty does not cover three times the gas waits; a rebalance after a real move waits for the mean.
  • It does not guard a harvest's compounding with the TWAP - only the router bound applies there, and only the interval's fee remainder is exposed.
  • It does not compound in kind. The remainder is swapped to the range's ratio, which costs the pool fee on the swapped part.
  • It does not let anyone but the owner change the rules, and it does not let the owner change them for a position they no longer hold.
Read the code
  • contracts/src/PositionKeeper.sol enroll, updatePolicy, unenroll, harvest, rebalance, shouldHarvest, shouldRebalance, the trust notes
  • contracts/src/libraries/Policy.sol the Policy struct shared with LPVault
  • contracts/src/libraries/Ranges.sol centred and outOfRange
  • contracts/src/libraries/TwapGuard.sol TWAP_WINDOW, MIN_TWAP_WINDOW, MAX_TWAP_DEVIATION_BPS, check, isSane, prepare
  • contracts/src/libraries/ZapExec.sol balanceAndIncrease and balanceAndMint, the router and mint bounds
  • contracts/test/PositionKeeper.t.sol the split, the interval, the hysteresis, the manipulated-price and TWAP-valuation cases, OwnerChanged
  • packages/core/src/policy.ts the mirrored Policy and the shouldHarvest / shouldRebalance predicates the UI uses
  • apps/web/app/(app)/portfolio/EnrollDrawer.tsx setApprovalForAll then enroll, with DEFAULT_POLICY from lib/portfolio/view.ts
  • apps/api/src/bots/keeper.ts sweepEnrollments - the views via Multicall3 and the bounty in wei
  • apps/api/src/bots/chain.ts GAS_BOUNTY_MULTIPLE and the skip rule