Maintenance and Bots
Permissionless and keeper-gated upkeep surfaces that keep the protocol healthy. A keeper is an off-chain bot that executes recurring maintenance transactions — checkpoints, flushes, settlements, compounding — on behalf of the protocol or individual users.
MaintenanceHub.poke(args) is the primary housekeeping entry point. Auto-compound keepers handle opt-in reward compounding for Barons and LP stakers.
Two categories of upkeep:
- Permissionless bounded upkeep — MaintenanceHub
- Opt-in compounding — per-user best-effort keepers
Policy: No official Crown-taking bot shipped.
Self-run agents (bot-owned wallets)
A self-run agent plays from its own wallet (EOA or smart contract):
- no DelegationHub sessions
- no custody of user funds
- simplest integration surface (agent owns positions and rewards)
Recommended tooling:
- SDK entrypoint:
agents/sdk/ - Dev manual: Agents and automation
Operational defaults:
- simulate before sending
- cap spend for takeovers
- use conservative slippage floors and short deadlines
Opt-in delegated bots (DelegationHub sessions)
DelegationHub enables session-based delegation for user opt-in automation.
Why this exists:
- a bot can execute actions for a user address without taking custody
- sessions are time-limited and revocable
- protocol contracts enforce permissions onchain only after resolving their canonical auth roots; many delegated paths fail closed on live wiring drift before calling
DelegationHub.isAuthorized(...)
Session shape:
delegate(bot executor)perms(bitmask)expiry(unix seconds)
Common delegated tasks:
| Task | Contract method | Required permission |
|---|---|---|
| Take over Crown for a user | MineCore.takeoverFor(user, maxPrice) | P_TAKEOVER_FOR |
| Update reign payout routing | MineCore.setCurrentReignRecipients(...) | P_SET_REIGN_ETH_RECIPIENT / P_SET_REIGN_ETH_RECIPIENT_TO_CALLER_ONLY and/or P_SET_REIGN_CLAIM_RECIPIENT / P_SET_REIGN_CLAIM_RECIPIENT_TO_USER_ONLY |
| Claim Barons rewards | ClaimAllHelper.claimShareholderForUser(user, ...) | P_CLAIM_SHAREHOLDER_FOR |
| Withdraw King fallback bucket | ClaimAllHelper.withdrawKingBalanceForUser(user) | P_WITHDRAW_KING_BUCKET_FOR |
| Claim all (bundle) | ClaimAllHelper.claimAllFor(user, ...) | P_CLAIM_ALL_FOR |
| Enter Furnace for a user (bot pays) | Furnace.enterWithEthFor(user, ...) | P_FURNACE_ENTER_ETH_FOR |
| Maintain veCLAIM lock (extend with bonus) | Furnace.extendWithBonusFor(user, tokenId, durationSeconds, minBonusOut) | P_VE_EXTEND_LOCK_FOR |
| Maintain veCLAIM locks (merge) | VeClaimNFT.mergeLocksForUser(user, fromTokenId, intoTokenId) | P_VE_MERGE_LOCKS_FOR |
| Unlock expired veCLAIM lock | VeClaimNFT.unlockExpiredForUser(user, tokenId) | P_VE_UNLOCK_EXPIRED_FOR |
| Update King auto-lock config | MineCore.setKingAutoLockConfigForUser(user, ...) | P_SET_KING_AUTO_LOCK_CONFIG_FOR |
| Update Barons auto-compound config | ShareholderRoyalties.setAutoCompoundConfigForUser(user, ...) | P_SET_SHAREHOLDER_AUTOCOMPOUND_CONFIG_FOR |
| Update vault auto-compound config | LpStakingVault7D.setAutoCompoundConfigForUser(user, ...) | P_SET_LP_AUTOCOMPOUND_CONFIG_FOR |
Notes:
- Delegated takeover defaults to routing the dethroned-King 75% ETH payout to the bot executor (to support looping).
- King-stream mined CLAIM stays with the user unless the session grants
P_ROUTE_REIGN_CLAIM_TO_CALLER. - Under contention, reverts are normal. Read price right before sending and avoid leaving txs pending for long.
MaintenanceHub.poke(args)
Deployment hardening
Constructor requirements:
The constructor requires marketRouter, furnace, ve, royalties, weth, and rescueRecipient to all be nonzero addresses. It also requires the full canonical bundle to be wired at deploy time (see Wiring safety model):
VeClaimNFT.claimToken()→ canonicalCLAIMShareholderRoyalties.mineCore()→ canonicalMineCore- Cross-checks across
MarketRouter,Furnace,VeClaimNFT,ShareholderRoyalties,MineCore, andClaimToken— every root must agree on one bundle
Do not point it at EOAs, placeholder addresses, or split-brain roots. A bad constructor bundle can look healthy while acting on the wrong surfaces.
Deployment scripts:
script/DeployMaintenanceHub.s.solresolves constructor pins fromDEPLOYMENTS_MANIFEST_JSONordeployments/<network>.jsonMARKET_ROUTER/FURNACE/VECLAIM_NFT/SHAREHOLDER_ROYALTIES/WETH/RESCUE_RECIPIENTenv overrides are cross-checks only and must match the manifestrescueRecipientdefaults to the deployer (msg.sender) if not specified via environment- If the canonical bundle is rewired, redeploy
MaintenanceHub. Its constructor wiring is immutable.
Token rescue:
rescueToken(IERC20 token) is permissionless and transfers the hub’s full balance of token to the immutable rescueRecipient. It reverts for WETH (which is forwarded via bounty logic). Use this to recover accidentally sent ERC20 tokens.
Settlement keeper opt-in:
poke(args) is permissionless, so deployment scripts keep the hub off MarketRouter’s settlement-keeper allowlist by default. Set ALLOW_MAINTENANCE_HUB_SETTLEMENT_KEEPER=true only when you intentionally want any poke caller to execute grace-window executeAutoFurnace offers. Without that flag, offers included in poke are still attempted best-effort but remain active until the grace window expires or an explicit keeper settles them.
Runtime hardening
poke(args) re-checks the full canonical bundle at runtime (see Wiring safety model). If the preflight fails, it reverts before any sub-action.
Signature: poke(PokeArgs args)
PokeArgs (ABI order):
| Field | Purpose |
|---|---|
offerIds: uint256[] | Buy intent IDs (bonus target escrows) to execute |
maxOffers: uint256 | Clamped to MAX_MAINTENANCE_OFFERS_PER_CALL=50 |
What poke does (best-effort after canonical preflight succeeds):
- VeClaimNFT:
checkpointGlobalState() - ShareholderRoyalties:
flushPendingShareholderETH() - MarketRouter:
executeAutoFurnace(offerId, deadline)for up to N offerIds (still subject to MarketRouter keeper-grace auth;MaintenanceHubpassesblock.timestamp + _OFFER_DEADLINE_GRACE(300s) as deadline) - Furnace:
tick()— accrues LP rewards stream and overflow drip - Forward any WETH delta accrued during
poke(args)to caller (non-revertingtry/catch; if transfer fails, WETH stays on hub)
Not included in poke (must be called separately):
MarketRouter.cancelExpiredListing(tokenId)— expired listings are not swept bypoke(args).MarketRouter.cancelExpiredBonusTargetEscrow(offerId)— expired offers are not cancelled bypoke(args).
Keepers that run poke(args) should also run a separate sweep for expired listings and offers (see Keeper patterns below).
Grace-window note: MarketRouter checks msg.sender, not the original EOA. If the hub is allowlisted as a settlement keeper, any poke caller inherits grace-window access. See Settlement keeper opt-in above.
Why the order matters:
- Flushing before
executeAutoFurnace(...)prevents a new lock created later in the same poke from sharing olderpendingShareholderETHthat predates its entry. - If
globalLastTs()remains stale after the bounded checkpoint calls, the flush no-ops and pending ETH stays queued. - Direct
flushPendingShareholderETH()calls revert withErrors.WiringMismatch()on bundle drift;poke(args)catches the same condition earlier in its canonical-bundle preflight.
Repo keeper (keeper/): poke scheduling
The shipped daemon task poke calls MaintenanceHub.poke only when skip-if-idle finds work (no tx on idle cycles):
- New reign: scans
MineCoreTakeoverlogs; a poke is justified only whenreignIdis greater thanlastProcessedTakeoverReignIdinpoke.json. That avoids re-triggering on the same reign while the log scan window overlaps prior blocks. - LP stream /
Furnace.tick: when the stream is active, a poke is justified only if at leastKEEPER_POKE_LP_TICK_INTERVAL_SECShave passed since the last successful furnace tick (default 3600 inkeeper/src/shared/config.ts). Shorter intervals mostly burn gas; accrued CLAIM is unchanged over time, only batching differs. - Ve checkpoint: when
globalLastTsis older thanKEEPER_POKE_STALE_SECS(default 86400s / 24h). This is a safety net for idle periods only; normal takeover-driven pokes keep the checkpoint fresh. - Market: eligible auto-furnace offers (after optional preflight).
- Shareholder flush signal:
pendingShareholderETHdelta exceedsKEEPER_POKE_MIN_PENDING_ETH_DELTA(default 0.01 ETH) since the last check. Rounding dust fromonTakeover()flushes is ignored.
Onchain, every successful poke still runs Furnace.tick inside MaintenanceHub; the keeper’s job is to avoid submitting txs when there is nothing meaningful to do.
Keeper patterns
| Task | Method | Bound |
|---|---|---|
| Shareholder auto-compound | ShareholderRoyalties.compoundForMany(users[], maxUsers) | Up to 50 users/call (clamped onchain; caller must be the owner or an allowlisted Baron compound keeper) |
| Vault auto-compound | LpStakingVault7D.compoundForMany(users[], maxUsers) | Up to 50 users/call (clamped onchain; caller must be the owner or an allowlisted LP harvest/compound keeper) |
| Settle listed locks | MarketRouter.sellListedLockToFurnace(tokenId, deadline) | 1 tokenId/tx (batch offchain; no onchain cap; approved listings are keeper/owner-gated during grace; deadline reverts with DeadlineExpired if block.timestamp > deadline) |
| AutoMax automatic bonus growth | Furnace.claimAutoMaxBonus(tokenId) or Furnace.claimAutoMaxBonusBatch(tokenIds[], maxLocks) | Single: 1 tokenId/tx. Batch: up to 200 locks/call (clamped by MAX_AUTOMAX_BONUS_BATCH). 24h cooldown per lock; ineligible locks return 0 (no revert); permissionless. Keeper skips locks whose quoted bonus is below KEEPER_AUTOMAX_BONUS_MIN_REWARD (default 100 CLAIM) to avoid wasting gas on small accruals |
| Cancel expired listings | MarketRouter.cancelExpiredListing(tokenId) | 1 tokenId/tx (batch offchain; no onchain cap) |
| Expire buy intents (bonus target escrows) | MarketRouter.cancelExpiredBonusTargetEscrow(offerId) | 1 offerId/tx (batch offchain; no onchain cap) |
Optional helper task:
checkpoint-before-expirymay still callShareholderRoyalties.checkpointUser(owner)for locks nearing expiry.- The historical reward-checkpoint mechanism preserves rewards for decaying locks automatically.
- It remains useful for UX / gas smoothing when you want balances crystallized ahead of time.
- A 48-hour per-owner cooldown prevents redundant checkpoints for the same owner within that window.
- Treat
Errors.WiringMismatch()as a hard operator signal — stop retrying until wiring is corrected (see Wiring safety model).
Keeper dust thresholds:
harvest-stakingskips harvests belowharvestStakingMinReward/KEEPER_HARVEST_STAKING_MIN_REWARD(default: 1000 CLAIM) to avoid wasting gas on dust.compound-shareholdersskips users whose claimable ETH is below 0.01 ETH (hardcoded keeper floor). Users can set their own higher minimum viaminEthToCompoundin their auto-compound config; the keeper uses whichever is higher.compound-lpskips users belowcompoundLpMinReward/KEEPER_COMPOUND_LP_MIN_REWARD(default: 1000 CLAIM per user) to avoid dust compounding. Intentionally high to avoid wasting gas; lower as gas economics improve.automax-bonusskips locks whose quoted bonus is belowautomaxBonusMinReward/KEEPER_AUTOMAX_BONUS_MIN_REWARD(default: 100 CLAIM). Locks accrue bonus daily; the keeper waits until the accrued bonus justifies the gas cost, then claims in a single batch transaction.
Sweep listings task
Settles Market listings (limit sells) when the live payout meets or exceeds the seller’s Minimum payout (minClaimOut):
- Scan for
LockListedevents to find active listings (trackexpiresAtTimeandlistedAtTime) - If
block.timestamp >= expiresAtTime: callcancelExpiredListing(tokenId)(permissionless cleanup) - If
block.timestamp < listedAtTime + SETTLEMENT_KEEPER_GRACE_SECONDSand you are neither an allowlisted settlement keeper nor theMarketRouterowner, skip approved listings during the keeper grace window. - Otherwise, compute the live quote payout (already net) and check if
claimOut >= minClaimOut- Read lock info:
VeClaimNFT.getLockInfo(tokenId)→(lockAmount, lockEnd, autoMax, listed) - Quote with:
FurnaceQuoter.quoteSellLockToFurnaceFromInfo(lockAmount, lockEnd, autoMax)(resolve address viaFurnace.furnaceQuoter()) - Note: user-scoped sell quotes (
quoteSellLockToFurnace/quoteSellLockToFurnaceBreakdown) revert while a lock is listed
- Read lock info:
- Call
sellListedLockToFurnace(tokenId, deadline)to settle
Expire buy intents task
Cancels expired Buy intents (bonus target escrows) and refunds remaining budget to buyers:
- Scan for active buy intents via bonus target escrow events
- Check if
block.timestamp > intent.expiresAt - Call
cancelExpiredBonusTargetEscrow(offerId)to refund
Guidance:
- Keepers should be aware that auto-compound pauses when destination locks have < 7 days remaining (non-AutoMax). Users need to extend the lock or switch to a different destination.
- Treat compound calls as best-effort
- Surface pause reasons in UX
- For flows where your bot supplies
minVeOut(direct Furnace entries / claim-and-lock), derive it from the live quote + your slippage policy. - For auto-compound calls,
minVeOutis computed onchain from a spot quote and the user’s storedmaxSlippageBps(the keeper cannot override it).
Slippage and deadlines
| Concept | Usage |
|---|---|
minClaimOut | Aerodrome swaps (harvest paths) |
minVeOut | Furnace entry / compounding paths |
Typical defaults:
- Swap slippage: 0.5–1%
- ve slippage: 0.5–1%
- Swap deadline (onchain DEX paths):
now + SWAP_DEADLINE_SECONDS(currently 300s) - Market sell deadline (
sellLockToFurnace): caller-supplied. The current shipped frontend uses 60s in the Sell now tab and 120s in the Furnace hero Sell modal; bots should choose their own short deadline.
See also
- Agents and Automation — SDK, CRAL pack, and agent architecture
- Bot Sessions (DelegationHub) — session permissions for keepers
- Security, Guardian, Pausing — pause states that affect keeper loops
- Constants Reference — slippage and deadline defaults
- Tutorial: Run a MaintenanceHub bot
- SDK examples:
agents/sdk→example:delegation,example:agent -- --acting-for <user>