Skip to content

Latest commit

 

History

History
467 lines (365 loc) · 17.8 KB

File metadata and controls

467 lines (365 loc) · 17.8 KB

Accounting

Roshi accounting is base-denominated and share-based. Users own shares, and shares represent a pro rata claim on the vault's fee-adjusted net asset value.

Vault State

The accounting-relevant vault fields are:

total_assets: u64,
pending_withdrawal_assets: u64,
fees_payable: u64,
last_report_hash: [u8; 32],
high_watermark: u64,
report_epoch: u64,
requested_withdrawal_shares: u64,
performance_fee_bps: u16,
treasury: Pubkey,
withdrawal_buffer_bps: u16,
last_update_ts: i64,
locked_profit: u64,
profit_unlock_start_ts: i64,
profit_unlock_end_ts: i64,

total_assets is active-share NAV in base atoms after subtracting Roshi-tracked liabilities and newly accrued fees.

locked_profit and the two unlock timestamps carry the profit-unlock drip: reported gains reach the share price linearly over a window, not at once. See Profit Unlock.

pending_withdrawal_assets is the vault-scoped base-asset amount owed across struck withdrawal tickets. It is not tied to any withdrawal subaccount.

report_epoch increments by one for each accepted NAV report. Withdrawal tickets record the epoch in which they were requested, and can only be struck after the configured epoch delay has elapsed.

requested_withdrawal_shares tracks shares that have been burned by redeem requests but have not yet been struck into fixed base-asset claims. These shares remain part of the vault's economic share supply until strike.

fees_payable is the base-asset fee liability accrued during NAV reporting but not yet transferred to the configured treasury token account.

The total supply of shares is the SPL share mint's supply field. Roshi does not mirror active share supply in vault state. For pricing while unstruck withdrawals exist, the economic share supply is:

economic_share_supply = share_mint.supply + requested_withdrawal_shares

Supported Assets

The vault base mint is native to the vault and does not need an Asset PDA. Additional deposit mints use vault-scoped Asset PDAs:

[b"asset", vault, asset_mint]

Each Asset records the non-base mint, oracle configuration, mint decimals, enabled state, pricing mode (direct or routed), and deposit_cap_atoms — an inventory cap on the asset's custody balance (u64::MAX = uncapped). Custody is the deposit sub-account's ATA for the mint, derived rather than stored.

Deposit-time math normalizes each non-base amount into base atoms before minting shares, scaling whole-token oracle prices by mint decimals on-chain. Direct assets price through one asset/base feed; routed assets compose their asset/QUOTE feed over the vault base_oracle's BASE/QUOTE feed.

See Oracles for the oracle contract.

NAV Reporting

ReportNav accepts the marked external value in base atoms:

ReportNav {
    external_value,
    report_hash,
}

external_value is everything the program does not read as idle: venue positions, non-base idle, and any base not held in the vault's current deposit/withdraw custodies. The program reads idle base on-chain from those two pinned custody ATAs — the authority cannot misreport it — and forms gross NAV before subtracting Roshi-tracked liabilities:

gross_nav = on_chain_idle_base + external_value
fee_base_assets = gross_nav - fees_payable - pending_withdrawal_assets
total_assets = fee_base_assets - newly_accrued_fees

report_hash commits to the private NAV report bundle. The bundle can contain position snapshots, venue statements, off-chain balances, internal marks, or reconciliation output. The all-zero report hash is reserved for "no accepted report yet".

RecoverNav carries the same fields and uses the same accounting path. It is available only when shares remain outstanding while the recognized share price rounds to zero. Both vault.nav_authority and vault.admin must sign, and deposits must already be paused. It bypasses only the upward NAV gain bound; the pause remains set after recovery.

