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 SwapAuthority,
39 NavAuthority,
40 WithdrawalAuthority,
41}
42
43#[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 pub max_unlock_duration_secs: u32,
55 pub max_report_age_secs: u32,
59 pub min_report_interval_secs: u32,
62 pub cancel_grace_slots: u32,
66 pub max_nav_gain_bps: u16,
70 pub atomic_redeem_fee_bps: u16,
73 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 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 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 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 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 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 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 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 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 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 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 self.profit_unlock_start_ts = now;
411 }
412 Ok(())
413 }
414
415 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 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 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 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 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 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 assert!(VaultControls::new(0, 0, 0, 0, 60_000, 10_000, 10_000)
750 .validate()
751 .is_ok());
752 }
753
754 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 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 assert_eq!(vault.profit_unlock_end_ts, 700);
826 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 vault.apply_reported_nav(1_700, 550).unwrap();
851
852 assert_eq!(vault.total_assets, 1_700);
853 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 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 assert!(vault.verify_report_fresh(i64::MAX).is_ok());
893
894 vault.controls = VaultControls::new(0, 100, 0, 0, 0, 0, 0);
895 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 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 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 assert!(vault.verify_nav_gain_bound(0, supply).is_ok());
938 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 #[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 #[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}