roshi-interface 0.1.3

On-chain instruction interface and types for the Roshi Solana program.
Documentation
use wincode::{SchemaRead, SchemaWrite};

/// Discriminator for oracle implementations.
#[repr(u8)]
#[derive(Clone, Copy, Debug, Eq, PartialEq, codama_macros::CodamaType, SchemaWrite, SchemaRead)]
#[wincode(tag_encoding = "u8")]
pub enum OracleKind {
    #[wincode(tag = 0)]
    Switchboard = 0,
    #[wincode(tag = 1)]
    Pyth = 1,
}

impl OracleKind {
    pub const fn as_u8(self) -> u8 {
        self as u8
    }

    pub const fn from_u8(kind: u8) -> Option<Self> {
        match kind {
            0 => Some(Self::Switchboard),
            1 => Some(Self::Pyth),
            _ => None,
        }
    }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct InvalidOracleKind;

/// Switchboard On-Demand oracle configuration stored with the asset it prices.
///
/// `price_decimals` is the scale of the raw oracle price. A price of `123`
/// with `price_decimals = 2` represents `1.23`.
#[derive(
    Clone, Copy, Debug, Default, Eq, PartialEq, codama_macros::CodamaType, SchemaWrite, SchemaRead,
)]
#[wincode(assert_zero_copy)]
#[repr(C)]
pub struct SwitchboardOracleConfig {
    pub quote_account: [u8; 32],
    pub queue_account: [u8; 32],
    pub feed_id: [u8; 32],
    pub max_age_slots: u64,
    pub price_decimals: u8,
    _padding: [u8; 7],
}

impl SwitchboardOracleConfig {
    pub const fn new(
        quote_account: [u8; 32],
        queue_account: [u8; 32],
        feed_id: [u8; 32],
        price_decimals: u8,
        max_age_slots: u64,
    ) -> Self {
        Self {
            quote_account,
            queue_account,
            feed_id,
            max_age_slots,
            price_decimals,
            _padding: [0; 7],
        }
    }
}

/// Pyth pull-oracle configuration stored with the asset it prices.
///
/// `feed_id` is the 32-byte Pyth price feed id expected inside the submitted
/// price update account. `price_decimals` is the scale Roshi exposes through
/// `OraclePrice`; for example, a Pyth price of `123456789 * 10^-8` with
/// `price_decimals = 8` is returned as `123456789`.
///
/// `max_confidence_bps = 0` disables the confidence-width guardrail.
#[derive(
    Clone, Copy, Debug, Default, Eq, PartialEq, codama_macros::CodamaType, SchemaWrite, SchemaRead,
)]
#[wincode(assert_zero_copy)]
#[repr(C)]
pub struct PythOracleConfig {
    pub feed_id: [u8; 32],
    pub max_age_seconds: u64,
    pub max_confidence_bps: u16,
    pub price_decimals: u8,
    _padding: [u8; 5],
}

impl PythOracleConfig {
    pub const fn new(
        feed_id: [u8; 32],
        price_decimals: u8,
        max_age_seconds: u64,
        max_confidence_bps: u16,
    ) -> Self {
        Self {
            feed_id,
            max_age_seconds,
            max_confidence_bps,
            price_decimals,
            _padding: [0; 5],
        }
    }
}

/// Oracle configuration stored by vault and asset accounts.
///
/// The serialized shape includes every supported oracle implementation from
/// the start. Switching implementations only changes `kind`, so account data
/// size remains stable.
#[derive(Clone, Copy, Debug, Eq, PartialEq, codama_macros::CodamaType, SchemaWrite, SchemaRead)]
#[wincode(assert_zero_copy)]
#[repr(C)]
pub struct OracleConfig {
    pub switchboard: SwitchboardOracleConfig,
    pub pyth: PythOracleConfig,
    kind: u8,
    _padding: [u8; 7],
}

impl OracleConfig {
    pub const fn raw_kind(&self) -> u8 {
        self.kind
    }

    pub const fn kind(&self) -> Result<OracleKind, InvalidOracleKind> {
        match OracleKind::from_u8(self.kind) {
            Some(kind) => Ok(kind),
            None => Err(InvalidOracleKind),
        }
    }

    pub const fn validate(&self) -> Result<(), InvalidOracleKind> {
        match self.kind() {
            Ok(_) => Ok(()),
            Err(error) => Err(error),
        }
    }

