> For the complete documentation index, see [llms.txt](https://docs.keystonefi.xyz/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.keystonefi.xyz/for-operators/keeper-bot.md).

# 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/mainnet/keeper-basis.ts`](https://github.com/kamwithak/keystone-contracts/blob/main/scripts/mainnet/keeper-basis.ts). TypeScript + Anchor + Rise SDK (Phoenix) + Jupiter API.

***

## 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                                                                                                 |

***

## 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](/for-developers/instructions.md)) 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 + 1`
* 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)

***

## Related

* [Admin operations](/for-operators/admin-ops.md)
* [Monitoring](/for-operators/monitoring.md)
* [Instructions reference](/for-developers/instructions.md) · [Errors](/for-developers/errors.md)