The NAV update flow:

  • verify the caller is vault.nav_authority,
  • reject reports arriving sooner than min_report_interval_secs after the previous report (ReportTooFrequent; the first report is exempt),
  • reject an all-zero report_hash,
  • read share_mint.supply and add requested_withdrawal_shares for fee pricing,
  • subtract existing fees_payable and pending_withdrawal_assets,
  • accrue performance fees when share price exceeds high_watermark,
  • reject a report that would raise the net share price by more than max_nav_gain_bps vs. the stored pre-report price (NavGainExceedsBound; no downward bound — losses land whole; skipped when supply or the stored price is zero),
  • increase fees_payable,
  • update high_watermark,
  • increment report_epoch,
  • store last_report_hash,
  • recognize the fee-adjusted NAV: losses land instantly, gains re-lock into the profit-unlock drip (see Profit Unlock),
  • store last_update_ts.

The update fails if arithmetic overflows or if reported gross NAV is less than existing fee and withdrawal liabilities. A NavGainExceedsBound rejection is not an error state for an honest authority: report the capped amount now and roll the remainder into subsequent reports. Rationale for the bound shape is in Economic Controls.

Profit Unlock

Reported gains do not reach the share price at once. At each gain report the whole gain (including any unfinished drip from the previous window) re-locks into locked_profit and unlocks linearly over

window = min(now - last_update_ts, max_unlock_duration_secs)

— the span the gain was earned in, clamped. max_unlock_duration_secs = 0 disables smoothing (gains recognize instantly). Losses always recognize instantly, absorbed by the locked remainder first: effective NAV declines but never jumps upward.

Every share-pricing read uses effective NAV, never raw total_assets:

remaining_locked_profit(now) = locked_profit * (end_ts - now) / (end_ts - start_ts)   // clamped to [0, locked_profit]
effective_total_assets(now)  = total_assets - remaining_locked_profit(now)

This applies to deposit minting, the redeem dust guard, withdrawal-ticket strikes, and atomic-redeem entitlements. Nothing cranks the drip: the three stored fields define a line, and every reader (on-chain and off-chain) interpolates it against the clock at read time.

Two consequences:

  • A depositor entering just before — or during — a drip mints as if the locked profit does not exist yet, and earns it only as it unlocks, at the same rate as every other holder.
  • A redeemer exiting mid-drip is paid at effective NAV and forfeits their slice of the still-locked profit, which socializes pro-rata to remaining holders.

Payouts priced at effective NAV (strikes, atomic redeems) re-anchor the window to (remaining(now), now, end_ts) when they debit total_assets — the same unlock line, restated — so the static invariant locked_profit <= total_assets holds without a clock.

Performance fees and the high watermark are computed on report-time gross, not effective NAV: the gain is fee-charged once, when reported, and the HWM ratchets on the full net price.

NAV and liquidity are separate. Token account balances determine whether queued withdrawals, fee collection, or strategy withdrawals can settle; balances do not recompute total NAV.

Share Price

Vault shares use fixed 9-decimal accounting:

SHARE_DECIMALS = 9

Share decimals do not inherit the vault base mint decimals.

Share price is derived from total_assets and the SPL share mint supply through checked integer math. It is not stored directly.

Deposit and redeem pricing carries a virtual position of 10^(SHARE_DECIMALS - base_decimals) shares against one virtual base atom (the ERC-4626 virtual-offset defense against donation share-price inflation). The first deposit needs no special case: an empty vault prices at exactly

initial_shares = base_atoms * 10^SHARE_DECIMALS / 10^base_decimals

For one whole base unit:

USDC base, 6 decimals: 1_000_000 base atoms -> 1_000_000_000 share atoms
SOL base, 9 decimals: 1_000_000_000 base atoms -> 1_000_000_000 share atoms

See Accounting Math for helper formulas and rounding behavior.

Deposits

Deposits mint shares at the current share price after normalizing the deposit amount into base atoms.

shares_to_mint = floor(base_atoms * (economic_share_supply + virtual_shares) / (effective_total_assets + 1))

where economic_share_supply = share_mint.supply + requested_withdrawal_shares (circulating shares plus shares burned for unstruck withdrawals) and virtual_shares = 10^(SHARE_DECIMALS - base_decimals).

