wp-solana-test-core 0.1.1

Protocol-agnostic Solana test infrastructure built on LiteSVM
Documentation
//! Shared SPL account builders for SVM-based tests.
//!
//! These helpers construct minimal but valid Solana account structs that can be
//! loaded into a `LiteSVM` instance (or any `AccountStore`) without hitting
//! mainnet RPC.

use anyhow::Result;
use borsh::BorshSerialize;
use solana_sdk::{account::Account, pubkey, pubkey::Pubkey};

use crate::context::TestContext;

// ---------------------------------------------------------------------------
// Pyth V2 constants
// ---------------------------------------------------------------------------

/// Pyth V2 oracle program ID on mainnet.
pub const PYTH_PROGRAM_ID: Pubkey = pubkey!("FsJ3A3u2vn5cTVofAjvy6y5kwABJAqYWpe4975bi2epH");

/// Total size of a Pyth V2 price account.
const PYTH_PRICE_ACCOUNT_SIZE: usize = 3312;

/// Pyth V2 magic number identifying a valid account.
const PYTH_MAGIC: u32 = 0xa1b2c3d4;
/// Pyth V2 version field.
const PYTH_VERSION: u32 = 2;
/// Account type = Price.
const PYTH_ACCOUNT_TYPE_PRICE: u32 = 3;
/// Price type = Price (as opposed to TWAP etc.).
const PYTH_PRICE_TYPE: u32 = 1;
/// PriceStatus::Trading — indicates the price is valid.
const PYTH_STATUS_TRADING: u32 = 1;

// -- Byte offsets inside the Pyth V2 price account --------------------------
const OFF_MAGIC: usize = 0;
const OFF_VERSION: usize = 4;
const OFF_ACCOUNT_TYPE: usize = 8;
const OFF_SIZE: usize = 12;
const OFF_PRICE_TYPE: usize = 16;
const OFF_EXPONENT: usize = 20;
const OFF_NUM_COMPONENTS: usize = 24;
const OFF_LAST_SLOT: usize = 32;
const OFF_VALID_SLOT: usize = 40;
const OFF_AGG_PRICE: usize = 208;
const OFF_AGG_CONF: usize = 216;
const OFF_AGG_STATUS: usize = 224;
const OFF_AGG_PUB_SLOT: usize = 232;
const OFF_AGG_PUBLISH_TIME: usize = 240;

/// Build a minimal SPL Token Mint account (82 bytes, initialized).
///
/// The resulting account is owned by `spl_token::ID` and is suitable for
/// loading into LiteSVM as a mock mint.
pub fn build_spl_mint(decimals: u8) -> Account {
    use solana_sdk::program_pack::Pack;
    use spl_token::state::Mint;

    let mint = Mint {
        mint_authority: solana_sdk::program_option::COption::None,
        supply: 1_000_000_000_000_000,
        decimals,
        is_initialized: true,
        freeze_authority: solana_sdk::program_option::COption::None,
    };
    let mut data = vec![0u8; Mint::LEN];
    mint.pack_into_slice(&mut data);
    Account { lamports: 10_000_000, data, owner: spl_token::ID, executable: false, rent_epoch: 0 }
}

/// Build a minimal SPL Token account (token account / vault).
///
/// The resulting account is owned by `spl_token::ID` and is suitable for
/// loading into LiteSVM as a mock token account or vault.
pub fn build_spl_token_account(mint: Pubkey, owner: Pubkey, amount: u64) -> Account {
    use solana_sdk::program_pack::Pack;
    use spl_token::state::Account as TokenAccount;

    let token = TokenAccount {
        mint,
        owner,
        amount,
        delegate: solana_sdk::program_option::COption::None,
        state: spl_token::state::AccountState::Initialized,
        is_native: solana_sdk::program_option::COption::None,
        delegated_amount: 0,
        close_authority: solana_sdk::program_option::COption::None,
    };
    let mut data = vec![0u8; TokenAccount::LEN];
    token.pack_into_slice(&mut data);
    Account { lamports: 10_000_000, data, owner: spl_token::ID, executable: false, rent_epoch: 0 }
}

