Skip to main content

roshi_interface/state/
vault.rs

1//! `Vault` account wire type and decode helpers.
2
3use solana_program_error::{ProgramError, ProgramResult};
4use solana_pubkey::Pubkey;
5use wincode::{deserialize, SchemaRead, SchemaWrite};
6
7use crate::{
8    access::verify_access_merkle_proof,
9    error::RoshiError,
10    math::{
11        checked_u64, mul_div_floor, share_price_from_assets, validate_percentage_bps,
12        BPS_DENOMINATOR, SHARE_DECIMALS,
13    },
14    oracle::OracleConfig,
15    state::VAULT_ACCOUNT_TAG,
16    ID,
17};
18
19const FLAG_FALSE: u8 = 0;
20const FLAG_TRUE: u8 = 1;
21
22const fn flag(value: bool) -> u8 {
23    value as u8
24}
25
26fn bool_flag(flag: u8) -> Result<bool, ProgramError> {
27    match flag {
28        FLAG_FALSE => Ok(false),
29        FLAG_TRUE => Ok(true),
30        _ => Err(RoshiError::InvalidVaultState.into()),
31    }
32}
33
34#[derive(Clone, Copy, Debug, Eq, PartialEq)]
35pub enum Role {
36    Admin,
37    Strategist,
38    SwapAuthority,
39    NavAuthority,
40    WithdrawalAuthority,
41}
42
43/// Admin-configured economic risk controls. Zero disables a control, so the
44/// all-zeros default is "every control off".
45#[derive(
46    Clone, Copy, Debug, Default, Eq, PartialEq, codama_macros::CodamaType, SchemaWrite, SchemaRead,
47)]
48#[wincode(assert_zero_copy)]
49#[repr(C)]
50pub struct VaultControls {
51    /// Clamp on the adaptive profit-unlock window: a reported gain drips over
52    /// `min(now - last_update_ts, max_unlock_duration_secs)`. 0 = gains apply
53    /// instantly (no smoothing).
54    pub max_unlock_duration_secs: u32,
55    /// Atomic redeems reject once the last NAV report is older than this
56    /// (pre-first-report vaults are exempt). Deposits and queued redeems are
57    /// never staleness-gated. 0 = disabled.
58    pub max_report_age_secs: u32,
59    /// Reports arriving sooner than this after the previous report are
60    /// rejected (the first report is exempt). 0 = disabled.
61    pub min_report_interval_secs: u32,
62    /// Strike-eligible unstruck withdrawal tickets become cancellable again
63    /// once `clock.slot >= request_slot + cancel_grace_slots` — the
64    /// withdrawal-authority liveness escape. 0 = escape disabled.
65    pub cancel_grace_slots: u32,
66    /// A report may not move the net share price up by more than this many
67    /// bps vs. the stored pre-report price. May exceed 10_000 (a bound above
68    /// +100% is meaningful). 0 = disabled.
69    pub max_nav_gain_bps: u16,
70    /// Fee on atomic redemptions, retained by the pool for remaining
71    /// holders. At most 10_000.
72    pub atomic_redeem_fee_bps: u16,
73    /// Oracle-valued swap output must be at least input value times
74    /// `1 - max_swap_slippage_bps`. At most 10_000. 0 = disabled.
75    pub max_swap_slippage_bps: u16,
76    _padding: [u8; 2],
77}
78
79impl VaultControls {
80    #[allow(clippy::too_many_arguments)]
81    pub const fn new(
82        max_unlock_duration_secs: u32,
83        max_report_age_secs: u32,
84        min_report_interval_secs: u32,
85        cancel_grace_slots: u32,
86        max_nav_gain_bps: u16,
87        atomic_redeem_fee_bps: u16,
88        max_swap_slippage_bps: u16,
89    ) -> Self {
90        Self {
91            max_unlock_duration_secs,
92            max_report_age_secs,
93            min_report_interval_secs,
94            cancel_grace_slots,
95            max_nav_gain_bps,
96            atomic_redeem_fee_bps,
97            max_swap_slippage_bps,
98            _padding: [0; 2],
99        }
100    }
101
102    pub fn validate(&self) -> ProgramResult {
103        // max_nav_gain_bps is deliberately not percentage-bounded: a gain
104        // bound above +100% is meaningful.
105        validate_percentage_bps(self.atomic_redeem_fee_bps)?;
106        validate_percentage_bps(self.max_swap_slippage_bps)?;
107        Ok(())
108    }
109}
110
111#[derive(Clone, Copy, Debug, Eq, PartialEq, SchemaWrite, SchemaRead)]
112#[wincode(assert_zero_copy)]
113#[repr(C)]
114pub struct Vault {
115    pub base_oracle: OracleConfig,
116    pub total_assets: u64,
117    pub external_assets: u64,
118    pub pending_withdrawal_assets: u64,
119    pub fees_payable: u64,
120    pub high_watermark: u64,
121    pub report_epoch: u64,
122    pub requested_withdrawal_shares: u64,
123    pub last_update_ts: i64,
124    /// Reported profit still locked from past gain reports; drips out
125    /// linearly between the unlock timestamps. Always at most `total_assets`.
126    pub locked_profit: u64,
127    pub profit_unlock_start_ts: i64,
128    pub profit_unlock_end_ts: i64,
129    pub tag: [u8; 32],
130    pub admin: [u8; 32],
131    pub strategist: [u8; 32],
132    pub swap_authority: [u8; 32],
133    pub nav_authority: [u8; 32],
134    pub withdrawal_authority: [u8; 32],
135    pub base_mint: [u8; 32],
136    pub share_mint: [u8; 32],
137    pub treasury: [u8; 32],
138    pub last_report_hash: [u8; 32],
139    pub access_merkle_root: [u8; 32],
140    pub controls: VaultControls,
141    pub performance_fee_bps: u16,
142    pub withdrawal_buffer_bps: u16,
143    pub tag_len: u8,
144    pub base_decimals: u8,
145    pub deposit_sub_account: u8,
146    pub withdraw_sub_account: u8,
147    deposits_paused_flag: u8,
148    withdrawals_paused_flag: u8,
149    manage_paused_flag: u8,
150    private_flag: u8,
151    external_enabled_flag: u8,
152    pub bump: u8,
153    _padding: [u8; 2],
154}
155
156impl Vault {
157    pub const SEED: &'static [u8] = b"vault";
158    pub const MAX_TAG_LEN: usize = 32;
159    pub const SPACE: usize = std::mem::size_of::<Self>() + 1;
160
161    #[allow(clippy::too_many_arguments)]
162    pub fn new(
163        tag: &[u8],
164        admin: [u8; 32],
165        strategist: [u8; 32],
166        swap_authority: [u8; 32],
167        nav_authority: [u8; 32],
168        withdrawal_authority: [u8; 32],
169        base_mint: [u8; 32],
170        share_mint: [u8; 32],
171        base_decimals: u8,
172        base_oracle: OracleConfig,
173        deposit_sub_account: u8,
174        withdraw_sub_account: u8,
175        treasury: [u8; 32],
176        performance_fee_bps: u16,
177        withdrawal_buffer_bps: u16,
178        controls: VaultControls,
179        private: bool,
180        access_merkle_root: [u8; 32],
181        bump: u8,
182    ) -> Result<Self, ProgramError> {
183        Self::validate_config(
184            base_mint,
185            share_mint,
186            base_decimals,
187            performance_fee_bps,
188            withdrawal_buffer_bps,
189        )?;
190        controls.validate()?;
191        base_oracle
192            .validate()
193            .map_err(|_| ProgramError::from(RoshiError::InvalidVaultState))?;
194
195        let (tag, tag_len) = Self::pack_tag(tag)?;
196
197        Ok(Self {
198            base_oracle,
199            total_assets: 0,
200            external_assets: 0,
201            pending_withdrawal_assets: 0,
202            fees_payable: 0,
203            high_watermark: 0,
204            report_epoch: 0,
205            requested_withdrawal_shares: 0,
206            last_update_ts: 0,
207            locked_profit: 0,
208            profit_unlock_start_ts: 0,
209            profit_unlock_end_ts: 0,
210            tag,
211            admin,
212            strategist,
213            swap_authority,
214            nav_authority,
215            withdrawal_authority,
216            base_mint,
217            share_mint,
218            treasury,
219            last_report_hash: [0; 32],
220            access_merkle_root,
221            controls,
222            performance_fee_bps,
223            withdrawal_buffer_bps,
224            tag_len,
225            base_decimals,
226            deposit_sub_account,
227            withdraw_sub_account,
228            deposits_paused_flag: flag(false),
229            withdrawals_paused_flag: flag(false),
230            manage_paused_flag: flag(false),
231            private_flag: flag(private),
232            external_enabled_flag: flag(false),
233            bump,
234            _padding: [0; 2],
235        })
236    }
237
238    /// Decode a `Vault` from raw Roshi account data — the wincode `Account::Vault`
239    /// payload (a one-byte tag then the vault).
240    pub fn from_account_data(data: &[u8]) -> Result<Self, ProgramError> {
241        let (&tag, rest) = data
242            .split_first()
243            .ok_or(ProgramError::from(RoshiError::InvalidVaultAccount))?;
244        if tag != VAULT_ACCOUNT_TAG {
245            return Err(RoshiError::InvalidVaultAccount.into());
246        }
247        let vault: Self =
248            deserialize(rest).map_err(|_| ProgramError::from(RoshiError::InvalidVaultAccount))?;
249        vault.validate_state()?;
250        Ok(vault)
251    }
252
253    pub fn validate_config(
254        base_mint: [u8; 32],
255        share_mint: [u8; 32],
256        base_decimals: u8,
257        performance_fee_bps: u16,
258        withdrawal_buffer_bps: u16,
259    ) -> ProgramResult {
260        validate_percentage_bps(performance_fee_bps)?;
261        validate_percentage_bps(withdrawal_buffer_bps)?;
262
263        if base_mint == share_mint {
264            return Err(ProgramError::InvalidArgument);
265        }
266
267        // Deposit/redeem pricing offsets virtual shares by
268        // 10^(SHARE_DECIMALS - base_decimals); a base mint with more decimals
269        // than the share mint has no valid offset.
270        if base_decimals > SHARE_DECIMALS {
271            return Err(RoshiError::InvalidDecimals.into());
272        }
273
274        Ok(())
275    }
276
277    pub fn pack_tag(tag: &[u8]) -> Result<([u8; Self::MAX_TAG_LEN], u8), ProgramError> {
278        Self::validate_tag(tag)?;
279
280        let mut packed_tag = [0; Self::MAX_TAG_LEN];
281        packed_tag[..tag.len()].copy_from_slice(tag);
282
283        Ok((packed_tag, tag.len() as u8))
284    }
285
286    pub fn unpack_tag(tag: &[u8; Self::MAX_TAG_LEN], tag_len: u8) -> Result<&[u8], ProgramError> {
287        let tag_len = usize::from(tag_len);
288        let tag = tag
289            .get(..tag_len)
290            .ok_or(ProgramError::from(RoshiError::InvalidVaultTag))?;
291        Self::validate_tag(tag)?;
292
293        Ok(tag)
294    }
295
296    pub fn tag_seed(&self) -> Result<&[u8], ProgramError> {
297        Self::unpack_tag(&self.tag, self.tag_len)
298    }
299
300    pub fn find_address(tag: &[u8], base_mint: &Pubkey) -> Result<(Pubkey, u8), ProgramError> {
301        Self::validate_tag(tag)?;
302
303        Ok(Pubkey::find_program_address(
304            &[Self::SEED, tag, base_mint.as_ref()],
305            &ID,
306        ))
307    }
308
309    fn validate_tag(tag: &[u8]) -> ProgramResult {
310        if tag.is_empty() || tag.len() > Self::MAX_TAG_LEN {
311            return Err(RoshiError::InvalidVaultTag.into());
312        }
313
314        Ok(())
315    }
316
317    pub fn authority_for_role(&self, role: Role) -> Pubkey {
318        match role {
319            Role::Admin => Pubkey::from(self.admin),
320            Role::Strategist => Pubkey::from(self.strategist),
321            Role::SwapAuthority => Pubkey::from(self.swap_authority),
322            Role::NavAuthority => Pubkey::from(self.nav_authority),
323            Role::WithdrawalAuthority => Pubkey::from(self.withdrawal_authority),
324        }
325    }
326
327    pub fn has_role(&self, role: Role, signer: &Pubkey) -> bool {
328        self.authority_for_role(role) == *signer
329    }
330
331    /// Verify `vault_key` is the canonical PDA for this vault's tag and base mint.
332    pub fn verify_address(&self, vault_key: &Pubkey) -> ProgramResult {
333        let base_mint = Pubkey::from(self.base_mint);
334        let (expected_vault_key, expected_bump) = Self::find_address(self.tag_seed()?, &base_mint)?;
335
336        if vault_key != &expected_vault_key || self.bump != expected_bump {
337            return Err(ProgramError::InvalidSeeds);
338        }
339
340        Ok(())
341    }
342
343    /// The economic share supply: circulating shares plus the shares already
344    /// burned for in-flight withdrawals.
345    pub fn economic_share_supply(&self, active_share_supply: u64) -> Result<u64, ProgramError> {
346        active_share_supply
347            .checked_add(self.requested_withdrawal_shares)
348            .ok_or(ProgramError::from(RoshiError::Overflow))
349    }
350
351    /// Reported profit still locked at `now`: the full `locked_profit` until
352    /// the window starts, decaying linearly to zero at the window end.
353    pub fn remaining_locked_profit(&self, now: i64) -> Result<u64, ProgramError> {
354        if now >= self.profit_unlock_end_ts {
355            return Ok(0);
356        }
357        if now <= self.profit_unlock_start_ts {
358            return Ok(self.locked_profit);
359        }
360
361        // start < now < end here, so both spans are positive.
362        let window = self
363            .profit_unlock_end_ts
364            .checked_sub(self.profit_unlock_start_ts)
365            .and_then(|span| u128::try_from(span).ok())
366            .ok_or(ProgramError::from(RoshiError::Overflow))?;
367        let left = self
368            .profit_unlock_end_ts
369            .checked_sub(now)
370            .and_then(|span| u128::try_from(span).ok())
371            .ok_or(ProgramError::from(RoshiError::Overflow))?;
372
373        let remaining = mul_div_floor(u128::from(self.locked_profit), left, window)?;
374        Ok(checked_u64(remaining)?)
375    }
376
377    /// Share-pricing NAV at `now`: `total_assets` minus the profit still
378    /// dripping. Every pricing read (deposit mint, redeem dust guard, ticket
379    /// strike, atomic-redeem entitlement) uses this, never raw `total_assets`.
380    pub fn effective_total_assets(&self, now: i64) -> Result<u64, ProgramError> {
381        let remaining = self.remaining_locked_profit(now)?;
382        self.total_assets
383            .checked_sub(remaining)
384            .ok_or(ProgramError::from(RoshiError::InvalidVaultState))
385    }
386
387    /// Pay `amount` (priced at [`Self::effective_total_assets`]) out of the
388    /// vault at `now`. Re-anchors the unlock window at `now` with the
389    /// still-locked remainder — the unlock line is preserved up to one atom
390    /// of floor rounding, and `amount <= effective` keeps the static
391    /// `locked_profit <= total_assets` invariant true after the debit.
392    pub fn debit_assets_at_effective(&mut self, amount: u64, now: i64) -> ProgramResult {
393        let remaining = self.remaining_locked_profit(now)?;
394        let effective = self
395            .total_assets
396            .checked_sub(remaining)
397            .ok_or(ProgramError::from(RoshiError::InvalidVaultState))?;
398        if amount > effective {
399            return Err(RoshiError::InvalidVaultState.into());
400        }
401
402        self.total_assets = self
403            .total_assets
404            .checked_sub(amount)
405            .ok_or(ProgramError::from(RoshiError::Overflow))?;
406        self.locked_profit = remaining;
407        if remaining > 0 {
408            // remaining > 0 implies now < end, and the clock is monotone past
409            // the recorded start, so start <= now < end holds.
410            self.profit_unlock_start_ts = now;
411        }
412        Ok(())
413    }
414
415    /// Recognize a report's post-fee NAV at `now`. A gain re-locks in full —
416    /// rolling any unfinished drip forward — over the span it was earned in,
417    /// clamped to `controls.max_unlock_duration_secs`. A loss recognizes
418    /// instantly: the locked remainder absorbs it first (`locked_profit = 0`
419    /// with the lower `total_assets` means effective NAV never jumps up).
420    pub fn apply_reported_nav(&mut self, net_total_assets: u64, now: i64) -> ProgramResult {
421        let prior_effective = self.effective_total_assets(now)?;
422
423        if net_total_assets > prior_effective {
424            let gain = net_total_assets
425                .checked_sub(prior_effective)
426                .ok_or(ProgramError::from(RoshiError::Overflow))?;
427            // Adaptive window: gains unlock over the span they were earned
428            // in. No min clamp — rapid reports carry small gains.
429            let elapsed = now.saturating_sub(self.last_update_ts).max(0);
430            let window = elapsed.min(i64::from(self.controls.max_unlock_duration_secs));
431            if window == 0 {
432                self.locked_profit = 0;
433            } else {
434                self.locked_profit = gain;
435            }
436            self.profit_unlock_start_ts = now;
437            self.profit_unlock_end_ts = now
438                .checked_add(window)
439                .ok_or(ProgramError::from(RoshiError::Overflow))?;
440        } else {
441            self.locked_profit = 0;
442            self.profit_unlock_start_ts = now;
443            self.profit_unlock_end_ts = now;
444        }
445
446        self.total_assets = net_total_assets;
447        Ok(())
448    }
449
450    /// Staleness gate: reject when the last report is older than
451    /// `controls.max_report_age_secs`. Applied to atomic redeems only —
452    /// deposits are never staleness-gated (stale-entry capture is bounded by
453    /// the drip and the gain bound; stale-high entry harms only the
454    /// depositor) and queued redeems price later at strike. Pre-first-report
455    /// vaults are exempt (pricing is exactly par via the virtual offset).
456    pub fn verify_report_fresh(&self, now: i64) -> ProgramResult {
457        let max_age = i64::from(self.controls.max_report_age_secs);
458        if self.report_epoch == 0 || max_age == 0 {
459            return Ok(());
460        }
461        if now.saturating_sub(self.last_update_ts) > max_age {
462            return Err(RoshiError::StaleNavReport.into());
463        }
464        Ok(())
465    }
466
467    /// Report rate limit: reject reports arriving sooner than
468    /// `controls.min_report_interval_secs` after the previous one (the first
469    /// report is exempt). Without this, a compromised NAV authority chains
470    /// small in-bound reports past the gain bound.
471    pub fn verify_report_interval(&self, now: i64) -> ProgramResult {
472        let interval = i64::from(self.controls.min_report_interval_secs);
473        if self.report_epoch == 0 || interval == 0 {
474            return Ok(());
475        }
476        if now.saturating_sub(self.last_update_ts) < interval {
477            return Err(RoshiError::ReportTooFrequent.into());
478        }
479        Ok(())
480    }
481
482    /// NAV gain bound: a report may not raise the net share price by more
483    /// than `controls.max_nav_gain_bps` vs. the stored pre-report price. No
484    /// downward bound — honest losses must land in one report. An over-bound
485    /// honest gain is not lost: the authority reports the capped amount and
486    /// rolls the remainder into subsequent reports. Skipped when supply or
487    /// the stored price is zero so post-total-loss recovery cannot wedge.
488    pub fn verify_nav_gain_bound(
489        &self,
490        net_total_assets: u64,
491        economic_share_supply: u64,
492    ) -> ProgramResult {
493        if self.controls.max_nav_gain_bps == 0 || economic_share_supply == 0 {
494            return Ok(());
495        }
496        let pre_price = share_price_from_assets(self.total_assets, economic_share_supply)?;
497        if pre_price == 0 {
498            return Ok(());
499        }
500
501        let new_price = share_price_from_assets(net_total_assets, economic_share_supply)?;
502        let max_price = checked_u64(mul_div_floor(
503            u128::from(pre_price),
504            u128::from(BPS_DENOMINATOR) + u128::from(self.controls.max_nav_gain_bps),
505            u128::from(BPS_DENOMINATOR),
506        )?)?;
507        if new_price > max_price {
508            return Err(RoshiError::NavGainExceedsBound.into());
509        }
510        Ok(())
511    }
512
513    /// Base custody only ever moves through the sub-accounts whose base ATAs
514    /// `report_nav` reads as idle — the vault's current deposit and withdraw
515    /// sub-accounts. External investment, returns, and fee collection are pinned
516    /// to these so the on-chain idle read always covers base in the *current*
517    /// custodies. The admin may repoint either sub-account, but every base
518    /// movement stays consistent with whatever the vault currently designates.
519    ///
520    /// Repointing while the old custody still holds base strands it: the on-chain
521    /// idle read no longer sees it, so the off-chain NAV must fold that balance
522    /// into the reported `external_value`.
523    pub fn verify_idle_sub_account(&self, sub_account: u8) -> ProgramResult {
524        if sub_account == self.deposit_sub_account || sub_account == self.withdraw_sub_account {
525            return Ok(());
526        }
527
528        Err(RoshiError::InvalidSubAccount.into())
529    }
530
531    pub fn verify_manage_enabled(&self) -> ProgramResult {
532        if self.manage_paused()? {
533            return Err(RoshiError::VaultPaused.into());
534        }
535
536        Ok(())
537    }
538
539    pub fn allows_depositor(&self, depositor: &Pubkey, proof: &[[u8; 32]]) -> bool {
540        match self.private() {
541            Ok(false) => true,
542            Ok(true) => verify_access_merkle_proof(depositor, &self.access_merkle_root, proof),
543            Err(_) => false,
544        }
545    }
546
547    pub fn deposits_paused(&self) -> Result<bool, ProgramError> {
548        bool_flag(self.deposits_paused_flag)
549    }
550
551    pub fn withdrawals_paused(&self) -> Result<bool, ProgramError> {
552        bool_flag(self.withdrawals_paused_flag)
553    }
554
555    pub fn manage_paused(&self) -> Result<bool, ProgramError> {
556        bool_flag(self.manage_paused_flag)
557    }
558
559    pub fn private(&self) -> Result<bool, ProgramError> {
560        bool_flag(self.private_flag)
561    }
562
563    pub fn external_enabled(&self) -> Result<bool, ProgramError> {
564        bool_flag(self.external_enabled_flag)
565    }
566
567    pub fn set_deposits_paused(&mut self, deposits_paused: bool) {
568        self.deposits_paused_flag = flag(deposits_paused);
569    }
570
571    pub fn set_withdrawals_paused(&mut self, withdrawals_paused: bool) {
572        self.withdrawals_paused_flag = flag(withdrawals_paused);
573    }
574
575    pub fn set_manage_paused(&mut self, manage_paused: bool) {
576        self.manage_paused_flag = flag(manage_paused);
577    }
578
579    pub fn set_private(&mut self, private: bool) {
580        self.private_flag = flag(private);
581    }
582
583    pub fn set_external_enabled(&mut self, external_enabled: bool) {
584        self.external_enabled_flag = flag(external_enabled);
585    }
586
587    pub fn validate_state(&self) -> ProgramResult {
588        Self::unpack_tag(&self.tag, self.tag_len)?;
589        Self::validate_config(
590            self.base_mint,
591            self.share_mint,
592            self.base_decimals,
593            self.performance_fee_bps,
594            self.withdrawal_buffer_bps,
595        )?;
596        self.base_oracle
597            .validate()
598            .map_err(|_| ProgramError::from(RoshiError::InvalidVaultState))?;
599        self.controls.validate()?;
600        if self.locked_profit > self.total_assets {
601            return Err(RoshiError::InvalidVaultState.into());
602        }
603        if self.profit_unlock_start_ts > self.profit_unlock_end_ts {
604            return Err(RoshiError::InvalidVaultState.into());
605        }
606        bool_flag(self.deposits_paused_flag)?;
607        bool_flag(self.withdrawals_paused_flag)?;
608        bool_flag(self.manage_paused_flag)?;
609        bool_flag(self.private_flag)?;
610        bool_flag(self.external_enabled_flag)?;
611        Ok(())
612    }
613}
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618    use crate::access::{access_merkle_leaf, access_merkle_node};
619    use wincode::{config::DefaultConfig, serialize, SchemaRead, SchemaWrite, TypeMeta};
620
621    fn assert_zero_copy<T>()
622    where
623        T: wincode::ZeroCopy,
624        T: for<'de> SchemaRead<'de, DefaultConfig> + SchemaWrite<DefaultConfig>,
625    {
626        assert_eq!(
627            <T as SchemaRead<'_, DefaultConfig>>::TYPE_META,
628            TypeMeta::Static {
629                size: core::mem::size_of::<T>(),
630                zero_copy: true,
631            }
632        );
633        assert_eq!(
634            <T as SchemaWrite<DefaultConfig>>::TYPE_META,
635            TypeMeta::Static {
636                size: core::mem::size_of::<T>(),
637                zero_copy: true,
638            }
639        );
640    }
641
642    pub(crate) fn new_test_vault(private: bool, access_merkle_root: [u8; 32]) -> Vault {
643        let admin = Pubkey::new_unique();
644        let base_mint = Pubkey::new_unique();
645        let (_, bump) = Vault::find_address(b"test", &base_mint).unwrap();
646
647        Vault::new(
648            b"test",
649            admin.to_bytes(),
650            [2; 32],
651            [3; 32],
652            [4; 32],
653            [5; 32],
654            base_mint.to_bytes(),
655            Pubkey::new_unique().to_bytes(),
656            6,
657            OracleConfig::default(),
658            7,
659            8,
660            [9; 32],
661            100,
662            250,
663            VaultControls::default(),
664            private,
665            access_merkle_root,
666            bump,
667        )
668        .unwrap()
669    }
670
671    #[test]
672    fn new_initializes_default_accounting_and_config() {
673        let vault = new_test_vault(true, [10; 32]);
674
675        assert_eq!(vault.tag_seed().unwrap(), b"test");
676        assert_eq!(vault.strategist, [2; 32]);
677        assert_eq!(vault.swap_authority, [3; 32]);
678        assert_eq!(vault.nav_authority, [4; 32]);
679        assert_eq!(vault.withdrawal_authority, [5; 32]);
680        assert_eq!(vault.base_decimals, 6);
681        assert_eq!(vault.deposit_sub_account, 7);
682        assert_eq!(vault.withdraw_sub_account, 8);
683        assert_eq!(vault.treasury, [9; 32]);
684        assert_eq!(vault.total_assets, 0);
685        assert_eq!(vault.external_assets, 0);
686        assert_eq!(vault.pending_withdrawal_assets, 0);
687        assert_eq!(vault.fees_payable, 0);
688        assert_eq!(vault.high_watermark, 0);
689        assert_eq!(vault.report_epoch, 0);
690        assert_eq!(vault.requested_withdrawal_shares, 0);
691        assert_eq!(vault.locked_profit, 0);
692        assert_eq!(vault.profit_unlock_start_ts, 0);
693        assert_eq!(vault.profit_unlock_end_ts, 0);
694        assert_eq!(vault.performance_fee_bps, 100);
695        assert_eq!(vault.withdrawal_buffer_bps, 250);
696        assert_eq!(vault.controls, VaultControls::default());
697        assert_eq!(vault.last_update_ts, 0);
698        assert_eq!(vault.deposits_paused(), Ok(false));
699        assert_eq!(vault.withdrawals_paused(), Ok(false));
700        assert_eq!(vault.manage_paused(), Ok(false));
701        assert_eq!(vault.private(), Ok(true));
702        assert_eq!(vault.external_enabled(), Ok(false));
703        assert_eq!(vault.access_merkle_root, [10; 32]);
704    }
705
706    #[test]
707    fn from_account_data_round_trips_a_tagged_vault() {
708        let vault = new_test_vault(false, [0; 32]);
709        let mut data = vec![VAULT_ACCOUNT_TAG];
710        data.extend_from_slice(&serialize(&vault).unwrap());
711
712        assert_eq!(Vault::from_account_data(&data).unwrap(), vault);
713    }
714
715    #[test]
716    fn from_account_data_rejects_wrong_tag() {
717        let vault = new_test_vault(false, [0; 32]);
718        let mut data = vec![VAULT_ACCOUNT_TAG + 1];
719        data.extend_from_slice(&serialize(&vault).unwrap());
720
721        assert_eq!(
722            Vault::from_account_data(&data),
723            Err(ProgramError::from(RoshiError::InvalidVaultAccount))
724        );
725    }
726
727    #[test]
728    fn vault_is_zero_copy_with_explicit_padding() {
729        assert_zero_copy::<Vault>();
730        assert_eq!(core::mem::size_of::<VaultControls>(), 24);
731        assert_eq!(core::mem::size_of::<Vault>(), 680);
732        assert_eq!(Vault::SPACE, 681);
733        let vault = new_test_vault(false, [0; 32]);
734        assert_eq!(
735            serialize(&vault).unwrap().len(),
736            core::mem::size_of::<Vault>()
737        );
738    }
739
740    #[test]
741    fn vault_controls_reject_invalid_percentage_bps() {
742        assert!(VaultControls::new(0, 0, 0, 0, 0, 10_001, 0)
743            .validate()
744            .is_err());
745        assert!(VaultControls::new(0, 0, 0, 0, 0, 0, 10_001)
746            .validate()
747            .is_err());
748        // The gain bound is not a percentage; above-100% bounds are legal.
749        assert!(VaultControls::new(0, 0, 0, 0, 60_000, 10_000, 10_000)
750            .validate()
751            .is_ok());
752    }
753
754    /// A vault mid-drip: `locked` profit unlocking linearly over
755    /// `[start, end]` on top of `total` assets.
756    fn drip_vault(total: u64, locked: u64, start: i64, end: i64) -> Vault {
757        let mut vault = new_test_vault(false, [0; 32]);
758        vault.total_assets = total;
759        vault.locked_profit = locked;
760        vault.profit_unlock_start_ts = start;
761        vault.profit_unlock_end_ts = end;
762        vault
763    }
764
765    #[test]
766    fn remaining_locked_profit_interpolates_linearly() {
767        let vault = drip_vault(2_000, 1_000, 0, 100);
768
769        assert_eq!(vault.remaining_locked_profit(-5), Ok(1_000));
770        assert_eq!(vault.remaining_locked_profit(0), Ok(1_000));
771        assert_eq!(vault.remaining_locked_profit(25), Ok(750));
772        assert_eq!(vault.remaining_locked_profit(50), Ok(500));
773        assert_eq!(vault.remaining_locked_profit(99), Ok(10));
774        assert_eq!(vault.remaining_locked_profit(100), Ok(0));
775        assert_eq!(vault.remaining_locked_profit(1_000), Ok(0));
776
777        assert_eq!(vault.effective_total_assets(50), Ok(1_500));
778        assert_eq!(vault.effective_total_assets(100), Ok(2_000));
779    }
780
781    #[test]
782    fn debit_at_effective_re_anchors_without_moving_the_unlock_line() {
783        let mut vault = drip_vault(2_000, 1_000, 0, 100);
784        let expected_remaining_at_70 = vault.remaining_locked_profit(70).unwrap();
785
786        vault.debit_assets_at_effective(1_400, 40).unwrap();
787
788        assert_eq!(vault.total_assets, 600);
789        assert_eq!(vault.locked_profit, 600);
790        assert_eq!(vault.profit_unlock_start_ts, 40);
791        assert_eq!(vault.profit_unlock_end_ts, 100);
792        assert_eq!(
793            vault.remaining_locked_profit(70),
794            Ok(expected_remaining_at_70)
795        );
796        assert!(vault.validate_state().is_ok());
797    }
798
799    #[test]
800    fn debit_at_effective_rejects_amounts_above_effective() {
801        let mut vault = drip_vault(2_000, 1_000, 0, 100);
802        let before = vault;
803
804        // Effective at t=40 is 1_400.
805        assert_eq!(
806            vault.debit_assets_at_effective(1_401, 40),
807            Err(ProgramError::from(RoshiError::InvalidVaultState))
808        );
809        assert_eq!(vault, before);
810    }
811
812    #[test]
813    fn apply_reported_nav_locks_gains_over_the_elapsed_window() {
814        let mut vault = new_test_vault(false, [0; 32]);
815        vault.controls = VaultControls::new(1_000, 0, 0, 0, 0, 0, 0);
816        vault.total_assets = 1_000;
817        vault.last_update_ts = 100;
818
819        vault.apply_reported_nav(1_600, 400).unwrap();
820
821        assert_eq!(vault.total_assets, 1_600);
822        assert_eq!(vault.locked_profit, 600);
823        assert_eq!(vault.profit_unlock_start_ts, 400);
824        // Earned over 300s < 1_000s clamp: drips over the same 300s.
825        assert_eq!(vault.profit_unlock_end_ts, 700);
826        // Effective NAV is continuous through the report.
827        assert_eq!(vault.effective_total_assets(400), Ok(1_000));
828    }
829
830    #[test]
831    fn apply_reported_nav_clamps_the_window() {
832        let mut vault = new_test_vault(false, [0; 32]);
833        vault.controls = VaultControls::new(1_000, 0, 0, 0, 0, 0, 0);
834        vault.total_assets = 1_000;
835        vault.last_update_ts = 0;
836
837        vault.apply_reported_nav(1_600, 5_000).unwrap();
838
839        assert_eq!(vault.profit_unlock_start_ts, 5_000);
840        assert_eq!(vault.profit_unlock_end_ts, 6_000);
841    }
842
843    #[test]
844    fn apply_reported_nav_rolls_unfinished_drip_forward() {
845        let mut vault = drip_vault(1_600, 600, 400, 700);
846        vault.controls = VaultControls::new(1_000, 0, 0, 0, 0, 0, 0);
847        vault.last_update_ts = 400;
848
849        // Mid-drip at t=550: remaining 300, effective 1_300.
850        vault.apply_reported_nav(1_700, 550).unwrap();
851
852        assert_eq!(vault.total_assets, 1_700);
853        // gain = 1_700 - 1_300: the unfinished 300 re-locks with the new 100.
854        assert_eq!(vault.locked_profit, 400);
855        assert_eq!(vault.profit_unlock_start_ts, 550);
856        assert_eq!(vault.profit_unlock_end_ts, 700);
857        assert_eq!(vault.effective_total_assets(550), Ok(1_300));
858    }
859
860    #[test]
861    fn apply_reported_nav_applies_losses_instantly() {
862        let mut vault = drip_vault(1_600, 600, 400, 700);
863        vault.controls = VaultControls::new(1_000, 0, 0, 0, 0, 0, 0);
864        vault.last_update_ts = 400;
865
866        // Effective at t=550 is 1_300; reporting below it is a loss.
867        vault.apply_reported_nav(1_200, 550).unwrap();
868
869        assert_eq!(vault.total_assets, 1_200);
870        assert_eq!(vault.locked_profit, 0);
871        assert_eq!(vault.effective_total_assets(550), Ok(1_200));
872    }
873
874    #[test]
875    fn apply_reported_nav_with_unlock_disabled_recognizes_gains_instantly() {
876        let mut vault = new_test_vault(false, [0; 32]);
877        vault.total_assets = 1_000;
878        vault.last_update_ts = 100;
879
880        vault.apply_reported_nav(1_600, 400).unwrap();
881
882        assert_eq!(vault.locked_profit, 0);
883        assert_eq!(vault.effective_total_assets(400), Ok(1_600));
884    }
885
886    #[test]
887    fn verify_report_fresh_gates_only_configured_post_first_report_vaults() {
888        let mut vault = new_test_vault(false, [0; 32]);
889        vault.last_update_ts = 0;
890
891        // Disabled control: never stale.
892        assert!(vault.verify_report_fresh(i64::MAX).is_ok());
893
894        vault.controls = VaultControls::new(0, 100, 0, 0, 0, 0, 0);
895        // Pre-first-report vaults are exempt.
896        assert!(vault.verify_report_fresh(1_000).is_ok());
897
898        vault.report_epoch = 1;
899        assert!(vault.verify_report_fresh(100).is_ok());
900        assert_eq!(
901            vault.verify_report_fresh(101),
902            Err(ProgramError::from(RoshiError::StaleNavReport))
903        );
904    }
905
906    #[test]
907    fn verify_report_interval_rejects_rapid_reports() {
908        let mut vault = new_test_vault(false, [0; 32]);
909        vault.controls = VaultControls::new(0, 0, 60, 0, 0, 0, 0);
910        vault.last_update_ts = 1_000;
911
912        // The first report is exempt.
913        assert!(vault.verify_report_interval(1_001).is_ok());
914
915        vault.report_epoch = 1;
916        assert_eq!(
917            vault.verify_report_interval(1_059),
918            Err(ProgramError::from(RoshiError::ReportTooFrequent))
919        );
920        assert!(vault.verify_report_interval(1_060).is_ok());
921    }
922
923    #[test]
924    fn verify_nav_gain_bound_caps_upward_price_moves_only() {
925        let mut vault = new_test_vault(false, [0; 32]);
926        vault.controls = VaultControls::new(0, 0, 0, 0, 1_000, 0, 0);
927        vault.total_assets = 1_000;
928        let supply = 1_000_000_000;
929
930        // +10% exactly passes; one atom more is rejected.
931        assert!(vault.verify_nav_gain_bound(1_100, supply).is_ok());
932        assert_eq!(
933            vault.verify_nav_gain_bound(1_101, supply),
934            Err(ProgramError::from(RoshiError::NavGainExceedsBound))
935        );
936        // No downward bound.
937        assert!(vault.verify_nav_gain_bound(0, supply).is_ok());
938        // Skips: supply zero, stored price zero, control disabled.
939        assert!(vault.verify_nav_gain_bound(u64::MAX, 0).is_ok());
940        vault.total_assets = 0;
941        assert!(vault.verify_nav_gain_bound(u64::MAX, supply).is_ok());
942        vault.total_assets = 1_000;
943        vault.controls = VaultControls::default();
944        assert!(vault.verify_nav_gain_bound(u64::MAX, supply).is_ok());
945    }
946
947    mod drip_properties {
948        use super::*;
949        use proptest::prelude::*;
950
951        proptest! {
952            #![proptest_config(ProptestConfig::with_cases(256))]
953
954            /// Remaining locked profit never increases with time and never
955            /// exceeds the locked amount.
956            #[test]
957            fn prop_remaining_locked_is_monotone_and_bounded(
958                locked in 0u64..=1_000_000_000_000,
959                extra in 0u64..=1_000_000_000_000,
960                start in 0i64..=1_000_000_000,
961                window in 0i64..=10_000_000,
962                t1 in -1_000i64..=20_000_000,
963                dt in 0i64..=20_000_000,
964            ) {
965                let vault = drip_vault(
966                    locked.saturating_add(extra),
967                    locked,
968                    start,
969                    start + window,
970                );
971                let early = vault.remaining_locked_profit(start + t1).unwrap();
972                let late = vault.remaining_locked_profit(start + t1 + dt).unwrap();
973
974                prop_assert!(early <= locked);
975                prop_assert!(late <= early);
976            }
977
978            /// Debiting at effective NAV preserves the unlock line up to one
979            /// atom of floor rounding, and keeps the state invariant valid.
980            #[test]
981            fn prop_debit_preserves_unlock_line_within_one_atom(
982                locked in 0u64..=1_000_000_000_000,
983                extra in 0u64..=1_000_000_000_000,
984                start in 0i64..=1_000_000,
985                window in 1i64..=10_000_000,
986                now_offset in 0i64..=10_000_000,
987                t_offset in 0i64..=10_000_000,
988                amount_seed in any::<u64>(),
989            ) {
990                let total = locked.saturating_add(extra);
991                let now = start + now_offset.min(window);
992                let t = now + t_offset.min(window);
993                let mut vault = drip_vault(locked.saturating_add(extra), locked, start, start + window);
994
995                let before = vault.remaining_locked_profit(t).unwrap();
996                let effective = vault.effective_total_assets(now).unwrap();
997                let amount = if effective == 0 { 0 } else { amount_seed % (effective + 1) };
998
999                vault.debit_assets_at_effective(amount, now).unwrap();
1000
1001                let after = vault.remaining_locked_profit(t).unwrap();
1002                prop_assert!(after <= before);
1003                prop_assert!(before - after <= 1);
1004                prop_assert_eq!(vault.total_assets, total - amount);
1005                prop_assert!(vault.validate_state().is_ok());
1006            }
1007        }
1008    }
1009
1010    #[test]
1011    fn validate_state_rejects_locked_profit_above_total_assets() {
1012        let mut vault = new_test_vault(false, [0; 32]);
1013        vault.total_assets = 100;
1014        vault.locked_profit = 101;
1015
1016        assert_eq!(
1017            vault.validate_state(),
1018            Err(ProgramError::from(RoshiError::InvalidVaultState))
1019        );
1020    }
1021
1022    #[test]
1023    fn validate_state_rejects_inverted_unlock_window() {
1024        let mut vault = new_test_vault(false, [0; 32]);
1025        vault.profit_unlock_start_ts = 10;
1026        vault.profit_unlock_end_ts = 9;
1027
1028        assert_eq!(
1029            vault.validate_state(),
1030            Err(ProgramError::from(RoshiError::InvalidVaultState))
1031        );
1032    }
1033
1034    #[test]
1035    fn pause_and_access_flags_use_typed_accessors() {
1036        let mut vault = new_test_vault(false, [0; 32]);
1037
1038        assert_eq!(vault.deposits_paused(), Ok(false));
1039        assert_eq!(vault.withdrawals_paused(), Ok(false));
1040        assert_eq!(vault.manage_paused(), Ok(false));
1041        assert_eq!(vault.private(), Ok(false));
1042        assert_eq!(vault.external_enabled(), Ok(false));
1043
1044        vault.set_deposits_paused(true);
1045        vault.set_withdrawals_paused(true);
1046        vault.set_manage_paused(true);
1047        vault.set_private(true);
1048        vault.set_external_enabled(true);
1049
1050        assert_eq!(vault.deposits_paused(), Ok(true));
1051        assert_eq!(vault.withdrawals_paused(), Ok(true));
1052        assert_eq!(vault.manage_paused(), Ok(true));
1053        assert_eq!(vault.private(), Ok(true));
1054        assert_eq!(vault.external_enabled(), Ok(true));
1055    }
1056
1057    #[test]
1058    fn verify_manage_enabled_rejects_paused_vault() {
1059        let mut vault = new_test_vault(false, [0; 32]);
1060
1061        vault.set_manage_paused(true);
1062
1063        assert_eq!(
1064            vault.verify_manage_enabled(),
1065            Err(ProgramError::from(RoshiError::VaultPaused))
1066        );
1067    }
1068
1069    #[test]
1070    fn unpack_tag_rejects_invalid_tags() {
1071        let (tag, _) = Vault::pack_tag(b"test").unwrap();
1072
1073        assert!(matches!(
1074            Vault::unpack_tag(&tag, 0),
1075            Err(error) if error == ProgramError::from(RoshiError::InvalidVaultTag)
1076        ));
1077        assert!(matches!(
1078            Vault::unpack_tag(&tag, 33),
1079            Err(error) if error == ProgramError::from(RoshiError::InvalidVaultTag)
1080        ));
1081    }
1082
1083    #[test]
1084    fn validate_config_rejects_invalid_bps() {
1085        assert!(matches!(
1086            Vault::validate_config([1; 32], [2; 32], 6, 10_001, 0),
1087            Err(error) if error == ProgramError::from(RoshiError::InvalidBps)
1088        ));
1089    }
1090
1091    #[test]
1092    fn validate_config_rejects_matching_base_and_share_mints() {
1093        assert!(matches!(
1094            Vault::validate_config([1; 32], [1; 32], 6, 0, 0),
1095            Err(ProgramError::InvalidArgument)
1096        ));
1097    }
1098
1099    #[test]
1100    fn validate_config_rejects_base_decimals_above_share_decimals() {
1101        assert!(Vault::validate_config([1; 32], [2; 32], 9, 0, 0).is_ok());
1102        assert!(matches!(
1103            Vault::validate_config([1; 32], [2; 32], 10, 0, 0),
1104            Err(error) if error == ProgramError::from(RoshiError::InvalidDecimals)
1105        ));
1106    }
1107
1108    #[test]
1109    fn from_account_data_rejects_invalid_vault_flags() {
1110        let mut vault = new_test_vault(false, [0; 32]);
1111        vault.manage_paused_flag = 255;
1112        let mut data = vec![VAULT_ACCOUNT_TAG];
1113        data.extend_from_slice(&serialize(&vault).unwrap());
1114
1115        assert_eq!(
1116            Vault::from_account_data(&data),
1117            Err(ProgramError::from(RoshiError::InvalidVaultState))
1118        );
1119    }
1120
1121    #[test]
1122    fn from_account_data_rejects_invalid_base_oracle_kind() {
1123        let vault = new_test_vault(false, [0; 32]);
1124        let mut data = vec![VAULT_ACCOUNT_TAG];
1125        data.extend_from_slice(&serialize(&vault).unwrap());
1126        let oracle_kind_offset = 1
1127            + core::mem::size_of::<crate::oracle::SwitchboardOracleConfig>()
1128            + core::mem::size_of::<crate::oracle::PythOracleConfig>();
1129        data[oracle_kind_offset] = 255;
1130
1131        assert_eq!(
1132            Vault::from_account_data(&data),
1133            Err(ProgramError::from(RoshiError::InvalidVaultState))
1134        );
1135    }
1136
1137    #[test]
1138    fn public_vault_allows_any_depositor_without_proof() {
1139        let vault = new_test_vault(false, [0; 32]);
1140
1141        assert!(vault.allows_depositor(&Pubkey::new_unique(), &[]));
1142        assert!(vault.allows_depositor(&Pubkey::new_unique(), &[[7; 32]]));
1143    }
1144
1145    #[test]
1146    fn private_vault_accepts_valid_access_proof() {
1147        let allowed = Pubkey::new_unique();
1148        let sibling = access_merkle_leaf(&Pubkey::new_unique());
1149        let root = access_merkle_node(&access_merkle_leaf(&allowed), &sibling);
1150        let vault = new_test_vault(true, root);
1151
1152        assert!(vault.allows_depositor(&allowed, &[sibling]));
1153    }
1154
1155    #[test]
1156    fn private_vault_rejects_missing_or_wrong_access_proof() {
1157        let allowed = Pubkey::new_unique();
1158        let sibling = access_merkle_leaf(&Pubkey::new_unique());
1159        let root = access_merkle_node(&access_merkle_leaf(&allowed), &sibling);
1160        let vault = new_test_vault(true, root);
1161
1162        assert!(!vault.allows_depositor(&allowed, &[]));
1163        assert!(!vault.allows_depositor(&Pubkey::new_unique(), &[sibling]));
1164        assert!(!vault.allows_depositor(&allowed, &[[9; 32]]));
1165    }
1166}