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, KaminoObligation,
20    KaminoObligationCollateral, KaminoObligationLiquidity, KaminoObligationOrder,
21    KaminoPriceHeuristic, KaminoPythConfiguration, KaminoReserve, KaminoReserveCollateral,
22    KaminoReserveConfig, KaminoReserveFees, KaminoReserveLiquidity, KaminoScopeConfiguration,
23    KaminoSwitchboardConfiguration, KaminoTokenInfo, KaminoWithdrawQueue, KaminoWithdrawTicket,
24    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)]
48mod tests {
49    use crate::pyra::ProtocolId;
50
51    use super::*;
52
53    #[test]
54    fn cache_deserialize_with_slot() {
55        let json = r#"{"account":{"spot_positions":[]},"last_updated_slot":285847350}"#;
56        let cache: Cache<DriftUser> = serde_json::from_str(json).unwrap();
57        assert_eq!(cache.last_updated_slot, 285847350);
58        assert!(cache.account.spot_positions.is_empty());
59    }
60
61    #[test]
62    fn cache_deserialize_without_slot() {
63        let json = r#"{"account":{"spot_positions":[]}}"#;
64        let cache: Cache<DriftUser> = serde_json::from_str(json).unwrap();
65        assert_eq!(cache.last_updated_slot, 0);
66    }
67
68    #[test]
69    fn spot_market_partial_fields() {
70        // Core fields (market_index, decimals, interest, margin weights) are required.
71        // Optional service-specific fields use serde(default).
72        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}"#;
73        let market: SpotMarket = serde_json::from_str(json).unwrap();
74        assert_eq!(market.market_index, 1);
75        assert_eq!(market.decimals, 6);
76        assert_eq!(market.initial_asset_weight, 8000);
77        assert_eq!(market.initial_liability_weight, 12000);
78        // Optional fields default to zero
79        assert_eq!(market.deposit_balance, 0);
80    }
81
82    #[test]
83    fn spot_market_missing_core_field_fails() {
84        // market_index, decimals, cumulative_*_interest, margin weights are required
85        let json = r#"{"market_index":1}"#;
86        let result = serde_json::from_str::<SpotMarket>(json);
87        assert!(result.is_err());
88
89        // Missing margin weight fields also fails
90        let json = r#"{"market_index":1,"decimals":6,"cumulative_deposit_interest":100,"cumulative_borrow_interest":50}"#;
91        let result = serde_json::from_str::<SpotMarket>(json);
92        assert!(result.is_err());
93    }
94
95    #[test]
96    fn spot_position_with_balance_type() {
97        let json = r#"{"scaled_balance":1000000,"market_index":0,"balance_type":"Deposit"}"#;
98        let pos: SpotPosition = serde_json::from_str(json).unwrap();
99        assert_eq!(pos.scaled_balance, 1000000);
100        assert_eq!(pos.balance_type, SpotBalanceType::Deposit);
101    }
102
103    #[test]
104    fn spot_position_borrow() {
105        let json =
106            r#"{"scaled_balance":500,"market_index":1,"balance_type":"Borrow","open_orders":2}"#;
107        let pos: SpotPosition = serde_json::from_str(json).unwrap();
108        assert_eq!(pos.balance_type, SpotBalanceType::Borrow);
109        assert_eq!(pos.open_orders, 2);
110    }
111
112    #[test]
113    fn drift_user_with_positions() {
114        let json = r#"{
115            "spot_positions": [
116                {"scaled_balance":1000,"market_index":0,"balance_type":"Deposit"},
117                {"scaled_balance":500,"market_index":1,"balance_type":"Borrow"}
118            ]
119        }"#;
120        let user: DriftUser = serde_json::from_str(json).unwrap();
121        assert_eq!(user.spot_positions.len(), 2);
122        assert_eq!(user.spot_positions[0].market_index, 0);
123        assert_eq!(user.spot_positions[1].balance_type, SpotBalanceType::Borrow);
124    }
125
126    #[test]
127    fn vault_all_fields_required() {
128        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}"#;
129        let vault: Vault = serde_json::from_str(json).unwrap();
130        assert_eq!(vault.owner, vec![1, 2, 3]);
131        assert_eq!(vault.spend_limit_per_transaction, 100);
132        assert_eq!(vault.timeframe_in_seconds, 86400);
133    }
134
135    #[test]
136    fn vault_missing_field_fails() {
137        // All fields are required — partial data should fail
138        let json = r#"{"owner":[1,2,3]}"#;
139        let result = serde_json::from_str::<Vault>(json);
140        assert!(result.is_err());
141    }
142
143    #[test]
144    fn time_lock_deserializes_snake_case() {
145        // Pubkey serializes as a byte array [0,0,...,0]
146        let zero_pubkey = serde_json::to_string(&solana_pubkey::Pubkey::default()).unwrap();
147        let json = format!(
148            r#"{{"owner":{pk},"payer":{pk},"release_slot":42}}"#,
149            pk = zero_pubkey
150        );
151        let tl: TimeLock = serde_json::from_str(&json).unwrap();
152        assert_eq!(tl.release_slot, 42);
153    }
154
155    #[test]
156    fn time_lock_serializes_camel_case() {
157        let tl = TimeLock {
158            owner: solana_pubkey::Pubkey::default(),
159            payer: solana_pubkey::Pubkey::default(),
160            release_slot: 42,
161        };
162        let json = serde_json::to_string(&tl).unwrap();
163        assert!(json.contains("releaseSlot"));
164        assert!(!json.contains("release_slot"));
165    }
166
167    #[test]
168    fn withdraw_order_deserializes_snake_case() {
169        let pk = serde_json::to_string(&solana_pubkey::Pubkey::default()).unwrap();
170        let json = format!(
171            concat!(
172                r#"{{"time_lock":{{"owner":{pk},"payer":{pk},"release_slot":100}},"#,
173                r#""amount_base_units":5000,"protocol_id":"Drift","asset_id":0,"reduce_only":false,"#,
174                r#""destination":{pk}}}"#,
175            ),
176            pk = pk
177        );
178        let order: WithdrawOrderAccount = serde_json::from_str(&json).unwrap();
179        assert_eq!(order.amount_base_units, 5000);
180        assert_eq!(order.asset_id, 0);
181        assert_eq!(order.protocol_id as u8, ProtocolId::Drift as u8);
182    }
183
184    #[test]
185    fn spot_market_roundtrip() {
186        let market = SpotMarket {
187            pubkey: vec![],
188            market_index: 1,
189            initial_asset_weight: 8000,
190            initial_liability_weight: 0,
191            imf_factor: 0,
192            scale_initial_asset_weight_start: 0,
193            decimals: 9,
194            cumulative_deposit_interest: 1_050_000_000_000,
195            cumulative_borrow_interest: 0,
196            deposit_balance: 0,
197            borrow_balance: 0,
198            optimal_utilization: 0,
199            optimal_borrow_rate: 0,
200            max_borrow_rate: 0,
201            min_borrow_rate: 0,
202            insurance_fund: InsuranceFund::default(),
203            historical_oracle_data: HistoricalOracleData::default(),
204            oracle: None,
205        };
206        let json = serde_json::to_string(&market).unwrap();
207        let deserialized: SpotMarket = serde_json::from_str(&json).unwrap();
208        assert_eq!(deserialized.market_index, 1);
209        assert_eq!(deserialized.cumulative_deposit_interest, 1_050_000_000_000);
210    }
211
212    #[test]
213    fn cache_spot_market_roundtrip() {
214        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}"#;
215        let deserialized: Cache<SpotMarket> = serde_json::from_str(json).unwrap();
216        assert_eq!(deserialized.account.market_index, 0);
217        assert_eq!(deserialized.last_updated_slot, 12345);
218    }
219
220    #[test]
221    fn spot_market_ignores_unknown_fields() {
222        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}"#;
223        // Default serde behavior: unknown fields are ignored for structs without deny_unknown_fields
224        let market: SpotMarket = serde_json::from_str(json).unwrap();
225        assert_eq!(market.market_index, 1);
226    }
227
228    // --- Kamino type tests ---
229
230    #[test]
231    fn kamino_reserve_roundtrip() {
232        let mut reserve = KaminoReserve::default();
233        reserve.liquidity.total_available_amount = 1_000_000;
234        reserve.liquidity.mint_decimals = 6;
235        reserve.collateral.mint_total_supply = 999;
236        reserve.config.loan_to_value_pct = 80;
237        reserve.last_update.slot = 100;
238
239        let json = serde_json::to_string(&reserve).unwrap();
240        let deserialized: KaminoReserve = serde_json::from_str(&json).unwrap();
241        assert_eq!(deserialized.liquidity.total_available_amount, 1_000_000);
242        assert_eq!(deserialized.liquidity.mint_decimals, 6);
243        assert_eq!(deserialized.collateral.mint_total_supply, 999);
244        assert_eq!(deserialized.config.loan_to_value_pct, 80);
245        assert_eq!(deserialized.last_update.slot, 100);
246    }
247
248    #[test]
249    fn kamino_obligation_roundtrip() {
250        let mut obligation = KaminoObligation::default();
251        obligation.deposits[0].deposited_amount = 5000;
252        obligation.deposits[0].market_value_sf = 42;
253        obligation.borrows[0].borrowed_amount_sf = 100;
254        obligation.borrows[0].market_value_sf = 50;
255        obligation.has_debt = 1;
256
257        let json = serde_json::to_string(&obligation).unwrap();
258        let deserialized: KaminoObligation = serde_json::from_str(&json).unwrap();
259        assert_eq!(deserialized.deposits[0].deposited_amount, 5000);
260        assert_eq!(deserialized.borrows[0].borrowed_amount_sf, 100);
261        assert_eq!(deserialized.has_debt, 1);
262        // Fixed-size arrays: always 8 deposits and 5 borrows
263        assert_eq!(deserialized.deposits.len(), 8);
264        assert_eq!(deserialized.borrows.len(), 5);
265    }
266
267    #[test]
268    fn kamino_withdraw_ticket_roundtrip() {
269        let pk = serde_json::to_string(&solana_pubkey::Pubkey::default()).unwrap();
270        let json = format!(
271            concat!(
272                r#"{{"sequence_number":42,"owner":{pk},"reserve":{pk},"#,
273                r#""user_destination_liquidity_ta":{pk},"#,
274                r#""queued_collateral_amount":1000,"created_at_timestamp":12345,"invalid":0}}"#,
275            ),
276            pk = pk
277        );
278        let ticket: KaminoWithdrawTicket = serde_json::from_str(&json).unwrap();
279        assert_eq!(ticket.sequence_number, 42);
280        assert_eq!(ticket.queued_collateral_amount, 1000);
281
282        let serialized = serde_json::to_string(&ticket).unwrap();
283        let deserialized: KaminoWithdrawTicket = serde_json::from_str(&serialized).unwrap();
284        assert_eq!(deserialized.sequence_number, 42);
285    }
286
287    #[test]
288    fn cache_kamino_obligation() {
289        let obligation = KaminoObligation::default();
290        let cache = Cache {
291            account: obligation,
292            last_updated_slot: 300,
293        };
294        let json = serde_json::to_string(&cache).unwrap();
295        let deserialized: Cache<KaminoObligation> = serde_json::from_str(&json).unwrap();
296        assert_eq!(deserialized.last_updated_slot, 300);
297        // Fixed-size array is never "empty" — check first slot is default
298        assert_eq!(deserialized.account.deposits[0].deposited_amount, 0);
299    }
300
301    #[test]
302    fn cache_kamino_reserve() {
303        let mut reserve = KaminoReserve::default();
304        reserve.liquidity.total_available_amount = 1_000_000;
305        let cache = Cache {
306            account: reserve,
307            last_updated_slot: 400,
308        };
309        let json = serde_json::to_string(&cache).unwrap();
310        let deserialized: Cache<KaminoReserve> = serde_json::from_str(&json).unwrap();
311        assert_eq!(deserialized.last_updated_slot, 400);
312        assert_eq!(
313            deserialized.account.liquidity.total_available_amount,
314            1_000_000
315        );
316    }
317
318    #[test]
319    fn kamino_elevation_group_defaults() {
320        let group = KaminoElevationGroup::default();
321        assert_eq!(group.id, 0);
322        assert_eq!(group.ltv_pct, 0);
323        assert_eq!(group.liquidation_threshold_pct, 0);
324        assert_eq!(group.max_liquidation_bonus_bps, 0);
325        assert_eq!(group.allow_new_loans, 0);
326        assert_eq!(group.max_reserves_as_collateral, 0);
327        assert_eq!(group.debt_reserve, solana_pubkey::Pubkey::default());
328    }
329
330    // --- Strict deserialization tests (no serde(default)) ---
331
332    #[test]
333    fn kamino_obligation_rejects_partial_json() {
334        // Only owner + tag — missing deposits, borrows, obligation_orders, etc.
335        let pk = serde_json::to_string(&solana_pubkey::Pubkey::default()).unwrap();
336        let json = format!(r#"{{"owner":{pk},"tag":0}}"#, pk = pk);
337        let result = serde_json::from_str::<KaminoObligation>(&json);
338        assert!(
339            result.is_err(),
340            "partial KaminoObligation should fail without serde(default)"
341        );
342    }
343
344    #[test]
345    fn kamino_reserve_rejects_partial_json() {
346        // Only liquidity — missing collateral, config, last_update, etc.
347        let json = r#"{"liquidity":{"mint_decimals":6,"total_available_amount":0}}"#;
348        let result = serde_json::from_str::<KaminoReserve>(&json);
349        assert!(
350            result.is_err(),
351            "partial KaminoReserve should fail without serde(default)"
352        );
353    }
354
355    #[test]
356    fn kamino_reserve_fees_rejects_empty_json() {
357        let result = serde_json::from_str::<KaminoReserveFees>("{}");
358        assert!(
359            result.is_err(),
360            "empty JSON should fail for KaminoReserveFees without serde(default)"
361        );
362    }
363}