chia-sdk-test 0.35.0

A wallet simulator and related tooling for testing Chia wallet code.
Documentation
use chia_bls::SecretKey;
use chia_consensus::validation_error::ErrorCode;
use chia_protocol::{Bytes32, Coin, CoinSpend, CoinState, Program, SpendBundle};
use chia_sdk_types::TESTNET11_CONSTANTS;
use clvmr::ENABLE_KECCAK_OPS_OUTSIDE_GUARD;
use indexmap::{IndexMap, IndexSet, indexset};
use rand::{Rng, SeedableRng};
use rand_chacha::ChaCha8Rng;

use crate::{
    BlsPair, BlsPairWithCoin, SimulatorError, sign_transaction,
    spend_bundle_validation::{
        CoinRecord, ValidationClock, ValidationSettings, validate_conditions,
        validate_relative_conditions,
    },
};

mod config;
mod data;

pub use config::*;

use data::SimulatorData;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Simulator {
    config: SimulatorConfig,
    data: SimulatorData,
}

impl Default for Simulator {
    fn default() -> Self {
        Self::new()
    }
}

impl Simulator {
    pub fn new() -> Self {
        Self::with_config(SimulatorConfig::default())
    }

    pub fn with_config(config: SimulatorConfig) -> Self {
        Self {
            config,
            data: SimulatorData::new(ChaCha8Rng::seed_from_u64(config.seed)),
        }
    }

    #[cfg(feature = "serde")]
    pub fn serialize(&self) -> Result<Vec<u8>, bincode::error::EncodeError> {
        bincode::serde::encode_to_vec(&self.data, bincode::config::standard())
    }

    #[cfg(feature = "serde")]
    pub fn deserialize_with_config(
        data: &[u8],
        config: SimulatorConfig,
    ) -> Result<Self, bincode::error::DecodeError> {
        let data: SimulatorData =
            bincode::serde::decode_from_slice(data, bincode::config::standard())?.0;
        Ok(Self { config, data })
    }

    #[cfg(feature = "serde")]
    pub fn deserialize(data: &[u8]) -> Result<Self, bincode::error::DecodeError> {
        Self::deserialize_with_config(data, SimulatorConfig::default())
    }

    pub fn height(&self) -> u32 {
        self.data.height
    }

    pub fn next_timestamp(&self) -> u64 {
        self.data.next_timestamp
    }

    pub fn header_hash(&self) -> Bytes32 {
        self.data.header_hashes.last().copied().unwrap()
    }

    pub fn header_hash_of(&self, height: u32) -> Option<Bytes32> {
        self.data.header_hashes.get(height as usize).copied()
    }

    pub fn insert_coin(&mut self, coin: Coin) {
        let coin_state = CoinState::new(coin, None, Some(self.data.height));
        self.data.coin_states.insert(coin.coin_id(), coin_state);
    }

    pub fn new_coin(&mut self, puzzle_hash: Bytes32, amount: u64) -> Coin {
        let mut parent_coin_info = [0; 32];
        self.data.rng.fill(&mut parent_coin_info);
        let coin = Coin::new(parent_coin_info.into(), puzzle_hash, amount);
        self.insert_coin(coin);
        coin
    }

    pub fn bls(&mut self, amount: u64) -> BlsPairWithCoin {
        let pair = BlsPair::new(self.data.rng.random());
        let coin = self.new_coin(pair.puzzle_hash, amount);
        BlsPairWithCoin::new(pair, coin)
    }

    pub fn set_next_timestamp(&mut self, time: u64) -> Result<(), SimulatorError> {
        if self.data.height > 0
            && let Some(last_block_timestamp) =
                self.data.block_timestamps.get(&(self.data.height - 1))
            && time < *last_block_timestamp
        {
            return Err(SimulatorError::Validation(ErrorCode::TimestampTooFarInPast));
        }
        self.data.next_timestamp = time;

        Ok(())
    }

    pub fn pass_time(&mut self, time: u64) {
        self.data.next_timestamp += time;
    }

    pub fn hint_coin(&mut self, coin_id: Bytes32, hint: Bytes32) {
        self.data
            .hinted_coins
            .entry(hint)
            .or_default()
            .insert(coin_id);
    }

    pub fn coin_state(&self, coin_id: Bytes32) -> Option<CoinState> {
        self.data.coin_states.get(&coin_id).copied()
    }

    pub fn children(&self, coin_id: Bytes32) -> Vec<CoinState> {
        self.data
            .coin_states
            .values()
            .filter(move |cs| cs.coin.parent_coin_info == coin_id)
            .copied()
            .collect()
    }

    pub fn hinted_coins(&self, hint: Bytes32) -> Vec<Bytes32> {
        self.data
            .hinted_coins
            .get(&hint)
            .into_iter()
            .flatten()
            .copied()
            .collect()
    }

    pub fn puzzle_reveal(&self, coin_id: Bytes32) -> Option<Program> {
        self.data
            .coin_spends
            .get(&coin_id)
            .map(|spend| spend.puzzle_reveal.clone())
    }

    pub fn solution(&self, coin_id: Bytes32) -> Option<Program> {
        self.data
            .coin_spends
            .get(&coin_id)
            .map(|spend| spend.solution.clone())
    }

    pub fn puzzle_and_solution(&self, coin_id: Bytes32) -> Option<(Program, Program)> {
        self.data
            .coin_spends
            .get(&coin_id)
            .map(|spend| (spend.puzzle_reveal.clone(), spend.solution.clone()))
    }

