Repository: stacks-network/stacks-core (language: python)

Issue:
Add additional GET RPC endpoints
Problem: the stacks blockchain RPC interface for GET requests is limited compared the data exposed in bitcoin/etc and the rich data in Hiro's API. 

With recent changes to the HTTP interface (https://github.com/stacks-network/stacks-core/tree/master/stackslib/src/net/api), it should be much easier to add more RPC endpoints to expose some generic data. Initially, i'm thinking something like:
- block data
- tx data
- address info

some comparable bitcoin-cli rpc commands:
`getblockhash`
`getblock`
`getrawtransaction`
`gettxout`

for the data exposed, i don't think it should be a requirement to be as detailed as the Hiro API - but i think should be enough to understand what is in a block, or what a tx is doing (as well as retrieving the block hash by a block height). 


references:
bitcoin RPC:
- https://developer.bitcoin.org/reference/rpc/getblockhash.html
- https://developer.bitcoin.org/reference/rpc/getblock.html
- https://developer.bitcoin.org/reference/rpc/getaddressinfo.html
- https://developer.bitcoin.org/reference/rpc/getrawtransaction.html
- https://developer.bitcoin.org/reference/rpc/gettxout.html

Hiro API:
- https://hirosystems.github.io/stacks-blockchain-api//#tag/Blocks/operation/get_block_by_hash
- https://hirosystems.github.io/stacks-blockchain-api//#tag/Blocks/operation/get_block_by_height
- https://hirosystems.github.io/stacks-blockchain-api//#tag/Blocks/operation/get_block_by_burn_block_hash
- https://hirosystems.github.io/stacks-blockchain-api//#tag/Transactions/operation/get_transaction_by_id
- https://hirosystems.github.io/stacks-blockchain-api//#tag/Transactions/operation/get_raw_transaction_by_id

Add additional GET RPC endpoints
Problem: the stacks blockchain RPC interface for GET requests is limited compared the data exposed in bitcoin/etc and the rich data in Hiro's API. 

With recent changes to the HTTP interface (https://github.com/stacks-network/stacks-core/tree/master/stackslib/src/net/api), it should be much easier to add more RPC endpoints to expose some generic data. Initially, i'm thinking something like:
- block data
- tx data
- address info

some comparable bitcoin-cli rpc commands:
`getblockhash`
`getblock`
`getrawtransaction`
`gettxout`

for the data exposed, i don't think it should be a requirement to be as detailed as the Hiro API - but i think should be enough to understand what is in a block, or what a tx is doing (as well as retrieving the block hash by a block height). 


references:
bitcoin RPC:
- https://developer.bitcoin.org/reference/rpc/getblockhash.html
- https://developer.bitcoin.org/reference/rpc/getblock.html
- https://developer.bitcoin.org/reference/rpc/getaddressinfo.html
- https://developer.bitcoin.org/reference/rpc/getrawtransaction.html
- https://developer.bitcoin.org/reference/rpc/gettxout.html

Hiro API:
- https://hirosystems.github.io/stacks-blockchain-api//#tag/Blocks/operation/get_block_by_hash
- https://hirosystems.github.io/stacks-blockchain-api//#tag/Blocks/operation/get_block_by_height
- https://hirosystems.github.io/stacks-blockchain-api//#tag/Blocks/operation/get_block_by_burn_block_hash
- https://hirosystems.github.io/stacks-blockchain-api//#tag/Transactions/operation/get_transaction_by_id
- https://hirosystems.github.io/stacks-blockchain-api//#tag/Transactions/operation/get_raw_transaction_by_id



Discussion / hints:
* We have `getblock` via `GET /v2/blocks/{index-block-hash}`.
* We have `gettxout` via `GET /v2/accounts/{:address}`

We can add an endpoint to get a sortition for a particular block height, which would be the equivalent of `getblockhash`.

We don't have our equivalent of txindex turned on by default, so `getrawtransaction` would be disabled by default.  However, we could add this endpoint to serve whatever indexed transactions we have if the user enables txindex.
@saralab Do you think this would be a good ticket for me to work on during my free cycles/after my current tickets are merged? If so, perhaps it could be further scoped with the help of @kantai and @jferrant to determine what would be most useful for Nakamoto.
* We have `getblock` via `GET /v2/blocks/{index-block-hash}`.
* We have `gettxout` via `GET /v2/accounts/{:address}`

We can add an endpoint to get a sortition for a particular block height, which would be the equivalent of `getblockhash`.

We don't have our equivalent of txindex turned on by default, so `getrawtransaction` would be disabled by default.  However, we could add this endpoint to serve whatever indexed transactions we have if the user enables txindex.
@saralab Do you think this would be a good ticket for me to work on during my free cycles/after my current tickets are merged? If so, perhaps it could be further scoped with the help of @kantai and @jferrant to determine what would be most useful for Nakamoto.

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e66d126390..2f9d50c249 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,7 @@ and this project adheres to the versioning scheme outlined in the [README.md](RE
 - Add index to `metadata_table` in Clarity DB on `blockhash`
 - Add `block_commit_delay_ms` to the config file to control the time to wait after seeing a new burn block, before submitting a block commit, to allow time for the first Nakamoto block of the new tenure to be mined, allowing this miner to avoid the need to RBF the block commit.
 - Add `tenure_cost_limit_per_block_percentage` to the miner config file to control the percentage remaining tenure cost limit to consume per nakamoto block.
+- Add `/v3/blocks/height/:block_height` rpc endpoint
 
 ## [3.0.0.0.1]
 
diff --git a/docs/rpc-endpoints.md b/docs/rpc-endpoints.md
index 9f0e09fd20..94a5479613 100644
--- a/docs/rpc-endpoints.md
+++ b/docs/rpc-endpoints.md
@@ -503,6 +503,17 @@ data.
 
 This will return 404 if the block does not exist.
 
+### GET /v3/blocks/height/[Block Height]
+
+Fetch a Nakamoto block given its block height.  This returns the raw block
+data.
+
+This will return 404 if the block does not exist.
+
+This endpoint also accepts a querystring parameter `?tip=` which when supplied 
+will return the block relative to the specified tip allowing the querying of 
+sibling blocks (same height, different tip) too.
+
 ### GET /v3/tenures/[Block ID]
 
 Fetch a Nakamoto block and all of its ancestors in the same tenure, given its
diff --git a/docs/rpc/openapi.yaml b/docs/rpc/openapi.yaml
index c4dd06721c..db36da8bac 100644
--- a/docs/rpc/openapi.yaml
+++ b/docs/rpc/openapi.yaml
@@ -627,6 +627,40 @@ paths:
           content:
             application/text-plain: {}
 
+  /v3/blocks/height/{block_height}:
+    get:
+      summary: Fetch a Nakamoto block by its height and optional tip
+      tags:
+        - Blocks
+      operationId: get_block_v3_by_height
+      description:
+        Fetch a Nakamoto block by its height and optional tip.
+      parameters:
+        - name: block_height
+          in: path
+          description: The block's height
+          required: true
+          schema:
+            type: integer
+        - name: tip
+          in: query
+          schema:
+            type: string
+          description: The Stacks chain tip to query from. If tip == latest or empty, the query will be run
+            from the latest known tip.
+      responses:
+        "200":
+          description: The raw SIP-003-encoded block will be returned.
+          content:
+            application/octet-stream:
+              schema:
+                type: string
+                format: binary
+        "404":
+          description: The block could not be found
+          content:
+            application/text-plain: {}
+
   /v3/tenures/info:
     get:
       summary: Fetch metadata about the ongoing Nakamoto tenure
diff --git a/stackslib/src/net/api/getblockbyheight.rs b/stackslib/src/net/api/getblockbyheight.rs
new file mode 100644
index 0000000000..9a17589c5d
--- /dev/null
+++ b/stackslib/src/net/api/getblockbyheight.rs
@@ -0,0 +1,231 @@
+// 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 <http://www.gnu.org/licenses/>.
+
+use std::io::{Read, Seek, SeekFrom, Write};
+use std::{fs, io};
+
+use regex::{Captures, Regex};
+use rusqlite::Connection;
+use serde::de::Error as de_Error;
+use stacks_common::codec::{StacksMessageCodec, MAX_MESSAGE_LEN};
+use stacks_common::types::chainstate::{ConsensusHash, StacksBlockId};
+use stacks_common::types::net::PeerHost;
+use {serde, serde_json};
+
+use crate::chainstate::nakamoto::{
+    NakamotoBlock, NakamotoChainState, NakamotoStagingBlocksConn, StacksDBIndexed,
+};
+use crate::chainstate::stacks::db::StacksChainState;
+use crate::chainstate::stacks::Error as ChainError;
+use crate::net::api::getblock_v3::{NakamotoBlockStream, RPCNakamotoBlockRequestHandler};
+use crate::net::http::{
+    parse_bytes, Error, HttpBadRequest, HttpChunkGenerator, HttpContentType, HttpNotFound,
+    HttpRequest, HttpRequestContents, HttpRequestPreamble, HttpResponse, HttpResponseContents,
+    HttpResponsePayload, HttpResponsePreamble, HttpServerError, HttpVersion,
+};
+use crate::net::httpcore::{
+    HttpRequestContentsExtensions, RPCRequestHandler, StacksHttp, StacksHttpRequest,
+    StacksHttpResponse,
+};
+use crate::net::{Error as NetError, StacksNodeState, TipRequest, MAX_HEADERS};
+use crate::util_lib::db::{DBConn, Error as DBError};
+
+#[derive(Clone)]
+pub struct RPCNakamotoBlockByHeightRequestHandler {
+    pub block_height: Option<u64>,
+}
+
+impl RPCNakamotoBlockByHeightRequestHandler {
+    pub fn new() -> Self {
+        Self { block_height: None }
+    }
+}
+
+/// Decode the HTTP request
+impl HttpRequest for RPCNakamotoBlockByHeightRequestHandler {
+    fn verb(&self) -> &'static str {
+        "GET"
+    }
+
+    fn path_regex(&self) -> Regex {
+        Regex::new(r#"^/v3/blocks/height/(?P<block_height>[0-9]{1,20})$"#).unwrap()
+    }
+
+    fn metrics_identifier(&self) -> &str {
+        "/v3/blocks/height/:block_height"
+    }
+
+    /// 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<HttpRequestContents, Error> {
+        if preamble.get_content_length() != 0 {
+            return Err(Error::DecodeError(
+                "Invalid Http request: expected 0-length body".to_string(),
+            ));
+        }
+
+        let block_height_str = captures
+            .name("block_height")
+            .ok_or_else(|| {
+                Error::DecodeError("Failed to match path to block height group".to_string())
+            })?
+            .as_str();
+
+        let block_height = block_height_str.parse::<u64>().map_err(|_| {
+            Error::DecodeError("Invalid path: unparseable block height".to_string())
+        })?;
+        self.block_height = Some(block_height);
+
+        Ok(HttpRequestContents::new().query_string(query))
+    }
+}
+
+impl RPCRequestHandler for RPCNakamotoBlockByHeightRequestHandler {
+    /// Reset internal state
+    fn restart(&mut self) {
+        self.block_height = None;
+    }
+
+    /// Make the response
+    fn try_handle_request(
+        &mut self,
+        preamble: HttpRequestPreamble,
+        contents: HttpRequestContents,
+        node: &mut StacksNodeState,
+    ) -> Result<(HttpResponsePreamble, HttpResponseContents), NetError> {
+        let block_height = self
+            .block_height
+            .take()
+            .ok_or(NetError::SendError("Missing `block_height`".into()))?;
+
+        let tip = match node.load_stacks_chain_tip(&preamble, &contents) {
+            Ok(tip) => tip,
+            Err(error_resp) => {
+                return error_resp.try_into_contents().map_err(NetError::from);
+            }
+        };
+
+        let index_block_hash_res =
+            node.with_node_state(|_network, _sortdb, chainstate, _mempool, _rpc_args| {
+                chainstate
+                    .index_conn()
+                    .get_ancestor_block_hash(block_height, &tip)
+            });
+
+        let block_id = match index_block_hash_res {
+            Ok(index_block_hash_opt) => match index_block_hash_opt {
+                Some(index_block_hash) => index_block_hash,
+                None => {
+                    // block hash not found
+                    let msg = format!("No such block #{:?}\n", block_height);
+                    warn!("{}", &msg);
+                    return StacksHttpResponse::new_error(&preamble, &HttpNotFound::new(msg))
+                        .try_into_contents()
+                        .map_err(NetError::from);
+                }
+            },
+            Err(e) => {
+                // error querying the db
+                let msg = format!("Failed to load block #{}: {:?}\n", block_height, &e);
+                warn!("{}", &msg);
+                return StacksHttpResponse::new_error(&preamble, &HttpServerError::new(msg))
+                    .try_into_contents()
+                    .map_err(NetError::from);
+            }
+        };
+
+        let stream_res =
+            node.with_node_state(|_network, _sortdb, chainstate, _mempool, _rpc_args| {
+                let Some((tenure_id, parent_block_id)) = chainstate
+                    .nakamoto_blocks_db()
+                    .get_tenure_and_parent_block_id(&block_id)?
+                else {
+                    return Err(ChainError::NoSuchBlockError);
+                };
+                NakamotoBlockStream::new(chainstate, block_id, tenure_id, parent_block_id)
+            });
+
+        // start loading up the block
+        let stream = match stream_res {
+            Ok(stream) => stream,
+            Err(ChainError::NoSuchBlockError) => {
+                return StacksHttpResponse::new_error(
+                    &preamble,
+                    &HttpNotFound::new(format!("No such block #{:?}\n", &block_height)),
+                )
+                .try_into_contents()
+                .map_err(NetError::from)
+            }
+            Err(e) => {
+                // nope -- error trying to check
+                let msg = format!("Failed to load block #{}: {:?}\n", block_height, &e);
+                warn!("{}", &msg);
+                return StacksHttpResponse::new_error(&preamble, &HttpServerError::new(msg))
+                    .try_into_contents()
+                    .map_err(NetError::from);
+            }
+        };
+
+        let resp_preamble = HttpResponsePreamble::from_http_request_preamble(
+            &preamble,
+            200,
+            "OK",
+            None,
+            HttpContentType::Bytes,
+        );
+
+        Ok((
+            resp_preamble,
+            HttpResponseContents::from_stream(Box::new(stream)),
+        ))
+    }
+}
+
+/// Decode the HTTP response
+impl HttpResponse for RPCNakamotoBlockByHeightRequestHandler {
+    /// 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<HttpResponsePayload, Error> {
+        let bytes = parse_bytes(preamble, body, MAX_MESSAGE_LEN.into())?;
+        Ok(HttpResponsePayload::Bytes(bytes))
+    }
+}
+
+impl StacksHttpRequest {
+    pub fn new_get_nakamoto_block_by_height(
+        host: PeerHost,
+        block_height: u64,
+        tip: TipRequest,
+    ) -> StacksHttpRequest {
+        StacksHttpRequest::new_for_peer(
+            host,
+            "GET".into(),
+            format!("/v3/blocks/height/{}", block_height),
+            HttpRequestContents::new().for_tip(tip),
+        )
+        .expect("FATAL: failed to construct request from infallible data")
+    }
+}
diff --git a/stackslib/src/net/api/mod.rs b/stackslib/src/net/api/mod.rs
index 72aa417204..8fc8ee33ba 100644
--- a/stackslib/src/net/api/mod.rs
+++ b/stackslib/src/net/api/mod.rs
@@ -42,6 +42,7 @@ pub mod getattachment;
 pub mod getattachmentsinv;
 pub mod getblock;
 pub mod getblock_v3;
+pub mod getblockbyheight;
 pub mod getconstantval;
 pub mod getcontractabi;
 pub mod getcontractsrc;
@@ -92,6 +93,7 @@ impl StacksHttp {
         self.register_rpc_endpoint(getattachmentsinv::RPCGetAttachmentsInvRequestHandler::new());
         self.register_rpc_endpoint(getblock::RPCBlocksRequestHandler::new());
         self.register_rpc_endpoint(getblock_v3::RPCNakamotoBlockRequestHandler::new());
+        self.register_rpc_endpoint(getblockbyheight::RPCNakamotoBlockByHeightRequestHandler::new());
         self.register_rpc_endpoint(getconstantval::RPCGetConstantValRequestHandler::new());
         self.register_rpc_endpoint(getcontractabi::RPCGetContractAbiRequestHandler::new());
         self.register_rpc_endpoint(getcontractsrc::RPCGetContractSrcRequestHandler::new());


Test patch:
diff --git a/.github/workflows/bitcoin-tests.yml b/.github/workflows/bitcoin-tests.yml
index 12094e88ee..ebe9f433a9 100644
--- a/.github/workflows/bitcoin-tests.yml
+++ b/.github/workflows/bitcoin-tests.yml
@@ -141,6 +141,7 @@ jobs:
           - tests::nakamoto_integrations::v3_signer_api_endpoint
           - tests::nakamoto_integrations::signer_chainstate
           - tests::nakamoto_integrations::clarity_cost_spend_down
+          - tests::nakamoto_integrations::v3_blockbyheight_api_endpoint
           # TODO: enable these once v1 signer is supported by a new nakamoto epoch
           # - tests::signer::v1::dkg
           # - tests::signer::v1::sign_request_rejected
diff --git a/stackslib/src/net/api/tests/getblockbyheight.rs b/stackslib/src/net/api/tests/getblockbyheight.rs
new file mode 100644
index 0000000000..0f608c4e13
--- /dev/null
+++ b/stackslib/src/net/api/tests/getblockbyheight.rs
@@ -0,0 +1,192 @@
+// 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 <http://www.gnu.org/licenses/>.
+
+use std::net::{IpAddr, Ipv4Addr, SocketAddr};
+
+use clarity::vm::types::{QualifiedContractIdentifier, StacksAddressExtensions};
+use clarity::vm::{ClarityName, ContractName};
+use stacks_common::codec::StacksMessageCodec;
+use stacks_common::types::chainstate::{
+    BlockHeaderHash, ConsensusHash, StacksAddress, StacksBlockId, StacksPrivateKey,
+};
+use stacks_common::types::net::PeerHost;
+use stacks_common::types::Address;
+
+use super::TestRPC;
+use crate::chainstate::burn::db::sortdb::{SortitionDB, SortitionHandle};
+use crate::chainstate::nakamoto::{NakamotoBlock, NakamotoChainState};
+use crate::chainstate::stacks::db::blocks::test::*;
+use crate::chainstate::stacks::db::StacksChainState;
+use crate::chainstate::stacks::{
+    Error as chainstate_error, StacksBlock, StacksBlockHeader, StacksMicroblock,
+};
+use crate::net::api::getblock_v3::NakamotoBlockStream;
+use crate::net::api::*;
+use crate::net::connection::ConnectionOptions;
+use crate::net::http::HttpChunkGenerator;
+use crate::net::httpcore::{
+    HttpPreambleExtensions, HttpRequestContentsExtensions, RPCRequestHandler, StacksHttp,
+    StacksHttpRequest,
+};
+use crate::net::test::TestEventObserver;
+use crate::net::tests::inv::nakamoto::make_nakamoto_peer_from_invs;
+use crate::net::{ProtocolFamily, TipRequest};
+use crate::util_lib::db::DBConn;
+
+#[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());
+
+    // NOTE: MARF enforces the height to be a u32 value
+    let request = StacksHttpRequest::new_get_nakamoto_block_by_height(
+        addr.into(),
+        0xfffffffe,
+        TipRequest::UseLatestAnchoredTip,
+    );
+    // NOTE: MARF enforces the height to be a u32 value
+    let request = StacksHttpRequest::new_get_nakamoto_block_by_height(
+        addr.into(),
+        0xfffffffe,
+        TipRequest::UseLatestAnchoredTip,
+    );
+    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 = getblockbyheight::RPCNakamotoBlockByHeightRequestHandler::new();
+    let mut parsed_request = http
+        .handle_try_parse_request(
+            &mut handler,
+            &parsed_preamble.expect_request(),
+            &bytes[offset..],
+        )
+        .unwrap();
+
+    // parsed request consumes headers that would not be in a constructed request
+    parsed_request.clear_headers();
+    let (preamble, contents) = parsed_request.destruct();
+
+    // consumed path args
+    assert_eq!(handler.block_height, Some(0xfffffffe));
+
+    assert_eq!(&preamble, request.preamble());
+
+    handler.restart();
+    assert!(handler.block_height.is_none());
+}
+
+#[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_height = rpc_test.tip_height.clone();
+    let canonical_tip = rpc_test.canonical_tip.clone();
+    let consensus_hash = rpc_test.consensus_hash.clone();
+
+    let mut requests = vec![];
+
+    // query existing block (empty tip)
+    let request = StacksHttpRequest::new_get_nakamoto_block_by_height(
+        addr.into(),
+        nakamoto_chain_tip_height,
+        TipRequest::UseLatestAnchoredTip,
+    );
+    requests.push(request);
+
+    // query non-existent block (with biggest positive u32 value - 1 as MARF enforces it)
+    let request = StacksHttpRequest::new_get_nakamoto_block_by_height(
+        addr.into(),
+        0xfffffffe,
+        TipRequest::UseLatestAnchoredTip,
+    );
+    requests.push(request);
+
+    // query existing block using the canonical_tip
+    let request = StacksHttpRequest::new_get_nakamoto_block_by_height(
+        addr.into(),
+        nakamoto_chain_tip_height,
+        TipRequest::SpecificTip(rpc_test.canonical_tip),
+    );
+    requests.push(request);
+
+    // query existing block using the unconfirmed tip
+    let request = StacksHttpRequest::new_get_nakamoto_block_by_height(
+        addr.into(),
+        nakamoto_chain_tip_height,
+        TipRequest::UseLatestUnconfirmedTip,
+    );
+    requests.push(request);
+
+    // dummy hack for generating an invalid tip
+    let mut dummy_tip = rpc_test.canonical_tip.clone();
+    dummy_tip.0[0] = dummy_tip.0[0].wrapping_add(1);
+
+    let request = StacksHttpRequest::new_get_nakamoto_block_by_height(
+        addr.into(),
+        nakamoto_chain_tip_height,
+        TipRequest::SpecificTip(dummy_tip),
+    );
+    // dummy hack for generating an invalid tip
+    let mut dummy_tip = rpc_test.canonical_tip.clone();
+    dummy_tip.0[0] = dummy_tip.0[0].wrapping_add(1);
+
+    let request = StacksHttpRequest::new_get_nakamoto_block_by_height(
+        addr.into(),
+        nakamoto_chain_tip_height,
+        TipRequest::SpecificTip(dummy_tip),
+    );
+    requests.push(request);
+
+    let mut responses = rpc_test.run(requests);
+
+    // got the block
+    let response = responses.remove(0);
+    let resp = response.decode_nakamoto_block().unwrap();
+
+    assert_eq!(resp.header.consensus_hash, consensus_hash);
+    assert_eq!(resp.header.block_id(), canonical_tip);
+
+    // no block
+    let response = responses.remove(0);
+    let (preamble, body) = response.destruct();
+
+    assert_eq!(preamble.status_code, 404);
+
+    // got the block from the tip
+    let response = responses.remove(0);
+    let resp = response.decode_nakamoto_block().unwrap();
+
+    assert_eq!(resp.header.consensus_hash, consensus_hash);
+    assert_eq!(resp.header.block_id(), canonical_tip);
+
+    // got the block from the tip (unconfirmed)
+    let response = responses.remove(0);
+    let resp = response.decode_nakamoto_block().unwrap();
+
+    assert_eq!(resp.header.consensus_hash, consensus_hash);
+    assert_eq!(resp.header.block_id(), canonical_tip);
+
+    // no block for dummy tip
+    let response = responses.remove(0);
+    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 d19854bf02..40d329686d 100644
--- a/stackslib/src/net/api/tests/mod.rs
+++ b/stackslib/src/net/api/tests/mod.rs
@@ -60,6 +60,7 @@ mod getattachment;
 mod getattachmentsinv;
 mod getblock;
 mod getblock_v3;
+mod getblockbyheight;
 mod getconstantval;
 mod getcontractabi;
 mod getcontractsrc;
@@ -199,6 +200,10 @@ pub struct TestRPC<'a> {
     pub convo_2: ConversationHttp,
     /// hash of the chain tip
     pub canonical_tip: StacksBlockId,
+    /// block header hash of the chain tip
+    pub tip_hash: BlockHeaderHash,
+    /// block height of the chain tip
+    pub tip_height: u64,
     /// consensus hash of the chain tip
     pub consensus_hash: ConsensusHash,
     /// hash of last microblock
@@ -515,6 +520,7 @@ impl<'a> TestRPC<'a> {
         let microblock_txids = microblock.txs.iter().map(|tx| tx.txid()).collect();
         let canonical_tip =
             StacksBlockHeader::make_index_block_hash(&consensus_hash, &stacks_block.block_hash());
+        let tip_hash = stacks_block.block_hash();
 
         if process_microblock {
             // store microblock stream
@@ -812,6 +818,8 @@ impl<'a> TestRPC<'a> {
             32,
         );
 
+        let tip_height: u64 = 1;
+
         TestRPC {
             privk1,
             privk2,
@@ -822,6 +830,8 @@ impl<'a> TestRPC<'a> {
             convo_1,
             convo_2,
             canonical_tip,
+            tip_hash,
+            tip_height,
             consensus_hash,
             microblock_tip_hash: microblock.block_hash(),
             mempool_txids,
@@ -909,6 +919,8 @@ impl<'a> TestRPC<'a> {
             convo_2,
             canonical_tip: nakamoto_tip.index_block_hash(),
             consensus_hash: nakamoto_tip.consensus_hash.clone(),
+            tip_hash: nakamoto_tip.anchored_header.block_hash(),
+            tip_height: nakamoto_tip.stacks_block_height,
             microblock_tip_hash: BlockHeaderHash([0x00; 32]),
             mempool_txids: vec![],
             microblock_txids: vec![],
diff --git a/testnet/stacks-node/src/tests/nakamoto_integrations.rs b/testnet/stacks-node/src/tests/nakamoto_integrations.rs
index 81ab236066..5d712ad550 100644
--- a/testnet/stacks-node/src/tests/nakamoto_integrations.rs
+++ b/testnet/stacks-node/src/tests/nakamoto_integrations.rs
@@ -9303,6 +9303,132 @@ fn v3_signer_api_endpoint() {
     run_loop_thread.join().unwrap();
 }
 
+/// Test `/v3/blocks/height` API endpoint
+///
+/// This endpoint returns the block blob given a height
+#[test]
+#[ignore]
+fn v3_blockbyheight_api_endpoint() {
+    if env::var("BITCOIND_TEST") != Ok("1".into()) {
+        return;
+    }
+
+    let (mut conf, _miner_account) = naka_neon_integration_conf(None);
+    let password = "12345".to_string();
+    conf.connection_options.auth_token = Some(password.clone());
+    conf.miner.wait_on_interim_blocks = Duration::from_secs(1);
+    let stacker_sk = setup_stacker(&mut conf);
+    let signer_sk = Secp256k1PrivateKey::new();
+    let signer_addr = tests::to_addr(&signer_sk);
+    let sender_sk = Secp256k1PrivateKey::new();
+    // setup sender + recipient for some test stx transfers
+    // these are necessary for the interim blocks to get mined at all
+    let sender_addr = tests::to_addr(&sender_sk);
+    let send_amt = 100;
+    let send_fee = 180;
+    conf.add_initial_balance(
+        PrincipalData::from(sender_addr).to_string(),
+        send_amt + send_fee,
+    );
+    conf.add_initial_balance(PrincipalData::from(signer_addr).to_string(), 100000);
+
+    // only subscribe to the block proposal events
+    test_observer::spawn();
+    test_observer::register(&mut conf, &[EventKeyType::BlockProposal]);
+
+    let mut btcd_controller = BitcoinCoreController::new(conf.clone());
+    btcd_controller
+        .start_bitcoind()
+        .expect("Failed starting bitcoind");
+    let mut btc_regtest_controller = BitcoinRegtestController::new(conf.clone(), None);
+    btc_regtest_controller.bootstrap_chain(201);
+
+    let mut run_loop = boot_nakamoto::BootRunLoop::new(conf.clone()).unwrap();
+    let run_loop_stopper = run_loop.get_termination_switch();
+    let Counters {
+        blocks_processed,
+        naka_submitted_commits: commits_submitted,
+        naka_proposed_blocks: proposals_submitted,
+        ..
+    } = run_loop.counters();
+
+    let coord_channel = run_loop.coordinator_channels();
+
+    let run_loop_thread = thread::spawn(move || run_loop.start(None, 0));
+    let mut signers = TestSigners::new(vec![signer_sk]);
+    wait_for_runloop(&blocks_processed);
+    boot_to_epoch_3(
+        &conf,
+        &blocks_processed,
+        &[stacker_sk],
+        &[signer_sk],
+        &mut Some(&mut signers),
+        &mut btc_regtest_controller,
+    );
+
+    info!("------------------------- Reached Epoch 3.0 -------------------------");
+
+    blind_signer(&conf, &signers, proposals_submitted);
+
+    wait_for_first_naka_block_commit(60, &commits_submitted);
+
+    // Mine 1 nakamoto tenure
+    next_block_and_mine_commit(
+        &mut btc_regtest_controller,
+        60,
+        &coord_channel,
+        &commits_submitted,
+    )
+    .unwrap();
+
+    let burnchain = conf.get_burnchain();
+    let sortdb = burnchain.open_sortition_db(true).unwrap();
+    let (chainstate, _) = StacksChainState::open(
+        conf.is_mainnet(),
+        conf.burnchain.chain_id,
+        &conf.get_chainstate_path_str(),
+        None,
+    )
+    .unwrap();
+
+    info!("------------------------- Setup finished, run test -------------------------");
+
+    let http_origin = format!("http://{}", &conf.node.rpc_bind);
+
+    let get_v3_block_by_height = |height: u64| {
+        let url = &format!("{http_origin}/v3/blocks/height/{height}");
+        info!("Send request: GET {url}");
+        reqwest::blocking::get(url).unwrap_or_else(|e| panic!("GET request failed: {e}"))
+    };
+
+    let tip = NakamotoChainState::get_canonical_block_header(chainstate.db(), &sortdb)
+        .unwrap()
+        .unwrap();
+
+    let block_height = tip.stacks_block_height;
+    let block_data = get_v3_block_by_height(block_height);
+
+    assert!(block_data.status().is_success());
+    let block_bytes_vec = block_data.bytes().unwrap().to_vec();
+    assert!(block_bytes_vec.len() > 0);
+
+    // does the block id of the returned blob matches ?
+    let block_id = NakamotoBlockHeader::consensus_deserialize(&mut block_bytes_vec.as_slice())
+        .unwrap()
+        .block_id();
+    assert_eq!(block_id, tip.index_block_hash());
+
+    info!("------------------------- Test finished, clean up -------------------------");
+
+    coord_channel
+        .lock()
+        .expect("Mutex poisoned")
+        .stop_chains_coordinator();
+    run_loop_stopper.store(false, Ordering::SeqCst);
+
+    run_loop_thread.join().unwrap();
+}
+
 #[test]
 #[ignore]
 /// This test spins up a nakamoto-neon node.