Skip to main content

pyra_types/
lib.rs

1//! Shared account types for Pyra services.
2//!
3//! Provides type definitions for Drift protocol accounts, Kamino protocol
4//! accounts, Pyra vault accounts, and Redis cache wrappers. These types are
5//! the superset of fields needed by api-v2, settlement-service, and
6//! notification-service for Redis deserialization.
7
8mod cache;
9mod drift;
10mod kamino;
11mod pyra;
12
13pub use cache::Cache;
14pub use drift::{
15    DriftUser, HistoricalOracleData, InsuranceFund, SpotBalanceType, SpotMarket, SpotPosition,
16};
17pub use kamino::{
18    KaminoBigFractionBytes, KaminoBorrowOrder, KaminoBorrowRateCurve, KaminoCurvePoint,
19    KaminoElevationGroup, KaminoFixedTermBorrowRolloverConfig, KaminoLastUpdate,
20    KaminoObligation, KaminoObligationCollateral, KaminoObligationLiquidity,
21    KaminoObligationOrder, KaminoPriceHeuristic, KaminoPythConfiguration, KaminoReserve,
22    KaminoReserveCollateral, KaminoReserveConfig, KaminoReserveFees, KaminoReserveLiquidity,
23    KaminoScopeConfiguration, KaminoSwitchboardConfiguration, KaminoTokenInfo,
24    KaminoWithdrawQueue, KaminoWithdrawTicket, KaminoWithdrawalCaps, KAMINO_FRACTION_SCALE,
25};
26pub use pyra::{
27    DepositAddressSolAccount, DepositAddressSplAccount, ProtocolId, SpendLimitsOrderAccount,
28    TimeLock, Vault, WithdrawOrderAccount,
29};
30
31pub type VaultCache = Cache<Vault>;
32pub type SpotMarketCache = Cache<SpotMarket>;
33pub type DriftUserCache = Cache<DriftUser>;
34pub type WithdrawOrderCache = Cache<WithdrawOrderAccount>;
35pub type SpendLimitsOrderCache = Cache<SpendLimitsOrderAccount>;
36pub type DepositAddressSplCache = Cache<DepositAddressSplAccount>;
37pub type DepositAddressSolCache = Cache<DepositAddressSolAccount>;
38
39#[cfg(test)]
40#[allow(
41    clippy::allow_attributes,
42    clippy::allow_attributes_without_reason,
43    clippy::unwrap_used,
44    clippy::expect_used,
45    clippy::panic,
46    clippy::arithmetic_side_effects,
47    reason = "test code"
48)]
49mod tests {
50    use crate::pyra::ProtocolId;
51
52    use super::*;
53
54    #[test]
55    fn cache_deserialize_with_slot() {
56        let json = r#"{"account":{"spot_positions":[]},"last_updated_slot":285847350}"#;
57        let cache: Cache<DriftUser> = serde_json::from_str(json).unwrap();
58        assert_eq!(cache.last_updated_slot, 285847350);
59        assert!(cache.account.spot_positions.is_empty());
60    }
61
62    #[test]
63    fn cache_deserialize_without_slot() {
64        let json = r#"{"account":{"spot_positions":[]}}"#;
65        let cache: Cache<DriftUser> = serde_json::from_str(json).unwrap();
66        assert_eq!(cache.last_updated_slot, 0);
67    }
68
69    #[test]
70    fn spot_market_partial_fields() {
71        // Core fields (market_index, decimals, interest, margin weights) are required.
72        // Optional service-specific fields use serde(default).
73        let json = r#"{"market_index":1,"decimals":6,"cumulative_deposit_interest":100,"cumulative_borrow_interest":50,"initial_asset_weight":8000,"initial_liability_weight":12000,"imf_factor":0,"scale_initial_asset_weight_start":0}"#;
74        let market: SpotMarket = serde_json::from_str(json).unwrap();
75        assert_eq!(market.market_index, 1);
76        assert_eq!(market.decimals, 6);
77        assert_eq!(market.initial_asset_weight, 8000);
78        assert_eq!(market.initial_liability_weight, 12000);
79        // Optional fields default to zero
80        assert_eq!(market.deposit_balance, 0);
81    }
82
83    #[test]
84    fn spot_market_missing_core_field_fails() {
85        // market_index, decimals, cumulative_*_interest, margin weights are required
86        let json = r#"{"market_index":1}"#;
87        let result = serde_json::from_str::<SpotMarket>(json);
88        assert!(result.is_err());
89
90        // Missing margin weight fields also fails
91        let json = r#"{"market_index":1,"decimals":6,"cumulative_deposit_interest":100,"cumulative_borrow_interest":50}"#;
92        let result = serde_json::from_str::<SpotMarket>(json);
93        assert!(result.is_err());
94    }
95
96    #[test]
97    fn spot_position_with_balance_type() {
98        let json = r#"{"scaled_balance":1000000,"market_index":0,"balance_type":"Deposit"}"#;
99        let pos: SpotPosition = serde_json::from_str(json).unwrap();
100        assert_eq!(pos.scaled_balance, 1000000);
101        assert_eq!(pos.balance_type, SpotBalanceType::Deposit);
102    }
103
104    #[test]
105    fn spot_position_borrow() {
106        let json =
107            r#"{"scaled_balance":500,"market_index":1,"balance_type":"Borrow","open_orders":2}"#;
108        let pos: SpotPosition = serde_json::from_str(json).unwrap();
109        assert_eq!(pos.balance_type, SpotBalanceType::Borrow);
110        assert_eq!(pos.open_orders, 2);
111    }
112
113    #[test]
114    fn drift_user_with_positions() {
115        let json = r#"{
116            "spot_positions": [
117                {"scaled_balance":1000,"market_index":0,"balance_type":"Deposit"},
118                {"scaled_balance":500,"market_index":1,"balance_type":"Borrow"}
119            ]
120        }"#;
121        let user: DriftUser = serde_json::from_str(json).unwrap();
122        assert_eq!(user.spot_positions.len(), 2);
123        assert_eq!(user.spot_positions[0].market_index, 0);
124        assert_eq!(user.spot_positions[1].balance_type, SpotBalanceType::Borrow);
125    }
126
127    #[test]
128    fn vault_all_fields_required() {
129        let json = r#"{"owner":[1,2,3],"bump":1,"spend_limit_per_transaction":100,"spend_limit_per_timeframe":1000,"remaining_spend_limit_per_timeframe":500,"next_timeframe_reset_timestamp":12345,"timeframe_in_seconds":86400}"#;
130        let vault: Vault = serde_json::from_str(json).unwrap();
131        assert_eq!(vault.owner, vec![1, 2, 3]);
132        assert_eq!(vault.spend_limit_per_transaction, 100);
133        assert_eq!(vault.timeframe_in_seconds, 86400);
134    }
135
136    #[test]
137    fn vault_missing_field_fails() {
138        // All fields are required — partial data should fail
139        let json = r#"{"owner":[1,2,3]}"#;
140        let result = serde_json::from_str::<Vault>(json);
141        assert!(result.is_err());
142    }
143
144    #[test]
145    fn time_lock_deserializes_snake_case() {
146        // Pubkey serializes as a byte array [0,0,...,0]
147        let zero_pubkey = serde_json::to_string(&solana_pubkey::Pubkey::default()).unwrap();
148        let json = format!(
149            r#"{{"owner":{pk},"payer":{pk},"release_slot":42}}"#,
150            pk = zero_pubkey
151        );
152        let tl: TimeLock = serde_json::from_str(&json).unwrap();
153        assert_eq!(tl.release_slot, 42);
154    }
155
156    #[test]
157    fn time_lock_serializes_camel_case() {
158        let tl = TimeLock {
159            owner: solana_pubkey::Pubkey::default(),
160            payer: solana_pubkey::Pubkey::default(),
161            release_slot: 42,
162        };
163        let json = serde_json::to_string(&tl).unwrap();
164        assert!(json.contains("releaseSlot"));
165        assert!(!json.contains("release_slot"));
166    }
167
168    #[test]
169    fn withdraw_order_deserializes_snake_case() {
170        let pk = serde_json::to_string(&solana_pubkey::Pubkey::default()).unwrap();
171        let json = format!(
172            concat!(
173                r#"{{"time_lock":{{"owner":{pk},"payer":{pk},"release_slot":100}},"#,
174                r#""amount_base_units":5000,"protocol_id":"Drift","asset_id":0,"reduce_only":false,"#,
175                r#""destination":{pk}}}"#,
176            ),
177            pk = pk
178        );
179        let order: WithdrawOrderAccount = serde_json::from_str(&json).unwrap();
180        assert_eq!(order.amount_base_units, 5000);
181        assert_eq!(order.asset_id, 0);
182        assert_eq!(order.protocol_id as u8, ProtocolId::Drift as u8);
183    }
184
185    #[test]
186    fn spot_market_roundtrip() {
187        let market = SpotMarket {
188            pubkey: vec![],
189            market_index: 1,
190            initial_asset_weight: 8000,
191            initial_liability_weight: 0,
192            imf_factor: 0,
193            scale_initial_asset_weight_start: 0,
194            decimals: 9,
195            cumulative_deposit_interest: 1_050_000_000_000,
196            cumulative_borrow_interest: 0,
197            deposit_balance: 0,
198            borrow_balance: 0,
199            optimal_utilization: 0,
200            optimal_borrow_rate: 0,
201            max_borrow_rate: 0,
202            min_borrow_rate: 0,
203            insurance_fund: InsuranceFund::default(),
204            historical_oracle_data: HistoricalOracleData::default(),
205            oracle: None,
206        };
207        let json = serde_json::to_string(&market).unwrap();
208        let deserialized: SpotMarket = serde_json::from_str(&json).unwrap();
209        assert_eq!(deserialized.market_index, 1);
210        assert_eq!(deserialized.cumulative_deposit_interest, 1_050_000_000_000);
211    }
212
213    #[test]
214    fn cache_spot_market_roundtrip() {
215        let json = r#"{"account":{"market_index":0,"decimals":6,"cumulative_deposit_interest":100,"cumulative_borrow_interest":50,"initial_asset_weight":8000,"initial_liability_weight":12000,"imf_factor":0,"scale_initial_asset_weight_start":0},"last_updated_slot":12345}"#;
216        let deserialized: Cache<SpotMarket> = serde_json::from_str(json).unwrap();
217        assert_eq!(deserialized.account.market_index, 0);
218        assert_eq!(deserialized.last_updated_slot, 12345);
219    }
220
221    #[test]
222    fn spot_market_ignores_unknown_fields() {
223        let json = r#"{"market_index":1,"some_future_field":"value","decimals":6,"cumulative_deposit_interest":0,"cumulative_borrow_interest":0,"initial_asset_weight":8000,"initial_liability_weight":12000,"imf_factor":0,"scale_initial_asset_weight_start":0}"#;
224        // Default serde behavior: unknown fields are ignored for structs without deny_unknown_fields
225        let market: SpotMarket = serde_json::from_str(json).unwrap();
226        assert_eq!(market.market_index, 1);
227    }
228
229    // --- Kamino type tests ---
230
231    #[test]
232    fn kamino_reserve_roundtrip() {
233        let mut reserve = KaminoReserve::default();
234        reserve.liquidity.total_available_amount = 1_000_000;
235        reserve.liquidity.mint_decimals = 6;
236        reserve.collateral.mint_total_supply = 999;
237        reserve.config.loan_to_value_pct = 80;
238        reserve.last_update.slot = 100;
239
240        let json = serde_json::to_string(&reserve).unwrap();
241        let deserialized: KaminoReserve = serde_json::from_str(&json).unwrap();
242        assert_eq!(deserialized.liquidity.total_available_amount, 1_000_000);
243        assert_eq!(deserialized.liquidity.mint_decimals, 6);
244        assert_eq!(deserialized.collateral.mint_total_supply, 999);
245        assert_eq!(deserialized.config.loan_to_value_pct, 80);
246        assert_eq!(deserialized.last_update.slot, 100);
247    }
248
249    #[test]
250    fn kamino_obligation_roundtrip() {
251        let mut obligation = KaminoObligation::default();
252        obligation.deposits[0].deposited_amount = 5000;
253        obligation.deposits[0].market_value_sf = 42;
254        obligation.borrows[0].borrowed_amount_sf = 100;
255        obligation.borrows[0].market_value_sf = 50;
256        obligation.has_debt = 1;
257
258        let json = serde_json::to_string(&obligation).unwrap();
259        let deserialized: KaminoObligation = serde_json::from_str(&json).unwrap();
260        assert_eq!(deserialized.deposits[0].deposited_amount, 5000);
261        assert_eq!(deserialized.borrows[0].borrowed_amount_sf, 100);
262        assert_eq!(deserialized.has_debt, 1);
263        // Fixed-size arrays: always 8 deposits and 5 borrows
264        assert_eq!(deserialized.deposits.len(), 8);
265        assert_eq!(deserialized.borrows.len(), 5);
266    }
267
268    #[test]
269    fn kamino_withdraw_ticket_roundtrip() {
270        let pk = serde_json::to_string(&solana_pubkey::Pubkey::default()).unwrap();
271        let json = format!(
272            concat!(
273                r#"{{"sequence_number":42,"owner":{pk},"reserve":{pk},"#,
274                r#""user_destination_liquidity_ta":{pk},"#,
275                r#""queued_collateral_amount":1000,"created_at_timestamp":12345,"invalid":0}}"#,
276            ),
277            pk = pk
278        );
279        let ticket: KaminoWithdrawTicket = serde_json::from_str(&json).unwrap();
280        assert_eq!(ticket.sequence_number, 42);
281        assert_eq!(ticket.queued_collateral_amount, 1000);
282
283        let serialized = serde_json::to_string(&ticket).unwrap();
284        let deserialized: KaminoWithdrawTicket = serde_json::from_str(&serialized).unwrap();
285        assert_eq!(deserialized.sequence_number, 42);
286    }
287
288    #[test]
289    fn cache_kamino_obligation() {
290        let obligation = KaminoObligation::default();
291        let cache = Cache {
292            account: obligation,
293            last_updated_slot: 300,
294        };
295        let json = serde_json::to_string(&cache).unwrap();
296        let deserialized: Cache<KaminoObligation> = serde_json::from_str(&json).unwrap();
297        assert_eq!(deserialized.last_updated_slot, 300);
298        // Fixed-size array is never "empty" — check first slot is default
299        assert_eq!(deserialized.account.deposits[0].deposited_amount, 0);
300    }
301
302    #[test]
303    fn cache_kamino_reserve() {
304        let mut reserve = KaminoReserve::default();
305        reserve.liquidity.total_available_amount = 1_000_000;
306        let cache = Cache {
307            account: reserve,
308            last_updated_slot: 400,
309        };
310        let json = serde_json::to_string(&cache).unwrap();
311        let deserialized: Cache<KaminoReserve> = serde_json::from_str(&json).unwrap();
312        assert_eq!(deserialized.last_updated_slot, 400);
313        assert_eq!(deserialized.account.liquidity.total_available_amount, 1_000_000);
314    }
315
316    #[test]
317    fn kamino_elevation_group_defaults() {
318        let group = KaminoElevationGroup::default();
319        assert_eq!(group.id, 0);
320        assert_eq!(group.ltv_pct, 0);
321        assert_eq!(group.liquidation_threshold_pct, 0);
322        assert_eq!(group.max_liquidation_bonus_bps, 0);
323        assert_eq!(group.allow_new_loans, 0);
324        assert_eq!(group.max_reserves_as_collateral, 0);
325        assert_eq!(group.debt_reserve, solana_pubkey::Pubkey::default());
326    }
327
328}