From 07135290c1720a14c9d2f18a5700bb6565ae7a10 Mon Sep 17 00:00:00 2001 From: Roman Zeyde Date: Sat, 6 Dec 2025 15:08:57 +0100 Subject: [PATCH] rest: allow reading partial block data from storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It will allow fetching specific transactions using an external index, following https://github.com/bitcoin/bitcoin/pull/32541#issuecomment-3267485313. Co-authored-by: Hodlinator <172445034+hodlinator@users.noreply.github.com> Co-authored-by: Lőrinc --- doc/REST-interface.md | 5 +++ doc/release-notes-33657.md | 5 +++ src/rest.cpp | 56 ++++++++++++++++++++++++------- test/functional/interface_rest.py | 56 ++++++++++++++++++++++++++++--- 4 files changed, 106 insertions(+), 16 deletions(-) create mode 100644 doc/release-notes-33657.md diff --git a/doc/REST-interface.md b/doc/REST-interface.md index 0fee78129a6..ed46e22262b 100644 --- a/doc/REST-interface.md +++ b/doc/REST-interface.md @@ -47,6 +47,11 @@ The HTTP request and response are both handled entirely in-memory. With the /notxdetails/ option JSON response will only contain the transaction hash instead of the complete transaction details. The option only affects the JSON response. +- `GET /rest/blockpart/.?offset=&size=` + +Given a block hash: returns a block part, in binary or hex-encoded binary formats. +Responds with 404 if the block or the byte range doesn't exist. + #### Blockheaders `GET /rest/headers/.?count=` diff --git a/doc/release-notes-33657.md b/doc/release-notes-33657.md new file mode 100644 index 00000000000..f9e6841bf74 --- /dev/null +++ b/doc/release-notes-33657.md @@ -0,0 +1,5 @@ +New REST API +------------ + +- A new REST API endpoint (`/rest/blockpart/BLOCKHASH.bin?offset=X&size=Y`) has been introduced + for efficiently fetching a range of bytes from block `BLOCKHASH`. diff --git a/src/rest.cpp b/src/rest.cpp index 26312633fdd..06da3906649 100644 --- a/src/rest.cpp +++ b/src/rest.cpp @@ -379,10 +379,17 @@ static bool rest_spent_txouts(const std::any& context, HTTPRequest* req, const s } } +/** + * This handler is used by multiple HTTP endpoints: + * - `/block/` via `rest_block_extended()` + * - `/block/notxdetails/` via `rest_block_notxdetails()` + * - `/blockpart/` via `rest_block_part()` (doesn't support JSON response, so `tx_verbosity` is unset) + */ static bool rest_block(const std::any& context, HTTPRequest* req, const std::string& uri_part, - TxVerbosity tx_verbosity) + std::optional tx_verbosity, + std::optional> block_part = std::nullopt) { if (!CheckWarmup(req)) return false; @@ -416,12 +423,14 @@ static bool rest_block(const std::any& context, pos = pblockindex->GetBlockPos(); } - const auto block_data{chainman.m_blockman.ReadRawBlock(pos)}; + const auto block_data{chainman.m_blockman.ReadRawBlock(pos, block_part)}; if (!block_data) { switch (block_data.error()) { case node::ReadRawError::IO: return RESTERR(req, HTTP_INTERNAL_SERVER_ERROR, "I/O error reading " + hashStr); - case node::ReadRawError::BadPartRange: break; // can happen only when reading a block part - } + case node::ReadRawError::BadPartRange: + assert(block_part); + return RESTERR(req, HTTP_BAD_REQUEST, strprintf("Bad block part offset/size %d/%d for %s", block_part->first, block_part->second, hashStr)); + } // no default case, so the compiler can warn about missing cases assert(false); } @@ -440,14 +449,17 @@ static bool rest_block(const std::any& context, } case RESTResponseFormat::JSON: { - CBlock block{}; - DataStream block_stream{*block_data}; - block_stream >> TX_WITH_WITNESS(block); - UniValue objBlock = blockToJSON(chainman.m_blockman, block, *tip, *pblockindex, tx_verbosity, chainman.GetConsensus().powLimit); - std::string strJSON = objBlock.write() + "\n"; - req->WriteHeader("Content-Type", "application/json"); - req->WriteReply(HTTP_OK, strJSON); - return true; + if (tx_verbosity) { + CBlock block{}; + DataStream block_stream{*block_data}; + block_stream >> TX_WITH_WITNESS(block); + UniValue objBlock = blockToJSON(chainman.m_blockman, block, *tip, *pblockindex, *tx_verbosity, chainman.GetConsensus().powLimit); + std::string strJSON = objBlock.write() + "\n"; + req->WriteHeader("Content-Type", "application/json"); + req->WriteReply(HTTP_OK, strJSON); + return true; + } + return RESTERR(req, HTTP_BAD_REQUEST, "JSON output is not supported for this request type"); } default: { @@ -466,6 +478,25 @@ static bool rest_block_notxdetails(const std::any& context, HTTPRequest* req, co return rest_block(context, req, uri_part, TxVerbosity::SHOW_TXID); } +static bool rest_block_part(const std::any& context, HTTPRequest* req, const std::string& uri_part) +{ + try { + if (const auto opt_offset{ToIntegral(req->GetQueryParameter("offset").value_or(""))}) { + if (const auto opt_size{ToIntegral(req->GetQueryParameter("size").value_or(""))}) { + return rest_block(context, req, uri_part, + /*tx_verbosity=*/std::nullopt, + /*block_part=*/{{*opt_offset, *opt_size}}); + } else { + return RESTERR(req, HTTP_BAD_REQUEST, "Block part size missing or invalid"); + } + } else { + return RESTERR(req, HTTP_BAD_REQUEST, "Block part offset missing or invalid"); + } + } catch (const std::runtime_error& e) { + return RESTERR(req, HTTP_BAD_REQUEST, e.what()); + } +} + static bool rest_filter_header(const std::any& context, HTTPRequest* req, const std::string& uri_part) { if (!CheckWarmup(req)) return false; @@ -1114,6 +1145,7 @@ static const struct { {"/rest/tx/", rest_tx}, {"/rest/block/notxdetails/", rest_block_notxdetails}, {"/rest/block/", rest_block_extended}, + {"/rest/blockpart/", rest_block_part}, {"/rest/blockfilter/", rest_block_filter}, {"/rest/blockfilterheaders/", rest_filter_header}, {"/rest/chaininfo", rest_chaininfo}, diff --git a/test/functional/interface_rest.py b/test/functional/interface_rest.py index 54fc908a14a..be5bb78b10e 100755 --- a/test/functional/interface_rest.py +++ b/test/functional/interface_rest.py @@ -28,7 +28,6 @@ from test_framework.wallet import ( MiniWallet, getnewdestination, ) -from typing import Optional INVALID_PARAM = "abc" @@ -66,13 +65,16 @@ class RESTTest (BitcoinTestFramework): body: str = '', status: int = 200, ret_type: RetType = RetType.JSON, - query_params: Optional[dict[str, typing.Any]] = None, + query_params: typing.Union[dict[str, typing.Any], str, None] = None, ) -> typing.Union[http.client.HTTPResponse, bytes, str, None]: rest_uri = '/rest' + uri if req_type in ReqType: rest_uri += f'.{req_type.name.lower()}' if query_params: - rest_uri += f'?{urllib.parse.urlencode(query_params)}' + if isinstance(query_params, str): + rest_uri += f'?{query_params}' + else: + rest_uri += f'?{urllib.parse.urlencode(query_params)}' conn = http.client.HTTPConnection(self.url.hostname, self.url.port) self.log.debug(f'{http_method} {rest_uri} {body}') @@ -82,7 +84,7 @@ class RESTTest (BitcoinTestFramework): conn.request('POST', rest_uri, body) resp = conn.getresponse() - assert_equal(resp.status, status) + assert resp.status == status, f"Expected: {status}, Got: {resp.status} - Response: {str(resp.read())}" if ret_type == RetType.OBJ: return resp @@ -455,6 +457,52 @@ class RESTTest (BitcoinTestFramework): expected = [(p["scriptPubKey"], p["value"]) for p in prevouts] assert_equal(expected, actual) + self.log.info("Test the /blockpart URI") + + blockhash = self.nodes[0].getbestblockhash() + block_bin = self.test_rest_request(f"/block/{blockhash}", req_type=ReqType.BIN, ret_type=RetType.BYTES) + for req_type in (ReqType.BIN, ReqType.HEX): + def get_block_part(status: int = 200, **kwargs): + resp = self.test_rest_request(f"/blockpart/{blockhash}", status=status, + req_type=req_type, ret_type=RetType.BYTES, **kwargs) + assert isinstance(resp, bytes) + if req_type is ReqType.HEX and status == 200: + resp = bytes.fromhex(resp.decode().strip()) + return resp + + assert_equal(block_bin, get_block_part(query_params={"offset": 0, "size": len(block_bin)})) + + assert len(block_bin) >= 500 + assert_equal(block_bin[20:320], get_block_part(query_params={"offset": 20, "size": 300})) + assert_equal(block_bin[-5:], get_block_part(query_params={"offset": len(block_bin) - 5, "size": 5})) + + get_block_part(status=400, query_params={"offset": 10}) + get_block_part(status=400, query_params={"size": 100}) + get_block_part(status=400, query_params={"offset": "x"}) + get_block_part(status=400, query_params={"size": "y"}) + get_block_part(status=400, query_params={"offset": "x", "size": "y"}) + assert get_block_part(status=400, query_params="%XY").decode("utf-8").startswith("URI parsing failed") + + get_block_part(status=400, query_params={"offset": 0, "size": 0}) + get_block_part(status=400, query_params={"offset": len(block_bin), "size": 0}) + get_block_part(status=400, query_params={"offset": len(block_bin) + 1, "size": 1}) + get_block_part(status=400, query_params={"offset": len(block_bin), "size": 1}) + get_block_part(status=400, query_params={"offset": len(block_bin) + 1, "size": 1}) + get_block_part(status=400, query_params={"offset": 0, "size": len(block_bin) + 1}) + + self.test_rest_request(f"/blockpart/{blockhash}", status=400, req_type=ReqType.JSON, ret_type=RetType.OBJ) + + self.log.info("Missing block data should cause REST API to fail") + + self.test_rest_request(f"/block/{blockhash}", status=200, req_type=ReqType.BIN, ret_type=RetType.OBJ) + self.test_rest_request(f"/blockpart/{blockhash}", query_params={"offset": 0, "size": 1}, status=200, req_type=ReqType.BIN, ret_type=RetType.OBJ) + blk_files = list(self.nodes[0].blocks_path.glob("blk*.dat")) + for blk_file in blk_files: + blk_file.rename(blk_file.with_suffix('.bkp')) + self.test_rest_request(f"/block/{blockhash}", status=500, req_type=ReqType.BIN, ret_type=RetType.OBJ) + self.test_rest_request(f"/blockpart/{blockhash}", query_params={"offset": 0, "size": 1}, status=500, req_type=ReqType.BIN, ret_type=RetType.OBJ) + for blk_file in blk_files: + blk_file.with_suffix('.bkp').rename(blk_file) self.log.info("Test the /deploymentinfo URI")