    pub fn coin_spend(&self, coin_id: Bytes32) -> Option<CoinSpend> {
        self.data.coin_spends.get(&coin_id).cloned()
    }

    pub fn spend_coins(
        &mut self,
        coin_spends: Vec<CoinSpend>,
        secret_keys: &[SecretKey],
    ) -> Result<IndexMap<Bytes32, CoinState>, SimulatorError> {
        let signature = sign_transaction(&coin_spends, secret_keys)?;
        self.new_transaction(SpendBundle::new(coin_spends, signature))
    }

    /// Processes a spend bunndle and returns the updated coin states.
    pub fn new_transaction(
        &mut self,
        spend_bundle: SpendBundle,
    ) -> Result<IndexMap<Bytes32, CoinState>, SimulatorError> {
        let clock = ValidationClock {
            height: self.data.height,
            timestamp: self.data.next_timestamp,
        };
        let validation = validate_conditions(
            &spend_bundle,
            ValidationSettings {
                constants: &TESTNET11_CONSTANTS,
                max_cost: 11_000_000_000 / 2,
                flags: ENABLE_KECCAK_OPS_OUTSIDE_GUARD,
                clock,
            },
        )
        .map_err(SimulatorError::Validation)?;
        let conds = validation.conditions;

        let mut removed_coins = IndexMap::new();
        let mut added_coins = IndexMap::new();
        let mut added_hints = IndexMap::new();
        let mut coin_spends = IndexMap::new();

        for coin_spend in spend_bundle.coin_spends {
            coin_spends.insert(coin_spend.coin.coin_id(), coin_spend);
        }

        // Calculate additions and removals.
        for (spend, parsed) in conds.spends.iter().zip(validation.additions) {
            debug_assert_eq!(parsed.coin_id, spend.coin_id);
            for addition in parsed.additions {
                let coin = addition.coin;
                added_coins.insert(
                    coin.coin_id(),
                    CoinState::new(coin, None, Some(self.data.height)),
                );

                let Some(hint) = addition.hint else {
                    continue;
                };

                added_hints
                    .entry(hint)
                    .or_insert_with(IndexSet::new)
                    .insert(coin.coin_id());
            }

            let coin = Coin::new(spend.parent_id, spend.puzzle_hash, spend.coin_amount);

            let coin_state = self
                .data
                .coin_states
                .get(&spend.coin_id)
                .copied()
                .unwrap_or(CoinState::new(coin, None, Some(self.data.height)));

            let created_timestamp = coin_state
                .created_height
                .and_then(|height| self.data.block_timestamps.get(&height).copied());
            validate_relative_conditions(
                spend,
                CoinRecord {
                    created_height: coin_state.created_height,
                    created_timestamp,
                },
                clock,
            )
            .map_err(SimulatorError::Validation)?;

            removed_coins.insert(spend.coin_id, coin_state);
        }

        // Validate removals.
        for (coin_id, coin_state) in &mut removed_coins {
            let height = self.data.height;

            if !self.data.coin_states.contains_key(coin_id) && !added_coins.contains_key(coin_id) {
                return Err(SimulatorError::Validation(ErrorCode::UnknownUnspent));
            }

            if coin_state.spent_height.is_some() {
                return Err(SimulatorError::Validation(ErrorCode::DoubleSpend));
            }

            coin_state.spent_height = Some(height);
        }

        // Update the coin data.
        let mut updates = added_coins.clone();
        updates.extend(removed_coins);

        self.create_block();

        self.data.coin_states.extend(updates.clone());

        if self.config.save_hints {
            for (hint, coins) in added_hints {
                self.data
                    .hinted_coins
                    .entry(hint)
                    .or_default()
                    .extend(coins);
            }
        }

        if self.config.save_spends {
            self.data.coin_spends.extend(coin_spends);
        }

        Ok(updates)
    }

    pub fn lookup_coin_ids(&self, coin_ids: &IndexSet<Bytes32>) -> Vec<CoinState> {
        coin_ids
            .iter()
            .filter_map(|coin_id| self.data.coin_states.get(coin_id).copied())
            .collect()
    }

    pub fn lookup_puzzle_hashes(
        &self,
        puzzle_hashes: IndexSet<Bytes32>,
        include_hints: bool,
    ) -> Vec<CoinState> {
        let mut coin_states = IndexMap::new();

        for (coin_id, coin_state) in &self.data.coin_states {
            if puzzle_hashes.contains(&coin_state.coin.puzzle_hash) {
                coin_states.insert(*coin_id, self.data.coin_states[coin_id]);
            }
        }

        if include_hints {
            for puzzle_hash in puzzle_hashes {
                if let Some(hinted_coins) = self.data.hinted_coins.get(&puzzle_hash) {
                    for coin_id in hinted_coins {
                        coin_states.insert(*coin_id, self.data.coin_states[coin_id]);
                    }
                }
            }
        }

        coin_states.into_values().collect()
    }

    pub fn unspent_coins(&self, puzzle_hash: Bytes32, include_hints: bool) -> Vec<Coin> {
        self.lookup_puzzle_hashes(indexset![puzzle_hash], include_hints)
            .iter()
            .filter(|cs| cs.spent_height.is_none())
            .map(|cs| cs.coin)
            .collect()
    }

    pub fn create_block(&mut self) {
        let mut header_hash = [0; 32];
        self.data.rng.fill(&mut header_hash);
        self.data.header_hashes.push(header_hash.into());
        self.data
            .block_timestamps
            .insert(self.data.height, self.data.next_timestamp);

        self.data.height += 1;
        self.data.next_timestamp += 1;
    }
}