Skip to Content
Maintenance & Bots

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:

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:

TaskContract methodRequired permission
Take over Crown for a userMineCore.takeoverFor(user, maxPrice)P_TAKEOVER_FOR
Update reign payout routingMineCore.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 rewardsClaimAllHelper.claimShareholderForUser(user, ...)P_CLAIM_SHAREHOLDER_FOR
Withdraw King fallback bucketClaimAllHelper.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 lockVeClaimNFT.unlockExpiredForUser(user, tokenId)P_VE_UNLOCK_EXPIRED_FOR
Update King auto-lock configMineCore.setKingAutoLockConfigForUser(user, ...)P_SET_KING_AUTO_LOCK_CONFIG_FOR
Update Barons auto-compound configShareholderRoyalties.setAutoCompoundConfigForUser(user, ...)P_SET_SHAREHOLDER_AUTOCOMPOUND_CONFIG_FOR
Update vault auto-compound configLpStakingVault7D.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() → canonical CLAIM
  • ShareholderRoyalties.mineCore() → canonical MineCore
  • Cross-checks across MarketRouter, Furnace, VeClaimNFT, ShareholderRoyalties, MineCore, and ClaimToken — 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.sol resolves constructor pins from DEPLOYMENTS_MANIFEST_JSON or deployments/<network>.json
  • MARKET_ROUTER / FURNACE / VECLAIM_NFT / SHAREHOLDER_ROYALTIES / WETH / RESCUE_RECIPIENT env overrides are cross-checks only and must match the manifest
  • rescueRecipient defaults 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):

FieldPurpose
offerIds: uint256[]Buy intent IDs (bonus target escrows) to execute
maxOffers: uint256Clamped to MAX_MAINTENANCE_OFFERS_PER_CALL=50

What poke does (best-effort after canonical preflight succeeds):

  1. VeClaimNFT: checkpointGlobalState()
  2. ShareholderRoyalties: flushPendingShareholderETH()
  3. MarketRouter: executeAutoFurnace(offerId, deadline) for up to N offerIds (still subject to MarketRouter keeper-grace auth; MaintenanceHub passes block.timestamp + _OFFER_DEADLINE_GRACE (300s) as deadline)
  4. Furnace: tick() — accrues LP rewards stream and overflow drip
  5. Forward any WETH delta accrued during poke(args) to caller (non-reverting try/catch; if transfer fails, WETH stays on hub)

Not included in poke (must be called separately):

  • MarketRouter.cancelExpiredListing(tokenId) — expired listings are not swept by poke(args).
  • MarketRouter.cancelExpiredBonusTargetEscrow(offerId) — expired offers are not cancelled by poke(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 older pendingShareholderETH that 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 with Errors.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 MineCore Takeover logs; a poke is justified only when reignId is greater than lastProcessedTakeoverReignId in poke.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 least KEEPER_POKE_LP_TICK_INTERVAL_SECS have passed since the last successful furnace tick (default 3600 in keeper/src/shared/config.ts). Shorter intervals mostly burn gas; accrued CLAIM is unchanged over time, only batching differs.
  • Ve checkpoint: when globalLastTs is older than KEEPER_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: pendingShareholderETH delta exceeds KEEPER_POKE_MIN_PENDING_ETH_DELTA (default 0.01 ETH) since the last check. Rounding dust from onTakeover() 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

TaskMethodBound
Shareholder auto-compoundShareholderRoyalties.compoundForMany(users[], maxUsers)Up to 50 users/call (clamped onchain; caller must be the owner or an allowlisted Baron compound keeper)
Vault auto-compoundLpStakingVault7D.compoundForMany(users[], maxUsers)Up to 50 users/call (clamped onchain; caller must be the owner or an allowlisted LP harvest/compound keeper)
Settle listed locksMarketRouter.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 growthFurnace.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 listingsMarketRouter.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-expiry may still call ShareholderRoyalties.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-staking skips harvests below harvestStakingMinReward / KEEPER_HARVEST_STAKING_MIN_REWARD (default: 1000 CLAIM) to avoid wasting gas on dust.
  • compound-shareholders skips users whose claimable ETH is below 0.01 ETH (hardcoded keeper floor). Users can set their own higher minimum via minEthToCompound in their auto-compound config; the keeper uses whichever is higher.
  • compound-lp skips users below compoundLpMinReward / 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-bonus skips locks whose quoted bonus is below automaxBonusMinReward / 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):

  1. Scan for LockListed events to find active listings (track expiresAtTime and listedAtTime)
  2. If block.timestamp >= expiresAtTime: call cancelExpiredListing(tokenId) (permissionless cleanup)
  3. If block.timestamp < listedAtTime + SETTLEMENT_KEEPER_GRACE_SECONDS and you are neither an allowlisted settlement keeper nor the MarketRouter owner, skip approved listings during the keeper grace window.
  4. 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 via Furnace.furnaceQuoter())
    • Note: user-scoped sell quotes (quoteSellLockToFurnace / quoteSellLockToFurnaceBreakdown) revert while a lock is listed
  5. Call sellListedLockToFurnace(tokenId, deadline) to settle

Expire buy intents task

Cancels expired Buy intents (bonus target escrows) and refunds remaining budget to buyers:

  1. Scan for active buy intents via bonus target escrow events
  2. Check if block.timestamp > intent.expiresAt
  3. 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, minVeOut is computed onchain from a spot quote and the user’s stored maxSlippageBps (the keeper cannot override it).

Slippage and deadlines

ConceptUsage
minClaimOutAerodrome swaps (harvest paths)
minVeOutFurnace 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