The deposit flow:

  • reject deposits while deposits are paused,
  • if the vault is private, verify the depositor's access proof,
  • for non-base assets, reject when custody_balance + amount would exceed the asset's deposit_cap_atoms (DepositCapExceeded; the custody balance is read live, so the cap self-heals as swaps drain custody),
  • price the deposit in base atoms: directly if asset_mint == vault.base_mint, otherwise through the enabled Asset PDA's configured oracle,
  • reject when the resulting total_assets + base_atoms would exceed the vault's nonzero deposit_cap (DepositCapExceeded),
  • compute shares_to_mint and enforce min_shares_out — no funds move on a slippage failure,
  • transfer the deposit into custody: base assets into custody owned by vault.deposit_sub_account, non-base assets into the Asset's configured custody token account,
  • mint shares to the user,
  • increase total_assets by base_atoms.

Deposits should not change share price except for integer rounding. Deposits that round to zero shares fail.

Redeems And Withdrawals

Redeems burn shares immediately and create queued withdrawal tickets. The ticket is not priced at request time.

request_epoch = vault.report_epoch
requested_withdrawal_shares += shares

The redeem flow:

  • reject new redeems while withdrawals are paused,
  • not require private-vault allowlist membership,
  • reject redeems whose entitlement rounds to zero at the current NAV,
  • burn the user's shares,
  • create an unpriced withdrawal ticket for later settlement,
  • increase requested_withdrawal_shares.

The burn removes the shares from the SPL mint supply, but the vault tracks them as requested_withdrawal_shares so the redeemer remains exposed to NAV changes until the ticket is struck.

Withdrawal ticket PDAs are bounded by vault, share owner, and ticket index:

[b"ticket", vault, owner, ticket_index]

Seeding by owner (not by recipient token account) gives every owner a private ticket-index namespace: redeeming toward someone else's recipient account can never occupy that user's slots.

Each ticket records:

WithdrawalTicket {
    vault: Pubkey,
    owner: Pubkey,
    recipient_token_account: Pubkey,
    ticket_index: u8,
    shares_burned: u64,
    assets_owed: u64,
    request_epoch: u64,
    request_slot: u64,
    bump: u8,
}

assets_owed == 0 means the ticket is unstruck. Once the withdrawal authority processes an eligible unstruck ticket, strike computes:

assets_owed = floor(shares_burned * (effective_total_assets + 1) / (economic_share_supply + virtual_shares))

where economic_share_supply = share_mint.supply + requested_withdrawal_shares immediately before that ticket is struck and virtual_shares is the same virtual-offset position deposits price against. The strike then:

  • decrements requested_withdrawal_shares,
  • moves assets_owed from total_assets into pending_withdrawal_assets,
  • fixes ticket.assets_owed.

A strike that floors to zero is valid: the ticket settles as a zero payout in the same ProcessWithdrawals call and is closed, so dust claims cannot wedge the queue (they cannot be cancelled once strike-eligible).

An owner may have up to 256 open queued tickets per vault. Reusing a slot requires the withdrawal authority to process and clear the existing ticket first (or the owner to cancel it before it becomes strike-eligible).

Tickets are vault-scoped user liabilities, not subaccount-scoped liabilities. vault.withdraw_sub_account only selects the default custody source used when the withdrawal authority pays open tickets.

ProcessWithdrawals strikes eligible unpriced tickets and settles supplied tickets:

  • verify the caller is vault.withdrawal_authority,
  • verify each ticket's vault, owner, recipient, PDA, bump, and nonzero shares_burned,
  • verify the share mint and read active share supply,
  • for unstruck tickets, require vault.report_epoch >= request_epoch + 1 and strike the ticket,
  • verify the configured withdraw custody can pay,
  • transfer owed base assets to each recorded recipient token account,
  • close settled ticket accounts back to their owners,
  • decrement pending_withdrawal_assets.

Processing is atomic. If any transfer cannot be paid, the instruction fails and the tickets remain open and unmodified.

CancelRedeem is a liveness escape. After REDEEM_CANCEL_DELAY_SLOTS, the ticket owner can cancel an unstruck ticket while it is still ineligible for strike (the no-report case). Once cancel_grace_slots have elapsed since the request, cancel re-opens even for a strike-eligible — but still unstruck — ticket: a dead withdrawal authority must not trap the redeemer's funds (0 = escape disabled). Struck tickets are never cancellable; their payout is already fixed. Cancellation remints the originally burned shares, closes the ticket, and decrements requested_withdrawal_shares.

