Skip to content

Commit b3bb403

Browse files
committed
Merge bitcoin/bitcoin#32540: rest: fetch spent transaction outputs by blockhash
c48846e doc: add release notes for #32540 (Roman Zeyde) d4e212e rest: fetch spent transaction outputs by blockhash (Roman Zeyde) Pull request description: Today, it is possible to fetch a block's spent prevouts in order to build an external index by using the `/rest/block/BLOCKHASH.json` endpoint. However, its performance is low due to JSON serialization overhead. We can significantly optimize it by adding a new [REST API](https://github.com/bitcoin/bitcoin/blob/master/doc/REST-interface.md) endpoint, using a binary response format (returning a collection of spent txout lists, one per each block transaction): ``` $ BLOCKHASH=00000000000000000002a7c4c1e48d76c5a37902165a270156b7a8d72728a054 $ ab -k -c 1 -n 100 http://localhost:8332/rest/block/$BLOCKHASH.json Document Length: 13278152 bytes Requests per second: 3.53 [#/sec] (mean) Time per request: 283.569 [ms] (mean) $ ab -k -c 1 -n 10000 http://localhost:8332/rest/spenttxouts/$BLOCKHASH.bin Document Length: 195591 bytes Requests per second: 254.47 [#/sec] (mean) Time per request: 3.930 [ms] (mean) ``` Currently, this PR is being used and tested by Bindex[^1]. This PR would allow to improve the performance of external indexers such as electrs[^2], ElectrumX[^3], Fulcrum[^4] and Blockbook[^5]. [^1]: https://github.com/romanz/bindex-rs [^2]: https://github.com/romanz/electrs (also [blockstream.info](https://github.com/Blockstream/electrs) and [mempool.space](https://github.com/mempool/electrs) forks) [^3]: https://github.com/spesmilo/electrumx [^4]: https://github.com/cculianu/Fulcrum [^5]: https://github.com/trezor/blockbook ACKs for top commit: maflcko: re-ACK c48846e 📶 TheCharlatan: Re-ACK c48846e achow101: ACK c48846e Tree-SHA512: cf423541be90d6615289760494ae849b7239b69427036db6cc528ac81df10900f514471d81a460125522c5ffa31e9747ddfca187a1f93151e4ae77fe773c6b7b
2 parents 3086c21 + c48846e commit b3bb403

File tree

4 files changed

+149
-0
lines changed

4 files changed

+149
-0
lines changed

doc/release-notes-32540.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
New RPCs
2+
--------
3+
4+
- A new REST API endpoint (`/rest/spenttxouts/BLOCKHASH`) has been introduced for
5+
efficiently fetching spent transaction outputs using the block's undo data (#32540).

src/rest.cpp

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
#include <streams.h>
2828
#include <sync.h>
2929
#include <txmempool.h>
30+
#include <undo.h>
3031
#include <util/any.h>
3132
#include <util/check.h>
3233
#include <util/strencodings.h>
@@ -281,6 +282,113 @@ static bool rest_headers(const std::any& context,
281282
}
282283
}
283284

