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, error::RoshiError, math::validate_percentage_bps,
9    oracle::OracleConfig, state::VAULT_ACCOUNT_TAG, ID,
10};
11
12const FLAG_FALSE: u8 = 0;
13const FLAG_TRUE: u8 = 1;
14
15const fn flag(value: bool) -> u8 {
16    value as u8
17}
18
19fn bool_flag(flag: u8) -> Result<bool, ProgramError> {
20    match flag {
21        FLAG_FALSE => Ok(false),
22        FLAG_TRUE => Ok(true),
23        _ => Err(RoshiError::InvalidVaultState.into()),
24    }
25}
26
27#[derive(Clone, Copy, Debug, Eq, PartialEq)]
28pub enum Role {
29    Admin,
30    Strategist,
31    SwapAuthority,
32    NavAuthority,
33    WithdrawalAuthority,
34}
35
36#[derive(Clone, Copy, Debug, Eq, PartialEq, SchemaWrite, SchemaRead)]
37#[wincode(assert_zero_copy)]
38#[repr(C)]
39pub struct Vault {
40    pub base_oracle: OracleConfig,
41    pub total_assets: u64,
42    pub external_assets: u64,
43    pub pending_withdrawal_assets: u64,
44    pub fees_payable: u64,
45    pub high_watermark: u64,
46    pub report_epoch: u64,
47    pub requested_withdrawal_shares: u64,
48    pub last_update_ts: i64,
49    pub tag: [u8; 32],
50    pub admin: [u8; 32],
51    pub strategist: [u8; 32],
52    pub swap_authority: [u8; 32],
53    pub nav_authority: [u8; 32],
54    pub withdrawal_authority: [u8; 32],
55    pub base_mint: [u8; 32],
56    pub share_mint: [u8; 32],
57    pub treasury: [u8; 32],
58    pub last_report_hash: [u8; 32],
59    pub access_merkle_root: [u8; 32],
60    pub performance_fee_bps: u16,
61    pub withdrawal_buffer_bps: u16,
62    pub tag_len: u8,
63    pub base_decimals: u8,
64    pub deposit_sub_account: u8,
65    pub withdraw_sub_account: u8,
66    deposits_paused_flag: u8,
67    withdrawals_paused_flag: u8,
68    manage_paused_flag: u8,
69    private_flag: u8,
70    external_enabled_flag: u8,
71    pub bump: u8,
72    _padding: [u8; 2],
73}
74
75impl Vault {
76    pub const SEED: &'static [u8] = b"vault";
77    pub const MAX_TAG_LEN: usize = 32;
78    pub const SPACE: usize = std::mem::size_of::<Self>() + 1;
79
80    #[allow(clippy::too_many_arguments)]
81    pub fn new(
82        tag: &[u8],
83        admin: [u8; 32],
84        strategist: [u8; 32],
85        swap_authority: [u8; 32],
86        nav_authority: [u8; 32],
87        withdrawal_authority: [u8; 32],
88        base_mint: [u8; 32],
89        share_mint: [u8; 32],
90        base_decimals: u8,
91        base_oracle: OracleConfig,
92        deposit_sub_account: u8,
93        withdraw_sub_account: u8,
94        treasury: [u8; 32],
95        performance_fee_bps: u16,
96        withdrawal_buffer_bps: u16,
97        private: bool,
98        access_merkle_root: [u8; 32],
99        bump: u8,
100    ) -> Result<Self, ProgramError> {
101        Self::validate_config(
102            base_mint,
103            share_mint,
104            performance_fee_bps,
105            withdrawal_buffer_bps,
106        )?;
107        base_oracle
108            .validate()
109            .map_err(|_| ProgramError::from(RoshiError::InvalidVaultState))?;
110
111        let (tag, tag_len) = Self::pack_tag(tag)?;
112
113        Ok(Self {
114            base_oracle,
115            total_assets: 0,
116            external_assets: 0,
117            pending_withdrawal_assets: 0,
118            fees_payable: 0,
119            high_watermark: 0,
120            report_epoch: 0,
121            requested_withdrawal_shares: 0,
122            last_update_ts: 0,
123            tag,
124            admin,
125            strategist,
126            swap_authority,
127            nav_authority,
128            withdrawal_authority,
129            base_mint,
130            share_mint,
131            treasury,
132            last_report_hash: [0; 32],
133            access_merkle_root,
134            performance_fee_bps,
135            withdrawal_buffer_bps,
136            tag_len,
137            base_decimals,
138            deposit_sub_account,
139            withdraw_sub_account,
140            deposits_paused_flag: flag(false),
141            withdrawals_paused_flag: flag(false),
142            manage_paused_flag: flag(false),
143            private_flag: flag(private),
144            external_enabled_flag: flag(false),
145            bump,
146            _padding: [0; 2],
147        })
148    }
149
150    /// Decode a `Vault` from raw Roshi account data — the wincode `Account::Vault`
151    /// payload (a one-byte tag then the vault).
152    pub fn from_account_data(data: &[u8]) -> Result<Self, ProgramError> {
153        let (&tag, rest) = data
154            .split_first()
155            .ok_or(ProgramError::from(RoshiError::InvalidVaultAccount))?;
156        if tag != VAULT_ACCOUNT_TAG {
157            return Err(RoshiError::InvalidVaultAccount.into());
158        }
159        let vault: Self =
160            deserialize(rest).map_err(|_| ProgramError::from(RoshiError::InvalidVaultAccount))?;
161        vault.validate_state()?;
162        Ok(vault)
163    }
164
165    pub fn validate_config(
166        base_mint: [u8; 32],
167        share_mint: [u8; 32],
168        performance_fee_bps: u16,
169        withdrawal_buffer_bps: u16,
170    ) -> ProgramResult {
171        validate_percentage_bps(performance_fee_bps)?;
172        validate_percentage_bps(withdrawal_buffer_bps)?;
173
174        if base_mint == share_mint {
175            return Err(ProgramError::InvalidArgument);
176        }
177
178        Ok(())
179    }
180
181    pub fn pack_tag(tag: &[u8]) -> Result<([u8; Self::MAX_TAG_LEN], u8), ProgramError> {
182        Self::validate_tag(tag)?;
183
184        let mut packed_tag = [0; Self::MAX_TAG_LEN];
185        packed_tag[..tag.len()].copy_from_slice(tag);
186
187        Ok((packed_tag, tag.len() as u8))
188    }
189
190    pub fn unpack_tag(tag: &[u8; Self::MAX_TAG_LEN], tag_len: u8) -> Result<&[u8], ProgramError> {
191        let tag_len = usize::from(tag_len);
192        let tag = tag
193            .get(..tag_len)
194            .ok_or(ProgramError::from(RoshiError::InvalidVaultTag))?;
195        Self::validate_tag(tag)?;
196
197        Ok(tag)
198    }
199
200    pub fn tag_seed(&self) -> Result<&[u8], ProgramError> {
201        Self::unpack_tag(&self.tag, self.tag_len)
202    }
203
204    pub fn find_address(tag: &[u8], base_mint: &Pubkey) -> Result<(Pubkey, u8), ProgramError> {
205        Self::validate_tag(tag)?;
206
207        Ok(Pubkey::find_program_address(
208            &[Self::SEED, tag, base_mint.as_ref()],
209            &ID,
210        ))
211    }
212
213    fn validate_tag(tag: &[u8]) -> ProgramResult {
214        if tag.is_empty() || tag.len() > Self::MAX_TAG_LEN {
215            return Err(RoshiError::InvalidVaultTag.into());
216        }
217
218        Ok(())
219    }
220
221    pub fn authority_for_role(&self, role: Role) -> Pubkey {
222        match role {
223            Role::Admin => Pubkey::from(self.admin),
224            Role::Strategist => Pubkey::from(self.strategist),
225            Role::SwapAuthority => Pubkey::from(self.swap_authority),
226            Role::NavAuthority => Pubkey::from(self.nav_authority),
227            Role::WithdrawalAuthority => Pubkey::from(self.withdrawal_authority),
228        }
229    }
230
231    pub fn has_role(&self, role: Role, signer: &Pubkey) -> bool {
232        self.authority_for_role(role) == *signer
233    }
234
235    /// Verify `vault_key` is the canonical PDA for this vault's tag and base mint.
236    pub fn verify_address(&self, vault_key: &Pubkey) -> ProgramResult {
237        let base_mint = Pubkey::from(self.base_mint);
238        let (expected_vault_key, expected_bump) = Self::find_address(self.tag_seed()?, &base_mint)?;
239
240        if vault_key != &expected_vault_key || self.bump != expected_bump {
241            return Err(ProgramError::InvalidSeeds);
242        }
243
244        Ok(())
245    }
246
247    /// The economic share supply: circulating shares plus the shares already
248    /// burned for in-flight withdrawals.
249    pub fn economic_share_supply(&self, active_share_supply: u64) -> Result<u64, ProgramError> {
250        active_share_supply
251            .checked_add(self.requested_withdrawal_shares)
252            .ok_or(ProgramError::from(RoshiError::Overflow))
253    }
254
255    /// Base custody only ever moves through the sub-accounts whose base ATAs
256    /// `report_nav` reads as idle — the vault's current deposit and withdraw
257    /// sub-accounts. External investment, returns, and fee collection are pinned
258    /// to these so the on-chain idle read always covers base in the *current*
259    /// custodies. The admin may repoint either sub-account, but every base
260    /// movement stays consistent with whatever the vault currently designates.
261    ///
262    /// Repointing while the old custody still holds base strands it: the on-chain
263    /// idle read no longer sees it, so the off-chain NAV must fold that balance
264    /// into the reported `external_value`.
265    pub fn verify_idle_sub_account(&self, sub_account: u8) -> ProgramResult {
266        if sub_account == self.deposit_sub_account || sub_account == self.withdraw_sub_account {
267            return Ok(());
268        }
269
270        Err(RoshiError::InvalidSubAccount.into())
271    }
272
273    pub fn verify_manage_enabled(&self) -> ProgramResult {
274        if self.manage_paused()? {
275            return Err(RoshiError::VaultPaused.into());
276        }
277
278        Ok(())
279    }
280
281    pub fn allows_depositor(&self, depositor: &Pubkey, proof: &[[u8; 32]]) -> bool {
282        match self.private() {
283            Ok(false) => true,
284            Ok(true) => verify_access_merkle_proof(depositor, &self.access_merkle_root, proof),
285            Err(_) => false,
286        }
287    }
288
289    pub fn deposits_paused(&self) -> Result<bool, ProgramError> {
290        bool_flag(self.deposits_paused_flag)
291    }
292
293    pub fn withdrawals_paused(&self) -> Result<bool, ProgramError> {
294        bool_flag(self.withdrawals_paused_flag)
295    }
296
297    pub fn manage_paused(&self) -> Result<bool, ProgramError> {
298        bool_flag(self.manage_paused_flag)
299    }
300
301    pub fn private(&self) -> Result<bool, ProgramError> {
302        bool_flag(self.private_flag)
303    }
304
305    pub fn external_enabled(&self) -> Result<bool, ProgramError> {
306        bool_flag(self.external_enabled_flag)
307    }
308
309    pub fn set_deposits_paused(&mut self, deposits_paused: bool) {
310        self.deposits_paused_flag = flag(deposits_paused);
311    }
312
313    pub fn set_withdrawals_paused(&mut self, withdrawals_paused: bool) {
314        self.withdrawals_paused_flag = flag(withdrawals_paused);
315    }
316
317    pub fn set_manage_paused(&mut self, manage_paused: bool) {
318        self.manage_paused_flag = flag(manage_paused);
319    }
320
321    pub fn set_private(&mut self, private: bool) {
322        self.private_flag = flag(private);
323    }
324
325    pub fn set_external_enabled(&mut self, external_enabled: bool) {
326        self.external_enabled_flag = flag(external_enabled);
327    }
328
329    pub fn validate_state(&self) -> ProgramResult {
330        Self::unpack_tag(&self.tag, self.tag_len)?;
331        Self::validate_config(
332            self.base_mint,
333            self.share_mint,
334            self.performance_fee_bps,
335            self.withdrawal_buffer_bps,
336        )?;
337        self.base_oracle
338            .validate()
339            .map_err(|_| ProgramError::from(RoshiError::InvalidVaultState))?;
340        bool_flag(self.deposits_paused_flag)?;
341        bool_flag(self.withdrawals_paused_flag)?;
342        bool_flag(self.manage_paused_flag)?;
343        bool_flag(self.private_flag)?;
344        bool_flag(self.external_enabled_flag)?;
345        Ok(())
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use crate::access::{access_merkle_leaf, access_merkle_node};
353    use wincode::{config::DefaultConfig, serialize, SchemaRead, SchemaWrite, TypeMeta};
354
355    fn assert_zero_copy<T>()
356    where
357        T: wincode::ZeroCopy,
358        T: for<'de> SchemaRead<'de, DefaultConfig> + SchemaWrite<DefaultConfig>,
359    {
360        assert_eq!(
361            <T as SchemaRead<'_, DefaultConfig>>::TYPE_META,
362            TypeMeta::Static {
363                size: core::mem::size_of::<T>(),
364                zero_copy: true,
365            }
366        );
367        assert_eq!(
368            <T as SchemaWrite<DefaultConfig>>::TYPE_META,
369            TypeMeta::Static {
370                size: core::mem::size_of::<T>(),
371                zero_copy: true,
372            }
373        );
374    }
375
376    pub(crate) fn new_test_vault(private: bool, access_merkle_root: [u8; 32]) -> Vault {
377        let admin = Pubkey::new_unique();
378        let base_mint = Pubkey::new_unique();
379        let (_, bump) = Vault::find_address(b"test", &base_mint).unwrap();
380
381        Vault::new(
382            b"test",
383            admin.to_bytes(),
384            [2; 32],
385            [3; 32],
386            [4; 32],
387            [5; 32],
388            base_mint.to_bytes(),
389            Pubkey::new_unique().to_bytes(),
390            6,
391            OracleConfig::default(),
392            7,
393            8,
394            [9; 32],
395            100,
396            250,
397            private,
398            access_merkle_root,
399            bump,
400        )
401        .unwrap()
402    }
403
404    #[test]
405    fn new_initializes_default_accounting_and_config() {
406        let vault = new_test_vault(true, [10; 32]);
407
408        assert_eq!(vault.tag_seed().unwrap(), b"test");
409        assert_eq!(vault.strategist, [2; 32]);
410        assert_eq!(vault.swap_authority, [3; 32]);
411        assert_eq!(vault.nav_authority, [4; 32]);
412        assert_eq!(vault.withdrawal_authority, [5; 32]);
413        assert_eq!(vault.base_decimals, 6);
414        assert_eq!(vault.deposit_sub_account, 7);
415        assert_eq!(vault.withdraw_sub_account, 8);
416        assert_eq!(vault.treasury, [9; 32]);
417        assert_eq!(vault.total_assets, 0);
418        assert_eq!(vault.external_assets, 0);
419        assert_eq!(vault.pending_withdrawal_assets, 0);
420        assert_eq!(vault.fees_payable, 0);
421        assert_eq!(vault.high_watermark, 0);
422        assert_eq!(vault.report_epoch, 0);
423        assert_eq!(vault.requested_withdrawal_shares, 0);
424        assert_eq!(vault.performance_fee_bps, 100);
425        assert_eq!(vault.withdrawal_buffer_bps, 250);
426        assert_eq!(vault.last_update_ts, 0);
427        assert_eq!(vault.deposits_paused(), Ok(false));
428        assert_eq!(vault.withdrawals_paused(), Ok(false));
429        assert_eq!(vault.manage_paused(), Ok(false));
430        assert_eq!(vault.private(), Ok(true));
431        assert_eq!(vault.external_enabled(), Ok(false));
432        assert_eq!(vault.access_merkle_root, [10; 32]);
433    }
434
435    #[test]
436    fn from_account_data_round_trips_a_tagged_vault() {
437        let vault = new_test_vault(false, [0; 32]);
438        let mut data = vec![VAULT_ACCOUNT_TAG];
439        data.extend_from_slice(&serialize(&vault).unwrap());
440
441        assert_eq!(Vault::from_account_data(&data).unwrap(), vault);
442    }
443
444    #[test]
445    fn from_account_data_rejects_wrong_tag() {
446        let vault = new_test_vault(false, [0; 32]);
447        let mut data = vec![VAULT_ACCOUNT_TAG + 1];
448        data.extend_from_slice(&serialize(&vault).unwrap());
449
450        assert_eq!(
451            Vault::from_account_data(&data),
452            Err(ProgramError::from(RoshiError::InvalidVaultAccount))
453        );
454    }
455
456    #[test]
457    fn vault_is_zero_copy_with_explicit_padding() {
458        assert_zero_copy::<Vault>();
459        assert_eq!(core::mem::size_of::<Vault>(), 600);
460        assert_eq!(Vault::SPACE, 601);
461        let vault = new_test_vault(false, [0; 32]);
462        assert_eq!(
463            serialize(&vault).unwrap().len(),
464            core::mem::size_of::<Vault>()
465        );
466    }
467
468    #[test]
469    fn pause_and_access_flags_use_typed_accessors() {
470        let mut vault = new_test_vault(false, [0; 32]);
471
472        assert_eq!(vault.deposits_paused(), Ok(false));
473        assert_eq!(vault.withdrawals_paused(), Ok(false));
474        assert_eq!(vault.manage_paused(), Ok(false));
475        assert_eq!(vault.private(), Ok(false));
476        assert_eq!(vault.external_enabled(), Ok(false));
477
478        vault.set_deposits_paused(true);
479        vault.set_withdrawals_paused(true);
480        vault.set_manage_paused(true);
481        vault.set_private(true);
482        vault.set_external_enabled(true);
483
484        assert_eq!(vault.deposits_paused(), Ok(true));
485        assert_eq!(vault.withdrawals_paused(), Ok(true));
486        assert_eq!(vault.manage_paused(), Ok(true));
487        assert_eq!(vault.private(), Ok(true));
488        assert_eq!(vault.external_enabled(), Ok(true));
489    }
490
491    #[test]
492    fn verify_manage_enabled_rejects_paused_vault() {
493        let mut vault = new_test_vault(false, [0; 32]);
494
495        vault.set_manage_paused(true);
496
497        assert_eq!(
498            vault.verify_manage_enabled(),
499            Err(ProgramError::from(RoshiError::VaultPaused))
500        );
501    }
502
503    #[test]
504    fn unpack_tag_rejects_invalid_tags() {
505        let (tag, _) = Vault::pack_tag(b"test").unwrap();
506
507        assert!(matches!(
508            Vault::unpack_tag(&tag, 0),
509            Err(error) if error == ProgramError::from(RoshiError::InvalidVaultTag)
510        ));
511        assert!(matches!(
512            Vault::unpack_tag(&tag, 33),
513            Err(error) if error == ProgramError::from(RoshiError::InvalidVaultTag)
514        ));
515    }
516
517    #[test]
518    fn validate_config_rejects_invalid_bps() {
519        assert!(matches!(
520            Vault::validate_config([1; 32], [2; 32], 10_001, 0),
521            Err(error) if error == ProgramError::from(RoshiError::InvalidBps)
522        ));
523    }
524
525    #[test]
526    fn validate_config_rejects_matching_base_and_share_mints() {
527        assert!(matches!(
528            Vault::validate_config([1; 32], [1; 32], 0, 0),
529            Err(ProgramError::InvalidArgument)
530        ));
531    }
532
533    #[test]
534    fn from_account_data_rejects_invalid_vault_flags() {
535        let mut vault = new_test_vault(false, [0; 32]);
536        vault.manage_paused_flag = 255;
537        let mut data = vec![VAULT_ACCOUNT_TAG];
538        data.extend_from_slice(&serialize(&vault).unwrap());
539
540        assert_eq!(
541            Vault::from_account_data(&data),
542            Err(ProgramError::from(RoshiError::InvalidVaultState))
543        );
544    }
545
546    #[test]
547    fn from_account_data_rejects_invalid_base_oracle_kind() {
548        let vault = new_test_vault(false, [0; 32]);
549        let mut data = vec![VAULT_ACCOUNT_TAG];
550        data.extend_from_slice(&serialize(&vault).unwrap());
551        let oracle_kind_offset = 1
552            + core::mem::size_of::<crate::oracle::SwitchboardOracleConfig>()
553            + core::mem::size_of::<crate::oracle::PythOracleConfig>();
554        data[oracle_kind_offset] = 255;
555
556        assert_eq!(
557            Vault::from_account_data(&data),
558            Err(ProgramError::from(RoshiError::InvalidVaultState))
559        );
560    }
561
562    #[test]
563    fn public_vault_allows_any_depositor_without_proof() {
564        let vault = new_test_vault(false, [0; 32]);
565
566        assert!(vault.allows_depositor(&Pubkey::new_unique(), &[]));
567        assert!(vault.allows_depositor(&Pubkey::new_unique(), &[[7; 32]]));
568    }
569
570    #[test]
571    fn private_vault_accepts_valid_access_proof() {
572        let allowed = Pubkey::new_unique();
573        let sibling = access_merkle_leaf(&Pubkey::new_unique());
574        let root = access_merkle_node(&access_merkle_leaf(&allowed), &sibling);
575        let vault = new_test_vault(true, root);
576
577        assert!(vault.allows_depositor(&allowed, &[sibling]));
578    }
579
580    #[test]
581    fn private_vault_rejects_missing_or_wrong_access_proof() {
582        let allowed = Pubkey::new_unique();
583        let sibling = access_merkle_leaf(&Pubkey::new_unique());
584        let root = access_merkle_node(&access_merkle_leaf(&allowed), &sibling);
585        let vault = new_test_vault(true, root);
586
587        assert!(!vault.allows_depositor(&allowed, &[]));
588        assert!(!vault.allows_depositor(&Pubkey::new_unique(), &[sibling]));
589        assert!(!vault.allows_depositor(&allowed, &[[9; 32]]));
590    }
591}