For the complete documentation index, see llms.txt. This page is also available as Markdown.

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.ts is the loop, duties/ are the individual cranks, shared plumbing is in scripts/lib/. TypeScript + Anchor + Jupiter API. Run with npm run keeper (KEEPER_DRY_RUN=1 to plan only, KEEPER_SIMULATE=1 to build and simulate without sending).


Cadence

Instruction
Frequency
Trigger

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.

  1. Read funding. settle parses 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 with FundingRateInsane.

  2. Stale check. If now - funding_smooth_last_ts > funding_max_staleness_seconds, opens revert with FundingSignalStale. Run settle first.

  3. Threshold + dwell + drawdown latency.

    • If funding_apr_smoothed_bps ≥ funding_threshold_normal_bps AND now − last_mode_change_ts ≥ min_dwell_seconds → call open_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) before emergency_close can fire on a NAV breach — single-tick noise is rejected with DrawdownTriggerLatent.

  4. Build the transaction. Assemble per-instruction remaining_accounts groups (see Instructions) and the Jupiter swap data via the Jupiter /swap-instructions endpoint.

  5. 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 the keys array.

  • 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-instructions HTTP endpoint with userPublicKey = vaultPda, then pass the returned swapInstruction.data as jupiter_swap_data and the returned keys as 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_through itself (both counters start at 0, and process_withdrawal requires request_id == queue_processed_through)

  • Any other request errors with WithdrawalNotNextInQueue

  • Anyone can crank (including the original requester)

  • Rent refunds to the original requester even if a different wallet pays the tx

Liquidity shortfall:

  • Reverts with InsufficientLiquidityBuffer if vault USDC is short of the next usdc_owed

  • Keeper should unlend_usdc first if Kamino USDC can cover the shortfall

  • Otherwise wait for the next close_* to free up USDC


Failure modes

Symptom
Cause
Resolution

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_mode invariants on entry

  • Duplicate calls during a retry storm revert cleanly without state damage

  • settle is 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-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 short

  • KEEPER_AUTO_MARGIN=0 reduces 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