diff --git a/Cargo.lock b/Cargo.lock index f85925a10..bab8426b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -180,6 +180,58 @@ dependencies = [ "libloading", ] +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "backtrace" version = "0.3.73" @@ -752,6 +804,7 @@ name = "chia-sdk-test" version = "0.34.0" dependencies = [ "anyhow", + "axum", "bincode", "bip39", "chia-bls 0.36.1", @@ -759,6 +812,7 @@ dependencies = [ "chia-protocol", "chia-puzzle-types", "chia-sdk-client", + "chia-sdk-coinset", "chia-sdk-signer", "chia-sdk-types", "chia-secp", @@ -770,12 +824,15 @@ dependencies = [ "futures-channel", "futures-util", "hex", + "hex-literal", "indexmap", "itertools 0.13.0", "prettytable-rs", "rand 0.9.2", "rand_chacha 0.9.0", + "reqwest", "serde", + "serde_json", "signature 2.2.0", "thiserror 2.0.17", "tokio", @@ -1993,6 +2050,12 @@ version = "1.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fcc0b4a115bf80b728eb8ea024ad5bd707b615bfed49e0665b6e0f86fd082d9" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hybrid-array" version = "0.4.12" @@ -2014,6 +2077,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -2337,6 +2401,12 @@ version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.7.4" @@ -2352,6 +2422,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -3702,6 +3778,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_spanned" version = "1.0.3" @@ -4209,6 +4296,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -4247,6 +4335,7 @@ version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", diff --git a/bindings/full_node_simulator.json b/bindings/full_node_simulator.json new file mode 100644 index 000000000..e2a47c856 --- /dev/null +++ b/bindings/full_node_simulator.json @@ -0,0 +1,261 @@ +{ + "FullNodeSimulator": { + "type": "class", + "methods": { + "new": { + "type": "constructor" + }, + "with_seed": { + "type": "factory", + "args": { + "seed": "u64" + } + }, + "with_secret_key": { + "type": "factory", + "args": { + "secret_key": "SecretKey" + } + }, + "height": { + "return": "u32" + }, + "header_hash": { + "return": "Bytes32" + }, + "header_hash_of": { + "args": { + "height": "u32" + }, + "return": "Option" + }, + "insert_coin": { + "args": { + "coin": "Coin" + } + }, + "new_coin": { + "args": { + "puzzle_hash": "Bytes32", + "amount": "u64" + }, + "return": "Coin" + }, + "get_farming_ph": { + "return": "Bytes32" + }, + "get_master_secret_key": { + "return": "SecretKey" + }, + "get_prefarm_puzzle_hash": { + "return": "Bytes32" + }, + "set_farming_ph": { + "args": { + "puzzle_hash": "Bytes32" + } + }, + "get_blockchain_state": { + "return": "BlockchainStateResponse" + }, + "get_network_info": { + "return": "GetNetworkInfoResponse" + }, + "get_aggsig_additional_data": { + "return": "Bytes32" + }, + "get_block_record": { + "args": { + "header_hash": "Bytes32" + }, + "return": "GetBlockRecordResponse" + }, + "get_block_record_by_height": { + "args": { + "height": "u32" + }, + "return": "GetBlockRecordResponse" + }, + "get_block_records": { + "args": { + "start": "u32", + "end": "u32" + }, + "return": "GetBlockRecordsResponse" + }, + "get_additions_and_removals": { + "args": { + "header_hash": "Bytes32" + }, + "return": "AdditionsAndRemovalsResponse" + }, + "get_block_spends": { + "args": { + "header_hash": "Bytes32" + }, + "return": "GetBlockSpendsResponse" + }, + "get_coin_record_by_name": { + "args": { + "name": "Bytes32" + }, + "return": "GetCoinRecordResponse" + }, + "get_coin_records_by_names": { + "args": { + "names": "Vec", + "start_height": "Option", + "end_height": "Option", + "include_spent_coins": "Option" + }, + "return": "GetCoinRecordsResponse" + }, + "get_coin_records_by_hint": { + "args": { + "hint": "Bytes32", + "start_height": "Option", + "end_height": "Option", + "include_spent_coins": "Option" + }, + "return": "GetCoinRecordsResponse" + }, + "get_coin_records_by_hints": { + "args": { + "hints": "Vec", + "start_height": "Option", + "end_height": "Option", + "include_spent_coins": "Option" + }, + "return": "GetCoinRecordsResponse" + }, + "get_coin_records_by_parent_ids": { + "args": { + "parent_ids": "Vec", + "start_height": "Option", + "end_height": "Option", + "include_spent_coins": "Option" + }, + "return": "GetCoinRecordsResponse" + }, + "get_coin_records_by_puzzle_hash": { + "args": { + "puzzle_hash": "Bytes32", + "start_height": "Option", + "end_height": "Option", + "include_spent_coins": "Option" + }, + "return": "GetCoinRecordsResponse" + }, + "get_coin_records_by_puzzle_hashes": { + "args": { + "puzzle_hashes": "Vec", + "start_height": "Option", + "end_height": "Option", + "include_spent_coins": "Option" + }, + "return": "GetCoinRecordsResponse" + }, + "get_puzzle_and_solution": { + "args": { + "coin_id": "Bytes32", + "height": "Option" + }, + "return": "GetPuzzleAndSolutionResponse" + }, + "push_tx": { + "args": { + "spend_bundle": "SpendBundle" + }, + "return": "PushTxResponse" + }, + "get_mempool_item_by_tx_id": { + "args": { + "tx_id": "Bytes32" + }, + "return": "GetMempoolItemResponse" + }, + "get_mempool_items_by_coin_name": { + "args": { + "coin_name": "Bytes32" + }, + "return": "GetMempoolItemsResponse" + }, + "farm_block": { + "args": { + "blocks": "u32" + }, + "return": "Vec" + }, + "revert_blocks": { + "args": { + "blocks": "u32" + }, + "return": "Vec" + }, + "reorg_blocks": { + "args": { + "num_of_blocks_to_rev": "u32", + "num_of_new_blocks": "u32" + }, + "return": "Vec" + }, + "drain_events": { + "return": "Vec" + }, + "dump_state": { + "return": "String" + }, + "restore_state": { + "args": { + "state": "String" + } + }, + "start_server": { + "type": "async", + "no_wasm": true, + "return": "FullNodeSimulatorServer" + } + } + }, + "FullNodeSimulatorEvent": { + "type": "class", + "methods": { + "block": { + "return": "Option" + }, + "reorg": { + "return": "Option" + } + } + }, + "FullNodeSimulatorBlockEvent": { + "type": "class", + "fields": { + "height": "u32", + "header_hash": "Bytes32", + "previous_header_hash": "Bytes32", + "additions": "Vec", + "removals": "Vec" + } + }, + "FullNodeSimulatorReorgEvent": { + "type": "class", + "fields": { + "fork_height": "u32", + "old_peak_hash": "Bytes32", + "new_peak_hash": "Bytes32", + "reverted_header_hashes": "Vec", + "new_header_hashes": "Vec" + } + }, + "FullNodeSimulatorServer": { + "type": "class", + "no_wasm": true, + "fields": { + "url": "String" + }, + "methods": { + "close": {} + } + } +} diff --git a/crates/chia-sdk-bindings/Cargo.toml b/crates/chia-sdk-bindings/Cargo.toml index ea440b14b..0dc8ceb0d 100644 --- a/crates/chia-sdk-bindings/Cargo.toml +++ b/crates/chia-sdk-bindings/Cargo.toml @@ -15,15 +15,27 @@ categories = { workspace = true } workspace = true [features] -napi = ["dep:chia-sdk-client", "dep:chia-ssl", "dep:tokio", "bindy/napi"] +napi = [ + "dep:chia-sdk-client", + "dep:chia-ssl", + "dep:tokio", + "bindy/napi", + "chia-sdk-test/full-node-simulator-http", +] wasm = ["bindy/wasm"] -pyo3 = ["dep:chia-sdk-client", "dep:chia-ssl", "dep:tokio", "bindy/pyo3"] +pyo3 = [ + "dep:chia-sdk-client", + "dep:chia-ssl", + "dep:tokio", + "bindy/pyo3", + "chia-sdk-test/full-node-simulator-http", +] [dependencies] chia-sdk-utils = { workspace = true } chia-sdk-driver = { workspace = true, features = ["offer-compression", "action-layer"] } chia-sdk-coinset = { workspace = true, features = ["native-tls"] } -chia-sdk-test = { workspace = true } +chia-sdk-test = { workspace = true, features = ["serde"] } chia-sdk-types = { workspace = true } chia-sdk-client = { workspace = true, optional = true, features = ["native-tls"] } chia-ssl = { workspace = true, optional = true } diff --git a/crates/chia-sdk-bindings/src/full_node_simulator.rs b/crates/chia-sdk-bindings/src/full_node_simulator.rs new file mode 100644 index 000000000..96cb5eeef --- /dev/null +++ b/crates/chia-sdk-bindings/src/full_node_simulator.rs @@ -0,0 +1,374 @@ +use std::sync::{Arc, Mutex}; + +use bindy::Result; +use chia_bls::SecretKey; +use chia_protocol::{BlockRecord, Bytes32, Coin, SpendBundle}; +use chia_sdk_coinset::{ + AdditionsAndRemovalsResponse, BlockchainStateResponse, CoinRecord, GetBlockRecordResponse, + GetBlockRecordsResponse, GetBlockSpendsResponse, GetCoinRecordResponse, GetCoinRecordsResponse, + GetMempoolItemResponse, GetMempoolItemsResponse, GetNetworkInfoResponse, + GetPuzzleAndSolutionResponse, PushTxResponse, +}; + +#[derive(Clone, Default)] +pub struct FullNodeSimulator(Arc>); + +#[derive(Debug, Clone)] +pub struct FullNodeSimulatorEvent(chia_sdk_test::FullNodeSimulatorEvent); + +#[derive(Debug, Clone)] +pub struct FullNodeSimulatorBlockEvent { + pub height: u32, + pub header_hash: Bytes32, + pub previous_header_hash: Bytes32, + pub additions: Vec, + pub removals: Vec, +} + +#[derive(Debug, Clone)] +pub struct FullNodeSimulatorReorgEvent { + pub fork_height: u32, + pub old_peak_hash: Bytes32, + pub new_peak_hash: Bytes32, + pub reverted_header_hashes: Vec, + pub new_header_hashes: Vec, +} + +#[cfg(any(feature = "napi", feature = "pyo3"))] +#[derive(Clone)] +pub struct FullNodeSimulatorServer { + pub url: String, + server: Arc>>, +} + +impl FullNodeSimulator { + pub fn new() -> Result { + Ok(Self::default()) + } + + pub fn with_seed(seed: u64) -> Result { + Ok(Self(Arc::new(Mutex::new( + chia_sdk_test::FullNodeSimulator::with_seed(seed), + )))) + } + + pub fn with_secret_key(secret_key: SecretKey) -> Result { + Ok(Self(Arc::new(Mutex::new( + chia_sdk_test::FullNodeSimulator::with_secret_key(secret_key), + )))) + } + + pub fn height(&self) -> Result { + Ok(self.0.lock().unwrap().height()) + } + + pub fn header_hash(&self) -> Result { + Ok(self.0.lock().unwrap().header_hash()) + } + + pub fn header_hash_of(&self, height: u32) -> Result> { + Ok(self.0.lock().unwrap().header_hash_of(height)) + } + + pub fn insert_coin(&self, coin: Coin) -> Result<()> { + self.0.lock().unwrap().insert_coin(coin); + Ok(()) + } + + pub fn new_coin(&self, puzzle_hash: Bytes32, amount: u64) -> Result { + Ok(self.0.lock().unwrap().new_coin(puzzle_hash, amount)) + } + + pub fn get_farming_ph(&self) -> Result { + Ok(self.0.lock().unwrap().get_farming_ph()) + } + + pub fn get_master_secret_key(&self) -> Result { + Ok(self.0.lock().unwrap().get_master_secret_key()) + } + + pub fn get_prefarm_puzzle_hash(&self) -> Result { + Ok(self.0.lock().unwrap().get_prefarm_puzzle_hash()) + } + + pub fn set_farming_ph(&self, puzzle_hash: Bytes32) -> Result<()> { + self.0.lock().unwrap().set_farming_ph(puzzle_hash); + Ok(()) + } + + pub fn get_blockchain_state(&self) -> Result { + Ok(self.0.lock().unwrap().get_blockchain_state()) + } + + pub fn get_network_info(&self) -> Result { + Ok(self.0.lock().unwrap().get_network_info()) + } + + pub fn get_aggsig_additional_data(&self) -> Result { + Ok(self.0.lock().unwrap().get_aggsig_additional_data()) + } + + pub fn get_block_record(&self, header_hash: Bytes32) -> Result { + Ok(self.0.lock().unwrap().get_block_record(header_hash)) + } + + pub fn get_block_record_by_height(&self, height: u32) -> Result { + Ok(self.0.lock().unwrap().get_block_record_by_height(height)) + } + + pub fn get_block_records(&self, start: u32, end: u32) -> Result { + Ok(self.0.lock().unwrap().get_block_records(start, end)) + } + + pub fn get_additions_and_removals( + &self, + header_hash: Bytes32, + ) -> Result { + Ok(self + .0 + .lock() + .unwrap() + .get_additions_and_removals(header_hash)) + } + + pub fn get_block_spends(&self, header_hash: Bytes32) -> Result { + Ok(self.0.lock().unwrap().get_block_spends(header_hash)) + } + + pub fn get_coin_record_by_name(&self, name: Bytes32) -> Result { + Ok(self.0.lock().unwrap().get_coin_record_by_name(name)) + } + + pub fn get_coin_records_by_names( + &self, + names: Vec, + start_height: Option, + end_height: Option, + include_spent_coins: Option, + ) -> Result { + Ok(self.0.lock().unwrap().get_coin_records_by_names( + &names, + start_height, + end_height, + include_spent_coins, + )) + } + + pub fn get_coin_records_by_hint( + &self, + hint: Bytes32, + start_height: Option, + end_height: Option, + include_spent_coins: Option, + ) -> Result { + Ok(self.0.lock().unwrap().get_coin_records_by_hint( + hint, + start_height, + end_height, + include_spent_coins, + )) + } + + pub fn get_coin_records_by_hints( + &self, + hints: Vec, + start_height: Option, + end_height: Option, + include_spent_coins: Option, + ) -> Result { + Ok(self.0.lock().unwrap().get_coin_records_by_hints( + hints, + start_height, + end_height, + include_spent_coins, + )) + } + + pub fn get_coin_records_by_parent_ids( + &self, + parent_ids: Vec, + start_height: Option, + end_height: Option, + include_spent_coins: Option, + ) -> Result { + Ok(self.0.lock().unwrap().get_coin_records_by_parent_ids( + parent_ids, + start_height, + end_height, + include_spent_coins, + )) + } + + pub fn get_coin_records_by_puzzle_hash( + &self, + puzzle_hash: Bytes32, + start_height: Option, + end_height: Option, + include_spent_coins: Option, + ) -> Result { + Ok(self.0.lock().unwrap().get_coin_records_by_puzzle_hash( + puzzle_hash, + start_height, + end_height, + include_spent_coins, + )) + } + + pub fn get_coin_records_by_puzzle_hashes( + &self, + puzzle_hashes: Vec, + start_height: Option, + end_height: Option, + include_spent_coins: Option, + ) -> Result { + Ok(self.0.lock().unwrap().get_coin_records_by_puzzle_hashes( + puzzle_hashes, + start_height, + end_height, + include_spent_coins, + )) + } + + pub fn get_puzzle_and_solution( + &self, + coin_id: Bytes32, + height: Option, + ) -> Result { + Ok(self + .0 + .lock() + .unwrap() + .get_puzzle_and_solution(coin_id, height)) + } + + pub fn push_tx(&self, spend_bundle: SpendBundle) -> Result { + Ok(self.0.lock().unwrap().push_tx(spend_bundle)) + } + + pub fn get_mempool_item_by_tx_id(&self, tx_id: Bytes32) -> Result { + Ok(self.0.lock().unwrap().get_mempool_item_by_tx_id(tx_id)) + } + + pub fn get_mempool_items_by_coin_name( + &self, + coin_name: Bytes32, + ) -> Result { + Ok(self + .0 + .lock() + .unwrap() + .get_mempool_items_by_coin_name(coin_name)) + } + + pub fn farm_block(&self, blocks: u32) -> Result> { + Ok(self.0.lock().unwrap().farm_block(blocks)) + } + + pub fn revert_blocks(&self, blocks: u32) -> Result> { + Ok(self.0.lock().unwrap().revert_blocks(blocks)) + } + + pub fn reorg_blocks( + &self, + num_of_blocks_to_rev: u32, + num_of_new_blocks: u32, + ) -> Result> { + Ok(self + .0 + .lock() + .unwrap() + .reorg_blocks(num_of_blocks_to_rev, num_of_new_blocks)) + } + + pub fn drain_events(&self) -> Result> { + Ok(self + .0 + .lock() + .unwrap() + .drain_events() + .into_iter() + .map(Into::into) + .collect()) + } + + pub fn dump_state(&self) -> Result { + self.0 + .lock() + .unwrap() + .dump_state() + .map_err(|error| bindy::Error::Custom(error.to_string())) + } + + pub fn restore_state(&self, state: String) -> Result<()> { + self.0 + .lock() + .unwrap() + .restore_state(&state) + .map_err(|error| bindy::Error::Custom(error.to_string())) + } + + #[cfg(any(feature = "napi", feature = "pyo3"))] + pub async fn start_server(&self) -> Result { + let server = chia_sdk_test::FullNodeSimulatorServer::with_simulator(self.0.clone()) + .await + .map_err(|error| bindy::Error::Custom(error.to_string()))?; + Ok(FullNodeSimulatorServer { + url: server.url(), + server: Arc::new(Mutex::new(Some(server))), + }) + } +} + +impl From for FullNodeSimulatorEvent { + fn from(event: chia_sdk_test::FullNodeSimulatorEvent) -> Self { + Self(event) + } +} + +impl FullNodeSimulatorEvent { + pub fn block(&self) -> Result> { + Ok(match &self.0 { + chia_sdk_test::FullNodeSimulatorEvent::Block { + height, + header_hash, + previous_header_hash, + additions, + removals, + } => Some(FullNodeSimulatorBlockEvent { + height: *height, + header_hash: *header_hash, + previous_header_hash: *previous_header_hash, + additions: additions.clone(), + removals: removals.clone(), + }), + chia_sdk_test::FullNodeSimulatorEvent::Reorg { .. } => None, + }) + } + + pub fn reorg(&self) -> Result> { + Ok(match &self.0 { + chia_sdk_test::FullNodeSimulatorEvent::Reorg { + fork_height, + old_peak_hash, + new_peak_hash, + reverted_header_hashes, + new_header_hashes, + } => Some(FullNodeSimulatorReorgEvent { + fork_height: *fork_height, + old_peak_hash: *old_peak_hash, + new_peak_hash: *new_peak_hash, + reverted_header_hashes: reverted_header_hashes.clone(), + new_header_hashes: new_header_hashes.clone(), + }), + chia_sdk_test::FullNodeSimulatorEvent::Block { .. } => None, + }) + } +} + +#[cfg(any(feature = "napi", feature = "pyo3"))] +impl FullNodeSimulatorServer { + pub fn close(&self) -> Result<()> { + self.server.lock().unwrap().take(); + Ok(()) + } +} diff --git a/crates/chia-sdk-bindings/src/lib.rs b/crates/chia-sdk-bindings/src/lib.rs index ff1db9f35..e91cabc5b 100644 --- a/crates/chia-sdk-bindings/src/lib.rs +++ b/crates/chia-sdk-bindings/src/lib.rs @@ -18,6 +18,7 @@ mod coin; mod conditions; mod constants; mod convert; +mod full_node_simulator; mod key_pairs; mod mips; mod mnemonic; @@ -38,6 +39,7 @@ pub use clvm_types::*; pub use coin::*; pub use conditions::*; pub use constants::*; +pub use full_node_simulator::*; pub use key_pairs::*; pub use mips::*; pub use mnemonic::*; diff --git a/crates/chia-sdk-coinset/src/models.rs b/crates/chia-sdk-coinset/src/models.rs index c9e660ee9..df0441c0c 100644 --- a/crates/chia-sdk-coinset/src/models.rs +++ b/crates/chia-sdk-coinset/src/models.rs @@ -1,16 +1,16 @@ use chia_protocol::{BlockRecord, Bytes32, CoinSpend, FullBlock}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use crate::{CoinRecord, MempoolItem}; -#[derive(Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct BlockchainStateResponse { pub blockchain_state: Option, pub error: Option, pub success: bool, } -#[derive(Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct BlockchainState { pub average_block_time: u64, pub block_max_cost: u64, @@ -28,12 +28,12 @@ pub struct BlockchainState { pub sync: SyncState, } -#[derive(Deserialize, Debug, Clone, Copy)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy)] pub struct MempoolMinFees { pub cost_5000000: u64, } -#[derive(Deserialize, Debug, Clone, Copy)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy)] pub struct SyncState { pub sync_mode: bool, pub sync_progress_height: u32, @@ -41,7 +41,7 @@ pub struct SyncState { pub synced: bool, } -#[derive(Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct AdditionsAndRemovalsResponse { pub additions: Option>, pub removals: Option>, @@ -49,14 +49,14 @@ pub struct AdditionsAndRemovalsResponse { pub success: bool, } -#[derive(Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct GetBlockResponse { pub block: Option, pub error: Option, pub success: bool, } -#[derive(Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct GetBlockRecordResponse { pub block_record: Option, pub error: Option, @@ -65,35 +65,35 @@ pub struct GetBlockRecordResponse { pub type GetBlockRecordByHeightResponse = GetBlockRecordResponse; -#[derive(Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct GetBlockRecordsResponse { pub block_records: Option>, pub error: Option, pub success: bool, } -#[derive(Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct GetBlocksResponse { pub blocks: Option>, pub error: Option, pub success: bool, } -#[derive(Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct GetBlockSpendsResponse { pub block_spends: Option>, pub error: Option, pub success: bool, } -#[derive(Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct GetCoinRecordResponse { pub coin_record: Option, pub error: Option, pub success: bool, } -#[derive(Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct GetCoinRecordsResponse { pub coin_records: Option>, pub error: Option, @@ -108,14 +108,14 @@ pub struct GetCoinRecordsResponse { pub next_cursor: Option, } -#[derive(Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct GetPuzzleAndSolutionResponse { pub coin_solution: Option, pub error: Option, pub success: bool, } -#[derive(Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct PushTxResponse { #[serde(default)] pub status: Option, @@ -123,7 +123,7 @@ pub struct PushTxResponse { pub success: bool, } -#[derive(Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct GetNetworkInfoResponse { pub network_name: Option, pub network_prefix: Option, @@ -132,14 +132,14 @@ pub struct GetNetworkInfoResponse { pub success: bool, } -#[derive(Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct GetMempoolItemResponse { pub mempool_item: Option, pub error: Option, pub success: bool, } -#[derive(Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct GetMempoolItemsResponse { pub mempool_items: Option>, pub error: Option, diff --git a/crates/chia-sdk-coinset/src/types.rs b/crates/chia-sdk-coinset/src/types.rs index 93b4b54fc..e6c7ea493 100644 --- a/crates/chia-sdk-coinset/src/types.rs +++ b/crates/chia-sdk-coinset/src/types.rs @@ -1,7 +1,7 @@ use chia_protocol::{Coin, SpendBundle}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; -#[derive(Deserialize, Debug, Clone, Copy)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy)] pub struct CoinRecord { pub coin: Coin, pub coinbase: bool, @@ -11,7 +11,7 @@ pub struct CoinRecord { pub timestamp: u64, } -#[derive(Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct MempoolItem { pub spend_bundle: SpendBundle, pub fee: u64, diff --git a/crates/chia-sdk-test/Cargo.toml b/crates/chia-sdk-test/Cargo.toml index 28dd4311f..507d4c4eb 100644 --- a/crates/chia-sdk-test/Cargo.toml +++ b/crates/chia-sdk-test/Cargo.toml @@ -15,6 +15,12 @@ categories = { workspace = true } workspace = true [features] +full-node-simulator-http = [ + "serde", + "dep:axum", + "dep:serde_json", + "dep:tokio", +] peer-simulator = [ "dep:tracing", "dep:itertools", @@ -32,6 +38,7 @@ serde = [ "rand/serde", "rand_chacha/serde", "dep:serde", + "dep:serde_json", "dep:bincode" ] @@ -54,7 +61,9 @@ bip39 = { workspace = true } anyhow = { workspace = true } chia-sdk-types = { workspace = true } chia-sdk-signer = { workspace = true } +chia-sdk-coinset = { workspace = true } hex = { workspace = true } +hex-literal = { workspace = true } tracing = { workspace = true, optional = true } itertools = { workspace = true, optional = true } signature = { workspace = true, optional = true } @@ -66,6 +75,11 @@ futures-util = { workspace = true, optional = true } prettytable-rs = {workspace = true } serde = { workspace = true, features = ["derive"], optional = true } bincode = { workspace = true, features = ["serde"], optional = true } +axum = { version = "0.8.9", optional = true } +serde_json = { workspace = true, optional = true } + +[dev-dependencies] +reqwest.workspace = true [package.metadata.cargo-machete] ignored = ["prettytable-rs"] diff --git a/crates/chia-sdk-test/src/error.rs b/crates/chia-sdk-test/src/error.rs index 5ed8bfcc0..49e695275 100644 --- a/crates/chia-sdk-test/src/error.rs +++ b/crates/chia-sdk-test/src/error.rs @@ -1,6 +1,8 @@ use std::io; use chia_consensus::validation_error::ErrorCode; +#[cfg(feature = "serde")] +use chia_protocol::Bytes32; use chia_sdk_signer::SignerError; use thiserror::Error; @@ -17,4 +19,165 @@ pub enum SimulatorError { #[error("Missing key")] MissingKey, + + #[error(transparent)] + ChainState(#[from] ChainStateError), + + #[cfg(feature = "serde")] + #[error(transparent)] + StateDump(#[from] StateDumpError), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum ChainStateError { + #[error("canonical height has no tip header")] + MissingTipHeader, + + #[error("canonical tip block is missing")] + MissingTipBlock, + + #[error("block height does not extend the canonical chain")] + InvalidBlockHeight, + + #[error("block previous hash does not match the canonical tip")] + InvalidPreviousHash, + + #[error("block timestamp does not match the next timestamp")] + InvalidBlockTimestamp, + + #[error("block header is already canonical")] + DuplicateBlockHeader, + + #[error("block is not the canonical tip")] + BlockIsNotTip, + + #[error("block delta changes a coin more than once")] + DuplicateCoinChange, + + #[error("block delta changes a coin spend more than once")] + DuplicateSpendChange, + + #[error("block delta changes a coin hint more than once")] + DuplicateHintChange, + + #[error("coin state does not match block delta")] + CoinStateMismatch, + + #[error("coin spend state does not match block delta")] + CoinSpendStateMismatch, + + #[error("coin hint state does not match block delta")] + CoinHintStateMismatch, +} + +#[cfg(feature = "serde")] +#[derive(Debug, Error)] +pub enum StateDumpError { + #[error("failed to serialize simulator state: {0}")] + Serialize(String), + + #[error("failed to deserialize simulator state: {0}")] + Deserialize(String), + + #[error("missing canonical block {0}")] + MissingCanonicalBlock(Bytes32), + + #[error("cannot dump state with canonical spend of unsupported manual coin {0}")] + UnsupportedManualCoinSpend(Bytes32), + + #[error("block key {block_key} does not match record header hash {record_header_hash}")] + BlockHeaderMismatch { + block_key: Bytes32, + record_header_hash: Bytes32, + }, + + #[error("canonical block is missing a timestamp")] + MissingBlockTimestamp, + + #[error("{0} contain a duplicate key")] + DuplicateKey(&'static str), + + #[error("previous coin record does not match replayed state for {0}")] + PreviousCoinRecordMismatch(Bytes32), + + #[error("previous coin records contain an unchanged coin")] + PreviousCoinRecordForUnchangedCoin, + + #[error("missing final coin record for addition {0}")] + MissingFinalCoinRecord(Bytes32), + + #[error("added coin record is inconsistent with block {0}")] + InconsistentAddedCoinRecord(u32), + + #[error("block removes unknown coin {0}")] + UnknownRemovedCoin(Bytes32), + + #[error("block spends contain a duplicate coin")] + DuplicateBlockSpend, + + #[error("block spend is not listed as a removal")] + SpendNotRemoval, + + #[error("coin spend does not match final index for {0}")] + CoinSpendIndexMismatch(Bytes32), + + #[error("block hint is not for an added coin")] + HintNotAddition, + + #[error("missing final hint for coin {0}")] + MissingFinalHint(Bytes32), + + #[error("coin hint does not match canonical transaction for {0}")] + CoinHintMismatch(Bytes32), + + #[error("block additions do not match canonical transactions")] + BlockAdditionsMismatch, + + #[error("block removals do not match canonical transactions")] + BlockRemovalsMismatch, + + #[error("block spends do not match canonical transactions")] + BlockSpendsMismatch, + + #[error("block hints do not match canonical transactions")] + BlockHintsMismatch, + + #[error("serialized {0} do not match canonical blocks")] + SerializedIndexMismatch(&'static str), + + #[error("unsupported full node simulator state format {0}")] + UnsupportedFormat(String), + + #[error("unsupported full node simulator state version {0}")] + UnsupportedVersion(u32), + + #[error("height {height} does not match {header_count} header hashes")] + HeightHeaderCountMismatch { height: u32, header_count: usize }, + + #[error("{header_count} canonical headers do not match {block_count} blocks")] + HeaderBlockCountMismatch { + header_count: usize, + block_count: usize, + }, + + #[error("coin record key does not match coin id {0}")] + CoinRecordKeyMismatch(Bytes32), + + #[error("coin spend key does not match coin id {0}")] + CoinSpendKeyMismatch(Bytes32), + + #[error("invalid master secret key: {0}")] + InvalidMasterSecretKey(String), + + #[error("canonical genesis block is missing a timestamp")] + MissingGenesisTimestamp, + + #[error("canonical block ordering does not match header hashes")] + CanonicalBlockOrderMismatch, + + #[error("replayed height does not match serialized height")] + ReplayedHeightMismatch, + + #[error("next timestamp does not follow the canonical blocks")] + NextTimestampMismatch, } diff --git a/crates/chia-sdk-test/src/full_node_simulator.rs b/crates/chia-sdk-test/src/full_node_simulator.rs new file mode 100644 index 000000000..1543216c6 --- /dev/null +++ b/crates/chia-sdk-test/src/full_node_simulator.rs @@ -0,0 +1,269 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use bip39::Mnemonic; +use chia_bls::{SecretKey, master_to_wallet_hardened}; +use chia_protocol::{BlockRecord, Bytes32, ClassgroupElement, Coin}; +use chia_puzzle_types::{DeriveSynthetic, standard::StandardArgs}; +use chia_sha2::Sha256; +use hex_literal::hex; +use indexmap::IndexMap; +use rand::{Rng, SeedableRng}; +use rand_chacha::ChaCha8Rng; + +mod chain; +mod fast_forward; +mod push_tx; +mod queries; +mod state; +#[cfg(feature = "serde")] +mod state_dump; +mod types; +mod validation; + +#[cfg(test)] +mod tests; + +use state::ChainState; +use types::{SimBlock, SimCoinRecord, ValidatedBundle, ValidatedSpend}; + +pub use types::{FullNodeSimulatorEvent, FullNodeSimulatorPushTxResponse}; + +const BLOCK_REWARD_AMOUNT: u64 = 2_000_000_000_000; +const PREFARM_WALLET_INDEX: u32 = 1; +const SIMULATOR_GENESIS_CHALLENGE: Bytes32 = Bytes32::new(hex!( + "eb8c4d20b322be8d9fddbf9412016bdffe9a2901d7edb0e364e94266d0e095f7" +)); + +#[derive(Debug, Clone)] +pub struct FullNodeSimulator { + rng: ChaCha8Rng, + state: ChainState, + orphaned_blocks: IndexMap, + mempool: IndexMap, + farming_puzzle_hash: Bytes32, + master_secret_key: SecretKey, + prefarm_puzzle_hash: Bytes32, + node_id: Bytes32, + events: Vec, +} + +impl Default for FullNodeSimulator { + fn default() -> Self { + Self::with_seed(1337) + } +} + +impl FullNodeSimulator { + pub fn new() -> Self { + Self::default() + } + + pub fn with_seed(seed: u64) -> Self { + Self::with_secret_key_and_rng( + Self::secret_key_from_seed(seed), + ChaCha8Rng::seed_from_u64(seed), + ) + } + + pub fn with_secret_key(root_secret_key: SecretKey) -> Self { + let mut seed = [0; 32]; + seed.copy_from_slice(&root_secret_key.to_bytes()); + Self::with_secret_key_and_rng(root_secret_key, ChaCha8Rng::from_seed(seed)) + } + + fn with_secret_key_and_rng(root_secret_key: SecretKey, mut rng: ChaCha8Rng) -> Self { + let prefarm_secret_key = + master_to_wallet_hardened(&root_secret_key, PREFARM_WALLET_INDEX).derive_synthetic(); + let prefarm_puzzle_hash = + StandardArgs::curry_tree_hash(prefarm_secret_key.public_key()).into(); + let mut node_id = [0; 32]; + rng.fill(&mut node_id); + + let genesis_height = 1; + let genesis_timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let genesis_hash = Bytes32::default(); + let prefarm_coins = vec![ + Self::reward_coin( + genesis_hash, + genesis_height, + 0, + prefarm_puzzle_hash, + 18_375_000_000_000_000_000, + ), + Self::reward_coin( + genesis_hash, + genesis_height, + 1, + prefarm_puzzle_hash, + 2_625_000_000_000_000_000, + ), + ]; + let genesis_record = Self::make_block_record( + genesis_hash, + Bytes32::default(), + genesis_height, + genesis_timestamp, + Bytes32::default(), + 0, + 0, + prefarm_puzzle_hash, + prefarm_coins.clone(), + ); + let additions = prefarm_coins.iter().map(Coin::coin_id).collect::>(); + let mut coins = IndexMap::new(); + for coin in prefarm_coins { + coins.insert( + coin.coin_id(), + SimCoinRecord { + coin, + coinbase: true, + confirmed_block_index: genesis_height, + spent_block_index: None, + timestamp: genesis_timestamp, + }, + ); + } + let mut blocks = IndexMap::new(); + blocks.insert( + genesis_hash, + SimBlock { + record: genesis_record, + additions: additions.clone(), + removals: Vec::new(), + spends: Vec::new(), + transactions: Vec::new(), + delta: state::BlockDelta { + coins: additions + .iter() + .map(|coin_id| state::CoinChange { + coin_id: *coin_id, + before: None, + after: coins.get(coin_id).copied(), + }) + .collect(), + ..state::BlockDelta::default() + }, + }, + ); + + Self { + rng, + state: ChainState::new( + genesis_height, + genesis_timestamp.saturating_add(1), + vec![genesis_hash], + blocks, + coins, + IndexMap::new(), + IndexMap::new(), + ), + orphaned_blocks: IndexMap::new(), + mempool: IndexMap::new(), + farming_puzzle_hash: prefarm_puzzle_hash, + master_secret_key: root_secret_key, + prefarm_puzzle_hash, + node_id: node_id.into(), + events: Vec::new(), + } + } + + pub fn insert_coin(&mut self, coin: Coin) { + self.insert_coin_record(coin, false, self.state.height, self.state.next_timestamp); + } + + pub fn new_coin(&mut self, puzzle_hash: Bytes32, amount: u64) -> Coin { + let mut parent_coin_info = [0; 32]; + self.rng.fill(&mut parent_coin_info); + let coin = Coin::new(parent_coin_info.into(), puzzle_hash, amount); + self.insert_coin(coin); + coin + } + + fn insert_coin_record(&mut self, coin: Coin, coinbase: bool, height: u32, timestamp: u64) { + self.state.insert_manual_coin( + coin.coin_id(), + SimCoinRecord { + coin, + coinbase, + confirmed_block_index: height, + spent_block_index: None, + timestamp, + }, + ); + } + + fn secret_key_from_seed(seed: u64) -> SecretKey { + let mut rng = ChaCha8Rng::seed_from_u64(seed); + let entropy: [u8; 32] = rng.random(); + let mnemonic = Mnemonic::from_entropy(&entropy).expect("32 bytes is valid BIP39 entropy"); + SecretKey::from_seed(&mnemonic.to_seed("")) + } + + fn reward_coin( + header_hash: Bytes32, + height: u32, + index: u8, + puzzle_hash: Bytes32, + amount: u64, + ) -> Coin { + Coin::new( + Self::reward_parent_id(header_hash, height, index), + puzzle_hash, + amount, + ) + } + + fn reward_parent_id(header_hash: Bytes32, height: u32, index: u8) -> Bytes32 { + let mut hasher = Sha256::new(); + hasher.update(b"chia-sdk-full-node-simulator-reward"); + hasher.update(header_hash.to_bytes()); + hasher.update(height.to_be_bytes()); + hasher.update([index]); + hasher.finalize().into() + } + + #[allow(clippy::too_many_arguments)] + fn make_block_record( + header_hash: Bytes32, + prev_hash: Bytes32, + height: u32, + timestamp: u64, + prev_transaction_block_hash: Bytes32, + fees: u64, + prev_transaction_block_height: u32, + farming_puzzle_hash: Bytes32, + reward_claims_incorporated: Vec, + ) -> BlockRecord { + BlockRecord::new( + header_hash, + prev_hash, + height, + u128::from(height), + u128::from(height), + 0, + ClassgroupElement::default(), + None, + header_hash, + header_hash, + 1, + farming_puzzle_hash, + farming_puzzle_hash, + 0, + 15, + false, + prev_transaction_block_height, + Some(timestamp), + Some(prev_transaction_block_hash), + Some(fees), + Some(reward_claims_incorporated), + None, + None, + None, + None, + ) + } +} diff --git a/crates/chia-sdk-test/src/full_node_simulator/chain.rs b/crates/chia-sdk-test/src/full_node_simulator/chain.rs new file mode 100644 index 000000000..5629f80f5 --- /dev/null +++ b/crates/chia-sdk-test/src/full_node_simulator/chain.rs @@ -0,0 +1,324 @@ +use std::collections::VecDeque; + +use chia_consensus::validation_error::ErrorCode; +use chia_protocol::{BlockRecord, Bytes32, SpendBundle}; +use indexmap::{IndexMap, IndexSet}; +use rand::Rng; + +use crate::{FullNodeSimulatorEvent, SimulatorError}; + +use super::{ + BLOCK_REWARD_AMOUNT, FullNodeSimulator, SimBlock, SimCoinRecord, ValidatedSpend, + state::{BlockDelta, CoinChange, HintChange, SpendChange}, +}; + +impl FullNodeSimulator { + pub(super) fn create_block_from_mempool(&mut self) -> BlockRecord { + let previous_header_hash = self.header_hash(); + let height = self.state.height + 1; + let timestamp = self.state.next_timestamp; + let header_hash = self.random_hash(); + + let mut included_tx_ids = Vec::new(); + let mut included = Vec::new(); + let mut included_spends_by_coin = IndexMap::::new(); + for (tx_id, item) in self.mempool.clone() { + let Ok(validated) = self.validate_bundle(item.spend_bundle.clone()) else { + continue; + }; + let has_conflict = validated.removals.iter().any(|coin_id| { + let Some(existing_spend) = included_spends_by_coin.get(coin_id) else { + return false; + }; + let Some(new_spend) = validated.spends.get(coin_id) else { + return true; + }; + !Self::spends_are_dedup_compatible(existing_spend, new_spend) + }); + if has_conflict { + continue; + } + for coin_id in &validated.removals { + let Some(spend) = validated.spends.get(coin_id) else { + continue; + }; + included_spends_by_coin + .entry(*coin_id) + .or_insert_with(|| spend.clone()); + } + included_tx_ids.push(tx_id); + included.push(validated); + } + + let mut additions = Vec::new(); + let mut removals = Vec::new(); + let mut spends = Vec::new(); + let mut transactions = Vec::new(); + let mut fees = 0_u64; + let mut applied_removals = IndexSet::new(); + let mut applied_additions = IndexSet::new(); + let mut applied_spends = IndexSet::new(); + let mut changed_coins = IndexSet::new(); + let mut changed_spends = IndexSet::new(); + let mut changed_hints = IndexSet::new(); + let mut staged_coins = self.state.coins.clone(); + let mut staged_coin_spends = self.state.coin_spends.clone(); + let mut staged_coin_hints = self.state.coin_hints.clone(); + let reward_coin = Self::reward_coin( + header_hash, + height, + 0, + self.farming_puzzle_hash, + BLOCK_REWARD_AMOUNT, + ); + let reward_coin_id = reward_coin.coin_id(); + staged_coins.insert( + reward_coin_id, + SimCoinRecord { + coin: reward_coin, + coinbase: true, + confirmed_block_index: height, + spent_block_index: None, + timestamp, + }, + ); + changed_coins.insert(reward_coin_id); + additions.push(reward_coin_id); + + for item in included { + fees = fees.saturating_add(item.fee); + transactions.push(item.spend_bundle); + + for (coin, hint) in item.additions { + let coin_id = coin.coin_id(); + if !applied_additions.insert(coin_id) { + continue; + } + staged_coins.insert( + coin_id, + SimCoinRecord { + coin, + coinbase: false, + confirmed_block_index: height, + spent_block_index: None, + timestamp, + }, + ); + changed_coins.insert(coin_id); + if let Some(hint) = hint { + staged_coin_hints.insert(coin_id, hint); + changed_hints.insert(coin_id); + } + additions.push(coin_id); + } + + for coin_id in item.removals { + if !applied_removals.insert(coin_id) { + continue; + } + if let Some(record) = staged_coins.get_mut(&coin_id) { + record.spent_block_index = Some(height); + changed_coins.insert(coin_id); + removals.push(coin_id); + } + } + + for (coin_id, spend) in item.spends { + if applied_spends.insert(coin_id) { + staged_coin_spends.insert(coin_id, spend.coin_spend.clone()); + changed_spends.insert(coin_id); + spends.push(spend.coin_spend); + } + } + } + + let delta = BlockDelta { + coins: changed_coins + .into_iter() + .map(|coin_id| CoinChange { + coin_id, + before: self.state.coins.get(&coin_id).copied(), + after: staged_coins.get(&coin_id).copied(), + }) + .collect(), + spends: changed_spends + .into_iter() + .map(|coin_id| SpendChange { + coin_id, + before: self.state.coin_spends.get(&coin_id).cloned(), + after: staged_coin_spends.get(&coin_id).cloned(), + }) + .collect(), + hints: changed_hints + .into_iter() + .map(|coin_id| HintChange { + coin_id, + before: self.state.coin_hints.get(&coin_id).copied(), + after: staged_coin_hints.get(&coin_id).copied(), + }) + .collect(), + }; + + let record = Self::make_block_record( + header_hash, + previous_header_hash, + height, + timestamp, + self.header_hash_of(height.saturating_sub(1)) + .unwrap_or_default(), + fees, + height.saturating_sub(1), + self.farming_puzzle_hash, + vec![reward_coin], + ); + let block = SimBlock { + record: record.clone(), + additions: additions.clone(), + removals: removals.clone(), + spends, + transactions, + delta, + }; + + self.state + .apply_block(block) + .expect("locally built block delta must apply atomically"); + for tx_id in included_tx_ids { + self.mempool.swap_remove(&tx_id); + } + + self.events.push(FullNodeSimulatorEvent::Block { + height, + header_hash, + previous_header_hash, + additions: self.records_for_ids(&additions), + removals: self.records_for_ids(&removals), + }); + + record + } + + pub(super) fn revert_canonical_blocks(&mut self, blocks: u32) -> Vec { + let mut reverted = Vec::new(); + for _ in 0..blocks { + let Some(block) = self + .state + .revert_tip() + .expect("canonical tip delta must revert exactly") + else { + break; + }; + reverted.push(block); + } + reverted.reverse(); + reverted + } + + pub(super) fn requeue_transactions( + &mut self, + transactions: impl IntoIterator, + ) { + let mut pending = transactions.into_iter().collect::>(); + while !pending.is_empty() { + let mut deferred = VecDeque::new(); + let mut made_progress = false; + while let Some(spend_bundle) = pending.pop_front() { + match self.normalize_and_insert(spend_bundle.clone()) { + Ok(()) => made_progress = true, + Err(SimulatorError::Validation(ErrorCode::UnknownUnspent)) => { + deferred.push_back(spend_bundle); + } + Err(_) => {} + } + } + if !made_progress { + break; + } + pending = deferred; + } + } + + pub(super) fn prune_mempool(&mut self) { + let spend_bundles = self + .mempool + .values() + .map(|item| item.spend_bundle.clone()) + .collect::>(); + self.mempool.clear(); + self.requeue_transactions(spend_bundles); + } + + fn random_hash(&mut self) -> Bytes32 { + let mut bytes = [0; 32]; + self.rng.fill(&mut bytes); + bytes.into() + } + + pub fn drain_events(&mut self) -> Vec { + std::mem::take(&mut self.events) + } + + pub fn set_farming_ph(&mut self, puzzle_hash: Bytes32) { + self.farming_puzzle_hash = puzzle_hash; + } + + pub fn farm_block(&mut self, blocks: u32) -> Vec { + let count = blocks.max(1); + let mut records = Vec::new(); + for _ in 0..count { + records.push(self.create_block_from_mempool()); + } + records + } + + pub fn revert_blocks(&mut self, blocks: u32) -> Vec { + let reverted = self.revert_canonical_blocks(blocks); + self.requeue_transactions(reverted.iter().flat_map(|block| block.transactions.clone())); + reverted + .iter() + .map(|block| block.record.header_hash) + .collect() + } + + pub fn reorg_blocks( + &mut self, + num_of_blocks_to_rev: u32, + num_of_new_blocks: u32, + ) -> Vec { + let old_peak_hash = self.header_hash(); + let reverted = self.revert_canonical_blocks(num_of_blocks_to_rev); + let fork_height = self.state.height; + let reverted_header_hashes = reverted + .iter() + .map(|block| block.record.header_hash) + .collect::>(); + let reverted_transactions = reverted + .iter() + .flat_map(|block| block.transactions.clone()) + .collect::>(); + + for block in reverted { + self.orphaned_blocks.insert(block.record.header_hash, block); + } + self.requeue_transactions(reverted_transactions); + + let mut records = Vec::new(); + let mut new_header_hashes = Vec::new(); + for _ in 0..num_of_new_blocks { + let record = self.create_block_from_mempool(); + new_header_hashes.push(record.header_hash); + records.push(record); + } + + self.prune_mempool(); + self.events.push(FullNodeSimulatorEvent::Reorg { + fork_height, + old_peak_hash, + new_peak_hash: self.header_hash(), + reverted_header_hashes, + new_header_hashes, + }); + + records + } +} diff --git a/crates/chia-sdk-test/src/full_node_simulator/fast_forward.rs b/crates/chia-sdk-test/src/full_node_simulator/fast_forward.rs new file mode 100644 index 000000000..075fda546 --- /dev/null +++ b/crates/chia-sdk-test/src/full_node_simulator/fast_forward.rs @@ -0,0 +1,591 @@ +use chia_bls::Signature; +use chia_consensus::{ + conditions::ELIGIBLE_FOR_FF, fast_forward::fast_forward_singleton, flags::COMPUTE_FINGERPRINT, +}; +use chia_protocol::{Bytes32, Coin, CoinSpend, SpendBundle}; +use chia_sdk_types::default_constants; +use clvmr::{ + Allocator, ENABLE_KECCAK_OPS_OUTSIDE_GUARD, + serde::{node_from_bytes, node_to_bytes}, +}; + +use crate::{ + FullNodeSimulator, + full_node_simulator::{SIMULATOR_GENESIS_CHALLENGE, ValidatedBundle, ValidatedSpend}, + validate_clvm_and_signature, +}; + +#[derive(Debug)] +pub(super) enum FastForwardResult { + Rewritten(Box), + NoProgress, +} + +impl FullNodeSimulator { + pub(super) fn fast_forward_mempool_spends( + &self, + validated: &ValidatedBundle, + ) -> FastForwardResult { + let mut coin_spends = validated.spend_bundle.coin_spends.clone(); + let mut rewrote_any = false; + + for (coin_id, spend) in &validated.spends { + if (spend.flags & ELIGIBLE_FOR_FF) == 0 { + continue; + } + + let Some(new_coin_spend) = self.fast_forward_mempool_spend(*coin_id, spend) else { + continue; + }; + let Some(existing_spend) = coin_spends + .iter_mut() + .find(|existing| existing.coin.coin_id() == *coin_id) + else { + continue; + }; + + *existing_spend = new_coin_spend; + rewrote_any = true; + } + + Self::rewrite_result( + rewrote_any, + coin_spends, + &validated.spend_bundle.aggregated_signature, + ) + } + + fn fast_forward_mempool_spend( + &self, + coin_id: Bytes32, + spend: &ValidatedSpend, + ) -> Option { + for mempool_item in self.mempool.values() { + if !mempool_item.removals.contains(&coin_id) { + continue; + } + + let Some(conflicting_spend) = mempool_item.spends.get(&coin_id) else { + continue; + }; + let Some((new_coin, _)) = conflicting_spend.additions.iter().find(|(coin, _)| { + coin.parent_coin_info == coin_id + && coin.puzzle_hash == spend.coin_spend.coin.puzzle_hash + && coin.amount == spend.coin_spend.coin.amount + && (coin.amount & 1) == 1 + }) else { + continue; + }; + + let Some(rewritten) = Self::fast_forward_coin_spend( + &spend.coin_spend, + *new_coin, + conflicting_spend.coin_spend.coin, + ) else { + continue; + }; + return Some(rewritten); + } + + None + } + + pub(super) fn fast_forward_settled_spends( + &self, + spend_bundle: &SpendBundle, + ) -> FastForwardResult { + let constants = default_constants(SIMULATOR_GENESIS_CHALLENGE, SIMULATOR_GENESIS_CHALLENGE); + let Ok(conds) = validate_clvm_and_signature( + spend_bundle, + 11_000_000_000 / 2, + &constants, + ENABLE_KECCAK_OPS_OUTSIDE_GUARD | COMPUTE_FINGERPRINT, + ) else { + return FastForwardResult::NoProgress; + }; + + let mut coin_spends = spend_bundle.coin_spends.clone(); + let mut rewrote_any = false; + + for spend in &conds.spends { + let Some(record) = self.state.coins.get(&spend.coin_id) else { + continue; + }; + if record.spent_block_index.is_none() { + continue; + } + + let Some(index) = coin_spends + .iter() + .position(|coin_spend| coin_spend.coin.coin_id() == spend.coin_id) + else { + continue; + }; + + let mut rewritten = coin_spends[index].clone(); + loop { + if (rewritten.coin.amount & 1) == 0 { + break; + } + let Some(current_record) = self.state.coins.get(&rewritten.coin.coin_id()) else { + break; + }; + if current_record.spent_block_index.is_none() { + break; + } + let next_coin = Coin::new( + rewritten.coin.coin_id(), + rewritten.coin.puzzle_hash, + rewritten.coin.amount, + ); + let Some(next_record) = self.state.coins.get(&next_coin.coin_id()) else { + break; + }; + let Some(next_spend) = + Self::fast_forward_coin_spend(&rewritten, next_record.coin, rewritten.coin) + else { + break; + }; + rewritten = next_spend; + rewrote_any = true; + } + + coin_spends[index] = rewritten; + } + + Self::rewrite_result(rewrote_any, coin_spends, &spend_bundle.aggregated_signature) + } + + fn rewrite_result( + rewrote_any: bool, + coin_spends: Vec, + signature: &Signature, + ) -> FastForwardResult { + if rewrote_any { + FastForwardResult::Rewritten(Box::new(SpendBundle::new(coin_spends, signature.clone()))) + } else { + FastForwardResult::NoProgress + } + } + + fn fast_forward_coin_spend( + coin_spend: &CoinSpend, + new_coin: Coin, + new_parent: Coin, + ) -> Option { + let mut allocator = Allocator::new_limited(500_000_000); + let puzzle = node_from_bytes(&mut allocator, coin_spend.puzzle_reveal.as_slice()).ok()?; + let solution = node_from_bytes(&mut allocator, coin_spend.solution.as_slice()).ok()?; + let new_solution = fast_forward_singleton( + &mut allocator, + puzzle, + solution, + &coin_spend.coin, + &new_coin, + &new_parent, + ) + .ok()?; + let new_solution_bytes = node_to_bytes(&allocator, new_solution).ok()?; + Some(CoinSpend::new( + new_coin, + coin_spend.puzzle_reveal.clone(), + new_solution_bytes.into(), + )) + } +} + +#[cfg(test)] +mod tests { + use chia_bls::Signature; + use chia_protocol::{Bytes32, Coin, CoinSpend, Program, SpendBundle}; + use chia_puzzle_types::{ + LineageProof, Proof, + singleton::{SingletonArgs, SingletonSolution}, + }; + use chia_sdk_types::Mod; + use chia_sdk_types::conditions::{CreateCoin, Memos}; + use clvm_traits::ToClvm; + use clvm_utils::CurriedProgram; + use clvmr::{Allocator, NodePtr, serde::node_from_bytes, serde::node_to_bytes}; + use indexmap::{IndexMap, IndexSet}; + + use crate::{full_node_simulator::ValidatedSpend, to_puzzle}; + + use super::*; + + fn singleton_spend_to_child( + coin: Coin, + launcher_id: Bytes32, + inner_puzzle_reveal: &Program, + lineage_proof: LineageProof, + child_puzzle_hash: Bytes32, + child_amount: u64, + hint: Option, + ) -> anyhow::Result { + let mut allocator = Allocator::new_limited(500_000_000); + let memos = if let Some(hint) = hint { + let hint_atom = allocator.new_atom(hint.as_ref())?; + let memo_list = allocator.new_pair(hint_atom, NodePtr::NIL)?; + Memos::Some(memo_list) + } else { + Memos::None + }; + let inner_solution = [CreateCoin::::new( + child_puzzle_hash, + child_amount, + memos, + )] + .to_clvm(&mut allocator)?; + let singleton_mod = node_from_bytes( + &mut allocator, + SingletonArgs::::mod_reveal().as_ref(), + )?; + let inner_puzzle = node_from_bytes(&mut allocator, inner_puzzle_reveal)?; + let singleton_puzzle = CurriedProgram { + program: singleton_mod, + args: SingletonArgs::new(launcher_id, inner_puzzle), + } + .to_clvm(&mut allocator)?; + let singleton_solution = SingletonSolution { + lineage_proof: Proof::Lineage(lineage_proof), + amount: coin.amount, + inner_solution, + } + .to_clvm(&mut allocator)?; + + Ok(CoinSpend::new( + coin, + node_to_bytes(&allocator, singleton_puzzle)?.into(), + node_to_bytes(&allocator, singleton_solution)?.into(), + )) + } + + fn singleton_conflict( + sim: &mut FullNodeSimulator, + seed: u8, + ) -> anyhow::Result<(CoinSpend, CoinSpend, Coin)> { + let (inner_puzzle_hash, inner_puzzle_reveal) = to_puzzle(u64::from(seed))?; + let launcher_id: Bytes32 = [seed; 32].into(); + let singleton_puzzle_hash: Bytes32 = + SingletonArgs::curry_tree_hash(launcher_id, inner_puzzle_hash.into()).into(); + let parent_coin = Coin::new( + [seed.saturating_add(1); 32].into(), + singleton_puzzle_hash, + 101, + ); + let singleton_coin = Coin::new(parent_coin.coin_id(), singleton_puzzle_hash, 101); + let lineage_proof = LineageProof { + parent_parent_coin_info: parent_coin.parent_coin_info, + parent_inner_puzzle_hash: inner_puzzle_hash, + parent_amount: parent_coin.amount, + }; + sim.insert_coin(singleton_coin); + + let first = singleton_spend_to_child( + singleton_coin, + launcher_id, + &inner_puzzle_reveal, + lineage_proof, + singleton_puzzle_hash, + singleton_coin.amount, + None, + )?; + let candidate = singleton_spend_to_child( + singleton_coin, + launcher_id, + &inner_puzzle_reveal, + lineage_proof, + singleton_puzzle_hash, + singleton_coin.amount, + Some([seed.saturating_add(2); 32].into()), + )?; + let child = Coin::new( + singleton_coin.coin_id(), + singleton_coin.puzzle_hash, + singleton_coin.amount, + ); + + Ok((first, candidate, child)) + } + + #[test] + fn fast_forward_rewrites_all_eligible_mempool_spends_in_one_pass() -> anyhow::Result<()> { + let mut sim = FullNodeSimulator::new(); + let (first_a, candidate_a, child_a) = singleton_conflict(&mut sim, 21)?; + let (first_b, candidate_b, child_b) = singleton_conflict(&mut sim, 31)?; + let left_coin_id = first_a.coin.coin_id(); + let right_coin_id = first_b.coin.coin_id(); + let first_bundle = + SpendBundle::new(vec![first_a.clone(), first_b.clone()], Signature::default()); + sim.mempool.insert( + first_bundle.name(), + ValidatedBundle { + spend_bundle: first_bundle, + removals: vec![left_coin_id, right_coin_id], + additions: vec![(child_a, None), (child_b, None)], + spends: IndexMap::from([ + ( + left_coin_id, + ValidatedSpend { + coin_spend: first_a, + flags: ELIGIBLE_FOR_FF, + fingerprint: None, + additions: vec![(child_a, None)], + }, + ), + ( + right_coin_id, + ValidatedSpend { + coin_spend: first_b, + flags: ELIGIBLE_FOR_FF, + fingerprint: None, + additions: vec![(child_b, None)], + }, + ), + ]), + cost: 0, + fee: 0, + }, + ); + + let candidate_bundle = SpendBundle::new( + vec![candidate_a.clone(), candidate_b.clone()], + Signature::default(), + ); + let FastForwardResult::Rewritten(rewritten) = + sim.fast_forward_mempool_spends(&ValidatedBundle { + spend_bundle: candidate_bundle, + removals: vec![left_coin_id, right_coin_id], + additions: Vec::new(), + spends: IndexMap::from([ + ( + left_coin_id, + ValidatedSpend { + coin_spend: candidate_a, + flags: ELIGIBLE_FOR_FF, + fingerprint: None, + additions: Vec::new(), + }, + ), + ( + right_coin_id, + ValidatedSpend { + coin_spend: candidate_b, + flags: ELIGIBLE_FOR_FF, + fingerprint: None, + additions: Vec::new(), + }, + ), + ]), + cost: 0, + fee: 0, + }) + else { + panic!("both singleton spends should be fast-forwarded"); + }; + + let rewritten_ids = rewritten + .coin_spends + .iter() + .map(|spend| spend.coin.coin_id()) + .collect::>(); + assert!(rewritten_ids.contains(&child_a.coin_id())); + assert!(rewritten_ids.contains(&child_b.coin_id())); + + Ok(()) + } + + #[test] + fn fast_forward_reports_no_progress_without_matching_lineage() -> anyhow::Result<()> { + let mut sim = FullNodeSimulator::new(); + let (_, candidate, _) = singleton_conflict(&mut sim, 41)?; + let coin_id = candidate.coin.coin_id(); + let bundle = SpendBundle::new(vec![candidate.clone()], Signature::default()); + + assert!(matches!( + sim.fast_forward_mempool_spends(&ValidatedBundle { + spend_bundle: bundle, + removals: vec![coin_id], + additions: Vec::new(), + spends: IndexMap::from([( + coin_id, + ValidatedSpend { + coin_spend: candidate, + flags: ELIGIBLE_FOR_FF, + fingerprint: None, + additions: Vec::new(), + }, + )]), + cost: 0, + fee: 0, + }), + FastForwardResult::NoProgress + )); + + Ok(()) + } + + #[test] + fn fast_forward_rewrites_singleton_spend_against_mempool_item() -> anyhow::Result<()> { + let mut sim = FullNodeSimulator::new(); + let (inner_puzzle_hash, inner_puzzle_reveal) = to_puzzle(1)?; + let launcher_id: Bytes32 = [7; 32].into(); + let singleton_puzzle_hash: Bytes32 = + SingletonArgs::curry_tree_hash(launcher_id, inner_puzzle_hash.into()).into(); + let parent_coin = Coin::new([9; 32].into(), singleton_puzzle_hash, 101); + let singleton_coin = Coin::new(parent_coin.coin_id(), singleton_puzzle_hash, 101); + let lineage_proof = LineageProof { + parent_parent_coin_info: parent_coin.parent_coin_info, + parent_inner_puzzle_hash: inner_puzzle_hash, + parent_amount: parent_coin.amount, + }; + sim.insert_coin(singleton_coin); + + let first_singleton_spend = singleton_spend_to_child( + singleton_coin, + launcher_id, + &inner_puzzle_reveal, + lineage_proof, + singleton_puzzle_hash, + singleton_coin.amount, + None, + )?; + let fast_forward_hint: Bytes32 = [8; 32].into(); + let second_singleton_spend = singleton_spend_to_child( + singleton_coin, + launcher_id, + &inner_puzzle_reveal, + lineage_proof, + singleton_puzzle_hash, + singleton_coin.amount, + Some(fast_forward_hint), + )?; + let child_coin = Coin::new( + singleton_coin.coin_id(), + singleton_coin.puzzle_hash, + singleton_coin.amount, + ); + let first_tx = SpendBundle::new(vec![first_singleton_spend.clone()], Signature::default()); + sim.mempool.insert( + first_tx.name(), + ValidatedBundle { + spend_bundle: first_tx, + removals: vec![singleton_coin.coin_id()], + additions: vec![(child_coin, None)], + spends: IndexMap::from([( + singleton_coin.coin_id(), + ValidatedSpend { + coin_spend: first_singleton_spend, + flags: ELIGIBLE_FOR_FF, + fingerprint: None, + additions: vec![(child_coin, None)], + }, + )]), + cost: 0, + fee: 0, + }, + ); + + let candidate_bundle = + SpendBundle::new(vec![second_singleton_spend.clone()], Signature::default()); + let FastForwardResult::Rewritten(rewritten) = + sim.fast_forward_mempool_spends(&ValidatedBundle { + spend_bundle: candidate_bundle.clone(), + removals: vec![singleton_coin.coin_id()], + additions: Vec::new(), + spends: IndexMap::from([( + singleton_coin.coin_id(), + ValidatedSpend { + coin_spend: second_singleton_spend, + flags: ELIGIBLE_FOR_FF, + fingerprint: None, + additions: Vec::new(), + }, + )]), + cost: 0, + fee: 0, + }) + else { + panic!("singleton spend should be fast-forwarded"); + }; + assert!( + rewritten + .coin_spends + .iter() + .any(|spend| spend.coin.coin_id() == child_coin.coin_id()) + ); + + Ok(()) + } + + #[test] + fn push_tx_fast_forwards_deep_settled_singleton_lineage() -> anyhow::Result<()> { + let mut sim = FullNodeSimulator::new(); + let (inner_puzzle_hash, inner_puzzle_reveal) = to_puzzle(1)?; + let launcher_id: Bytes32 = [11; 32].into(); + let singleton_puzzle_hash: Bytes32 = + SingletonArgs::curry_tree_hash(launcher_id, inner_puzzle_hash.into()).into(); + let parent_coin = Coin::new([13; 32].into(), singleton_puzzle_hash, 101); + let singleton_coin = Coin::new(parent_coin.coin_id(), singleton_puzzle_hash, 101); + let lineage_proof = LineageProof { + parent_parent_coin_info: parent_coin.parent_coin_info, + parent_inner_puzzle_hash: inner_puzzle_hash, + parent_amount: parent_coin.amount, + }; + sim.insert_coin(singleton_coin); + let mut lineage_tip = singleton_coin; + for height in 2..=98 { + let child_coin = Coin::new( + lineage_tip.coin_id(), + lineage_tip.puzzle_hash, + lineage_tip.amount, + ); + sim.insert_coin(child_coin); + sim.state + .coins + .get_mut(&lineage_tip.coin_id()) + .unwrap() + .spent_block_index = Some(height); + lineage_tip = child_coin; + } + + let stale_singleton_spend = singleton_spend_to_child( + singleton_coin, + launcher_id, + &inner_puzzle_reveal, + lineage_proof, + singleton_puzzle_hash, + singleton_coin.amount, + Some([14; 32].into()), + )?; + let stale_bundle = SpendBundle::new(vec![stale_singleton_spend], Signature::default()); + let FastForwardResult::Rewritten(rewritten) = + sim.fast_forward_settled_spends(&stale_bundle) + else { + panic!("settled singleton spend should be fast-forwarded"); + }; + assert_eq!( + rewritten.coin_spends[0].coin.coin_id(), + lineage_tip.coin_id() + ); + + let response = sim.push_tx(stale_bundle); + assert!(response.success, "{response:?}"); + sim.farm_block(1); + + let tip_record = sim + .get_coin_record_by_name(lineage_tip.coin_id()) + .coin_record + .unwrap(); + assert!(tip_record.spent); + + let last_spends = sim + .get_block_spends(sim.header_hash()) + .block_spends + .unwrap(); + assert_eq!(last_spends.len(), 1); + assert_eq!(last_spends[0].coin.coin_id(), lineage_tip.coin_id()); + + Ok(()) + } +} diff --git a/crates/chia-sdk-test/src/full_node_simulator/push_tx.rs b/crates/chia-sdk-test/src/full_node_simulator/push_tx.rs new file mode 100644 index 000000000..2862060fb --- /dev/null +++ b/crates/chia-sdk-test/src/full_node_simulator/push_tx.rs @@ -0,0 +1,239 @@ +use chia_consensus::{conditions::ELIGIBLE_FOR_DEDUP, validation_error::ErrorCode}; +use chia_protocol::{Bytes32, SpendBundle}; +use chia_sdk_coinset::PushTxResponse; +use indexmap::IndexSet; + +use crate::SimulatorError; + +use super::{ + FullNodeSimulator, FullNodeSimulatorPushTxResponse, ValidatedBundle, ValidatedSpend, + fast_forward::FastForwardResult, +}; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct NormalizationState { + ordered_coin_ids: Vec, + spend_bundle_id: Bytes32, +} + +impl NormalizationState { + fn new(spend_bundle: &SpendBundle) -> Self { + Self { + ordered_coin_ids: spend_bundle + .coin_spends + .iter() + .map(|coin_spend| coin_spend.coin.coin_id()) + .collect(), + spend_bundle_id: spend_bundle.name(), + } + } +} + +#[derive(Debug, Default)] +struct NormalizationProgress { + seen: IndexSet, +} + +impl NormalizationProgress { + fn record(&mut self, spend_bundle: &SpendBundle) -> bool { + self.seen.insert(NormalizationState::new(spend_bundle)) + } +} + +impl FullNodeSimulator { + fn insert_mempool_item( + &mut self, + tx_id: Bytes32, + validated: ValidatedBundle, + ) -> Result<(), SimulatorError> { + let conflicting_tx_ids = self.conflicting_mempool_tx_ids(&validated); + if !conflicting_tx_ids.is_empty() + && !self.is_mempool_replacement(&validated, &conflicting_tx_ids) + { + return Err(SimulatorError::Validation(ErrorCode::MempoolConflict)); + } + if !conflicting_tx_ids.is_empty() { + for tx_id in conflicting_tx_ids { + self.mempool.swap_remove(&tx_id); + } + } + + self.mempool.insert(tx_id, validated); + Ok(()) + } + + fn mempool_rejects(&self, validated: &ValidatedBundle) -> bool { + let conflicting_tx_ids = self.conflicting_mempool_tx_ids(validated); + !conflicting_tx_ids.is_empty() + && !self.is_mempool_replacement(validated, &conflicting_tx_ids) + } + + fn is_mempool_replacement( + &self, + validated: &ValidatedBundle, + conflicting_tx_ids: &[Bytes32], + ) -> bool { + let conflicting_removals = conflicting_tx_ids + .iter() + .filter_map(|tx_id| self.mempool.get(tx_id)) + .flat_map(|item| item.removals.iter().copied()) + .collect::>(); + let conflicting_fees = conflicting_tx_ids + .iter() + .filter_map(|tx_id| self.mempool.get(tx_id)) + .map(|item| item.fee) + .sum::(); + + conflicting_removals + .iter() + .all(|coin_id| validated.removals.contains(coin_id)) + && validated.fee > conflicting_fees + } + + fn conflicting_mempool_tx_ids(&self, validated: &ValidatedBundle) -> Vec { + self.mempool + .iter() + .filter(|(_, item)| Self::has_non_dedup_overlap(validated, item)) + .map(|(tx_id, _)| *tx_id) + .collect() + } + + fn has_non_dedup_overlap(lhs: &ValidatedBundle, rhs: &ValidatedBundle) -> bool { + lhs.removals.iter().any(|coin_id| { + rhs.removals.contains(coin_id) && !Self::removal_is_dedup_compatible(lhs, rhs, *coin_id) + }) + } + + fn removal_is_dedup_compatible( + lhs: &ValidatedBundle, + rhs: &ValidatedBundle, + coin_id: Bytes32, + ) -> bool { + let Some(lhs_spend) = lhs.spends.get(&coin_id) else { + return false; + }; + let Some(rhs_spend) = rhs.spends.get(&coin_id) else { + return false; + }; + Self::spends_are_dedup_compatible(lhs_spend, rhs_spend) + } + + pub(super) fn spends_are_dedup_compatible(lhs: &ValidatedSpend, rhs: &ValidatedSpend) -> bool { + (lhs.flags & ELIGIBLE_FOR_DEDUP) != 0 + && (rhs.flags & ELIGIBLE_FOR_DEDUP) != 0 + && lhs.fingerprint.is_some() + && lhs.fingerprint == rhs.fingerprint + } + + fn push_tx_success() -> FullNodeSimulatorPushTxResponse { + FullNodeSimulatorPushTxResponse { + response: PushTxResponse { + status: Some("SUCCESS".to_string()), + error: None, + success: true, + }, + error: None, + } + } + + fn push_tx_failure(error: SimulatorError) -> FullNodeSimulatorPushTxResponse { + FullNodeSimulatorPushTxResponse { + response: PushTxResponse { + status: Some("FAILED".to_string()), + error: Some(error.to_string()), + success: false, + }, + error: Some(error), + } + } + + pub fn push_tx(&mut self, spend_bundle: SpendBundle) -> PushTxResponse { + self.push_tx_detailed(spend_bundle).response + } + + pub fn push_tx_detailed( + &mut self, + spend_bundle: SpendBundle, + ) -> FullNodeSimulatorPushTxResponse { + match self.normalize_and_insert(spend_bundle) { + Ok(()) => Self::push_tx_success(), + Err(error) => Self::push_tx_failure(error), + } + } + + pub(super) fn normalize_and_insert( + &mut self, + mut spend_bundle: SpendBundle, + ) -> Result<(), SimulatorError> { + let mut progress = NormalizationProgress::default(); + let mut cycle_error = ErrorCode::DoubleSpend; + + loop { + let tx_id = spend_bundle.name(); + if self.mempool.contains_key(&tx_id) { + return Ok(()); + } + if !progress.record(&spend_bundle) { + return Err(SimulatorError::Validation(cycle_error)); + } + + if let FastForwardResult::Rewritten(rewritten) = + self.fast_forward_settled_spends(&spend_bundle) + { + cycle_error = ErrorCode::DoubleSpend; + spend_bundle = *rewritten; + continue; + } + + let validated = self.validate_bundle(spend_bundle)?; + if self.mempool_rejects(&validated) { + match self.fast_forward_mempool_spends(&validated) { + FastForwardResult::Rewritten(rewritten) => { + cycle_error = ErrorCode::MempoolConflict; + spend_bundle = *rewritten; + continue; + } + FastForwardResult::NoProgress => { + return Err(SimulatorError::Validation(ErrorCode::MempoolConflict)); + } + } + } + + return self.insert_mempool_item(tx_id, validated); + } + } +} + +#[cfg(test)] +mod tests { + use chia_bls::Signature; + use chia_protocol::{Coin, CoinSpend, Program}; + + use super::*; + + #[test] + fn normalization_progress_detects_cycles_without_false_identity_matches() { + let coin = Coin::new([1; 32].into(), [2; 32].into(), 1); + let first = SpendBundle::new( + vec![CoinSpend::new( + coin, + Program::from(vec![1]), + Program::from(vec![2]), + )], + Signature::default(), + ); + let second = SpendBundle::new( + vec![CoinSpend::new( + coin, + Program::from(vec![1]), + Program::from(vec![3]), + )], + Signature::default(), + ); + let mut progress = NormalizationProgress::default(); + + assert!(progress.record(&first)); + assert!(progress.record(&second)); + assert!(!progress.record(&first)); + } +} diff --git a/crates/chia-sdk-test/src/full_node_simulator/queries.rs b/crates/chia-sdk-test/src/full_node_simulator/queries.rs new file mode 100644 index 000000000..e756e3ebc --- /dev/null +++ b/crates/chia-sdk-test/src/full_node_simulator/queries.rs @@ -0,0 +1,375 @@ +use std::collections::HashSet; + +use chia_bls::SecretKey; +use chia_protocol::Bytes32; +use chia_sdk_coinset::{ + AdditionsAndRemovalsResponse, BlockchainState, BlockchainStateResponse, CoinRecord, + GetBlockRecordResponse, GetBlockRecordsResponse, GetBlockSpendsResponse, GetCoinRecordResponse, + GetCoinRecordsResponse, GetMempoolItemResponse, GetMempoolItemsResponse, + GetNetworkInfoResponse, GetPuzzleAndSolutionResponse, MempoolMinFees, SyncState, +}; + +use super::{FullNodeSimulator, SIMULATOR_GENESIS_CHALLENGE, SimCoinRecord, ValidatedBundle}; + +impl FullNodeSimulator { + pub fn height(&self) -> u32 { + self.state.height + } + + pub fn header_hash(&self) -> Bytes32 { + self.state.header_hashes.last().copied().unwrap_or_default() + } + + pub fn header_hash_of(&self, height: u32) -> Option { + self.state + .header_hashes + .get((height as usize).saturating_sub(1)) + .copied() + } + + pub fn get_farming_ph(&self) -> Bytes32 { + self.farming_puzzle_hash + } + + pub fn get_master_secret_key(&self) -> SecretKey { + self.master_secret_key.clone() + } + + pub fn get_prefarm_puzzle_hash(&self) -> Bytes32 { + self.prefarm_puzzle_hash + } + + pub fn get_blockchain_state(&self) -> BlockchainStateResponse { + let peak = self.state.blocks.get(&self.header_hash()).map_or_else( + || { + Self::make_block_record( + Bytes32::default(), + Bytes32::default(), + 0, + 0, + Bytes32::default(), + 0, + 0, + self.farming_puzzle_hash, + Vec::new(), + ) + }, + |block| block.record.clone(), + ); + + BlockchainStateResponse { + blockchain_state: Some(BlockchainState { + average_block_time: 1, + block_max_cost: 11_000_000_000, + difficulty: 1, + genesis_challenge_initialized: true, + mempool_cost: self.mempool.values().map(|item| item.cost).sum(), + mempool_fees: self.mempool.values().map(|item| item.fee).sum(), + mempool_max_total_cost: 110_000_000_000, + mempool_min_fees: MempoolMinFees { cost_5000000: 0 }, + mempool_size: self.mempool.len().try_into().unwrap(), + node_id: self.node_id, + peak, + space: 0, + sub_slot_iters: 1, + sync: SyncState { + sync_mode: false, + sync_progress_height: self.state.height, + sync_tip_height: self.state.height, + synced: true, + }, + }), + error: None, + success: true, + } + } + + pub fn get_network_info(&self) -> GetNetworkInfoResponse { + GetNetworkInfoResponse { + network_name: Some("simulator0".to_string()), + network_prefix: Some("txch".to_string()), + genesis_challenge: Some(SIMULATOR_GENESIS_CHALLENGE), + error: None, + success: true, + } + } + + pub fn get_aggsig_additional_data(&self) -> Bytes32 { + SIMULATOR_GENESIS_CHALLENGE + } + + pub fn get_block_record(&self, header_hash: Bytes32) -> GetBlockRecordResponse { + GetBlockRecordResponse { + block_record: self + .state + .blocks + .get(&header_hash) + .or_else(|| self.orphaned_blocks.get(&header_hash)) + .map(|block| block.record.clone()), + error: None, + success: true, + } + } + + pub fn get_block_record_by_height(&self, height: u32) -> GetBlockRecordResponse { + let block_record = self + .header_hash_of(height) + .and_then(|header_hash| self.state.blocks.get(&header_hash)) + .map(|block| block.record.clone()); + + GetBlockRecordResponse { + block_record, + error: None, + success: true, + } + } + + pub fn get_block_records(&self, start: u32, end: u32) -> GetBlockRecordsResponse { + let block_records = (start..end) + .filter_map(|height| self.get_block_record_by_height(height).block_record) + .collect(); + + GetBlockRecordsResponse { + block_records: Some(block_records), + error: None, + success: true, + } + } + + pub fn get_additions_and_removals(&self, header_hash: Bytes32) -> AdditionsAndRemovalsResponse { + let Some(block) = self + .state + .blocks + .get(&header_hash) + .or_else(|| self.orphaned_blocks.get(&header_hash)) + else { + return AdditionsAndRemovalsResponse { + additions: None, + removals: None, + error: Some("block not found".to_string()), + success: false, + }; + }; + + AdditionsAndRemovalsResponse { + additions: Some(self.records_for_ids(&block.additions)), + removals: Some(self.records_for_ids(&block.removals)), + error: None, + success: true, + } + } + + pub fn get_block_spends(&self, header_hash: Bytes32) -> GetBlockSpendsResponse { + GetBlockSpendsResponse { + block_spends: self + .state + .blocks + .get(&header_hash) + .or_else(|| self.orphaned_blocks.get(&header_hash)) + .map(|block| block.spends.clone()), + error: None, + success: true, + } + } + + pub fn get_coin_record_by_name(&self, name: Bytes32) -> GetCoinRecordResponse { + GetCoinRecordResponse { + coin_record: self + .state + .coins + .get(&name) + .map(|record| record.to_coin_record()), + error: None, + success: true, + } + } + + pub fn get_coin_records_by_names( + &self, + names: &[Bytes32], + start_height: Option, + end_height: Option, + include_spent_coins: Option, + ) -> GetCoinRecordsResponse { + Self::records_response( + self.state + .coins + .iter() + .filter(|(coin_id, _)| names.contains(coin_id)) + .map(|(_, record)| *record), + start_height, + end_height, + include_spent_coins, + ) + } + + pub fn get_coin_records_by_hint( + &self, + hint: Bytes32, + start_height: Option, + end_height: Option, + include_spent_coins: Option, + ) -> GetCoinRecordsResponse { + self.get_coin_records_by_hints(vec![hint], start_height, end_height, include_spent_coins) + } + + pub fn get_coin_records_by_hints( + &self, + hints: Vec, + start_height: Option, + end_height: Option, + include_spent_coins: Option, + ) -> GetCoinRecordsResponse { + let hints: HashSet = hints.into_iter().collect(); + Self::records_response( + self.state + .coins + .iter() + .filter(|(coin_id, _)| { + self.state + .coin_hints + .get(*coin_id) + .is_some_and(|hint| hints.contains(hint)) + }) + .map(|(_, record)| *record), + start_height, + end_height, + include_spent_coins, + ) + } + + pub fn get_coin_records_by_parent_ids( + &self, + parent_ids: Vec, + start_height: Option, + end_height: Option, + include_spent_coins: Option, + ) -> GetCoinRecordsResponse { + let parent_ids: HashSet = parent_ids.into_iter().collect(); + Self::records_response( + self.state + .coins + .values() + .filter(|record| parent_ids.contains(&record.coin.parent_coin_info)) + .copied(), + start_height, + end_height, + include_spent_coins, + ) + } + + pub fn get_coin_records_by_puzzle_hash( + &self, + puzzle_hash: Bytes32, + start_height: Option, + end_height: Option, + include_spent_coins: Option, + ) -> GetCoinRecordsResponse { + self.get_coin_records_by_puzzle_hashes( + vec![puzzle_hash], + start_height, + end_height, + include_spent_coins, + ) + } + + pub fn get_coin_records_by_puzzle_hashes( + &self, + puzzle_hashes: Vec, + start_height: Option, + end_height: Option, + include_spent_coins: Option, + ) -> GetCoinRecordsResponse { + let puzzle_hashes: HashSet = puzzle_hashes.into_iter().collect(); + Self::records_response( + self.state + .coins + .values() + .filter(|record| puzzle_hashes.contains(&record.coin.puzzle_hash)) + .copied(), + start_height, + end_height, + include_spent_coins, + ) + } + + pub fn get_puzzle_and_solution( + &self, + coin_id: Bytes32, + height: Option, + ) -> GetPuzzleAndSolutionResponse { + let coin_solution = self.state.coin_spends.get(&coin_id).and_then(|spend| { + let record = self.state.coins.get(&coin_id)?; + if height.is_none() || record.spent_block_index == height { + Some(spend.clone()) + } else { + None + } + }); + + GetPuzzleAndSolutionResponse { + coin_solution, + error: None, + success: true, + } + } + + pub fn get_mempool_item_by_tx_id(&self, tx_id: Bytes32) -> GetMempoolItemResponse { + GetMempoolItemResponse { + mempool_item: self + .mempool + .get(&tx_id) + .map(ValidatedBundle::to_mempool_item), + error: None, + success: true, + } + } + + pub fn get_mempool_items_by_coin_name(&self, coin_name: Bytes32) -> GetMempoolItemsResponse { + GetMempoolItemsResponse { + mempool_items: Some( + self.mempool + .values() + .filter(|item| item.removals.contains(&coin_name)) + .map(ValidatedBundle::to_mempool_item) + .collect(), + ), + error: None, + success: true, + } + } + + pub(super) fn records_for_ids(&self, coin_ids: &[Bytes32]) -> Vec { + coin_ids + .iter() + .filter_map(|coin_id| self.state.coins.get(coin_id)) + .map(|record| record.to_coin_record()) + .collect() + } + + fn records_response( + records: impl IntoIterator, + start_height: Option, + end_height: Option, + include_spent_coins: Option, + ) -> GetCoinRecordsResponse { + let include_spent = include_spent_coins.unwrap_or(false); + let records = records + .into_iter() + .filter(|record| include_spent || record.spent_block_index.is_none()) + .filter(|record| { + start_height.is_none_or(|start| record.confirmed_block_index >= start) + && end_height.is_none_or(|end| record.confirmed_block_index < end) + }) + .map(SimCoinRecord::to_coin_record) + .collect(); + + GetCoinRecordsResponse { + coin_records: Some(records), + error: None, + success: true, + next_cursor: None, + truncated: None, + } + } +} diff --git a/crates/chia-sdk-test/src/full_node_simulator/state.rs b/crates/chia-sdk-test/src/full_node_simulator/state.rs new file mode 100644 index 000000000..4e797b8da --- /dev/null +++ b/crates/chia-sdk-test/src/full_node_simulator/state.rs @@ -0,0 +1,283 @@ +use chia_protocol::{Bytes32, CoinSpend}; +use indexmap::{IndexMap, IndexSet}; + +use crate::{ChainStateError, SimulatorError}; + +use super::{SimBlock, SimCoinRecord}; + +#[derive(Debug, Clone)] +pub(super) struct CoinChange { + pub(super) coin_id: Bytes32, + pub(super) before: Option, + pub(super) after: Option, +} + +#[derive(Debug, Clone)] +pub(super) struct SpendChange { + pub(super) coin_id: Bytes32, + pub(super) before: Option, + pub(super) after: Option, +} + +#[derive(Debug, Clone)] +pub(super) struct HintChange { + pub(super) coin_id: Bytes32, + pub(super) before: Option, + pub(super) after: Option, +} + +#[derive(Debug, Clone, Default)] +pub(super) struct BlockDelta { + pub(super) coins: Vec, + pub(super) spends: Vec, + pub(super) hints: Vec, +} + +#[derive(Debug, Clone)] +pub(super) struct ChainState { + pub(super) height: u32, + pub(super) next_timestamp: u64, + pub(super) header_hashes: Vec, + pub(super) blocks: IndexMap, + pub(super) coins: IndexMap, + pub(super) coin_spends: IndexMap, + pub(super) coin_hints: IndexMap, +} + +impl ChainState { + pub(super) fn new( + height: u32, + next_timestamp: u64, + header_hashes: Vec, + blocks: IndexMap, + coins: IndexMap, + coin_spends: IndexMap, + coin_hints: IndexMap, + ) -> Self { + Self { + height, + next_timestamp, + header_hashes, + blocks, + coins, + coin_spends, + coin_hints, + } + } + + pub(super) fn apply_block(&mut self, block: SimBlock) -> Result<(), SimulatorError> { + self.validate_apply(&block)?; + + for change in &block.delta.coins { + set_entry(&mut self.coins, change.coin_id, change.after); + } + for change in &block.delta.spends { + set_entry(&mut self.coin_spends, change.coin_id, change.after.clone()); + } + for change in &block.delta.hints { + set_entry(&mut self.coin_hints, change.coin_id, change.after); + } + + let header_hash = block.record.header_hash; + self.height = block.record.height; + self.next_timestamp = block + .record + .timestamp + .unwrap_or(self.next_timestamp) + .saturating_add(1); + self.header_hashes.push(header_hash); + self.blocks.insert(header_hash, block); + Ok(()) + } + + pub(super) fn revert_tip(&mut self) -> Result, SimulatorError> { + if self.height == 0 { + return Ok(None); + } + let Some(header_hash) = self.header_hashes.last().copied() else { + return Err(ChainStateError::MissingTipHeader.into()); + }; + let Some(block) = self.blocks.get(&header_hash) else { + return Err(ChainStateError::MissingTipBlock.into()); + }; + self.validate_revert(block)?; + + let block = self + .blocks + .swap_remove(&header_hash) + .expect("tip existence was validated"); + self.header_hashes.pop(); + for change in block.delta.hints.iter().rev() { + set_entry(&mut self.coin_hints, change.coin_id, change.before); + } + for change in block.delta.spends.iter().rev() { + set_entry(&mut self.coin_spends, change.coin_id, change.before.clone()); + } + for change in block.delta.coins.iter().rev() { + set_entry(&mut self.coins, change.coin_id, change.before); + } + self.height = self.height.saturating_sub(1); + self.next_timestamp = block.record.timestamp.unwrap_or(self.next_timestamp); + Ok(Some(block)) + } + + pub(super) fn insert_manual_coin(&mut self, coin_id: Bytes32, record: SimCoinRecord) { + self.coins.insert(coin_id, record); + } + + fn validate_apply(&self, block: &SimBlock) -> Result<(), SimulatorError> { + if block.record.height != self.height.saturating_add(1) { + return Err(ChainStateError::InvalidBlockHeight.into()); + } + if block.record.prev_hash != self.header_hash() { + return Err(ChainStateError::InvalidPreviousHash.into()); + } + if block.record.timestamp != Some(self.next_timestamp) { + return Err(ChainStateError::InvalidBlockTimestamp.into()); + } + if self.blocks.contains_key(&block.record.header_hash) { + return Err(ChainStateError::DuplicateBlockHeader.into()); + } + validate_unique_changes(&block.delta)?; + validate_current_values(self, &block.delta, false) + } + + fn validate_revert(&self, block: &SimBlock) -> Result<(), SimulatorError> { + if block.record.height != self.height || block.record.header_hash != self.header_hash() { + return Err(ChainStateError::BlockIsNotTip.into()); + } + validate_unique_changes(&block.delta)?; + validate_current_values(self, &block.delta, true) + } + + fn header_hash(&self) -> Bytes32 { + self.header_hashes.last().copied().unwrap_or_default() + } +} + +fn validate_unique_changes(delta: &BlockDelta) -> Result<(), SimulatorError> { + let mut coin_ids = IndexSet::new(); + if delta + .coins + .iter() + .any(|change| !coin_ids.insert(change.coin_id)) + { + return Err(ChainStateError::DuplicateCoinChange.into()); + } + let mut spend_ids = IndexSet::new(); + if delta + .spends + .iter() + .any(|change| !spend_ids.insert(change.coin_id)) + { + return Err(ChainStateError::DuplicateSpendChange.into()); + } + let mut hint_ids = IndexSet::new(); + if delta + .hints + .iter() + .any(|change| !hint_ids.insert(change.coin_id)) + { + return Err(ChainStateError::DuplicateHintChange.into()); + } + Ok(()) +} + +fn validate_current_values( + state: &ChainState, + delta: &BlockDelta, + use_after: bool, +) -> Result<(), SimulatorError> { + for change in &delta.coins { + let expected = if use_after { + change.after.as_ref() + } else { + change.before.as_ref() + }; + if state.coins.get(&change.coin_id) != expected { + return Err(ChainStateError::CoinStateMismatch.into()); + } + } + for change in &delta.spends { + let expected = if use_after { + change.after.as_ref() + } else { + change.before.as_ref() + }; + if state.coin_spends.get(&change.coin_id) != expected { + return Err(ChainStateError::CoinSpendStateMismatch.into()); + } + } + for change in &delta.hints { + let expected = if use_after { + change.after.as_ref() + } else { + change.before.as_ref() + }; + if state.coin_hints.get(&change.coin_id) != expected { + return Err(ChainStateError::CoinHintStateMismatch.into()); + } + } + Ok(()) +} + +fn set_entry(map: &mut IndexMap, key: Bytes32, value: Option) { + if let Some(value) = value { + map.insert(key, value); + } else { + map.swap_remove(&key); + } +} + +#[cfg(test)] +mod tests { + use crate::{FullNodeSimulator, to_puzzle}; + + #[test] + fn apply_then_revert_restores_exact_chain_state() { + let mut sim = FullNodeSimulator::new(); + let (puzzle_hash, _) = to_puzzle(1).unwrap(); + let manual_coin = sim.new_coin(puzzle_hash, 100); + let before = sim.state.clone(); + + sim.farm_block(1); + let reverted = sim.state.revert_tip().unwrap().unwrap(); + + assert_eq!(reverted.record.height, before.height + 1); + assert_eq!(sim.state.height, before.height); + assert_eq!(sim.state.next_timestamp, before.next_timestamp); + assert_eq!(sim.state.header_hashes, before.header_hashes); + assert_eq!( + sim.state.blocks.keys().collect::>(), + before.blocks.keys().collect::>() + ); + assert_eq!(sim.state.coins, before.coins); + assert_eq!(sim.state.coin_spends, before.coin_spends); + assert_eq!(sim.state.coin_hints, before.coin_hints); + assert_eq!( + sim.state.coins.get(&manual_coin.coin_id()), + before.coins.get(&manual_coin.coin_id()) + ); + } + + #[test] + fn rejected_block_delta_does_not_mutate_chain_state() { + let mut sim = FullNodeSimulator::new(); + sim.farm_block(1); + let mut block = sim.state.revert_tip().unwrap().unwrap(); + block.record.prev_hash = block.record.header_hash; + let before = sim.state.clone(); + + assert!(sim.state.apply_block(block).is_err()); + assert_eq!(sim.state.height, before.height); + assert_eq!(sim.state.next_timestamp, before.next_timestamp); + assert_eq!(sim.state.header_hashes, before.header_hashes); + assert_eq!( + sim.state.blocks.keys().collect::>(), + before.blocks.keys().collect::>() + ); + assert_eq!(sim.state.coins, before.coins); + assert_eq!(sim.state.coin_spends, before.coin_spends); + assert_eq!(sim.state.coin_hints, before.coin_hints); + } +} diff --git a/crates/chia-sdk-test/src/full_node_simulator/state_dump.rs b/crates/chia-sdk-test/src/full_node_simulator/state_dump.rs new file mode 100644 index 000000000..5d02ea56a --- /dev/null +++ b/crates/chia-sdk-test/src/full_node_simulator/state_dump.rs @@ -0,0 +1,159 @@ +mod restore; + +use chia_protocol::{BlockRecord, Bytes32, CoinSpend, SpendBundle}; +use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; + +use crate::{SimulatorError, StateDumpError}; + +use super::{FullNodeSimulator, SimBlock, SimCoinRecord}; + +const FORMAT: &str = "chia-wallet-sdk/full-node-simulator-state"; +const VERSION: u32 = 1; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct SimulatorStateDump { + format: String, + version: u32, + rng: rand_chacha::ChaCha8Rng, + height: u32, + next_timestamp: u64, + header_hashes: Vec, + blocks: Vec, + coins: Vec<(Bytes32, SimCoinRecord)>, + coin_spends: Vec<(Bytes32, CoinSpend)>, + coin_hints: Vec<(Bytes32, Bytes32)>, + farming_puzzle_hash: Bytes32, + master_secret_key: [u8; 32], + prefarm_puzzle_hash: Bytes32, + node_id: Bytes32, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct DumpBlock { + header_hash: Bytes32, + record: BlockRecord, + additions: Vec, + removals: Vec, + spends: Vec, + transactions: Vec, + previous_coin_records: Vec<(Bytes32, SimCoinRecord)>, + added_hints: Vec, +} + +impl FullNodeSimulator { + pub fn dump_state(&self) -> Result { + let canonical_coin_ids = self.canonical_coin_ids()?; + let blocks = self + .state + .header_hashes + .iter() + .map(|header_hash| { + let block = self + .state + .blocks + .get(header_hash) + .ok_or(StateDumpError::MissingCanonicalBlock(*header_hash))?; + Ok(DumpBlock::from_block(*header_hash, block)) + }) + .collect::, SimulatorError>>()?; + + let coins = self + .state + .coins + .iter() + .filter(|(coin_id, _)| canonical_coin_ids.contains_key(*coin_id)) + .map(|(coin_id, record)| (*coin_id, *record)) + .collect(); + let coin_spends = self + .state + .coin_spends + .iter() + .filter(|(coin_id, _)| canonical_coin_ids.contains_key(*coin_id)) + .map(|(coin_id, spend)| (*coin_id, spend.clone())) + .collect(); + let coin_hints = self + .state + .coin_hints + .iter() + .filter(|(coin_id, _)| canonical_coin_ids.contains_key(*coin_id)) + .map(|(coin_id, hint)| (*coin_id, *hint)) + .collect(); + + let dump = SimulatorStateDump { + format: FORMAT.to_string(), + version: VERSION, + rng: self.rng.clone(), + height: self.state.height, + next_timestamp: self.state.next_timestamp, + header_hashes: self.state.header_hashes.clone(), + blocks, + coins, + coin_spends, + coin_hints, + farming_puzzle_hash: self.farming_puzzle_hash, + master_secret_key: self.master_secret_key.to_bytes(), + prefarm_puzzle_hash: self.prefarm_puzzle_hash, + node_id: self.node_id, + }; + + serde_json::to_string_pretty(&dump) + .map_err(|error| StateDumpError::Serialize(error.to_string()).into()) + } + + pub fn restore_state(&mut self, state_json: &str) -> Result<(), SimulatorError> { + let dump: SimulatorStateDump = serde_json::from_str(state_json) + .map_err(|error| StateDumpError::Deserialize(error.to_string()))?; + let restored = FullNodeSimulator::try_from(dump)?; + *self = restored; + Ok(()) + } + + fn canonical_coin_ids(&self) -> Result, SimulatorError> { + let mut coin_ids = IndexMap::new(); + for header_hash in &self.state.header_hashes { + let Some(block) = self.state.blocks.get(header_hash) else { + return Err(StateDumpError::MissingCanonicalBlock(*header_hash).into()); + }; + let mut block_coin_ids = coin_ids.clone(); + for addition in &block.additions { + block_coin_ids.insert(*addition, ()); + } + for removal in &block.removals { + if !block_coin_ids.contains_key(removal) { + return Err(StateDumpError::UnsupportedManualCoinSpend(*removal).into()); + } + } + coin_ids = block_coin_ids; + } + Ok(coin_ids) + } +} + +impl DumpBlock { + fn from_block(header_hash: Bytes32, block: &SimBlock) -> Self { + Self { + header_hash, + record: block.record.clone(), + additions: block.additions.clone(), + removals: block.removals.clone(), + spends: block.spends.clone(), + transactions: block.transactions.clone(), + previous_coin_records: block + .delta + .coins + .iter() + .filter_map(|change| change.before.map(|record| (change.coin_id, record))) + .collect(), + added_hints: block + .delta + .hints + .iter() + .filter(|change| change.before.is_none() && change.after.is_some()) + .map(|change| change.coin_id) + .collect(), + } + } +} diff --git a/crates/chia-sdk-test/src/full_node_simulator/state_dump/restore.rs b/crates/chia-sdk-test/src/full_node_simulator/state_dump/restore.rs new file mode 100644 index 000000000..50bd29413 --- /dev/null +++ b/crates/chia-sdk-test/src/full_node_simulator/state_dump/restore.rs @@ -0,0 +1,332 @@ +use chia_protocol::{Bytes32, CoinSpend}; +use indexmap::{IndexMap, IndexSet}; + +use crate::{SimulatorError, StateDumpError}; + +use super::super::{ + FullNodeSimulator, SimBlock, SimCoinRecord, ValidatedBundle, + state::{BlockDelta, ChainState, CoinChange, HintChange, SpendChange}, + validation::ValidationOverlay, +}; +use super::{DumpBlock, FORMAT, SimulatorStateDump, VERSION}; + +impl DumpBlock { + fn into_block( + self, + simulator: &FullNodeSimulator, + final_coins: &IndexMap, + final_spends: &IndexMap, + final_hints: &IndexMap, + ) -> Result<(Bytes32, SimBlock), SimulatorError> { + let state = &simulator.state; + if self.header_hash != self.record.header_hash { + return Err(StateDumpError::BlockHeaderMismatch { + block_key: self.header_hash, + record_header_hash: self.record.header_hash, + } + .into()); + } + + let height = self.record.height; + let timestamp = self + .record + .timestamp + .ok_or(StateDumpError::MissingBlockTimestamp)?; + validate_unique_ids("block additions", &self.additions)?; + validate_unique_ids("block removals", &self.removals)?; + validate_unique_ids("block added hints", &self.added_hints)?; + let (derived_hints, reward_coin_ids) = self.validate_contents(simulator)?; + + let mut changed_coin_ids = IndexSet::new(); + for coin_id in self.additions.iter().chain(&self.removals) { + changed_coin_ids.insert(*coin_id); + } + let previous_coin_records = + collect_unique_pairs("block previous coin records", self.previous_coin_records)?; + for coin_id in &changed_coin_ids { + if state.coins.get(coin_id) != previous_coin_records.get(coin_id) { + return Err(StateDumpError::PreviousCoinRecordMismatch(*coin_id).into()); + } + } + if previous_coin_records + .keys() + .any(|coin_id| !changed_coin_ids.contains(coin_id)) + { + return Err(StateDumpError::PreviousCoinRecordForUnchangedCoin.into()); + } + + let coins = changed_coin_ids + .into_iter() + .map(|coin_id| { + let before = state.coins.get(&coin_id).copied(); + let mut after = if self.additions.contains(&coin_id) { + let mut record = final_coins + .get(&coin_id) + .copied() + .ok_or(StateDumpError::MissingFinalCoinRecord(coin_id))?; + if record.coin.coin_id() != coin_id + || record.coinbase != reward_coin_ids.contains(&coin_id) + || record.confirmed_block_index != height + || record.timestamp != timestamp + { + return Err(StateDumpError::InconsistentAddedCoinRecord(height).into()); + } + record.spent_block_index = None; + Some(record) + } else { + before + }; + if self.removals.contains(&coin_id) { + let record = after + .as_mut() + .ok_or(StateDumpError::UnknownRemovedCoin(coin_id))?; + record.spent_block_index = Some(height); + } + Ok(CoinChange { + coin_id, + before, + after, + }) + }) + .collect::, SimulatorError>>()?; + + let mut spend_ids = IndexSet::new(); + let spends = self + .spends + .iter() + .cloned() + .map(|spend| { + let coin_id = spend.coin.coin_id(); + if !spend_ids.insert(coin_id) { + return Err(StateDumpError::DuplicateBlockSpend.into()); + } + if !self.removals.contains(&coin_id) { + return Err(StateDumpError::SpendNotRemoval.into()); + } + if final_spends.get(&coin_id) != Some(&spend) { + return Err(StateDumpError::CoinSpendIndexMismatch(coin_id).into()); + } + Ok(SpendChange { + coin_id, + before: state.coin_spends.get(&coin_id).cloned(), + after: Some(spend), + }) + }) + .collect::, SimulatorError>>()?; + + let hints = self + .added_hints + .iter() + .map(|coin_id| { + if !self.additions.contains(coin_id) { + return Err(StateDumpError::HintNotAddition.into()); + } + let hint = final_hints + .get(coin_id) + .copied() + .ok_or(StateDumpError::MissingFinalHint(*coin_id))?; + if derived_hints.get(coin_id) != Some(&hint) { + return Err(StateDumpError::CoinHintMismatch(*coin_id).into()); + } + Ok(HintChange { + coin_id: *coin_id, + before: state.coin_hints.get(coin_id).copied(), + after: Some(hint), + }) + }) + .collect::, SimulatorError>>()?; + + Ok(( + self.header_hash, + SimBlock { + record: self.record, + additions: self.additions, + removals: self.removals, + spends: self.spends, + transactions: self.transactions, + delta: BlockDelta { + coins, + spends, + hints, + }, + }, + )) + } + + fn validate_contents( + &self, + simulator: &FullNodeSimulator, + ) -> Result<(IndexMap, IndexSet), SimulatorError> { + let mut additions = IndexSet::new(); + let mut reward_coin_ids = IndexSet::new(); + if let Some(rewards) = &self.record.reward_claims_incorporated { + for coin in rewards { + let coin_id = coin.coin_id(); + additions.insert(coin_id); + reward_coin_ids.insert(coin_id); + } + } + let mut removals = IndexSet::new(); + let mut spends = IndexMap::new(); + let mut hints = IndexMap::new(); + let mut overlay = ValidationOverlay::default(); + + for transaction in &self.transactions { + let validated = simulator.validate_bundle_in_block(transaction.clone(), &overlay)?; + overlay.apply(&validated); + for (coin, hint) in &validated.additions { + let coin_id = coin.coin_id(); + if additions.insert(coin_id) + && let Some(hint) = *hint + { + hints.insert(coin_id, hint); + } + } + for coin_id in &validated.removals { + removals.insert(*coin_id); + } + for (coin_id, spend) in &validated.spends { + spends + .entry(*coin_id) + .or_insert_with(|| spend.coin_spend.clone()); + } + } + + if additions.iter().copied().collect::>() != self.additions { + return Err(StateDumpError::BlockAdditionsMismatch.into()); + } + if removals.iter().copied().collect::>() != self.removals { + return Err(StateDumpError::BlockRemovalsMismatch.into()); + } + if spends.into_values().collect::>() != self.spends { + return Err(StateDumpError::BlockSpendsMismatch.into()); + } + if hints.keys().copied().collect::>() != self.added_hints { + return Err(StateDumpError::BlockHintsMismatch.into()); + } + Ok((hints, reward_coin_ids)) + } +} + +fn validate_unique_ids(label: &'static str, ids: &[Bytes32]) -> Result<(), SimulatorError> { + let mut unique = IndexSet::new(); + if ids.iter().any(|id| !unique.insert(*id)) { + return Err(StateDumpError::DuplicateKey(label).into()); + } + Ok(()) +} + +fn collect_unique_pairs( + label: &'static str, + pairs: Vec<(Bytes32, T)>, +) -> Result, SimulatorError> { + let mut values = IndexMap::new(); + for (key, value) in pairs { + if values.insert(key, value).is_some() { + return Err(StateDumpError::DuplicateKey(label).into()); + } + } + Ok(values) +} + +fn validate_index( + expected: &IndexMap, + actual: &IndexMap, + label: &'static str, +) -> Result<(), SimulatorError> { + if expected.len() != actual.len() + || expected + .iter() + .any(|(coin_id, value)| actual.get(coin_id) != Some(value)) + { + return Err(StateDumpError::SerializedIndexMismatch(label).into()); + } + Ok(()) +} + +impl TryFrom for FullNodeSimulator { + type Error = SimulatorError; + + fn try_from(dump: SimulatorStateDump) -> Result { + if dump.format != FORMAT { + return Err(StateDumpError::UnsupportedFormat(dump.format).into()); + } + if dump.version != VERSION { + return Err(StateDumpError::UnsupportedVersion(dump.version).into()); + } + if dump.height as usize != dump.header_hashes.len() { + return Err(StateDumpError::HeightHeaderCountMismatch { + height: dump.height, + header_count: dump.header_hashes.len(), + } + .into()); + } + if dump.blocks.len() != dump.header_hashes.len() { + return Err(StateDumpError::HeaderBlockCountMismatch { + header_count: dump.header_hashes.len(), + block_count: dump.blocks.len(), + } + .into()); + } + validate_unique_ids("canonical header hashes", &dump.header_hashes)?; + + let coins = collect_unique_pairs("serialized coin records", dump.coins)?; + for (coin_id, record) in &coins { + if record.coin.coin_id() != *coin_id { + return Err(StateDumpError::CoinRecordKeyMismatch(*coin_id).into()); + } + } + let coin_spends = collect_unique_pairs("serialized coin spends", dump.coin_spends)?; + for (coin_id, spend) in &coin_spends { + if spend.coin.coin_id() != *coin_id { + return Err(StateDumpError::CoinSpendKeyMismatch(*coin_id).into()); + } + } + let coin_hints = collect_unique_pairs("serialized coin hints", dump.coin_hints)?; + let master_secret_key = chia_bls::SecretKey::from_bytes(&dump.master_secret_key) + .map_err(|error| StateDumpError::InvalidMasterSecretKey(error.to_string()))?; + + let first_timestamp = dump + .blocks + .first() + .and_then(|block| block.record.timestamp) + .ok_or(StateDumpError::MissingGenesisTimestamp)?; + let mut simulator = Self { + rng: dump.rng, + state: ChainState::new( + 0, + first_timestamp, + Vec::new(), + IndexMap::new(), + IndexMap::new(), + IndexMap::new(), + IndexMap::new(), + ), + orphaned_blocks: IndexMap::new(), + mempool: IndexMap::::new(), + farming_puzzle_hash: dump.farming_puzzle_hash, + master_secret_key, + prefarm_puzzle_hash: dump.prefarm_puzzle_hash, + node_id: dump.node_id, + events: Vec::new(), + }; + for (expected_header, block) in dump.header_hashes.iter().zip(dump.blocks) { + if block.header_hash != *expected_header { + return Err(StateDumpError::CanonicalBlockOrderMismatch.into()); + } + let (_, block) = block.into_block(&simulator, &coins, &coin_spends, &coin_hints)?; + simulator.state.apply_block(block)?; + } + if simulator.state.height != dump.height { + return Err(StateDumpError::ReplayedHeightMismatch.into()); + } + if simulator.state.next_timestamp != dump.next_timestamp { + return Err(StateDumpError::NextTimestampMismatch.into()); + } + validate_index(&coins, &simulator.state.coins, "coin records")?; + validate_index(&coin_spends, &simulator.state.coin_spends, "coin spends")?; + validate_index(&coin_hints, &simulator.state.coin_hints, "coin hints")?; + + Ok(simulator) + } +} diff --git a/crates/chia-sdk-test/src/full_node_simulator/tests.rs b/crates/chia-sdk-test/src/full_node_simulator/tests.rs new file mode 100644 index 000000000..96c442594 --- /dev/null +++ b/crates/chia-sdk-test/src/full_node_simulator/tests.rs @@ -0,0 +1,4 @@ +#[cfg(feature = "serde")] +mod dump_restore; +mod integration; +mod mempool; diff --git a/crates/chia-sdk-test/src/full_node_simulator/tests/dump_restore.rs b/crates/chia-sdk-test/src/full_node_simulator/tests/dump_restore.rs new file mode 100644 index 000000000..fdbc776a3 --- /dev/null +++ b/crates/chia-sdk-test/src/full_node_simulator/tests/dump_restore.rs @@ -0,0 +1,420 @@ +use chia_bls::Signature; +use chia_protocol::{Bytes32, Coin, CoinSpend, Program, SpendBundle}; +use chia_sdk_types::conditions::{CreateCoin, Memos}; +use clvmr::NodePtr; + +use crate::{FullNodeSimulator, to_program, to_puzzle}; + +fn spend_to_child( + coin: Coin, + puzzle_reveal: Program, + puzzle_hash: Bytes32, + amount: u64, +) -> anyhow::Result { + Ok(SpendBundle::new( + vec![CoinSpend::new( + coin, + puzzle_reveal, + to_program([CreateCoin::::new(puzzle_hash, amount, Memos::None)])?, + )], + Signature::default(), + )) +} + +fn simulator_with_spend_and_hint() -> anyhow::Result { + let mut sim = FullNodeSimulator::with_seed(123); + let (puzzle_hash, puzzle_reveal) = to_puzzle(1)?; + sim.set_farming_ph(puzzle_hash); + let parent = sim.farm_block(1)[0] + .reward_claims_incorporated + .as_ref() + .unwrap()[0]; + let hint = Bytes32::new([7; 32]); + let spend = SpendBundle::new( + vec![CoinSpend::new( + parent, + puzzle_reveal, + to_program([CreateCoin::new(puzzle_hash, 99, Memos::Some([hint]))])?, + )], + Signature::default(), + ); + assert!(sim.push_tx(spend).success); + sim.farm_block(1); + Ok(sim) +} + +fn assert_restore_rejected(value: &serde_json::Value) -> anyhow::Result<()> { + let mut restored = FullNodeSimulator::with_seed(999); + assert!( + restored + .restore_state(&serde_json::to_string(value)?) + .is_err() + ); + Ok(()) +} + +#[test] +fn dump_restore_accepts_canonical_ephemeral_spends() -> anyhow::Result<()> { + let mut sim = FullNodeSimulator::new(); + let (puzzle_hash, puzzle_reveal) = to_puzzle(1)?; + sim.set_farming_ph(puzzle_hash); + let parent = sim.farm_block(1)[0] + .reward_claims_incorporated + .as_ref() + .unwrap()[0]; + let child = Coin::new(parent.coin_id(), puzzle_hash, 99); + let grandchild = Coin::new(child.coin_id(), puzzle_hash, 98); + + let parent_spend = spend_to_child(parent, puzzle_reveal.clone(), puzzle_hash, 99)?; + let child_spend = CoinSpend::new( + child, + puzzle_reveal, + to_program([CreateCoin::::new(puzzle_hash, 98, Memos::None)])?, + ); + let spend_bundle = SpendBundle::new( + vec![parent_spend.coin_spends[0].clone(), child_spend], + Signature::default(), + ); + assert!(sim.push_tx(spend_bundle).success); + sim.farm_block(1); + + let state = sim.dump_state()?; + let mut restored = FullNodeSimulator::new(); + restored.restore_state(&state)?; + + let child_record = restored + .get_coin_record_by_name(child.coin_id()) + .coin_record + .unwrap(); + assert!(child_record.spent); + assert_eq!(child_record.spent_block_index, 3); + let grandchild_record = restored + .get_coin_record_by_name(grandchild.coin_id()) + .coin_record + .unwrap(); + assert!(!grandchild_record.spent); + assert_eq!(grandchild_record.confirmed_block_index, 3); + Ok(()) +} + +#[test] +fn dump_restore_accepts_separate_parent_child_bundles_in_one_block() -> anyhow::Result<()> { + let mut sim = FullNodeSimulator::new(); + let (puzzle_hash, puzzle_reveal) = to_puzzle(1)?; + sim.set_farming_ph(puzzle_hash); + let parent = sim.farm_block(1)[0] + .reward_claims_incorporated + .as_ref() + .unwrap()[0]; + let child = Coin::new(parent.coin_id(), puzzle_hash, 99); + let grandchild = Coin::new(child.coin_id(), puzzle_hash, 98); + + assert!( + sim.push_tx(spend_to_child( + parent, + puzzle_reveal.clone(), + puzzle_hash, + 99 + )?) + .success + ); + assert!( + sim.push_tx(spend_to_child(child, puzzle_reveal, puzzle_hash, 98)?) + .success + ); + sim.farm_block(1); + + let state = sim.dump_state()?; + let value: serde_json::Value = serde_json::from_str(&state)?; + assert_eq!(value["version"], 1); + let mut restored = FullNodeSimulator::new(); + restored.restore_state(&state)?; + + assert!( + restored + .get_coin_record_by_name(parent.coin_id()) + .coin_record + .unwrap() + .spent + ); + assert!( + restored + .get_coin_record_by_name(child.coin_id()) + .coin_record + .unwrap() + .spent + ); + assert!( + !restored + .get_coin_record_by_name(grandchild.coin_id()) + .coin_record + .unwrap() + .spent + ); + Ok(()) +} + +#[test] +fn dump_restore_preserves_canonical_state_and_future_rng() -> anyhow::Result<()> { + let mut sim = FullNodeSimulator::with_seed(123); + let (puzzle_hash, puzzle_reveal) = to_puzzle(1)?; + sim.set_farming_ph(puzzle_hash); + let coin = sim.farm_block(1)[0] + .reward_claims_incorporated + .as_ref() + .unwrap()[0]; + let spend_bundle = spend_to_child(coin, puzzle_reveal, puzzle_hash, 100)?; + assert!(sim.push_tx(spend_bundle.clone()).success); + sim.farm_block(1); + let spent_coin_id = spend_bundle.coin_spends[0].coin.coin_id(); + let state = sim.dump_state()?; + + let mut expected = sim.clone(); + let expected_next_block = expected.farm_block(1)[0].clone(); + let mut restored = FullNodeSimulator::with_seed(999); + restored.restore_state(&state)?; + + assert_eq!(restored.height(), sim.height()); + assert_eq!(restored.header_hash(), sim.header_hash()); + assert_eq!(restored.get_farming_ph(), sim.get_farming_ph()); + assert_eq!( + restored.get_master_secret_key().to_bytes(), + sim.get_master_secret_key().to_bytes() + ); + assert_eq!( + restored + .get_blockchain_state() + .blockchain_state + .unwrap() + .node_id, + sim.get_blockchain_state().blockchain_state.unwrap().node_id + ); + assert_eq!( + restored + .get_puzzle_and_solution(spent_coin_id, None) + .coin_solution + .unwrap() + .coin, + spend_bundle.coin_spends[0].coin + ); + assert_eq!( + restored.farm_block(1)[0].header_hash, + expected_next_block.header_hash + ); + Ok(()) +} + +#[test] +fn dump_restore_drops_pending_mempool() -> anyhow::Result<()> { + let mut sim = FullNodeSimulator::new(); + let (puzzle_hash, puzzle_reveal) = to_puzzle(1)?; + sim.set_farming_ph(puzzle_hash); + let coin = sim.farm_block(1)[0] + .reward_claims_incorporated + .as_ref() + .unwrap()[0]; + assert!( + sim.push_tx(spend_to_child(coin, puzzle_reveal, puzzle_hash, 100)?) + .success + ); + assert_eq!( + sim.get_blockchain_state() + .blockchain_state + .unwrap() + .mempool_size, + 1 + ); + + let state = sim.dump_state()?; + let mut restored = FullNodeSimulator::new(); + restored.restore_state(&state)?; + assert_eq!( + restored + .get_blockchain_state() + .blockchain_state + .unwrap() + .mempool_size, + 0 + ); + Ok(()) +} + +#[test] +fn dump_restore_drops_orphaned_blocks() -> anyhow::Result<()> { + let mut sim = FullNodeSimulator::new(); + let old_peak = sim.farm_block(1)[0].header_hash; + sim.reorg_blocks(1, 1); + assert!(sim.get_block_record(old_peak).block_record.is_some()); + + let state = sim.dump_state()?; + let mut restored = FullNodeSimulator::new(); + restored.restore_state(&state)?; + assert!(restored.get_block_record(old_peak).block_record.is_none()); + assert_eq!(restored.header_hash(), sim.header_hash()); + Ok(()) +} + +#[test] +fn dump_restore_drops_unspent_manual_coins() -> anyhow::Result<()> { + let mut sim = FullNodeSimulator::new(); + let (puzzle_hash, _) = to_puzzle(1)?; + let manual_coin = sim.new_coin(puzzle_hash, 100); + + let state = sim.dump_state()?; + let mut restored = FullNodeSimulator::new(); + restored.restore_state(&state)?; + assert!( + restored + .get_coin_record_by_name(manual_coin.coin_id()) + .coin_record + .is_none() + ); + Ok(()) +} + +#[test] +fn dump_fails_when_canonical_chain_spends_manual_coin() -> anyhow::Result<()> { + let mut sim = FullNodeSimulator::new(); + let (puzzle_hash, puzzle_reveal) = to_puzzle(1)?; + let manual_coin = sim.new_coin(puzzle_hash, 100); + assert!( + sim.push_tx(spend_to_child(manual_coin, puzzle_reveal, puzzle_hash, 99)?) + .success + ); + sim.farm_block(1); + + let error = sim.dump_state().unwrap_err().to_string(); + assert!(error.contains("unsupported manual coin")); + Ok(()) +} + +#[test] +fn restore_rejects_invalid_state_atomically() -> anyhow::Result<()> { + let mut source = FullNodeSimulator::with_seed(123); + source.farm_block(2); + let mut value: serde_json::Value = serde_json::from_str(&source.dump_state()?)?; + value["format"] = serde_json::Value::String("wrong".to_string()); + let invalid_state = serde_json::to_string(&value)?; + + let mut target = FullNodeSimulator::with_seed(999); + let original_height = target.height(); + let original_peak = target.header_hash(); + assert!(target.restore_state(&invalid_state).is_err()); + assert_eq!(target.height(), original_height); + assert_eq!(target.header_hash(), original_peak); + assert!(target.restore_state("{").is_err()); + assert_eq!(target.height(), original_height); + assert_eq!(target.header_hash(), original_peak); + Ok(()) +} + +#[test] +fn restore_clears_event_queue() -> anyhow::Result<()> { + let mut sim = FullNodeSimulator::new(); + sim.farm_block(1); + let state = sim.dump_state()?; + + let mut restored = FullNodeSimulator::new(); + restored.restore_state(&state)?; + assert!(restored.drain_events().is_empty()); + Ok(()) +} + +#[test] +fn restore_accepts_valid_v1_state() -> anyhow::Result<()> { + let source = simulator_with_spend_and_hint()?; + let state = source.dump_state()?; + let value: serde_json::Value = serde_json::from_str(&state)?; + assert_eq!(value["version"], 1); + + let mut restored = FullNodeSimulator::new(); + restored.restore_state(&state)?; + assert_eq!(restored.height(), source.height()); + assert_eq!(restored.header_hash(), source.header_hash()); + Ok(()) +} + +#[test] +fn restore_rejects_tampered_coin_records() -> anyhow::Result<()> { + let source = simulator_with_spend_and_hint()?; + let mut value: serde_json::Value = serde_json::from_str(&source.dump_state()?)?; + let timestamp = value["coins"][0][1]["timestamp"].as_u64().unwrap(); + value["coins"][0][1]["timestamp"] = (timestamp + 1).into(); + + assert_restore_rejected(&value) +} + +#[test] +fn restore_rejects_tampered_spends_and_hints() -> anyhow::Result<()> { + let source = simulator_with_spend_and_hint()?; + let value: serde_json::Value = serde_json::from_str(&source.dump_state()?)?; + + let mut tampered_spend = value.clone(); + tampered_spend["coin_spends"][0][0] = serde_json::to_value(Bytes32::default())?; + assert_restore_rejected(&tampered_spend)?; + + let mut tampered_hint = value; + tampered_hint["coin_hints"][0][1] = serde_json::to_value(Bytes32::default())?; + assert_restore_rejected(&tampered_hint) +} + +#[test] +fn restore_rejects_broken_order_links_heights_and_timestamps() -> anyhow::Result<()> { + let source = simulator_with_spend_and_hint()?; + let value: serde_json::Value = serde_json::from_str(&source.dump_state()?)?; + + let mut broken_order = value.clone(); + broken_order["blocks"].as_array_mut().unwrap().swap(0, 1); + assert_restore_rejected(&broken_order)?; + + let mut broken_link = value.clone(); + broken_link["blocks"][1]["record"]["prev_hash"] = serde_json::to_value(Bytes32::new([9; 32]))?; + assert_restore_rejected(&broken_link)?; + + let mut broken_height = value.clone(); + broken_height["blocks"][1]["record"]["height"] = 99.into(); + assert_restore_rejected(&broken_height)?; + + let mut broken_timestamp = value; + let timestamp = broken_timestamp["blocks"][1]["record"]["timestamp"] + .as_u64() + .unwrap(); + broken_timestamp["blocks"][1]["record"]["timestamp"] = (timestamp + 1).into(); + assert_restore_rejected(&broken_timestamp) +} + +#[test] +fn restore_rejects_duplicate_serialized_keys() -> anyhow::Result<()> { + let source = simulator_with_spend_and_hint()?; + let value: serde_json::Value = serde_json::from_str(&source.dump_state()?)?; + + for field in ["coins", "coin_spends", "coin_hints"] { + let mut duplicate = value.clone(); + let entry = duplicate[field][0].clone(); + duplicate[field].as_array_mut().unwrap().push(entry); + assert_restore_rejected(&duplicate)?; + } + + let mut duplicate_header = value; + let header = duplicate_header["header_hashes"][0].clone(); + duplicate_header["header_hashes"][1] = header; + assert_restore_rejected(&duplicate_header) +} + +#[test] +fn tampered_restore_failure_is_atomic() -> anyhow::Result<()> { + let source = simulator_with_spend_and_hint()?; + let mut value: serde_json::Value = serde_json::from_str(&source.dump_state()?)?; + value["blocks"][1]["record"]["height"] = 99.into(); + + let mut target = FullNodeSimulator::with_seed(999); + target.farm_block(2); + let before = target.dump_state()?; + assert!( + target + .restore_state(&serde_json::to_string(&value)?) + .is_err() + ); + assert_eq!(target.dump_state()?, before); + Ok(()) +} diff --git a/crates/chia-sdk-test/src/full_node_simulator/tests/integration.rs b/crates/chia-sdk-test/src/full_node_simulator/tests/integration.rs new file mode 100644 index 000000000..85bb0c504 --- /dev/null +++ b/crates/chia-sdk-test/src/full_node_simulator/tests/integration.rs @@ -0,0 +1,495 @@ +use chia_bls::{SecretKey, Signature, master_to_wallet_hardened}; +use chia_protocol::{Bytes32, Coin, CoinSpend, Program, SpendBundle}; +use chia_puzzle_types::{DeriveSynthetic, standard::StandardArgs}; +use chia_sdk_types::conditions::{CreateCoin, Memos}; +use clvmr::NodePtr; + +use crate::{FullNodeSimulator, FullNodeSimulatorEvent, to_program, to_puzzle}; + +use super::super::BLOCK_REWARD_AMOUNT; + +fn spend_to_child( + coin: Coin, + puzzle_reveal: Program, + puzzle_hash: Bytes32, + amount: u64, +) -> anyhow::Result { + Ok(SpendBundle::new( + vec![CoinSpend::new( + coin, + puzzle_reveal, + to_program([CreateCoin::::new(puzzle_hash, amount, Memos::None)])?, + )], + Signature::default(), + )) +} + +fn simulator_with_multiblock_parent_child() -> anyhow::Result<( + FullNodeSimulator, + SpendBundle, + SpendBundle, + Coin, + Coin, + Coin, +)> { + let mut sim = FullNodeSimulator::new(); + let (puzzle_hash, puzzle_reveal) = to_puzzle(1)?; + sim.set_farming_ph(puzzle_hash); + let parent = sim.farm_block(1)[0] + .reward_claims_incorporated + .as_ref() + .unwrap()[0]; + let child = Coin::new(parent.coin_id(), puzzle_hash, 99); + let grandchild = Coin::new(child.coin_id(), puzzle_hash, 98); + let parent_bundle = spend_to_child(parent, puzzle_reveal.clone(), puzzle_hash, 99)?; + let child_bundle = spend_to_child(child, puzzle_reveal, puzzle_hash, 98)?; + + assert!(sim.push_tx(parent_bundle.clone()).success); + sim.farm_block(1); + assert!(sim.push_tx(child_bundle.clone()).success); + sim.farm_block(1); + + Ok((sim, parent_bundle, child_bundle, parent, child, grandchild)) +} + +#[test] +fn genesis_contains_prefarm_rewards() { + let sim = FullNodeSimulator::new(); + let prefarm_puzzle_hash = sim.get_prefarm_puzzle_hash(); + assert_eq!(sim.height(), 1); + assert_eq!(sim.get_farming_ph(), prefarm_puzzle_hash); + + let prefarm_records = sim + .get_coin_records_by_puzzle_hash(prefarm_puzzle_hash, None, None, None) + .coin_records + .unwrap(); + assert_eq!(prefarm_records.len(), 2); + assert!(prefarm_records.iter().all(|record| record.coinbase)); + assert!(prefarm_records.iter().all(|record| !record.spent)); + assert_eq!( + prefarm_records + .iter() + .map(|record| u128::from(record.coin.amount)) + .sum::(), + 21_000_000_000_000_000_000_u128 + ); + + let genesis = sim.get_block_record_by_height(1).block_record.unwrap(); + let reward_claims = genesis.reward_claims_incorporated.unwrap(); + assert_eq!(reward_claims.len(), 2); + assert_eq!( + reward_claims + .iter() + .map(|coin| u128::from(coin.amount)) + .sum::(), + 21_000_000_000_000_000_000_u128 + ); + assert!( + reward_claims + .iter() + .all(|coin| coin.puzzle_hash == prefarm_puzzle_hash) + ); +} + +#[test] +fn explicit_secret_key_derives_prefarm_wallet_index_one() { + let root_secret_key = SecretKey::from_seed(&[42; 32]); + let sim = FullNodeSimulator::with_secret_key(root_secret_key.clone()); + let expected_secret_key = master_to_wallet_hardened(&root_secret_key, 1).derive_synthetic(); + let expected_puzzle_hash = + StandardArgs::curry_tree_hash(expected_secret_key.public_key()).into(); + + assert_eq!(sim.get_prefarm_puzzle_hash(), expected_puzzle_hash); +} + +#[test] +fn push_tx_waits_for_manual_farming() -> anyhow::Result<()> { + let mut sim = FullNodeSimulator::new(); + let (puzzle_hash, puzzle_reveal) = to_puzzle(1)?; + let coin = sim.new_coin(puzzle_hash, 100); + let spend_bundle = spend_to_child(coin, puzzle_reveal, puzzle_hash, 99)?; + + assert!(sim.push_tx(spend_bundle).success); + assert_eq!( + sim.get_blockchain_state() + .blockchain_state + .unwrap() + .mempool_size, + 1 + ); + assert_eq!(sim.height(), 1); + + sim.farm_block(1); + assert_eq!( + sim.get_blockchain_state() + .blockchain_state + .unwrap() + .mempool_size, + 0 + ); + let record = sim + .get_coin_record_by_name(coin.coin_id()) + .coin_record + .unwrap(); + assert!(record.spent); + assert_eq!(record.spent_block_index, 2); + assert_eq!(sim.height(), 2); + assert_eq!( + sim.get_block_spends(sim.header_hash()) + .block_spends + .unwrap() + .len(), + 1 + ); + Ok(()) +} + +#[test] +fn farm_block_includes_mempool_and_emits_event() -> anyhow::Result<()> { + let mut sim = FullNodeSimulator::new(); + let (puzzle_hash, puzzle_reveal) = to_puzzle(1)?; + let coin = sim.new_coin(puzzle_hash, 100); + let child = Coin::new(coin.coin_id(), puzzle_hash, 99); + let spend_bundle = spend_to_child(coin, puzzle_reveal, puzzle_hash, 99)?; + assert!(sim.push_tx(spend_bundle).success); + + let records = sim.farm_block(1); + assert_eq!(records.len(), 1); + assert_eq!(records[0].height, 2); + let reward_claims = records[0].reward_claims_incorporated.clone().unwrap(); + assert_eq!(reward_claims.len(), 1); + assert_eq!(reward_claims[0].amount, BLOCK_REWARD_AMOUNT); + assert_eq!(reward_claims[0].puzzle_hash, sim.get_prefarm_puzzle_hash()); + assert_eq!( + sim.get_blockchain_state() + .blockchain_state + .unwrap() + .mempool_size, + 0 + ); + + let spent = sim + .get_coin_record_by_name(coin.coin_id()) + .coin_record + .unwrap(); + assert!(spent.spent); + assert_eq!(spent.spent_block_index, 2); + let created = sim + .get_coin_record_by_name(child.coin_id()) + .coin_record + .unwrap(); + assert!(!created.spent); + assert_eq!(created.confirmed_block_index, 2); + assert_eq!( + sim.get_block_spends(records[0].header_hash) + .block_spends + .unwrap() + .len(), + 1 + ); + + let events = sim.drain_events(); + assert!(matches!( + events.as_slice(), + [FullNodeSimulatorEvent::Block { + height: 2, + additions, + .. + }] if additions.iter().any(|record| record.coin.coin_id() == reward_claims[0].coin_id()) + )); + Ok(()) +} + +#[test] +fn set_farming_ph_changes_future_reward_destination() { + let mut sim = FullNodeSimulator::new(); + let (new_farming_ph, _) = to_puzzle(99).unwrap(); + sim.set_farming_ph(new_farming_ph); + + let record = sim.farm_block(1).pop().unwrap(); + let reward_claims = record.reward_claims_incorporated.unwrap(); + assert_eq!(reward_claims.len(), 1); + assert_eq!(reward_claims[0].amount, BLOCK_REWARD_AMOUNT); + assert_eq!(reward_claims[0].puzzle_hash, new_farming_ph); +} + +#[test] +fn push_tx_accepts_ephemeral_spends_in_same_bundle() -> anyhow::Result<()> { + let mut sim = FullNodeSimulator::new(); + let (puzzle_hash, puzzle_reveal) = to_puzzle(1)?; + let parent = sim.new_coin(puzzle_hash, 100); + let child = Coin::new(parent.coin_id(), puzzle_hash, 99); + let grandchild = Coin::new(child.coin_id(), puzzle_hash, 98); + + let parent_spend = spend_to_child(parent, puzzle_reveal.clone(), puzzle_hash, 99)?; + let child_spend = CoinSpend::new( + child, + puzzle_reveal, + to_program([CreateCoin::::new(puzzle_hash, 98, Memos::None)])?, + ); + let spend_bundle = SpendBundle::new( + vec![parent_spend.coin_spends[0].clone(), child_spend], + Signature::default(), + ); + assert!(sim.push_tx(spend_bundle).success); + sim.farm_block(1); + + let parent_record = sim + .get_coin_record_by_name(parent.coin_id()) + .coin_record + .unwrap(); + assert!(parent_record.spent); + let child_record = sim + .get_coin_record_by_name(child.coin_id()) + .coin_record + .unwrap(); + assert!(child_record.spent); + assert_eq!(child_record.confirmed_block_index, 2); + assert_eq!(child_record.spent_block_index, 2); + let grandchild_record = sim + .get_coin_record_by_name(grandchild.coin_id()) + .coin_record + .unwrap(); + assert!(!grandchild_record.spent); + assert_eq!(grandchild_record.confirmed_block_index, 2); + + sim.revert_blocks(1); + let restored_parent = sim + .get_coin_record_by_name(parent.coin_id()) + .coin_record + .unwrap(); + assert!(!restored_parent.spent); + assert!( + sim.get_coin_record_by_name(child.coin_id()) + .coin_record + .is_none() + ); + assert!( + sim.get_coin_record_by_name(grandchild.coin_id()) + .coin_record + .is_none() + ); + assert!( + sim.get_puzzle_and_solution(parent.coin_id(), None) + .coin_solution + .is_none() + ); + assert!( + sim.get_puzzle_and_solution(child.coin_id(), None) + .coin_solution + .is_none() + ); + assert_eq!( + sim.get_blockchain_state() + .blockchain_state + .unwrap() + .mempool_size, + 1 + ); + Ok(()) +} + +#[test] +fn revert_removes_farmed_reward() { + let mut sim = FullNodeSimulator::new(); + let reward = sim + .farm_block(1) + .pop() + .unwrap() + .reward_claims_incorporated + .unwrap() + .pop() + .unwrap(); + assert!( + sim.get_coin_record_by_name(reward.coin_id()) + .coin_record + .is_some() + ); + + sim.revert_blocks(1); + assert!( + sim.get_coin_record_by_name(reward.coin_id()) + .coin_record + .is_none() + ); +} + +#[test] +fn revert_requeues_multiblock_parent_child_in_order() -> anyhow::Result<()> { + let (mut sim, parent_bundle, child_bundle, parent, child, grandchild) = + simulator_with_multiblock_parent_child()?; + + sim.revert_blocks(2); + + assert_eq!( + sim.mempool.keys().copied().collect::>(), + vec![parent_bundle.name(), child_bundle.name()] + ); + assert!( + !sim.get_coin_record_by_name(parent.coin_id()) + .coin_record + .unwrap() + .spent + ); + assert!( + sim.get_coin_record_by_name(child.coin_id()) + .coin_record + .is_none() + ); + + sim.farm_block(1); + assert!( + sim.get_coin_record_by_name(child.coin_id()) + .coin_record + .unwrap() + .spent + ); + assert!( + !sim.get_coin_record_by_name(grandchild.coin_id()) + .coin_record + .unwrap() + .spent + ); + Ok(()) +} + +#[test] +fn prune_mempool_retries_out_of_order_dependencies() -> anyhow::Result<()> { + let (mut sim, parent_bundle, child_bundle, _, _, _) = simulator_with_multiblock_parent_child()?; + sim.revert_blocks(2); + sim.mempool.swap_indices(0, 1); + + sim.prune_mempool(); + + assert_eq!( + sim.mempool.keys().copied().collect::>(), + vec![parent_bundle.name(), child_bundle.name()] + ); + Ok(()) +} + +#[test] +fn reorg_replaces_peak_and_emits_reorg() { + let mut sim = FullNodeSimulator::new(); + let old_blocks = sim.farm_block(2); + let old_peak = old_blocks.last().unwrap().header_hash; + let old_reward = old_blocks + .last() + .unwrap() + .reward_claims_incorporated + .clone() + .unwrap() + .pop() + .unwrap(); + + let new_blocks = sim.reorg_blocks(1, 2); + assert_eq!(new_blocks.len(), 2); + assert_ne!(sim.header_hash(), old_peak); + assert_eq!(sim.height(), 4); + assert!( + sim.get_coin_record_by_name(old_reward.coin_id()) + .coin_record + .is_none() + ); + let orphan = sim.get_block_record(old_peak).block_record.unwrap(); + assert_eq!( + orphan.reward_claims_incorporated.unwrap()[0].coin_id(), + old_reward.coin_id() + ); + assert!( + new_blocks + .iter() + .all(|block| block.reward_claims_incorporated.as_ref().unwrap().len() == 1) + ); + + let events = sim.drain_events(); + assert!(events.iter().any(|event| { + matches!( + event, + FullNodeSimulatorEvent::Reorg { + old_peak_hash, + new_peak_hash, + .. + } if *old_peak_hash == old_peak && *new_peak_hash == sim.header_hash() + ) + })); +} + +#[test] +fn reorg_requeues_reverted_transactions_through_new_chain() -> anyhow::Result<()> { + let mut sim = FullNodeSimulator::new(); + let (puzzle_hash, puzzle_reveal) = to_puzzle(1)?; + let coin = sim.new_coin(puzzle_hash, 100); + let spend_bundle = spend_to_child(coin, puzzle_reveal, puzzle_hash, 99)?; + + assert!(sim.push_tx(spend_bundle).success); + sim.farm_block(1); + assert!( + sim.get_coin_record_by_name(coin.coin_id()) + .coin_record + .unwrap() + .spent + ); + + let replacement = sim.reorg_blocks(1, 1); + + assert_eq!(replacement.len(), 1); + assert!( + sim.get_coin_record_by_name(coin.coin_id()) + .coin_record + .unwrap() + .spent + ); + assert_eq!( + sim.get_block_spends(replacement[0].header_hash) + .block_spends + .unwrap() + .len(), + 1 + ); + assert_eq!( + sim.get_blockchain_state() + .blockchain_state + .unwrap() + .mempool_size, + 0 + ); + + Ok(()) +} + +#[test] +fn reorg_requeues_multiblock_parent_child_in_order() -> anyhow::Result<()> { + let (mut sim, parent_bundle, child_bundle, _, child, grandchild) = + simulator_with_multiblock_parent_child()?; + + let replacement = sim.reorg_blocks(2, 0); + + assert!(replacement.is_empty()); + assert_eq!( + sim.mempool.keys().copied().collect::>(), + vec![parent_bundle.name(), child_bundle.name()] + ); + + let replacement = sim.farm_block(1); + assert_eq!( + sim.get_block_spends(replacement[0].header_hash) + .block_spends + .unwrap() + .len(), + 2 + ); + assert!( + sim.get_coin_record_by_name(child.coin_id()) + .coin_record + .unwrap() + .spent + ); + assert!( + !sim.get_coin_record_by_name(grandchild.coin_id()) + .coin_record + .unwrap() + .spent + ); + Ok(()) +} diff --git a/crates/chia-sdk-test/src/full_node_simulator/tests/mempool.rs b/crates/chia-sdk-test/src/full_node_simulator/tests/mempool.rs new file mode 100644 index 000000000..1bc00b6c3 --- /dev/null +++ b/crates/chia-sdk-test/src/full_node_simulator/tests/mempool.rs @@ -0,0 +1,162 @@ +use chia_bls::Signature; +use chia_protocol::{Bytes32, Coin, CoinSpend, Program, SpendBundle}; +use chia_sdk_types::conditions::{CreateCoin, Memos}; +use clvmr::NodePtr; + +use crate::{FullNodeSimulator, to_program, to_puzzle}; + +fn spend_to_child( + coin: Coin, + puzzle_reveal: Program, + puzzle_hash: Bytes32, + amount: u64, +) -> anyhow::Result { + Ok(SpendBundle::new( + vec![CoinSpend::new( + coin, + puzzle_reveal, + to_program([CreateCoin::::new(puzzle_hash, amount, Memos::None)])?, + )], + Signature::default(), + )) +} + +#[test] +fn push_tx_rejects_mempool_conflict() -> anyhow::Result<()> { + let mut sim = FullNodeSimulator::new(); + let (puzzle_hash, puzzle_reveal) = to_puzzle(1)?; + let coin = sim.new_coin(puzzle_hash, 100); + let first = spend_to_child(coin, puzzle_reveal.clone(), puzzle_hash, 98)?; + let conflicting = spend_to_child(coin, puzzle_reveal, puzzle_hash, 99)?; + + assert!(sim.push_tx(first).success); + let response = sim.push_tx(conflicting); + + assert!(!response.success); + assert_eq!( + response.error.as_deref(), + Some("Validation error: MempoolConflict") + ); + assert_eq!( + sim.get_blockchain_state() + .blockchain_state + .unwrap() + .mempool_size, + 1 + ); + + Ok(()) +} + +#[test] +fn push_tx_replaces_mempool_conflict_with_higher_fee_superset() -> anyhow::Result<()> { + let mut sim = FullNodeSimulator::new(); + let (puzzle_hash, puzzle_reveal) = to_puzzle(1)?; + let coin = sim.new_coin(puzzle_hash, 100); + let first = spend_to_child(coin, puzzle_reveal.clone(), puzzle_hash, 99)?; + let replacement = spend_to_child(coin, puzzle_reveal, puzzle_hash, 98)?; + let first_tx_id = first.name(); + let replacement_tx_id = replacement.name(); + + assert!(sim.push_tx(first).success); + assert!(sim.push_tx(replacement).success); + + assert!( + sim.get_mempool_item_by_tx_id(first_tx_id) + .mempool_item + .is_none() + ); + assert!( + sim.get_mempool_item_by_tx_id(replacement_tx_id) + .mempool_item + .is_some() + ); + assert_eq!( + sim.get_blockchain_state() + .blockchain_state + .unwrap() + .mempool_size, + 1 + ); + + Ok(()) +} + +#[test] +fn push_tx_does_not_replace_conflict_that_is_not_a_superset() -> anyhow::Result<()> { + let mut sim = FullNodeSimulator::new(); + let (puzzle_hash, puzzle_reveal) = to_puzzle(1)?; + let coin_a = sim.new_coin(puzzle_hash, 100); + let coin_b = sim.new_coin(puzzle_hash, 100); + let first_spend_a = spend_to_child(coin_a, puzzle_reveal.clone(), puzzle_hash, 99)?; + let first_spend_b = spend_to_child(coin_b, puzzle_reveal.clone(), puzzle_hash, 99)?; + let first = SpendBundle::new( + vec![ + first_spend_a.coin_spends[0].clone(), + first_spend_b.coin_spends[0].clone(), + ], + Signature::default(), + ); + let conflicting = spend_to_child(coin_a, puzzle_reveal, puzzle_hash, 50)?; + + assert!(sim.push_tx(first).success); + let response = sim.push_tx(conflicting); + + assert!(!response.success); + assert_eq!( + response.error.as_deref(), + Some("Validation error: MempoolConflict") + ); + assert_eq!( + sim.get_blockchain_state() + .blockchain_state + .unwrap() + .mempool_size, + 1 + ); + + Ok(()) +} + +#[test] +fn push_tx_allows_dedup_compatible_mempool_overlap() -> anyhow::Result<()> { + let mut sim = FullNodeSimulator::new(); + let (puzzle_hash, puzzle_reveal) = to_puzzle(1)?; + let shared_coin = sim.new_coin(puzzle_hash, 100); + let extra_coin = sim.new_coin(puzzle_hash, 100); + let shared_spend = spend_to_child(shared_coin, puzzle_reveal.clone(), puzzle_hash, 100)?; + let extra_spend = spend_to_child(extra_coin, puzzle_reveal, puzzle_hash, 99)?; + let second_bundle = SpendBundle::new( + vec![ + shared_spend.coin_spends[0].clone(), + extra_spend.coin_spends[0].clone(), + ], + Signature::default(), + ); + + assert!(sim.push_tx(shared_spend).success); + assert!(sim.push_tx(second_bundle).success); + assert_eq!( + sim.get_blockchain_state() + .blockchain_state + .unwrap() + .mempool_size, + 2 + ); + + sim.farm_block(1); + let spends = sim + .get_block_spends(sim.header_hash()) + .block_spends + .unwrap(); + assert_eq!(spends.len(), 2); + assert_eq!( + spends + .iter() + .filter(|spend| spend.coin.coin_id() == shared_coin.coin_id()) + .count(), + 1 + ); + + Ok(()) +} diff --git a/crates/chia-sdk-test/src/full_node_simulator/types.rs b/crates/chia-sdk-test/src/full_node_simulator/types.rs new file mode 100644 index 000000000..36f2e4805 --- /dev/null +++ b/crates/chia-sdk-test/src/full_node_simulator/types.rs @@ -0,0 +1,92 @@ +use chia_protocol::{BlockRecord, Bytes32, Coin, CoinSpend, SpendBundle}; +use chia_sdk_coinset::{CoinRecord, MempoolItem, PushTxResponse}; +use indexmap::IndexMap; + +use crate::SimulatorError; + +use super::state::BlockDelta; + +#[derive(Debug, Clone)] +pub(super) struct SimBlock { + pub(super) record: BlockRecord, + pub(super) additions: Vec, + pub(super) removals: Vec, + pub(super) spends: Vec, + pub(super) transactions: Vec, + pub(super) delta: BlockDelta, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub(super) struct SimCoinRecord { + pub(super) coin: Coin, + pub(super) coinbase: bool, + pub(super) confirmed_block_index: u32, + pub(super) spent_block_index: Option, + pub(super) timestamp: u64, +} + +#[derive(Debug)] +pub struct FullNodeSimulatorPushTxResponse { + pub response: PushTxResponse, + pub error: Option, +} + +#[derive(Debug, Clone)] +pub(super) struct ValidatedBundle { + pub(super) spend_bundle: SpendBundle, + pub(super) removals: Vec, + pub(super) additions: Vec<(Coin, Option)>, + pub(super) spends: IndexMap, + pub(super) cost: u64, + pub(super) fee: u64, +} + +#[derive(Debug, Clone)] +pub(super) struct ValidatedSpend { + pub(super) coin_spend: CoinSpend, + pub(super) flags: u32, + pub(super) fingerprint: Option, + pub(super) additions: Vec<(Coin, Option)>, +} + +#[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum FullNodeSimulatorEvent { + Block { + height: u32, + header_hash: Bytes32, + previous_header_hash: Bytes32, + additions: Vec, + removals: Vec, + }, + Reorg { + fork_height: u32, + old_peak_hash: Bytes32, + new_peak_hash: Bytes32, + reverted_header_hashes: Vec, + new_header_hashes: Vec, + }, +} + +impl SimCoinRecord { + pub(super) fn to_coin_record(self) -> CoinRecord { + CoinRecord { + coin: self.coin, + coinbase: self.coinbase, + confirmed_block_index: self.confirmed_block_index, + spent: self.spent_block_index.is_some(), + spent_block_index: self.spent_block_index.unwrap_or(0), + timestamp: self.timestamp, + } + } +} + +impl ValidatedBundle { + pub(super) fn to_mempool_item(&self) -> MempoolItem { + MempoolItem { + spend_bundle: self.spend_bundle.clone(), + fee: self.fee, + } + } +} diff --git a/crates/chia-sdk-test/src/full_node_simulator/validation.rs b/crates/chia-sdk-test/src/full_node_simulator/validation.rs new file mode 100644 index 000000000..5dfa14c39 --- /dev/null +++ b/crates/chia-sdk-test/src/full_node_simulator/validation.rs @@ -0,0 +1,247 @@ +use chia_consensus::{ + conditions::ELIGIBLE_FOR_DEDUP, flags::COMPUTE_FINGERPRINT, validation_error::ErrorCode, +}; +use chia_protocol::{Bytes32, Coin, SpendBundle}; +use chia_sdk_types::default_constants; +use clvmr::ENABLE_KECCAK_OPS_OUTSIDE_GUARD; +use indexmap::{IndexMap, IndexSet}; + +use crate::{ + FullNodeSimulator, SimulatorError, + full_node_simulator::{SIMULATOR_GENESIS_CHALLENGE, ValidatedBundle, ValidatedSpend}, + spend_bundle_validation::{ + CoinRecord, ValidationClock, ValidationSettings, validate_conditions, + validate_relative_conditions, validate_reserve_fee, + }, +}; + +#[derive(Debug, Default)] +pub(super) struct ValidationOverlay { + additions: IndexSet, + removals: IndexSet, +} + +impl ValidationOverlay { + #[cfg(feature = "serde")] + pub(super) fn apply(&mut self, bundle: &ValidatedBundle) { + self.additions + .extend(bundle.additions.iter().map(|(coin, _)| coin.coin_id())); + self.removals.extend(bundle.removals.iter().copied()); + } +} + +impl FullNodeSimulator { + pub(super) fn validate_bundle( + &self, + spend_bundle: SpendBundle, + ) -> Result { + self.validate_bundle_with_overlay(spend_bundle, None) + } + + #[cfg(feature = "serde")] + pub(super) fn validate_bundle_in_block( + &self, + spend_bundle: SpendBundle, + overlay: &ValidationOverlay, + ) -> Result { + self.validate_bundle_with_overlay(spend_bundle, Some(overlay)) + } + + fn validate_bundle_with_overlay( + &self, + spend_bundle: SpendBundle, + overlay: Option<&ValidationOverlay>, + ) -> Result { + let constants = default_constants(SIMULATOR_GENESIS_CHALLENGE, SIMULATOR_GENESIS_CHALLENGE); + let clock = ValidationClock { + height: self.state.height, + timestamp: self.state.next_timestamp, + }; + let validation = validate_conditions( + &spend_bundle, + ValidationSettings { + constants: &constants, + max_cost: 11_000_000_000 / 2, + flags: ENABLE_KECCAK_OPS_OUTSIDE_GUARD | COMPUTE_FINGERPRINT, + clock, + }, + ) + .map_err(SimulatorError::Validation)?; + let conds = validation.conditions; + + let bundle_coin_spends = spend_bundle + .coin_spends + .iter() + .map(|spend| (spend.coin.coin_id(), spend.clone())) + .collect::>(); + + let mut removals = IndexSet::new(); + let mut additions = IndexMap::new(); + let mut spends = IndexMap::new(); + + for (spend, parsed) in conds.spends.iter().zip(validation.additions) { + let coin_id = spend.coin_id; + debug_assert_eq!(parsed.coin_id, coin_id); + let spend_additions = parsed + .additions + .into_iter() + .map(|addition| (addition.coin, addition.hint)) + .collect::>(); + for (coin, hint) in &spend_additions { + additions.insert(coin.coin_id(), (*coin, *hint)); + } + + let Some(coin_spend) = bundle_coin_spends.get(&coin_id).cloned() else { + return Err(SimulatorError::Validation(ErrorCode::InvalidSpendBundle)); + }; + + let fingerprint = if (spend.flags & ELIGIBLE_FOR_DEDUP) != 0 { + Bytes32::try_from(spend.fingerprint.as_ref()).ok() + } else { + None + }; + + spends.insert( + coin_id, + ValidatedSpend { + coin_spend, + flags: spend.flags, + fingerprint, + additions: spend_additions, + }, + ); + } + + for spend in &conds.spends { + let coin_id = spend.coin_id; + if !removals.insert(coin_id) { + return Err(SimulatorError::Validation(ErrorCode::DoubleSpend)); + } + if overlay.is_some_and(|overlay| overlay.removals.contains(&coin_id)) { + return Err(SimulatorError::Validation(ErrorCode::DoubleSpend)); + } + + if let Some(record) = self.state.coins.get(&coin_id) { + if record.spent_block_index.is_some() { + return Err(SimulatorError::Validation(ErrorCode::DoubleSpend)); + } + + validate_relative_conditions( + spend, + CoinRecord { + created_height: Some(record.confirmed_block_index), + created_timestamp: Some(record.timestamp), + }, + clock, + ) + .map_err(SimulatorError::Validation)?; + } else if additions.contains_key(&coin_id) + || overlay.is_some_and(|overlay| overlay.additions.contains(&coin_id)) + || self.mempool_addition_coin(coin_id).is_some() + { + validate_relative_conditions( + spend, + CoinRecord { + created_height: Some(clock.height), + created_timestamp: Some(clock.timestamp), + }, + clock, + ) + .map_err(SimulatorError::Validation)?; + } else { + return Err(SimulatorError::Validation(ErrorCode::UnknownUnspent)); + } + } + + validate_reserve_fee(&conds, validation.fee).map_err(SimulatorError::Validation)?; + + Ok(ValidatedBundle { + spend_bundle, + removals: removals.into_iter().collect(), + additions: additions.into_values().collect(), + spends, + cost: conds.cost, + fee: validation.fee, + }) + } + + fn mempool_addition_coin(&self, coin_id: Bytes32) -> Option { + self.mempool + .values() + .flat_map(|item| item.additions.iter().map(|(coin, _)| *coin)) + .find(|coin| coin.coin_id() == coin_id) + } +} + +#[cfg(test)] +mod tests { + use chia_bls::Signature; + use chia_consensus::validation_error::ErrorCode; + use chia_protocol::{Coin, CoinSpend, SpendBundle}; + use chia_sdk_types::conditions::{ + AssertBeforeHeightAbsolute, AssertBeforeHeightRelative, AssertBeforeSecondsAbsolute, + AssertBeforeSecondsRelative, Condition, Conditions, CreateCoin, Memos, + }; + use clvmr::NodePtr; + + use crate::{FullNodeSimulator, SimulatorError, to_program, to_puzzle}; + + fn spend_with_condition( + coin: Coin, + puzzle_reveal: chia_protocol::Program, + puzzle_hash: chia_protocol::Bytes32, + condition: Condition, + ) -> anyhow::Result { + let conditions = Conditions::new().with(condition).with(CreateCoin::new( + puzzle_hash, + coin.amount - 1, + Memos::None, + )); + Ok(SpendBundle::new( + vec![CoinSpend::new(coin, puzzle_reveal, to_program(conditions)?)], + Signature::default(), + )) + } + + #[test] + fn assert_before_conditions_reject_equality() -> anyhow::Result<()> { + let cases = [ + ( + AssertBeforeHeightAbsolute::new(1).into(), + ErrorCode::AssertBeforeHeightAbsoluteFailed, + ), + ( + AssertBeforeSecondsAbsolute::new(1).into(), + ErrorCode::AssertBeforeSecondsAbsoluteFailed, + ), + ( + AssertBeforeHeightRelative::new(0).into(), + ErrorCode::AssertBeforeHeightRelativeFailed, + ), + ( + AssertBeforeSecondsRelative::new(0).into(), + ErrorCode::AssertBeforeSecondsRelativeFailed, + ), + ]; + + for (condition, expected_error) in cases { + let mut sim = FullNodeSimulator::new(); + let (puzzle_hash, puzzle_reveal) = to_puzzle(1)?; + let coin = sim.new_coin(puzzle_hash, 100); + let condition = match expected_error { + ErrorCode::AssertBeforeSecondsAbsoluteFailed => { + AssertBeforeSecondsAbsolute::new(sim.state.next_timestamp).into() + } + _ => condition, + }; + let spend_bundle = spend_with_condition(coin, puzzle_reveal, puzzle_hash, condition)?; + + assert!(matches!( + sim.validate_bundle(spend_bundle), + Err(SimulatorError::Validation(error)) if error == expected_error + )); + } + + Ok(()) + } +} diff --git a/crates/chia-sdk-test/src/full_node_simulator_http.rs b/crates/chia-sdk-test/src/full_node_simulator_http.rs new file mode 100644 index 000000000..fd5f9123b --- /dev/null +++ b/crates/chia-sdk-test/src/full_node_simulator_http.rs @@ -0,0 +1,10 @@ +mod handlers; +mod push_tx; +mod server; +mod state; +mod types; + +#[cfg(test)] +mod tests; + +pub use server::FullNodeSimulatorServer; diff --git a/crates/chia-sdk-test/src/full_node_simulator_http/handlers.rs b/crates/chia-sdk-test/src/full_node_simulator_http/handlers.rs new file mode 100644 index 000000000..bf59719fa --- /dev/null +++ b/crates/chia-sdk-test/src/full_node_simulator_http/handlers.rs @@ -0,0 +1,435 @@ +use axum::{Json, Router, extract::State, routing::post}; +use chia_sdk_coinset::{ + AdditionsAndRemovalsResponse, BlockchainStateResponse, GetBlockRecordResponse, + GetBlockRecordsResponse, GetBlockResponse, GetBlockSpendsResponse, GetBlocksResponse, + GetCoinRecordResponse, GetCoinRecordsResponse, GetMempoolItemResponse, GetMempoolItemsResponse, + GetNetworkInfoResponse, GetPuzzleAndSolutionResponse, +}; + +use super::{ + push_tx::push_tx_response_body, + state::SharedSimulator, + types::{ + BlockRecordsRequest, BlocksRequest, CoinNameRequest, EmptyRequest, FarmBlockRequest, + GetAggsigAdditionalDataResponse, HeaderHashRequest, HeightRequest, HintRequest, + HintsRequest, InsertCoinRequest, NameRequest, NamesRequest, NewCoinRequest, + ParentIdsRequest, PushTxRequest, PuzzleAndSolutionRequest, PuzzleHashRequest, + PuzzleHashesRequest, ReorgBlocksRequest, RestoreStateRequest, RevertBlocksRequest, + SetFarmingPhRequest, SimEventsResponse, SimFarmBlockResponse, SimNewCoinResponse, + SimRevertBlocksResponse, SimStateResponse, SimSuccessResponse, TxIdRequest, + }, +}; + +pub(super) fn router(simulator: SharedSimulator) -> Router { + Router::new() + .route("/get_blockchain_state", post(get_blockchain_state)) + .route("/get_network_info", post(get_network_info)) + .route( + "/get_aggsig_additional_data", + post(get_aggsig_additional_data), + ) + .route("/get_block", post(get_block)) + .route("/get_blocks", post(get_blocks)) + .route("/get_block_record", post(get_block_record)) + .route( + "/get_block_record_by_height", + post(get_block_record_by_height), + ) + .route("/get_block_records", post(get_block_records)) + .route( + "/get_additions_and_removals", + post(get_additions_and_removals), + ) + .route("/get_block_spends", post(get_block_spends)) + .route("/get_coin_record_by_name", post(get_coin_record_by_name)) + .route( + "/get_coin_records_by_names", + post(get_coin_records_by_names), + ) + .route("/get_coin_records_by_hint", post(get_coin_records_by_hint)) + .route( + "/get_coin_records_by_hints", + post(get_coin_records_by_hints), + ) + .route( + "/get_coin_records_by_parent_ids", + post(get_coin_records_by_parent_ids), + ) + .route( + "/get_coin_records_by_puzzle_hash", + post(get_coin_records_by_puzzle_hash), + ) + .route( + "/get_coin_records_by_puzzle_hashes", + post(get_coin_records_by_puzzle_hashes), + ) + .route("/get_puzzle_and_solution", post(get_puzzle_and_solution)) + .route("/push_tx", post(push_tx)) + .route( + "/get_mempool_item_by_tx_id", + post(get_mempool_item_by_tx_id), + ) + .route( + "/get_mempool_items_by_coin_name", + post(get_mempool_items_by_coin_name), + ) + .route("/sim/farm_block", post(sim_farm_block)) + .route("/sim/revert_blocks", post(sim_revert_blocks)) + .route("/sim/reorg_blocks", post(sim_reorg_blocks)) + .route("/sim/new_coin", post(sim_new_coin)) + .route("/sim/insert_coin", post(sim_insert_coin)) + .route("/sim/set_farming_ph", post(sim_set_farming_ph)) + .route("/sim/dump_state", post(sim_dump_state)) + .route("/sim/restore_state", post(sim_restore_state)) + .route("/sim/drain_events", post(sim_drain_events)) + .with_state(simulator) +} + +async fn get_blockchain_state( + State(simulator): State, + Json(_request): Json, +) -> Json { + Json(simulator.lock().unwrap().get_blockchain_state()) +} + +async fn get_network_info( + State(simulator): State, + Json(_request): Json, +) -> Json { + Json(simulator.lock().unwrap().get_network_info()) +} + +async fn get_aggsig_additional_data( + State(simulator): State, + Json(_request): Json, +) -> Json { + let additional_data = simulator + .lock() + .unwrap() + .get_aggsig_additional_data() + .to_bytes(); + Json(GetAggsigAdditionalDataResponse { + additional_data: Some(hex::encode(additional_data)), + error: None, + success: true, + }) +} + +async fn get_block( + State(_simulator): State, + Json(_request): Json, +) -> Json { + Json(GetBlockResponse { + block: None, + error: Some("get_block is not supported by FullNodeSimulator".to_string()), + success: false, + }) +} + +async fn get_blocks( + State(_simulator): State, + Json(_request): Json, +) -> Json { + Json(GetBlocksResponse { + blocks: None, + error: Some("get_blocks is not supported by FullNodeSimulator".to_string()), + success: false, + }) +} + +async fn get_block_record( + State(simulator): State, + Json(request): Json, +) -> Json { + Json( + simulator + .lock() + .unwrap() + .get_block_record(request.header_hash), + ) +} + +async fn get_block_record_by_height( + State(simulator): State, + Json(request): Json, +) -> Json { + Json( + simulator + .lock() + .unwrap() + .get_block_record_by_height(request.height), + ) +} + +async fn get_block_records( + State(simulator): State, + Json(request): Json, +) -> Json { + Json( + simulator + .lock() + .unwrap() + .get_block_records(request.start, request.end), + ) +} + +async fn get_additions_and_removals( + State(simulator): State, + Json(request): Json, +) -> Json { + Json( + simulator + .lock() + .unwrap() + .get_additions_and_removals(request.header_hash), + ) +} + +async fn get_block_spends( + State(simulator): State, + Json(request): Json, +) -> Json { + Json( + simulator + .lock() + .unwrap() + .get_block_spends(request.header_hash), + ) +} + +async fn get_coin_record_by_name( + State(simulator): State, + Json(request): Json, +) -> Json { + Json( + simulator + .lock() + .unwrap() + .get_coin_record_by_name(request.name), + ) +} + +async fn get_coin_records_by_names( + State(simulator): State, + Json(request): Json, +) -> Json { + Json(simulator.lock().unwrap().get_coin_records_by_names( + &request.names, + request.start_height, + request.end_height, + request.include_spent_coins, + )) +} + +async fn get_coin_records_by_hint( + State(simulator): State, + Json(request): Json, +) -> Json { + Json(simulator.lock().unwrap().get_coin_records_by_hint( + request.hint, + request.start_height, + request.end_height, + request.include_spent_coins, + )) +} + +async fn get_coin_records_by_hints( + State(simulator): State, + Json(request): Json, +) -> Json { + Json(simulator.lock().unwrap().get_coin_records_by_hints( + request.hints, + request.start_height, + request.end_height, + request.include_spent_coins, + )) +} + +async fn get_coin_records_by_parent_ids( + State(simulator): State, + Json(request): Json, +) -> Json { + Json(simulator.lock().unwrap().get_coin_records_by_parent_ids( + request.parent_ids, + request.start_height, + request.end_height, + request.include_spent_coins, + )) +} + +async fn get_coin_records_by_puzzle_hash( + State(simulator): State, + Json(request): Json, +) -> Json { + Json(simulator.lock().unwrap().get_coin_records_by_puzzle_hash( + request.puzzle_hash, + request.start_height, + request.end_height, + request.include_spent_coins, + )) +} + +async fn get_coin_records_by_puzzle_hashes( + State(simulator): State, + Json(request): Json, +) -> Json { + Json(simulator.lock().unwrap().get_coin_records_by_puzzle_hashes( + request.puzzle_hashes, + request.start_height, + request.end_height, + request.include_spent_coins, + )) +} + +async fn get_puzzle_and_solution( + State(simulator): State, + Json(request): Json, +) -> Json { + Json( + simulator + .lock() + .unwrap() + .get_puzzle_and_solution(request.coin_id, request.height), + ) +} + +async fn push_tx( + State(simulator): State, + Json(request): Json, +) -> Json { + let spend_name = request.spend_bundle.name(); + let response = simulator + .lock() + .unwrap() + .push_tx_detailed(request.spend_bundle); + Json(push_tx_response_body(spend_name, &response)) +} + +async fn get_mempool_item_by_tx_id( + State(simulator): State, + Json(request): Json, +) -> Json { + Json( + simulator + .lock() + .unwrap() + .get_mempool_item_by_tx_id(request.tx_id), + ) +} + +async fn get_mempool_items_by_coin_name( + State(simulator): State, + Json(request): Json, +) -> Json { + Json( + simulator + .lock() + .unwrap() + .get_mempool_items_by_coin_name(request.coin_name), + ) +} + +async fn sim_farm_block( + State(simulator): State, + Json(request): Json, +) -> Json { + Json(SimFarmBlockResponse { + block_records: simulator.lock().unwrap().farm_block(request.blocks), + success: true, + }) +} + +async fn sim_revert_blocks( + State(simulator): State, + Json(request): Json, +) -> Json { + Json(SimRevertBlocksResponse { + header_hashes: simulator.lock().unwrap().revert_blocks(request.blocks), + success: true, + }) +} + +async fn sim_reorg_blocks( + State(simulator): State, + Json(request): Json, +) -> Json { + Json(SimFarmBlockResponse { + block_records: simulator + .lock() + .unwrap() + .reorg_blocks(request.num_of_blocks_to_rev, request.num_of_new_blocks), + success: true, + }) +} + +async fn sim_new_coin( + State(simulator): State, + Json(request): Json, +) -> Json { + Json(SimNewCoinResponse { + coin: simulator + .lock() + .unwrap() + .new_coin(request.puzzle_hash, request.amount), + success: true, + }) +} + +async fn sim_insert_coin( + State(simulator): State, + Json(request): Json, +) -> Json { + simulator.lock().unwrap().insert_coin(request.coin); + Json(SimSuccessResponse { success: true }) +} + +async fn sim_set_farming_ph( + State(simulator): State, + Json(request): Json, +) -> Json { + simulator + .lock() + .unwrap() + .set_farming_ph(request.puzzle_hash); + Json(SimSuccessResponse { success: true }) +} + +async fn sim_dump_state( + State(simulator): State, + Json(_request): Json, +) -> Json { + match simulator.lock().unwrap().dump_state() { + Ok(state) => Json(SimStateResponse { + state: Some(state), + error: None, + success: true, + }), + Err(error) => Json(SimStateResponse { + state: None, + error: Some(error.to_string()), + success: false, + }), + } +} + +async fn sim_restore_state( + State(simulator): State, + Json(request): Json, +) -> Json { + let success = simulator + .lock() + .unwrap() + .restore_state(&request.state) + .is_ok(); + Json(SimSuccessResponse { success }) +} + +async fn sim_drain_events( + State(simulator): State, + Json(_request): Json, +) -> Json { + Json(SimEventsResponse { + events: simulator.lock().unwrap().drain_events(), + success: true, + }) +} diff --git a/crates/chia-sdk-test/src/full_node_simulator_http/push_tx.rs b/crates/chia-sdk-test/src/full_node_simulator_http/push_tx.rs new file mode 100644 index 000000000..6af19649f --- /dev/null +++ b/crates/chia-sdk-test/src/full_node_simulator_http/push_tx.rs @@ -0,0 +1,70 @@ +use chia_consensus::validation_error::ErrorCode; +use chia_protocol::Bytes32; + +use crate::{FullNodeSimulatorPushTxResponse, SimulatorError}; + +pub(super) fn push_tx_response_body( + spend_name: Bytes32, + result: &FullNodeSimulatorPushTxResponse, +) -> serde_json::Value { + if result.response.success { + return serde_json::json!({ + "status": result.response.status, + "error": null, + "success": true, + }); + } + + let error_name = result.error.as_ref().map_or("UNKNOWN", push_tx_error_name); + if error_name == "MEMPOOL_CONFLICT" { + return serde_json::json!({ + "status": "PENDING", + "error": null, + "success": true, + }); + } + + let message = format!("Failed to include transaction {spend_name}, error {error_name}"); + + serde_json::json!({ + "success": false, + "error": message, + "structuredError": { + "code": "TRANSACTION_FAILED", + "message": "Failed to include transaction", + "data": { + "spend_name": spend_name.to_string(), + "error": error_name, + }, + }, + }) +} + +fn push_tx_error_name(error: &SimulatorError) -> &'static str { + let SimulatorError::Validation(error_code) = error else { + return "UNKNOWN"; + }; + + match error_code { + ErrorCode::CostExceeded => "BLOCK_COST_EXCEEDS_MAX", + ErrorCode::MempoolConflict => "MEMPOOL_CONFLICT", + ErrorCode::InvalidSpendBundle => "INVALID_SPEND_BUNDLE", + ErrorCode::DoubleSpend => "DOUBLE_SPEND", + ErrorCode::UnknownUnspent => "UNKNOWN_UNSPENT", + ErrorCode::BadAggregateSignature => "BAD_AGGREGATE_SIGNATURE", + ErrorCode::InvalidCoinAnnouncement => "INVALID_COIN_ANNOUNCEMENT", + ErrorCode::InvalidPuzzleAnnouncement => "INVALID_PUZZLE_ANNOUNCEMENT", + ErrorCode::AssertCoinAnnouncementFailed => "ASSERT_COIN_ANNOUNCEMENT_FAILED", + ErrorCode::AssertPuzzleAnnouncementFailed => "ASSERT_PUZZLE_ANNOUNCEMENT_FAILED", + ErrorCode::ReserveFeeConditionFailed => "RESERVE_FEE_CONDITION_FAILED", + ErrorCode::AssertHeightAbsoluteFailed => "ASSERT_HEIGHT_ABSOLUTE_FAILED", + ErrorCode::AssertSecondsAbsoluteFailed => "ASSERT_SECONDS_ABSOLUTE_FAILED", + ErrorCode::AssertBeforeHeightAbsoluteFailed => "ASSERT_BEFORE_HEIGHT_ABSOLUTE_FAILED", + ErrorCode::AssertBeforeSecondsAbsoluteFailed => "ASSERT_BEFORE_SECONDS_ABSOLUTE_FAILED", + ErrorCode::AssertHeightRelativeFailed => "ASSERT_HEIGHT_RELATIVE_FAILED", + ErrorCode::AssertSecondsRelativeFailed => "ASSERT_SECONDS_RELATIVE_FAILED", + ErrorCode::AssertBeforeHeightRelativeFailed => "ASSERT_BEFORE_HEIGHT_RELATIVE_FAILED", + ErrorCode::AssertBeforeSecondsRelativeFailed => "ASSERT_BEFORE_SECONDS_RELATIVE_FAILED", + _ => "UNKNOWN", + } +} diff --git a/crates/chia-sdk-test/src/full_node_simulator_http/server.rs b/crates/chia-sdk-test/src/full_node_simulator_http/server.rs new file mode 100644 index 000000000..a2917fd28 --- /dev/null +++ b/crates/chia-sdk-test/src/full_node_simulator_http/server.rs @@ -0,0 +1,47 @@ +use std::{ + net::SocketAddr, + sync::{Arc, Mutex}, +}; + +use tokio::{net::TcpListener, task::JoinHandle}; + +use crate::FullNodeSimulator; + +use super::{handlers::router, state::SharedSimulator}; + +#[derive(Debug)] +pub struct FullNodeSimulatorServer { + addr: SocketAddr, + join_handle: JoinHandle<()>, +} + +impl FullNodeSimulatorServer { + pub async fn new() -> std::io::Result { + Self::with_simulator(Arc::new(Mutex::new(FullNodeSimulator::default()))).await + } + + pub async fn with_simulator(simulator: SharedSimulator) -> std::io::Result { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let app = router(simulator.clone()); + let join_handle = tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + Ok(Self { addr, join_handle }) + } + + pub fn addr(&self) -> SocketAddr { + self.addr + } + + pub fn url(&self) -> String { + format!("http://{}", self.addr) + } +} + +impl Drop for FullNodeSimulatorServer { + fn drop(&mut self) { + self.join_handle.abort(); + } +} diff --git a/crates/chia-sdk-test/src/full_node_simulator_http/state.rs b/crates/chia-sdk-test/src/full_node_simulator_http/state.rs new file mode 100644 index 000000000..714b6f64d --- /dev/null +++ b/crates/chia-sdk-test/src/full_node_simulator_http/state.rs @@ -0,0 +1,5 @@ +use std::sync::{Arc, Mutex}; + +use crate::FullNodeSimulator; + +pub type SharedSimulator = Arc>; diff --git a/crates/chia-sdk-test/src/full_node_simulator_http/tests.rs b/crates/chia-sdk-test/src/full_node_simulator_http/tests.rs new file mode 100644 index 000000000..e5b70efe6 --- /dev/null +++ b/crates/chia-sdk-test/src/full_node_simulator_http/tests.rs @@ -0,0 +1,651 @@ +use std::sync::{Arc, Mutex}; + +use chia_bls::Signature; +use chia_consensus::validation_error::ErrorCode; +use chia_protocol::{Bytes32, Coin, CoinSpend, SpendBundle}; +use chia_sdk_coinset::{ChiaRpcClient, CoinsetClient, PushTxResponse}; +use chia_sdk_types::conditions::{AssertCoinAnnouncement, Conditions, CreateCoin, Memos}; +use clvmr::NodePtr; +use serde_json::Value; + +use crate::{ + FullNodeSimulator, FullNodeSimulatorPushTxResponse, SimulatorError, to_program, to_puzzle, +}; + +use super::{ + push_tx::push_tx_response_body, + server::FullNodeSimulatorServer, + types::{ + GetAggsigAdditionalDataResponse, SimFarmBlockResponse, SimNewCoinResponse, SimStateResponse, + }, +}; + +#[tokio::test] +async fn rpc_client_can_drive_http_simulator() -> anyhow::Result<()> { + let server = FullNodeSimulatorServer::new().await?; + let client = CoinsetClient::new(server.url()); + + let network_info = client.get_network_info().await?; + assert!(network_info.success); + assert_eq!(network_info.network_name.as_deref(), Some("simulator0")); + + let state = client.get_blockchain_state().await?; + assert!(state.success); + let blockchain_state = state.blockchain_state.unwrap(); + assert_eq!(blockchain_state.mempool_size, 0); + let peak_hash = blockchain_state.peak.header_hash; + + let http = reqwest::Client::new(); + let aggsig_response = http + .post(format!("{}/get_aggsig_additional_data", server.url())) + .json(&serde_json::json!({})) + .send() + .await? + .json::() + .await?; + assert!(aggsig_response.success); + let additional_data = aggsig_response.additional_data.unwrap(); + assert_eq!(additional_data.len(), 64); + assert!(!additional_data.starts_with("0x")); + + let (puzzle_hash, puzzle_reveal) = to_puzzle(1)?; + let new_coin = http + .post(format!("{}/sim/new_coin", server.url())) + .json(&serde_json::json!({ + "puzzle_hash": puzzle_hash, + "amount": 100_u64, + })) + .send() + .await? + .json::() + .await? + .coin; + + for (endpoint, body) in [ + ( + "get_coin_records_by_puzzle_hashes", + serde_json::json!({ + "puzzle_hashes": [puzzle_hash], + "start_height": null, + "end_height": null, + "include_spent_coins": null, + }), + ), + ( + "get_block_record_by_height", + serde_json::json!({ "height": 0_u32 }), + ), + ( + "get_coin_record_by_name", + serde_json::json!({ "name": new_coin.coin_id() }), + ), + ( + "get_coin_records_by_names", + serde_json::json!({ + "names": [new_coin.coin_id()], + "start_height": null, + "end_height": null, + "include_spent_coins": null, + }), + ), + ( + "get_coin_records_by_parent_ids", + serde_json::json!({ + "parent_ids": [new_coin.parent_coin_info], + "start_height": null, + "end_height": null, + "include_spent_coins": null, + }), + ), + ( + "get_coin_records_by_hint", + serde_json::json!({ + "hint": puzzle_hash, + "start_height": null, + "end_height": null, + "include_spent_coins": null, + }), + ), + ( + "get_puzzle_and_solution", + serde_json::json!({ + "coin_id": new_coin.coin_id(), + "height": null, + }), + ), + ( + "get_block_records", + serde_json::json!({ + "start": 0_u32, + "end": 2_u32, + }), + ), + ( + "get_block_spends", + serde_json::json!({ "header_hash": peak_hash }), + ), + ] { + let response = http + .post(format!("{}/{endpoint}", server.url())) + .json(&body) + .send() + .await?; + assert!( + response.status().is_success(), + "{endpoint} returned {}", + response.status() + ); + let body = response.json::().await?; + assert_eq!(body.get("success"), Some(&serde_json::Value::Bool(true))); + } + + let spend_bundle = SpendBundle::new( + vec![CoinSpend::new( + new_coin, + puzzle_reveal, + to_program([CreateCoin::::new(puzzle_hash, 99, Memos::None)])?, + )], + Signature::default(), + ); + let push_response = client.push_tx(spend_bundle).await?; + assert!(push_response.success, "{push_response:?}"); + + let failed_push = http + .post(format!("{}/push_tx", server.url())) + .json(&serde_json::json!({ + "spend_bundle": { + "coin_spends": [], + "aggregated_signature": "0x".to_string() + + &hex::encode(Signature::default().to_bytes()), + }, + })) + .send() + .await? + .json::() + .await?; + assert_eq!( + failed_push + .get("structuredError") + .and_then(|error| error.get("code")), + Some(&serde_json::Value::String("TRANSACTION_FAILED".to_string())) + ); + assert_eq!( + failed_push + .get("structuredError") + .and_then(|error| error.get("data")) + .and_then(|data| data.get("error")), + Some(&serde_json::Value::String( + "INVALID_SPEND_BUNDLE".to_string() + )) + ); + + let state = client.get_blockchain_state().await?; + assert_eq!(state.blockchain_state.unwrap().mempool_size, 1); + + let farmed = http + .post(format!("{}/sim/farm_block", server.url())) + .json(&serde_json::json!({ "blocks": 1_u32 })) + .send() + .await? + .json::() + .await?; + assert!(farmed.success); + assert_eq!(farmed.block_records.len(), 1); + + let state = client.get_blockchain_state().await?; + assert_eq!(state.blockchain_state.unwrap().mempool_size, 0); + + Ok(()) +} + +#[tokio::test] +async fn http_simulator_can_dump_and_restore_state() -> anyhow::Result<()> { + let server = FullNodeSimulatorServer::new().await?; + let http = reqwest::Client::new(); + + let farm_response = http + .post(format!("{}/sim/farm_block", server.url())) + .json(&serde_json::json!({ "blocks": 2_u32 })) + .send() + .await? + .json::() + .await?; + assert!(farm_response.success); + let dumped_peak = farm_response.block_records.last().unwrap().header_hash; + + let dump_response = http + .post(format!("{}/sim/dump_state", server.url())) + .json(&serde_json::json!({})) + .send() + .await? + .json::() + .await?; + assert!(dump_response.success); + let dumped_state = dump_response.state.unwrap(); + + http.post(format!("{}/sim/farm_block", server.url())) + .json(&serde_json::json!({ "blocks": 1_u32 })) + .send() + .await?; + + let restore_response = http + .post(format!("{}/sim/restore_state", server.url())) + .json(&serde_json::json!({ "state": dumped_state })) + .send() + .await? + .json::() + .await?; + assert_eq!( + restore_response.get("success"), + Some(&serde_json::Value::Bool(true)) + ); + + let state = http + .post(format!("{}/get_blockchain_state", server.url())) + .json(&serde_json::json!({})) + .send() + .await? + .json::() + .await?; + assert_eq!( + state.blockchain_state.unwrap().peak.header_hash, + dumped_peak + ); + Ok(()) +} + +#[tokio::test] +async fn get_coin_records_by_puzzle_hashes_uses_exclusive_end_height() -> anyhow::Result<()> { + let server = FullNodeSimulatorServer::new().await?; + let client = CoinsetClient::new(server.url()); + let http = reqwest::Client::new(); + + let (parent_puzzle_hash, parent_puzzle_reveal) = to_puzzle(1)?; + let (child_puzzle_hash, _) = to_puzzle(2)?; + let parent = http + .post(format!("{}/sim/new_coin", server.url())) + .json(&serde_json::json!({ + "puzzle_hash": parent_puzzle_hash, + "amount": 100_u64, + })) + .send() + .await? + .json::() + .await? + .coin; + let child = Coin::new(parent.coin_id(), child_puzzle_hash, 99); + let spend_bundle = SpendBundle::new( + vec![CoinSpend::new( + parent, + parent_puzzle_reveal, + to_program([CreateCoin::::new( + child_puzzle_hash, + child.amount, + Memos::None, + )])?, + )], + Signature::default(), + ); + + let push_response = client.push_tx(spend_bundle).await?; + assert!(push_response.success, "{push_response:?}"); + http.post(format!("{}/sim/farm_block", server.url())) + .json(&serde_json::json!({ "blocks": 1_u32 })) + .send() + .await?; + + let before_created = client + .get_coin_records_by_puzzle_hashes(vec![child_puzzle_hash], None, Some(2), None, None) + .await?; + assert_eq!(before_created.coin_records.unwrap().len(), 0); + + let after_created = client + .get_coin_records_by_puzzle_hashes(vec![child_puzzle_hash], None, Some(3), None, None) + .await?; + let records = after_created.coin_records.unwrap(); + assert_eq!(records.len(), 1); + assert_eq!(records[0].coin, child); + assert_eq!(records[0].confirmed_block_index, 2); + + Ok(()) +} + +#[tokio::test] +async fn get_coin_records_by_puzzle_hashes_passes_through_include_spent_coins() -> anyhow::Result<()> +{ + let server = FullNodeSimulatorServer::new().await?; + let client = CoinsetClient::new(server.url()); + let http = reqwest::Client::new(); + + let (historical_puzzle_hash, historical_puzzle_reveal) = to_puzzle(1)?; + let parent = http + .post(format!("{}/sim/new_coin", server.url())) + .json(&serde_json::json!({ + "puzzle_hash": historical_puzzle_hash, + "amount": 100_u64, + })) + .send() + .await? + .json::() + .await? + .coin; + + let historical_coin = Coin::new(parent.coin_id(), historical_puzzle_hash, 99); + let create_historical = SpendBundle::new( + vec![CoinSpend::new( + parent, + historical_puzzle_reveal.clone(), + to_program([CreateCoin::::new( + historical_puzzle_hash, + historical_coin.amount, + Memos::None, + )])?, + )], + Signature::default(), + ); + assert!(client.push_tx(create_historical).await?.success); + http.post(format!("{}/sim/farm_block", server.url())) + .json(&serde_json::json!({ "blocks": 1_u32 })) + .send() + .await?; + + let spend_historical = SpendBundle::new( + vec![CoinSpend::new( + historical_coin, + historical_puzzle_reveal, + to_program([CreateCoin::::new( + historical_puzzle_hash, + 98, + Memos::None, + )])?, + )], + Signature::default(), + ); + assert!(client.push_tx(spend_historical).await?.success); + http.post(format!("{}/sim/farm_block", server.url())) + .json(&serde_json::json!({ "blocks": 1_u32 })) + .send() + .await?; + + let omitted_records = client + .get_coin_records_by_puzzle_hashes(vec![historical_puzzle_hash], None, None, None, None) + .await? + .coin_records + .unwrap(); + assert!( + omitted_records + .iter() + .all(|record| record.coin != historical_coin) + ); + + let null_records = http + .post(format!( + "{}/get_coin_records_by_puzzle_hashes", + server.url() + )) + .json(&serde_json::json!({ + "puzzle_hashes": [historical_puzzle_hash], + "include_spent_coins": null, + })) + .send() + .await? + .json::() + .await?; + let null_records = null_records + .get("coin_records") + .and_then(|records| records.as_array()) + .unwrap(); + assert!(null_records.iter().all(|record| { + record + .get("coin") + .and_then(|coin| coin.get("amount")) + .and_then(Value::as_u64) + != Some(historical_coin.amount) + })); + + let unspent_only_records = client + .get_coin_records_by_puzzle_hashes( + vec![historical_puzzle_hash], + None, + None, + Some(false), + None, + ) + .await? + .coin_records + .unwrap(); + assert!( + unspent_only_records + .iter() + .all(|record| record.coin != historical_coin) + ); + + let include_spent_records = client + .get_coin_records_by_puzzle_hashes( + vec![historical_puzzle_hash], + None, + None, + Some(true), + None, + ) + .await? + .coin_records + .unwrap(); + assert!( + include_spent_records + .iter() + .any(|record| record.coin == historical_coin && record.spent) + ); + + Ok(()) +} + +#[tokio::test] +async fn shared_simulator_is_used_by_direct_and_http_apis() -> anyhow::Result<()> { + let simulator = Arc::new(Mutex::new(FullNodeSimulator::new())); + let server = FullNodeSimulatorServer::with_simulator(simulator.clone()).await?; + let (puzzle_hash, _) = to_puzzle(1)?; + + let coin = simulator.lock().unwrap().new_coin(puzzle_hash, 42); + let response = CoinsetClient::new(server.url()) + .get_coin_record_by_name(coin.coin_id()) + .await?; + + assert_eq!(response.coin_record.unwrap().coin, coin); + assert!( + simulator + .lock() + .unwrap() + .get_coin_record_by_name(coin.coin_id()) + .coin_record + .is_some() + ); + + Ok(()) +} + +#[tokio::test] +async fn unsupported_endpoints_and_cursor_policy_are_explicit() -> anyhow::Result<()> { + let server = FullNodeSimulatorServer::new().await?; + let http = reqwest::Client::new(); + + let unsupported = http + .post(format!("{}/get_block", server.url())) + .json(&serde_json::json!({ "header_hash": Bytes32::default() })) + .send() + .await? + .json::() + .await?; + assert_eq!( + unsupported.get("success"), + Some(&serde_json::Value::Bool(false)) + ); + assert_eq!( + unsupported.get("error"), + Some(&serde_json::Value::String( + "get_block is not supported by FullNodeSimulator".to_string() + )) + ); + + let cursor_ignored = http + .post(format!( + "{}/get_coin_records_by_puzzle_hashes", + server.url() + )) + .json(&serde_json::json!({ + "puzzle_hashes": [Bytes32::default()], + "cursor": "ignored", + })) + .send() + .await? + .json::() + .await?; + assert_eq!( + cursor_ignored.get("success"), + Some(&serde_json::Value::Bool(true)) + ); + + Ok(()) +} + +#[tokio::test] +async fn push_tx_returns_pending_for_mempool_conflict() -> anyhow::Result<()> { + let server = FullNodeSimulatorServer::new().await?; + let http = reqwest::Client::new(); + + let (puzzle_hash, puzzle_reveal) = to_puzzle(1)?; + let coin = http + .post(format!("{}/sim/new_coin", server.url())) + .json(&serde_json::json!({ + "puzzle_hash": puzzle_hash, + "amount": 100_u64, + })) + .send() + .await? + .json::() + .await? + .coin; + + let first = SpendBundle::new( + vec![CoinSpend::new( + coin, + puzzle_reveal.clone(), + to_program([CreateCoin::::new(puzzle_hash, 99, Memos::None)])?, + )], + Signature::default(), + ); + let conflict = SpendBundle::new( + vec![CoinSpend::new( + coin, + puzzle_reveal, + to_program([CreateCoin::::new(puzzle_hash, 100, Memos::None)])?, + )], + Signature::default(), + ); + + let first_response = http + .post(format!("{}/push_tx", server.url())) + .json(&serde_json::json!({ "spend_bundle": first })) + .send() + .await? + .json::() + .await?; + assert_eq!( + first_response.get("status"), + Some(&serde_json::Value::String("SUCCESS".to_string())) + ); + + let conflict_response = http + .post(format!("{}/push_tx", server.url())) + .json(&serde_json::json!({ "spend_bundle": conflict })) + .send() + .await? + .json::() + .await?; + assert_eq!( + conflict_response.get("status"), + Some(&serde_json::Value::String("PENDING".to_string())) + ); + assert_eq!( + conflict_response.get("success"), + Some(&serde_json::Value::Bool(true)) + ); + assert!(conflict_response.get("structuredError").is_none()); + + Ok(()) +} + +#[tokio::test] +async fn push_tx_returns_assert_coin_announcement_failed() -> anyhow::Result<()> { + let server = FullNodeSimulatorServer::new().await?; + let http = reqwest::Client::new(); + + let (puzzle_hash, puzzle_reveal) = to_puzzle(1)?; + let coin = http + .post(format!("{}/sim/new_coin", server.url())) + .json(&serde_json::json!({ + "puzzle_hash": puzzle_hash, + "amount": 100_u64, + })) + .send() + .await? + .json::() + .await? + .coin; + let spend_bundle = SpendBundle::new( + vec![CoinSpend::new( + coin, + puzzle_reveal, + to_program( + Conditions::::new() + .with(AssertCoinAnnouncement::new(Bytes32::default())) + .with(CreateCoin::new(puzzle_hash, 99, Memos::None)), + )?, + )], + Signature::default(), + ); + + let response = http + .post(format!("{}/push_tx", server.url())) + .json(&serde_json::json!({ "spend_bundle": spend_bundle })) + .send() + .await? + .json::() + .await?; + + assert_eq!( + response + .get("structuredError") + .and_then(|error| error.get("data")) + .and_then(|data| data.get("error")), + Some(&serde_json::Value::String( + "ASSERT_COIN_ANNOUNCEMENT_FAILED".to_string() + )) + ); + + Ok(()) +} + +#[test] +fn push_tx_response_maps_cost_exceeded_to_block_cost_exceeds_max() { + let body = push_tx_response_body( + Bytes32::default(), + &FullNodeSimulatorPushTxResponse { + response: PushTxResponse { + status: Some("FAILED".to_string()), + error: Some(SimulatorError::Validation(ErrorCode::CostExceeded).to_string()), + success: false, + }, + error: Some(SimulatorError::Validation(ErrorCode::CostExceeded)), + }, + ); + + assert_eq!( + body.get("structuredError") + .and_then(|error| error.get("data")) + .and_then(|data| data.get("error")), + Some(&serde_json::Value::String( + "BLOCK_COST_EXCEEDS_MAX".to_string() + )) + ); +} diff --git a/crates/chia-sdk-test/src/full_node_simulator_http/types.rs b/crates/chia-sdk-test/src/full_node_simulator_http/types.rs new file mode 100644 index 000000000..386df4b5f --- /dev/null +++ b/crates/chia-sdk-test/src/full_node_simulator_http/types.rs @@ -0,0 +1,201 @@ +use chia_protocol::{BlockRecord, Bytes32, Coin, SpendBundle}; +use serde::{Deserialize, Serialize}; + +use crate::FullNodeSimulatorEvent; + +#[derive(Debug, Deserialize)] +pub(super) struct EmptyRequest {} + +#[derive(Debug, Deserialize)] +pub(super) struct HeaderHashRequest { + pub(super) header_hash: Bytes32, +} + +#[derive(Debug, Deserialize)] +pub(super) struct HeightRequest { + pub(super) height: u32, +} + +#[derive(Debug, Deserialize)] +pub(super) struct BlockRecordsRequest { + pub(super) start: u32, + pub(super) end: u32, +} + +#[derive(Debug, Deserialize)] +pub(super) struct BlocksRequest { + #[allow(dead_code)] + pub(super) start: u32, + #[allow(dead_code)] + pub(super) end: u32, + #[allow(dead_code)] + pub(super) exclude_header_hash: bool, + #[allow(dead_code)] + pub(super) exclude_reorged: bool, +} + +#[derive(Debug, Deserialize)] +pub(super) struct NameRequest { + pub(super) name: Bytes32, +} + +#[derive(Debug, Deserialize)] +pub(super) struct NamesRequest { + pub(super) names: Vec, + pub(super) start_height: Option, + pub(super) end_height: Option, + pub(super) include_spent_coins: Option, + #[allow(dead_code)] + pub(super) cursor: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct HintRequest { + pub(super) hint: Bytes32, + pub(super) start_height: Option, + pub(super) end_height: Option, + pub(super) include_spent_coins: Option, + #[allow(dead_code)] + pub(super) cursor: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct HintsRequest { + pub(super) hints: Vec, + pub(super) start_height: Option, + pub(super) end_height: Option, + pub(super) include_spent_coins: Option, + #[allow(dead_code)] + pub(super) cursor: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct ParentIdsRequest { + pub(super) parent_ids: Vec, + pub(super) start_height: Option, + pub(super) end_height: Option, + pub(super) include_spent_coins: Option, + #[allow(dead_code)] + pub(super) cursor: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct PuzzleHashRequest { + pub(super) puzzle_hash: Bytes32, + pub(super) start_height: Option, + pub(super) end_height: Option, + pub(super) include_spent_coins: Option, + #[allow(dead_code)] + pub(super) cursor: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct PuzzleHashesRequest { + pub(super) puzzle_hashes: Vec, + pub(super) start_height: Option, + pub(super) end_height: Option, + pub(super) include_spent_coins: Option, + #[allow(dead_code)] + pub(super) cursor: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct PuzzleAndSolutionRequest { + pub(super) coin_id: Bytes32, + pub(super) height: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct PushTxRequest { + pub(super) spend_bundle: SpendBundle, +} + +#[derive(Debug, Deserialize)] +pub(super) struct TxIdRequest { + pub(super) tx_id: Bytes32, +} + +#[derive(Debug, Deserialize)] +pub(super) struct CoinNameRequest { + pub(super) coin_name: Bytes32, +} + +#[derive(Debug, Deserialize)] +pub(super) struct FarmBlockRequest { + pub(super) blocks: u32, +} + +#[derive(Debug, Deserialize)] +pub(super) struct RevertBlocksRequest { + pub(super) blocks: u32, +} + +#[derive(Debug, Deserialize)] +pub(super) struct ReorgBlocksRequest { + pub(super) num_of_blocks_to_rev: u32, + pub(super) num_of_new_blocks: u32, +} + +#[derive(Debug, Deserialize)] +pub(super) struct NewCoinRequest { + pub(super) puzzle_hash: Bytes32, + pub(super) amount: u64, +} + +#[derive(Debug, Deserialize)] +pub(super) struct InsertCoinRequest { + pub(super) coin: Coin, +} + +#[derive(Debug, Deserialize)] +pub(super) struct SetFarmingPhRequest { + pub(super) puzzle_hash: Bytes32, +} + +#[derive(Debug, Deserialize)] +pub(super) struct RestoreStateRequest { + pub(super) state: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub(super) struct SimSuccessResponse { + pub(super) success: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +pub(super) struct SimStateResponse { + pub(super) state: Option, + pub(super) error: Option, + pub(super) success: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +pub(super) struct SimFarmBlockResponse { + pub(super) block_records: Vec, + pub(super) success: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +pub(super) struct SimRevertBlocksResponse { + pub(super) header_hashes: Vec, + pub(super) success: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +pub(super) struct SimNewCoinResponse { + pub(super) coin: Coin, + pub(super) success: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +pub(super) struct SimEventsResponse { + pub(super) events: Vec, + pub(super) success: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +pub(super) struct GetAggsigAdditionalDataResponse { + pub(super) additional_data: Option, + pub(super) error: Option, + pub(super) success: bool, +} diff --git a/crates/chia-sdk-test/src/lib.rs b/crates/chia-sdk-test/src/lib.rs index eebdc2b05..a87908c46 100644 --- a/crates/chia-sdk-test/src/lib.rs +++ b/crates/chia-sdk-test/src/lib.rs @@ -1,18 +1,27 @@ mod announcements; mod benchmark; mod error; +mod full_node_simulator; mod key_pairs; mod simulator; +mod spend_bundle_validation; mod transaction; mod validate_clvm_and_signature; pub use announcements::*; pub use benchmark::*; pub use error::*; +pub use full_node_simulator::*; pub use key_pairs::*; pub use simulator::*; pub use transaction::*; +#[cfg(feature = "full-node-simulator-http")] +mod full_node_simulator_http; + +#[cfg(feature = "full-node-simulator-http")] +pub use full_node_simulator_http::*; + pub(crate) use validate_clvm_and_signature::validate_clvm_and_signature; #[cfg(feature = "peer-simulator")] diff --git a/crates/chia-sdk-test/src/simulator.rs b/crates/chia-sdk-test/src/simulator.rs index 8ad917a14..6475445d6 100644 --- a/crates/chia-sdk-test/src/simulator.rs +++ b/crates/chia-sdk-test/src/simulator.rs @@ -1,5 +1,3 @@ -use std::collections::HashSet; - use chia_bls::SecretKey; use chia_consensus::validation_error::ErrorCode; use chia_protocol::{Bytes32, Coin, CoinSpend, CoinState, Program, SpendBundle}; @@ -10,7 +8,11 @@ use rand::{Rng, SeedableRng}; use rand_chacha::ChaCha8Rng; use crate::{ - BlsPair, BlsPairWithCoin, SimulatorError, sign_transaction, validate_clvm_and_signature, + BlsPair, BlsPairWithCoin, SimulatorError, sign_transaction, + spend_bundle_validation::{ + CoinRecord, ValidationClock, ValidationSettings, validate_conditions, + validate_relative_conditions, + }, }; mod config; @@ -186,88 +188,47 @@ impl Simulator { &mut self, spend_bundle: SpendBundle, ) -> Result, SimulatorError> { - if spend_bundle.coin_spends.is_empty() { - return Err(SimulatorError::Validation(ErrorCode::InvalidSpendBundle)); - } - - let conds = validate_clvm_and_signature( + let clock = ValidationClock { + height: self.data.height, + timestamp: self.data.next_timestamp, + }; + let validation = validate_conditions( &spend_bundle, - 11_000_000_000 / 2, - &TESTNET11_CONSTANTS, - ENABLE_KECCAK_OPS_OUTSIDE_GUARD, + ValidationSettings { + constants: &TESTNET11_CONSTANTS, + max_cost: 11_000_000_000 / 2, + flags: ENABLE_KECCAK_OPS_OUTSIDE_GUARD, + clock, + }, ) .map_err(SimulatorError::Validation)?; - - let puzzle_hashes: HashSet = - conds.spends.iter().map(|spend| spend.puzzle_hash).collect(); - - let bundle_puzzle_hashes: HashSet = spend_bundle - .coin_spends - .iter() - .map(|cs| cs.coin.puzzle_hash) - .collect(); - - if puzzle_hashes != bundle_puzzle_hashes { - return Err(SimulatorError::Validation(ErrorCode::InvalidSpendBundle)); - } + 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(); - if self.data.height < conds.height_absolute { - return Err(SimulatorError::Validation( - ErrorCode::AssertHeightAbsoluteFailed, - )); - } - - if self.data.next_timestamp < conds.seconds_absolute { - return Err(SimulatorError::Validation( - ErrorCode::AssertSecondsAbsoluteFailed, - )); - } - - if let Some(height) = conds.before_height_absolute - && height < self.data.height - { - return Err(SimulatorError::Validation( - ErrorCode::AssertBeforeHeightAbsoluteFailed, - )); - } - - if let Some(seconds) = conds.before_seconds_absolute - && seconds < self.data.next_timestamp - { - return Err(SimulatorError::Validation( - ErrorCode::AssertBeforeSecondsAbsoluteFailed, - )); - } - for coin_spend in spend_bundle.coin_spends { coin_spends.insert(coin_spend.coin.coin_id(), coin_spend); } // Calculate additions and removals. - for spend in &conds.spends { - for new_coin in &spend.create_coin { - let coin = Coin::new(spend.coin_id, new_coin.0, new_coin.1); - + 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) = new_coin.2.clone() else { + let Some(hint) = addition.hint else { continue; }; - if hint.len() != 32 { - continue; - } - added_hints - .entry(Bytes32::try_from(hint).unwrap()) + .entry(hint) .or_insert_with(IndexSet::new) .insert(coin.coin_id()); } @@ -281,73 +242,18 @@ impl Simulator { .copied() .unwrap_or(CoinState::new(coin, None, Some(self.data.height))); - if let Some(relative_height) = spend.height_relative { - let Some(created_height) = coin_state.created_height else { - return Err(SimulatorError::Validation( - ErrorCode::EphemeralRelativeCondition, - )); - }; - - if self.data.height < created_height + relative_height { - return Err(SimulatorError::Validation( - ErrorCode::AssertHeightRelativeFailed, - )); - } - } - - if let Some(relative_seconds) = spend.seconds_relative { - let Some(created_height) = coin_state.created_height else { - return Err(SimulatorError::Validation( - ErrorCode::EphemeralRelativeCondition, - )); - }; - let Some(created_timestamp) = self.data.block_timestamps.get(&created_height) - else { - return Err(SimulatorError::Validation( - ErrorCode::EphemeralRelativeCondition, - )); - }; - - if self.data.next_timestamp < created_timestamp + relative_seconds { - return Err(SimulatorError::Validation( - ErrorCode::AssertSecondsRelativeFailed, - )); - } - } - - if let Some(relative_height) = spend.before_height_relative { - let Some(created_height) = coin_state.created_height else { - return Err(SimulatorError::Validation( - ErrorCode::EphemeralRelativeCondition, - )); - }; - - if created_height + relative_height < self.data.height { - return Err(SimulatorError::Validation( - ErrorCode::AssertBeforeHeightRelativeFailed, - )); - } - } - - if let Some(relative_seconds) = spend.before_seconds_relative { - let Some(created_height) = coin_state.created_height else { - return Err(SimulatorError::Validation( - ErrorCode::EphemeralRelativeCondition, - )); - }; - let Some(created_timestamp) = self.data.block_timestamps.get(&created_height) - else { - return Err(SimulatorError::Validation( - ErrorCode::EphemeralRelativeCondition, - )); - }; - - if created_timestamp + relative_seconds < self.data.next_timestamp { - return Err(SimulatorError::Validation( - ErrorCode::AssertBeforeSecondsRelativeFailed, - )); - } - } + 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); } diff --git a/crates/chia-sdk-test/src/spend_bundle_validation.rs b/crates/chia-sdk-test/src/spend_bundle_validation.rs new file mode 100644 index 000000000..66f6bf8ea --- /dev/null +++ b/crates/chia-sdk-test/src/spend_bundle_validation.rs @@ -0,0 +1,381 @@ +use std::collections::HashSet; + +use chia_consensus::{ + consensus_constants::ConsensusConstants, + owned_conditions::{OwnedSpendBundleConditions, OwnedSpendConditions}, + validation_error::ErrorCode, +}; +use chia_protocol::{Bytes32, Coin, SpendBundle}; + +use crate::validate_clvm_and_signature; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ValidationClock { + pub height: u32, + pub timestamp: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct CoinRecord { + pub created_height: Option, + pub created_timestamp: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct CreatedCoin { + pub coin: Coin, + pub hint: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SpendAdditions { + pub coin_id: Bytes32, + pub additions: Vec, +} + +#[derive(Debug)] +pub(crate) struct ValidatedConditions { + pub conditions: OwnedSpendBundleConditions, + pub additions: Vec, + pub fee: u64, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct ValidationSettings<'a> { + pub constants: &'a ConsensusConstants, + pub max_cost: u64, + pub flags: u32, + pub clock: ValidationClock, +} + +pub(crate) fn validate_conditions( + spend_bundle: &SpendBundle, + settings: ValidationSettings<'_>, +) -> Result { + if spend_bundle.coin_spends.is_empty() { + return Err(ErrorCode::InvalidSpendBundle); + } + + let conditions = validate_clvm_and_signature( + spend_bundle, + settings.max_cost, + settings.constants, + settings.flags, + )?; + + validate_puzzle_hashes(spend_bundle, &conditions)?; + validate_absolute_conditions(&conditions, settings.clock)?; + + let additions = conditions + .spends + .iter() + .map(parse_additions) + .collect::>(); + let fee = conditions + .removal_amount + .saturating_sub(conditions.addition_amount) + .try_into() + .unwrap_or(u64::MAX); + + Ok(ValidatedConditions { + conditions, + additions, + fee, + }) +} + +pub(crate) fn validate_relative_conditions( + spend: &OwnedSpendConditions, + record: CoinRecord, + clock: ValidationClock, +) -> Result<(), ErrorCode> { + if let Some(relative_height) = spend.height_relative { + let created_height = record + .created_height + .ok_or(ErrorCode::EphemeralRelativeCondition)?; + if clock.height < created_height + relative_height { + return Err(ErrorCode::AssertHeightRelativeFailed); + } + } + + if let Some(relative_seconds) = spend.seconds_relative { + let created_timestamp = record + .created_timestamp + .ok_or(ErrorCode::EphemeralRelativeCondition)?; + if clock.timestamp < created_timestamp + relative_seconds { + return Err(ErrorCode::AssertSecondsRelativeFailed); + } + } + + if let Some(relative_height) = spend.before_height_relative { + let created_height = record + .created_height + .ok_or(ErrorCode::EphemeralRelativeCondition)?; + if created_height + relative_height <= clock.height { + return Err(ErrorCode::AssertBeforeHeightRelativeFailed); + } + } + + if let Some(relative_seconds) = spend.before_seconds_relative { + let created_timestamp = record + .created_timestamp + .ok_or(ErrorCode::EphemeralRelativeCondition)?; + if created_timestamp + relative_seconds <= clock.timestamp { + return Err(ErrorCode::AssertBeforeSecondsRelativeFailed); + } + } + + Ok(()) +} + +pub(crate) fn validate_reserve_fee( + conditions: &OwnedSpendBundleConditions, + fee: u64, +) -> Result<(), ErrorCode> { + if fee < conditions.reserve_fee { + return Err(ErrorCode::ReserveFeeConditionFailed); + } + Ok(()) +} + +fn validate_puzzle_hashes( + spend_bundle: &SpendBundle, + conditions: &OwnedSpendBundleConditions, +) -> Result<(), ErrorCode> { + let bundle_puzzle_hashes = spend_bundle + .coin_spends + .iter() + .map(|spend| spend.coin.puzzle_hash) + .collect::>(); + let condition_puzzle_hashes = conditions + .spends + .iter() + .map(|spend| spend.puzzle_hash) + .collect::>(); + + if bundle_puzzle_hashes != condition_puzzle_hashes { + return Err(ErrorCode::InvalidSpendBundle); + } + Ok(()) +} + +fn validate_absolute_conditions( + conditions: &OwnedSpendBundleConditions, + clock: ValidationClock, +) -> Result<(), ErrorCode> { + if clock.height < conditions.height_absolute { + return Err(ErrorCode::AssertHeightAbsoluteFailed); + } + if clock.timestamp < conditions.seconds_absolute { + return Err(ErrorCode::AssertSecondsAbsoluteFailed); + } + if let Some(height) = conditions.before_height_absolute + && height <= clock.height + { + return Err(ErrorCode::AssertBeforeHeightAbsoluteFailed); + } + if let Some(timestamp) = conditions.before_seconds_absolute + && timestamp <= clock.timestamp + { + return Err(ErrorCode::AssertBeforeSecondsAbsoluteFailed); + } + Ok(()) +} + +fn parse_additions(spend: &OwnedSpendConditions) -> SpendAdditions { + let additions = spend + .create_coin + .iter() + .map(|(puzzle_hash, amount, hint)| CreatedCoin { + coin: Coin::new(spend.coin_id, *puzzle_hash, *amount), + hint: hint + .as_ref() + .filter(|bytes| bytes.len() == 32) + .and_then(|bytes| Bytes32::try_from(bytes.as_ref()).ok()), + }) + .collect(); + + SpendAdditions { + coin_id: spend.coin_id, + additions, + } +} + +#[cfg(test)] +mod tests { + use chia_bls::Signature; + use chia_consensus::owned_conditions::{OwnedSpendBundleConditions, OwnedSpendConditions}; + use chia_consensus::validation_error::ErrorCode; + use chia_protocol::{Bytes, Bytes32, Coin, CoinSpend, Program, SpendBundle}; + use chia_sdk_types::conditions::{ + AssertBeforeHeightAbsolute, AssertHeightAbsolute, Condition, Conditions, CreateCoin, Memos, + }; + use clvmr::NodePtr; + + use super::{ + CoinRecord, ValidationClock, parse_additions, validate_absolute_conditions, + validate_relative_conditions, validate_reserve_fee, + }; + use crate::{FullNodeSimulator, Simulator, SimulatorError, to_program, to_puzzle}; + + fn spend_with_condition( + coin: Coin, + puzzle_reveal: Program, + puzzle_hash: Bytes32, + condition: Condition, + ) -> anyhow::Result { + let conditions = Conditions::new().with(condition).with(CreateCoin::new( + puzzle_hash, + coin.amount - 1, + Memos::None, + )); + Ok(SpendBundle::new( + vec![CoinSpend::new(coin, puzzle_reveal, to_program(conditions)?)], + Signature::default(), + )) + } + + #[test] + fn simulators_match_on_shared_absolute_validation() -> anyhow::Result<()> { + let (puzzle_hash, puzzle_reveal) = to_puzzle(1)?; + + let mut legacy = Simulator::new(); + legacy.create_block(); + let legacy_coin = legacy.new_coin(puzzle_hash, 100); + let legacy_bundle = spend_with_condition( + legacy_coin, + puzzle_reveal.clone(), + puzzle_hash, + AssertHeightAbsolute::new(legacy.height() + 1).into(), + )?; + assert!(matches!( + legacy.new_transaction(legacy_bundle), + Err(SimulatorError::Validation( + ErrorCode::AssertHeightAbsoluteFailed + )) + )); + + let mut full_node = FullNodeSimulator::new(); + let full_node_coin = full_node.new_coin(puzzle_hash, 100); + let full_node_bundle = spend_with_condition( + full_node_coin, + puzzle_reveal, + puzzle_hash, + AssertHeightAbsolute::new(full_node.height() + 1).into(), + )?; + assert!(matches!( + full_node.push_tx_detailed(full_node_bundle).error, + Some(SimulatorError::Validation( + ErrorCode::AssertHeightAbsoluteFailed + )) + )); + + Ok(()) + } + + #[test] + fn simulators_reject_assert_before_equality() -> anyhow::Result<()> { + let (puzzle_hash, puzzle_reveal) = to_puzzle(1)?; + + let mut legacy = Simulator::new(); + legacy.create_block(); + let legacy_coin = legacy.new_coin(puzzle_hash, 100); + let legacy_bundle = spend_with_condition( + legacy_coin, + puzzle_reveal.clone(), + puzzle_hash, + AssertBeforeHeightAbsolute::new(legacy.height()).into(), + )?; + assert!(matches!( + legacy.new_transaction(legacy_bundle), + Err(SimulatorError::Validation( + ErrorCode::AssertBeforeHeightAbsoluteFailed + )) + )); + + let mut full_node = FullNodeSimulator::new(); + let full_node_coin = full_node.new_coin(puzzle_hash, 100); + let full_node_bundle = spend_with_condition( + full_node_coin, + puzzle_reveal, + puzzle_hash, + AssertBeforeHeightAbsolute::new(full_node.height()).into(), + )?; + assert!(matches!( + full_node.push_tx_detailed(full_node_bundle).error, + Some(SimulatorError::Validation( + ErrorCode::AssertBeforeHeightAbsoluteFailed + )) + )); + + Ok(()) + } + + #[test] + fn parses_valid_hints_and_ignores_other_memos() { + let valid_hint = Bytes32::from([7; 32]); + let spend = OwnedSpendConditions { + coin_id: Bytes32::from([1; 32]), + create_coin: vec![ + (Bytes32::from([2; 32]), 3, Some(valid_hint.to_vec().into())), + (Bytes32::from([4; 32]), 5, Some(Bytes::from(vec![6; 31]))), + (Bytes32::from([7; 32]), 8, None), + ], + ..Default::default() + }; + + let parsed = parse_additions(&spend); + + assert_eq!(parsed.additions.len(), 3); + assert_eq!(parsed.additions[0].hint, Some(valid_hint)); + assert_eq!(parsed.additions[1].hint, None); + assert_eq!(parsed.additions[2].hint, None); + } + + #[test] + fn absolute_and_relative_before_checks_reject_equality() { + let clock = ValidationClock { + height: 10, + timestamp: 20, + }; + let conditions = OwnedSpendBundleConditions { + before_height_absolute: Some(clock.height), + before_seconds_absolute: Some(clock.timestamp), + ..Default::default() + }; + let spend = OwnedSpendConditions { + before_height_relative: Some(0), + before_seconds_relative: Some(0), + ..Default::default() + }; + let record = CoinRecord { + created_height: Some(clock.height), + created_timestamp: Some(clock.timestamp), + }; + + assert_eq!( + validate_absolute_conditions(&conditions, clock), + Err(ErrorCode::AssertBeforeHeightAbsoluteFailed) + ); + assert_eq!( + validate_relative_conditions(&spend, record, clock), + Err(ErrorCode::AssertBeforeHeightRelativeFailed) + ); + } + + #[test] + fn reserve_fee_uses_saturated_bundle_fee() { + let conditions = OwnedSpendBundleConditions { + reserve_fee: 6, + removal_amount: 10, + addition_amount: 5, + ..Default::default() + }; + + assert_eq!( + validate_reserve_fee(&conditions, 5), + Err(ErrorCode::ReserveFeeConditionFailed) + ); + assert!(validate_reserve_fee(&conditions, 6).is_ok()); + } +} diff --git a/napi/__test__/full_node_simulator.spec.ts b/napi/__test__/full_node_simulator.spec.ts new file mode 100644 index 000000000..3691a6fad --- /dev/null +++ b/napi/__test__/full_node_simulator.spec.ts @@ -0,0 +1,159 @@ +import test from "ava"; + +import { + bytesEqual, + Clvm, + CoinSpend, + FullNodeSimulator, + RpcClient, + SecretKey, + SpendBundle, + Signature, + standardPuzzleHash, +} from "../index.js"; + +test("full node simulator exposes prefarm rewards", (t) => { + const sim = new FullNodeSimulator(); + const prefarmPuzzleHash = sim.getPrefarmPuzzleHash(); + + t.true(bytesEqual(sim.getFarmingPh(), prefarmPuzzleHash)); + + const prefarmRecords = + sim.getCoinRecordsByPuzzleHash(prefarmPuzzleHash).coinRecords ?? []; + t.is(prefarmRecords.length, 2); + t.true(prefarmRecords.every((record) => record.coinbase)); + t.true(prefarmRecords.every((record) => !record.spent)); + t.is( + prefarmRecords.reduce((sum, record) => sum + record.coin.amount, 0n), + 21_000_000_000_000_000_000n + ); + + const genesis = sim.getBlockRecordByHeight(0).blockRecord!; + t.is(genesis.rewardClaimsIncorporated?.length, 2); + t.true( + genesis.rewardClaimsIncorporated!.every((coin) => + bytesEqual(coin.puzzleHash, prefarmPuzzleHash) + ) + ); +}); + +test("full node simulator derives prefarm from explicit secret key", (t) => { + const rootSecretKey = SecretKey.fromSeed(Buffer.alloc(32, 42)); + const expectedPrefarmSecretKey = rootSecretKey + .deriveHardenedPath([12381, 8444, 2, 1]) + .deriveSynthetic(); + const expectedPrefarmPuzzleHash = standardPuzzleHash( + expectedPrefarmSecretKey.publicKey() + ); + + const sim = FullNodeSimulator.withSecretKey(rootSecretKey); + const derivedPrefarmSecretKey = sim + .getMasterSecretKey() + .deriveHardenedPath([12381, 8444, 2, 1]) + .deriveSynthetic(); + t.true( + bytesEqual( + derivedPrefarmSecretKey.toBytes(), + expectedPrefarmSecretKey.toBytes() + ) + ); + t.true(bytesEqual(sim.getPrefarmPuzzleHash(), expectedPrefarmPuzzleHash)); +}); + +test("full node simulator includes farmed rewards in block records", (t) => { + const sim = FullNodeSimulator.withSeed(123n); + const block = sim.farmBlock(1)[0]; + + t.is(block.rewardClaimsIncorporated?.length, 1); + const reward = block.rewardClaimsIncorporated![0]; + t.is(reward.amount, 2_000_000_000_000n); + t.true(bytesEqual(reward.puzzleHash, sim.getPrefarmPuzzleHash())); + + const rewardRecord = sim.getCoinRecordByName(reward.coinId()).coinRecord; + t.truthy(rewardRecord); + t.true(rewardRecord!.coinbase); + t.false(rewardRecord!.spent); +}); + +test("full node simulator push tx waits for manual farming", (t) => { + const sim = new FullNodeSimulator(); + const clvm = new Clvm(); + const puzzle = clvm.parse("1"); + const puzzleHash = puzzle.treeHash(); + const coin = sim.newCoin(puzzleHash, 100n); + + const spendBundle = new SpendBundle( + [ + new CoinSpend( + coin, + puzzle.serialize(), + clvm.parse(`((51 0x${Buffer.from(puzzleHash).toString("hex")} 99))`).serialize() + ), + ], + Signature.infinity() + ); + + t.true(sim.pushTx(spendBundle).success); + t.is(sim.getBlockchainState().blockchainState?.mempoolSize, 1); + + sim.farmBlock(1); + t.is(sim.getBlockchainState().blockchainState?.mempoolSize, 0); +}); + +test("full node simulator can serve rpc over http", async (t) => { + const sim = new FullNodeSimulator(); + const server = await sim.startServer(); + + try { + const rpc = new RpcClient(server.url); + const networkInfo = await rpc.getNetworkInfo(); + t.true(networkInfo.success); + t.is(networkInfo.networkName, "simulator0"); + + const clvm = new Clvm(); + const puzzle = clvm.parse("1"); + const puzzleHash = puzzle.treeHash(); + const coin = sim.newCoin(puzzleHash, 100n); + + const spendBundle = new SpendBundle( + [ + new CoinSpend( + coin, + puzzle.serialize(), + clvm + .parse(`((51 0x${Buffer.from(puzzleHash).toString("hex")} 99))`) + .serialize() + ), + ], + Signature.infinity() + ); + + const pushResponse = await rpc.pushTx(spendBundle); + t.true(pushResponse.success, pushResponse.error ?? undefined); + + const state = await rpc.getBlockchainState(); + t.is(state.blockchainState?.mempoolSize, 1); + } finally { + server.close(); + } +}); + +test("full node simulator can dump and restore state", (t) => { + const sim = FullNodeSimulator.withSeed(123n); + sim.farmBlock(2); + const state = sim.dumpState(); + const peak = sim.headerHash(); + + const expected = FullNodeSimulator.withSeed(999n); + expected.restoreState(state); + const expectedNext = expected.farmBlock(1)[0].headerHash; + + sim.farmBlock(1); + t.false(bytesEqual(sim.headerHash(), peak)); + + sim.restoreState(state); + t.true(bytesEqual(sim.headerHash(), peak)); + t.deepEqual(sim.drainEvents(), []); + t.true(bytesEqual(sim.farmBlock(1)[0].headerHash, expectedNext)); + t.is(sim.getBlockchainState().blockchainState?.mempoolSize, 0); +}); diff --git a/napi/index.d.ts b/napi/index.d.ts index 6a340f7c5..df4172cf7 100644 --- a/napi/index.d.ts +++ b/napi/index.d.ts @@ -1194,6 +1194,89 @@ export declare class FullBlock { set transactionsGeneratorRefList(value: Array) } +export declare class FullNodeSimulator { + clone(): FullNodeSimulator + constructor() + static withSeed(seed: bigint): FullNodeSimulator + static withSecretKey(secretKey: SecretKey): FullNodeSimulator + height(): number + headerHash(): Buffer + headerHashOf(height: number): Buffer | null + insertCoin(coin: Coin): void + newCoin(puzzleHash: Uint8Array, amount: bigint): Coin + getFarmingPh(): Buffer + getMasterSecretKey(): SecretKey + getPrefarmPuzzleHash(): Buffer + setFarmingPh(puzzleHash: Uint8Array): void + getBlockchainState(): BlockchainStateResponse + getNetworkInfo(): GetNetworkInfoResponse + getAggsigAdditionalData(): Buffer + getBlockRecord(headerHash: Uint8Array): GetBlockRecordResponse + getBlockRecordByHeight(height: number): GetBlockRecordResponse + getBlockRecords(start: number, end: number): GetBlockRecordsResponse + getAdditionsAndRemovals(headerHash: Uint8Array): AdditionsAndRemovalsResponse + getBlockSpends(headerHash: Uint8Array): GetBlockSpendsResponse + getCoinRecordByName(name: Uint8Array): GetCoinRecordResponse + getCoinRecordsByNames(names: Array, startHeight?: number | undefined | null, endHeight?: number | undefined | null, includeSpentCoins?: boolean | undefined | null): GetCoinRecordsResponse + getCoinRecordsByHint(hint: Uint8Array, startHeight?: number | undefined | null, endHeight?: number | undefined | null, includeSpentCoins?: boolean | undefined | null): GetCoinRecordsResponse + getCoinRecordsByHints(hints: Array, startHeight?: number | undefined | null, endHeight?: number | undefined | null, includeSpentCoins?: boolean | undefined | null): GetCoinRecordsResponse + getCoinRecordsByParentIds(parentIds: Array, startHeight?: number | undefined | null, endHeight?: number | undefined | null, includeSpentCoins?: boolean | undefined | null): GetCoinRecordsResponse + getCoinRecordsByPuzzleHash(puzzleHash: Uint8Array, startHeight?: number | undefined | null, endHeight?: number | undefined | null, includeSpentCoins?: boolean | undefined | null): GetCoinRecordsResponse + getCoinRecordsByPuzzleHashes(puzzleHashes: Array, startHeight?: number | undefined | null, endHeight?: number | undefined | null, includeSpentCoins?: boolean | undefined | null): GetCoinRecordsResponse + getPuzzleAndSolution(coinId: Uint8Array, height?: number | undefined | null): GetPuzzleAndSolutionResponse + pushTx(spendBundle: SpendBundle): PushTxResponse + getMempoolItemByTxId(txId: Uint8Array): GetMempoolItemResponse + getMempoolItemsByCoinName(coinName: Uint8Array): GetMempoolItemsResponse + farmBlock(blocks: number): Array + revertBlocks(blocks: number): Array + reorgBlocks(numOfBlocksToRev: number, numOfNewBlocks: number): Array + drainEvents(): Array + dumpState(): string + restoreState(state: string): void + startServer(): Promise +} + +export declare class FullNodeSimulatorBlockEvent { + clone(): FullNodeSimulatorBlockEvent + get height(): number + set height(value: number) + get headerHash(): Buffer + set headerHash(value: Uint8Array) + get previousHeaderHash(): Buffer + set previousHeaderHash(value: Uint8Array) + get additions(): Array + set additions(value: Array) + get removals(): Array + set removals(value: Array) +} + +export declare class FullNodeSimulatorEvent { + clone(): FullNodeSimulatorEvent + block(): FullNodeSimulatorBlockEvent | null + reorg(): FullNodeSimulatorReorgEvent | null +} + +export declare class FullNodeSimulatorReorgEvent { + clone(): FullNodeSimulatorReorgEvent + get forkHeight(): number + set forkHeight(value: number) + get oldPeakHash(): Buffer + set oldPeakHash(value: Uint8Array) + get newPeakHash(): Buffer + set newPeakHash(value: Uint8Array) + get revertedHeaderHashes(): Array + set revertedHeaderHashes(value: Array) + get newHeaderHashes(): Array + set newHeaderHashes(value: Array) +} + +export declare class FullNodeSimulatorServer { + clone(): FullNodeSimulatorServer + close(): void + get url(): string + set url(value: string) +} + export declare class GetBlockRecordResponse { clone(): GetBlockRecordResponse constructor(blockRecord: BlockRecord | undefined | null, error: string | undefined | null, success: boolean) diff --git a/napi/index.js b/napi/index.js index 35d2a2440..774c58a89 100644 --- a/napi/index.js +++ b/napi/index.js @@ -450,6 +450,11 @@ module.exports.FoliageTransactionBlock = nativeBinding.FoliageTransactionBlock module.exports.Force1Of2RestrictedVariableMemo = nativeBinding.Force1Of2RestrictedVariableMemo module.exports.Force1of2RestrictedVariableMemo = nativeBinding.Force1of2RestrictedVariableMemo module.exports.FullBlock = nativeBinding.FullBlock +module.exports.FullNodeSimulator = nativeBinding.FullNodeSimulator +module.exports.FullNodeSimulatorBlockEvent = nativeBinding.FullNodeSimulatorBlockEvent +module.exports.FullNodeSimulatorEvent = nativeBinding.FullNodeSimulatorEvent +module.exports.FullNodeSimulatorReorgEvent = nativeBinding.FullNodeSimulatorReorgEvent +module.exports.FullNodeSimulatorServer = nativeBinding.FullNodeSimulatorServer module.exports.GetBlockRecordResponse = nativeBinding.GetBlockRecordResponse module.exports.GetBlockRecordsResponse = nativeBinding.GetBlockRecordsResponse module.exports.GetBlockResponse = nativeBinding.GetBlockResponse diff --git a/pyo3/chia_wallet_sdk.pyi b/pyo3/chia_wallet_sdk.pyi index 1b3c3ffe6..8c9974e84 100644 --- a/pyo3/chia_wallet_sdk.pyi +++ b/pyo3/chia_wallet_sdk.pyi @@ -977,6 +977,70 @@ class Constants: def one_of_n() -> bytes: ... @staticmethod def one_of_n_hash() -> bytes: ... +class FullNodeSimulator: + def clone(self) -> FullNodeSimulator: ... + def __init__(self) -> None: ... + @staticmethod + def with_seed(seed: int) -> FullNodeSimulator: ... + @staticmethod + def with_secret_key(secretKey: SecretKey) -> FullNodeSimulator: ... + def height(self) -> int: ... + def header_hash(self) -> bytes: ... + def header_hash_of(self, height: int) -> Optional[bytes]: ... + def insert_coin(self, coin: Coin) -> None: ... + def new_coin(self, puzzleHash: bytes, amount: int) -> Coin: ... + def get_farming_ph(self) -> bytes: ... + def get_master_secret_key(self) -> SecretKey: ... + def get_prefarm_puzzle_hash(self) -> bytes: ... + def set_farming_ph(self, puzzleHash: bytes) -> None: ... + def get_blockchain_state(self) -> BlockchainStateResponse: ... + def get_network_info(self) -> GetNetworkInfoResponse: ... + def get_aggsig_additional_data(self) -> bytes: ... + def get_block_record(self, headerHash: bytes) -> GetBlockRecordResponse: ... + def get_block_record_by_height(self, height: int) -> GetBlockRecordResponse: ... + def get_block_records(self, start: int, end: int) -> GetBlockRecordsResponse: ... + def get_additions_and_removals(self, headerHash: bytes) -> AdditionsAndRemovalsResponse: ... + def get_block_spends(self, headerHash: bytes) -> GetBlockSpendsResponse: ... + def get_coin_record_by_name(self, name: bytes) -> GetCoinRecordResponse: ... + def get_coin_records_by_names(self, names: List[bytes], startHeight: Optional[int] = None, endHeight: Optional[int] = None, includeSpentCoins: Optional[bool] = None) -> GetCoinRecordsResponse: ... + def get_coin_records_by_hint(self, hint: bytes, startHeight: Optional[int] = None, endHeight: Optional[int] = None, includeSpentCoins: Optional[bool] = None) -> GetCoinRecordsResponse: ... + def get_coin_records_by_hints(self, hints: List[bytes], startHeight: Optional[int] = None, endHeight: Optional[int] = None, includeSpentCoins: Optional[bool] = None) -> GetCoinRecordsResponse: ... + def get_coin_records_by_parent_ids(self, parentIds: List[bytes], startHeight: Optional[int] = None, endHeight: Optional[int] = None, includeSpentCoins: Optional[bool] = None) -> GetCoinRecordsResponse: ... + def get_coin_records_by_puzzle_hash(self, puzzleHash: bytes, startHeight: Optional[int] = None, endHeight: Optional[int] = None, includeSpentCoins: Optional[bool] = None) -> GetCoinRecordsResponse: ... + def get_coin_records_by_puzzle_hashes(self, puzzleHashes: List[bytes], startHeight: Optional[int] = None, endHeight: Optional[int] = None, includeSpentCoins: Optional[bool] = None) -> GetCoinRecordsResponse: ... + def get_puzzle_and_solution(self, coinId: bytes, height: Optional[int] = None) -> GetPuzzleAndSolutionResponse: ... + def push_tx(self, spendBundle: SpendBundle) -> PushTxResponse: ... + def get_mempool_item_by_tx_id(self, txId: bytes) -> GetMempoolItemResponse: ... + def get_mempool_items_by_coin_name(self, coinName: bytes) -> GetMempoolItemsResponse: ... + def farm_block(self, blocks: int) -> List[BlockRecord]: ... + def revert_blocks(self, blocks: int) -> List[bytes]: ... + def reorg_blocks(self, numOfBlocksToRev: int, numOfNewBlocks: int) -> List[BlockRecord]: ... + def drain_events(self) -> List[FullNodeSimulatorEvent]: ... + def dump_state(self) -> str: ... + def restore_state(self, state: str) -> None: ... + async def start_server(self) -> Awaitable[FullNodeSimulatorServer]: ... +class FullNodeSimulatorEvent: + def clone(self) -> FullNodeSimulatorEvent: ... + def block(self) -> Optional[FullNodeSimulatorBlockEvent]: ... + def reorg(self) -> Optional[FullNodeSimulatorReorgEvent]: ... +class FullNodeSimulatorBlockEvent: + def clone(self) -> FullNodeSimulatorBlockEvent: ... + height: int + header_hash: bytes + previous_header_hash: bytes + additions: List[CoinRecord] + removals: List[CoinRecord] +class FullNodeSimulatorReorgEvent: + def clone(self) -> FullNodeSimulatorReorgEvent: ... + fork_height: int + old_peak_hash: bytes + new_peak_hash: bytes + reverted_header_hashes: List[bytes] + new_header_hashes: List[bytes] +class FullNodeSimulatorServer: + def clone(self) -> FullNodeSimulatorServer: ... + url: str + def close(self) -> None: ... class Vault: def clone(self) -> Vault: ... def __init__(self, coin: Coin, proof: Proof, info: VaultInfo) -> None: ...