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, KaminoBorrowRateCurve, KaminoCurvePoint, KaminoElevationGroup,
19    KaminoLastUpdate, KaminoObligation, KaminoObligationCollateral, KaminoObligationLiquidity,
20    KaminoReserve, KaminoReserveCollateral, KaminoReserveConfig, KaminoReserveFees,
21    KaminoReserveLiquidity, KaminoWithdrawTicket, KaminoWithdrawalCaps, KAMINO_FRACTION_SCALE,
22};
23pub use pyra::{
24    DepositAddressSolAccount, DepositAddressSplAccount, SpendLimitsOrderAccount, TimeLock, Vault,
25    WithdrawOrderAccount,
26};
27
28pub type VaultCache = Cache<Vault>;
29pub type SpotMarketCache = Cache<SpotMarket>;
30pub type DriftUserCache = Cache<DriftUser>;
31pub type WithdrawOrderCache = Cache<WithdrawOrderAccount>;
32pub type SpendLimitsOrderCache = Cache<SpendLimitsOrderAccount>;
33pub type DepositAddressSplCache = Cache<DepositAddressSplAccount>;
34pub type DepositAddressSolCache = Cache<DepositAddressSolAccount>;
35
36#[cfg(test)]
37#[allow(
38    clippy::allow_attributes,
39    clippy::allow_attributes_without_reason,
40    clippy::unwrap_used,
41    clippy::expect_used,
42    clippy::panic,
43    clippy::arithmetic_side_effects,
44    reason = "test code"
45)]
46mod tests {
47    use crate::pyra::ProtocolId;
48
49    use super::*;
50
51    #[test]
52    fn cache_deserialize_with_slot() {
53        let json = r#"{"account":{"spot_positions":[]},"last_updated_slot":285847350}"#;
54        let cache: Cache<DriftUser> = serde_json::from_str(json).unwrap();
55        assert_eq!(cache.last_updated_slot, 285847350);
56        assert!(cache.account.spot_positions.is_empty());
57    }
58
59    #[test]
60    fn cache_deserialize_without_slot() {
61        let json = r#"{"account":{"spot_positions":[]}}"#;
62        let cache: Cache<DriftUser> = serde_json::from_str(json).unwrap();
63        assert_eq!(cache.last_updated_slot, 0);
64    }
65
66    #[test]
67    fn spot_market_partial_fields() {
68        // Core fields (market_index, decimals, interest, margin weights) are required.
69        // Optional service-specific fields use serde(default).
70        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}"#;
71        let market: SpotMarket = serde_json::from_str(json).unwrap();
72        assert_eq!(market.market_index, 1);
73        assert_eq!(market.decimals, 6);
74        assert_eq!(market.initial_asset_weight, 8000);
75        assert_eq!(market.initial_liability_weight, 12000);
76        // Optional fields default to zero
77        assert_eq!(market.deposit_balance, 0);
78    }
79
80    #[test]
81    fn spot_market_missing_core_field_fails() {
82        // market_index, decimals, cumulative_*_interest, margin weights are required
83        let json = r#"{"market_index":1}"#;
84        let result = serde_json::from_str::<SpotMarket>(json);
85        assert!(result.is_err());
86
87        // Missing margin weight fields also fails
88        let json = r#"{"market_index":1,"decimals":6,"cumulative_deposit_interest":100,"cumulative_borrow_interest":50}"#;
89        let result = serde_json::from_str::<SpotMarket>(json);
90        assert!(result.is_err());
91    }
92
93    #[test]
94    fn spot_position_with_balance_type() {
95        let json = r#"{"scaled_balance":1000000,"market_index":0,"balance_type":"Deposit"}"#;
96        let pos: SpotPosition = serde_json::from_str(json).unwrap();
97        assert_eq!(pos.scaled_balance, 1000000);
98        assert_eq!(pos.balance_type, SpotBalanceType::Deposit);
99    }
100
101    #[test]
102    fn spot_position_borrow() {
103        let json =
104            r#"{"scaled_balance":500,"market_index":1,"balance_type":"Borrow","open_orders":2}"#;
105        let pos: SpotPosition = serde_json::from_str(json).unwrap();
106        assert_eq!(pos.balance_type, SpotBalanceType::Borrow);
107        assert_eq!(pos.open_orders, 2);
108    }
109
110    #[test]
111    fn drift_user_with_positions() {
112        let json = r#"{
113            "spot_positions": [
114                {"scaled_balance":1000,"market_index":0,"balance_type":"Deposit"},
115                {"scaled_balance":500,"market_index":1,"balance_type":"Borrow"}
116            ]
117        }"#;
118        let user: DriftUser = serde_json::from_str(json).unwrap();
119        assert_eq!(user.spot_positions.len(), 2);
120        assert_eq!(user.spot_positions[0].market_index, 0);
121        assert_eq!(user.spot_positions[1].balance_type, SpotBalanceType::Borrow);
122    }
123
124    #[test]
125    fn vault_all_fields_required() {
126        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}"#;
127        let vault: Vault = serde_json::from_str(json).unwrap();
128        assert_eq!(vault.owner, vec![1, 2, 3]);
129        assert_eq!(vault.spend_limit_per_transaction, 100);
130        assert_eq!(vault.timeframe_in_seconds, 86400);
131    }
132
133    #[test]
134    fn vault_missing_field_fails() {
135        // All fields are required — partial data should fail
136        let json = r#"{"owner":[1,2,3]}"#;
137        let result = serde_json::from_str::<Vault>(json);
138        assert!(result.is_err());
139    }
140
141    #[test]
142    fn time_lock_deserializes_snake_case() {
143        // Pubkey serializes as a byte array [0,0,...,0]
144        let zero_pubkey = serde_json::to_string(&solana_pubkey::Pubkey::default()).unwrap();
145        let json = format!(
146            r#"{{"owner":{pk},"payer":{pk},"release_slot":42}}"#,
147            pk = zero_pubkey
148        );
149        let tl: TimeLock = serde_json::from_str(&json).unwrap();
150        assert_eq!(tl.release_slot, 42);
151    }
152
153    #[test]
154    fn time_lock_serializes_camel_case() {
155        let tl = TimeLock {
156            owner: solana_pubkey::Pubkey::default(),
157            payer: solana_pubkey::Pubkey::default(),
158            release_slot: 42,
159        };
160        let json = serde_json::to_string(&tl).unwrap();
161        assert!(json.contains("releaseSlot"));
162        assert!(!json.contains("release_slot"));
163    }
164
165    #[test]
166    fn withdraw_order_deserializes_snake_case() {
167        let pk = serde_json::to_string(&solana_pubkey::Pubkey::default()).unwrap();
168        let json = format!(
169            concat!(
170                r#"{{"time_lock":{{"owner":{pk},"payer":{pk},"release_slot":100}},"#,
171                r#""amount_base_units":5000,"protocol_id":"Drift","asset_id":0,"reduce_only":false,"#,
172                r#""destination":{pk}}}"#,
173            ),
174            pk = pk
175        );
176        let order: WithdrawOrderAccount = serde_json::from_str(&json).unwrap();
177        assert_eq!(order.amount_base_units, 5000);
178        assert_eq!(order.asset_id, 0);
179        assert_eq!(order.protocol_id as u8, ProtocolId::Drift as u8);
180    }
181
182    #[test]
183    fn spot_market_roundtrip() {
184        let market = SpotMarket {
185            pubkey: vec![],
186            market_index: 1,
187            initial_asset_weight: 8000,
188            initial_liability_weight: 0,
189            imf_factor: 0,
190            scale_initial_asset_weight_start: 0,
191            decimals: 9,
192            cumulative_deposit_interest: 1_050_000_000_000,
193            cumulative_borrow_interest: 0,
194            deposit_balance: 0,
195            borrow_balance: 0,
196            optimal_utilization: 0,
197            optimal_borrow_rate: 0,
198            max_borrow_rate: 0,
199            min_borrow_rate: 0,
200            insurance_fund: InsuranceFund::default(),
201            historical_oracle_data: HistoricalOracleData::default(),
202            oracle: None,
203        };
204        let json = serde_json::to_string(&market).unwrap();
205        let deserialized: SpotMarket = serde_json::from_str(&json).unwrap();
206        assert_eq!(deserialized.market_index, 1);
207        assert_eq!(deserialized.cumulative_deposit_interest, 1_050_000_000_000);
208    }
209
210    #[test]
211    fn cache_spot_market_roundtrip() {
212        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}"#;
213        let deserialized: Cache<SpotMarket> = serde_json::from_str(json).unwrap();
214        assert_eq!(deserialized.account.market_index, 0);
215        assert_eq!(deserialized.last_updated_slot, 12345);
216    }
217
218    #[test]
219    fn spot_market_ignores_unknown_fields() {
220        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}"#;
221        // Default serde behavior: unknown fields are ignored for structs without deny_unknown_fields
222        let market: SpotMarket = serde_json::from_str(json).unwrap();
223        assert_eq!(market.market_index, 1);
224    }
225
226    // --- Kamino type tests ---
227
228    /// Helper to build a minimal KaminoReserve JSON string.
229    fn kamino_reserve_json() -> String {
230        let pk = serde_json::to_string(&solana_pubkey::Pubkey::default()).unwrap();
231        format!(
232            concat!(
233                r#"{{"lending_market":{pk},"#,
234                r#""liquidity":{{"mint_pubkey":{pk},"supply_vault":{pk},"fee_vault":{pk},"#,
235                r#""total_available_amount":1000000,"borrowed_amount_sf":500,"#,
236                r#""cumulative_borrow_rate_bsf":{{"value":[1,0,0,0]}},"#,
237                r#""mint_decimals":6,"market_price_sf":42}},"#,
238                r#""collateral":{{"mint_pubkey":{pk},"supply_vault":{pk},"mint_total_supply":999}},"#,
239                r#""config":{{"loan_to_value_pct":80,"liquidation_threshold_pct":85,"#,
240                r#""protocol_take_rate_pct":10,"protocol_liquidation_fee_pct":5,"#,
241                r#""borrow_factor_pct":100,"deposit_limit":1000000,"borrow_limit":500000,"#,
242                r#""fees":{{"origination_fee_sf":1,"flash_loan_fee_sf":2}},"#,
243                r#""borrow_rate_curve":{{"points":["#,
244                r#"{{"utilization_rate_bps":0,"borrow_rate_bps":0}},"#,
245                r#"{{"utilization_rate_bps":1000,"borrow_rate_bps":100}},"#,
246                r#"{{"utilization_rate_bps":2000,"borrow_rate_bps":200}},"#,
247                r#"{{"utilization_rate_bps":3000,"borrow_rate_bps":300}},"#,
248                r#"{{"utilization_rate_bps":4000,"borrow_rate_bps":400}},"#,
249                r#"{{"utilization_rate_bps":5000,"borrow_rate_bps":500}},"#,
250                r#"{{"utilization_rate_bps":6000,"borrow_rate_bps":600}},"#,
251                r#"{{"utilization_rate_bps":7000,"borrow_rate_bps":700}},"#,
252                r#"{{"utilization_rate_bps":8000,"borrow_rate_bps":800}},"#,
253                r#"{{"utilization_rate_bps":9000,"borrow_rate_bps":900}},"#,
254                r#"{{"utilization_rate_bps":10000,"borrow_rate_bps":1000}}"#,
255                r#"]}},"#,
256                r#""deposit_withdrawal_cap":{{"config_capacity":100,"current_total":50,"#,
257                r#""last_interval_start_timestamp":0,"config_interval_length_seconds":3600}},"#,
258                r#""debt_withdrawal_cap":{{"config_capacity":100,"current_total":50,"#,
259                r#""last_interval_start_timestamp":0,"config_interval_length_seconds":3600}},"#,
260                r#""elevation_groups":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}},"#,
261                r#""last_update":{{"slot":100,"stale":0}}}}"#,
262            ),
263            pk = pk
264        )
265    }
266
267    #[test]
268    fn kamino_reserve_roundtrip() {
269        let json = kamino_reserve_json();
270        let reserve: KaminoReserve = serde_json::from_str(&json).unwrap();
271        assert_eq!(reserve.liquidity.total_available_amount, 1000000);
272        assert_eq!(reserve.liquidity.mint_decimals, 6);
273        assert_eq!(reserve.collateral.mint_total_supply, 999);
274        assert_eq!(reserve.config.loan_to_value_pct, 80);
275        assert_eq!(reserve.last_update.slot, 100);
276
277        // Roundtrip
278        let serialized = serde_json::to_string(&reserve).unwrap();
279        let deserialized: KaminoReserve = serde_json::from_str(&serialized).unwrap();
280        assert_eq!(deserialized.liquidity.total_available_amount, 1000000);
281    }
282
283    #[test]
284    fn kamino_obligation_roundtrip() {
285        let pk = serde_json::to_string(&solana_pubkey::Pubkey::default()).unwrap();
286        let json = format!(
287            concat!(
288                r#"{{"lending_market":{pk},"#,
289                r#""deposits":[{{"deposit_reserve":{pk},"deposited_amount":5000,"market_value_sf":42}}],"#,
290                r#""borrows":[{{"borrow_reserve":{pk},"#,
291                r#""cumulative_borrow_rate_bsf":{{"value":[1,0,0,0]}},"#,
292                r#""borrowed_amount_sf":100,"market_value_sf":50,"#,
293                r#""borrow_factor_adjusted_market_value_sf":55}}],"#,
294                r#""deposited_value_sf":5000,"borrowed_assets_market_value_sf":100,"#,
295                r#""allowed_borrow_value_sf":4000,"unhealthy_borrow_value_sf":4500,"#,
296                r#""has_debt":1,"elevation_group":0,"#,
297                r#""last_update":{{"slot":200,"stale":0}}}}"#,
298            ),
299            pk = pk
300        );
301        let obligation: KaminoObligation = serde_json::from_str(&json).unwrap();
302        assert_eq!(obligation.deposits.len(), 1);
303        assert_eq!(obligation.deposits[0].deposited_amount, 5000);
304        assert_eq!(obligation.borrows.len(), 1);
305        assert_eq!(obligation.has_debt, 1);
306
307        let serialized = serde_json::to_string(&obligation).unwrap();
308        let deserialized: KaminoObligation = serde_json::from_str(&serialized).unwrap();
309        assert_eq!(deserialized.deposits[0].deposited_amount, 5000);
310    }
311
312    #[test]
313    fn kamino_obligation_owner_defaults() {
314        // owner has serde(default), so it should be optional
315        let pk = serde_json::to_string(&solana_pubkey::Pubkey::default()).unwrap();
316        let json = format!(
317            concat!(
318                r#"{{"lending_market":{pk},"deposits":[],"borrows":[],"#,
319                r#""deposited_value_sf":0,"borrowed_assets_market_value_sf":0,"#,
320                r#""allowed_borrow_value_sf":0,"unhealthy_borrow_value_sf":0,"#,
321                r#""has_debt":0,"elevation_group":0,"#,
322                r#""last_update":{{"slot":0,"stale":0}}}}"#,
323            ),
324            pk = pk
325        );
326        let obligation: KaminoObligation = serde_json::from_str(&json).unwrap();
327        assert_eq!(obligation.owner, solana_pubkey::Pubkey::default());
328    }
329
330    #[test]
331    fn kamino_withdraw_ticket_roundtrip() {
332        let pk = serde_json::to_string(&solana_pubkey::Pubkey::default()).unwrap();
333        let json = format!(
334            concat!(
335                r#"{{"sequence_number":42,"owner":{pk},"reserve":{pk},"#,
336                r#""user_destination_liquidity_ta":{pk},"#,
337                r#""queued_collateral_amount":1000,"created_at_timestamp":12345,"invalid":0}}"#,
338            ),
339            pk = pk
340        );
341        let ticket: KaminoWithdrawTicket = serde_json::from_str(&json).unwrap();
342        assert_eq!(ticket.sequence_number, 42);
343        assert_eq!(ticket.queued_collateral_amount, 1000);
344
345        let serialized = serde_json::to_string(&ticket).unwrap();
346        let deserialized: KaminoWithdrawTicket = serde_json::from_str(&serialized).unwrap();
347        assert_eq!(deserialized.sequence_number, 42);
348    }
349
350    #[test]
351    fn kamino_reserve_fees_defaults() {
352        // Both fee fields have serde(default)
353        let json = r#"{}"#;
354        let fees: KaminoReserveFees = serde_json::from_str(json).unwrap();
355        assert_eq!(fees.origination_fee_sf, 0);
356        assert_eq!(fees.flash_loan_fee_sf, 0);
357    }
358
359    #[test]
360    fn cache_kamino_obligation() {
361        let pk = serde_json::to_string(&solana_pubkey::Pubkey::default()).unwrap();
362        let json = format!(
363            concat!(
364                r#"{{"account":{{"lending_market":{pk},"deposits":[],"borrows":[],"#,
365                r#""deposited_value_sf":0,"borrowed_assets_market_value_sf":0,"#,
366                r#""allowed_borrow_value_sf":0,"unhealthy_borrow_value_sf":0,"#,
367                r#""has_debt":0,"elevation_group":0,"#,
368                r#""last_update":{{"slot":0,"stale":0}}}},"#,
369                r#""last_updated_slot":300}}"#,
370            ),
371            pk = pk
372        );
373        let cache: Cache<KaminoObligation> = serde_json::from_str(&json).unwrap();
374        assert_eq!(cache.last_updated_slot, 300);
375        assert!(cache.account.deposits.is_empty());
376    }
377
378    #[test]
379    fn cache_kamino_reserve() {
380        let reserve_json = kamino_reserve_json();
381        let json = format!(r#"{{"account":{},"last_updated_slot":400}}"#, reserve_json);
382        let cache: Cache<KaminoReserve> = serde_json::from_str(&json).unwrap();
383        assert_eq!(cache.last_updated_slot, 400);
384        assert_eq!(cache.account.liquidity.total_available_amount, 1000000);
385    }
386
387    #[test]
388    fn kamino_elevation_group_defaults() {
389        let group = KaminoElevationGroup::default();
390        assert_eq!(group.id, 0);
391        assert_eq!(group.ltv_pct, 0);
392        assert_eq!(group.liquidation_threshold_pct, 0);
393        assert_eq!(group.max_liquidation_bonus_bps, 0);
394        assert_eq!(group.allow_new_loans, 0);
395        assert_eq!(group.max_reserves_as_collateral, 0);
396        assert_eq!(group.debt_reserve, solana_pubkey::Pubkey::default());
397    }
398}