Skip to main content

roshi_interface/state/
asset.rs

1//! `Asset` account wire type and decode helpers — a vault's per-asset oracle
2//! registration for a non-base deposit asset (off-chain readers decode it to learn
3//! how the asset prices, including whether it routes through the vault `base_oracle`).
4
5use solana_program_error::{ProgramError, ProgramResult};
6use solana_pubkey::Pubkey;
7use wincode::{deserialize, SchemaRead, SchemaWrite};
8
9use crate::{error::RoshiError, oracle::OracleConfig, state::ASSET_ACCOUNT_TAG, ID};
10
11const FLAG_FALSE: u8 = 0;
12const FLAG_TRUE: u8 = 1;
13
14const fn flag(value: bool) -> u8 {
15    value as u8
16}
17
18fn bool_flag(flag: u8) -> Result<bool, ProgramError> {
19    match flag {
20        FLAG_FALSE => Ok(false),
21        FLAG_TRUE => Ok(true),
22        _ => Err(RoshiError::InvalidAssetAccount.into()),
23    }
24}
25
26#[derive(Clone, Copy, Debug, Eq, PartialEq, SchemaWrite, SchemaRead)]
27#[wincode(assert_zero_copy)]
28#[repr(C)]
29pub struct Asset {
30    /// Vault this non-base asset belongs to.
31    pub vault: [u8; 32],
32    /// Mint of the supported non-base deposit asset.
33    pub asset_mint: [u8; 32],
34    /// Oracle pricing one whole asset token. Direct mode: in whole base
35    /// tokens. Routed mode: in the quote currency shared with the vault's
36    /// `base_oracle`.
37    pub oracle: OracleConfig,
38    /// Inventory cap in asset atoms: deposits reject once the custody balance
39    /// plus the deposit would exceed it. `u64::MAX` = uncapped (explicit — no
40    /// zero-means-off magic; a zero cap blocks all deposits of this asset).
41    pub deposit_cap_atoms: u64,
42    /// Asset mint decimals.
43    pub asset_decimals: u8,
44    /// Whether deposits for this asset are enabled.
45    enabled_flag: u8,
46    /// Whether deposit pricing routes through the vault's `base_oracle`
47    /// (asset/quote ÷ base/quote) instead of reading `oracle` as a direct
48    /// asset/base feed.
49    routed_flag: u8,
50    pub bump: u8,
51    _padding: [u8; 4],
52}
53
54impl Asset {
55    pub const SEED: &'static [u8] = b"asset";
56    pub const SPACE: usize = std::mem::size_of::<Self>() + 1;
57
58    #[allow(clippy::too_many_arguments)]
59    pub fn new(
60        vault: [u8; 32],
61        asset_mint: [u8; 32],
62        oracle: OracleConfig,
63        asset_decimals: u8,
64        enabled: bool,
65        routed: bool,
66        deposit_cap_atoms: u64,
67        bump: u8,
68    ) -> Result<Self, ProgramError> {
69        oracle
70            .validate()
71            .map_err(|_| ProgramError::from(RoshiError::InvalidAssetAccount))?;
72
73        Ok(Self {
74            vault,
75            asset_mint,
76            oracle,
77            deposit_cap_atoms,
78            asset_decimals,
79            enabled_flag: flag(enabled),
80            routed_flag: flag(routed),
81            bump,
82            _padding: [0; 4],
83        })
84    }
85
86    pub fn find_address(vault: &Pubkey, asset_mint: &Pubkey) -> (Pubkey, u8) {
87        Pubkey::find_program_address(&[Self::SEED, vault.as_ref(), asset_mint.as_ref()], &ID)
88    }
89
90    /// Decode an `Asset` from raw Roshi account data — the wincode `Account::Asset`
91    /// payload (a one-byte tag then the asset). Mirrors [`super::Vault::from_account_data`].
92    pub fn from_account_data(data: &[u8]) -> Result<Self, ProgramError> {
93        let (&tag, rest) = data
94            .split_first()
95            .ok_or(ProgramError::from(RoshiError::InvalidAssetAccount))?;
96        if tag != ASSET_ACCOUNT_TAG {
97            return Err(RoshiError::InvalidAssetAccount.into());
98        }
99        let asset: Self =
100            deserialize(rest).map_err(|_| ProgramError::from(RoshiError::InvalidAssetAccount))?;
101        asset.validate_state()?;
102        Ok(asset)
103    }
104
105    pub fn enabled(&self) -> Result<bool, ProgramError> {
106        bool_flag(self.enabled_flag)
107    }
108
109    pub fn set_enabled(&mut self, enabled: bool) {
110        self.enabled_flag = flag(enabled);
111    }
112
113    pub fn routed(&self) -> Result<bool, ProgramError> {
114        bool_flag(self.routed_flag)
115    }
116
117    pub fn set_routed(&mut self, routed: bool) {
118        self.routed_flag = flag(routed);
119    }
120
121    pub fn validate_state(&self) -> ProgramResult {
122        self.oracle
123            .validate()
124            .map_err(|_| ProgramError::from(RoshiError::InvalidAssetAccount))?;
125        bool_flag(self.enabled_flag)?;
126        bool_flag(self.routed_flag)?;
127        Ok(())
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use wincode::{config::DefaultConfig, serialize, TypeMeta};
135
136    fn assert_zero_copy<T>()
137    where
138        T: wincode::ZeroCopy,
139        T: for<'de> SchemaRead<'de, DefaultConfig> + SchemaWrite<DefaultConfig>,
140    {
141        assert_eq!(
142            <T as SchemaRead<'_, DefaultConfig>>::TYPE_META,
143            TypeMeta::Static {
144                size: core::mem::size_of::<T>(),
145                zero_copy: true,
146            }
147        );
148        assert_eq!(
149            <T as SchemaWrite<DefaultConfig>>::TYPE_META,
150            TypeMeta::Static {
151                size: core::mem::size_of::<T>(),
152                zero_copy: true,
153            }
154        );
155    }
156
157    fn test_asset(enabled: bool, routed: bool) -> Asset {
158        Asset::new(
159            [1; 32],
160            [2; 32],
161            OracleConfig::default(),
162            6,
163            enabled,
164            routed,
165            u64::MAX,
166            7,
167        )
168        .unwrap()
169    }
170
171    /// The tagged account payload `from_account_data` consumes (tag byte + asset).
172    fn account_data(asset: &Asset) -> Vec<u8> {
173        let mut data = vec![ASSET_ACCOUNT_TAG];
174        data.extend_from_slice(&serialize(asset).unwrap());
175        data
176    }
177
178    /// Offset of `routed_flag` in the tagged payload — after the leading tag, the two
179    /// pubkeys, the oracle, the cap, the decimals, and `enabled_flag`.
180    fn routed_flag_offset() -> usize {
181        1 + core::mem::size_of::<[u8; 32]>()
182            + core::mem::size_of::<[u8; 32]>()
183            + core::mem::size_of::<OracleConfig>()
184            + core::mem::size_of::<u64>()
185            + core::mem::size_of::<u8>()
186            + core::mem::size_of::<u8>()
187    }
188
189    #[test]
190    fn asset_is_zero_copy_with_explicit_padding() {
191        let asset = test_asset(true, false);
192        assert_zero_copy::<Asset>();
193        assert_eq!(core::mem::size_of::<Asset>(), 280);
194        assert_eq!(Asset::SPACE, 281);
195        assert_eq!(
196            serialize(&asset).unwrap().len(),
197            core::mem::size_of::<Asset>()
198        );
199    }
200
201    #[test]
202    fn enabled_and_routed_flags_use_typed_accessors() {
203        let mut asset = test_asset(false, false);
204        assert_eq!(asset.enabled(), Ok(false));
205        asset.set_enabled(true);
206        assert_eq!(asset.enabled(), Ok(true));
207        assert_eq!(asset.routed(), Ok(false));
208        asset.set_routed(true);
209        assert_eq!(asset.routed(), Ok(true));
210    }
211
212    #[test]
213    fn from_account_data_round_trips_a_valid_asset() {
214        let asset = test_asset(true, true);
215        let decoded = Asset::from_account_data(&account_data(&asset)).unwrap();
216        assert_eq!(decoded, asset);
217        assert_eq!(decoded.routed(), Ok(true));
218    }
219
220    #[test]
221    fn from_account_data_rejects_a_bad_tag() {
222        let mut data = account_data(&test_asset(true, false));
223        data[0] = ASSET_ACCOUNT_TAG.wrapping_add(1);
224        assert_eq!(
225            Asset::from_account_data(&data),
226            Err(ProgramError::from(RoshiError::InvalidAssetAccount))
227        );
228    }
229
230    #[test]
231    fn from_account_data_rejects_an_invalid_routed_flag() {
232        let mut data = account_data(&test_asset(true, false));
233        data[routed_flag_offset()] = 255;
234        assert_eq!(
235            Asset::from_account_data(&data),
236            Err(ProgramError::from(RoshiError::InvalidAssetAccount))
237        );
238    }
239}