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