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

Security Model

ksUSD is one Anchor program, one vault account the program controls, and one token. There isn't much surface area to attack, which is deliberate.


Access control

Role
Capabilities

Admin

initialize, enable_phoenix, enable_lending, set_oracles, set_pause, update_params, collect_fees, transfer_admin, accept_admin, reset_peak, pay_from_reserve, init_wind_down

Keeper

attest_nav, open_position, close_position, lend_idle_usdc, unlend_usdc, lend_reserve, unlend_reserve. Gated by exact match against authorized_keeper. Note the direction: Pubkey::default() (the post-initialize state) matches no signer, so these stay blocked until the admin pins a real key — it is not a permissionless fallback.

Any user

deposit, withdraw_instant, request_withdrawal, process_withdrawal, claim_wind_down (during wind-down), settle, emergency_close (once tripped). None of these consult the keeper key.

Vault PDA

Signs every token operation for the vault. It's derived from the program, so there's no private key anyone could steal.

Admin control moves in two steps, transfer_admin then accept_admin, so it can't be handed to a wrong or unreachable address by a single mistake. Once mainnet is live the admin becomes a multisig, Squads or similar.


Protections

Protection
Mechanism

Emergency pause

Admin halt of new deposits + new positions; instant withdrawals stay open

Wind-down

Terminal init_wind_down blocks new state; users redeem pro-rata via claim_wind_down

Deposit cap

cached_nav_usdc + usdc_amount > deposit_cap_usdc reverts; 0 fully pauses deposits

Bootstrap residual guard

First deposit reverts if vault USDC ATA is non-empty (BootstrapResidualUsdc) — prevents dilution griefing

Oracle validation

Pyth staleness (5 min) + confidence (2%) checks; revert on either

LST depeg auto-pause

settle reverts AND pauses the vault if jitoSOL/SOL deviates beyond lst_depeg_bps

Integer arithmetic

checked_add / checked_mul everywhere; 128-bit intermediates for NAV math; no floating point

Slippage protection

Jupiter swaps gated by max_swap_slippage_bps (default 0.5%) plus explicit min_*_out arguments

HWM monotonicity

Performance fees never charged twice on the same gains; HWM only goes up

Drawdown guard

emergency_close is callable by anyone once peak − current ≥ emergency_close_dd_bps AND observed across consecutive_dd_settles_required settles

Drawdown latency

Single-tick drawdown reverts with DrawdownTriggerLatent

Mode-switch dwell

min_dwell_seconds (default 12 h) between mode transitions — prevents ping-pong

Funding signal staleness

Opens revert with FundingSignalStale if the EMA hasn't been refreshed within funding_max_staleness_seconds

Funding signal integrity

settle is open to anyone, but only the authorized keeper's call updates the funding EMA. Otherwise any caller could steer the signal (or seed it outright on the first call) to force or block basis entry

Venue circuit breaker

Opens revert with PhoenixMarketNotActive if SOL-PERP is halted, settling, or delisted

Position size caps

The short is bounded by max_position_base_lots in absolute base lots (PerpShortExceedsSizeCap). There is no on-chain open-interest check — OI discipline is applied off-chain when that cap is set

Funding rate sanity

Venue-reported rates outside a hard sanity band revert with FundingRateInsane

NAV change cap

attest_nav deltas bounded by max_nav_change_bps_per_hour

Withdrawal FIFO

Strict FIFO order via WithdrawalNotNextInQueue guard; per-request price-at-process haircut so depositors never extract more than pro-rata

Reserve fund segregation

Reserve USDC sits in its own ATA, lent under a distinct cToken ATA; excluded from effective_nav_usdc


Account-context layering (strategy instructions)

open_position, close_position, and emergency_close each call several other programs in a single transaction: Phoenix, Ember, Kamino, and Jupiter. Since every one of those needs its own set of accounts, the handler has to divide up one long list. It does that in three steps:

  1. Takes the number of accounts per group as u8 arguments.

  2. Splits remaining_accounts using checked_add(...).ok_or(InvalidParams), so an offset that would overflow reverts instead of wrapping around.

  3. Hands each slice to the right helper, which then checks the accounts it was given.

The result is that the program never reads past the accounts it was actually handed, even if someone passes deliberately bad arguments. And each venue still validates its own accounts, so the vault isn't the only thing standing between a bad call and a bad outcome.


Audit status

The program is currently unaudited. An audit is planned before mainnet with a top-tier Solana firm (Ottersec, Sec3, or Neodyme). The scope is small enough to audit properly: one Anchor crate and one state struct.

Internal pre-audit notes are in the source.

Don't deploy significant capital before the audit.


Reporting

Suspected vulnerabilities:

A formal bug bounty stands up alongside the mainnet audit.


Last updated