Skip to main content

chio_link/
cache.rs

1use std::collections::{BTreeMap, VecDeque};
2
3use crate::config::PairConfig;
4use crate::{ExchangeRate, PriceOracleError};
5
6const TWAP_SCALE: u128 = 1_000_000_000_000;
7
8#[derive(Debug, Clone)]
9pub struct CacheEntry {
10    latest: ExchangeRate,
11    observations: VecDeque<ExchangeRate>,
12}
13
14#[derive(Debug, Default)]
15pub struct PriceCache {
16    entries: BTreeMap<String, CacheEntry>,
17}
18
19impl PriceCache {
20    pub fn record(
21        &mut self,
22        pair: &PairConfig,
23        rate: ExchangeRate,
24        now: u64,
25    ) -> Result<(), PriceOracleError> {
26        rate.ensure_matches_pair(pair)?;
27        rate.ensure_fresh(now)?;
28        let key = pair.pair();
29        let entry = self.entries.entry(key).or_insert_with(|| CacheEntry {
30            latest: rate.clone(),
31            observations: VecDeque::new(),
32        });
33        entry.latest = rate.clone();
34        entry.observations.push_back(rate);
35        trim_observations(entry, pair, now);
36        Ok(())
37    }
38
39    pub fn resolve(
40        &mut self,
41        pair: &PairConfig,
42        now: u64,
43    ) -> Result<Option<ExchangeRate>, PriceOracleError> {
44        let key = pair.pair();
45        let Some(entry) = self.entries.get_mut(&key) else {
46            return Ok(None);
47        };
48        trim_observations(entry, pair, now);
49        entry.latest.ensure_matches_pair(pair)?;
50        entry.latest.ensure_fresh(now)?;
51        if !pair.policy.twap_enabled || entry.observations.len() <= 1 {
52            return Ok(Some(entry.latest.clone()));
53        }
54        Ok(Some(build_twap(pair, entry)?))
55    }
56
57    #[must_use]
58    pub fn latest(&self, pair: &PairConfig) -> Option<ExchangeRate> {
59        self.entries
60            .get(&pair.pair())
61            .map(|entry| entry.latest.clone())
62    }
63}
64
65fn trim_observations(entry: &mut CacheEntry, pair: &PairConfig, now: u64) {
66    while entry.observations.len() > pair.policy.twap_max_observations {
67        let _ = entry.observations.pop_front();
68    }
69    while let Some(oldest) = entry.observations.front() {
70        if now.saturating_sub(oldest.fetched_at) <= pair.policy.twap_window_seconds {
71            break;
72        }
73        let _ = entry.observations.pop_front();
74    }
75}
76
77fn build_twap(pair: &PairConfig, entry: &CacheEntry) -> Result<ExchangeRate, PriceOracleError> {
78    let mut count: u128 = 0;
79    let mut total_scaled = 0_u128;
80    for rate in &entry.observations {
81        let scaled = normalized_price(rate)?;
82        total_scaled = total_scaled.checked_add(scaled).ok_or_else(|| {
83            PriceOracleError::ArithmeticOverflow(format!(
84                "TWAP accumulation overflowed for {}",
85                pair.pair()
86            ))
87        })?;
88        count += 1;
89    }
90    let average = total_scaled.div_ceil(count);
91    let mut twap = entry.latest.clone();
92    twap.rate_numerator = average;
93    twap.rate_denominator = TWAP_SCALE;
94    twap.source = format!("{}:twap", twap.source);
95    twap.conversion_margin_bps = pair.policy.exchange_rate_margin_bps;
96    Ok(twap)
97}
98
99fn normalized_price(rate: &ExchangeRate) -> Result<u128, PriceOracleError> {
100    let scaled = rate.rate_numerator.checked_mul(TWAP_SCALE).ok_or_else(|| {
101        PriceOracleError::ArithmeticOverflow(format!(
102            "normalizing TWAP price overflowed for {}",
103            rate.pair()
104        ))
105    })?;
106    Ok(scaled / rate.rate_denominator)
107}
108
109#[cfg(test)]
110mod tests {
111    use super::PriceCache;
112    use crate::config::PriceOracleConfig;
113    use crate::test_support::TestUnwrap;
114    use crate::ExchangeRate;
115
116    fn sample_rate(numerator: u128, fetched_at: u64) -> ExchangeRate {
117        ExchangeRate {
118            base: "ETH".to_string(),
119            quote: "USD".to_string(),
120            rate_numerator: numerator,
121            rate_denominator: 100,
122            updated_at: fetched_at.saturating_sub(30),
123            fetched_at,
124            source: "chainlink".to_string(),
125            feed_reference: "0x71041dddad3595F9CEd3DcCFBe3D1F4b0a16Bb70".to_string(),
126            max_age_seconds: 600,
127            conversion_margin_bps: 200,
128            confidence_numerator: None,
129            confidence_denominator: None,
130        }
131    }
132
133    #[test]
134    fn returns_twap_when_enabled() {
135        let config = PriceOracleConfig::base_mainnet_default("https://example.invalid");
136        let pair = config.pair("ETH", "USD").test_unwrap("pair").clone();
137        let mut cache = PriceCache::default();
138        cache
139            .record(&pair, sample_rate(300_000, 1_743_292_700), 1_743_292_700)
140            .test_unwrap("record");
141        cache
142            .record(&pair, sample_rate(306_000, 1_743_292_760), 1_743_292_760)
143            .test_unwrap("record");
144        let rate = cache
145            .resolve(&pair, 1_743_292_780)
146            .test_unwrap("resolve")
147            .test_unwrap("rate");
148        assert_eq!(rate.source, "chainlink:twap");
149        assert_eq!(rate.rate_numerator, 3_030_000_000_000_000);
150        assert_eq!(rate.rate_denominator, 1_000_000_000_000);
151    }
152
153    #[test]
154    fn drops_expired_observations() {
155        let config = PriceOracleConfig::base_mainnet_default("https://example.invalid");
156        let pair = config.pair("ETH", "USD").test_unwrap("pair").clone();
157        let mut cache = PriceCache::default();
158        cache
159            .record(&pair, sample_rate(300_000, 1_743_292_000), 1_743_292_000)
160            .test_unwrap("record");
161        cache
162            .record(&pair, sample_rate(306_000, 1_743_292_760), 1_743_292_760)
163            .test_unwrap("record");
164        let rate = cache
165            .resolve(&pair, 1_743_292_780)
166            .test_unwrap("resolve")
167            .test_unwrap("rate");
168        assert_eq!(rate.rate_numerator, 306_000);
169        assert_eq!(rate.rate_denominator, 100);
170    }
171}