    pub const fn switchboard(config: SwitchboardOracleConfig) -> Self {
        Self {
            switchboard: config,
            pyth: PythOracleConfig {
                feed_id: [0; 32],
                max_age_seconds: 0,
                max_confidence_bps: 0,
                price_decimals: 0,
                _padding: [0; 5],
            },
            kind: OracleKind::Switchboard.as_u8(),
            _padding: [0; 7],
        }
    }

    pub const fn pyth(config: PythOracleConfig) -> Self {
        Self {
            switchboard: SwitchboardOracleConfig {
                quote_account: [0; 32],
                queue_account: [0; 32],
                feed_id: [0; 32],
                max_age_slots: 0,
                price_decimals: 0,
                _padding: [0; 7],
            },
            pyth: config,
            kind: OracleKind::Pyth.as_u8(),
            _padding: [0; 7],
        }
    }

    pub const fn with_configs(
        kind: OracleKind,
        switchboard: SwitchboardOracleConfig,
        pyth: PythOracleConfig,
    ) -> Self {
        Self {
            switchboard,
            pyth,
            kind: kind.as_u8(),
            _padding: [0; 7],
        }
    }
}

impl Default for OracleConfig {
    fn default() -> Self {
        Self::switchboard(SwitchboardOracleConfig::default())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use wincode::{config::DefaultConfig, serialize, SchemaRead, SchemaWrite, TypeMeta};

    fn assert_zero_copy<T>()
    where
        T: wincode::ZeroCopy,
        T: for<'de> SchemaRead<'de, DefaultConfig> + SchemaWrite<DefaultConfig>,
    {
        assert_eq!(
            <T as SchemaRead<'_, DefaultConfig>>::TYPE_META,
            TypeMeta::Static {
                size: core::mem::size_of::<T>(),
                zero_copy: true,
            }
        );
        assert_eq!(
            <T as SchemaWrite<DefaultConfig>>::TYPE_META,
            TypeMeta::Static {
                size: core::mem::size_of::<T>(),
                zero_copy: true,
            }
        );
    }

    #[test]
    fn oracle_config_size_is_fixed_across_implementations() {
        let switchboard = OracleConfig::switchboard(SwitchboardOracleConfig::new(
            [1; 32], [2; 32], [3; 32], 6, 100,
        ));
        let pyth = OracleConfig::pyth(PythOracleConfig::new([4; 32], 8, 30, 250));

        assert_eq!(
            serialize(&switchboard).unwrap().len(),
            serialize(&pyth).unwrap().len()
        );
        assert_eq!(switchboard.kind(), Ok(OracleKind::Switchboard));
        assert_eq!(pyth.kind(), Ok(OracleKind::Pyth));
    }

    #[test]
    fn with_configs_keeps_inactive_config_available() {
        let switchboard_config = SwitchboardOracleConfig::new([1; 32], [2; 32], [3; 32], 6, 100);
        let pyth_config = PythOracleConfig::new([4; 32], 8, 30, 250);

        let config = OracleConfig::with_configs(OracleKind::Pyth, switchboard_config, pyth_config);

        assert_eq!(config.kind(), Ok(OracleKind::Pyth));
        assert_eq!(config.switchboard, switchboard_config);
        assert_eq!(config.pyth, pyth_config);
    }

    #[test]
    fn oracle_configs_are_zero_copy() {
        assert_zero_copy::<SwitchboardOracleConfig>();
        assert_zero_copy::<PythOracleConfig>();
        assert_zero_copy::<OracleConfig>();
        assert_eq!(core::mem::size_of::<SwitchboardOracleConfig>(), 112);
        assert_eq!(core::mem::size_of::<PythOracleConfig>(), 48);
        assert_eq!(core::mem::size_of::<OracleConfig>(), 168);
        assert_eq!(
            serialize(&OracleConfig::default()).unwrap().len(),
            core::mem::size_of::<OracleConfig>()
        );
    }

    #[test]
    fn oracle_config_rejects_invalid_kind() {
        let config = OracleConfig {
            kind: 255,
            ..OracleConfig::default()
        };

        assert_eq!(config.kind(), Err(InvalidOracleKind));
        assert_eq!(config.validate(), Err(InvalidOracleKind));
    }
}