switchboard-on-demand 0.4.5

A Rust library to interact with the Switchboard Solana program.
Documentation
#[cfg(feature = "anchor")]
use anchor_lang::solana_program;
use anyhow::{bail, Context, Error as AnyError};
use borsh::{BorshDeserialize, BorshSerialize};
use rust_decimal::prelude::*;
use solana_program::account_info::AccountInfo;
use solana_program::hash::hash;
use solana_program::sysvar;
use solana_program::sysvar::clock::Clock;
use solana_program::sysvar::instructions::get_instruction_relative;

use crate::prelude::*;
use crate::Instructions;

const VERIFY_BUNDLE_IX_DISCRIMINATOR: [u8; 8] = [136, 34, 166, 38, 233, 13, 248, 165];
#[allow(unused)]
const SLOTS_PER_EPOCH: u64 = 432_000;

#[derive(Clone, BorshSerialize, BorshDeserialize)]
pub struct FeedInfo {
    pub checksum: [u8; 32],     // 32 bytes
    pub value: i128,            // 16 bytes => 48 bytes total
    pub min_oracle_samples: u8, // 1 byte => 49 bytes total
}
impl FeedInfo {
    pub const PACKED_SIZE: usize = 49;
    // pub const COMPRESSED_SIZE: usize = 40;

    pub fn packed_size() -> usize {
        Self::PACKED_SIZE
    }

    /// Gets the feed ID, which is the checksum of the feed.
    pub fn feed_id(&self) -> [u8; 32] {
        // The checksum is used as the feed ID
        self.checksum
    }

    /// Creates a new `FeedInfo` instance from slice.
    pub fn from_packed(buffer: &[u8]) -> Result<Self, AnyError> {
        if buffer.len() != Self::PACKED_SIZE {
            bail!("Invalid FeedInfo packed size");
        }
        let checksum = buffer[0..32].try_into().unwrap();
        let value = i128::from_le_bytes(buffer[32..48].try_into().unwrap());
        let min_oracle_samples = buffer[48];
        Ok(Self {
            checksum,
            value,
            min_oracle_samples,
        })
    }

    /// Converts the `FeedInfo` instance into a packed byte array.
    pub fn to_packed(&self) -> [u8; Self::PACKED_SIZE] {
        let mut buffer = [0u8; Self::PACKED_SIZE];
        buffer[0..32].copy_from_slice(&self.checksum);
        buffer[32..48].copy_from_slice(&self.value.to_le_bytes());
        buffer[48] = self.min_oracle_samples;
        buffer
    }

    /// Returns the feed value as a `Decimal`, scaled using the program-wide `PRECISION`.
    ///
    /// This converts the raw fixed-point integer into a human-readable decimal form.
    pub fn value(&self) -> Decimal {
        Decimal::from_i128_with_scale(self.value, PRECISION)
    }

    #[inline]
    fn generate_combined_checksum(feed_infos: &[FeedInfo], signed_slothash: &[u8; 32]) -> [u8; 32] {
        // Precompute the total size to avoid reallocations
        let total_size = 40 + feed_infos.len() * FeedInfo::PACKED_SIZE;

        let mut buffer = Vec::with_capacity(total_size);
        buffer.extend_from_slice(signed_slothash);
        buffer.extend_from_slice(&[0; 8]); // Placeholder for timestamp, if needed
        for info in feed_infos {
            buffer.extend_from_slice(&info.to_packed());
        }
        hash(&buffer).to_bytes()
    }
}

#[derive(Clone, BorshSerialize, BorshDeserialize)]
pub struct VerifiedBundle {
    pub slot_lower: u8,            // 1 byte
    pub feed_infos: Vec<FeedInfo>, // 48 bytes (each FeedInfo)
    //
    // Filled data on parse, not in input
    pub verified_slot: u64,
    pub verified_slothash: [u8; 32],
    pub verification_count: u8,
}

impl VerifiedBundle {
    /// Returns how many slots have elapsed since this bundle was verified.
    ///
    /// This uses the current slot from the Clock sysvar and subtracts the `verified_slot`.
    /// Will panic if current_slot < verified_slot, so this assumes proper ordering.
    pub fn slots_stale(&self, clock: &Clock) -> u64 {
        let current_slot = clock.slot;
        current_slot.checked_sub(self.verified_slot).unwrap()
    }

    /// Feeds the bundle with a specific feed ID
    /// # Arguments
    /// * `feed_id` - A 32-byte array representing the feed ID to look for.
    /// # Returns
    /// A `Result` containing the `FeedInfo` if found and valid
    pub fn feed(&self, feed_id: &[u8; 32]) -> Result<FeedInfo, AnyError> {
        let info = self
            .feed_infos
            .iter()
            .find(|info| info.feed_id() == *feed_id)
            .context("Switchboard On-Demand FeedNotFound")?;
        Ok(info.clone())
    }

