Skip to main content

roshi_interface/
oracle.rs

1use wincode::{SchemaRead, SchemaWrite};
2
3/// A fixed-point oracle price: `value / 10^decimals` quote units per one
4/// *whole* token of the priced asset (standard market convention).
5#[derive(Clone, Copy, Debug, Eq, PartialEq)]
6pub struct OraclePrice {
7    pub value: u128,
8    pub decimals: u8,
9}
10
11impl OraclePrice {
12    /// The exact price of the base asset in itself. Direct asset/base feeds
13    /// price against this as their base leg, collapsing the two-leg
14    /// conversion to a single feed.
15    pub const UNIT: Self = Self {
16        value: 1,
17        decimals: 0,
18    };
19}
20
21/// Discriminator for oracle implementations.
22#[repr(u8)]
23#[derive(Clone, Copy, Debug, Eq, PartialEq, codama_macros::CodamaType, SchemaWrite, SchemaRead)]
24#[wincode(tag_encoding = "u8")]
25pub enum OracleKind {
26    #[wincode(tag = 0)]
27    Switchboard = 0,
28    #[wincode(tag = 1)]
29    Pyth = 1,
30}
31
32impl OracleKind {
33    pub const fn as_u8(self) -> u8 {
34        self as u8
35    }
36
37    pub const fn from_u8(kind: u8) -> Option<Self> {
38        match kind {
39            0 => Some(Self::Switchboard),
40            1 => Some(Self::Pyth),
41            _ => None,
42        }
43    }
44}
45
46#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub struct InvalidOracleConfig;
48
49/// Switchboard On-Demand oracle configuration stored with the asset it prices.
50///
51/// `price_decimals` is the scale of the raw oracle price. A price of `123`
52/// with `price_decimals = 2` represents `1.23`.
53#[derive(
54    Clone, Copy, Debug, Default, Eq, PartialEq, codama_macros::CodamaType, SchemaWrite, SchemaRead,
55)]
56#[wincode(assert_zero_copy)]
57#[repr(C)]
58pub struct SwitchboardOracleConfig {
59    pub quote_account: [u8; 32],
60    pub queue_account: [u8; 32],
61    pub feed_id: [u8; 32],
62    pub max_age_slots: u64,
63    pub price_decimals: u8,
64    _padding: [u8; 7],
65}
66
67impl SwitchboardOracleConfig {
68    pub const fn new(
69        quote_account: [u8; 32],
70        queue_account: [u8; 32],
71        feed_id: [u8; 32],
72        price_decimals: u8,
73        max_age_slots: u64,
74    ) -> Self {
75        Self {
76            quote_account,
77            queue_account,
78            feed_id,
79            max_age_slots,
80            price_decimals,
81            _padding: [0; 7],
82        }
83    }
84}
85
86/// Pyth pull-oracle configuration stored with the asset it prices.
87///
88/// `feed_id` is the 32-byte Pyth price feed id expected inside the submitted
89/// price update account. `price_decimals` is the scale Roshi exposes through
90/// `OraclePrice`; for example, a Pyth price of `123456789 * 10^-8` with
91/// `price_decimals = 8` is returned as `123456789`.
92///
93/// `max_confidence_bps` must be nonzero for an active Pyth leg —
94/// [`OracleConfig::validate`] rejects an unbounded confidence interval. The
95/// raw reader still treats `0` as "no width check" for inactive configs.
96///
97/// `price_update_account` optionally pins the price update account by address;
98/// all-zeros (the default) accepts any Pyth-verified update account carrying
99/// `feed_id`, which is the intended pull-oracle posture.
100#[derive(
101    Clone, Copy, Debug, Default, Eq, PartialEq, codama_macros::CodamaType, SchemaWrite, SchemaRead,
102)]
103#[wincode(assert_zero_copy)]
104#[repr(C)]
105pub struct PythOracleConfig {
106    pub feed_id: [u8; 32],
107    pub price_update_account: [u8; 32],
108    pub max_age_seconds: u64,
109    pub max_confidence_bps: u16,
110    pub price_decimals: u8,
111    _padding: [u8; 5],
112}
113
114impl PythOracleConfig {
115    pub const fn new(
116        feed_id: [u8; 32],
117        price_decimals: u8,
118        max_age_seconds: u64,
119        max_confidence_bps: u16,
120    ) -> Self {
121        Self {
122            feed_id,
123            price_update_account: [0; 32],
124            max_age_seconds,
125            max_confidence_bps,
126            price_decimals,
127            _padding: [0; 5],
128        }
129    }
130
131    /// Pin pricing to one specific price update account (e.g. a sponsored
132    /// Pyth feed account) instead of accepting any verified update for
133    /// `feed_id`.
134    pub const fn pin_price_update_account(mut self, price_update_account: [u8; 32]) -> Self {
135        self.price_update_account = price_update_account;
136        self
137    }
138
139    /// The pinned price update account, or `None` when any verified update
140    /// for `feed_id` is accepted (`price_update_account` all-zeros).
141    pub fn pinned_price_update_account(&self) -> Option<[u8; 32]> {
142        if self.price_update_account == [0; 32] {
143            return None;
144        }
145
146        Some(self.price_update_account)
147    }
148}
149
150/// Oracle configuration stored by vault and asset accounts.
151///
152/// The serialized shape includes every supported oracle implementation from
153/// the start. Switching implementations only changes `kind`, so account data
154/// size remains stable.
155#[derive(Clone, Copy, Debug, Eq, PartialEq, codama_macros::CodamaType, SchemaWrite, SchemaRead)]
156#[wincode(assert_zero_copy)]
157#[repr(C)]
158pub struct OracleConfig {
159    pub switchboard: SwitchboardOracleConfig,
160    pub pyth: PythOracleConfig,
161    kind: u8,
162    _padding: [u8; 7],
163}
164
165impl OracleConfig {
166    pub const fn raw_kind(&self) -> u8 {
167        self.kind
168    }
169
170    pub const fn kind(&self) -> Result<OracleKind, InvalidOracleConfig> {
171        match OracleKind::from_u8(self.kind) {
172            Some(kind) => Ok(kind),
173            None => Err(InvalidOracleConfig),
174        }
175    }
176
177    pub const fn validate(&self) -> Result<(), InvalidOracleConfig> {
178        match self.kind() {
179            // An active Pyth leg must carry a confidence-width guardrail: an
180            // unbounded confidence interval admits an arbitrarily uncertain,
181            // technically-fresh price. Only the active leg is checked, so
182            // zeroed inactive configs stay legal.
183            Ok(OracleKind::Pyth) => {
184                if self.pyth.max_confidence_bps == 0 {
185                    return Err(InvalidOracleConfig);
186                }
187                Ok(())
188            }
189            Ok(OracleKind::Switchboard) => Ok(()),
190            Err(error) => Err(error),
191        }
192    }
193
194    pub const fn switchboard(config: SwitchboardOracleConfig) -> Self {
195        Self {
196            switchboard: config,
197            pyth: PythOracleConfig {
198                feed_id: [0; 32],
199                price_update_account: [0; 32],
200                max_age_seconds: 0,
201                max_confidence_bps: 0,
202                price_decimals: 0,
203                _padding: [0; 5],
204            },
205            kind: OracleKind::Switchboard.as_u8(),
206            _padding: [0; 7],
207        }
208    }
209
210    pub const fn pyth(config: PythOracleConfig) -> Self {
211        Self {
212            switchboard: SwitchboardOracleConfig {
213                quote_account: [0; 32],
214                queue_account: [0; 32],
215                feed_id: [0; 32],
216                max_age_slots: 0,
217                price_decimals: 0,
218                _padding: [0; 7],
219            },
220            pyth: config,
221            kind: OracleKind::Pyth.as_u8(),
222            _padding: [0; 7],
223        }
224    }
225
226    pub const fn with_configs(
227        kind: OracleKind,
228        switchboard: SwitchboardOracleConfig,
229        pyth: PythOracleConfig,
230    ) -> Self {
231        Self {
232            switchboard,
233            pyth,
234            kind: kind.as_u8(),
235            _padding: [0; 7],
236        }
237    }
238}
239
240impl Default for OracleConfig {
241    fn default() -> Self {
242        Self::switchboard(SwitchboardOracleConfig::default())
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use wincode::{config::DefaultConfig, serialize, SchemaRead, SchemaWrite, TypeMeta};
250
251    fn assert_zero_copy<T>()
252    where
253        T: wincode::ZeroCopy,
254        T: for<'de> SchemaRead<'de, DefaultConfig> + SchemaWrite<DefaultConfig>,
255    {
256        assert_eq!(
257            <T as SchemaRead<'_, DefaultConfig>>::TYPE_META,
258            TypeMeta::Static {
259                size: core::mem::size_of::<T>(),
260                zero_copy: true,
261            }
262        );
263        assert_eq!(
264            <T as SchemaWrite<DefaultConfig>>::TYPE_META,
265            TypeMeta::Static {
266                size: core::mem::size_of::<T>(),
267                zero_copy: true,
268            }
269        );
270    }
271
272    #[test]
273    fn oracle_config_size_is_fixed_across_implementations() {
274        let switchboard = OracleConfig::switchboard(SwitchboardOracleConfig::new(
275            [1; 32], [2; 32], [3; 32], 6, 100,
276        ));
277        let pyth = OracleConfig::pyth(PythOracleConfig::new([4; 32], 8, 30, 250));
278
279        assert_eq!(
280            serialize(&switchboard).unwrap().len(),
281            serialize(&pyth).unwrap().len()
282        );
283        assert_eq!(switchboard.kind(), Ok(OracleKind::Switchboard));
284        assert_eq!(pyth.kind(), Ok(OracleKind::Pyth));
285    }
286
287    #[test]
288    fn with_configs_keeps_inactive_config_available() {
289        let switchboard_config = SwitchboardOracleConfig::new([1; 32], [2; 32], [3; 32], 6, 100);
290        let pyth_config = PythOracleConfig::new([4; 32], 8, 30, 250);
291
292        let config = OracleConfig::with_configs(OracleKind::Pyth, switchboard_config, pyth_config);
293
294        assert_eq!(config.kind(), Ok(OracleKind::Pyth));
295        assert_eq!(config.switchboard, switchboard_config);
296        assert_eq!(config.pyth, pyth_config);
297    }
298
299    #[test]
300    fn oracle_configs_are_zero_copy() {
301        assert_zero_copy::<SwitchboardOracleConfig>();
302        assert_zero_copy::<PythOracleConfig>();
303        assert_zero_copy::<OracleConfig>();
304        assert_eq!(core::mem::size_of::<SwitchboardOracleConfig>(), 112);
305        assert_eq!(core::mem::size_of::<PythOracleConfig>(), 80);
306        assert_eq!(core::mem::size_of::<OracleConfig>(), 200);
307        assert_eq!(
308            serialize(&OracleConfig::default()).unwrap().len(),
309            core::mem::size_of::<OracleConfig>()
310        );
311    }
312
313    #[test]
314    fn pyth_price_update_pin_defaults_off_and_round_trips() {
315        let unpinned = PythOracleConfig::new([4; 32], 8, 30, 250);
316        assert_eq!(unpinned.pinned_price_update_account(), None);
317
318        let pinned = unpinned.pin_price_update_account([5; 32]);
319        assert_eq!(pinned.pinned_price_update_account(), Some([5; 32]));
320    }
321
322    #[test]
323    fn validate_requires_confidence_bound_on_active_pyth_leg() {
324        let unbounded = OracleConfig::pyth(PythOracleConfig::new([4; 32], 8, 30, 0));
325        assert_eq!(unbounded.validate(), Err(InvalidOracleConfig));
326
327        let bounded = OracleConfig::pyth(PythOracleConfig::new([4; 32], 8, 30, 250));
328        assert_eq!(bounded.validate(), Ok(()));
329
330        // The inactive Pyth config may stay zeroed under a Switchboard kind.
331        let switchboard = OracleConfig::switchboard(SwitchboardOracleConfig::new(
332            [1; 32], [2; 32], [3; 32], 6, 100,
333        ));
334        assert_eq!(switchboard.pyth.max_confidence_bps, 0);
335        assert_eq!(switchboard.validate(), Ok(()));
336    }
337
338    #[test]
339    fn oracle_config_rejects_invalid_kind() {
340        let config = OracleConfig {
341            kind: 255,
342            ..OracleConfig::default()
343        };
344
345        assert_eq!(config.kind(), Err(InvalidOracleConfig));
346        assert_eq!(config.validate(), Err(InvalidOracleConfig));
347    }
348}