From c0ab371b4bbf35c79b4fe542512f07ba9a713e8d Mon Sep 17 00:00:00 2001 From: rob-stacks Date: Mon, 1 Dec 2025 15:21:46 +0100 Subject: [PATCH 1/8] added no_fees mode --- stackslib/src/chainstate/stacks/db/mod.rs | 4 + .../src/chainstate/stacks/db/transactions.rs | 14 +- stackslib/src/clarity_vm/clarity.rs | 8 + stackslib/src/net/api/blockreplay.rs | 14 +- stackslib/src/net/api/blocksimulate.rs | 435 ++++++++++++++++++ stackslib/src/net/api/mod.rs | 4 + 6 files changed, 469 insertions(+), 10 deletions(-) create mode 100644 stackslib/src/net/api/blocksimulate.rs diff --git a/stackslib/src/chainstate/stacks/db/mod.rs b/stackslib/src/chainstate/stacks/db/mod.rs index dae996501c1..f148ff4405f 100644 --- a/stackslib/src/chainstate/stacks/db/mod.rs +++ b/stackslib/src/chainstate/stacks/db/mod.rs @@ -606,6 +606,10 @@ impl<'a, 'b> ClarityTx<'a, 'b> { }) .expect("FATAL: `ust-liquid-supply` overflowed"); } + + pub fn disable_fees(&mut self) { + self.block.no_fees = true; + } } pub struct ChainstateTx<'a> { diff --git a/stackslib/src/chainstate/stacks/db/transactions.rs b/stackslib/src/chainstate/stacks/db/transactions.rs index 46dd54b0413..11c9ff40f96 100644 --- a/stackslib/src/chainstate/stacks/db/transactions.rs +++ b/stackslib/src/chainstate/stacks/db/transactions.rs @@ -564,6 +564,7 @@ impl StacksChainState { } StacksChainState::account_debit(clarity_tx, &payer_account.principal, fee); + Ok(fee) } @@ -1554,6 +1555,8 @@ impl StacksChainState { debug!("Process transaction {} ({})", tx.txid(), tx.payload.name()); let epoch = clarity_block.get_epoch(); + let no_fees = clarity_block.block.no_fees; + StacksChainState::process_transaction_precheck(&clarity_block.config, tx, epoch)?; // what version of Clarity did the transaction caller want? And, is it valid now? @@ -1578,7 +1581,10 @@ impl StacksChainState { let payer_address = payer_account.principal.clone(); let payer_nonce = payer_account.nonce; - StacksChainState::pay_transaction_fee(&mut transaction, fee, payer_account)?; + + if !no_fees { + StacksChainState::pay_transaction_fee(&mut transaction, fee, payer_account)?; + } // origin balance may have changed (e.g. if the origin paid the tx fee), so reload the account let origin_account = @@ -1619,8 +1625,10 @@ impl StacksChainState { None, )?; - let new_payer_account = StacksChainState::get_payer_account(&mut transaction, tx); - StacksChainState::pay_transaction_fee(&mut transaction, fee, new_payer_account)?; + if !no_fees { + let new_payer_account = StacksChainState::get_payer_account(&mut transaction, tx); + StacksChainState::pay_transaction_fee(&mut transaction, fee, new_payer_account)?; + } // update the account nonces StacksChainState::update_account_nonce( diff --git a/stackslib/src/clarity_vm/clarity.rs b/stackslib/src/clarity_vm/clarity.rs index b563bbbc959..9855d9f0aaf 100644 --- a/stackslib/src/clarity_vm/clarity.rs +++ b/stackslib/src/clarity_vm/clarity.rs @@ -117,6 +117,7 @@ pub struct ClarityBlockConnection<'a, 'b> { mainnet: bool, chain_id: u32, epoch: StacksEpochId, + pub no_fees: bool, } /// @@ -318,6 +319,7 @@ impl ClarityBlockConnection<'_, '_> { mainnet: false, chain_id: CHAIN_ID_TESTNET, epoch, + no_fees: false, } } @@ -444,6 +446,7 @@ impl ClarityInstance { mainnet: self.mainnet, chain_id: self.chain_id, epoch: epoch.epoch_id, + no_fees: false, } } @@ -468,6 +471,7 @@ impl ClarityInstance { mainnet: self.mainnet, chain_id: self.chain_id, epoch, + no_fees: false, } } @@ -494,6 +498,7 @@ impl ClarityInstance { mainnet: self.mainnet, chain_id: self.chain_id, epoch, + no_fees: false, }; let use_mainnet = self.mainnet; @@ -590,6 +595,7 @@ impl ClarityInstance { mainnet: self.mainnet, chain_id: self.chain_id, epoch, + no_fees: false, }; let use_mainnet = self.mainnet; @@ -698,6 +704,7 @@ impl ClarityInstance { mainnet: self.mainnet, chain_id: self.chain_id, epoch: epoch.epoch_id, + no_fees: false, } } @@ -738,6 +745,7 @@ impl ClarityInstance { mainnet: self.mainnet, chain_id: self.chain_id, epoch: epoch.epoch_id, + no_fees: false, } } diff --git a/stackslib/src/net/api/blockreplay.rs b/stackslib/src/net/api/blockreplay.rs index dac83bf6a73..c8a70807920 100644 --- a/stackslib/src/net/api/blockreplay.rs +++ b/stackslib/src/net/api/blockreplay.rs @@ -51,7 +51,7 @@ struct BlockReplayProfiler { struct BlockReplayProfiler(); #[derive(Default)] -struct BlockReplayProfilerResult { +pub struct BlockReplayProfilerResult { cpu_instructions: Option, cpu_cycles: Option, cpu_ref_cycles: Option, @@ -310,7 +310,7 @@ impl RPCNakamotoBlockReplayRequestHandler { let tx_merkle_root = block.header.tx_merkle_root.clone(); let mut rpc_replayed_block = - RPCReplayedBlock::from_block(block, block_fees, tenure_id, parent_block_id); + RPCReplayedBlock::from_block(&replayed_block, block_fees, tenure_id, parent_block_id); for (receipt, profiler_result) in &txs_receipts { let transaction = RPCReplayedBlockTransaction::from_receipt(receipt, &profiler_result); @@ -356,7 +356,7 @@ pub struct RPCReplayedBlockTransaction { } impl RPCReplayedBlockTransaction { - fn from_receipt( + pub fn from_receipt( receipt: &StacksTransactionReceipt, profiler_result: &BlockReplayProfilerResult, ) -> Self { @@ -433,7 +433,7 @@ pub struct RPCReplayedBlock { impl RPCReplayedBlock { pub fn from_block( - block: NakamotoBlock, + block: &NakamotoBlock, block_fees: u128, tenure_id: ConsensusHash, parent_block_id: StacksBlockId, @@ -448,11 +448,11 @@ impl RPCReplayedBlock { parent_block_id, consensus_hash: tenure_id, fees: block_fees, - tx_merkle_root: block.header.tx_merkle_root, + tx_merkle_root: block.header.tx_merkle_root.clone(), state_index_root: block.header.state_index_root, timestamp: block.header.timestamp, - miner_signature: block.header.miner_signature, - signer_signature: block.header.signer_signature, + miner_signature: block.header.miner_signature.clone(), + signer_signature: block.header.signer_signature.clone(), transactions: vec![], valid_merkle_root: false, } diff --git a/stackslib/src/net/api/blocksimulate.rs b/stackslib/src/net/api/blocksimulate.rs new file mode 100644 index 00000000000..ff59d2f2554 --- /dev/null +++ b/stackslib/src/net/api/blocksimulate.rs @@ -0,0 +1,435 @@ +// Copyright (C) 2025 Stacks Open Internet Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use clarity::vm::costs::ExecutionCost; +use clarity::vm::Value; +use regex::{Captures, Regex}; +use stacks_common::codec::{Error as CodecError, StacksMessageCodec, MAX_PAYLOAD_LEN}; +use stacks_common::types::chainstate::{BlockHeaderHash, ConsensusHash, StacksBlockId, TrieHash}; +use stacks_common::types::net::PeerHost; +use stacks_common::util::hash::{hex_bytes, Sha512Trunc256Sum}; +use stacks_common::util::secp256k1::MessageSignature; +use stacks_common::util::serde_serializers::prefix_hex_codec; +use url::form_urlencoded; + +use crate::burnchains::Txid; +use crate::chainstate::burn::db::sortdb::SortitionDB; +use crate::chainstate::nakamoto::miner::{MinerTenureInfoCause, NakamotoBlockBuilder}; +use crate::chainstate::nakamoto::{NakamotoBlock, NakamotoChainState}; +use crate::chainstate::stacks::db::StacksChainState; +use crate::chainstate::stacks::events::{StacksTransactionReceipt, TransactionOrigin}; +use crate::chainstate::stacks::miner::{BlockBuilder, BlockLimitFunction, TransactionResult}; +use crate::chainstate::stacks::{Error as ChainError, StacksTransaction, TransactionPayload}; +use crate::config::DEFAULT_MAX_TENURE_BYTES; +use crate::net::api::blockreplay::{ + BlockReplayProfilerResult, RPCReplayedBlock, RPCReplayedBlockTransaction, +}; +use crate::net::http::{ + parse_json, Error, HttpContentType, HttpNotFound, HttpRequest, HttpRequestContents, + HttpRequestPreamble, HttpResponse, HttpResponseContents, HttpResponsePayload, + HttpResponsePreamble, HttpServerError, +}; +use crate::net::httpcore::{RPCRequestHandler, StacksHttpResponse}; +use crate::net::{Error as NetError, StacksHttpRequest, StacksNodeState}; + +#[derive(Clone)] +pub struct RPCNakamotoBlockSimulateRequestHandler { + pub block_id: Option, + pub auth: Option, + pub profiler: bool, + pub transactions: Vec, +} + +impl RPCNakamotoBlockSimulateRequestHandler { + pub fn new(auth: Option) -> Self { + Self { + block_id: None, + auth, + profiler: false, + transactions: vec![], + } + } + + fn parse_json(body: &[u8]) -> Result, Error> { + let transactions_hex: Vec = serde_json::from_slice(body) + .map_err(|e| Error::DecodeError(format!("Failed to parse body: {e}")))?; + + let mut transactions = vec![]; + + for tx_hex in transactions_hex { + let tx_bytes = + hex_bytes(&tx_hex).map_err(|_e| Error::DecodeError("Failed to parse tx".into()))?; + let tx = StacksTransaction::consensus_deserialize(&mut &tx_bytes[..]).map_err(|e| { + if let CodecError::DeserializeError(msg) = e { + Error::DecodeError(format!("Failed to deserialize transaction: {}", msg)) + } else { + e.into() + } + })?; + transactions.push(tx); + } + + Ok(transactions) + } + + pub fn block_simulate( + &self, + sortdb: &SortitionDB, + chainstate: &mut StacksChainState, + ) -> Result { + let Some(block_id) = &self.block_id else { + return Err(ChainError::InvalidStacksBlock("block_id is None".into())); + }; + + let Some((tenure_id, parent_block_id)) = chainstate + .nakamoto_blocks_db() + .get_tenure_and_parent_block_id(&block_id)? + else { + return Err(ChainError::NoSuchBlockError); + }; + + let staging_db_path = chainstate.get_nakamoto_staging_blocks_path()?; + let db_conn = StacksChainState::open_nakamoto_staging_blocks(&staging_db_path, false)?; + let rowid = db_conn + .conn() + .get_nakamoto_block_rowid(&block_id)? + .ok_or(ChainError::NoSuchBlockError)?; + + let mut blob_fd = match db_conn.open_nakamoto_block(rowid, false).map_err(|e| { + let msg = format!("Failed to open Nakamoto block {}: {:?}", &block_id, &e); + warn!("{}", &msg); + msg + }) { + Ok(blob_fd) => blob_fd, + Err(e) => return Err(ChainError::InvalidStacksBlock(e)), + }; + + let block = match NakamotoBlock::consensus_deserialize(&mut blob_fd).map_err(|e| { + let msg = format!("Failed to read Nakamoto block {}: {:?}", &block_id, &e); + warn!("{}", &msg); + msg + }) { + Ok(block) => block, + Err(e) => return Err(ChainError::InvalidStacksBlock(e)), + }; + + let burn_dbconn = match sortdb.index_handle_at_block(chainstate, &parent_block_id) { + Ok(burn_dbconn) => burn_dbconn, + Err(_) => return Err(ChainError::NoSuchBlockError), + }; + + let tenure_change = block + .txs + .iter() + .find(|tx| matches!(tx.payload, TransactionPayload::TenureChange(..))); + let coinbase = block + .txs + .iter() + .find(|tx| matches!(tx.payload, TransactionPayload::Coinbase(..))); + let tenure_cause = tenure_change + .and_then(|tx| match &tx.payload { + TransactionPayload::TenureChange(tc) => Some(tc.into()), + _ => None, + }) + .unwrap_or(MinerTenureInfoCause::NoTenureChange); + + let parent_stacks_header_opt = + match NakamotoChainState::get_block_header(chainstate.db(), &parent_block_id) { + Ok(parent_stacks_header_opt) => parent_stacks_header_opt, + Err(e) => return Err(e), + }; + + let Some(parent_stacks_header) = parent_stacks_header_opt else { + return Err(ChainError::InvalidStacksBlock( + "Invalid Parent Block".into(), + )); + }; + + let mut builder = match NakamotoBlockBuilder::new( + &parent_stacks_header, + &block.header.consensus_hash, + block.header.burn_spent, + tenure_change, + coinbase, + block.header.pox_treatment.len(), + None, + None, + Some(block.header.timestamp), + u64::from(DEFAULT_MAX_TENURE_BYTES), + ) { + Ok(builder) => builder, + Err(e) => return Err(e), + }; + + let mut miner_tenure_info = + match builder.load_ephemeral_tenure_info(chainstate, &burn_dbconn, tenure_cause) { + Ok(miner_tenure_info) => miner_tenure_info, + Err(e) => return Err(e), + }; + + let burn_chain_height = miner_tenure_info.burn_tip_height; + let mut tenure_tx = match builder.tenure_begin(&burn_dbconn, &mut miner_tenure_info) { + Ok(tenure_tx) => tenure_tx, + Err(e) => return Err(e), + }; + + tenure_tx.disable_fees(); + + let mut block_fees: u128 = 0; + let mut txs_receipts = vec![]; + + for (i, tx) in self.transactions.iter().enumerate() { + let tx_len = tx.tx_len(); + + let tx_result = builder.try_mine_tx_with_len( + &mut tenure_tx, + tx, + tx_len, + &BlockLimitFunction::NO_LIMIT_HIT, + None, + ); + + let err = match tx_result { + TransactionResult::Success(tx_result) => { + txs_receipts.push(tx_result.receipt); + Ok(()) + } + TransactionResult::ProcessingError(e) => { + Err(format!("Error processing tx {}: {}", i, e.error)) + } + TransactionResult::Skipped(e) => Err(format!("Skipped tx {}: {}", i, e.error)), + TransactionResult::Problematic(e) => { + Err(format!("Problematic tx {}: {}", i, e.error)) + } + }; + if let Err(reason) = err { + let txid = tx.txid(); + return Err(ChainError::InvalidStacksTransaction( + format!("Unable to simulate transaction {txid}: {reason}").into(), + false, + )); + } + + block_fees += tx.get_tx_fee() as u128; + } + + let simulated_block = builder.mine_nakamoto_block(&mut tenure_tx, burn_chain_height); + + tenure_tx.rollback_block(); + + let tx_merkle_root = block.header.tx_merkle_root.clone(); + + let mut rpc_replayed_block = + RPCReplayedBlock::from_block(&simulated_block, block_fees, tenure_id, parent_block_id); + + for receipt in &txs_receipts { + let profiler_result = BlockReplayProfilerResult::default(); + let transaction = RPCReplayedBlockTransaction::from_receipt(receipt, &profiler_result); + rpc_replayed_block.transactions.push(transaction); + } + + Ok(rpc_replayed_block) + } +} + +/// Decode the HTTP request +impl HttpRequest for RPCNakamotoBlockSimulateRequestHandler { + fn verb(&self) -> &'static str { + "POST" + } + + fn path_regex(&self) -> Regex { + Regex::new(r#"^/v3/blocks/simulate/(?P[0-9a-f]{64})$"#).unwrap() + } + + fn metrics_identifier(&self) -> &str { + "/v3/blocks/simulate/:block_id" + } + + /// Try to decode this request. + /// There's nothing to load here, so just make sure the request is well-formed. + fn try_parse_request( + &mut self, + preamble: &HttpRequestPreamble, + captures: &Captures, + query: Option<&str>, + body: &[u8], + ) -> Result { + // If no authorization is set, then the block replay endpoint is not enabled + let Some(password) = &self.auth else { + return Err(Error::Http(400, "Bad Request.".into())); + }; + let Some(auth_header) = preamble.headers.get("authorization") else { + return Err(Error::Http(401, "Unauthorized".into())); + }; + if auth_header != password { + return Err(Error::Http(401, "Unauthorized".into())); + } + + let block_id_str = captures + .name("block_id") + .ok_or_else(|| { + Error::DecodeError("Failed to match path to block ID group".to_string()) + })? + .as_str(); + + let block_id = StacksBlockId::from_hex(block_id_str) + .map_err(|_| Error::DecodeError("Invalid path: unparseable block id".to_string()))?; + + self.block_id = Some(block_id); + + if let Some(query_string) = query { + for (key, value) in form_urlencoded::parse(query_string.as_bytes()) { + if key == "profiler" { + if value == "1" { + self.profiler = true; + } + break; + } + } + } + + if preamble.get_content_length() == 0 { + return Err(Error::DecodeError( + "Invalid Http request: expected non-zero-length body for block proposal endpoint" + .to_string(), + )); + } + if preamble.get_content_length() > MAX_PAYLOAD_LEN { + return Err(Error::DecodeError( + "Invalid Http request: BlockProposal body is too big".to_string(), + )); + } + + self.transactions = match preamble.content_type { + Some(HttpContentType::JSON) => Self::parse_json(body)?, + Some(_) => { + return Err(Error::DecodeError( + "Wrong Content-Type for block proposal; expected application/json".to_string(), + )) + } + None => { + return Err(Error::DecodeError( + "Missing Content-Type for block simulation".to_string(), + )) + } + }; + + Ok(HttpRequestContents::new().query_string(query)) + } +} + +impl RPCRequestHandler for RPCNakamotoBlockSimulateRequestHandler { + /// Reset internal state + fn restart(&mut self) { + self.block_id = None; + } + + /// Make the response + fn try_handle_request( + &mut self, + preamble: HttpRequestPreamble, + _contents: HttpRequestContents, + node: &mut StacksNodeState, + ) -> Result<(HttpResponsePreamble, HttpResponseContents), NetError> { + let Some(block_id) = &self.block_id else { + return Err(NetError::SendError("Missing `block_id`".into())); + }; + + let simulated_block_res = + node.with_node_state(|_network, sortdb, chainstate, _mempool, _rpc_args| { + self.block_simulate(sortdb, chainstate) + }); + + // start loading up the block + let simulated_block = match simulated_block_res { + Ok(simulated_block) => simulated_block, + Err(ChainError::NoSuchBlockError) => { + return StacksHttpResponse::new_error( + &preamble, + &HttpNotFound::new(format!("No such block {block_id}\n")), + ) + .try_into_contents() + .map_err(NetError::from) + } + Err(e) => { + // nope -- error trying to check + let msg = format!("Failed to simulate block {}: {:?}\n", &block_id, &e); + warn!("{}", &msg); + return StacksHttpResponse::new_error(&preamble, &HttpServerError::new(msg)) + .try_into_contents() + .map_err(NetError::from); + } + }; + + let preamble = HttpResponsePreamble::ok_json(&preamble); + let body = HttpResponseContents::try_from_json(&simulated_block)?; + Ok((preamble, body)) + } +} + +impl StacksHttpRequest { + /// Make a new block_replay request to this endpoint + pub fn new_block_simulate(host: PeerHost, block_id: &StacksBlockId) -> StacksHttpRequest { + StacksHttpRequest::new_for_peer( + host, + "GET".into(), + format!("/v3/blocks/simulate/{block_id}"), + HttpRequestContents::new(), + ) + .expect("FATAL: failed to construct request from infallible data") + } + + pub fn new_block_simulate_with_profiler( + host: PeerHost, + block_id: &StacksBlockId, + profiler: bool, + ) -> StacksHttpRequest { + StacksHttpRequest::new_for_peer( + host, + "GET".into(), + format!("/v3/blocks/simulate/{block_id}"), + HttpRequestContents::new().query_arg( + "profiler".into(), + if profiler { "1".into() } else { "0".into() }, + ), + ) + .expect("FATAL: failed to construct request from infallible data") + } +} + +/// Decode the HTTP response +impl HttpResponse for RPCNakamotoBlockSimulateRequestHandler { + /// Decode this response from a byte stream. This is called by the client to decode this + /// message + fn try_parse_response( + &self, + preamble: &HttpResponsePreamble, + body: &[u8], + ) -> Result { + let rpc_replayed_block: RPCReplayedBlock = parse_json(preamble, body)?; + Ok(HttpResponsePayload::try_from_json(rpc_replayed_block)?) + } +} + +impl StacksHttpResponse { + pub fn decode_simulated_block(self) -> Result { + let contents = self.get_http_payload_ok()?; + let response_json: serde_json::Value = contents.try_into()?; + let replayed_block: RPCReplayedBlock = serde_json::from_value(response_json) + .map_err(|_e| Error::DecodeError("Failed to decode JSON".to_string()))?; + Ok(replayed_block) + } +} diff --git a/stackslib/src/net/api/mod.rs b/stackslib/src/net/api/mod.rs index a5777a751d9..3661d48a31b 100644 --- a/stackslib/src/net/api/mod.rs +++ b/stackslib/src/net/api/mod.rs @@ -18,6 +18,7 @@ use crate::net::httpcore::StacksHttp; use crate::net::Error as NetError; pub mod blockreplay; +pub mod blocksimulate; pub mod callreadonly; pub mod fastcallreadonly; pub mod get_tenures_fork_info; @@ -78,6 +79,9 @@ impl StacksHttp { self.register_rpc_endpoint(blockreplay::RPCNakamotoBlockReplayRequestHandler::new( self.auth_token.clone(), )); + self.register_rpc_endpoint(blocksimulate::RPCNakamotoBlockSimulateRequestHandler::new( + self.auth_token.clone(), + )); self.register_rpc_endpoint(callreadonly::RPCCallReadOnlyRequestHandler::new( self.maximum_call_argument_size, self.read_only_call_limit.clone(), From de360c6895abfaf010c0a3d68d98394105821f67 Mon Sep 17 00:00:00 2001 From: rob-stacks Date: Mon, 8 Dec 2025 17:14:16 +0100 Subject: [PATCH 2/8] refactored block_replay and block_simulate --- stackslib/src/net/api/blockreplay.rs | 328 ++++++++++++++----------- stackslib/src/net/api/blocksimulate.rs | 165 ++----------- 2 files changed, 202 insertions(+), 291 deletions(-) diff --git a/stackslib/src/net/api/blockreplay.rs b/stackslib/src/net/api/blockreplay.rs index c8a70807920..6f6f20dc337 100644 --- a/stackslib/src/net/api/blockreplay.rs +++ b/stackslib/src/net/api/blockreplay.rs @@ -150,176 +150,220 @@ pub struct RPCNakamotoBlockReplayRequestHandler { pub profiler: bool, } -impl RPCNakamotoBlockReplayRequestHandler { - pub fn new(auth: Option) -> Self { - Self { - block_id: None, - auth, - profiler: false, - } - } - - pub fn block_replay( - &self, - sortdb: &SortitionDB, - chainstate: &mut StacksChainState, - ) -> Result { - let Some(block_id) = &self.block_id else { - return Err(ChainError::InvalidStacksBlock("block_id is None".into())); +pub fn remine_nakamoto_block( + block_id: &StacksBlockId, + sortdb: &SortitionDB, + chainstate: &mut StacksChainState, + enable_profiler: bool, + disable_fees: bool, + get_transactions: F, +) -> Result +where + F: FnOnce(&NakamotoBlock) -> Vec, +{ + let Some((tenure_id, parent_block_id)) = chainstate + .nakamoto_blocks_db() + .get_tenure_and_parent_block_id(block_id)? + else { + return Err(ChainError::NoSuchBlockError); + }; + + let staging_db_path = chainstate.get_nakamoto_staging_blocks_path()?; + let db_conn = StacksChainState::open_nakamoto_staging_blocks(&staging_db_path, false)?; + let rowid = db_conn + .conn() + .get_nakamoto_block_rowid(&block_id)? + .ok_or(ChainError::NoSuchBlockError)?; + + let mut blob_fd = match db_conn.open_nakamoto_block(rowid, false).map_err(|e| { + let msg = format!("Failed to open Nakamoto block {}: {:?}", &block_id, &e); + warn!("{}", &msg); + msg + }) { + Ok(blob_fd) => blob_fd, + Err(e) => return Err(ChainError::InvalidStacksBlock(e)), + }; + + let block = match NakamotoBlock::consensus_deserialize(&mut blob_fd).map_err(|e| { + let msg = format!("Failed to read Nakamoto block {}: {:?}", &block_id, &e); + warn!("{}", &msg); + msg + }) { + Ok(block) => block, + Err(e) => return Err(ChainError::InvalidStacksBlock(e)), + }; + + let burn_dbconn = match sortdb.index_handle_at_block(chainstate, &parent_block_id) { + Ok(burn_dbconn) => burn_dbconn, + Err(_) => return Err(ChainError::NoSuchBlockError), + }; + + let tenure_change = block + .txs + .iter() + .find(|tx| matches!(tx.payload, TransactionPayload::TenureChange(..))); + let coinbase = block + .txs + .iter() + .find(|tx| matches!(tx.payload, TransactionPayload::Coinbase(..))); + let tenure_cause = tenure_change + .and_then(|tx| match &tx.payload { + TransactionPayload::TenureChange(tc) => Some(tc.into()), + _ => None, + }) + .unwrap_or(MinerTenureInfoCause::NoTenureChange); + + let parent_stacks_header_opt = + match NakamotoChainState::get_block_header(chainstate.db(), &parent_block_id) { + Ok(parent_stacks_header_opt) => parent_stacks_header_opt, + Err(e) => return Err(e), }; - let Some((tenure_id, parent_block_id)) = chainstate - .nakamoto_blocks_db() - .get_tenure_and_parent_block_id(&block_id)? - else { - return Err(ChainError::NoSuchBlockError); + let Some(parent_stacks_header) = parent_stacks_header_opt else { + return Err(ChainError::InvalidStacksBlock( + "Invalid Parent Block".into(), + )); + }; + + let mut builder = match NakamotoBlockBuilder::new( + &parent_stacks_header, + &block.header.consensus_hash, + block.header.burn_spent, + tenure_change, + coinbase, + block.header.pox_treatment.len(), + None, + None, + Some(block.header.timestamp), + u64::from(DEFAULT_MAX_TENURE_BYTES), + ) { + Ok(builder) => builder, + Err(e) => return Err(e), + }; + + let mut miner_tenure_info = + match builder.load_ephemeral_tenure_info(chainstate, &burn_dbconn, tenure_cause) { + Ok(miner_tenure_info) => miner_tenure_info, + Err(e) => return Err(e), }; - let staging_db_path = chainstate.get_nakamoto_staging_blocks_path()?; - let db_conn = StacksChainState::open_nakamoto_staging_blocks(&staging_db_path, false)?; - let rowid = db_conn - .conn() - .get_nakamoto_block_rowid(&block_id)? - .ok_or(ChainError::NoSuchBlockError)?; - - let mut blob_fd = match db_conn.open_nakamoto_block(rowid, false).map_err(|e| { - let msg = format!("Failed to open Nakamoto block {}: {:?}", &block_id, &e); - warn!("{}", &msg); - msg - }) { - Ok(blob_fd) => blob_fd, - Err(e) => return Err(ChainError::InvalidStacksBlock(e)), - }; + let burn_chain_height = miner_tenure_info.burn_tip_height; + let mut tenure_tx = match builder.tenure_begin(&burn_dbconn, &mut miner_tenure_info) { + Ok(tenure_tx) => tenure_tx, + Err(e) => return Err(e), + }; - let block = match NakamotoBlock::consensus_deserialize(&mut blob_fd).map_err(|e| { - let msg = format!("Failed to read Nakamoto block {}: {:?}", &block_id, &e); - warn!("{}", &msg); - msg - }) { - Ok(block) => block, - Err(e) => return Err(ChainError::InvalidStacksBlock(e)), - }; + if disable_fees { + tenure_tx.disable_fees(); + } - let burn_dbconn = match sortdb.index_handle_at_block(chainstate, &parent_block_id) { - Ok(burn_dbconn) => burn_dbconn, - Err(_) => return Err(ChainError::NoSuchBlockError), - }; + let mut block_fees: u128 = 0; + let mut txs_receipts = vec![]; - let tenure_change = block - .txs - .iter() - .find(|tx| matches!(tx.payload, TransactionPayload::TenureChange(..))); - let coinbase = block - .txs - .iter() - .find(|tx| matches!(tx.payload, TransactionPayload::Coinbase(..))); - let tenure_cause = tenure_change - .and_then(|tx| match &tx.payload { - TransactionPayload::TenureChange(tc) => Some(tc.into()), - _ => None, - }) - .unwrap_or(MinerTenureInfoCause::NoTenureChange); + let transactions = get_transactions(&block); - let parent_stacks_header_opt = - match NakamotoChainState::get_block_header(chainstate.db(), &parent_block_id) { - Ok(parent_stacks_header_opt) => parent_stacks_header_opt, - Err(e) => return Err(e), - }; + for (i, tx) in transactions.iter().enumerate() { + let tx_len = tx.tx_len(); - let Some(parent_stacks_header) = parent_stacks_header_opt else { - return Err(ChainError::InvalidStacksBlock( - "Invalid Parent Block".into(), - )); - }; + let mut profiler: Option = None; + let mut profiler_result = BlockReplayProfilerResult::default(); - let mut builder = match NakamotoBlockBuilder::new( - &parent_stacks_header, - &block.header.consensus_hash, - block.header.burn_spent, - tenure_change, - coinbase, - block.header.pox_treatment.len(), - None, + if enable_profiler { + profiler = Some(BlockReplayProfiler::new()); + } + + let tx_result = builder.try_mine_tx_with_len( + &mut tenure_tx, + tx, + tx_len, + &BlockLimitFunction::NO_LIMIT_HIT, None, - Some(block.header.timestamp), - u64::from(DEFAULT_MAX_TENURE_BYTES), - ) { - Ok(builder) => builder, - Err(e) => return Err(e), - }; + ); - let mut miner_tenure_info = - match builder.load_ephemeral_tenure_info(chainstate, &burn_dbconn, tenure_cause) { - Ok(miner_tenure_info) => miner_tenure_info, - Err(e) => return Err(e), - }; + if let Some(profiler) = profiler { + profiler_result = profiler.collect(); + } - let burn_chain_height = miner_tenure_info.burn_tip_height; - let mut tenure_tx = match builder.tenure_begin(&burn_dbconn, &mut miner_tenure_info) { - Ok(tenure_tx) => tenure_tx, - Err(e) => return Err(e), + let err = match tx_result { + TransactionResult::Success(tx_result) => { + txs_receipts.push((tx_result.receipt, profiler_result)); + Ok(()) + } + TransactionResult::ProcessingError(e) => { + Err(format!("Error processing tx {}: {}", i, e.error)) + } + TransactionResult::Skipped(e) => Err(format!("Skipped tx {}: {}", i, e.error)), + TransactionResult::Problematic(e) => Err(format!("Problematic tx {}: {}", i, e.error)), }; + if let Err(reason) = err { + let txid = tx.txid(); + return Err(ChainError::InvalidStacksTransaction( + format!("Unable to process transaction {txid}: {reason}").into(), + false, + )); + } - let mut block_fees: u128 = 0; - let mut txs_receipts = vec![]; + block_fees += tx.get_tx_fee() as u128; + } - for (i, tx) in block.txs.iter().enumerate() { - let tx_len = tx.tx_len(); + let mut replayed_block = builder.mine_nakamoto_block(&mut tenure_tx, burn_chain_height); - let mut profiler: Option = None; - let mut profiler_result = BlockReplayProfilerResult::default(); + // copy values that will contribute to the block_hash that cannot be the same in the new replayed block + replayed_block.header.timestamp = block.header.timestamp; + replayed_block.header.state_index_root = block.header.state_index_root; + replayed_block.header.miner_signature = block.header.miner_signature; + replayed_block.header.pox_treatment = block.header.pox_treatment; - if self.profiler { - profiler = Some(BlockReplayProfiler::new()); - } + tenure_tx.rollback_block(); - let tx_result = builder.try_mine_tx_with_len( - &mut tenure_tx, - tx, - tx_len, - &BlockLimitFunction::NO_LIMIT_HIT, - None, - ); + let mut rpc_replayed_block = + RPCReplayedBlock::from_block(&replayed_block, block_fees, tenure_id, parent_block_id); - if let Some(profiler) = profiler { - profiler_result = profiler.collect(); - } + for (receipt, profiler_result) in &txs_receipts { + let transaction = RPCReplayedBlockTransaction::from_receipt(receipt, &profiler_result); + rpc_replayed_block.transactions.push(transaction); + } - let err = match tx_result { - TransactionResult::Success(tx_result) => { - txs_receipts.push((tx_result.receipt, profiler_result)); - Ok(()) - } - _ => Err(format!("Problematic tx {i}")), - }; - if let Err(reason) = err { - let txid = tx.txid(); - return Err(ChainError::InvalidStacksTransaction( - format!("Unable to replay transaction {txid}: {reason}").into(), - false, - )); - } + Ok(rpc_replayed_block) +} - block_fees += tx.get_tx_fee() as u128; +impl RPCNakamotoBlockReplayRequestHandler { + pub fn new(auth: Option) -> Self { + Self { + block_id: None, + auth, + profiler: false, } + } - let replayed_block = builder.mine_nakamoto_block(&mut tenure_tx, burn_chain_height); - - tenure_tx.rollback_block(); - - let tx_merkle_root = block.header.tx_merkle_root.clone(); + pub fn block_replay( + &self, + sortdb: &SortitionDB, + chainstate: &mut StacksChainState, + ) -> Result { + let Some(block_id) = &self.block_id else { + return Err(ChainError::InvalidStacksBlock("block_id is None".into())); + }; - let mut rpc_replayed_block = - RPCReplayedBlock::from_block(&replayed_block, block_fees, tenure_id, parent_block_id); + let mut tx_merkle_root: Option = None; - for (receipt, profiler_result) in &txs_receipts { - let transaction = RPCReplayedBlockTransaction::from_receipt(receipt, &profiler_result); - rpc_replayed_block.transactions.push(transaction); + let mut rpc_replayed_block = remine_nakamoto_block( + block_id, + sortdb, + chainstate, + self.profiler, + false, + |block| { + tx_merkle_root = Some(block.header.tx_merkle_root.clone()); + block.txs.clone() + }, + )?; + + if let Some(tx_merkle_root) = tx_merkle_root { + rpc_replayed_block.valid_merkle_root = + tx_merkle_root == rpc_replayed_block.tx_merkle_root; } - rpc_replayed_block.valid_merkle_root = - tx_merkle_root == replayed_block.header.tx_merkle_root; - Ok(rpc_replayed_block) } } diff --git a/stackslib/src/net/api/blocksimulate.rs b/stackslib/src/net/api/blocksimulate.rs index ff59d2f2554..779aa04c987 100644 --- a/stackslib/src/net/api/blocksimulate.rs +++ b/stackslib/src/net/api/blocksimulate.rs @@ -34,7 +34,7 @@ use crate::chainstate::stacks::miner::{BlockBuilder, BlockLimitFunction, Transac use crate::chainstate::stacks::{Error as ChainError, StacksTransaction, TransactionPayload}; use crate::config::DEFAULT_MAX_TENURE_BYTES; use crate::net::api::blockreplay::{ - BlockReplayProfilerResult, RPCReplayedBlock, RPCReplayedBlockTransaction, + remine_nakamoto_block, BlockReplayProfilerResult, RPCReplayedBlock, RPCReplayedBlockTransaction, }; use crate::net::http::{ parse_json, Error, HttpContentType, HttpNotFound, HttpRequest, HttpRequestContents, @@ -49,6 +49,7 @@ pub struct RPCNakamotoBlockSimulateRequestHandler { pub block_id: Option, pub auth: Option, pub profiler: bool, + pub disable_fees: bool, pub transactions: Vec, } @@ -58,6 +59,7 @@ impl RPCNakamotoBlockSimulateRequestHandler { block_id: None, auth, profiler: false, + disable_fees: false, transactions: vec![], } } @@ -93,154 +95,16 @@ impl RPCNakamotoBlockSimulateRequestHandler { return Err(ChainError::InvalidStacksBlock("block_id is None".into())); }; - let Some((tenure_id, parent_block_id)) = chainstate - .nakamoto_blocks_db() - .get_tenure_and_parent_block_id(&block_id)? - else { - return Err(ChainError::NoSuchBlockError); - }; - - let staging_db_path = chainstate.get_nakamoto_staging_blocks_path()?; - let db_conn = StacksChainState::open_nakamoto_staging_blocks(&staging_db_path, false)?; - let rowid = db_conn - .conn() - .get_nakamoto_block_rowid(&block_id)? - .ok_or(ChainError::NoSuchBlockError)?; - - let mut blob_fd = match db_conn.open_nakamoto_block(rowid, false).map_err(|e| { - let msg = format!("Failed to open Nakamoto block {}: {:?}", &block_id, &e); - warn!("{}", &msg); - msg - }) { - Ok(blob_fd) => blob_fd, - Err(e) => return Err(ChainError::InvalidStacksBlock(e)), - }; - - let block = match NakamotoBlock::consensus_deserialize(&mut blob_fd).map_err(|e| { - let msg = format!("Failed to read Nakamoto block {}: {:?}", &block_id, &e); - warn!("{}", &msg); - msg - }) { - Ok(block) => block, - Err(e) => return Err(ChainError::InvalidStacksBlock(e)), - }; - - let burn_dbconn = match sortdb.index_handle_at_block(chainstate, &parent_block_id) { - Ok(burn_dbconn) => burn_dbconn, - Err(_) => return Err(ChainError::NoSuchBlockError), - }; - - let tenure_change = block - .txs - .iter() - .find(|tx| matches!(tx.payload, TransactionPayload::TenureChange(..))); - let coinbase = block - .txs - .iter() - .find(|tx| matches!(tx.payload, TransactionPayload::Coinbase(..))); - let tenure_cause = tenure_change - .and_then(|tx| match &tx.payload { - TransactionPayload::TenureChange(tc) => Some(tc.into()), - _ => None, - }) - .unwrap_or(MinerTenureInfoCause::NoTenureChange); - - let parent_stacks_header_opt = - match NakamotoChainState::get_block_header(chainstate.db(), &parent_block_id) { - Ok(parent_stacks_header_opt) => parent_stacks_header_opt, - Err(e) => return Err(e), - }; - - let Some(parent_stacks_header) = parent_stacks_header_opt else { - return Err(ChainError::InvalidStacksBlock( - "Invalid Parent Block".into(), - )); - }; - - let mut builder = match NakamotoBlockBuilder::new( - &parent_stacks_header, - &block.header.consensus_hash, - block.header.burn_spent, - tenure_change, - coinbase, - block.header.pox_treatment.len(), - None, - None, - Some(block.header.timestamp), - u64::from(DEFAULT_MAX_TENURE_BYTES), - ) { - Ok(builder) => builder, - Err(e) => return Err(e), - }; - - let mut miner_tenure_info = - match builder.load_ephemeral_tenure_info(chainstate, &burn_dbconn, tenure_cause) { - Ok(miner_tenure_info) => miner_tenure_info, - Err(e) => return Err(e), - }; - - let burn_chain_height = miner_tenure_info.burn_tip_height; - let mut tenure_tx = match builder.tenure_begin(&burn_dbconn, &mut miner_tenure_info) { - Ok(tenure_tx) => tenure_tx, - Err(e) => return Err(e), - }; - - tenure_tx.disable_fees(); + let rpc_simulated_block = remine_nakamoto_block( + block_id, + sortdb, + chainstate, + self.profiler, + self.disable_fees, + |_| self.transactions.clone(), + )?; - let mut block_fees: u128 = 0; - let mut txs_receipts = vec![]; - - for (i, tx) in self.transactions.iter().enumerate() { - let tx_len = tx.tx_len(); - - let tx_result = builder.try_mine_tx_with_len( - &mut tenure_tx, - tx, - tx_len, - &BlockLimitFunction::NO_LIMIT_HIT, - None, - ); - - let err = match tx_result { - TransactionResult::Success(tx_result) => { - txs_receipts.push(tx_result.receipt); - Ok(()) - } - TransactionResult::ProcessingError(e) => { - Err(format!("Error processing tx {}: {}", i, e.error)) - } - TransactionResult::Skipped(e) => Err(format!("Skipped tx {}: {}", i, e.error)), - TransactionResult::Problematic(e) => { - Err(format!("Problematic tx {}: {}", i, e.error)) - } - }; - if let Err(reason) = err { - let txid = tx.txid(); - return Err(ChainError::InvalidStacksTransaction( - format!("Unable to simulate transaction {txid}: {reason}").into(), - false, - )); - } - - block_fees += tx.get_tx_fee() as u128; - } - - let simulated_block = builder.mine_nakamoto_block(&mut tenure_tx, burn_chain_height); - - tenure_tx.rollback_block(); - - let tx_merkle_root = block.header.tx_merkle_root.clone(); - - let mut rpc_replayed_block = - RPCReplayedBlock::from_block(&simulated_block, block_fees, tenure_id, parent_block_id); - - for receipt in &txs_receipts { - let profiler_result = BlockReplayProfilerResult::default(); - let transaction = RPCReplayedBlockTransaction::from_receipt(receipt, &profiler_result); - rpc_replayed_block.transactions.push(transaction); - } - - Ok(rpc_replayed_block) + Ok(rpc_simulated_block) } } @@ -296,7 +160,10 @@ impl HttpRequest for RPCNakamotoBlockSimulateRequestHandler { if value == "1" { self.profiler = true; } - break; + } else if key == "disable_fees" { + if value == "1" { + self.disable_fees = true; + } } } } From efc925e5f31b72b9ca706af2bf34955272ddbf1c Mon Sep 17 00:00:00 2001 From: rob-stacks Date: Mon, 15 Dec 2025 14:56:41 +0100 Subject: [PATCH 3/8] merged with develop [2] --- stackslib/src/net/api/blockreplay.rs | 3 +++ stackslib/src/net/api/blocksimulate.rs | 20 ++++---------------- 2 files changed, 7 insertions(+), 16 deletions(-) diff --git a/stackslib/src/net/api/blockreplay.rs b/stackslib/src/net/api/blockreplay.rs index 5b3e34c9e38..dc549fb12d3 100644 --- a/stackslib/src/net/api/blockreplay.rs +++ b/stackslib/src/net/api/blockreplay.rs @@ -272,12 +272,15 @@ where profiler = Some(BlockReplayProfiler::new()); } + let mut total_receipts = 0; + let tx_result = builder.try_mine_tx_with_len( &mut tenure_tx, tx, tx_len, &BlockLimitFunction::NO_LIMIT_HIT, None, + &mut total_receipts, ); if let Some(profiler) = profiler { diff --git a/stackslib/src/net/api/blocksimulate.rs b/stackslib/src/net/api/blocksimulate.rs index 779aa04c987..fc560f20a6d 100644 --- a/stackslib/src/net/api/blocksimulate.rs +++ b/stackslib/src/net/api/blocksimulate.rs @@ -13,29 +13,17 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -use clarity::vm::costs::ExecutionCost; -use clarity::vm::Value; use regex::{Captures, Regex}; use stacks_common::codec::{Error as CodecError, StacksMessageCodec, MAX_PAYLOAD_LEN}; -use stacks_common::types::chainstate::{BlockHeaderHash, ConsensusHash, StacksBlockId, TrieHash}; +use stacks_common::types::chainstate::StacksBlockId; use stacks_common::types::net::PeerHost; -use stacks_common::util::hash::{hex_bytes, Sha512Trunc256Sum}; -use stacks_common::util::secp256k1::MessageSignature; -use stacks_common::util::serde_serializers::prefix_hex_codec; +use stacks_common::util::hash::hex_bytes; use url::form_urlencoded; -use crate::burnchains::Txid; use crate::chainstate::burn::db::sortdb::SortitionDB; -use crate::chainstate::nakamoto::miner::{MinerTenureInfoCause, NakamotoBlockBuilder}; -use crate::chainstate::nakamoto::{NakamotoBlock, NakamotoChainState}; use crate::chainstate::stacks::db::StacksChainState; -use crate::chainstate::stacks::events::{StacksTransactionReceipt, TransactionOrigin}; -use crate::chainstate::stacks::miner::{BlockBuilder, BlockLimitFunction, TransactionResult}; -use crate::chainstate::stacks::{Error as ChainError, StacksTransaction, TransactionPayload}; -use crate::config::DEFAULT_MAX_TENURE_BYTES; -use crate::net::api::blockreplay::{ - remine_nakamoto_block, BlockReplayProfilerResult, RPCReplayedBlock, RPCReplayedBlockTransaction, -}; +use crate::chainstate::stacks::{Error as ChainError, StacksTransaction}; +use crate::net::api::blockreplay::{remine_nakamoto_block, RPCReplayedBlock}; use crate::net::http::{ parse_json, Error, HttpContentType, HttpNotFound, HttpRequest, HttpRequestContents, HttpRequestPreamble, HttpResponse, HttpResponseContents, HttpResponsePayload, From bf25dfb34f1bf4e2f6e1d22672c83c59846660f9 Mon Sep 17 00:00:00 2001 From: rob-stacks Date: Mon, 15 Dec 2025 15:05:04 +0100 Subject: [PATCH 4/8] updated openapi --- docs/rpc/openapi.yaml | 45 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/docs/rpc/openapi.yaml b/docs/rpc/openapi.yaml index d70875a00ec..541bde301a5 100644 --- a/docs/rpc/openapi.yaml +++ b/docs/rpc/openapi.yaml @@ -2238,3 +2238,48 @@ paths: $ref: "#/components/responses/NotFound" "500": $ref: "#/components/responses/InternalServerError" + + /v3/blocks/simulate/{block_id}: + get: + summary: Simulate mining of a block with the specified transactions and returns its content + tags: + - Blocks + security: + - rpcAuth: [] + operationId: blockSimulate + description: | + Simulate the mining of a block (no data is written in the MARF) with specified trnsactions and returns its content. + parameters: + - name: block_id + in: path + description: The block ID hash + required: true + schema: + type: string + pattern: "^[0-9a-f]{64}$" + requestBody: + required: true + content: + application/json: + schema: + type: array + items: + type: string + description: SIP-003-encoded Transaction in hex format + responses: + "200": + description: Content of the simulated block + content: + application/json: + schema: + $ref: "#/components/schemas/BlockReplay" + example: + $ref: "./components/examples/block-replay.example.json" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalServerError" From a2232de90ac5fe9887e9a1c0d24e65da949474b7 Mon Sep 17 00:00:00 2001 From: rob-stacks Date: Mon, 15 Dec 2025 15:39:18 +0100 Subject: [PATCH 5/8] added test infrastructure --- stackslib/src/net/api/tests/blocksimulate.rs | 430 +++++++++++++++++++ stackslib/src/net/api/tests/mod.rs | 1 + 2 files changed, 431 insertions(+) create mode 100644 stackslib/src/net/api/tests/blocksimulate.rs diff --git a/stackslib/src/net/api/tests/blocksimulate.rs b/stackslib/src/net/api/tests/blocksimulate.rs new file mode 100644 index 00000000000..5d986fc7755 --- /dev/null +++ b/stackslib/src/net/api/tests/blocksimulate.rs @@ -0,0 +1,430 @@ +// Copyright (C) 2013-2020 Blockstack PBC, a public benefit corporation +// Copyright (C) 2020-2025 Stacks Open Internet Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + +use clarity::types::chainstate::StacksPrivateKey; +use clarity::vm::{ClarityName, ContractName, Value as ClarityValue}; +use stacks_common::consts::CHAIN_ID_TESTNET; +use stacks_common::types::chainstate::StacksBlockId; + +use crate::chainstate::stacks::{ + Error as ChainError, StacksTransaction, StacksTransactionSigner, TransactionAnchorMode, + TransactionContractCall, TransactionPayload, TransactionPostConditionMode, TransactionVersion, +}; +use crate::core::test_util::{ + make_contract_publish, make_contract_publish_tx, make_unsigned_tx, to_addr, +}; +use crate::net::api::blocksimulate; +use crate::net::api::tests::TestRPC; +use crate::net::connection::ConnectionOptions; +use crate::net::httpcore::{StacksHttp, StacksHttpRequest}; +use crate::net::test::TestEventObserver; +use crate::net::tests::{NakamotoBootStep, NakamotoBootTenure}; +use crate::net::ProtocolFamily; +use crate::stacks_common::codec::StacksMessageCodec; + +#[test] +fn test_try_parse_request() { + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 33333); + let mut http = StacksHttp::new(addr.clone(), &ConnectionOptions::default()); + + let mut request = + StacksHttpRequest::new_block_simulate(addr.into(), &StacksBlockId([0x01; 32])); + + // add the authorization header + request.add_header("authorization".into(), "password".into()); + + let bytes = request.try_serialize().unwrap(); + + debug!("Request:\n{}\n", std::str::from_utf8(&bytes).unwrap()); + + let (parsed_preamble, offset) = http.read_preamble(&bytes).unwrap(); + + let mut handler = + blocksimulate::RPCNakamotoBlockSimulateRequestHandler::new(Some("password".into())); + + let mut parsed_request = http + .handle_try_parse_request( + &mut handler, + &parsed_preamble.expect_request(), + &bytes[offset..], + ) + .unwrap(); + assert_eq!(handler.block_id, Some(StacksBlockId([0x01; 32]))); + + // parsed request consumes headers that would not be in a constructed request + parsed_request.clear_headers(); + parsed_request.add_header("authorization".into(), "password".into()); + + let (preamble, contents) = parsed_request.destruct(); + + assert_eq!(&preamble, request.preamble()); + assert_eq!(handler.profiler, false); +} + +#[test] +fn test_try_parse_request_with_profiler() { + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 33333); + let mut http = StacksHttp::new(addr.clone(), &ConnectionOptions::default()); + + let mut request = StacksHttpRequest::new_block_replay_with_profiler( + addr.into(), + &StacksBlockId([0x01; 32]), + true, + ); + + // add the authorization header + request.add_header("authorization".into(), "password".into()); + + let bytes = request.try_serialize().unwrap(); + + debug!("Request:\n{}\n", std::str::from_utf8(&bytes).unwrap()); + + let (parsed_preamble, offset) = http.read_preamble(&bytes).unwrap(); + + let mut handler = + blocksimulate::RPCNakamotoBlockSimulateRequestHandler::new(Some("password".into())); + + let parsed_request = http + .handle_try_parse_request( + &mut handler, + &parsed_preamble.expect_request(), + &bytes[offset..], + ) + .unwrap(); + + let (preamble, contents) = parsed_request.destruct(); + + assert_eq!(handler.profiler, true); +} + +#[test] +fn test_block_reply_errors() { + let mut handler = + blocksimulate::RPCNakamotoBlockSimulateRequestHandler::new(Some("password".into())); + + let test_observer = TestEventObserver::new(); + let mut rpc_test = TestRPC::setup_nakamoto(function_name!(), &test_observer); + + let sort_db = rpc_test.peer_1.chain.sortdb.take().unwrap(); + let chainstate = rpc_test.peer_1.chainstate(); + + let err = handler.block_simulate(&sort_db, chainstate).err().unwrap(); + + assert!(matches!(err, ChainError::InvalidStacksBlock(_))); + assert_eq!(err.to_string(), "block_id is None"); + + handler.block_id = Some(StacksBlockId([0x01; 32])); + + let err = handler.block_simulate(&sort_db, chainstate).err().unwrap(); + + assert!(matches!(err, ChainError::NoSuchBlockError)); + assert_eq!(err.to_string(), "No such Stacks block"); +} + +#[test] +fn test_try_make_response() { + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 33333); + + let test_observer = TestEventObserver::new(); + let rpc_test = TestRPC::setup_nakamoto(function_name!(), &test_observer); + + let nakamoto_consensus_hash = rpc_test.consensus_hash.clone(); + + let mut requests = vec![]; + + // query existing, non-empty Nakamoto block + let mut request = StacksHttpRequest::new_block_simulate_with_profiler( + addr.clone().into(), + &rpc_test.canonical_tip, + true, + ); + // add the authorization header + request.add_header("authorization".into(), "password".into()); + requests.push(request); + + // query non-existent block + let mut request = + StacksHttpRequest::new_block_replay(addr.clone().into(), &StacksBlockId([0x01; 32])); + // add the authorization header + request.add_header("authorization".into(), "password".into()); + requests.push(request); + + // unauthenticated request + let request = + StacksHttpRequest::new_block_replay(addr.clone().into(), &StacksBlockId([0x00; 32])); + requests.push(request); + + let mut responses = rpc_test.run(requests); + + // got the Nakamoto tip + let response = responses.remove(0); + + debug!( + "Response:\n{}\n", + std::str::from_utf8(&response.try_serialize().unwrap()).unwrap() + ); + + let resp = response.decode_replayed_block().unwrap(); + + let tip_block = test_observer.get_blocks().last().unwrap().clone(); + + assert_eq!(resp.consensus_hash, nakamoto_consensus_hash); + assert_eq!(resp.consensus_hash, tip_block.metadata.consensus_hash); + + assert_eq!(resp.block_hash, tip_block.block.block_hash); + assert_eq!(resp.block_id, tip_block.metadata.index_block_hash()); + assert_eq!(resp.parent_block_id, tip_block.parent); + + assert_eq!(resp.block_height, tip_block.metadata.stacks_block_height); + + assert!(resp.valid_merkle_root); + + assert_eq!(resp.transactions.len(), tip_block.receipts.len()); + + for (resp_tx, tip_tx) in resp.transactions.iter().zip(tip_block.receipts.iter()) { + assert_eq!(resp_tx.txid, tip_tx.transaction.txid()); + assert_eq!(resp_tx.events.len(), tip_tx.events.len()); + assert_eq!(resp_tx.result, tip_tx.result); + assert_eq!(resp_tx.result_hex, tip_tx.result); + assert!(!resp_tx.post_condition_aborted); + } + + // got a failure (404) + let response = responses.remove(0); + debug!( + "Response:\n{}\n", + std::str::from_utf8(&response.try_serialize().unwrap()).unwrap() + ); + + let (preamble, body) = response.destruct(); + assert_eq!(preamble.status_code, 404); + + // got another failure (401 this time) + let response = responses.remove(0); + debug!( + "Response:\n{}\n", + std::str::from_utf8(&response.try_serialize().unwrap()).unwrap() + ); + + let (preamble, body) = response.destruct(); + assert_eq!(preamble.status_code, 401); +} + +/// Test that events properly set the `committed` flag to `false` +/// when the transaction is aborted by a post-condition. +#[test] +fn replay_block_with_pc_failure() { + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 33333); + + let test_observer = TestEventObserver::new(); + + // Set up the RPC test with a contract, so that we can test a post-condition failure + let rpc_test = + TestRPC::setup_nakamoto_with_boot_plan(function_name!(), &test_observer, |boot_plan| { + let private_key = StacksPrivateKey::from_seed("blockreplay".as_bytes()); + let addr = to_addr(&private_key); + + let code_body = + "(define-public (test) (stx-transfer? u100 tx-sender 'ST000000000000000000002AMW42H))"; + + let contract_deploy = make_contract_publish_tx( + &private_key, + 0, + 1000, + CHAIN_ID_TESTNET, + &"test", + &code_body, + None, + ); + + let contract_call = { + let contract_name = ContractName::from("test"); + let function_name = ClarityName::from("test"); + + let payload = TransactionContractCall { + address: addr.clone(), + contract_name, + function_name, + function_args: vec![], + }; + let mut unsigned_tx = make_unsigned_tx( + TransactionPayload::ContractCall(payload), + &private_key, + None, + 1, + None, + 1000, + CHAIN_ID_TESTNET, + TransactionAnchorMode::Any, + TransactionVersion::Testnet, + ); + unsigned_tx.post_condition_mode = TransactionPostConditionMode::Deny; + + let mut tx_signer = StacksTransactionSigner::new(&unsigned_tx); + tx_signer.sign_origin(&private_key).unwrap(); + tx_signer.get_tx().unwrap() + }; + + let boot_tenures = vec![NakamotoBootTenure::Sortition(vec![ + NakamotoBootStep::Block(vec![contract_deploy]), + NakamotoBootStep::Block(vec![contract_call]), + ])]; + + boot_plan + .with_boot_tenures(boot_tenures) + .with_ignore_transaction_errors(true) + .with_initial_balances(vec![(addr.into(), 1_000_000)]) + }); + + let nakamoto_consensus_hash = rpc_test.consensus_hash.clone(); + + let mut requests = vec![]; + + let mut request = + StacksHttpRequest::new_block_replay(addr.clone().into(), &rpc_test.canonical_tip); + request.add_header("authorization".into(), "password".into()); + requests.push(request); + + let mut responses = rpc_test.run(requests); + + let response = responses.remove(0); + + debug!( + "Response:\n{}\n", + std::str::from_utf8(&response.try_serialize().unwrap()).unwrap() + ); + + let contents = response.clone().get_http_payload_ok().unwrap(); + let response_json: serde_json::Value = contents.try_into().unwrap(); + + let result_hex = response_json + .get("transactions") + .expect("Expected JSON to have a transactions field") + .as_array() + .expect("Expected transactions to be an array") + .get(0) + .expect("Expected transactions to have at least one element") + .as_object() + .expect("Expected transaction to be an object") + .get("result_hex") + .expect("Expected JSON to have a result_hex field") + .as_str() + .unwrap(); + let result = ClarityValue::try_deserialize_hex_untyped(&result_hex).unwrap(); + result.expect_result_ok().expect("FATAL: result is not ok"); + + let resp = response.decode_replayed_block().unwrap(); + + let tip_block = test_observer.get_blocks().last().unwrap().clone(); + + assert_eq!(resp.transactions.len(), tip_block.receipts.len()); + + assert_eq!(resp.transactions.len(), 1); + + let resp_tx = &resp.transactions.get(0).unwrap(); + + assert!(resp_tx.vm_error.is_some()); + + for event in resp_tx.events.iter() { + let committed = event.get("committed").unwrap().as_bool().unwrap(); + assert!(!committed); + } + + assert!(resp_tx.post_condition_aborted); +} + +#[test] +fn test_try_make_response_with_unsuccessful_transaction() { + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 33333); + + let test_observer = TestEventObserver::new(); + let rpc_test = + TestRPC::setup_nakamoto_with_boot_plan(function_name!(), &test_observer, |boot_plan| { + let mut tip_transactions: Vec = vec![]; + + let miner_privk = boot_plan.private_key.clone(); + + let contract_code = "(broken)"; + + let deploy_tx_bytes = make_contract_publish( + &miner_privk, + 100, + 1000, + CHAIN_ID_TESTNET, + &"err-contract", + &contract_code, + ); + let deploy_tx = + StacksTransaction::consensus_deserialize(&mut deploy_tx_bytes.as_slice()).unwrap(); + + tip_transactions.push(deploy_tx); + boot_plan + .with_tip_transactions(tip_transactions) + .with_ignore_transaction_errors(true) + }); + + let tip_block = test_observer.get_blocks().last().unwrap().clone(); + + let nakamoto_consensus_hash = rpc_test.consensus_hash.clone(); + + let mut requests = vec![]; + + let mut request = + StacksHttpRequest::new_block_replay(addr.clone().into(), &rpc_test.canonical_tip); + // add the authorization header + request.add_header("authorization".into(), "password".into()); + requests.push(request); + + let mut responses = rpc_test.run(requests); + + // got the Nakamoto tip + let response = responses.remove(0); + + debug!( + "Response:\n{}\n", + std::str::from_utf8(&response.try_serialize().unwrap()).unwrap() + ); + + let resp = response.decode_replayed_block().unwrap(); + + assert_eq!(resp.consensus_hash, nakamoto_consensus_hash); + assert_eq!(resp.consensus_hash, tip_block.metadata.consensus_hash); + + assert_eq!(resp.block_hash, tip_block.block.block_hash); + assert_eq!(resp.block_id, tip_block.metadata.index_block_hash()); + assert_eq!(resp.parent_block_id, tip_block.parent); + + assert_eq!(resp.block_height, tip_block.metadata.stacks_block_height); + + assert!(resp.valid_merkle_root); + + assert_eq!(resp.transactions.len(), tip_block.receipts.len()); + + for (resp_tx, tip_tx) in resp.transactions.iter().zip(tip_block.receipts.iter()) { + assert_eq!(resp_tx.txid, tip_tx.transaction.txid()); + assert_eq!(resp_tx.events.len(), tip_tx.events.len()); + assert_eq!(resp_tx.result, tip_tx.result); + assert_eq!(resp_tx.result_hex, tip_tx.result); + assert!(!resp_tx.post_condition_aborted); + } + + assert_eq!( + resp.transactions.last().unwrap().vm_error.clone().unwrap(), + ":0:0: use of unresolved function 'broken'" + ); +} diff --git a/stackslib/src/net/api/tests/mod.rs b/stackslib/src/net/api/tests/mod.rs index aacc86a0fcd..57fd6c26d8f 100644 --- a/stackslib/src/net/api/tests/mod.rs +++ b/stackslib/src/net/api/tests/mod.rs @@ -60,6 +60,7 @@ use crate::net::{ }; mod blockreplay; +mod blocksimulate; mod callreadonly; mod fastcallreadonly; mod get_tenures_fork_info; From 68666322472596839c8923a9eac15f029780a1dc Mon Sep 17 00:00:00 2001 From: rob-stacks Date: Wed, 17 Dec 2025 13:18:25 +0100 Subject: [PATCH 6/8] fixed block simulation tests --- stackslib/src/net/api/blocksimulate.rs | 53 ++++- stackslib/src/net/api/tests/blocksimulate.rs | 219 ++++++++++++------- 2 files changed, 182 insertions(+), 90 deletions(-) diff --git a/stackslib/src/net/api/blocksimulate.rs b/stackslib/src/net/api/blocksimulate.rs index fc560f20a6d..a8ae32233da 100644 --- a/stackslib/src/net/api/blocksimulate.rs +++ b/stackslib/src/net/api/blocksimulate.rs @@ -13,6 +13,7 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +use clarity::util::hash::bytes_to_hex; use regex::{Captures, Regex}; use stacks_common::codec::{Error as CodecError, StacksMessageCodec, MAX_PAYLOAD_LEN}; use stacks_common::types::chainstate::StacksBlockId; @@ -237,12 +238,21 @@ impl RPCRequestHandler for RPCNakamotoBlockSimulateRequestHandler { impl StacksHttpRequest { /// Make a new block_replay request to this endpoint - pub fn new_block_simulate(host: PeerHost, block_id: &StacksBlockId) -> StacksHttpRequest { + pub fn new_block_simulate( + host: PeerHost, + block_id: &StacksBlockId, + transactions: &Vec, + ) -> StacksHttpRequest { + let transactions_hex = transactions + .iter() + .map(|transaction| bytes_to_hex(&transaction.serialize_to_vec())) + .collect(); + StacksHttpRequest::new_for_peer( host, - "GET".into(), + "POST".into(), format!("/v3/blocks/simulate/{block_id}"), - HttpRequestContents::new(), + HttpRequestContents::new().payload_json(transactions_hex), ) .expect("FATAL: failed to construct request from infallible data") } @@ -251,15 +261,42 @@ impl StacksHttpRequest { host: PeerHost, block_id: &StacksBlockId, profiler: bool, + transactions: &Vec, + ) -> StacksHttpRequest { + let transactions_hex = transactions + .iter() + .map(|transaction| bytes_to_hex(&transaction.serialize_to_vec())) + .collect(); + StacksHttpRequest::new_for_peer( + host, + "POST".into(), + format!("/v3/blocks/simulate/{block_id}"), + HttpRequestContents::new() + .query_arg( + "profiler".into(), + if profiler { "1".into() } else { "0".into() }, + ) + .payload_json(transactions_hex), + ) + .expect("FATAL: failed to construct request from infallible data") + } + + pub fn new_block_simulate_with_no_fees( + host: PeerHost, + block_id: &StacksBlockId, + transactions: &Vec, ) -> StacksHttpRequest { + let transactions_hex = transactions + .iter() + .map(|transaction| bytes_to_hex(&transaction.serialize_to_vec())) + .collect(); StacksHttpRequest::new_for_peer( host, - "GET".into(), + "POST".into(), format!("/v3/blocks/simulate/{block_id}"), - HttpRequestContents::new().query_arg( - "profiler".into(), - if profiler { "1".into() } else { "0".into() }, - ), + HttpRequestContents::new() + .query_arg("disable_fees".into(), "1".into()) + .payload_json(transactions_hex), ) .expect("FATAL: failed to construct request from infallible data") } diff --git a/stackslib/src/net/api/tests/blocksimulate.rs b/stackslib/src/net/api/tests/blocksimulate.rs index 5d986fc7755..437b9b089d8 100644 --- a/stackslib/src/net/api/tests/blocksimulate.rs +++ b/stackslib/src/net/api/tests/blocksimulate.rs @@ -26,7 +26,7 @@ use crate::chainstate::stacks::{ TransactionContractCall, TransactionPayload, TransactionPostConditionMode, TransactionVersion, }; use crate::core::test_util::{ - make_contract_publish, make_contract_publish_tx, make_unsigned_tx, to_addr, + make_contract_call_tx, make_contract_publish_tx, make_unsigned_tx, to_addr, }; use crate::net::api::blocksimulate; use crate::net::api::tests::TestRPC; @@ -35,7 +35,6 @@ use crate::net::httpcore::{StacksHttp, StacksHttpRequest}; use crate::net::test::TestEventObserver; use crate::net::tests::{NakamotoBootStep, NakamotoBootTenure}; use crate::net::ProtocolFamily; -use crate::stacks_common::codec::StacksMessageCodec; #[test] fn test_try_parse_request() { @@ -43,7 +42,7 @@ fn test_try_parse_request() { let mut http = StacksHttp::new(addr.clone(), &ConnectionOptions::default()); let mut request = - StacksHttpRequest::new_block_simulate(addr.into(), &StacksBlockId([0x01; 32])); + StacksHttpRequest::new_block_simulate(addr.into(), &StacksBlockId([0x01; 32]), &vec![]); // add the authorization header request.add_header("authorization".into(), "password".into()); @@ -81,10 +80,11 @@ fn test_try_parse_request_with_profiler() { let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 33333); let mut http = StacksHttp::new(addr.clone(), &ConnectionOptions::default()); - let mut request = StacksHttpRequest::new_block_replay_with_profiler( + let mut request = StacksHttpRequest::new_block_simulate_with_profiler( addr.into(), &StacksBlockId([0x01; 32]), true, + &vec![], ); // add the authorization header @@ -113,7 +113,7 @@ fn test_try_parse_request_with_profiler() { } #[test] -fn test_block_reply_errors() { +fn test_block_simulate_errors() { let mut handler = blocksimulate::RPCNakamotoBlockSimulateRequestHandler::new(Some("password".into())); @@ -147,26 +147,54 @@ fn test_try_make_response() { let mut requests = vec![]; + let private_key = StacksPrivateKey::from_seed("blocksimulate".as_bytes()); + + let deploy_tx1 = make_contract_publish_tx( + &private_key, + 0, + 1000, + CHAIN_ID_TESTNET, + &"print-contract1", + &"(print u1)", + Some(clarity::vm::ClarityVersion::Clarity1), + ); + + let deploy_tx2 = make_contract_publish_tx( + &private_key, + 1, + 1000, + CHAIN_ID_TESTNET, + &"print-contract2", + &"(print u2)", + Some(clarity::vm::ClarityVersion::Clarity1), + ); + // query existing, non-empty Nakamoto block - let mut request = StacksHttpRequest::new_block_simulate_with_profiler( + let mut request = StacksHttpRequest::new_block_simulate_with_no_fees( addr.clone().into(), &rpc_test.canonical_tip, - true, + &vec![deploy_tx1.clone(), deploy_tx2.clone()], ); // add the authorization header request.add_header("authorization".into(), "password".into()); requests.push(request); // query non-existent block - let mut request = - StacksHttpRequest::new_block_replay(addr.clone().into(), &StacksBlockId([0x01; 32])); + let mut request = StacksHttpRequest::new_block_simulate( + addr.clone().into(), + &StacksBlockId([0x01; 32]), + &vec![], + ); // add the authorization header request.add_header("authorization".into(), "password".into()); requests.push(request); // unauthenticated request - let request = - StacksHttpRequest::new_block_replay(addr.clone().into(), &StacksBlockId([0x00; 32])); + let request = StacksHttpRequest::new_block_simulate( + addr.clone().into(), + &StacksBlockId([0x00; 32]), + &vec![], + ); requests.push(request); let mut responses = rpc_test.run(requests); @@ -174,35 +202,45 @@ fn test_try_make_response() { // got the Nakamoto tip let response = responses.remove(0); - debug!( + println!( "Response:\n{}\n", std::str::from_utf8(&response.try_serialize().unwrap()).unwrap() ); - let resp = response.decode_replayed_block().unwrap(); + let resp = response.decode_simulated_block().unwrap(); let tip_block = test_observer.get_blocks().last().unwrap().clone(); assert_eq!(resp.consensus_hash, nakamoto_consensus_hash); assert_eq!(resp.consensus_hash, tip_block.metadata.consensus_hash); - assert_eq!(resp.block_hash, tip_block.block.block_hash); - assert_eq!(resp.block_id, tip_block.metadata.index_block_hash()); assert_eq!(resp.parent_block_id, tip_block.parent); assert_eq!(resp.block_height, tip_block.metadata.stacks_block_height); - assert!(resp.valid_merkle_root); + assert_eq!(resp.transactions.len(), 2); - assert_eq!(resp.transactions.len(), tip_block.receipts.len()); + assert_eq!(resp.transactions[0].txid, deploy_tx1.txid()); + assert_eq!(resp.transactions[0].events.len(), 1); + assert_eq!( + resp.transactions[0].events[0].as_object().unwrap()["contract_event"] + .as_object() + .unwrap()["raw_value"] + .as_str() + .unwrap(), + "0x0100000000000000000000000000000001" + ); - for (resp_tx, tip_tx) in resp.transactions.iter().zip(tip_block.receipts.iter()) { - assert_eq!(resp_tx.txid, tip_tx.transaction.txid()); - assert_eq!(resp_tx.events.len(), tip_tx.events.len()); - assert_eq!(resp_tx.result, tip_tx.result); - assert_eq!(resp_tx.result_hex, tip_tx.result); - assert!(!resp_tx.post_condition_aborted); - } + assert_eq!(resp.transactions[1].txid, deploy_tx2.txid()); + assert_eq!(resp.transactions[1].events.len(), 1); + assert_eq!( + resp.transactions[1].events[0].as_object().unwrap()["contract_event"] + .as_object() + .unwrap()["raw_value"] + .as_str() + .unwrap(), + "0x0100000000000000000000000000000002" + ); // got a failure (404) let response = responses.remove(0); @@ -228,17 +266,20 @@ fn test_try_make_response() { /// Test that events properly set the `committed` flag to `false` /// when the transaction is aborted by a post-condition. #[test] -fn replay_block_with_pc_failure() { +fn simulate_block_with_pc_failure() { let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 33333); let test_observer = TestEventObserver::new(); + let private_key = StacksPrivateKey::from_seed("blocksimulate".as_bytes()); + let address = to_addr(&private_key); + + let contract_name = ContractName::from("test"); + let function_name = ClarityName::from("test"); + // Set up the RPC test with a contract, so that we can test a post-condition failure let rpc_test = TestRPC::setup_nakamoto_with_boot_plan(function_name!(), &test_observer, |boot_plan| { - let private_key = StacksPrivateKey::from_seed("blockreplay".as_bytes()); - let addr = to_addr(&private_key); - let code_body = "(define-public (test) (stx-transfer? u100 tx-sender 'ST000000000000000000002AMW42H))"; @@ -252,33 +293,16 @@ fn replay_block_with_pc_failure() { None, ); - let contract_call = { - let contract_name = ContractName::from("test"); - let function_name = ClarityName::from("test"); - - let payload = TransactionContractCall { - address: addr.clone(), - contract_name, - function_name, - function_args: vec![], - }; - let mut unsigned_tx = make_unsigned_tx( - TransactionPayload::ContractCall(payload), - &private_key, - None, - 1, - None, - 1000, - CHAIN_ID_TESTNET, - TransactionAnchorMode::Any, - TransactionVersion::Testnet, - ); - unsigned_tx.post_condition_mode = TransactionPostConditionMode::Deny; - - let mut tx_signer = StacksTransactionSigner::new(&unsigned_tx); - tx_signer.sign_origin(&private_key).unwrap(); - tx_signer.get_tx().unwrap() - }; + let contract_call = make_contract_call_tx( + &private_key, + 1, + 1000, + CHAIN_ID_TESTNET, + &address, + &contract_name, + &function_name, + &vec![], + ); let boot_tenures = vec![NakamotoBootTenure::Sortition(vec![ NakamotoBootStep::Block(vec![contract_deploy]), @@ -288,15 +312,43 @@ fn replay_block_with_pc_failure() { boot_plan .with_boot_tenures(boot_tenures) .with_ignore_transaction_errors(true) - .with_initial_balances(vec![(addr.into(), 1_000_000)]) + .with_initial_balances(vec![(address.clone().into(), 1_000_000)]) }); + let contract_call = { + let payload = TransactionContractCall { + address: address.clone(), + contract_name, + function_name, + function_args: vec![], + }; + let mut unsigned_tx = make_unsigned_tx( + TransactionPayload::ContractCall(payload), + &private_key, + None, + 1, + None, + 1000, + CHAIN_ID_TESTNET, + TransactionAnchorMode::Any, + TransactionVersion::Testnet, + ); + unsigned_tx.post_condition_mode = TransactionPostConditionMode::Deny; + + let mut tx_signer = StacksTransactionSigner::new(&unsigned_tx); + tx_signer.sign_origin(&private_key).unwrap(); + tx_signer.get_tx().unwrap() + }; + let nakamoto_consensus_hash = rpc_test.consensus_hash.clone(); let mut requests = vec![]; - let mut request = - StacksHttpRequest::new_block_replay(addr.clone().into(), &rpc_test.canonical_tip); + let mut request = StacksHttpRequest::new_block_simulate( + addr.clone().into(), + &rpc_test.canonical_tip, + &vec![contract_call], + ); request.add_header("authorization".into(), "password".into()); requests.push(request); @@ -328,7 +380,7 @@ fn replay_block_with_pc_failure() { let result = ClarityValue::try_deserialize_hex_untyped(&result_hex).unwrap(); result.expect_result_ok().expect("FATAL: result is not ok"); - let resp = response.decode_replayed_block().unwrap(); + let resp = response.decode_simulated_block().unwrap(); let tip_block = test_observer.get_blocks().last().unwrap().clone(); @@ -359,33 +411,46 @@ fn test_try_make_response_with_unsuccessful_transaction() { let miner_privk = boot_plan.private_key.clone(); - let contract_code = "(broken)"; + let contract_code = "(ok u1)"; - let deploy_tx_bytes = make_contract_publish( + let deploy_tx = make_contract_publish_tx( &miner_privk, 100, 1000, CHAIN_ID_TESTNET, - &"err-contract", + &"dummy-contract", &contract_code, + Some(clarity::vm::ClarityVersion::Clarity1), ); - let deploy_tx = - StacksTransaction::consensus_deserialize(&mut deploy_tx_bytes.as_slice()).unwrap(); tip_transactions.push(deploy_tx); - boot_plan - .with_tip_transactions(tip_transactions) - .with_ignore_transaction_errors(true) + boot_plan.with_tip_transactions(tip_transactions) }); let tip_block = test_observer.get_blocks().last().unwrap().clone(); let nakamoto_consensus_hash = rpc_test.consensus_hash.clone(); + let private_key = StacksPrivateKey::from_seed("blocksimulate".as_bytes()); + let contract_code = "(broken)"; + + let deploy_tx = make_contract_publish_tx( + &private_key, + 0, + 1000, + CHAIN_ID_TESTNET, + &"err-contract", + &contract_code, + Some(clarity::vm::ClarityVersion::Clarity1), + ); + let mut requests = vec![]; - let mut request = - StacksHttpRequest::new_block_replay(addr.clone().into(), &rpc_test.canonical_tip); + let mut request = StacksHttpRequest::new_block_simulate_with_no_fees( + addr.clone().into(), + &rpc_test.canonical_tip, + &vec![deploy_tx.clone()], + ); // add the authorization header request.add_header("authorization".into(), "password".into()); requests.push(request); @@ -400,28 +465,18 @@ fn test_try_make_response_with_unsuccessful_transaction() { std::str::from_utf8(&response.try_serialize().unwrap()).unwrap() ); - let resp = response.decode_replayed_block().unwrap(); + let resp = response.decode_simulated_block().unwrap(); assert_eq!(resp.consensus_hash, nakamoto_consensus_hash); assert_eq!(resp.consensus_hash, tip_block.metadata.consensus_hash); - assert_eq!(resp.block_hash, tip_block.block.block_hash); - assert_eq!(resp.block_id, tip_block.metadata.index_block_hash()); assert_eq!(resp.parent_block_id, tip_block.parent); assert_eq!(resp.block_height, tip_block.metadata.stacks_block_height); - assert!(resp.valid_merkle_root); - - assert_eq!(resp.transactions.len(), tip_block.receipts.len()); + assert_eq!(resp.transactions.len(), 1); - for (resp_tx, tip_tx) in resp.transactions.iter().zip(tip_block.receipts.iter()) { - assert_eq!(resp_tx.txid, tip_tx.transaction.txid()); - assert_eq!(resp_tx.events.len(), tip_tx.events.len()); - assert_eq!(resp_tx.result, tip_tx.result); - assert_eq!(resp_tx.result_hex, tip_tx.result); - assert!(!resp_tx.post_condition_aborted); - } + assert_eq!(resp.transactions[0].txid, deploy_tx.txid()); assert_eq!( resp.transactions.last().unwrap().vm_error.clone().unwrap(), From 70b7b22e7035091666c9794c33d0327959d6a792 Mon Sep 17 00:00:00 2001 From: rob-stacks Date: Wed, 17 Dec 2025 13:22:00 +0100 Subject: [PATCH 7/8] fixed typo --- docs/rpc/openapi.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rpc/openapi.yaml b/docs/rpc/openapi.yaml index 541bde301a5..d13e94a2267 100644 --- a/docs/rpc/openapi.yaml +++ b/docs/rpc/openapi.yaml @@ -2248,7 +2248,7 @@ paths: - rpcAuth: [] operationId: blockSimulate description: | - Simulate the mining of a block (no data is written in the MARF) with specified trnsactions and returns its content. + Simulate the mining of a block (no data is written in the MARF) with specified transactions and returns its content. parameters: - name: block_id in: path From 04b6bbe215e7581b3b236567372741c732bac7cb Mon Sep 17 00:00:00 2001 From: rob-stacks Date: Wed, 17 Dec 2025 13:30:18 +0100 Subject: [PATCH 8/8] updated CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0985d33202e..73e0e1b05d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to the versioning scheme outlined in the [README.md](RE ### Added - In the `/v3/transaction/{txid}` RPC endpoint, added `block_height` and `is_canonical` to the response. +- New endpoint `/v3/blocks/simulate/{block_id}` allows to simulate the execution fo a specific block with a brand new set of transactions ## [3.3.0.0.2]