    /// Attempts to recover a `VerifiedBundle` from a previous instruction in the current transaction.
    ///
    /// This scans the instruction sysvar in reverse order, looking for the first matching instruction
    /// with the expected program ID and discriminator. Once found, it deserializes the bundle payload.
    ///
    /// # Arguments
    /// * `ix_sysvar` - A reference to the Instructions sysvar account
    ///
    /// # Returns
    /// A deserialized `VerifiedBundle` if found and valid; otherwise, returns an error.
    pub fn load_from_instruction(ix_sysvar: &AccountInfo) -> Result<Self, AnyError> {
        let mut offset = -1;
        while let Ok(ix) = get_instruction_relative(offset, ix_sysvar) {
            offset -= 1;
            if ix.program_id != SWITCHBOARD_ON_DEMAND_PROGRAM_ID.to_bytes().into() {
                continue;
            }
            if ix.data.len() < 8 {
                bail!("Switchboard On-Demand InvalidDiscriminator");
            }
            let discriminator = &ix.data[0..8];
            if discriminator != VERIFY_BUNDLE_IX_DISCRIMINATOR {
                bail!("Switchboard On-Demand InvalidDiscriminator");
            }
            let bundle = VerifiedBundle::try_from_slice(&ix.data[8..])
                .context("Failed to deserialize VerifiedBundle")?;
            return Ok(bundle);
        }
        bail!("Switchboard On-Demand instruction not found")
    }

    pub fn verify<'info>(
        &mut self,
        queue: &AccountInfo<'info>,
        slothash_sysvar: &AccountInfo,
        ix_sysvar: &AccountInfo,
    ) -> Result<(), AnyError> {
        // Ensure the queue is valid
        if *queue.owner != SWITCHBOARD_ON_DEMAND_PROGRAM_ID.to_bytes().into() {
            bail!("Invalid queue account owner");
        }
        // Ensure the slothash sysvar is valid
        if slothash_sysvar.key != &sysvar::slot_hashes::ID.to_bytes().into() {
            bail!("Invalid slothash sysvar account owner");
        }
        if ix_sysvar.key != &sysvar::instructions::ID.to_bytes().into() {
            bail!("Invalid instructions sysvar account owner");
        }

        let parsed_signatures = Instructions::load_secp_ix(ix_sysvar)?;

        // Get the checksum - note that feed's queue is checked implicitly by way of feed hash
        let computed_checksum =
            FeedInfo::generate_combined_checksum(&self.feed_infos, &self.verified_slothash);

        // make sure that all parsed_signatures are checking the computed checksum
        for info in &self.feed_infos {
            if parsed_signatures.len() < (info.min_oracle_samples as usize) {
                bail!("Switchboard On-Demand InsufficientSamples");
            }
        }

        let queue_buf = queue.data.borrow();
        if queue_buf[..8] != *QUEUE_ACCOUNT_DISCRIMINATOR {
            bail!("Switchboard On-Demand InvalidQueueAccountData");
        }
        let queue: &QueueAccountData = bytemuck::try_from_bytes(&queue_buf[8..])
            .map_err(|_| AnyError::msg("Invalid QueueAccountData"))?;

        // alterntativelty, sort once and run through the found keys sorted
        let mut keyset = queue.oracle_signing_keys[0..queue.oracle_keys_len as usize].to_vec();
        let mut found_keys: Vec<_> = Vec::with_capacity(parsed_signatures.len());

        // Iterate over each parsed signature and verify it
        for parsed_sig in parsed_signatures {
            // Check that the parsed_sig has signed the correct message
            if parsed_sig.message != computed_checksum {
                bail!("Switchboard On-Demand ChecksumMismatch");
            }

            // Oracles must be in the same order as the parsed signatures
            let eth_address = parsed_sig.eth_address;
            found_keys.push(eth_address);
        }

        keyset.sort_unstable();
        found_keys.sort_unstable();
        let mut i = 0;
        let mut j = 0;
        while i < found_keys.len() && j < keyset.len() {
            if found_keys[i] == keyset[j] {
                self.verification_count = self.verification_count.checked_add(1).unwrap();
                i += 1;
                j += 1;
            } else {
                j += 1;
            }
        }

        if i < found_keys.len() {
            bail!("Switchboard On-Demand NotAllSignaturesFound");
        }
        Ok(())
    }
}