templar_common/
supply.rs

1use std::ops::{Deref, DerefMut};
2
3use near_sdk::{json_types::U64, near, require, AccountId};
4use templar_primitives::number::Decimal;
5
6use crate::{
7    accumulator::{AccumulationRecord, Accumulator},
8    asset::{BorrowAsset, BorrowAssetAmount},
9    event::MarketEvent,
10    incoming_deposit::IncomingDeposit,
11    market::{Market, Withdrawal},
12    YEAR_PER_MS,
13};
14
15/// This struct can only be constructed after accumulating yield on a
16/// supply position. This serves as proof that the yield has accrued, so it
17/// is safe to perform certain other operations.
18pub struct YieldAccumulationProof(());
19
20#[derive(Default, Debug, Clone, PartialEq, Eq)]
21#[near(serializers = [json, borsh])]
22pub struct Deposit {
23    pub active: BorrowAssetAmount,
24    pub incoming: Vec<IncomingDeposit>,
25    pub outgoing: BorrowAssetAmount,
26}
27
28impl Deposit {
29    pub fn total(&self) -> BorrowAssetAmount {
30        let mut total = self.active + self.outgoing;
31        for incoming in &self.incoming {
32            total += incoming.amount;
33        }
34        total
35    }
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39#[near(serializers = [json, borsh])]
40pub struct SupplyPosition {
41    started_at_block_timestamp_ms: Option<U64>,
42    borrow_asset_deposit: Deposit,
43    pub borrow_asset_yield: Accumulator<BorrowAsset>,
44}
45
46impl SupplyPosition {
47    pub fn new(current_snapshot_index: u32) -> Self {
48        Self {
49            started_at_block_timestamp_ms: None,
50            borrow_asset_deposit: Deposit::default(),
51            borrow_asset_yield: Accumulator::new(current_snapshot_index),
52        }
53    }
54
55    pub fn get_deposit(&self) -> &Deposit {
56        &self.borrow_asset_deposit
57    }
58
59    pub fn total_incoming(&self) -> BorrowAssetAmount {
60        self.borrow_asset_deposit
61            .incoming
62            .iter()
63            .fold(BorrowAssetAmount::zero(), |total_incoming, incoming| {
64                total_incoming + incoming.amount
65            })
66    }
67
68    pub fn get_started_at_block_timestamp_ms(&self) -> Option<u64> {
69        self.started_at_block_timestamp_ms.map(u64::from)
70    }
71
72    pub fn exists(&self) -> bool {
73        !self.borrow_asset_deposit.total().is_zero()
74            || !self.borrow_asset_yield.get_total().is_zero()
75    }
76
77    pub fn can_be_removed(&self) -> bool {
78        !self.exists()
79    }
80}
81
82pub struct SupplyPositionRef<M> {
83    market: M,
84    account_id: AccountId,
85    position: SupplyPosition,
86}
87
88impl<M> SupplyPositionRef<M> {
89    pub fn new(market: M, account_id: AccountId, position: SupplyPosition) -> Self {
90        Self {
91            market,
92            account_id,
93            position,
94        }
95    }
96
97    pub fn account_id(&self) -> &AccountId {
98        &self.account_id
99    }
100
101    pub fn total_deposit(&self) -> BorrowAssetAmount {
102        self.position.borrow_asset_deposit.total()
103    }
104
105    pub fn total_yield(&self) -> BorrowAssetAmount {
106        self.position.borrow_asset_yield.get_total()
107    }
108
109    pub fn inner(&self) -> &SupplyPosition {
110        &self.position
111    }
112}
113
114impl<M: Deref<Target = Market>> SupplyPositionRef<M> {
115    pub fn is_within_allowable_range(&self) -> bool {
116        self.market
117            .configuration
118            .supply_range
119            .contains(self.position.borrow_asset_deposit.total())
120    }
121
122    pub fn calculate_yield(&self, snapshot_limit: u32) -> AccumulationRecord<BorrowAsset> {
123        let mut next_snapshot_index = self.position.borrow_asset_yield.get_next_snapshot_index();
124
125        let mut amount = u128::from(self.position.borrow_asset_deposit.active);
126        let mut accumulated = Decimal::ZERO;
127        let mut next_incoming = 0;
128
129        #[allow(clippy::unwrap_used, reason = "Guaranteed previous snapshot exists")]
130        let mut prev_end_timestamp_ms = self
131            .market
132            .finalized_snapshots
133            .get(next_snapshot_index.checked_sub(1).unwrap())
134            .unwrap()
135            .end_timestamp_ms
136            .0;
137
138        let weight_numerator = self.market.configuration.yield_weights.supply.get();
139        let weight_denominator = self.market.configuration.yield_weights.total_weight().get();
140
141        #[allow(
142            clippy::cast_possible_truncation,
143            reason = "Assume # of snapshots is never >u32::MAX"
144        )]
145        for (i, snapshot) in self
146            .market
147            .finalized_snapshots
148            .iter()
149            .enumerate()
150            .skip(next_snapshot_index as usize)
151            .take(snapshot_limit as usize)
152        {
153            while let Some(incoming) = self
154                .position
155                .borrow_asset_deposit
156                .incoming
157                .get(next_incoming)
158                .filter(|incoming| incoming.activate_at_snapshot_index as usize == i)
159            {
160                next_incoming += 1;
161                amount += u128::from(incoming.amount);
162            }
163
164            if !snapshot.borrow_asset_deposited_active.is_zero() {
165                let snapshot_duration_ms = snapshot.end_timestamp_ms.0 - prev_end_timestamp_ms;
166                let interest_paid_by_borrowers = Decimal::from(snapshot.borrow_asset_borrowed)
167                    * snapshot.interest_rate
168                    * snapshot_duration_ms
169                    * YEAR_PER_MS;
170                let other_yield = Decimal::from(snapshot.yield_distribution);
171                accumulated +=
172                    (interest_paid_by_borrowers + other_yield) * amount * weight_numerator
173                        / u128::from(snapshot.borrow_asset_deposited_active)
174                        / weight_denominator;
175            }
176
177            next_snapshot_index = i as u32 + 1;
178            prev_end_timestamp_ms = snapshot.end_timestamp_ms.0;
179        }
180
181        AccumulationRecord {
182            // Accumulated amount is derived from real balances, so it should
183            // never overflow underlying data type.
184            #[allow(clippy::unwrap_used, reason = "Derived from real balances")]
185            amount: accumulated.to_u128_floor().unwrap().into(),
186            fraction_as_u128_dividend: accumulated.fractional_part_as_u128_dividend(),
187            next_snapshot_index,
188        }
189    }
190}
191
192pub struct SupplyPositionGuard<'a>(SupplyPositionRef<&'a mut Market>);
193
194impl Drop for SupplyPositionGuard<'_> {
195    fn drop(&mut self) {
196        self.0
197            .market
198            .supply_positions
199            .insert(&self.0.account_id, &self.0.position);
200    }
201}
202
203impl<'a> Deref for SupplyPositionGuard<'a> {
204    type Target = SupplyPositionRef<&'a mut Market>;
205
206    fn deref(&self) -> &Self::Target {
207        &self.0
208    }
209}
210
211impl DerefMut for SupplyPositionGuard<'_> {
212    fn deref_mut(&mut self) -> &mut Self::Target {
213        &mut self.0
214    }
215}
216
217impl<'a> SupplyPositionGuard<'a> {
218    pub fn new(market: &'a mut Market, account_id: AccountId, position: SupplyPosition) -> Self {
219        Self(SupplyPositionRef::new(market, account_id, position))
220    }
221
222    fn activate_incoming(&mut self, through_snapshot_index: u32) {
223        let mut incoming = self
224            .position
225            .borrow_asset_deposit
226            .incoming
227            .clone()
228            .into_iter()
229            .peekable();
230        while let Some(deposit) =
231            incoming.next_if(|d| d.activate_at_snapshot_index <= through_snapshot_index)
232        {
233            self.position.borrow_asset_deposit.active += deposit.amount;
234        }
235        self.position.borrow_asset_deposit.incoming = incoming.collect();
236
237        // Calling market.snapshot() performs the market accounting
238    }
239
240    fn remove_active(&mut self, amount: BorrowAssetAmount) {
241        self.position.borrow_asset_deposit.active -= amount;
242        self.market.borrow_asset_deposited_active -= amount;
243    }
244
245    fn add_incoming(&mut self, amount: BorrowAssetAmount, activate_at_snapshot_index: u32) {
246        let incoming = &mut self.position.borrow_asset_deposit.incoming;
247        if let Some(deposit) = incoming
248            .last_mut()
249            .filter(|i| i.activate_at_snapshot_index == activate_at_snapshot_index)
250        {
251            deposit.amount += amount;
252        } else {
253            const MAX_INCOMING: usize = 4;
254            require!(
255                incoming.len() < MAX_INCOMING,
256                "Too many deposits without running accumulation",
257            );
258            incoming.push(IncomingDeposit {
259                activate_at_snapshot_index,
260                amount,
261            });
262        }
263
264        if let Some(incoming) = self
265            .market
266            .borrow_asset_deposited_incoming
267            .iter_mut()
268            .find(|incoming| incoming.activate_at_snapshot_index == activate_at_snapshot_index)
269        {
270            incoming.amount += amount;
271        } else {
272            self.market
273                .borrow_asset_deposited_incoming
274                .push(IncomingDeposit {
275                    activate_at_snapshot_index,
276                    amount,
277                });
278        }
279    }
280
281    /// Returns the amount successfully removed from incoming.
282    fn remove_incoming(&mut self, amount: BorrowAssetAmount) -> BorrowAssetAmount {
283        let mut total = BorrowAssetAmount::zero();
284        while let Some(newest) = self.position.borrow_asset_deposit.incoming.pop() {
285            total += newest.amount;
286
287            let Some(market_incoming) = self
288                .market
289                .borrow_asset_deposited_incoming
290                .iter_mut()
291                .find(|incoming| {
292                    incoming.activate_at_snapshot_index == newest.activate_at_snapshot_index
293                })
294            else {
295                crate::panic_with_message("Invariant violation: Market incoming entry should exist if position incoming entry exists");
296            };
297            market_incoming.amount = market_incoming.amount.unwrap_sub(newest.amount, "Invariant violation: Market incoming >= position incoming should hold for all snapshot indices");
298
299            #[allow(clippy::comparison_chain)]
300            if total == amount {
301                return amount;
302            } else if total > amount {
303                self.add_incoming(total - amount, newest.activate_at_snapshot_index);
304                return amount;
305            }
306        }
307
308        total
309    }
310
311    pub fn accumulate_yield_partial(&mut self, snapshot_limit: u32) {
312        require!(snapshot_limit > 0, "snapshot_limit must be nonzero");
313
314        let accumulation_record = self.calculate_yield(snapshot_limit);
315        self.activate_incoming(accumulation_record.next_snapshot_index);
316
317        if !accumulation_record.amount.is_zero() {
318            MarketEvent::YieldAccumulated {
319                account_id: self.account_id.clone(),
320                borrow_asset_amount: accumulation_record.amount,
321            }
322            .emit();
323        }
324
325        self.position
326            .borrow_asset_yield
327            .accumulate(accumulation_record);
328    }
329
330    pub fn accumulate_yield(&mut self) -> YieldAccumulationProof {
331        self.accumulate_yield_partial(u32::MAX);
332        YieldAccumulationProof(())
333    }
334
335    /// Removes the requested amount from the supply record. The amount is
336    /// removed from the yield record, the incoming deposit record, and the
337    /// active supply record, in that order.
338    pub fn record_withdrawal_initial(
339        &mut self,
340        _proof: YieldAccumulationProof,
341        requested_amount: BorrowAssetAmount,
342        block_timestamp_ms: u64,
343    ) -> WithdrawalAttempt {
344        //
345        // Check liquidity & eligibility
346        //
347
348        let my_incoming = self.position.total_incoming();
349        let my_active = self.position.get_deposit().active;
350        let my_yield = self
351            .position
352            .borrow_asset_yield
353            .get_total()
354            .min(self.market.borrow_asset_paid_to_fees);
355        let entitled_to_withdraw = my_incoming + my_active + my_yield;
356
357        if entitled_to_withdraw.is_zero() {
358            return WithdrawalAttempt::EmptyPosition;
359        }
360
361        let requested_amount = requested_amount.min(entitled_to_withdraw);
362        let available_to_me = self.market.borrow_asset_deposited_active
363            + self.market.borrow_asset_paid_to_fees
364            - self.market.borrow_asset_borrowed
365            + my_incoming;
366        let can_withdraw_now = entitled_to_withdraw.min(available_to_me);
367
368        if can_withdraw_now.is_zero() {
369            return WithdrawalAttempt::NoLiquidity;
370        }
371
372        let withdrawal_amount = requested_amount.min(can_withdraw_now);
373
374        //
375        // Execute removal
376        //
377
378        self.position.borrow_asset_deposit.outgoing += withdrawal_amount;
379
380        let mut amount_to_remove = withdrawal_amount;
381
382        let amount_from_yield = my_yield.min(amount_to_remove);
383        self.position.borrow_asset_yield.remove(amount_from_yield);
384        self.market.borrow_asset_paid_to_fees -= amount_from_yield;
385        amount_to_remove -= amount_from_yield;
386
387        let amount_from_incoming = my_incoming.min(amount_to_remove);
388        self.remove_incoming(amount_from_incoming);
389        amount_to_remove -= amount_from_incoming;
390
391        if !amount_to_remove.is_zero() {
392            self.remove_active(amount_to_remove);
393        }
394
395        // The only way to withdraw from a position is if it already has a deposit.
396        // Adding a deposit guarantees started_at_block_timestamp_ms != None
397        let Some(U64(started_at_block_timestamp_ms)) =
398            self.0.position.started_at_block_timestamp_ms
399        else {
400            crate::panic_with_message(
401                "Invariant violation: Position with deposit has no timestamp",
402            );
403        };
404        let supply_duration = block_timestamp_ms.saturating_sub(started_at_block_timestamp_ms);
405
406        let amount_to_fees = self
407            .market
408            .configuration
409            .supply_withdrawal_fee
410            .of(withdrawal_amount, supply_duration)
411            .unwrap_or_else(|| crate::panic_with_message("Fee calculation overflow"))
412            .min(withdrawal_amount);
413
414        let amount_to_account = withdrawal_amount.saturating_sub(amount_to_fees);
415
416        self.market.borrow_asset_balance -= amount_to_account;
417        self.market.borrow_asset_withdrawal_in_flight += amount_to_account;
418
419        let withdrawal = Withdrawal {
420            account_id: self.account_id.clone(),
421            amount_to_account,
422            amount_to_fees,
423        };
424
425        if requested_amount > can_withdraw_now {
426            WithdrawalAttempt::Partial {
427                withdrawal,
428                remaining: requested_amount.saturating_sub(can_withdraw_now),
429            }
430        } else {
431            WithdrawalAttempt::Full(withdrawal)
432        }
433    }
434
435    pub fn record_withdrawal_final(&mut self, withdrawal: &Withdrawal, success: bool) {
436        let amount = withdrawal.amount_to_account + withdrawal.amount_to_fees;
437
438        self.position.borrow_asset_deposit.outgoing -= amount;
439        self.market.borrow_asset_withdrawal_in_flight -= withdrawal.amount_to_account;
440
441        if success {
442            self.market
443                .record_borrow_asset_protocol_yield(withdrawal.amount_to_fees);
444
445            MarketEvent::SupplyWithdrawn {
446                account_id: self.account_id.clone(),
447                borrow_asset_amount_to_account: withdrawal.amount_to_account,
448                borrow_asset_amount_to_fees: withdrawal.amount_to_fees,
449            }
450            .emit();
451        } else {
452            self.market.borrow_asset_balance += withdrawal.amount_to_account;
453            self.add_incoming(amount, self.market.finalized_snapshots.len() + 1);
454        }
455    }
456
457    pub fn record_deposit(
458        &mut self,
459        _proof: YieldAccumulationProof,
460        amount: BorrowAssetAmount,
461        block_timestamp_ms: u64,
462    ) {
463        if self.position.started_at_block_timestamp_ms.is_none()
464            || self.position.borrow_asset_deposit.active.is_zero()
465        {
466            self.position.started_at_block_timestamp_ms = Some(block_timestamp_ms.into());
467        }
468
469        self.market.borrow_asset_balance += amount;
470        self.add_incoming(amount, self.market.finalized_snapshots.len() + 1);
471
472        if !amount.is_zero() {
473            MarketEvent::SupplyDeposited {
474                account_id: self.account_id.clone(),
475                borrow_asset_amount: amount,
476            }
477            .emit();
478        }
479    }
480
481    pub fn record_yield_withdrawal(&mut self, amount: BorrowAssetAmount) {
482        self.0.position.borrow_asset_yield.remove(amount);
483    }
484}
485
486#[derive(Debug)]
487pub enum WithdrawalAttempt {
488    Full(Withdrawal),
489    Partial {
490        withdrawal: Withdrawal,
491        remaining: BorrowAssetAmount,
492    },
493    EmptyPosition,
494    NoLiquidity,
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500    use crate::incoming_deposit::IncomingDeposit;
501
502    // Backstop for `fuzz_supply_overflow`: that target asserts correctness up
503    // to the boundary but cannot observe the abort itself (libfuzzer-sys
504    // aborts on panic before catch_unwind sees it). The contract's safety
505    // property — `Deposit::total` overflow aborts — is asserted here.
506    #[test]
507    // Match only the stable "overflow" substring, not the full toolchain-
508    // specific panic text ("attempt to add with overflow").
509    #[should_panic(expected = "overflow")]
510    fn deposit_total_overflow_aborts() {
511        let deposit = Deposit {
512            active: BorrowAssetAmount::new(u128::MAX),
513            incoming: vec![IncomingDeposit {
514                activate_at_snapshot_index: 1,
515                amount: BorrowAssetAmount::new(1),
516            }],
517            outgoing: BorrowAssetAmount::zero(),
518        };
519        let _ = deposit.total();
520    }
521
522    #[test]
523    fn can_be_removed_is_complement_of_exists() {
524        let p = SupplyPosition::new(0);
525        assert!(!p.exists());
526        assert!(p.can_be_removed());
527        assert_eq!(p.exists(), !p.can_be_removed());
528    }
529
530    /// Restores the coverage lost when the sandbox test
531    /// `contract/market/tests/supply_within_snapshot.rs::partial_snapshot_no_earnings`
532    /// was deleted: the multi-supplier proportional yield split with staggered
533    /// activation, computed by [`SupplyPositionRef::calculate_yield`].
534    ///
535    /// Two suppliers, A and B, each end up with an equal (100-unit) active
536    /// deposit, but B's deposit only activates one snapshot after A's:
537    ///
538    /// * Snapshot N: only A is active (market active = 100). A must receive
539    ///   100% of that snapshot's supply-side yield; B must receive nothing.
540    /// * Snapshot N+1: A and B are both active with equal deposits (market
541    ///   active = 200). They must split that snapshot's yield 50/50.
542    ///
543    /// Yield is supplied via `yield_distribution` (the `other_yield` term) with
544    /// `interest_rate = 0`, so the per-snapshot amount is an exact integer and
545    /// the split can be asserted for exact equality. `yield_distribution` flows
546    /// through the identical proportional-split expression as borrower interest
547    /// (`(interest_paid_by_borrowers + other_yield) * amount / active`), so this
548    /// exercises the split logic the deleted test guarded.
549    #[test]
550    #[allow(clippy::too_many_lines, reason = "hand-built market/snapshot fixture")]
551    fn multi_supplier_proportional_split_staggered_activation() {
552        use near_sdk::{test_utils::VMContextBuilder, testing_env};
553
554        use crate::{
555            asset::FungibleAsset,
556            dec,
557            fee::{Fee, TimeBasedFee},
558            interest_rate_strategy::InterestRateStrategy,
559            market::{PriceOracleConfiguration, YieldWeights},
560            oracle::pyth::PriceIdentifier,
561            snapshot::Snapshot,
562            time_chunk::{TimeChunk, TimeChunkConfiguration},
563        };
564        use templar_primitives::number::Decimal;
565
566        // supply weight 1, no static weight => numerator == denominator == 1,
567        // so the whole snapshot yield is distributed to suppliers pro rata.
568        let configuration = crate::market::MarketConfiguration {
569            time_chunk_configuration: TimeChunkConfiguration::new(1),
570            borrow_asset: FungibleAsset::nep141("borrow.near".parse().unwrap()),
571            collateral_asset: FungibleAsset::nep141("collateral.near".parse().unwrap()),
572            price_oracle_configuration: PriceOracleConfiguration {
573                account_id: "pyth-oracle.near".parse().unwrap(),
574                collateral_asset_price_id: PriceIdentifier([0xcc; 32]),
575                collateral_asset_decimals: 24,
576                borrow_asset_price_id: PriceIdentifier([0xbb; 32]),
577                borrow_asset_decimals: 24,
578                price_maximum_age_s: 60,
579            },
580            borrow_mcr_maintenance: dec!("1.25"),
581            borrow_mcr_liquidation: dec!("1.2"),
582            borrow_asset_maximum_usage_ratio: dec!("0.9"),
583            borrow_origination_fee: Fee::zero(),
584            borrow_interest_rate_strategy: InterestRateStrategy::zero(),
585            borrow_maximum_duration_ms: None,
586            borrow_range: (1, None).try_into().unwrap(),
587            supply_range: (1, None).try_into().unwrap(),
588            supply_withdrawal_range: (1, None).try_into().unwrap(),
589            supply_withdrawal_fee: TimeBasedFee::zero(),
590            yield_weights: YieldWeights::new_with_supply_weight(1),
591            protocol_account_id: "revenue.tmplr.near".parse().unwrap(),
592            liquidation_maximum_spread: dec!("0.05"),
593        };
594
595        // Clock at 1ms (1_000_000ns): `Market::new` needs `now() >= 1` (it calls
596        // `TimeChunkConfiguration::previous()`), and `Snapshot::new` stamps
597        // finalized snapshot 0 with `end_timestamp_ms = 1`. The snapshots below
598        // must stay monotonically increasing above that, or the `end - prev`
599        // duration underflows.
600        testing_env!(VMContextBuilder::new().block_timestamp(1_000_000).build());
601
602        let mut market = Market::new(b"m", configuration);
603        // Market::new pushes finalized snapshot index 0 (end_timestamp_ms 1). Its
604        // end timestamp is `prev_end_timestamp_ms` for the first accumulated
605        // snapshot; with interest_rate == 0 the snapshot duration is irrelevant to
606        // the yield.
607        let time_chunk = TimeChunk(0.into());
608
609        // Snapshot N (index 1): only A active. active = 100, yield = 1000.
610        market.finalized_snapshots.push(Snapshot {
611            time_chunk,
612            end_timestamp_ms: 1_000.into(),
613            borrow_asset_deposited_active: 100.into(),
614            borrow_asset_borrowed: 0.into(),
615            collateral_asset_deposited: 0.into(),
616            yield_distribution: 1_000.into(),
617            interest_rate: Decimal::ZERO,
618        });
619
620        // Snapshot N+1 (index 2): A and B both active. active = 200, yield = 800.
621        // Derive the active denominator through the *production* path
622        // (`current_snapshot` → `incoming_at`) rather than hard-coding 200: A is
623        // active (100) in the market aggregate and B is registered as an incoming
624        // deposit activating at index 2. A regression that omits the incoming
625        // supplier from the denominator (yielding 100, over-distributing to A)
626        // is then caught right here.
627        market.borrow_asset_deposited_active = 100.into();
628        market.borrow_asset_deposited_incoming = vec![IncomingDeposit {
629            activate_at_snapshot_index: 2,
630            amount: 100.into(),
631        }];
632        let derived_active = market.current_snapshot().borrow_asset_deposited_active;
633        assert_eq!(
634            u128::from(derived_active),
635            200,
636            "incoming supplier B must be included in the snapshot N+1 active denominator",
637        );
638        market.finalized_snapshots.push(Snapshot {
639            time_chunk,
640            end_timestamp_ms: 2_000.into(),
641            borrow_asset_deposited_active: derived_active,
642            borrow_asset_borrowed: 0.into(),
643            collateral_asset_deposited: 0.into(),
644            yield_distribution: 800.into(),
645            interest_rate: Decimal::ZERO,
646        });
647
648        // Supplier A: active for snapshot N (index 1) onward.
649        let position_a = SupplyPosition {
650            started_at_block_timestamp_ms: Some(0.into()),
651            borrow_asset_deposit: Deposit {
652                active: 100.into(),
653                incoming: vec![],
654                outgoing: BorrowAssetAmount::zero(),
655            },
656            borrow_asset_yield: Accumulator::new(1),
657        };
658
659        // Supplier B: deposit activates only at snapshot N+1 (index 2), so B is
660        // NOT active during snapshot N.
661        let position_b = SupplyPosition {
662            started_at_block_timestamp_ms: Some(0.into()),
663            borrow_asset_deposit: Deposit {
664                active: 0.into(),
665                incoming: vec![IncomingDeposit {
666                    activate_at_snapshot_index: 2,
667                    amount: 100.into(),
668                }],
669                outgoing: BorrowAssetAmount::zero(),
670            },
671            borrow_asset_yield: Accumulator::new(1),
672        };
673
674        let yield_a = SupplyPositionRef::new(&market, "a.near".parse().unwrap(), position_a)
675            .calculate_yield(u32::MAX);
676        let yield_b = SupplyPositionRef::new(&market, "b.near".parse().unwrap(), position_b)
677            .calculate_yield(u32::MAX);
678
679        // Snapshot N (only A active): A gets 1000 * 100 / 100 = 1000; B gets 0.
680        // Snapshot N+1 (both active): each gets 800 * 100 / 200 = 400.
681        //   A total = 1000 + 400 = 1400
682        //   B total =    0 + 400 =  400
683        assert_eq!(u128::from(yield_a.get_amount()), 1_400);
684        assert_eq!(u128::from(yield_b.get_amount()), 400);
685
686        // Snapshot-N interest must go entirely to A, never to B.
687        assert!(
688            u128::from(yield_a.get_amount()) > u128::from(yield_b.get_amount()),
689            "A earned the full snapshot-N yield plus half of snapshot N+1; B only half of N+1",
690        );
691        assert_eq!(
692            u128::from(yield_a.get_amount()) - u128::from(yield_b.get_amount()),
693            1_000,
694            "the difference is exactly the snapshot-N yield, which B must not share",
695        );
696
697        assert_eq!(yield_a.next_snapshot_index, 3);
698        assert_eq!(yield_b.next_snapshot_index, 3);
699    }
700}