/// Serialize a Borsh struct into a Solana [`Account`] owned by `program_id`.
///
/// This is useful for building mock program-owned accounts (e.g. pool state,
/// position state) in offline / LiteSVM tests.
pub fn pack_into_program_account<T: BorshSerialize>(
    data: &T,
    program_id: &Pubkey,
) -> Result<Account, std::io::Error> {
    let bytes = borsh::to_vec(data)?;
    Ok(Account {
        lamports: 10_000_000,
        data: bytes,
        owner: *program_id,
        executable: false,
        rent_epoch: 0,
    })
}

// ---------------------------------------------------------------------------
// Pyth V2 oracle helpers
// ---------------------------------------------------------------------------

/// Build a Pyth V2 price account that passes on-chain oracle parsing.
///
/// # Arguments
/// * `price` — aggregate price value (e.g. `12345` for $123.45 with `expo =
///   -2`)
/// * `expo`  — price exponent (e.g. `-8`)
/// * `conf`  — confidence interval
///
/// The returned [`Account`] is owned by [`PYTH_PROGRAM_ID`] and is 3312 bytes,
/// matching the layout expected by programs that read Pyth V2 price feeds.
pub fn build_pyth_price_account(price: i64, expo: i32, conf: u64) -> Account {
    let mut data = vec![0u8; PYTH_PRICE_ACCOUNT_SIZE];
    let now = chrono::Utc::now().timestamp();

    // Header
    write_le_u32(&mut data, OFF_MAGIC, PYTH_MAGIC);
    write_le_u32(&mut data, OFF_VERSION, PYTH_VERSION);
    write_le_u32(&mut data, OFF_ACCOUNT_TYPE, PYTH_ACCOUNT_TYPE_PRICE);
    write_le_u32(&mut data, OFF_SIZE, PYTH_PRICE_ACCOUNT_SIZE as u32);
    write_le_u32(&mut data, OFF_PRICE_TYPE, PYTH_PRICE_TYPE);
    write_le_i32(&mut data, OFF_EXPONENT, expo);
    write_le_u32(&mut data, OFF_NUM_COMPONENTS, 1);

    // Slot fields — use 1 as a safe non-zero default.
    write_le_u64(&mut data, OFF_LAST_SLOT, 1);
    write_le_u64(&mut data, OFF_VALID_SLOT, 1);

    // Aggregate price info — this is what on-chain programs read.
    write_le_i64(&mut data, OFF_AGG_PRICE, price);
    write_le_u64(&mut data, OFF_AGG_CONF, conf);
    write_le_u32(&mut data, OFF_AGG_STATUS, PYTH_STATUS_TRADING);
    write_le_u64(&mut data, OFF_AGG_PUB_SLOT, 1);
    write_le_i64(&mut data, OFF_AGG_PUBLISH_TIME, now);

    Account { lamports: 10_000_000, data, owner: PYTH_PROGRAM_ID, executable: false, rent_epoch: 0 }
}

/// Update the aggregate price inside an existing Pyth V2 price account stored
/// in the test context.
///
/// This reads the account from the SVM, overwrites the aggregate-price bytes,
/// and writes it back — useful for simulating oracle price movements in tests.
pub fn update_oracle_price(ctx: &TestContext, oracle: Pubkey, new_price: i64) -> Result<()> {
    let mut svm = ctx.lock_svm();
    let mut account = svm
        .get_account(&oracle)
        .ok_or_else(|| anyhow::anyhow!("oracle account {oracle} not found in SVM"))?;

    if account.data.len() < OFF_AGG_PRICE + 8 {
        anyhow::bail!(
            "account data too short ({} bytes) to be a Pyth V2 price account",
            account.data.len()
        );
    }

    let magic = u32::from_le_bytes(account.data[OFF_MAGIC..OFF_MAGIC + 4].try_into().unwrap());
    if magic != PYTH_MAGIC {
        anyhow::bail!("account does not have Pyth V2 magic (got {:#x})", magic);
    }

    write_le_i64(&mut account.data, OFF_AGG_PRICE, new_price);
    svm.set_account(oracle, account)?;
    Ok(())
}

// ---------------------------------------------------------------------------
// Little-endian byte writers
// ---------------------------------------------------------------------------

