Keeper Bot
The keeper is an off-chain bot that cranks settlement, lending, mode transitions, and withdrawals on a schedule. Mode transitions are automatic: the bot opens or closes the basis when the on-chain smoothed funding signal and guardrails permit, with no manual intervention. The position, NAV-attestation, and lending instructions require the vault's authorized_keeper to sign — so the keeper is a permissioned executor, bounded by the on-chain rules, not a decision-maker. settle, process_withdrawal, and emergency_close are permissionless, so anyone can keep the vault safe and redeemable if the keeper goes offline.
v1 hedges on Phoenix Perps (USDC margin via Ember). The keeper assembles the Phoenix and Ember account groups with the Rise SDK. Mode transitions run automatically off the on-chain funding signal; the keeper bot executes them and cranks settlement, lending, and withdrawals.
Reference implementation: the keeper —
index.tsis the loop,duties/are the individual cranks, shared plumbing is in its shared libraries. TypeScript + Anchor + Jupiter API. Run withnpm run keeper(KEEPER_MODE=dry-runto plan only,KEEPER_MODE=simulateto build and simulate without sending).npm run config:checkvalidates the configuration first and refuses anything that would run but not work.
Cadence
The keeper ticks every KEEPER_INTERVAL seconds — 300 by default, and hard capped at 900 on mainnet, because deposits and withdrawals revert NavStale once NAV is 30 minutes old. That is a liveness constraint, not a preference: the keeper refuses to start with a longer interval.
settle
Every tick
Refresh funding EMA, drawdown peak, LST depeg check, settle_pnl if open. While parked it is the only thing keeping NAV fresh.
rebalance_hedge
When |delta| / NAV clears delta_band_bps (25 = ~every other week)
Sweep the unhedged jitoSOL accrual back onto the short. Bounded by max_rebalance_clip_lots, which admin must set — see the clip.
attest_nav(new_nav_usdc)
After settle
Snap NAV to live position value when on-chain reads can't capture unrealized PnL. Bounded by max_nav_change_bps_per_hour.
open_position
When the smoothed funding signal clears the threshold and dwell has elapsed
One-shot per mode entry
close_position
When the signal falls back through the threshold
One-shot per mode exit
lend_idle_usdc / unlend_usdc
Whenever idle vault USDC exceeds the buffer target by > a margin (or buffer is short)
Keep USDC productive without breaching liquidity_buffer_target
lend_reserve / unlend_reserve
Same, for the reserve fund
Reserve fund stays in Kamino unless an admin draw is queued
process_withdrawal
When the queue has unprocessed requests AND vault holds enough idle USDC for the next request (FIFO)
Permissionless — anyone can crank
emergency_close
When drawdown guard trips OR vault is paused with a position open
Permissionless once tripped
add_margin
Every tick while a position is open, when margin health drops below 2.5
The keeper moves funds on its own here — see below
reduce_position
When the withdrawal queue still falls short after redeeming the whole Kamino position, and that shortfall persists across ticks
De-lever both legs to fund the queue: buy back part of the short and sell the matching slice of jitoSOL, so net exposure never moves. It waits deliberately — tearing down part of the hedge realises slippage on both legs and permanently shrinks the position, so a queue one deposit would satisfy must not trigger it. Also the margin backstop, and permissionless below hard_margin_floor_bps
Every perp fill is priced against the oracle, whoever sent it. open_position, close_position, rebalance_hedge, reduce_position and emergency_close all band the realized fill against the pinned SOL/USD mark by max_swap_slippage_bps, computed from the position delta the trader account records. The keeper's min_base_lots_to_fill / min_quote_lots_to_fill are its own floor on top; the program's bound does not depend on the keeper getting them right, which matters most on emergency_close, where the caller is a stranger.
Margin only ever moves onto the venue to defend the position. Nothing the keeper does reduces collateral on a short that stays the same size — the only way collateral comes back is if the short itself gets smaller, which reduce_position does by shrinking both legs together. Surplus margin raises a margin-surplus warning so an operator can judge whether the drag is worth closing early, and when idle USDC and the Kamino balance can no longer fund a top-up, reduce_position de-levers both legs proportionally rather than pulling collateral out from under the short.
The rebalance clip
initialize turns rebalancing on — delta_band_bps = 25 — but leaves max_rebalance_clip_lots at 0, which means unclipped: one order may take the entire correction. That is deliberate, because the right ceiling is a function of the venue's book depth and the program never sees the book. It is also the one thing that must not be left as it ships.
It matters because rebalance_hedge places an IOC market order with min_base_lots_to_fill = 0 and no price_in_ticks. The fill price is bounded after the fact — assert_fill_price_within bands it against the pinned SOL/USD mark — so the clip is what bounds how much size one order pushes into the book, and how far that order walks the book.
The keeper knows and will warn (clip-unset, clip-oversized) but cannot fix it: update_params is admin-gated. Measure and set it:
Re-measure after any large move in Phoenix volume — the clip is 1% of a typical day, and a book that halves makes yesterday's clip twice as aggressive.
Signal pipeline (open / close decisions)
The normal↔parked transition is decided by the on-chain funding signal; the keeper bot assembles and submits the switch automatically whenever the rules below permit. The pipeline is that funding logic.
Read funding.
settleparses the last funding rate + timestamp from the perp venue's SOL-PERP market and updates the on-chain EMA (funding_apr_smoothed_bps). Sanity-bounded — absurd values revert withFundingRateInsane.Stale check. If
now - funding_smooth_last_ts > funding_max_staleness_seconds, opens revert withFundingSignalStale. Runsettlefirst.Threshold + dwell + drawdown latency.
If
funding_apr_smoothed_bps ≥ funding_threshold_normal_bpsANDnow − last_mode_change_ts ≥ min_dwell_seconds→ callopen_position.Else if in the active mode but the signal decayed below threshold + hysteresis → call
close_position.Drawdown trips require
consecutive_dd_settles_observed ≥ consecutive_dd_settles_required(default 2) beforeemergency_closecan fire on a NAV breach — single-tick noise is rejected withDrawdownTriggerLatent.
Build the transaction. Assemble per-instruction
remaining_accountsgroups (see Instructions) and the Jupiter swap data via the Jupiter/swap-instructionsendpoint.Submit. Wrap with priority-fee compute budget instructions. Retry on slot-out / blockhash-not-found.
Assembling remaining_accounts
Strategy instructions pack multiple CPIs into one transaction
Each group needs a precise account list — wrong order or missing accounts reverts the CPI
Use the venue's official client / API to build each group:
Phoenix margin (Ember) / place_perp_order / settle funding — use the Rise SDK (
github.com/Ellipsis-Labs/rise-public) instruction builders and extract thekeysarray.Kamino lending (
lend_idle_usdc/unlend_usdc/lend_reserve/unlend_reserve) — use the Kamino IDL and PDA derivation utilities to build the USDC reserve deposit/withdraw account groups.Jupiter route_swap — call Jupiter's
/swap-instructionsHTTP endpoint withuserPublicKey = vaultPda, then pass the returnedswapInstruction.dataasjupiter_swap_dataand the returnedkeysas the relevant remaining-accounts group.
Pass per-group counts as the u8 instruction args (
jupiter_account_count,phoenix_deposit_account_count, etc.) so the on-chain handler can split safely.
Process-withdrawal cranking
Strict FIFO:
Next-in-line request_id is
vault.queue_processed_throughitself (both counters start at 0, andprocess_withdrawalrequiresrequest_id == queue_processed_through)Any other request errors with
WithdrawalNotNextInQueueAnyone can crank (including the original requester)
Rent refunds to the original requester even if a different wallet pays the tx
Liquidity shortfall:
Reverts with
InsufficientLiquidityBufferif vault USDC is short of the nextusdc_owedKeeper should
unlend_usdcfirst if Kamino USDC can cover the shortfallOtherwise wait for the next
close_*to free up USDC
Failure modes
MinDwellNotElapsed
Tried to switch modes too soon
Wait for last_mode_change_ts + min_dwell_seconds
FundingThresholdNotMet
Smoothed funding hasn't crossed the threshold
Re-read funding_apr_smoothed_bps
FundingSignalStale
EMA older than funding_max_staleness_seconds
Run settle first
SlippageExceeded
Jupiter route filled at worse price than expected
Refresh the quote, narrow the bound, retry
PhoenixCpiFailed
Phoenix returned an error — usually margin or oracle
Inspect program logs; may need to settle funding first
InsufficientLiquidityBuffer (process_withdrawal)
Not enough idle USDC for the next request
unlend_usdc to refill, or wait for next close_*
LendingBreachesBuffer
lend_idle_usdc would drop the buffer below liquidity_buffer_target
Reduce amount or run unlend_usdc instead
NavChangeExceedsCap
attest_nav delta > max_nav_change_bps_per_hour
Stage with smaller deltas over multiple hours, or admin raises the cap if the position genuinely moved that much
DrawdownTriggerLatent
Drawdown observed but not yet across consecutive settles
Wait one more settle cycle; this is by design
LstDepeg
jitoSOL/SOL deviation > lst_depeg_bps
settle auto-pauses; admin investigates and either unpauses or emergency-closes
Idempotency
All open / close instructions assert
position_modeinvariants on entryDuplicate calls during a retry storm revert cleanly without state damage
settleis fully idempotent within an hour (no state change beyond the EMA refresh)
The keeper moves margin autonomously
Unlike every other duty, margin-health can move vault funds without a human. That is deliberate: liquidation on a delta-hedged short can arrive in minutes, and an alert you read an hour later is not a defence.
The mechanism it defends against is unintuitive — the jitoSOL leg is in the vault and the margin is on Phoenix, and Phoenix can only see its own side. On a rally the short loses margin while the offsetting jitoSOL gain sits somewhere Phoenix cannot count, so a hedged position gets liquidated anyway. emergency_close will not save it, because NAV doesn't fall in a hedged rally.
Bounds on that autonomy:
it can never draw on the withdrawal buffer
total posted margin can never exceed half of effective NAV (enforced on-chain, not just in the keeper)
collateral never falls on a short that stays the same size; de-levering closes part of the position and the matching spot leg together
KEEPER_AUTO_MARGIN=0reduces it to alert-only — but then you must be reachable within minutes
If the keeper is down, or auto-margin is off, the thin-margin policy (min_margin_bps = 900) is no longer justified — close the position rather than leave it sitting. Note the 10× this implies is the venue's view of the perp leg, not portfolio leverage: the vault's total exposure equals its NAV.
Related
Deploying it —
scripts/keeper/deploy/README.md: Railway setup, dead-man's switch, kill switch (KEEPER_READ_ONLY=1), key rotation, and the keeper-down procedureInstructions reference — every entry point and who may call it
Errors — what a revert means
Last updated