1use 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#[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 pub max_unlock_duration_secs: u32,
54 pub max_report_age_secs: u32,
58 pub min_report_interval_secs: u32,
61 pub cancel_grace_slots: u32,
65 pub max_nav_gain_bps: u16,
69 pub atomic_redeem_fee_bps: u16,
72 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 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 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 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 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 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 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 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 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 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 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 self.profit_unlock_start_ts = now;
406 }
407 Ok(())
408 }
409
410 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 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 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 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 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 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 assert!(VaultControls::new(0, 0, 0, 0, 60_000, 10_000, 10_000)
743 .validate()
744 .is_ok());
745 }
746
747 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 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 assert_eq!(vault.profit_unlock_end_ts, 700);
819 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 vault.apply_reported_nav(1_700, 550).unwrap();
844
845 assert_eq!(vault.total_assets, 1_700);
846 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 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 assert!(vault.verify_report_fresh(i64::MAX).is_ok());
886
887 vault.controls = VaultControls::new(0, 100, 0, 0, 0, 0, 0);
888 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 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 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 assert!(vault.verify_nav_gain_bound(0, supply).is_ok());
931 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 #[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 #[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}