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.
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
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.
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_secsafter the previous report (ReportTooFrequent; the first report is exempt), - reject an all-zero
report_hash, - read
share_mint.supplyand addrequested_withdrawal_sharesfor fee pricing, - subtract existing
fees_payableandpending_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_bpsvs. 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.
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.
Vault shares use fixed 9-decimal accounting:
SHARE_DECIMALS = 9Share 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 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 + amountwould exceed the asset'sdeposit_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 enabledAssetPDA's configured oracle, - reject when the resulting
total_assets + base_atomswould exceed the vault's nonzerodeposit_cap(DepositCapExceeded), - compute
shares_to_mintand enforcemin_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_assetsbybase_atoms.
Deposits should not change share price except for integer rounding. Deposits that round to zero shares fail.
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_owedfromtotal_assetsintopending_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 + 1and 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_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.
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.
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.
total_assetsequals the last accepted fee-adjusted active-share NAV.fees_payablerepresents fees already excluded fromtotal_assets.pending_withdrawal_assetsrepresents assets already removed from active share accounting.last_report_hashcommits 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_assetsand shares inrequested_withdrawal_sharesuntil strike. - Striking a withdrawal ticket decreases
total_assetsandrequested_withdrawal_sharesproportionally (priced at effective NAV), then fixesassets_owed. - Withdrawal tickets are settled only by
ProcessWithdrawals. locked_profit <= total_assetsalways;effective_total_assets(now) <= total_assetsfor everynow; remaining locked profit is monotone non-increasing between reports.- Collecting fees does not change
total_assets; writing fees down changes neithertotal_assetsnor any token balance. - Custody token account balances are the payment source of truth for withdrawals and fee collection.
- No base asset
AssetPDA. - 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.