Keeper Bot
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.
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.
Reference implementation:
scripts/keeper/—index.tsis the loop,duties/are the individual cranks, shared plumbing is inscripts/lib/. TypeScript + Anchor + Jupiter API. Run withnpm run keeper(KEEPER_DRY_RUN=1to plan only,KEEPER_SIMULATE=1to build and simulate without sending).
Cadence
settle
Every ~1 hour
Refresh funding EMA, drawdown peak, LST depeg check, settle_pnl if open
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
No increase_position or remove_margin — they aren't in the program. v1 ships what prevents a loss; growing a live short and pulling surplus margin back both only save the cost of a close+reopen, which the vault performs on its own at the next mode flip. Surplus margin still raises a margin-surplus warning so an operator can judge whether the drag is worth closing early, but no keeper action can reduce collateral on a live short.
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)
Related
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-neutral 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)
there is no
remove_margin, so no keeper action can reduce collateral on a live shortKEEPER_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.
Last updated