Withdrawal Buffer

withdrawal_buffer_bps is a target, not a hard accounting bucket.

target_idle_assets = ceil(total_assets * withdrawal_buffer_bps / 10_000)

Strategists should manage deployed positions so withdrawal custody can settle queued withdrawals. The vault does not store a separate reserved-assets counter; custody token account balances are the source of truth for settlement capacity.

Fees

Performance fees apply only when gross share price exceeds high_watermark. Fees are denominated in base assets and never accrue as newly minted shares.

During ReportNav, existing fees_payable and pending_withdrawal_assets are removed from the fee base:

fee_base_assets = gross_total_assets - fees_payable - pending_withdrawal_assets

The program then computes newly accrued fees:

economic_share_supply = share_mint.supply + requested_withdrawal_shares
gross_share_price = floor(fee_base_assets * 10^SHARE_DECIMALS / economic_share_supply)
high_watermark_assets = ceil(high_watermark * economic_share_supply / 10^SHARE_DECIMALS)
profit_assets = fee_base_assets - high_watermark_assets
new_fee = floor(profit_assets * performance_fee_bps / 10_000)
net_total_assets = fee_base_assets - new_fee

If high_watermark == 0, the report establishes the baseline and accrues no fee. If gross share price does not exceed the high watermark, no fee accrues and the high watermark is unchanged.

CollectFees settles an existing payable:

CollectFees {
    sub_account,
    amount,
}

The instruction is admin-gated. It transfers base tokens from the supplied vault subaccount's custody account to the configured treasury token account and decrements fees_payable. Collection does not change total_assets; NAV already excluded the fee when it accrued.

WriteDownFees forgives accrued fee liability without moving tokens:

WriteDownFees {
    amount, // 0 < amount <= fees_payable
}

It is admin-gated and decrements fees_payable only; gross NAV is untouched (total_assets is recomputed at the next report from unchanged gross and the smaller liabilities). It exists to unwedge report_nav when losses ate into the fee cushion (gross < fees_payable + pending_withdrawal_assets). Struck withdrawal tickets remain inviolable — losses deeper than the fee cushion leave the vault wedged by design. See Economic Controls.

Future NAV Verification

V1 intentionally trusts the configured NAV authority. Future designs can reduce trust by adding signed report bundles, a quorum of independent attestors, challenge periods, public reconciliation leaves for verifiable assets, or private/zero-knowledge computation. These are research paths, not requirements for the v1 trusted-authority model.

Invariants

  • total_assets equals the last accepted fee-adjusted active-share NAV.
  • fees_payable represents fees already excluded from total_assets.
  • pending_withdrawal_assets represents assets already removed from active share accounting.
  • last_report_hash commits to the private report bundle for the last accepted NAV update.
  • Share mint supply changes only when shares are minted or burned.
  • Deposits increase both assets and shares proportionally after normalization to base atoms.
  • Redeems burn SPL shares at request time but leave assets in total_assets and shares in requested_withdrawal_shares until strike.
  • Striking a withdrawal ticket decreases total_assets and requested_withdrawal_shares proportionally (priced at effective NAV), then fixes assets_owed.
  • Withdrawal tickets are settled only by ProcessWithdrawals.
  • locked_profit <= total_assets always; effective_total_assets(now) <= total_assets for every now; remaining locked profit is monotone non-increasing between reports.
  • Collecting fees does not change total_assets; writing fees down changes neither total_assets nor any token balance.
  • Custody token account balances are the payment source of truth for withdrawals and fee collection.

Non-Goals

  • No base asset Asset PDA.
  • No inverse feeds and no composition beyond the two configured legs (asset leg, and the vault base leg for routed assets).
  • No on-chain recomputation of full portfolio NAV in v1.
  • No multi-asset redemption path in the current design.
  • No withdrawal solver market, discounts, maturity auctions, or deadline market.