285+
/**
286+
* Serialize spent outputs as a list of per-transaction CTxOut lists using binary format.
287+
*/
288+
static void SerializeBlockUndo(DataStream& stream, const CBlockUndo& block_undo)
289+
{
290+
WriteCompactSize(stream, block_undo.vtxundo.size() + 1);
291+
WriteCompactSize(stream, 0); // block_undo.vtxundo doesn't contain coinbase tx
292+
for (const CTxUndo& tx_undo : block_undo.vtxundo) {
293+
WriteCompactSize(stream, tx_undo.vprevout.size());
294+
for (const Coin& coin : tx_undo.vprevout) {
295+
coin.out.Serialize(stream);
296+
}
297+
}
298+
}
299+
300+
/**
301+
* Serialize spent outputs as a list of per-transaction CTxOut lists using JSON format.
302+
*/
303+
static void BlockUndoToJSON(const CBlockUndo& block_undo, UniValue& result)
304+
{
305+
result.push_back({UniValue::VARR}); // block_undo.vtxundo doesn't contain coinbase tx
306+
for (const CTxUndo& tx_undo : block_undo.vtxundo) {
307+
UniValue tx_prevouts(UniValue::VARR);
308+
for (const Coin& coin : tx_undo.vprevout) {
309+
UniValue prevout(UniValue::VOBJ);
310+
prevout.pushKV("value", ValueFromAmount(coin.out.nValue));
311+
312+
UniValue script_pub_key(UniValue::VOBJ);
313+
ScriptToUniv(coin.out.scriptPubKey, /*out=*/script_pub_key, /*include_hex=*/true, /*include_address=*/true);
314+
prevout.pushKV("scriptPubKey", std::move(script_pub_key));
315+
316+
tx_prevouts.push_back(std::move(prevout));
317+
}
318+
result.push_back(std::move(tx_prevouts));
319+
}
320+
}
321+
322+
static bool rest_spent_txouts(const std::any& context, HTTPRequest* req, const std::string& strURIPart)
323+
{
324+
if (!CheckWarmup(req)) {
325+
return false;
326+
}
327+
std::string param;
328+
const RESTResponseFormat rf = ParseDataFormat(param, strURIPart);
329+
std::vector<std::string> path = SplitString(param, '/');
330+
331+
std::string hashStr;
332+
if (path.size() == 1) {
333+
// path with query parameter: /rest/spenttxouts/<hash>
334+
hashStr = path[0];
335+
} else {
336+
return RESTERR(req, HTTP_BAD_REQUEST, "Invalid URI format. Expected /rest/spenttxouts/<hash>.<ext>");
337+
}
338+
339+
auto hash{uint256::FromHex(hashStr)};
340+
if (!hash) {
341+
return RESTERR(req, HTTP_BAD_REQUEST, "Invalid hash: " + hashStr);
342+
}
343+
344+
ChainstateManager* chainman = GetChainman(context, req);
345+
if (!chainman) {
346+
return false;
347+
}
348+
349+
const CBlockIndex* pblockindex = WITH_LOCK(cs_main, return chainman->m_blockman.LookupBlockIndex(*hash));
350+
if (!pblockindex) {
351+
return RESTERR(req, HTTP_NOT_FOUND, hashStr + " not found");
352+
}
353+
354+
CBlockUndo block_undo;
355+
if (pblockindex->nHeight > 0 && !chainman->m_blockman.ReadBlockUndo(block_undo, *pblockindex)) {
356+
return RESTERR(req, HTTP_NOT_FOUND, hashStr + " undo not available");
357+
}
358+
359+
switch (rf) {
360+
case RESTResponseFormat::BINARY: {
361+
DataStream ssSpentResponse{};
362+
SerializeBlockUndo(ssSpentResponse, block_undo);
363+
req->WriteHeader("Content-Type", "application/octet-stream");
364+
req->WriteReply(HTTP_OK, ssSpentResponse);
365+
return true;
366+
}
367+
368+
case RESTResponseFormat::HEX: {
369+
DataStream ssSpentResponse{};
370+
SerializeBlockUndo(ssSpentResponse, block_undo);
371+
const std::string strHex{HexStr(ssSpentResponse) + "\n"};
372+
req->WriteHeader("Content-Type", "text/plain");
373+
req->WriteReply(HTTP_OK, strHex);
374+
return true;
375+
}
376+
377+
case RESTResponseFormat::JSON: {
378+
UniValue result(UniValue::VARR);
379+
BlockUndoToJSON(block_undo, result);
380+
std::string strJSON = result.write() + "\n";
381+
req->WriteHeader("Content-Type", "application/json");
382+
req->WriteReply(HTTP_OK, strJSON);
383+
return true;
384+
}
385+
386+
default: {
387+
return RESTERR(req, HTTP_NOT_FOUND, "output format not found (available: " + AvailableDataFormatsString() + ")");
388+
}
389+
}
390+
}
391+
284392
static bool rest_block(const std::any& context,
285393
HTTPRequest* req,
286394
const std::string& strURIPart,
@@ -1021,6 +1129,7 @@ static const struct {
10211129
{"/rest/deploymentinfo/", rest_deploymentinfo},
10221130
{"/rest/deploymentinfo", rest_deploymentinfo},
10231131
{"/rest/blockhashbyheight/", rest_blockhash_by_height},
1132+
{"/rest/spenttxouts/", rest_spent_txouts},
10241133
};
10251134

10261135
void StartREST(const std::any& context)

test/functional/interface_rest.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from decimal import Decimal
88
from enum import Enum
9+
from io import BytesIO
910
import http.client
1011
import json
1112
import typing
@@ -15,6 +16,7 @@
1516
from test_framework.messages import (
1617
BLOCK_HEADER_SIZE,
1718
COIN,
19+
deser_block_spent_outputs,
1820
)
1921
from test_framework.test_framework import BitcoinTestFramework
2022
from test_framework.util import (
@@ -426,6 +428,34 @@ def run_test(self):
426428
assert_equal(self.test_rest_request(f"/headers/{bb_hash}", query_params={"count": 1}), self.test_rest_request(f"/headers/1/{bb_hash}"))
427429
assert_equal(self.test_rest_request(f"/blockfilterheaders/basic/{bb_hash}", query_params={"count": 1}), self.test_rest_request(f"/blockfilterheaders/basic/5/{bb_hash}"))
428430

431+
self.log.info("Test the /spenttxouts URI")
432+
433+
block_count = self.nodes[0].getblockcount()
434+
for height in range(0, block_count + 1):
435+
blockhash = self.nodes[0].getblockhash(height)
436+
spent_bin = self.test_rest_request(f"/spenttxouts/{blockhash}", req_type=ReqType.BIN, ret_type=RetType.BYTES)
437+
spent_hex = self.test_rest_request(f"/spenttxouts/{blockhash}", req_type=ReqType.HEX, ret_type=RetType.BYTES)
438+
spent_json = self.test_rest_request(f"/spenttxouts/{blockhash}", req_type=ReqType.JSON, ret_type=RetType.JSON)
439+
440+
assert_equal(bytes.fromhex(spent_hex.decode()), spent_bin)
441+
442+
spent = deser_block_spent_outputs(BytesIO(spent_bin))
443+
block = self.nodes[0].getblock(blockhash, 3) # return prevout for each input
444+
assert_equal(len(spent), len(block["tx"]))
445+
assert_equal(len(spent_json), len(block["tx"]))
446+
447+
for i, tx in enumerate(block["tx"]):
448+
prevouts = [txin["prevout"] for txin in tx["vin"] if "coinbase" not in txin]
449+
# compare with `getblock` JSON output (coinbase tx has no prevouts)
450+
actual = [(txout.scriptPubKey.hex(), Decimal(txout.nValue) / COIN) for txout in spent[i]]
451+
expected = [(p["scriptPubKey"]["hex"], p["value"]) for p in prevouts]
452+
assert_equal(expected, actual)
453+
# also compare JSON format
454+
actual = [(prevout["scriptPubKey"], prevout["value"]) for prevout in spent_json[i]]
455+
expected = [(p["scriptPubKey"], p["value"]) for p in prevouts]
456+
assert_equal(expected, actual)
457+
458+
429459
self.log.info("Test the /deploymentinfo URI")
430460

431461
deployment_info = self.nodes[0].getdeploymentinfo()

test/functional/test_framework/messages.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,11 @@ def ser_string_vector(l):
230230
return r
231231

232232

233+
def deser_block_spent_outputs(f):
234+
nit = deser_compact_size(f)
235+
return [deser_vector(f, CTxOut) for _ in range(nit)]
236+
237+
233238
def from_hex(obj, hex_string):
234239
"""Deserialize from a hex string representation (e.g. from RPC)
235240

0 commit comments

Comments
 (0)