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}