> 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/run-it/keeper-bot/mainnet-checklist.md).

# Mainnet Checklist

{% hint style="info" %}
**TL;DR** ksUSD is not on mainnet. The hard gate is an external audit, which is not yet booked. Everything else — the program, the keeper, the fork proofs — is done. One program, one vault, one mint.

This page is the inventory of what is and is not complete. To execute a launch, follow [the runbook](https://github.com/kamwithak/keystone-contracts/blob/main/docs/MAINNET_LAUNCH_RUNBOOK.md) — the single ordered path, with the key ceremony and a rollback plan per stage.
{% endhint %}

{% hint style="warning" %}
**Pre-deployment (v1).** The perp venue is **Phoenix Perps (Ellipsis Labs)**, USDC margin via **Ember**, integrated by on-chain CPI (Rise SDK). Before it can post margin, the vault's trader PDA needs its `CAN_DEPOSIT`/`CAN_WITHDRAW` capabilities enabled. Our contact at Phoenix has agreed to enable them **once the program is deployed to mainnet**, so this closes after deploy rather than before it. It is requested at Stage 6 and confirmed at Stage 8, and the deposit cap stays at the operator's own seed until the hedge is on — outside money does not arrive before the position it is meant to be in. The self-serve onboarding endpoint still reports itself gated, which is why the agreed path runs through the contact.
{% endhint %}

***

## Phase 1 — Engineering (in progress)

* [x] Single-program rewrite: one `keystone_finance` program, one vault, one share mint
* [x] **Phoenix Perps CPI via the Rise SDK** — Ember USDC-margin path, trader onboarding, funding/position reads (`phoenix-rise` account views). Fork-proven: `open_position` → `close_position`, base lots 0 → −1 → 0, with the Jupiter spot leg
* [x] **Real-Pyth build of the perp path** — `prepare-pyth-fork.ts` injects the real mainnet `PriceUpdateV2` accounts restamped to boot, so the fork round-trip runs the same reader mainnet does (verified 2026-08-23; the fork suite also asserts it rejects stale / skewed / wrong-feed / wide-confidence / bad-owner / bad-discriminator accounts)
* [ ] **Phoenix capability grant** on the vault's trader PDA — flags 6 → 63 (only the two margin bits, `1<<4 | 1<<5`, are required); agreed with our Phoenix contact for after deploy, so this box closes post-mainnet
* [x] Kamino KLend CPI surface (idle USDC lending)
* [x] Jupiter V6 swap CPI helper with explicit `min_*_out` slippage bounds
* [x] Hardened account-context offsets (`checked_add` on all slice math)
* [x] Funding EMA + staleness guard on-chain (`funding_apr_smoothed_bps`, `FundingSignalStale`)
* [x] Drawdown latency (`consecutive_dd_settles_required`, `DrawdownTriggerLatent`)
* [x] LST depeg auto-pause (`lst_depeg_bps`, `LstDepeg`)
* [x] Wind-down terminal path (`init_wind_down`, `claim_wind_down`)
* [x] Mainnet-fork test harness (`scripts/fork/start-fork.sh` + `tests/fork/`) — the verification standard, because **devnet is not a usable target**: Phoenix/Ember, Kamino, Jupiter and Pyth have no devnet deployments to integrate against, so a devnet run proves nothing about the paths that matter
* [x] **Keeper Tier B implemented** — `attest_nav`, automatic regime transitions and `emergency_close`, behind the single `KEEPER_PERP` interlock, which the one `mainnet` profile arms. Proven on a mainnet fork 2026-08-23: `attest_nav` accepted by the program against a live 7-lot short, keeper-built `open_position` simulating green with a real Jupiter route
* [x] **Transaction layer able to carry the perp instructions** — address-lookup-table support in `lib/tx.ts` (a Jupiter route does not fit a static v0 message), and the sizing simulation raised to the 1.4M CU ceiling. It had been running at Solana's 200k default, which made every instruction over 200k unsendable through the duty runner — `open_position` measures \~416k
* [ ] Keeper driven end-to-end against the mainnet fork — `npm run test:fork:keeper` (`scripts/fork/keeper-e2e.ts`) drives the real duties in runner order and exits non-zero on any leg. **6 of 10 legs green today:** preflight, oracle-refresh, settle, the funding-EMA seed, and both Kamino lend cranks. The regime transition reaches `open_position`, Jupiter V6 executes the swap, and the program's own slippage guard then refuses the fill — the pinned `jupiter-route.json` is quoted for a different notional than the driver deposits, so re-quoting the fixture at the driver's size is what closes this. `attest_nav`, `margin-health` and `emergency_close` sit behind it and report skipped rather than passing vacuously
* [x] **Replay-protected admin transfer proven** — `transfer_admin` records the proposal without moving the authority, only the proposed account may accept, and a consumed proposal cannot be replayed. Green on a mainnet fork (`npm run test:fork`)
* [ ] Keeper runs clean for a full day before the first deposit (runbook Stage 6). There is no long-running pre-mainnet soak: the integrations it cranks exist only on mainnet, so the soak is the cap-limited private beta in Phase 4, not a devnet clock
* [x] Bootstrap dry-run against mainnet RPC — verified 2026-08-23: every pinned account resolves on mainnet (mints, Kamino reserve + cToken, both Pyth feeds, Jito stake pool) and `initialize` / `enable_lending` / `set_oracles` all encode cleanly. Nothing sent

***

## Phase 2 — Audit

* [ ] Audit firm engaged (Ottersec / Sec3 / Neodyme — pick one)
* [ ] **Legal position settled — this page and the runbook cover none of it, and it books on someone else's calendar the way the audit does.** At minimum: which entity issues ksUSD and where; terms of service and a risk disclosure the app links; whether the published APY is a forward-looking statement anywhere it appears; whether any jurisdiction is geofenced at the front end; and who is liable if the keeper mis-executes. Answers belong with counsel — what belongs here is the checkbox, so it cannot be skipped by not being written down
* [ ] All issues triaged; high / critical findings resolved
* [ ] Public audit report published in the repo and linked from the whitepaper
* [ ] Bug bounty stood up (Immunefi or equivalent)

***

## Phase 3 — Mainnet deployment

The program deploy itself is one script, run via `npm run mainnet:deploy`, which builds, preflights, deploys, verifies the on-chain binary against the local build, writes the IDL, and records the deploy. Run `--check` first (no transactions), and `--rehearse` against devnet to walk the identical path.

```bash
npm run mainnet:deploy:check     # preflight only, sends nothing
npm run mainnet:deploy           # the real thing
npm run mainnet:deploy:verify    # re-verify on-chain binary vs local build
npm run mainnet:deploy:rehearse  # identical path against devnet
```

* [ ] **Toolchain pinned: `anchor --version` reports 0.32.1**, matching `Anchor.toml`. A stale standalone binary at `~/.cargo/bin/anchor` (0.31.1) shadows the avm shim; `deploy-mainnet.sh` puts `~/.avm/bin` first to compensate, but a hand-run build through the shadowed binary will not reproduce from the audit-locked commit
* [ ] Final program build under audit-locked commit; `anchor idl build` regenerated
* [ ] Verifiable build produced and the sha256 recorded (`anchor build --verifiable`; `solana-verify` 0.4.9 is installed for the on-chain attestation)
* [ ] Admin multisig provisioned and tested on devnet — a Squads-of-1 (<https://squads.xyz/multisig>) with the hardware wallet as sole signer, so signers can be added later without moving authority. Record the **Vault** address, not the Squad address: the Vault (index 0) is the PDA that signs, and it is what `<multisig>` means in every step below
* [ ] `deploy-mainnet.sh --check` clean on the audit-locked commit
* [ ] Program deployed to mainnet at `E7tpCcxtvuTXLAckBGWb1AsndpLQ1Y9hQA3iGYSXz2vJ` — the launch ID, deliberately not rotated. (`--rotate-id` stays available as a *recovery* path: `initialize` runs once per program id, so a bad init is fixed by redeploying under a fresh one)
* [ ] On-chain binary hash verified against the local build (the script does this and fails loudly)
* [ ] On-chain IDL upgraded
* [ ] `deployments/mainnet-<utc>.json` committed as the deploy record
* [ ] `initialize` with conservative launch params — **signed by the program's upgrade authority**, which at this point is still the deploy key. `initialize` is gated on that authority, so moving it to the multisig first turns this and every parameter below into multisig transactions over an empty vault. Hand both authorities over afterwards, not before. The gate is also why there is no race to initialize: nobody else can take the vault. These are what `init-v1.ts` actually sets — keep this list and the script in lockstep.
  * `deposit_cap_usdc` = $500k (venue-derived; recompute with `npm run phoenix:capacity` on the day, raise after 30 days of clean operation)
  * `liquidity_buffer_bps` = 1\_000 (10%)
  * `instant_buffer_bps` = 200 (2% raw-USDC floor for instant withdrawals)
  * `max_swap_slippage_bps` = 50 — the program also enforces an oracle-derived floor on every swap, so this is the tighter of two bounds, not the only one
  * `emergency_close_dd_bps` = 500 (5% drawdown auto-trip)
  * `consecutive_dd_settles_required` = 2
  * `perf_fee_bps` = 2\_000 (20% above HWM)
  * `hurdle_apr_bps` = measured at init from the live USDC lending rate (\~350), not hardcoded
  * `lst_depeg_bps` = 500 (5%, measured against the stake-pool redemption rate, not 1.0)
  * `max_nav_change_bps_per_hour` = **1\_000 (±10%/hr)**
  * `funding_threshold_normal_bps` = **−78** — derived at deploy from the live lending rate; a safety *floor* ("never short into worse than break-even"), not the entry rule; the keeper applies a tighter dynamic threshold above it
  * `min_dwell_seconds` = 43\_200 (12h) **at init, and raised to 21 days at Stage 8, once the first position exists — see below.** It cannot start long: `initialize` stamps `last_mode_change_ts`, and `open_position` measures dwell against it, so a 21-day value here blocks the *first* hedge for 21 days
  * `min_margin_bps` = 900 (bounds effective leverage at \~10x)
  * `authorized_keeper` = the keeper's own pubkey, from `KEEPER_PUBKEY`. The script **refuses** to default it to the admin wallet on mainnet: the keeper runs hot and the admin is meant to be cold, and `initialize` runs once
  * `funding_max_staleness_seconds` = 21\_600 (6h) — how old the funding EMA may be before `open_position` rejects it
  * `min_request_shares` = 1\_000\_000 (1 ksUSD) — dust floor on a withdrawal request
  * `max_pending_queue_usdc` = `u64::MAX` — the queue is deliberately unbounded; `deposit_cap_usdc` is what bounds the vault
* [ ] `set_oracles` with Pyth pull-oracle pubkeys for SOL/USD + jitoSOL/USD. **Not optional and not deferrable:** `settle` hard-requires `oracles_initialized`, and while Idle `settle` is the only writer of `last_nav_attest_ts` — so until this runs, every deposit and withdrawal starts failing `NavStale` 30 minutes after `initialize`
* [ ] **Vault jitoSOL ATA created** — `init-v1.ts` does it as its fifth step, because no instruction does: `open_position` takes the account as a plain `Account<TokenAccount>` rather than `init_if_needed`, so without it the first hedge reverts `AccountNotInitialized`. Confirm the address it prints exists on-chain
* [ ] `enable_phoenix` — onboard the vault PDA as a Phoenix trader (Rise builder path) and pin the Phoenix/Ember accounts
* [ ] `enable_lending` against Kamino USDC reserve
* [ ] **Arm the four guards that ship at `0`,** where `0` means *off*, not *default*. Set with `update_params`, and set them **before `transfer_admin`** — afterwards each change needs a multisig transaction.
  * `max_rebalance_clip_lots` — per-order ceiling on a rebalance, in base lots. It bounds how much **size** one IOC pushes into the book; price is bounded separately by the oracle band on every fill. Measure the book, don't guess
  * `max_position_base_lots` — absolute size ceiling, checked against the *resulting total* rather than the increment
  * `hard_margin_floor_bps` — below this, anyone may `reduce_position` to Margin. Zero leaves margin defence entirely dependent on the keeper being alive
  * `mint_window_seconds` + `mint_limit_per_window_usdc` — the deposit *rate* limit. `deposit_cap_usdc` bounds total size but not how fast it fills; both halves are needed or the guard is off
* [ ] **Check the two that ship already set** — these are not zeroed, so they are a review step rather than an arming step. A wrong value is worse than a zero: zero makes `rebalance_hedge` stop with `RebalanceDisabled`, where a wrong one keeps working off a bad number.
  * `perp_base_lot_lamports` = `10_000_000` (0.01 SOL) — the venue's lot size. Confirm against Phoenix's live SOL-PERP market before the first position
  * `delta_band_bps` = `25` — how far delta may drift before a rebalance
* [ ] `npm run freeze:check` reports every safety guard armed — it decodes the live vault and fails on an unset one, so this is the gate rather than the list above
* [ ] `transfer_admin` → `accept_admin` to the multisig (two-step)
* [ ] Upgrade authority moved to the multisig (`deploy-mainnet.sh --transfer-authority <PK>`), IDL authority moved with it

***

## Phase 4 — Private beta

* [ ] Invite-only; raise `deposit_cap_usdc` $500k → $1M once the launch cap fills cleanly (gated by live OI, not demand)
* [ ] Keeper deployed on Railway (`Dockerfile` + `railway.toml`; see `scripts/keeper/deploy/README.md`) — `railway.toml` declares the correctness constraints (`numReplicas = 1`, `overlapSeconds = 0`, `drainingSeconds = 180`, `sleepApplication = false`, and `requiredMountPath = "/data"`, which fails the deploy without the volume) — plus a dead-man's switch on `KEEPER_HEARTBEAT_URL`. Redundancy is a **warm spare** — a second service kept stopped. Two keepers cranking one vault would plan the same actions from the same state and double-submit; true multi-operator redundancy needs leader election and is post-v1
* [ ] **Raise `min_dwell_seconds` 43\_200 → 1\_814\_400 (21 days), once the first position is open.** A multisig `update_params` by this point, and deliberately not done at init, where it would gate the first entry rather than the whipsaw it exists to stop. Worth doing: on the 286-day window every value up to 7 days reproduces the unguarded result exactly — 12h is not a guard at the cadence the keeper runs — while 21 days removes the two round trips that lasted 9 and 12 days, 80 of the 120 bps that window paid to switch, for +38 bps gross and −0.317% drawdown against −0.392%. It matches the T = 30 days the hysteresis width is already derived from, and it is entry-only, so it can never trap the vault: `close_position`, `reduce_position` and `emergency_close` do not consult it. Program caps it at 30 days
* [ ] `lend_idle_usdc` running so the buffer's excess sits in Kamino productively
* [ ] Monitoring + alerting wired ([/for-operators/monitoring.md](/run-it/keeper-bot/monitoring.md))
* [ ] First `collect_fees` cycle after 30 days
* [ ] Public dashboard with live NAV, share price, position mode, queue depth

***

## Phase 5 — Public launch

* [ ] Raise `deposit_cap_usdc` to $5M, then $25M as TVL fills
* [ ] First composability integration (Kamino accepts ksUSD as collateral)
* [ ] DAO / treasury wrappers announced (Solana-aligned partners)
* [ ] Public docs polish + integrator quickstarts

***

## Rollback / circuit-breaker plan

If any of the following occur in the first 90 days post-launch:

| Trigger                                        | Action                                                                                                                   |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| Audit reveals a missed critical issue          | `set_pause(true)` immediately; if non-recoverable, `init_wind_down` and coordinate redeploy under new program ID         |
| NAV drawdown exceeds 3% within a week          | `emergency_close` + `set_pause(true)`; investigate before resuming                                                       |
| Phoenix or Kamino announce a critical incident | `set_pause(true)` until the dependency is verified safe                                                                  |
| Pyth feed instability for > 1h                 | `set_pause(true)`; instant withdrawals stay open                                                                         |
| LST depeg auto-trip                            | Vault is already paused by `settle`; admin investigates and either unpauses or emergency-closes                          |
| `attest_nav` stalls > 2h in Normal mode        | Vault is already paused by `settle` (`NavAttesterStalled`); restart the NAV duty, or `emergency_close` to unwind to Idle |

> **While paused, instant withdrawals from the liquidity buffer remain available** — but they require a NAV attested within the last 5 minutes, so they stay open only as long as something is refreshing NAV. In `Idle` the permissionless `settle` crank does that, so the buffer really is always open. In `Normal` mode only `attest_nav` does, and if it stops, every user path — deposit, queued withdrawal, and instant withdrawal alike — reverts `NavStale` 30 minutes later. `settle` auto-pauses the vault after 2 hours of this, which is what re-opens the permissionless `emergency_close` path back to `Idle`. The duty is now implemented (Phase 1), but **do not enable Normal mode until you have watched it run for a full day** — in Normal mode it is the only thing standing between the vault and every user path freezing.

***

## Related

* [**Launch runbook**](https://github.com/kamwithak/keystone-contracts/blob/main/docs/MAINNET_LAUNCH_RUNBOOK.md) — the ordered path this page inventories
* [Security model](/reference/security.md) · [Admin operations](/run-it/keeper-bot/admin-ops.md)
* [Pre-mainnet audit notes](https://github.com/kamwithak/keystone-contracts/blob/main/docs/PRE_MAINNET_AUDIT.md)
* [Whitepaper](/how-it-works/whitepaper.md)
