Skip to main content

pyra_redis/kamino/
positions.rs

1//! Typed position data fetching and computation for Kamino.
2//!
3//! Mirrors the Drift positions module but uses Pubkey-keyed reserves
4//! instead of u16 market indices.
5
6use std::collections::HashMap;
7
8use solana_pubkey::Pubkey;
9
10use pyra_margin::{get_kamino_borrow_balance, get_kamino_deposit_balance};
11use pyra_types::{Cache, KaminoObligation, KaminoReserve, KAMINO_FRACTION_SCALE};
12
13use crate::{RedisClient, RedisError, RedisKey, RedisResult};
14
15/// Extract market price (f64) from a Kamino reserve's `market_price_sf` field.
16///
17/// Divides by 2^60 (Kamino Fraction type).
18fn market_price_from_reserve(reserve: &KaminoReserve) -> f64 {
19    reserve.liquidity.market_price_sf as f64 / KAMINO_FRACTION_SCALE as f64
20}
21
22/// Convert a token balance (i128) to a signed USD value in cents using the given price and decimals.
23///
24/// Uses f64 floating-point arithmetic for display and caching purposes only.
25/// The authoritative balance-to-USD conversion for spending decisions lives in
26/// `pyra_margin::kamino::balance` / `pyra_margin::kamino::capacity`, which uses
27/// checked integer arithmetic (u128/i128) to avoid rounding errors.
28fn balance_to_value_cents(token_balance: i128, decimals: u32, price: f64) -> RedisResult<i64> {
29    let decimals_pow = 10_f64.powi(i32::try_from(decimals).map_err(|_| RedisError::MathOverflow)?);
30    let value = (token_balance as f64) / decimals_pow * price * 100.0;
31    let rounded = value.round();
32    if rounded.is_finite() && rounded >= i64::MIN as f64 && rounded <= i64::MAX as f64 {
33        Ok(rounded as i64)
34    } else {
35        Err(RedisError::MathOverflow)
36    }
37}
38
39// ── Data structures ──────────────────────────────────────────────────
40
41/// Parsed Redis position data for a single vault's Kamino obligation.
42pub struct VaultKaminoPositionData {
43    /// The cached KaminoObligation account (includes slot information).
44    pub obligation: Cache<KaminoObligation>,
45    /// Kamino reserves keyed by reserve pubkey.
46    pub reserves: HashMap<Pubkey, KaminoReserve>,
47    /// Market prices (USD) keyed by reserve pubkey, extracted from reserve data.
48    pub prices: HashMap<Pubkey, f64>,
49}
50
51/// All Kamino obligation positions with shared reserve/price data.
52pub struct AllKaminoPositionsData {
53    /// `(vault_address, obligation_cache)` pairs for every Kamino obligation in Redis.
54    pub obligations: Vec<(Pubkey, Cache<KaminoObligation>)>,
55    /// Shared reserves keyed by reserve pubkey.
56    pub reserves: HashMap<Pubkey, KaminoReserve>,
57    /// Market prices (USD) keyed by reserve pubkey.
58    pub prices: HashMap<Pubkey, f64>,
59}
60
61// ── Fetch methods on RedisClient ─────────────────────────────────────
62
63impl RedisClient {
64    /// Fetch KaminoObligation, reserves, and prices for a vault via MGET.
65    ///
66    /// Builds keys as: `[obligation, reserve_0..N]` and extracts prices
67    /// from `reserve.liquidity.market_price_sf`.
68    ///
69    /// Returns `NotFound` if the obligation key is missing.
70    pub async fn fetch_vault_kamino_position_data(
71        &self,
72        vault_address: &Pubkey,
73        lending_market: &Pubkey,
74        reserve_pubkeys: &[Pubkey],
75    ) -> RedisResult<VaultKaminoPositionData> {
76        let obligation_key = RedisKey::kamino_obligation(vault_address, lending_market).to_string();
77        let mut keys = vec![obligation_key];
78        for pk in reserve_pubkeys {
79            keys.push(RedisKey::kamino_reserve(pk).to_string());
80        }
81
82        let values = self.mget(&keys).await?;
83
84        let obligation_raw = values
85            .first()
86            .and_then(|v| v.as_ref())
87            .ok_or_else(|| RedisError::NotFound("KaminoObligation not found in Redis".into()))?;
88        let obligation: Cache<KaminoObligation> = serde_json::from_str(obligation_raw)?;
89
90        let mut reserves: HashMap<Pubkey, KaminoReserve> = HashMap::new();
91        let mut prices: HashMap<Pubkey, f64> = HashMap::new();
92
93        for (i, pk) in reserve_pubkeys.iter().enumerate() {
94            if let Some(Some(raw)) =
95                values.get(1usize.checked_add(i).ok_or(RedisError::MathOverflow)?)
96            {
97                if let Ok(cached) = serde_json::from_str::<Cache<KaminoReserve>>(raw) {
98                    let price = market_price_from_reserve(&cached.account);
99                    prices.insert(*pk, price);
100                    reserves.insert(*pk, cached.account);
101                }
102            }
103        }
104
105        Ok(VaultKaminoPositionData {
106            obligation,
107            reserves,
108            prices,
109        })
110    }
111
112    /// Fetch ALL Kamino obligations from Redis via SCAN + MGET.
113    ///
114    /// Scans every `account:kamino:obligation:*` key and fetches all
115    /// obligation data in a single MGET round-trip.
116    pub async fn fetch_all_kamino_positions(
117        &self,
118        reserve_pubkeys: &[Pubkey],
119    ) -> RedisResult<AllKaminoPositionsData> {
120        // 1. Scan for all obligation keys
121        let obligation_keys = self.scan_keys(&RedisKey::kamino_obligation_glob()).await?;
122
123        let prefix_with_colon = format!("{}:", RedisKey::KAMINO_OBLIGATION_PREFIX);
124        let vault_addresses: Vec<Option<Pubkey>> = obligation_keys
125            .iter()
126            .map(|k| {
127                k.strip_prefix(prefix_with_colon.as_str())
128                    .and_then(|rest| rest.split(':').next())
129                    .and_then(|s| s.parse::<Pubkey>().ok())
130            })
131            .collect();
132
133        // 2. Build MGET: [obligations...] [reserves...]
134        let num_obligations = obligation_keys.len();
135        let mut all_keys: Vec<String> = obligation_keys;
136
137        for pk in reserve_pubkeys {
138            all_keys.push(RedisKey::kamino_reserve(pk).to_string());
139        }
140
141        let values = self.mget(&all_keys).await?;
142
143        // 3. Parse obligations
144        let mut obligations: Vec<(Pubkey, Cache<KaminoObligation>)> = Vec::new();
145        for (i, vault_pk) in vault_addresses.iter().enumerate() {
146            if let (Some(pk), Some(Some(raw))) = (vault_pk, values.get(i)) {
147                if let Ok(cached) = serde_json::from_str::<Cache<KaminoObligation>>(raw) {
148                    obligations.push((*pk, cached));
149                }
150            }
151        }
152
153        // 4. Parse reserves and extract prices
154        let mut reserves: HashMap<Pubkey, KaminoReserve> = HashMap::new();
155        let mut prices: HashMap<Pubkey, f64> = HashMap::new();
156
157        for (i, pk) in reserve_pubkeys.iter().enumerate() {
158            let offset = num_obligations
159                .checked_add(i)
160                .ok_or(RedisError::MathOverflow)?;
161            if let Some(Some(raw)) = values.get(offset) {
162                if let Ok(cached) = serde_json::from_str::<Cache<KaminoReserve>>(raw) {
163                    let price = market_price_from_reserve(&cached.account);
164                    prices.insert(*pk, price);
165                    reserves.insert(*pk, cached.account);
166                }
167            }
168        }
169
170        Ok(AllKaminoPositionsData {
171            obligations,
172            reserves,
173            prices,
174        })
175    }
176}
177
178// ── Computation helpers ──────────────────────────────────────────────
179
180/// Compute the signed USD-cent value for each position in a Kamino obligation.
181///
182/// Deposits are positive, borrows are negative.
183pub fn compute_kamino_position_values(data: &VaultKaminoPositionData) -> RedisResult<Vec<i64>> {
184    compute_user_kamino_position_values(&data.obligation.account, &data.reserves, &data.prices)
185}
186
187/// Compute per-asset data: `(reserve_pubkey, balance_base_units, value_cents)`.
188pub fn compute_kamino_asset_data(
189    data: &VaultKaminoPositionData,
190) -> RedisResult<Vec<(Pubkey, i64, i64)>> {
191    compute_user_kamino_asset_data(&data.obligation.account, &data.reserves, &data.prices)
192}
193
194/// Compute signed USD-cent position values for a single Kamino obligation,
195/// using shared reserve and price maps.
196pub fn compute_user_kamino_position_values(
197    obligation: &KaminoObligation,
198    reserves: &HashMap<Pubkey, KaminoReserve>,
199    prices: &HashMap<Pubkey, f64>,
200) -> RedisResult<Vec<i64>> {
201    let mut results = Vec::new();
202
203    for deposit in &obligation.deposits {
204        if deposit.deposited_amount == 0 {
205            continue;
206        }
207        let Some(reserve) = reserves.get(&deposit.deposit_reserve) else {
208            continue;
209        };
210        let Some(&price) = prices.get(&deposit.deposit_reserve) else {
211            continue;
212        };
213        let balance = get_kamino_deposit_balance(deposit, reserve)?;
214        let decimals =
215            u32::try_from(reserve.liquidity.mint_decimals).map_err(|_| RedisError::MathOverflow)?;
216        let cents = balance_to_value_cents(balance, decimals, price)?;
217        results.push(cents);
218    }
219
220    for borrow in &obligation.borrows {
221        if borrow.borrowed_amount_sf == 0 {
222            continue;
223        }
224        let Some(reserve) = reserves.get(&borrow.borrow_reserve) else {
225            continue;
226        };
227        let Some(&price) = prices.get(&borrow.borrow_reserve) else {
228            continue;
229        };
230        let balance = get_kamino_borrow_balance(borrow, reserve)?;
231        let decimals =
232            u32::try_from(reserve.liquidity.mint_decimals).map_err(|_| RedisError::MathOverflow)?;
233        let cents = balance_to_value_cents(balance, decimals, price)?;
234        results.push(cents);
235    }
236
237    Ok(results)
238}
239
240/// Compute per-asset data for a single Kamino obligation using shared reserve/price maps.
241///
242/// Returns `(reserve_pubkey, signed_token_balance_i64, signed_value_cents)` tuples.
243pub fn compute_user_kamino_asset_data(
244    obligation: &KaminoObligation,
245    reserves: &HashMap<Pubkey, KaminoReserve>,
246    prices: &HashMap<Pubkey, f64>,
247) -> RedisResult<Vec<(Pubkey, i64, i64)>> {
248    let mut results = Vec::new();
249
250    for deposit in &obligation.deposits {
251        if deposit.deposited_amount == 0 {
252            continue;
253        }
254        let Some(reserve) = reserves.get(&deposit.deposit_reserve) else {
255            continue;
256        };
257        let Some(&price) = prices.get(&deposit.deposit_reserve) else {
258            continue;
259        };
260        let balance_i128 = get_kamino_deposit_balance(deposit, reserve)?;
261        let balance = i64::try_from(balance_i128).map_err(|_| RedisError::MathOverflow)?;
262        let decimals =
263            u32::try_from(reserve.liquidity.mint_decimals).map_err(|_| RedisError::MathOverflow)?;
264        let cents = balance_to_value_cents(balance_i128, decimals, price)?;
265        results.push((deposit.deposit_reserve, balance, cents));
266    }
267
268    for borrow in &obligation.borrows {
269        if borrow.borrowed_amount_sf == 0 {
270            continue;
271        }
272        let Some(reserve) = reserves.get(&borrow.borrow_reserve) else {
273            continue;
274        };
275        let Some(&price) = prices.get(&borrow.borrow_reserve) else {
276            continue;
277        };
278        let balance_i128 = get_kamino_borrow_balance(borrow, reserve)?;
279        let balance = i64::try_from(balance_i128).map_err(|_| RedisError::MathOverflow)?;
280        let decimals =
281            u32::try_from(reserve.liquidity.mint_decimals).map_err(|_| RedisError::MathOverflow)?;
282        let cents = balance_to_value_cents(balance_i128, decimals, price)?;
283        results.push((borrow.borrow_reserve, balance, cents));
284    }
285
286    Ok(results)
287}
288
289#[cfg(test)]
290#[allow(
291    clippy::allow_attributes,
292    clippy::allow_attributes_without_reason,
293    clippy::unwrap_used,
294    clippy::expect_used,
295    clippy::panic,
296    clippy::arithmetic_side_effects
297)]
298mod tests {
299    use super::*;
300    use pyra_types::{
301        KaminoBigFractionBytes, KaminoBorrowRateCurve, KaminoCurvePoint,
302        KaminoObligationCollateral, KaminoObligationLiquidity, KaminoReserveCollateral,
303        KaminoReserveConfig, KaminoReserveFees, KaminoReserveLiquidity, KaminoWithdrawalCaps,
304    };
305    fn test_pubkey(seed: u8) -> Pubkey {
306        Pubkey::new_from_array([seed; 32])
307    }
308
309    const FRACTION_ONE: u128 = 1 << 60;
310
311    fn rate_to_bsf(rate: u128) -> KaminoBigFractionBytes {
312        KaminoBigFractionBytes {
313            value: [rate as u64, (rate >> 64) as u64, 0, 0],
314        }
315    }
316
317    fn make_reserve(mint_decimals: u64, market_price_sf: u128) -> KaminoReserve {
318        KaminoReserve {
319            liquidity: KaminoReserveLiquidity {
320                total_available_amount: 1_000_000,
321                cumulative_borrow_rate_bsf: rate_to_bsf(FRACTION_ONE),
322                mint_decimals,
323                market_price_sf,
324                ..Default::default()
325            },
326            collateral: KaminoReserveCollateral {
327                mint_total_supply: 1_000_000,
328                ..Default::default()
329            },
330            config: KaminoReserveConfig {
331                loan_to_value_pct: 80,
332                liquidation_threshold_pct: 85,
333                protocol_take_rate_pct: 10,
334                protocol_liquidation_fee_pct: 5,
335                borrow_factor_pct: 100,
336                deposit_limit: u64::MAX,
337                borrow_limit: u64::MAX,
338                fees: KaminoReserveFees {
339                    origination_fee_sf: 0,
340                    flash_loan_fee_sf: 0,
341                },
342                borrow_rate_curve: KaminoBorrowRateCurve {
343                    points: [KaminoCurvePoint {
344                        utilization_rate_bps: 0,
345                        borrow_rate_bps: 0,
346                    }; 11],
347                },
348                deposit_withdrawal_cap: KaminoWithdrawalCaps {
349                    config_capacity: 0,
350                    current_total: 0,
351                    last_interval_start_timestamp: 0,
352                    config_interval_length_seconds: 0,
353                },
354                debt_withdrawal_cap: KaminoWithdrawalCaps {
355                    config_capacity: 0,
356                    current_total: 0,
357                    last_interval_start_timestamp: 0,
358                    config_interval_length_seconds: 0,
359                },
360                elevation_groups: [0; 20],
361                ..Default::default()
362            },
363            ..Default::default()
364        }
365    }
366
367    fn make_obligation(
368        deposits_vec: Vec<KaminoObligationCollateral>,
369        borrows_vec: Vec<KaminoObligationLiquidity>,
370    ) -> KaminoObligation {
371        let mut deposits = <[KaminoObligationCollateral; 8]>::default();
372        for (i, d) in deposits_vec.into_iter().enumerate() {
373            deposits[i] = d;
374        }
375        let mut borrows = <[KaminoObligationLiquidity; 5]>::default();
376        for (i, b) in borrows_vec.into_iter().enumerate() {
377            borrows[i] = b;
378        }
379        KaminoObligation {
380            deposits,
381            borrows,
382            ..Default::default()
383        }
384    }
385
386    #[test]
387    fn market_price_extraction() {
388        // 1.0 USD = 1 * 2^60
389        let reserve = make_reserve(6, KAMINO_FRACTION_SCALE);
390        let price = market_price_from_reserve(&reserve);
391        assert!((price - 1.0).abs() < f64::EPSILON);
392    }
393
394    #[test]
395    fn market_price_extraction_150_usd() {
396        // 150.0 USD = 150 * 2^60
397        let price_sf = KAMINO_FRACTION_SCALE
398            .checked_mul(150)
399            .expect("test value fits");
400        let reserve = make_reserve(9, price_sf);
401        let price = market_price_from_reserve(&reserve);
402        assert!((price - 150.0).abs() < 0.001);
403    }
404
405    #[test]
406    fn vault_position_data_struct_construction() {
407        let reserve_pk = test_pubkey(1);
408        let reserve = make_reserve(6, KAMINO_FRACTION_SCALE);
409
410        let mut reserves = HashMap::new();
411        reserves.insert(reserve_pk, reserve);
412        let mut prices = HashMap::new();
413        prices.insert(reserve_pk, 1.0);
414
415        let deposit = KaminoObligationCollateral {
416            deposit_reserve: reserve_pk,
417            deposited_amount: 1_000_000,
418            market_value_sf: KAMINO_FRACTION_SCALE,
419            ..Default::default()
420        };
421        let obligation = make_obligation(vec![deposit], vec![]);
422
423        let data = VaultKaminoPositionData {
424            obligation: Cache {
425                account: obligation,
426                last_updated_slot: 12345,
427            },
428            reserves,
429            prices,
430        };
431
432        assert_eq!(data.obligation.last_updated_slot, 12345);
433        assert_eq!(data.reserves.len(), 1);
434        assert_eq!(data.prices.len(), 1);
435    }
436
437    #[test]
438    fn all_positions_data_struct_construction() {
439        let data = AllKaminoPositionsData {
440            obligations: vec![],
441            reserves: HashMap::new(),
442            prices: HashMap::new(),
443        };
444        assert!(data.obligations.is_empty());
445        assert!(data.reserves.is_empty());
446        assert!(data.prices.is_empty());
447    }
448
449    #[test]
450    fn compute_values_empty_obligation() {
451        let obligation = make_obligation(vec![], vec![]);
452        let data = VaultKaminoPositionData {
453            obligation: Cache {
454                account: obligation,
455                last_updated_slot: 0,
456            },
457            reserves: HashMap::new(),
458            prices: HashMap::new(),
459        };
460        let values = compute_kamino_position_values(&data).unwrap();
461        assert!(values.is_empty());
462    }
463
464    #[test]
465    fn compute_values_single_deposit() {
466        let reserve_pk = test_pubkey(1);
467        // 1:1 exchange rate, 6 decimals, $1.00
468        let reserve = make_reserve(6, KAMINO_FRACTION_SCALE);
469
470        let deposit = KaminoObligationCollateral {
471            deposit_reserve: reserve_pk,
472            deposited_amount: 1_000_000, // 1 token
473            market_value_sf: 0,
474            ..Default::default()
475        };
476        let obligation = make_obligation(vec![deposit], vec![]);
477
478        let mut reserves = HashMap::new();
479        reserves.insert(reserve_pk, reserve);
480        let mut prices = HashMap::new();
481        prices.insert(reserve_pk, 1.0);
482
483        let values = compute_user_kamino_position_values(&obligation, &reserves, &prices).unwrap();
484        assert_eq!(values.len(), 1);
485        assert_eq!(values[0], 100); // 1 token * $1.00 = 100 cents
486    }
487
488    #[test]
489    fn compute_values_deposit_and_borrow() {
490        let deposit_pk = test_pubkey(1);
491        let borrow_pk = test_pubkey(2);
492
493        let deposit_reserve = make_reserve(6, KAMINO_FRACTION_SCALE);
494        let borrow_reserve = make_reserve(6, KAMINO_FRACTION_SCALE);
495
496        let deposit = KaminoObligationCollateral {
497            deposit_reserve: deposit_pk,
498            deposited_amount: 2_000_000,
499            market_value_sf: 0,
500            ..Default::default()
501        };
502        let borrow = KaminoObligationLiquidity {
503            borrow_reserve: borrow_pk,
504            cumulative_borrow_rate_bsf: rate_to_bsf(FRACTION_ONE),
505            borrowed_amount_sf: 500_000 * FRACTION_ONE,
506            market_value_sf: 0,
507            borrow_factor_adjusted_market_value_sf: 0,
508            ..Default::default()
509        };
510        let obligation = make_obligation(vec![deposit], vec![borrow]);
511
512        let mut reserves = HashMap::new();
513        reserves.insert(deposit_pk, deposit_reserve);
514        reserves.insert(borrow_pk, borrow_reserve);
515        let mut prices = HashMap::new();
516        prices.insert(deposit_pk, 1.0);
517        prices.insert(borrow_pk, 1.0);
518
519        let values = compute_user_kamino_position_values(&obligation, &reserves, &prices).unwrap();
520        assert_eq!(values.len(), 2);
521        assert_eq!(values[0], 200); // 2 tokens deposited = 200 cents
522        assert_eq!(values[1], -50); // 0.5 tokens borrowed = -50 cents
523    }
524
525    #[test]
526    fn compute_values_skips_zero_deposit() {
527        let reserve_pk = test_pubkey(1);
528        let reserve = make_reserve(6, KAMINO_FRACTION_SCALE);
529
530        let deposit = KaminoObligationCollateral {
531            deposit_reserve: reserve_pk,
532            deposited_amount: 0,
533            market_value_sf: 0,
534            ..Default::default()
535        };
536        let obligation = make_obligation(vec![deposit], vec![]);
537
538        let mut reserves = HashMap::new();
539        reserves.insert(reserve_pk, reserve);
540        let mut prices = HashMap::new();
541        prices.insert(reserve_pk, 1.0);
542
543        let values = compute_user_kamino_position_values(&obligation, &reserves, &prices).unwrap();
544        assert!(values.is_empty());
545    }
546
547    #[test]
548    fn compute_values_skips_missing_reserve() {
549        let reserve_pk = test_pubkey(1);
550
551        let deposit = KaminoObligationCollateral {
552            deposit_reserve: reserve_pk,
553            deposited_amount: 1_000_000,
554            market_value_sf: 0,
555            ..Default::default()
556        };
557        let obligation = make_obligation(vec![deposit], vec![]);
558
559        let reserves = HashMap::new();
560        let mut prices = HashMap::new();
561        prices.insert(reserve_pk, 1.0);
562
563        let values = compute_user_kamino_position_values(&obligation, &reserves, &prices).unwrap();
564        assert!(values.is_empty());
565    }
566
567    #[test]
568    fn compute_values_skips_missing_price() {
569        let reserve_pk = test_pubkey(1);
570        let reserve = make_reserve(6, KAMINO_FRACTION_SCALE);
571
572        let deposit = KaminoObligationCollateral {
573            deposit_reserve: reserve_pk,
574            deposited_amount: 1_000_000,
575            market_value_sf: 0,
576            ..Default::default()
577        };
578        let obligation = make_obligation(vec![deposit], vec![]);
579
580        let mut reserves = HashMap::new();
581        reserves.insert(reserve_pk, reserve);
582        let prices = HashMap::new();
583
584        let values = compute_user_kamino_position_values(&obligation, &reserves, &prices).unwrap();
585        assert!(values.is_empty());
586    }
587
588    #[test]
589    fn compute_values_high_price_sol() {
590        let reserve_pk = test_pubkey(1);
591        // SOL: 9 decimals, $150
592        let price_sf = KAMINO_FRACTION_SCALE.checked_mul(150).unwrap();
593        let reserve = make_reserve(9, price_sf);
594
595        let deposit = KaminoObligationCollateral {
596            deposit_reserve: reserve_pk,
597            deposited_amount: 100_000_000, // 0.1 SOL
598            market_value_sf: 0,
599            ..Default::default()
600        };
601        let obligation = make_obligation(vec![deposit], vec![]);
602
603        let mut reserves = HashMap::new();
604        reserves.insert(reserve_pk, reserve);
605        let mut prices = HashMap::new();
606        prices.insert(reserve_pk, 150.0);
607
608        let values = compute_user_kamino_position_values(&obligation, &reserves, &prices).unwrap();
609        assert_eq!(values.len(), 1);
610        assert_eq!(values[0], 1500); // 0.1 SOL * $150 = $15 = 1500 cents
611    }
612
613    #[test]
614    fn compute_asset_data_returns_tuples() {
615        let reserve_pk = test_pubkey(1);
616        let reserve = make_reserve(6, KAMINO_FRACTION_SCALE);
617
618        let deposit = KaminoObligationCollateral {
619            deposit_reserve: reserve_pk,
620            deposited_amount: 5_000_000,
621            market_value_sf: 0,
622            ..Default::default()
623        };
624        let obligation = make_obligation(vec![deposit], vec![]);
625
626        let mut reserves = HashMap::new();
627        reserves.insert(reserve_pk, reserve);
628        let mut prices = HashMap::new();
629        prices.insert(reserve_pk, 1.0);
630
631        let data = compute_user_kamino_asset_data(&obligation, &reserves, &prices).unwrap();
632        assert_eq!(data.len(), 1);
633        let (pk, token_balance, value_cents) = data[0];
634        assert_eq!(pk, reserve_pk);
635        assert_eq!(token_balance, 5_000_000);
636        assert_eq!(value_cents, 500); // 5 USDC = 500 cents
637    }
638
639    #[test]
640    fn compute_position_values_delegates_to_user_variant() {
641        let reserve_pk = test_pubkey(1);
642        let reserve = make_reserve(6, KAMINO_FRACTION_SCALE);
643
644        let deposit = KaminoObligationCollateral {
645            deposit_reserve: reserve_pk,
646            deposited_amount: 1_000_000,
647            market_value_sf: 0,
648            ..Default::default()
649        };
650        let obligation = Cache {
651            account: make_obligation(vec![deposit], vec![]),
652            last_updated_slot: 12345,
653        };
654
655        let mut reserves = HashMap::new();
656        reserves.insert(reserve_pk, reserve);
657        let mut prices = HashMap::new();
658        prices.insert(reserve_pk, 1.0);
659
660        let vpd = VaultKaminoPositionData {
661            obligation,
662            reserves,
663            prices,
664        };
665        let values = compute_kamino_position_values(&vpd).unwrap();
666        assert_eq!(values.len(), 1);
667        assert_eq!(values[0], 100);
668    }
669}