diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f7db5d27c..1e32395bcf 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 - When determining a global transaction replay set, the state evaluator now uses a longest-common-prefix algorithm to find a replay set in the case where a single replay set has less than 70% of signer weight. +- New endpoint /v3/tenures/blocks/ allowing retrieving the list of stacks blocks from a burn block ### Changed diff --git a/stackslib/src/chainstate/nakamoto/mod.rs b/stackslib/src/chainstate/nakamoto/mod.rs index e66b0bae59..9be199dde1 100644 --- a/stackslib/src/chainstate/nakamoto/mod.rs +++ b/stackslib/src/chainstate/nakamoto/mod.rs @@ -2862,6 +2862,29 @@ impl NakamotoChainState { Self::get_block_header_nakamoto(chainstate_conn.sqlite(), &block_id) } + /// Get all the blocks for a specific consensus hash and burnview + /// highest block on top + pub fn get_block_headers_in_tenure_at_burnview( + db: &Connection, + tenure_id: &ConsensusHash, + burn_view: &ConsensusHash, + ) -> Result, ChainstateError> { + // see if we have a nakamoto block in this tenure + let qry = " + SELECT * + FROM nakamoto_block_headers + WHERE consensus_hash = ?1 + AND burn_view = ?2 + ORDER BY block_height DESC + "; + let args = params![tenure_id, burn_view]; + let out = query_rows(db, qry, args)?; + if !out.is_empty() { + return Ok(out); + } + Err(ChainstateError::NoSuchBlockError) + } + /// DO NOT USE IN CONSENSUS CODE. Different nodes can have different blocks for the same /// tenure. /// diff --git a/stackslib/src/net/api/gettenureblocks.rs b/stackslib/src/net/api/gettenureblocks.rs new file mode 100644 index 0000000000..67213ec92b --- /dev/null +++ b/stackslib/src/net/api/gettenureblocks.rs @@ -0,0 +1,257 @@ +// 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::types::chainstate::StacksBlockId; +use regex::{Captures, Regex}; +use serde_json; +use stacks_common::types::chainstate::{BlockHeaderHash, ConsensusHash}; +use stacks_common::types::net::PeerHost; + +use crate::chainstate::nakamoto::NakamotoChainState; +use crate::chainstate::stacks::db::{StacksBlockHeaderTypes, StacksHeaderInfo}; +use crate::net::http::{ + parse_json, Error, HttpNotFound, HttpRequest, HttpRequestContents, HttpRequestPreamble, + HttpResponse, HttpResponseContents, HttpResponsePayload, HttpResponsePreamble, HttpServerError, +}; +use crate::net::httpcore::{request, RPCRequestHandler, StacksHttpRequest, StacksHttpResponse}; +use crate::net::{Error as NetError, StacksNodeState}; + +#[derive(Clone)] +pub struct RPCNakamotoTenureBlocksRequestHandler { + pub(crate) consensus_hash: Option, +} + +impl RPCNakamotoTenureBlocksRequestHandler { + pub fn new() -> Self { + Self { + consensus_hash: None, + } + } +} + +#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] +pub struct RPCTenureBlock { + pub block_id: StacksBlockId, + pub block_hash: BlockHeaderHash, + pub parent_block_id: String, + pub height: u64, +} + +#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] +pub struct RPCTenure { + pub consensus_hash: ConsensusHash, + pub burn_block_height: u64, + pub burn_block_hash: String, + pub stacks_blocks: Vec, +} + +/// Decode the HTTP request +impl HttpRequest for RPCNakamotoTenureBlocksRequestHandler { + fn verb(&self) -> &'static str { + "GET" + } + + fn path_regex(&self) -> Regex { + Regex::new(r#"^/v3/tenures/blocks/(?P[0-9a-f]{40})$"#).unwrap() + } + + fn metrics_identifier(&self) -> &str { + "/v3/tenures/blocks/:consensus_hash" + } + + /// 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 preamble.get_content_length() != 0 { + return Err(Error::DecodeError( + "Invalid Http request: expected 0-length body".to_string(), + )); + } + let consensus_hash = request::get_consensus_hash(captures, "consensus_hash")?; + self.consensus_hash = Some(consensus_hash); + Ok(HttpRequestContents::new().query_string(query)) + } +} + +impl RPCRequestHandler for RPCNakamotoTenureBlocksRequestHandler { + /// Reset internal state + fn restart(&mut self) { + self.consensus_hash = None; + } + + /// Make the response + fn try_handle_request( + &mut self, + preamble: HttpRequestPreamble, + _contents: HttpRequestContents, + node: &mut StacksNodeState, + ) -> Result<(HttpResponsePreamble, HttpResponseContents), NetError> { + let consensus_hash = self + .consensus_hash + .take() + .ok_or(NetError::SendError("`consensus_hash` not set".into()))?; + + let tenure_blocks_resp = + node.with_node_state(|_network, sortdb, chainstate, _mempool, _rpc_args| { + let header_info = + match NakamotoChainState::find_highest_known_block_header_in_tenure( + &chainstate, + sortdb, + &consensus_hash, + ) { + Ok(Some(header)) => header, + Ok(None) => { + let msg = format!("No blocks in tenure {}", &consensus_hash); + debug!("{}", &msg); + return Err(StacksHttpResponse::new_error( + &preamble, + &HttpNotFound::new(msg), + )); + } + Err(e) => { + let msg = format!( + "Failed to query tenure blocks by consensus '{}': {:?}", + consensus_hash, &e + ); + error!("{}", &msg); + return Err(StacksHttpResponse::new_error( + &preamble, + &HttpServerError::new(msg), + )); + } + }; + + let blocks = match NakamotoChainState::get_block_headers_in_tenure_at_burnview( + chainstate.db(), + &header_info.consensus_hash, + &header_info.burn_view.unwrap(), + ) { + Ok(blocks) => blocks, + Err(e) => { + let msg = format!( + "Failed to query tenure blocks by consensus '{}': {:?}", + consensus_hash, &e + ); + error!("{}", &msg); + return Err(StacksHttpResponse::new_error( + &preamble, + &HttpServerError::new(msg), + )); + } + }; + + let mut canonical_blocks: Vec = vec![]; + // now go back from the top to the bottom + let mut current_block_opt = blocks.first(); + while let Some(current_block) = current_block_opt { + if let Some(nakamoto_block) = current_block.anchored_header.as_stacks_nakamoto() + { + let block = current_block.clone(); + let block_parent_id = nakamoto_block.parent_block_id; + canonical_blocks.push(block); + current_block_opt = blocks + .iter() + .find(|block| block.index_block_hash() == block_parent_id); + } else { + break; + } + } + + Ok((canonical_blocks, header_info)) + }); + + let (tenure_blocks, header_info): (Vec, StacksHeaderInfo) = + match tenure_blocks_resp { + Ok((tenure_blocks, header_info)) => ( + tenure_blocks + .into_iter() + .map(|header| RPCTenureBlock { + block_id: header.index_block_hash(), + block_hash: header.anchored_header.block_hash(), + parent_block_id: match header.anchored_header { + StacksBlockHeaderTypes::Nakamoto(nakamoto) => { + nakamoto.parent_block_id.to_hex() + } + StacksBlockHeaderTypes::Epoch2(epoch2) => { + epoch2.parent_block.to_hex() + } + }, + + height: header.stacks_block_height, + }) + .collect(), + header_info, + ), + Err(response) => { + return response.try_into_contents().map_err(NetError::from); + } + }; + + let tenure = RPCTenure { + consensus_hash: header_info.consensus_hash, + burn_block_height: header_info.burn_header_height.into(), + burn_block_hash: header_info.burn_header_hash.to_hex(), + stacks_blocks: tenure_blocks, + }; + + let preamble = HttpResponsePreamble::ok_json(&preamble); + let body = HttpResponseContents::try_from_json(&tenure)?; + Ok((preamble, body)) + } +} + +/// Decode the HTTP response +impl HttpResponse for RPCNakamotoTenureBlocksRequestHandler { + fn try_parse_response( + &self, + preamble: &HttpResponsePreamble, + body: &[u8], + ) -> Result { + let tenure: RPCTenure = parse_json(preamble, body)?; + Ok(HttpResponsePayload::try_from_json(tenure)?) + } +} + +impl StacksHttpRequest { + /// Make a new getinfo request to this endpoint + pub fn new_get_tenure_blocks( + host: PeerHost, + consensus_hash: &ConsensusHash, + ) -> StacksHttpRequest { + StacksHttpRequest::new_for_peer( + host, + "GET".into(), + format!("/v3/tenures/blocks/{}", consensus_hash), + HttpRequestContents::new(), + ) + .expect("FATAL: failed to construct request from infallible data") + } +} + +impl StacksHttpResponse { + pub fn decode_tenure_blocks(self) -> Result { + let contents = self.get_http_payload_ok()?; + let response_json: serde_json::Value = contents.try_into()?; + let tenure: RPCTenure = serde_json::from_value(response_json) + .map_err(|_e| Error::DecodeError("Failed to decode JSON".to_string()))?; + Ok(tenure) + } +} diff --git a/stackslib/src/net/api/mod.rs b/stackslib/src/net/api/mod.rs index 2f2f052eb6..af17424f60 100644 --- a/stackslib/src/net/api/mod.rs +++ b/stackslib/src/net/api/mod.rs @@ -49,6 +49,7 @@ pub mod getstackerdbmetadata; pub mod getstackers; pub mod getstxtransfercost; pub mod gettenure; +pub mod gettenureblocks; pub mod gettenureinfo; pub mod gettenuretip; pub mod gettransaction; @@ -119,6 +120,7 @@ impl StacksHttp { self.register_rpc_endpoint(gettenure::RPCNakamotoTenureRequestHandler::new()); self.register_rpc_endpoint(gettenureinfo::RPCNakamotoTenureInfoRequestHandler::new()); self.register_rpc_endpoint(gettenuretip::RPCNakamotoTenureTipRequestHandler::new()); + self.register_rpc_endpoint(gettenureblocks::RPCNakamotoTenureBlocksRequestHandler::new()); self.register_rpc_endpoint(get_tenures_fork_info::GetTenuresForkInfo::default()); self.register_rpc_endpoint( gettransaction_unconfirmed::RPCGetTransactionUnconfirmedRequestHandler::new(), diff --git a/stackslib/src/net/api/tests/gettenureblocks.rs b/stackslib/src/net/api/tests/gettenureblocks.rs new file mode 100644 index 0000000000..94fac116e2 --- /dev/null +++ b/stackslib/src/net/api/tests/gettenureblocks.rs @@ -0,0 +1,115 @@ +// Copyright (C) 2013-2020 Blockstack PBC, a public benefit corporation +// Copyright (C) 2020-2024 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 stacks_common::types::chainstate::ConsensusHash; + +use crate::net::api::gettenureblocks; +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::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 request = StacksHttpRequest::new_get_tenure_blocks(addr.into(), &ConsensusHash([0x01; 20])); + + 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 = gettenureblocks::RPCNakamotoTenureBlocksRequestHandler::new(); + let mut parsed_request = http + .handle_try_parse_request( + &mut handler, + &parsed_preamble.expect_request(), + &bytes[offset..], + ) + .unwrap(); + assert_eq!(handler.consensus_hash, Some(ConsensusHash([0x01; 20]))); + + // parsed request consumes headers that would not be in a constructed reqeuest + parsed_request.clear_headers(); + let (preamble, contents) = parsed_request.destruct(); + + assert_eq!(&preamble, request.preamble()); +} + +#[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_chain_tip = rpc_test.canonical_tip.clone(); + let consensus_hash = rpc_test.consensus_hash.clone(); + + let mut requests = vec![]; + + // query existing, non-empty Nakamoto tenure + let request = StacksHttpRequest::new_get_tenure_blocks(addr.clone().into(), &consensus_hash); + requests.push(request); + + // query non-existant tenure + let request = + StacksHttpRequest::new_get_tenure_blocks(addr.clone().into(), &ConsensusHash([0x01; 20])); + 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_tenure_blocks().unwrap(); + assert_eq!(resp.consensus_hash, consensus_hash); + let mut blocks_index = 0; + for block in test_observer.get_blocks().iter().rev() { + if block.metadata.consensus_hash != consensus_hash { + break; + } + + assert_eq!( + resp.stacks_blocks[blocks_index].block_id, + block.metadata.index_block_hash() + ); + + blocks_index += 1; + } + + assert_eq!(blocks_index, resp.stacks_blocks.len()); + + // got a failure + 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); +} diff --git a/stackslib/src/net/api/tests/mod.rs b/stackslib/src/net/api/tests/mod.rs index d8c97e6bbe..fd1141855c 100644 --- a/stackslib/src/net/api/tests/mod.rs +++ b/stackslib/src/net/api/tests/mod.rs @@ -90,6 +90,7 @@ mod getstackerdbchunk; mod getstackerdbmetadata; mod getstxtransfercost; mod gettenure; +mod gettenureblocks; mod gettenureinfo; mod gettenuretip; mod gettransaction;