Skip to main content

pyra_types/
lib.rs

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