diff --git a/CHANGELOG.md b/CHANGELOG.md index 0985d33202..73e0e1b05d 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] diff --git a/docs/rpc/openapi.yaml b/docs/rpc/openapi.yaml index d70875a00e..d13e94a226 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 transactions 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" diff --git a/stackslib/src/chainstate/stacks/db/mod.rs b/stackslib/src/chainstate/stacks/db/mod.rs index dae996501c..f148ff4405 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 fb587c6876..7f6db72d0c 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) } @@ -1568,6 +1569,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? @@ -1592,7 +1595,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 = @@ -1633,8 +1639,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 b563bbbc95..9855d9f0aa 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 62f1f3312c..dc549fb12d 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, @@ -150,177 +150,223 @@ 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 mut total_receipts = 0; + + 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), - }; + &mut total_receipts, + ); - 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![]; - let mut total_receipts = 0u64; - for (i, tx) in block.txs.iter().enumerate() { - let tx_len = tx.tx_len(); + block_fees += tx.get_tx_fee() as u128; + } - let mut profiler: Option = None; - let mut profiler_result = BlockReplayProfilerResult::default(); + let mut replayed_block = builder.mine_nakamoto_block(&mut tenure_tx, burn_chain_height); - if self.profiler { - profiler = Some(BlockReplayProfiler::new()); - } + // 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; - 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 { - profiler_result = profiler.collect(); - } + tenure_tx.rollback_block(); - 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, - )); - } + let mut rpc_replayed_block = + RPCReplayedBlock::from_block(&replayed_block, block_fees, tenure_id, parent_block_id); - block_fees += tx.get_tx_fee() as u128; - } + for (receipt, profiler_result) in &txs_receipts { + let transaction = RPCReplayedBlockTransaction::from_receipt(receipt, &profiler_result); + rpc_replayed_block.transactions.push(transaction); + } - let replayed_block = builder.mine_nakamoto_block(&mut tenure_tx, burn_chain_height); + Ok(rpc_replayed_block) +} - tenure_tx.rollback_block(); +impl RPCNakamotoBlockReplayRequestHandler { + pub fn new(auth: Option) -> Self { + Self { + block_id: None, + auth, + profiler: false, + } + } - 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(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) } } @@ -357,7 +403,7 @@ pub struct RPCReplayedBlockTransaction { } impl RPCReplayedBlockTransaction { - fn from_receipt( + pub fn from_receipt( receipt: &StacksTransactionReceipt, profiler_result: &BlockReplayProfilerResult, ) -> Self { @@ -439,7 +485,7 @@ pub struct RPCReplayedBlock { impl RPCReplayedBlock { pub fn from_block( - block: NakamotoBlock, + block: &NakamotoBlock, block_fees: u128, tenure_id: ConsensusHash, parent_block_id: StacksBlockId, @@ -454,11 +500,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 0000000000..a8ae32233d --- /dev/null +++ b/stackslib/src/net/api/blocksimulate.rs @@ -0,0 +1,327 @@ +// 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::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; +use stacks_common::types::net::PeerHost; +use stacks_common::util::hash::hex_bytes; +use url::form_urlencoded; + +use crate::chainstate::burn::db::sortdb::SortitionDB; +use crate::chainstate::stacks::db::StacksChainState; +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, + 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 disable_fees: bool, + pub transactions: Vec, +} + +impl RPCNakamotoBlockSimulateRequestHandler { + pub fn new(auth: Option) -> Self { + Self { + block_id: None, + auth, + profiler: false, + disable_fees: 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 rpc_simulated_block = remine_nakamoto_block( + block_id, + sortdb, + chainstate, + self.profiler, + self.disable_fees, + |_| self.transactions.clone(), + )?; + + Ok(rpc_simulated_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; + } + } else if key == "disable_fees" { + if value == "1" { + self.disable_fees = true; + } + } + } + } + + 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, + 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().payload_json(transactions_hex), + ) + .expect("FATAL: failed to construct request from infallible data") + } + + pub fn new_block_simulate_with_profiler( + 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, + "POST".into(), + format!("/v3/blocks/simulate/{block_id}"), + HttpRequestContents::new() + .query_arg("disable_fees".into(), "1".into()) + .payload_json(transactions_hex), + ) + .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 a5777a751d..3661d48a31 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(), diff --git a/stackslib/src/net/api/tests/blocksimulate.rs b/stackslib/src/net/api/tests/blocksimulate.rs new file mode 100644 index 0000000000..437b9b089d --- /dev/null +++ b/stackslib/src/net/api/tests/blocksimulate.rs @@ -0,0 +1,485 @@ +// 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_call_tx, 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; + +#[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]), &vec![]); + + // 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_simulate_with_profiler( + addr.into(), + &StacksBlockId([0x01; 32]), + true, + &vec![], + ); + + // 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_simulate_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![]; + + 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_no_fees( + addr.clone().into(), + &rpc_test.canonical_tip, + &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_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_simulate( + addr.clone().into(), + &StacksBlockId([0x00; 32]), + &vec![], + ); + requests.push(request); + + let mut responses = rpc_test.run(requests); + + // got the Nakamoto tip + let response = responses.remove(0); + + println!( + "Response:\n{}\n", + std::str::from_utf8(&response.try_serialize().unwrap()).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.parent_block_id, tip_block.parent); + + assert_eq!(resp.block_height, tip_block.metadata.stacks_block_height); + + assert_eq!(resp.transactions.len(), 2); + + 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" + ); + + 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); + 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 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 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 = 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]), + NakamotoBootStep::Block(vec![contract_call]), + ])]; + + boot_plan + .with_boot_tenures(boot_tenures) + .with_ignore_transaction_errors(true) + .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_simulate( + addr.clone().into(), + &rpc_test.canonical_tip, + &vec![contract_call], + ); + 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_simulated_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 = "(ok u1)"; + + let deploy_tx = make_contract_publish_tx( + &miner_privk, + 100, + 1000, + CHAIN_ID_TESTNET, + &"dummy-contract", + &contract_code, + Some(clarity::vm::ClarityVersion::Clarity1), + ); + + tip_transactions.push(deploy_tx); + 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_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); + + 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_simulated_block().unwrap(); + + assert_eq!(resp.consensus_hash, nakamoto_consensus_hash); + assert_eq!(resp.consensus_hash, tip_block.metadata.consensus_hash); + + assert_eq!(resp.parent_block_id, tip_block.parent); + + assert_eq!(resp.block_height, tip_block.metadata.stacks_block_height); + + assert_eq!(resp.transactions.len(), 1); + + assert_eq!(resp.transactions[0].txid, deploy_tx.txid()); + + 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 aacc86a0fc..57fd6c26d8 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;