A concentrated position earns only while the price is inside its range, and its fees sit in the position until someone collects them. PositionKeeper lets anyone do both chores on a Uniswap v3 position you own: harvest collects the fees, and rebalance moves the position to a fresh range around the current price. You set the rules and the reward for whoever does the work; the NFT never leaves your wallet. You enrol a position from Portfolio.
Enrolling a position
- 01Approve the keeperCall
setApprovalForAll(positionKeeper, true)on the Uniswap v3 position manager. This operator approval covers every v3 position you own, not just the one you enrol; the keeper only touches enrolled ids. Approving a single NFT is not enough:enrollchecks for the operator approval and revertsNotApprovedwithout it. - 02Enrol with a policy
enroll(tokenId, policy)from the NFT's owner (NotOwnerotherwise). The policy is validated, the enrolment records you as owner and starts its clock, so the first action can come no sooner thanminIntervallater. Enrolling an id again replaces its enrolment and restarts the clock. - 03Change or stop
updatePolicyswaps the rules without resetting the clock.unenrollstops all actions at once. Both are owner-only. Revoking the operator approval also stops the keeper, with a less readable revert.
On Portfolio, the Enroll in keeper drawer offers both steps and skips the first when the approval exists; Harvest, Rebalance now and Unenroll buttons sit beside your positions.
Between calls the keeper holds no NFT and no tokens, and it pays only the enrolment's owner, the caller and the FeeRouter.
Your rules
The Policy struct is the same one Levee's vaults run under, and @levee/core mirrors it field for field in policy.ts.
| Name | Allowed | Default | Effect |
|---|---|---|---|
widthTicks | above 0, a multiple of the pool tick spacing ticks | 1200 | Total width of the range a rebalance mints. Presets: 1000, 4500 and 8100 for SPOT, CURVE and WIDE, rounded to the nearest multiple of the spacing. |
hysteresisTicks | 0 or more ticks | 120 | How far beyond the range edge the tick must be before a rebalance is allowed. |
minInterval | 0 or more seconds | 3600 | Time since enrolment or the last action before the next one. |
maxSlippageBps | 0 - 9 999 (app: 1 - 500) bps | 100 | Bounds the swap, the mint ratio and the rebalance round trip. |
compound | true · false | true | Harvest puts your share back into the position, or pays it to you. |
bountyBps | 0 - 300 bps of fees collected | 50 | Paid to whoever calls harvest or rebalance. |
enroll and updatePolicy reject a bounty above MAX_BOUNTY_BPS (300), a slippage of 10 000 or more, negative hysteresis, and a width that is zero, negative or off the pool's spacing, all as BadPolicy. The spacing comes from the position's own pool, so the Enroll drawer rounds each preset to the nearest multiple of it: SPOT becomes 1020 on a 0.30 % pool (spacing 60), CURVE and WIDE become 4600 and 8200 on a 1 % pool (spacing 200). A width you type off the grid is flagged before you sign.
Hysteresis keeps a position that hovers on its edge from being rebalanced again and again, each time paying a bounty and a swap. Nothing but minInterval stops a caller from harvesting crumbs repeatedly to collect bounties. The contract will harvest a single wei once the interval is up, so choose an interval that makes each harvest worth having.
What every action checks first
harvest and rebalance share three gates. The enrolment must be active (NotEnrolled), minInterval must have passed (TooSoon), and the position's current ownerOf must still be the address that enrolled it (OwnerChanged).
Two views answer the same questions without gas. shouldHarvest adds "are there any uncollected fees", including fees already owed to the position. shouldRebalance adds non-zero liquidity, a tick outside the range by the hysteresis, and a passing TWAP check, so a bot is never offered a rebalance the guard would refuse.
Collecting fees
harvest moves the clock first, then collects every fee the position has earned and none of its liquidity. Each token is split on its own:
- 10 % (
PROTOCOL_FEE_BPS= 1000) to theFeeRouter, booked to you so that your referrer earns 20 % of it; bountyBpsto the caller;- the rest is yours.
With compound on, your share is swapped to the range's ratio on the same pool and added back with increaseLiquidity (ZapExec.balanceAndIncrease); whatever the ratio could not take is sent to you. With it off, your share is sent to you as collected. Harvested reports the totals before the split, the two bounties and whether it compounded.
Compounding is bounded only by maxSlippageBps, on the swap and on the add. A harvest has no TWAP check: one interval's fees are at stake, and a guard that refused on every real move would cost more than it saved.
Re-centring a position
- 01Confirm it is out of rangeThe tick must be below
tickLower − hysteresisTicksor at or abovetickUpper + hysteresisTicks(Ranges.outOfRange), orInRange. The upper test includes equality because a Uniswap range stops short oftickUpper. - 02Check the price
TwapGuard.check: spot within 300 bps of the TWAP, orPriceDeviation; at least 10 minutes of oracle history, orTwapWindowTooShort. The TWAP is kept for the value check. - 03UnwindAll liquidity is removed, everything collected and the old NFT burned. The fee portion is split exactly as in a harvest; the principal is not. What remains is valued in token1 at the TWAP.
- 04New range
Ranges.centredputs the lower edge about half the width below the current tick, on the spacing, and the upper edge exactlywidthTicksabove it, so the price always sits inside. Both edges stay within the widest ticks the spacing allows. - 05Swap and mint to you
ZapExec.balanceAndMintsells the excess side on the same pool and mints the new position with you as owner. Anything left over is sent to you. - 06Value afterThe minted amounts plus what was sent back, valued at the same TWAP, must be at least the value before less
maxSlippageBps. Otherwise the whole transaction reverts withSlippage, unwind included. - 07Move the enrolmentYour policy is copied to the new token id with a fresh clock and the old enrolment is deleted. Your operator approval already covers the new NFT.
Rebalancednames the old id, the new id, the caller and the new ticks.
Price protection on a rebalance
Rebalancing hands a stranger a swap of your principal. Three checks stand between that and a bad price.
Before anything moves, the TWAP guard refuses while spot is more than 3 % from the mean price over the last 30 minutes, or over the history there is if that is shorter (TWAP_WINDOW, MAX_TWAP_DEVIATION_BPS). Without it, someone could push the pool, knock your position out of range and rebalance it at the price they created. The guard also needs at least 10 minutes of history (MIN_TWAP_WINDOW). A fresh pool's oracle keeps a single observation that every new block overwrites, so its "average" would just be the latest swap. Anyone can deepen it with Uniswap's increaseObservationCardinalityNext; 60 slots (MIN_OBS_CARDINALITY) cover 10 minutes on a pool that writes less than once every ten seconds. Levee's vault factory does this for its vault pools; the keeper cannot, because it does not know which pools you will enrol.
During the swap, the router refuses to fill below the spot quote less maxSlippageBps, which bounds the swap's own price impact.
After the mint, the value check compares the round trip at the TWAP, not at spot. Valuing at spot would use the very price the swap filled at, so a manipulated spot would look fine. Inside the 3 % band a wrong spot can still cost you up to half the gap: the tests move spot about 2.7 % above the mean and show a loss near 1.35 % that only a TWAP valuation sees. The same check also covers rebalances that needed no swap, or that minted far less than they were offered.
The price of this is delay: after a genuine move, spot runs ahead of the 30-minute mean, so a position can take up to half an hour to qualify, and shouldRebalance says no until then.
If you sell the position
An enrolment pays the address that created it. If you transfer an enrolled NFT without unenrolling, and the new owner has also approved the keeper, a harvest would send their fees to you and a rebalance would mint their whole position to you. So both actions compare the live ownerOf with the enrolment and revert OwnerChanged on a mismatch, and both views return false. The new owner enrols the position again to resume.
Who calls the keeper
Anyone can. In practice Levee's keeper bot does. Every minute by default it reads shouldHarvest and shouldRebalance for every active enrolment in one Multicall3 batch. It values the bounty as bountyBps of the position's uncollected fees in USD, converts that to wei at the ETH price, and sends only when the bounty is at least three times the estimated gas (GAS_BOUNTY_MULTIPLE). A bounty it cannot price is skipped, not sent blind; the fees stay in the position. After a success it leaves that action alone for 5 minutes; after a skip or a failure, for 15 (see Indexer, API and bots).
Reference
Reverts
| Revert | Cause |
|---|---|
NotOwner | enroll by anyone but the NFT's owner; updatePolicy or unenroll by anyone but the enrolment's owner. |
NotApproved | enroll without setApprovalForAll(keeper, true). |
BadPolicy | Bounty above 300, slippage of 10 000 or more, negative hysteresis, or a width that is not a positive multiple of the spacing. |
NotEnrolled | Any action, updatePolicy or unenroll on an inactive enrolment. |
TooSoon | minInterval has not passed. |
OwnerChanged | The NFT's current owner is not the address that enrolled it. |
InRange | rebalance while the tick is inside the range widened by the hysteresis. |
TwapWindowTooShort | rebalance while the pool has less than 10 minutes of oracle history. |
PriceDeviation | rebalance while spot is more than 300 bps from the TWAP. |
Slippage | The rebalance round trip lost more than maxSlippageBps at the TWAP. |
A swap or mint that misses its bound reverts inside Uniswap instead, as "Too little received" or "Price slippage check".
Emitted events
| Event | Emitted by | Carries |
|---|---|---|
Enrolled | enroll, updatePolicy | tokenId, owner, policy |
Unenrolled | unenroll | tokenId |
Harvested | harvest | tokenId, keeper, fees0, fees1 (before the split), bounty0, bounty1, compounded |
Rebalanced | rebalance | oldTokenId, newTokenId, keeper, tickLower, tickUpper |
The indexer stores them in keeperEnrollment and keeperAction, which feed the enrolments shown on Portfolio and the bot's list of positions to check.
contracts/src/PositionKeeper.solenroll, updatePolicy, unenroll, harvest, rebalance and the two should viewscontracts/src/libraries/Policy.solthe Policy struct shared with the vaultscontracts/src/libraries/Ranges.solcentred and outOfRangecontracts/src/libraries/TwapGuard.solthe 30-minute window, the 10-minute minimum, the 300 bps bandcontracts/src/libraries/ZapExec.solbalanceAndIncrease and balanceAndMint, with the swap and mint boundscontracts/test/PositionKeeper.t.solthe split, the interval, the hysteresis, the TWAP cases and OwnerChangedpackages/core/src/policy.tsthe mirrored Policy the app usesapps/web/app/(app)/portfolio/EnrollDrawer.tsxapproval then enroll, with DEFAULT_POLICY from lib/portfolio/view.tsapps/api/src/bots/keeper.tssweepEnrollments: the views in one batch and the bounty in weiapps/api/src/bots/chain.tsGAS_BOUNTY_MULTIPLE and the skip rule