fn write_le_u32(buf: &mut [u8], offset: usize, value: u32) {
    buf[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
}

fn write_le_i32(buf: &mut [u8], offset: usize, value: i32) {
    buf[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
}

fn write_le_u64(buf: &mut [u8], offset: usize, value: u64) {
    buf[offset..offset + 8].copy_from_slice(&value.to_le_bytes());
}

fn write_le_i64(buf: &mut [u8], offset: usize, value: i64) {
    buf[offset..offset + 8].copy_from_slice(&value.to_le_bytes());
}

// ===========================================================================
// Tests
// ===========================================================================

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{accounts::set_account, context::new_test_context};

    // -- Little-endian byte readers (test-only) -----------------------------

    fn read_le_u32(buf: &[u8], offset: usize) -> u32 {
        u32::from_le_bytes(buf[offset..offset + 4].try_into().unwrap())
    }

    fn read_le_i32(buf: &[u8], offset: usize) -> i32 {
        i32::from_le_bytes(buf[offset..offset + 4].try_into().unwrap())
    }

    fn read_le_u64(buf: &[u8], offset: usize) -> u64 {
        u64::from_le_bytes(buf[offset..offset + 8].try_into().unwrap())
    }

    fn read_le_i64(buf: &[u8], offset: usize) -> i64 {
        i64::from_le_bytes(buf[offset..offset + 8].try_into().unwrap())
    }

    #[test]
    fn test_build_pyth_price_account_fields() {
        let price: i64 = 12_345_000_000;
        let expo: i32 = -8;
        let conf: u64 = 500_000;

        let account = build_pyth_price_account(price, expo, conf);

        // Correct size and ownership
        assert_eq!(account.data.len(), PYTH_PRICE_ACCOUNT_SIZE);
        assert_eq!(account.owner, PYTH_PROGRAM_ID);

        let data = &account.data;

        // Header fields
        assert_eq!(read_le_u32(data, OFF_MAGIC), PYTH_MAGIC, "magic mismatch");
        assert_eq!(read_le_u32(data, OFF_VERSION), PYTH_VERSION, "version mismatch");
        assert_eq!(
            read_le_u32(data, OFF_ACCOUNT_TYPE),
            PYTH_ACCOUNT_TYPE_PRICE,
            "account type mismatch"
        );
        assert_eq!(read_le_i32(data, OFF_EXPONENT), expo, "exponent mismatch");

        // Aggregate price info
        assert_eq!(read_le_i64(data, OFF_AGG_PRICE), price, "aggregate price mismatch");
        assert_eq!(read_le_u64(data, OFF_AGG_CONF), conf, "aggregate conf mismatch");
        assert_eq!(
            read_le_u32(data, OFF_AGG_STATUS),
            PYTH_STATUS_TRADING,
            "aggregate status should be Trading"
        );
    }

    #[test]
    fn test_update_oracle_price() {
        let ctx = new_test_context().unwrap();
        let oracle = Pubkey::new_unique();

        // Build and load an initial price account.
        let initial_price: i64 = 10_000_000_000; // $100 with expo -8
        let account = build_pyth_price_account(initial_price, -8, 100_000);
        set_account(&ctx, oracle, account).unwrap();

        // Verify initial price.
        {
            let svm = ctx.lock_svm();
            let stored = svm.get_account(&oracle).unwrap();
            assert_eq!(read_le_i64(&stored.data, OFF_AGG_PRICE), initial_price);
        }

        // Update to a new price.
        let new_price: i64 = 15_000_000_000; // $150
        update_oracle_price(&ctx, oracle, new_price).unwrap();

        // Verify updated price.
        {
            let svm = ctx.lock_svm();
            let stored = svm.get_account(&oracle).unwrap();
            assert_eq!(read_le_i64(&stored.data, OFF_AGG_PRICE), new_price);
        }

        // Other fields should be unchanged.
        {
            let svm = ctx.lock_svm();
            let stored = svm.get_account(&oracle).unwrap();
            assert_eq!(read_le_u32(&stored.data, OFF_MAGIC), PYTH_MAGIC);
            assert_eq!(read_le_i32(&stored.data, OFF_EXPONENT), -8);
            assert_eq!(read_le_u64(&stored.data, OFF_AGG_CONF), 100_000);
        }
    }
}