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.
| Parameter | Range | Default | Meaning |
|---|---|---|---|
widthTicks | > 0, multiple of the pool spacing ticks | 1200 (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 ticks | 120 | How far past the edge the tick must sit before a rebalance is allowed. Seed vaults use ten spacings. |
minInterval | ≥ 0 seconds | 3600 | Time that must pass since the enrolment or the last action before the next one. Seed vaults use 6 hours. |
maxSlippageBps | 0 - 9 999 bps | 100 | Bound on the rebalance swap, on the mint ratio and on the value round trip; 10 000 is BadPolicy. Seed vaults use 100. |
compound | true · false | true | Whether harvest puts the remainder back into the position or pays it to you. |
bountyBps | 0 - 300 (MAX_BOUNTY_BPS) bps of fees collected | 50 | Your 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
- 01Approve the operator
npm.setApprovalForAll(positionKeeper, true)on the Uniswap position manager. This is the ERC-721 operator approval, revocable at any time withfalse; the keeper checks it atenroll(NotApproved) because without it every later action would revert inside the position manager, where the error is illegible. - 02Enroll
enroll(tokenId, policy)by the NFT'sownerOf(NotOwnerotherwise). The enrolment records the owner, the policy,active = trueandlastActionAt = block.timestamp, so the first action is at leastminIntervalaway. Enrolling an already enrolled id replaces the enrolment and restarts the clock.Enrolled(tokenId, owner, policy). - 03Change or leave
updatePolicy(tokenId, policy)replaces the rules (owner only, validated the same way, emitsEnrolledagain).unenroll(tokenId)setsactive = falseand emitsUnenrolled; 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.
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
- 01Gate
NotEnrolledif inactive,TooSoonifblock.timestamp < lastActionAt + minInterval,OwnerChangedifnpm.ownerOf(tokenId)is no longer the enrolment's owner.lastActionAtis set to now before anything moves. - 02Collect
npm.collectwith 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. - 03Split each token
_splitruns once per token.PROTOCOL_FEE_BPS(1000) of the amount is transferred to theFeeRouterand reported withtake(token, protocol, owner), so the owner's referrer is credited;bountyBpsof the amount goes tomsg.sender; the rest is the owner's. - 04Compound or payWith
policy.compoundand a non-zero remainder,ZapExec.balanceAndIncreaseswaps the remainder to the position's ratio and adds it back withincreaseLiquidity; whatever the ratio could not absorb is swept to the owner. Otherwise both remainders are transferred to the owner as they are. - 05Emit
Harvested(tokenId, keeper, fees0, fees1, bounty0, bounty1, compounded), wherefees0/fees1are the totals before any split.
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
- 01GateThe same three:
NotEnrolled,TooSoon,OwnerChanged. - 02Out of range by the hysteresis
Ranges.outOfRange(tick, tickLower, tickUpper, hysteresisTicks)istick < tickLower − hortick ≥ tickUpper + h- the upper test is non-strict becausetickUpperitself is already outside a Uniswap range. OtherwiseInRange. - 03TwapGuard.checkSpot must sit within
MAX_TWAP_DEVIATION_BPS(300) of the TWAP over the lastTWAP_WINDOW(30 minutes), and the pool's observation ring must reach back at leastMIN_TWAP_WINDOW(10 minutes).TwapWindowTooShortorPriceDeviationotherwise. The TWAP is kept: the round trip below is valued at it. - 04Unwind
decreaseLiquidityof the whole position,collect,burn.collect − decreaseis 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. - 05Value before
_valueInToken1(have0, have1, twap): both holdings in token1 at the TWAP price. - 06Centre the new range
Ranges.centred(tick, spacing, widthTicks): the tick floored to the spacing,widthTicks / 2floored to the spacing below it, and exactlywidthTicksabove 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. - 07Mint to the owner
ZapExec.balanceAndMint(route, have0, have1, owner): the excess side is swapped on the same pool withamountOutMinimumatmaxSlippageBpsbelow the spot quote, and the position is minted withamount0Min/amount1MinatmaxSlippageBpsbelow 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. - 08Value afterThe minted amounts plus the swept dust, valued in token1 at the same TWAP, must be at least
valueBeforelessmaxSlippageBps. OtherwiseSlippage, and the whole transaction - unwind included - is undone. - 09Move the enrolmentA new
Enrollmentwith the same owner and policy is written undernewTokenIdwithlastActionAt = now; the old one is deleted.Rebalanced(oldTokenId, newTokenId, keeper, tickLower, tickUpper). The function returnsnewTokenId.
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
| Error | When |
|---|---|
NotOwner | enroll by anyone but the NFT's ownerOf; updatePolicy or unenroll by anyone but the enrolment's owner. |
NotApproved | enroll before setApprovalForAll(keeper, true). |
BadPolicy | bountyBps > 300; maxSlippageBps >= 10 000; hysteresisTicks < 0; widthTicks <= 0 or not a multiple of the pool spacing. |
NotEnrolled | updatePolicy, unenroll, harvest or rebalance on an inactive enrolment. |
TooSoon | minInterval has not elapsed since the enrolment or the last action. |
OwnerChanged | the position's live ownerOf is not the address that enrolled it. |
InRange | rebalance while the tick is inside the range widened by hysteresisTicks on both sides. |
TwapWindowTooShort | rebalance while the pool's observation ring reaches back less than 10 minutes (from TwapGuard). |
PriceDeviation | rebalance while spot is more than 300 bps from the 30-minute TWAP (from TwapGuard). |
Slippage | the 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
| Event | Fields |
|---|---|
Enrolled | tokenId (indexed), owner (indexed), policy - on enroll and on updatePolicy |
Unenrolled | tokenId (indexed) |
Harvested | tokenId (indexed), keeper (indexed), fees0, fees1 (totals before the split), bounty0, bounty1, compounded |
Rebalanced | oldTokenId (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.
contracts/src/PositionKeeper.solenroll, updatePolicy, unenroll, harvest, rebalance, shouldHarvest, shouldRebalance, the trust notescontracts/src/libraries/Policy.solthe Policy struct shared with LPVaultcontracts/src/libraries/Ranges.solcentred and outOfRangecontracts/src/libraries/TwapGuard.solTWAP_WINDOW, MIN_TWAP_WINDOW, MAX_TWAP_DEVIATION_BPS, check, isSane, preparecontracts/src/libraries/ZapExec.solbalanceAndIncrease and balanceAndMint, the router and mint boundscontracts/test/PositionKeeper.t.solthe split, the interval, the hysteresis, the manipulated-price and TWAP-valuation cases, OwnerChangedpackages/core/src/policy.tsthe mirrored Policy and the shouldHarvest / shouldRebalance predicates the UI usesapps/web/app/(app)/portfolio/EnrollDrawer.tsxsetApprovalForAll then enroll, with DEFAULT_POLICY from lib/portfolio/view.tsapps/api/src/bots/keeper.tssweepEnrollments - the views via Multicall3 and the bounty in weiapps/api/src/bots/chain.tsGAS_BOUNTY_MULTIPLE and the skip rule