diff --git a/Makefile.am b/Makefile.am index 8b01e680..00490a40 100644 --- a/Makefile.am +++ b/Makefile.am @@ -49,6 +49,7 @@ src_libbitcoin_server_la_SOURCES = \ src/protocols/protocol_http.cpp \ src/protocols/bitcoind/protocol_bitcoind_rest.cpp \ src/protocols/bitcoind/protocol_bitcoind_rpc.cpp \ + src/protocols/bitcoind/protocol_bitcoind_rpc_json.cpp \ src/protocols/electrum/protocol_electrum.cpp \ src/protocols/electrum/protocol_electrum_addresses.cpp \ src/protocols/electrum/protocol_electrum_fees.cpp \ @@ -93,6 +94,7 @@ test_libbitcoin_server_test_SOURCES = \ test/parsers/electrum_version.cpp \ test/parsers/native_query.cpp \ test/parsers/native_target.cpp \ + test/protocols/bitcoind/bitcoind_json.cpp \ test/protocols/bitcoind/bitcoind_rest.cpp \ test/protocols/bitcoind/bitcoind_rpc.cpp \ test/protocols/bitcoind/bitcoind_setup_fixture.cpp \ diff --git a/builds/msvc/vs2022/libbitcoin-server-test/libbitcoin-server-test.vcxproj b/builds/msvc/vs2022/libbitcoin-server-test/libbitcoin-server-test.vcxproj index 01210daa..14be6f87 100644 --- a/builds/msvc/vs2022/libbitcoin-server-test/libbitcoin-server-test.vcxproj +++ b/builds/msvc/vs2022/libbitcoin-server-test/libbitcoin-server-test.vcxproj @@ -131,6 +131,7 @@ + diff --git a/builds/msvc/vs2022/libbitcoin-server-test/libbitcoin-server-test.vcxproj.filters b/builds/msvc/vs2022/libbitcoin-server-test/libbitcoin-server-test.vcxproj.filters index 47a68fa3..1c804ffb 100644 --- a/builds/msvc/vs2022/libbitcoin-server-test/libbitcoin-server-test.vcxproj.filters +++ b/builds/msvc/vs2022/libbitcoin-server-test/libbitcoin-server-test.vcxproj.filters @@ -57,6 +57,9 @@ src\parsers + + src\protocols\bitcoind + src\protocols\bitcoind diff --git a/builds/msvc/vs2022/libbitcoin-server/libbitcoin-server.vcxproj b/builds/msvc/vs2022/libbitcoin-server/libbitcoin-server.vcxproj index 01aa5cde..e95d1d4a 100644 --- a/builds/msvc/vs2022/libbitcoin-server/libbitcoin-server.vcxproj +++ b/builds/msvc/vs2022/libbitcoin-server/libbitcoin-server.vcxproj @@ -131,6 +131,7 @@ + diff --git a/builds/msvc/vs2022/libbitcoin-server/libbitcoin-server.vcxproj.filters b/builds/msvc/vs2022/libbitcoin-server/libbitcoin-server.vcxproj.filters index 203758f9..994d6cc2 100644 --- a/builds/msvc/vs2022/libbitcoin-server/libbitcoin-server.vcxproj.filters +++ b/builds/msvc/vs2022/libbitcoin-server/libbitcoin-server.vcxproj.filters @@ -93,6 +93,9 @@ src\protocols\bitcoind + + src\protocols\bitcoind + src\protocols\electrum diff --git a/builds/msvc/vs2026/libbitcoin-server-test/libbitcoin-server-test.vcxproj b/builds/msvc/vs2026/libbitcoin-server-test/libbitcoin-server-test.vcxproj index 3e8d017a..3861753f 100644 --- a/builds/msvc/vs2026/libbitcoin-server-test/libbitcoin-server-test.vcxproj +++ b/builds/msvc/vs2026/libbitcoin-server-test/libbitcoin-server-test.vcxproj @@ -131,6 +131,7 @@ + diff --git a/builds/msvc/vs2026/libbitcoin-server-test/libbitcoin-server-test.vcxproj.filters b/builds/msvc/vs2026/libbitcoin-server-test/libbitcoin-server-test.vcxproj.filters index 47a68fa3..1c804ffb 100644 --- a/builds/msvc/vs2026/libbitcoin-server-test/libbitcoin-server-test.vcxproj.filters +++ b/builds/msvc/vs2026/libbitcoin-server-test/libbitcoin-server-test.vcxproj.filters @@ -57,6 +57,9 @@ src\parsers + + src\protocols\bitcoind + src\protocols\bitcoind diff --git a/builds/msvc/vs2026/libbitcoin-server/libbitcoin-server.vcxproj b/builds/msvc/vs2026/libbitcoin-server/libbitcoin-server.vcxproj index ea9667da..e44cfc13 100644 --- a/builds/msvc/vs2026/libbitcoin-server/libbitcoin-server.vcxproj +++ b/builds/msvc/vs2026/libbitcoin-server/libbitcoin-server.vcxproj @@ -131,6 +131,7 @@ + diff --git a/builds/msvc/vs2026/libbitcoin-server/libbitcoin-server.vcxproj.filters b/builds/msvc/vs2026/libbitcoin-server/libbitcoin-server.vcxproj.filters index 203758f9..994d6cc2 100644 --- a/builds/msvc/vs2026/libbitcoin-server/libbitcoin-server.vcxproj.filters +++ b/builds/msvc/vs2026/libbitcoin-server/libbitcoin-server.vcxproj.filters @@ -93,6 +93,9 @@ src\protocols\bitcoind + + src\protocols\bitcoind + src\protocols\electrum diff --git a/endpoints/test_bitcoind_rest.py b/endpoints/test_bitcoind_rest.py new file mode 100644 index 00000000..c82475bb --- /dev/null +++ b/endpoints/test_bitcoind_rest.py @@ -0,0 +1,257 @@ +""" +Tests for libbitcoin-server bitcoind REST compatibility interface. + +Covers the Bitcoin Core REST endpoints served under /rest/: chaininfo, block, +block/notxdetails, block/spent (libbitcoin extension), blockhashbyheight, +headers, blockpart (libbitcoin extension), and the basic (neutrino) filters. + +Per-endpoint media type is selected by the URL extension: .json, .hex (text), +or .bin (raw bytes). doc: + github.com/bitcoin/bitcoin/blob/master/doc/REST-interface.md + +Run with: + pytest test_bitcoind_rest.py + pytest test_bitcoind_rest.py --bitcoind-rest-host=localhost --bitcoind-rest-port=8332 +""" + +import hashlib +import os +import time +import pytest +import requests + +from utils import ReferenceData, TestConfig, reverse_hex, double_sha256 + + +HEADER_SIZE = 80 # serialized block header size, in bytes + + +def _header_hash(header: bytes) -> str: + """Block hash (display byte order) from the first 80 bytes of a header.""" + digest = hashlib.sha256(hashlib.sha256(header[:HEADER_SIZE]).digest()).hexdigest() + return reverse_hex(digest) + + +def _get(rest_config: dict, path: str) -> requests.Response: + """GET a REST path. A non-200 status degrades to xfail (as in the RPC suite).""" + url = f"{rest_config['base_url']}/{path}" + + if os.getenv("BITCOIND_DEBUG"): + print(f">>> GET {url}", flush=True) + + _t0 = time.monotonic() + try: + resp = requests.get( + url, + timeout=TestConfig.DEFAULT_RPC_TIMEOUT, + headers={"Connection": "close"} + ) + except requests.exceptions.RequestException as e: + raise RuntimeError(f"REST connection error: {e}") + _elapsed = time.monotonic() - _t0 + + if os.getenv("BITCOIND_DEBUG"): + print(f"<<< {path} ({_elapsed * 1000:.1f} ms) " + f"status={resp.status_code} bytes={len(resp.content)}", flush=True) + + if resp.status_code != 200: + pytest.xfail(f"Server sent status {resp.status_code} for {path}: " + f"{resp.text[:200]!r}") + + return resp + + +def get_json(rest_config: dict, path: str): + resp = _get(rest_config, path) + try: + return resp.json() + except ValueError: + pytest.fail(f"Non-JSON response for {path}: {resp.text[:200]!r}") + + +def get_hex(rest_config: dict, path: str) -> str: + return _get(rest_config, path).text.strip() + + +def get_bin(rest_config: dict, path: str) -> bytes: + return _get(rest_config, path).content + + +# ═══════════════════════════════════════════════════════════════════════════════ +# CHAIN INFORMATION +# ═══════════════════════════════════════════════════════════════════════════════ + +def test_chaininfo_json(bitcoind_rest_config): + """GET /rest/chaininfo.json returns blockchain state.""" + data = get_json(bitcoind_rest_config, "chaininfo.json") + + assert isinstance(data, dict) + assert data.get("chain") == "main" + for field in ("blocks", "headers", "bestblockhash", "difficulty"): + assert field in data + assert len(data["bestblockhash"]) == 64 + + +# ═══════════════════════════════════════════════════════════════════════════════ +# BLOCK (full, notxdetails, spent) +# ═══════════════════════════════════════════════════════════════════════════════ + +def test_block_json(bitcoind_rest_config): + """GET /rest/block/.json returns a block with full tx objects.""" + data = get_json( + bitcoind_rest_config, + f"block/{ReferenceData.KNOWN_BLOCK_HASH}.json" + ) + + assert isinstance(data, dict) + assert data["hash"] == ReferenceData.KNOWN_BLOCK_HASH + assert isinstance(data["tx"], list) and len(data["tx"]) >= 1 + assert isinstance(data["tx"][0], dict) # full transaction objects + + +def test_block_hex(bitcoind_rest_config): + """GET /rest/block/.hex returns the raw block; header hashes to .""" + raw = get_hex(bitcoind_rest_config, f"block/{ReferenceData.KNOWN_BLOCK_HASH}.hex") + + assert len(raw) >= 2 * HEADER_SIZE + assert all(c in "0123456789abcdefABCDEF" for c in raw) + assert reverse_hex(double_sha256(raw[:2 * HEADER_SIZE])) == ReferenceData.KNOWN_BLOCK_HASH + + +def test_block_bin(bitcoind_rest_config): + """GET /rest/block/.bin returns raw bytes; header hashes to .""" + raw = get_bin(bitcoind_rest_config, f"block/{ReferenceData.KNOWN_BLOCK_HASH}.bin") + + assert len(raw) >= HEADER_SIZE + assert _header_hash(raw) == ReferenceData.KNOWN_BLOCK_HASH + + +def test_block_notxdetails_json(bitcoind_rest_config): + """GET /rest/block/notxdetails/.json lists txids, not full objects.""" + data = get_json( + bitcoind_rest_config, + f"block/notxdetails/{ReferenceData.KNOWN_BLOCK_HASH}.json" + ) + + assert isinstance(data, dict) + assert data["hash"] == ReferenceData.KNOWN_BLOCK_HASH + assert isinstance(data["tx"], list) and len(data["tx"]) >= 1 + assert isinstance(data["tx"][0], str) + assert len(data["tx"][0]) == 64 + + +def test_block_spent_json(bitcoind_rest_config): + """GET /rest/block/spent/.json (libbitcoin extension) returns spent outputs.""" + data = get_json( + bitcoind_rest_config, + f"block/spent/{ReferenceData.KNOWN_BLOCK_HASH}.json" + ) + + assert isinstance(data, (list, dict)) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# BLOCK HASH BY HEIGHT +# ═══════════════════════════════════════════════════════════════════════════════ + +def test_blockhashbyheight_json(bitcoind_rest_config): + """GET /rest/blockhashbyheight/.json returns the block hash.""" + data = get_json( + bitcoind_rest_config, + f"blockhashbyheight/{ReferenceData.KNOWN_HEIGHT}.json" + ) + + assert isinstance(data, dict) + assert data["blockhash"] == ReferenceData.KNOWN_BLOCK_HASH + + +def test_blockhashbyheight_genesis_json(bitcoind_rest_config): + """GET /rest/blockhashbyheight/0.json returns the genesis hash.""" + data = get_json(bitcoind_rest_config, "blockhashbyheight/0.json") + + assert data["blockhash"] == ReferenceData.GENESIS_HASH + + +# ═══════════════════════════════════════════════════════════════════════════════ +# HEADERS +# ═══════════════════════════════════════════════════════════════════════════════ + +def test_headers_json(bitcoind_rest_config): + """GET /rest/headers//.json returns header objects.""" + data = get_json( + bitcoind_rest_config, + f"headers/5/{ReferenceData.KNOWN_BLOCK_HASH}.json" + ) + + assert isinstance(data, list) + assert len(data) == 5 + assert data[0]["hash"] == ReferenceData.KNOWN_BLOCK_HASH + for field in ("merkleroot", "time", "nonce", "bits"): + assert field in data[0] + + +def test_headers_hex(bitcoind_rest_config): + """GET /rest/headers/1/.hex returns one 80-byte header hashing to .""" + raw = get_hex(bitcoind_rest_config, f"headers/1/{ReferenceData.KNOWN_BLOCK_HASH}.hex") + + assert len(raw) == 2 * HEADER_SIZE + assert reverse_hex(double_sha256(raw)) == ReferenceData.KNOWN_BLOCK_HASH + + +def test_headers_bin(bitcoind_rest_config): + """GET /rest/headers/2/.bin returns two concatenated 80-byte headers.""" + raw = get_bin(bitcoind_rest_config, f"headers/2/{ReferenceData.KNOWN_BLOCK_HASH}.bin") + + assert len(raw) == 2 * HEADER_SIZE + assert _header_hash(raw[:HEADER_SIZE]) == ReferenceData.KNOWN_BLOCK_HASH + + +# ═══════════════════════════════════════════════════════════════════════════════ +# BLOCK PART (libbitcoin extension, bin|hex only) +# ═══════════════════════════════════════════════════════════════════════════════ + +def test_blockpart_hex_header(bitcoind_rest_config): + """GET /rest/blockpart//0/80.hex returns the block's 80-byte header.""" + raw = get_hex( + bitcoind_rest_config, + f"blockpart/{ReferenceData.KNOWN_BLOCK_HASH}/0/{HEADER_SIZE}.hex" + ) + + assert len(raw) == 2 * HEADER_SIZE + assert reverse_hex(double_sha256(raw)) == ReferenceData.KNOWN_BLOCK_HASH + + +def test_blockpart_bin_header(bitcoind_rest_config): + """GET /rest/blockpart//0/80.bin returns the block's 80-byte header.""" + raw = get_bin( + bitcoind_rest_config, + f"blockpart/{ReferenceData.KNOWN_BLOCK_HASH}/0/{HEADER_SIZE}.bin" + ) + + assert len(raw) == HEADER_SIZE + assert _header_hash(raw) == ReferenceData.KNOWN_BLOCK_HASH + + +# ═══════════════════════════════════════════════════════════════════════════════ +# BLOCK FILTERS (basic / neutrino; requires [blockchain] bip158 = true) +# ═══════════════════════════════════════════════════════════════════════════════ + +def test_blockfilter_basic_json(bitcoind_rest_config): + """GET /rest/blockfilter/basic/.json returns a compact block filter.""" + data = get_json( + bitcoind_rest_config, + f"blockfilter/basic/{ReferenceData.KNOWN_BLOCK_HASH}.json" + ) + + assert isinstance(data, dict) + assert "filter" in data + + +def test_blockfilterheaders_basic_json(bitcoind_rest_config): + """GET /rest/blockfilterheaders/basic/.json returns filter headers.""" + data = get_json( + bitcoind_rest_config, + f"blockfilterheaders/basic/{ReferenceData.KNOWN_BLOCK_HASH}.json" + ) + + assert isinstance(data, (dict, list)) diff --git a/endpoints/test_bitcoind_rpc.py b/endpoints/test_bitcoind_rpc.py index 38fe54b3..70f5e7c9 100644 --- a/endpoints/test_bitcoind_rpc.py +++ b/endpoints/test_bitcoind_rpc.py @@ -18,7 +18,13 @@ import warnings from typing import Optional, Dict, Any -from utils import ReferenceData, TestConfig +from utils import ( + ReferenceData, + TestConfig, + double_sha256, + reverse_hex, + validate_hex_hash, +) def send_rpc( @@ -119,6 +125,48 @@ def parse_response(raw_data: str) -> dict: return resp +def raw_rpc( + config: dict, + method: str, + params: Optional[list] = None +) -> Dict[str, Any]: + """ + Send a JSON-RPC 2.0 request and return the full response without xfail. + + Unlike send_rpc, this does not treat an error response as an expected + failure, so error paths (unknown txid, invalid input) can be asserted. + """ + payload = { + "jsonrpc": "2.0", + "id": 0, + "method": method, + "params": params if params is not None else [], + } + + if os.getenv("BITCOIND_DEBUG"): + print(">>>", json.dumps(payload, indent=2), flush=True) + + try: + response = requests.post( + config["url"], + json=payload, + headers={"Content-Type": "application/json", "Connection": "close"}, + auth=config.get("auth"), + timeout=config.get("timeout", TestConfig.DEFAULT_RPC_TIMEOUT) + ) + response.raise_for_status() + data = response.json() + except requests.exceptions.RequestException as e: + raise RuntimeError(f"RPC connection error: {e}") + except ValueError: + raise RuntimeError("Invalid JSON response from RPC server") + + if os.getenv("BITCOIND_DEBUG"): + print(f"<<< {method}:", json.dumps(data, indent=2), flush=True) + + return data + + # ═══════════════════════════════════════════════════════════════════════════════ # BLOCKCHAIN METHODS # ═══════════════════════════════════════════════════════════════════════════════ @@ -172,13 +220,15 @@ def test_getblockchaininfo(bitcoind_rpc_config): assert "bits" in result assert "blocks" in result assert "chain" in result - assert "chainwork" in result + # chainwork and size_on_disk are intentionally omitted by the implementation: + # chainwork needs a cumulative-work index (cf. getchainwork, not implemented) + # and size_on_disk needs store-size accounting. The remaining Core fields are + # returned. assert "difficulty" in result assert "headers" in result assert "initialblockdownload" in result assert "mediantime" in result assert "pruned" in result - assert "size_on_disk" in result assert "target" in result assert "time" in result assert "verificationprogress" in result @@ -376,6 +426,126 @@ def test_verifytxoutset(bitcoind_rpc_config): assert "total_amount" in result +# ═══════════════════════════════════════════════════════════════════════════════ +# RAW TRANSACTION METHODS +# ═══════════════════════════════════════════════════════════════════════════════ + +def test_getrawtransaction_raw(bitcoind_rpc_config): + """getrawtransaction verbosity=0 returns the serialized transaction hex.""" + # Block 170 transaction (first payment, non-segwit) round-trips to its txid. + response = send_rpc( + bitcoind_rpc_config, + "getrawtransaction", + [ReferenceData.FIRST_TX_HASH, 0] + ) + + result = response["result"] + assert isinstance(result, str) + assert len(result) % 2 == 0 + assert validate_hex_hash(result, len(result)) + # For a non-witness transaction txid == reverse(double_sha256(serialized)). + assert reverse_hex(double_sha256(result)) == ReferenceData.FIRST_TX_HASH + + +def test_getrawtransaction_verbose(bitcoind_rpc_config): + """getrawtransaction verbosity=1 returns a decoded tx with chain context.""" + response = send_rpc( + bitcoind_rpc_config, + "getrawtransaction", + [ReferenceData.KNOWN_TX_HASH, 1] + ) + + result = response["result"] + assert isinstance(result, dict) + assert result["txid"] == ReferenceData.KNOWN_TX_HASH + assert isinstance(result["vin"], list) and len(result["vin"]) > 0 + assert isinstance(result["vout"], list) and len(result["vout"]) > 0 + # Context injected at the protocol layer (the serializer omits these). + assert result["blockhash"] == ReferenceData.KNOWN_BLOCK_HASH + assert result["in_active_chain"] is True + assert isinstance(result["confirmations"], int) and result["confirmations"] > 0 + + +def test_getrawtransaction_coinbase(bitcoind_rpc_config): + """getrawtransaction serves coinbase transactions (block 1 coinbase).""" + response = send_rpc( + bitcoind_rpc_config, + "getrawtransaction", + [ReferenceData.BLOCK1_COINBASE_TX_HASH, 1] + ) + + result = response["result"] + assert isinstance(result, dict) + assert result["txid"] == ReferenceData.BLOCK1_COINBASE_TX_HASH + assert isinstance(result["vin"], list) and len(result["vin"]) == 1 + # Non-segwit transaction: the witness txid (hash) equals the txid. + if "hash" in result: + assert result["hash"] == result["txid"] + + +def test_getrawtransaction_segwit(bitcoind_rpc_config): + """getrawtransaction handles segwit transactions (witness serialization).""" + response = send_rpc( + bitcoind_rpc_config, + "getrawtransaction", + [ReferenceData.SEGWIT_TX_HASH_P2WPKH, 1] + ) + + result = response["result"] + assert isinstance(result, dict) + assert result["txid"] == ReferenceData.SEGWIT_TX_HASH_P2WPKH + assert isinstance(result["vin"], list) and len(result["vin"]) > 0 + assert isinstance(result["vout"], list) and len(result["vout"]) > 0 + # Witness is serialized, so the witness txid (hash) differs from the txid. + assert result["hash"] != result["txid"] + # Segwit weight accounting: vsize == ceil(weight / 4). + # (Note: libbitcoin reports "size" as the stripped, non-witness size, whereas + # Core reports the total witnessed size, so size/vsize ordering is not + # asserted here.) + if "weight" in result and "vsize" in result: + assert result["vsize"] == (result["weight"] + 3) // 4 + + +def test_getrawtransaction_unknown(bitcoind_rpc_config): + """getrawtransaction returns a JSON-RPC error for an unknown txid.""" + data = raw_rpc(bitcoind_rpc_config, "getrawtransaction", ["00" * 32, 1]) + assert data.get("error") is not None + assert data.get("result") is None + + +def test_sendrawtransaction_invalid_hex(bitcoind_rpc_config): + """sendrawtransaction rejects non-hexadecimal input with an error.""" + data = raw_rpc(bitcoind_rpc_config, "sendrawtransaction", ["nothexZZ"]) + assert data.get("error") is not None + + +def test_sendrawtransaction_malformed(bitcoind_rpc_config): + """sendrawtransaction rejects hex that does not deserialize to a tx.""" + data = raw_rpc(bitcoind_rpc_config, "sendrawtransaction", ["00"]) + assert data.get("error") is not None + + +@pytest.mark.skipif( + not os.getenv("BITCOIND_ALLOW_BROADCAST"), + reason="set BITCOIND_ALLOW_BROADCAST=1 to exercise the broadcast path " + "(re-announces an already-confirmed tx to connected peers)" +) +def test_sendrawtransaction_known(bitcoind_rpc_config): + """sendrawtransaction accepts a valid tx and echoes its txid. + + Gated behind BITCOIND_ALLOW_BROADCAST: it announces an inv to peers. The + transaction is already mined, so acceptance is idempotent and benign. + """ + raw = send_rpc( + bitcoind_rpc_config, "getrawtransaction", + [ReferenceData.FIRST_TX_HASH, 0] + )["result"] + + data = raw_rpc(bitcoind_rpc_config, "sendrawtransaction", [raw]) + assert data.get("error") is None + assert data["result"] == ReferenceData.FIRST_TX_HASH + + # ═══════════════════════════════════════════════════════════════════════════════ # NETWORK METHODS # ═══════════════════════════════════════════════════════════════════════════════ diff --git a/include/bitcoin/server/interfaces/bitcoind_rpc.hpp b/include/bitcoin/server/interfaces/bitcoind_rpc.hpp index 1fede90b..7c83d608 100644 --- a/include/bitcoin/server/interfaces/bitcoind_rpc.hpp +++ b/include/bitcoin/server/interfaces/bitcoind_rpc.hpp @@ -38,7 +38,7 @@ struct bitcoind_rpc_methods method<"getblockfilter", string_t, optional<"basic"_t>>{ "blockhash", "filtertype" }, method<"getblockhash", number_t>{ "height" }, method<"getblockheader", string_t, optional>{ "blockhash", "verbose" }, - method<"getblockstats", string_t, optional>{ "hash_or_height", "stats" }, + method<"getblockstats", value_t, optional>{ "hash_or_height", "stats" }, method<"getchaintxstats", optional<-1.0>, optional<""_t>>{ "nblocks", "blockhash" }, method<"getchainwork">{}, method<"gettxout", string_t, number_t, optional>{ "txid", "n", "include_mempool" }, @@ -70,7 +70,11 @@ struct bitcoind_rpc_methods ////method<"disconnectnode", string_t, optional<-1_i32>>{ "address", "nodeid" }, ////method<"getaddednodeinfo", optional, optional, optional<""_t>>{ "include_chain_info", "dns", "addnode" }, ////method<"getconnectioncount">{}, - method<"getnetworkinfo">{} + method<"getnetworkinfo">{}, + + /// Rawtransactions methods (implemented). + method<"getrawtransaction", string_t, optional<0.0>, optional<""_t>>{ "txid", "verbosity", "blockhash" }, + method<"sendrawtransaction", string_t, optional<0.0>>{ "hexstring", "maxfeerate" } ////method<"getpeerinfo">{}, ////method<"listbanned">{}, ////method<"ping">{}, @@ -188,6 +192,8 @@ struct bitcoind_rpc_methods ////using get_added_node_info = at<31>; ////using get_connection_count = at<32>; using get_network_info = at<17>; + using get_raw_transaction = at<18>; + using send_raw_transaction = at<19>; ////using get_peer_info = at<34>; ////using list_banned = at<35>; ////using ping = at<36>; diff --git a/include/bitcoin/server/protocols/protocol_bitcoind_rest.hpp b/include/bitcoin/server/protocols/protocol_bitcoind_rest.hpp index e5511113..57e36102 100644 --- a/include/bitcoin/server/protocols/protocol_bitcoind_rest.hpp +++ b/include/bitcoin/server/protocols/protocol_bitcoind_rest.hpp @@ -57,7 +57,31 @@ class BCS_API protocol_bitcoind_rest /// REST interface handlers. bool handle_get_block(const code& ec, rest_interface::block, - uint8_t media, system::hash_cptr hash) NOEXCEPT; + uint8_t media, const system::hash_cptr& hash) NOEXCEPT; + bool handle_get_block_hash(const code& ec, rest_interface::block_hash, + uint8_t media, uint32_t height) NOEXCEPT; + bool handle_get_block_txs(const code& ec, rest_interface::block_txs, + uint8_t media, const system::hash_cptr& hash) NOEXCEPT; + bool handle_get_block_headers(const code& ec, rest_interface::block_headers, + uint8_t media, const system::hash_cptr& hash, uint32_t count) NOEXCEPT; + bool handle_get_block_part(const code& ec, rest_interface::block_part, + uint8_t media, const system::hash_cptr& hash, uint32_t offset, + uint32_t size) NOEXCEPT; + bool handle_get_block_spent_tx_outputs(const code& ec, + rest_interface::block_spent_tx_outputs, uint8_t media, + const system::hash_cptr& hash) NOEXCEPT; + bool handle_get_block_filter(const code& ec, rest_interface::block_filter, + uint8_t media, const system::hash_cptr& hash, uint8_t type) NOEXCEPT; + bool handle_get_block_filter_headers(const code& ec, + rest_interface::block_filter_headers, uint8_t media, + const system::hash_cptr& hash, uint8_t type) NOEXCEPT; + bool handle_get_chain_information(const code& ec, + rest_interface::chain_information) NOEXCEPT; + + /// REST raw-http response senders (not json-rpc enveloped). + void send_data(system::data_chunk&& bytes) NOEXCEPT; + void send_hex(std::string&& text) NOEXCEPT; + void send_dom(boost::json::value&& model, size_t size_hint) NOEXCEPT; private: template diff --git a/include/bitcoin/server/protocols/protocol_bitcoind_rpc.hpp b/include/bitcoin/server/protocols/protocol_bitcoind_rpc.hpp index 9462ab36..a374b760 100644 --- a/include/bitcoin/server/protocols/protocol_bitcoind_rpc.hpp +++ b/include/bitcoin/server/protocols/protocol_bitcoind_rpc.hpp @@ -24,6 +24,7 @@ #include #include #include +#include namespace libbitcoin { namespace server { @@ -79,7 +80,7 @@ class BCS_API protocol_bitcoind_rpc bool handle_get_block_header(const code& ec, rpc_interface::get_block_header, const std::string&, bool) NOEXCEPT; bool handle_get_block_stats(const code& ec, - rpc_interface::get_block_stats, const std::string&, + rpc_interface::get_block_stats, const network::rpc::value_t&, const network::rpc::array_t&) NOEXCEPT; bool handle_get_chain_tx_stats(const code& ec, rpc_interface::get_chain_tx_stats, double, @@ -103,6 +104,24 @@ class BCS_API protocol_bitcoind_rpc rpc_interface::verify_tx_out_set, const std::string&) NOEXCEPT; bool handle_get_network_info(const code& ec, rpc_interface::get_network_info) NOEXCEPT; + bool handle_get_raw_transaction(const code& ec, + rpc_interface::get_raw_transaction, const std::string& txid, + double verbose, const std::string& blockhash) NOEXCEPT; + bool handle_send_raw_transaction(const code& ec, + rpc_interface::send_raw_transaction, const std::string& hexstring, + double maxfeerate) NOEXCEPT; + + /// Json context helpers (shared with rest, defined in *_json.cpp). + static uint32_t median_time_past(const node::query& query, + size_t height) NOEXCEPT; + static void inject_block_context(boost::json::object& out, + const node::query& query, const database::header_link& link, + const system::chain::header& header) NOEXCEPT; + static void inject_tx_context(boost::json::object& out, + const node::query& query, const database::tx_link& link) NOEXCEPT; + static boost::json::object header_to_bitcoind( + const system::chain::header& header) NOEXCEPT; + static std::string chain_name(const node::query& query) NOEXCEPT; /// Senders. void send_error(const code& ec) NOEXCEPT; diff --git a/src/parsers/bitcoind_target.cpp b/src/parsers/bitcoind_target.cpp index 35d1e462..ca5f17b6 100644 --- a/src/parsers/bitcoind_target.cpp +++ b/src/parsers/bitcoind_target.cpp @@ -18,6 +18,8 @@ */ #include +#include +#include #include #include #include @@ -28,13 +30,257 @@ namespace server { using namespace system; using namespace network::rpc; +using namespace network::http; BC_PUSH_WARNING(NO_ARRAY_INDEXING) BC_PUSH_WARNING(NO_THROW_IN_NOEXCEPT) -code bitcoind_target(request_t& , const std::string_view& ) NOEXCEPT +static hash_cptr to_hash(const std::string_view& token) NOEXCEPT { - return {}; + hash_digest out{}; + return decode_hash(out, token) ? + to_shared(std::move(out)) : hash_cptr{}; +} + +// Map a Bitcoin Core REST file extension to a media value. +static bool to_media(uint8_t& out, const std::string_view& extension) NOEXCEPT +{ + if (extension == "bin") + { + out = to_value(media_type::application_octet_stream); + return true; + } + if (extension == "hex") + { + out = to_value(media_type::text_plain); + return true; + } + if (extension == "json") + { + out = to_value(media_type::application_json); + return true; + } + + return false; +} + +template +static bool to_number(Number& out, const std::string_view& token) NOEXCEPT +{ + if (token.empty() || (token.size() > one && token.front() == '0')) + return false; + + const auto end = std::next(token.data(), token.size()); + const auto result = std::from_chars(token.data(), end, out); + return result.ec == std::errc{} && result.ptr == end; +} + +// Split a "." leaf into its name and a media value. +static bool split_leaf(std::string& name, uint8_t& media, + const std::string_view& leaf) NOEXCEPT +{ + const auto parts = split(leaf, ".", false, true); + if (parts.size() != two) + return false; + + name = parts.front(); + return to_media(media, parts.back()); +} + +// Parse a Bitcoin Core REST path into a json-rpc request model. +// github.com/bitcoin/bitcoin/blob/master/doc/REST-interface.md +// Supports: block, block/notxdetails, blockhashbyheight, headers, blockpart, +// chaininfo (remaining endpoints return invalid_target until implemented). +code bitcoind_target(request_t& out, const std::string_view& path) NOEXCEPT +{ + const auto clean = split(path, "?", false, false).front(); + if (clean.empty()) + return error::empty_path; + + // Avoid conflict with node type. + using object_t = network::rpc::object_t; + + // Initialize json-rpc.v2 named params message. + out = request_t + { + .jsonrpc = version::v2, + .id = null_t{}, + .method = {}, + .params = object_t{} + }; + + auto& method = out.method; + auto& params = std::get(out.params.value()); + const auto segments = split(clean, "/", false, true); + BC_ASSERT(!segments.empty()); + + size_t segment{}; + + // Accept an optional "rest" prefix (Core mounts endpoints under /rest/). + if (segments[segment] == "rest") + ++segment; + + if (segment == segments.size()) + return error::missing_target; + + const auto target = segments[segment++]; + + // /rest/chaininfo.json + if (target == "chaininfo" || target == "chaininfo.json") + { + method = "chain_information"; + return error::success; + } + + // /rest/block/., /rest/block/notxdetails/. and + // /rest/block/spent/. (the latter is a libbitcoin extension). + if (target == "block") + { + if (segment == segments.size()) + return error::missing_hash; + + std::string rest_method = "block"; + if (segments[segment] == "notxdetails") + rest_method = "block_txs"; + else if (segments[segment] == "spent") + rest_method = "block_spent_tx_outputs"; + + if (rest_method != "block" && ++segment == segments.size()) + return error::missing_hash; + + std::string name{}; + uint8_t media{}; + if (!split_leaf(name, media, segments[segment++])) + return error::invalid_target; + + const auto hash = to_hash(name); + if (!hash) + return error::invalid_hash; + + method = rest_method; + params["media"] = media; + params["hash"] = hash; + return error::success; + } + + // /rest/blockhashbyheight/. + if (target == "blockhashbyheight") + { + if (segment == segments.size()) + return error::missing_target; + + std::string name{}; + uint8_t media{}; + if (!split_leaf(name, media, segments[segment++])) + return error::invalid_target; + + uint32_t height{}; + if (!to_number(height, name)) + return error::invalid_number; + + method = "block_hash"; + params["media"] = media; + params["height"] = height; + return error::success; + } + + // /rest/headers//. + if (target == "headers") + { + if (segment == segments.size()) + return error::missing_target; + + uint32_t count{}; + if (!to_number(count, segments[segment++])) + return error::invalid_number; + + if (segment == segments.size()) + return error::missing_hash; + + std::string name{}; + uint8_t media{}; + if (!split_leaf(name, media, segments[segment++])) + return error::invalid_target; + + const auto hash = to_hash(name); + if (!hash) + return error::invalid_hash; + + method = "block_headers"; + params["media"] = media; + params["hash"] = hash; + params["count"] = count; + return error::success; + } + + // /rest/blockfilter//. and blockfilterheaders likewise. + if (target == "blockfilter" || target == "blockfilterheaders") + { + if (segment == segments.size()) + return error::missing_target; + + // libbitcoin supports only the "basic" (neutrino) filter type. + if (segments[segment++] != "basic") + return error::invalid_target; + + if (segment == segments.size()) + return error::missing_hash; + + std::string name{}; + uint8_t media{}; + if (!split_leaf(name, media, segments[segment++])) + return error::invalid_target; + + const auto hash = to_hash(name); + if (!hash) + return error::invalid_hash; + + method = target == "blockfilter" ? "block_filter" : + "block_filter_headers"; + params["media"] = media; + params["hash"] = hash; + params["type"] = uint8_t{ 0 }; + return error::success; + } + + // /rest/blockpart///. (libbitcoin extension) + if (target == "blockpart") + { + if (segment == segments.size()) + return error::missing_hash; + + const auto hash = to_hash(segments[segment++]); + if (!hash) + return error::invalid_hash; + + if (segment == segments.size()) + return error::missing_target; + + uint32_t offset{}; + if (!to_number(offset, segments[segment++])) + return error::invalid_number; + + if (segment == segments.size()) + return error::missing_target; + + std::string name{}; + uint8_t media{}; + if (!split_leaf(name, media, segments[segment++])) + return error::invalid_target; + + uint32_t size{}; + if (!to_number(size, name)) + return error::invalid_number; + + method = "block_part"; + params["media"] = media; + params["hash"] = hash; + params["offset"] = offset; + params["size"] = size; + return error::success; + } + + return error::invalid_target; } BC_POP_WARNING() diff --git a/src/protocols/bitcoind/protocol_bitcoind_rest.cpp b/src/protocols/bitcoind/protocol_bitcoind_rest.cpp index 281a2a23..0d831037 100644 --- a/src/protocols/bitcoind/protocol_bitcoind_rest.cpp +++ b/src/protocols/bitcoind/protocol_bitcoind_rest.cpp @@ -18,9 +18,13 @@ */ #include +#include +#include +#include #include #include #include +#include namespace libbitcoin { namespace server { @@ -50,6 +54,14 @@ void protocol_bitcoind_rest::start() NOEXCEPT return; SUBSCRIBE_BITCOIND(handle_get_block, _1, _2, _3, _4); + SUBSCRIBE_BITCOIND(handle_get_block_hash, _1, _2, _3, _4); + SUBSCRIBE_BITCOIND(handle_get_block_txs, _1, _2, _3, _4); + SUBSCRIBE_BITCOIND(handle_get_block_headers, _1, _2, _3, _4, _5); + SUBSCRIBE_BITCOIND(handle_get_block_part, _1, _2, _3, _4, _5, _6); + SUBSCRIBE_BITCOIND(handle_get_block_spent_tx_outputs, _1, _2, _3, _4); + SUBSCRIBE_BITCOIND(handle_get_block_filter, _1, _2, _3, _4, _5); + SUBSCRIBE_BITCOIND(handle_get_block_filter_headers, _1, _2, _3, _4, _5); + SUBSCRIBE_BITCOIND(handle_get_chain_information, _1, _2); protocol_bitcoind_rpc::start(); } @@ -86,50 +98,527 @@ void protocol_bitcoind_rest::handle_receive_get(const code& ec, return; } - // The post is saved off during asynchonous handling and used in send_json + // The get is saved off during asynchonous handling and used in send_json // to formulate response headers, isolating handlers from http semantics. set_request(get); - ////if (const auto code = rest_dispatcher_.notify({})) - //// stop(code); - protocol_bitcoind_rpc::handle_receive_get(ec, get); + // Parse the REST url into a json-rpc model and dispatch to a handler. + request_t model{}; + if (bitcoind_target(model, get->target())) + { + send_not_found(); + return; + } + + if (rest_dispatcher_.notify(model)) + send_not_found(); } // Handlers. // ---------------------------------------------------------------------------- -////constexpr auto data = to_value(media_type::application_octet_stream); -////constexpr auto json = to_value(media_type::application_json); -////constexpr auto text = to_value(media_type::text_plain); +constexpr auto data = to_value(media_type::application_octet_stream); +constexpr auto json = to_value(media_type::application_json); +constexpr auto text = to_value(media_type::text_plain); + +template +data_chunk to_bin(const Object& object, size_t size, Args&&... args) NOEXCEPT +{ + data_chunk out(size); + stream::out::fast sink{ out }; + write::bytes::fast writer{ sink }; + object.to_data(writer, std::forward(args)...); + return out; +} + +template +std::string to_hex(const Object& object, size_t size, Args&&... args) NOEXCEPT +{ + std::string out(two * size, '\0'); + stream::out::fast sink{ out }; + write::base16::fast writer{ sink }; + object.to_data(writer, std::forward(args)...); + return out; +} bool protocol_bitcoind_rest::handle_get_block(const code& ec, - rest_interface::block, uint8_t , system::hash_cptr ) NOEXCEPT + rest_interface::block, uint8_t media, const system::hash_cptr& hash) NOEXCEPT +{ + if (stopped(ec)) + return false; + + if (!hash) + { + send_not_found(); + return true; + } + + constexpr auto witness = true; + const auto& query = archive(); + const auto block = query.get_block(query.to_header(*hash), witness); + if (is_null(block)) + { + send_not_found(); + return true; + } + + const auto size = block->serialized_size(witness); + switch (media) + { + case data: + send_data(to_bin(*block, size, witness)); + return true; + case text: + send_hex(to_hex(*block, size, witness)); + return true; + case json: + send_dom(value_from(bitcoind_verbose(*block)), two * size); + return true; + } + + send_not_found(); + return true; +} + +bool protocol_bitcoind_rest::handle_get_block_hash(const code& ec, + rest_interface::block_hash, uint8_t media, uint32_t height) NOEXCEPT +{ + if (stopped(ec)) + return false; + + const auto& query = archive(); + const auto link = query.to_confirmed(height); + if (link.is_terminal()) + { + send_not_found(); + return true; + } + + const auto hash = query.get_header_key(link); + switch (media) + { + case data: + send_data(to_chunk(hash)); + return true; + case text: + send_hex(encode_base16(hash)); + return true; + case json: + send_dom(boost::json::object{ { "blockhash", encode_hash(hash) } }, + two * hash_size); + return true; + } + + send_not_found(); + return true; +} + +bool protocol_bitcoind_rest::handle_get_block_txs(const code& ec, + rest_interface::block_txs, uint8_t media, const system::hash_cptr& hash) NOEXCEPT +{ + if (stopped(ec)) + return false; + + if (!hash) + { + send_not_found(); + return true; + } + + constexpr auto witness = true; + const auto& query = archive(); + const auto block = query.get_block(query.to_header(*hash), witness); + if (is_null(block)) + { + send_not_found(); + return true; + } + + // notxdetails: raw block for bin/hex, txid list (bitcoind_hashed) for json. + const auto size = block->serialized_size(witness); + switch (media) + { + case data: + send_data(to_bin(*block, size, witness)); + return true; + case text: + send_hex(to_hex(*block, size, witness)); + return true; + case json: + send_dom(value_from(bitcoind_hashed(*block)), two * size); + return true; + } + + send_not_found(); + return true; +} + +bool protocol_bitcoind_rest::handle_get_block_headers(const code& ec, + rest_interface::block_headers, uint8_t media, const system::hash_cptr& hash, + uint32_t count) NOEXCEPT +{ + if (stopped(ec)) + return false; + + if (!hash || is_zero(count)) + { + send_not_found(); + return true; + } + + const auto& query = archive(); + const auto link = query.to_header(*hash); + size_t height{}; + if (link.is_terminal() || !query.get_height(height, link)) + { + send_not_found(); + return true; + } + + // Core caps the header count at 2000. + constexpr size_t maximum = 2000; + constexpr auto header_size = chain::header::serialized_size(); + const auto top = query.get_top_confirmed(); + const auto limit = std::min(static_cast(count), maximum); + + std::vector headers{}; + headers.reserve(limit); + for (size_t index = 0; index < limit && height + index <= top; ++index) + { + const auto header = query.get_header(query.to_confirmed(height + index)); + if (!header) + break; + + headers.push_back(header); + } + + if (headers.empty()) + { + send_not_found(); + return true; + } + + switch (media) + { + case data: + { + data_chunk out{}; + out.reserve(headers.size() * header_size); + for (const auto& header: headers) + { + const auto bin = to_bin(*header, header_size); + out.insert(out.end(), bin.begin(), bin.end()); + } + + send_data(std::move(out)); + return true; + } + case text: + { + std::string out{}; + out.reserve(headers.size() * two * header_size); + for (const auto& header: headers) + out += to_hex(*header, header_size); + + send_hex(std::move(out)); + return true; + } + case json: + { + boost::json::array out{}; + out.reserve(headers.size()); + for (const auto& header: headers) + out.push_back(header_to_bitcoind(*header)); + + send_dom(std::move(out), headers.size() * two * header_size); + return true; + } + } + + send_not_found(); + return true; +} + +bool protocol_bitcoind_rest::handle_get_block_part(const code& ec, + rest_interface::block_part, uint8_t media, const system::hash_cptr& hash, + uint32_t offset, uint32_t size) NOEXCEPT +{ + if (stopped(ec)) + return false; + + if (!hash) + { + send_not_found(); + return true; + } + + constexpr auto witness = true; + const auto& query = archive(); + const auto block = query.get_block(query.to_header(*hash), witness); + if (is_null(block)) + { + send_not_found(); + return true; + } + + const auto full = to_bin(*block, block->serialized_size(witness), witness); + if (offset >= full.size()) + { + send_not_found(); + return true; + } + + const auto end = std::min(static_cast(offset) + size, full.size()); + data_chunk part(full.begin() + offset, full.begin() + end); + switch (media) + { + case data: + send_data(std::move(part)); + return true; + case text: + send_hex(encode_base16(part)); + return true; + } + + // block_part is bin|hex only (json not supported). + send_not_found(); + return true; +} + +bool protocol_bitcoind_rest::handle_get_block_spent_tx_outputs(const code& ec, + rest_interface::block_spent_tx_outputs, uint8_t media, + const system::hash_cptr& hash) NOEXCEPT { if (stopped(ec)) return false; - ////const auto& query = archive(); - ////if (const auto block = query.get_block(query.to_header(*hash), true)) - ////{ - //// const auto size = block->serialized_size(true); - //// switch (media) - //// { - //// case data: - //// send_chunk(to_bin(*block, size, true)); - //// return true; - //// case text: - //// send_text(to_hex(*block, size, true)); - //// return true; - //// case json: - //// send_json(value_from(block), two * size); - //// return true; - //// } - ////} + if (!hash) + { + send_not_found(); + return true; + } + + constexpr auto witness = true; + const auto& query = archive(); + const auto block = query.get_block(query.to_header(*hash), witness); + if (is_null(block)) + { + send_not_found(); + return true; + } + + // Resolve every prevout spent by the block's non-coinbase transactions. + chain::output_cptrs spent{}; + const auto& txs = *block->transactions_ptr(); + for (size_t tx = 1; tx < txs.size(); ++tx) + for (const auto& in: *txs.at(tx)->inputs_ptr()) + if (const auto out = query.get_output(query.to_output(in->point()))) + spent.push_back(out); + + size_t size{}; + for (const auto& output: spent) + size += output->serialized_size(); + + switch (media) + { + case data: + { + data_chunk out(size); + stream::out::fast sink{ out }; + write::bytes::fast writer{ sink }; + for (const auto& output: spent) + output->to_data(writer); + + send_data(std::move(out)); + return true; + } + case text: + { + std::string out(two * size, '\0'); + stream::out::fast sink{ out }; + write::base16::fast writer{ sink }; + for (const auto& output: spent) + output->to_data(writer); + + send_hex(std::move(out)); + return true; + } + case json: + send_dom(value_from(bitcoind(spent)), two * size); + return true; + } send_not_found(); return true; } +bool protocol_bitcoind_rest::handle_get_block_filter(const code& ec, + rest_interface::block_filter, uint8_t media, const system::hash_cptr& hash, + uint8_t) NOEXCEPT +{ + if (stopped(ec)) + return false; + + const auto& query = archive(); + if (!hash || !query.filter_enabled()) + { + send_not_found(); + return true; + } + + // libbitcoin stores only the neutrino (basic) filter; type is ignored. + data_chunk filter{}; + if (!query.get_filter_body(filter, query.to_header(*hash))) + { + send_not_found(); + return true; + } + + switch (media) + { + case data: + send_data(std::move(filter)); + return true; + case text: + send_hex(encode_base16(filter)); + return true; + case json: + send_dom(boost::json::object + { + { "filter", encode_base16(filter) } + }, two * filter.size()); + return true; + } + + send_not_found(); + return true; +} + +bool protocol_bitcoind_rest::handle_get_block_filter_headers(const code& ec, + rest_interface::block_filter_headers, uint8_t media, const system::hash_cptr& hash, + uint8_t) NOEXCEPT +{ + if (stopped(ec)) + return false; + + const auto& query = archive(); + if (!hash || !query.filter_enabled()) + { + send_not_found(); + return true; + } + + hash_digest filter_head{}; + if (!query.get_filter_head(filter_head, query.to_header(*hash))) + { + send_not_found(); + return true; + } + + switch (media) + { + case data: + send_data(to_chunk(filter_head)); + return true; + case text: + send_hex(encode_base16(filter_head)); + return true; + case json: + send_dom(boost::json::object + { + { "filter_header", encode_hash(filter_head) } + }, two * hash_size); + return true; + } + + send_not_found(); + return true; +} + +bool protocol_bitcoind_rest::handle_get_chain_information(const code& ec, + rest_interface::chain_information) NOEXCEPT +{ + if (stopped(ec)) + return false; + + const auto& query = archive(); + const auto blocks = query.get_top_confirmed(); + const auto link = query.to_confirmed(blocks); + const auto header = query.get_header(link); + if (is_null(header)) + { + send_not_found(); + return true; + } + + send_dom(boost::json::object + { + { "chain", chain_name(query) }, + { "blocks", static_cast(blocks) }, + { "headers", static_cast(query.get_top_candidate()) }, + { "bestblockhash", encode_hash(query.get_header_key(link)) }, + { "bits", encode_base16(to_big_endian(header->bits())) }, + { "difficulty", header->difficulty() }, + { "time", header->timestamp() }, + { "mediantime", median_time_past(query, blocks) }, + { "pruned", false } + }, 256); + return true; +} + +// Raw-http response senders (mirror protocol_html, not json-rpc enveloped). +// ---------------------------------------------------------------------------- + +void protocol_bitcoind_rest::send_data(data_chunk&& bytes) NOEXCEPT +{ + BC_ASSERT(stranded()); + const auto request = reset_request(); + network::http::response message{ network::http::status::ok, + request->version() }; + add_common_headers(message, *request); + add_access_control_headers(message, *request); + message.set(network::http::field::content_type, network::http:: + from_media_type(media_type::application_octet_stream)); + message.body() = std::move(bytes); + message.prepare_payload(); + SEND(std::move(message), handle_complete, _1, error::success); +} + +void protocol_bitcoind_rest::send_hex(std::string&& text) NOEXCEPT +{ + BC_ASSERT(stranded()); + const auto request = reset_request(); + network::http::response message{ network::http::status::ok, + request->version() }; + add_common_headers(message, *request); + add_access_control_headers(message, *request); + message.set(network::http::field::content_type, network::http:: + from_media_type(media_type::text_plain)); + message.body() = std::move(text); + message.prepare_payload(); + SEND(std::move(message), handle_complete, _1, error::success); +} + +void protocol_bitcoind_rest::send_dom(boost::json::value&& model, + size_t size_hint) NOEXCEPT +{ + BC_ASSERT(stranded()); + const auto request = reset_request(); + network::http::response message{ network::http::status::ok, + request->version() }; + add_common_headers(message, *request); + add_access_control_headers(message, *request); + message.set(network::http::field::content_type, network::http:: + from_media_type(media_type::application_json)); + message.body() = network::http::json_value + { + .model = std::move(model), + .size_hint = size_hint + }; + message.prepare_payload(); + SEND(std::move(message), handle_complete, _1, error::success); +} + // private // ---------------------------------------------------------------------------- diff --git a/src/protocols/bitcoind/protocol_bitcoind_rpc.cpp b/src/protocols/bitcoind/protocol_bitcoind_rpc.cpp index 2961185f..821fa125 100644 --- a/src/protocols/bitcoind/protocol_bitcoind_rpc.cpp +++ b/src/protocols/bitcoind/protocol_bitcoind_rpc.cpp @@ -20,6 +20,8 @@ #include #include +#include +#include namespace libbitcoin { namespace server { @@ -66,6 +68,8 @@ void protocol_bitcoind_rpc::start() NOEXCEPT SUBSCRIBE_BITCOIND(handle_verify_tx_out_set, _1, _2, _3); SUBSCRIBE_BITCOIND(handle_get_network_info, _1, _2); + SUBSCRIBE_BITCOIND(handle_get_raw_transaction, _1, _2, _3, _4, _5); + SUBSCRIBE_BITCOIND(handle_send_raw_transaction, _1, _2, _3, _4); network::protocol_http::start(); } @@ -207,15 +211,23 @@ bool protocol_bitcoind_rpc::handle_get_block(const code& ec, return true; } - if (verbosity == 1.0) + if (verbosity == 1.0 || verbosity == 2.0) { - send_error(error::not_implemented); - return true; - } + const auto block = query.get_block(link, witness); + if (is_null(block)) + { + send_error(error::not_found, blockhash, blockhash.size()); + return true; + } - if (verbosity == 2.0) - { - send_error(error::not_implemented); + // verbosity 1 lists txids; verbosity 2 embeds full tx objects. + auto model = (verbosity == 1.0) ? + value_from(bitcoind_hashed(*block)) : + value_from(bitcoind_verbose(*block)); + + inject_block_context(model.as_object(), query, link, block->header()); + send_result(rpc::value_t(std::move(model)), + two * block->serialized_size(witness)); return true; } @@ -226,46 +238,158 @@ bool protocol_bitcoind_rpc::handle_get_block(const code& ec, bool protocol_bitcoind_rpc::handle_get_block_chain_info(const code& ec, rpc_interface::get_block_chain_info) NOEXCEPT { - if (stopped(ec)) return false; - send_error(error::not_implemented); + if (stopped(ec)) + return false; + + const auto& query = archive(); + const auto blocks = query.get_top_confirmed(); + const auto headers = query.get_top_candidate(); + const auto link = query.to_confirmed(blocks); + const auto header = query.get_header(link); + if (is_null(header)) + { + send_error(database::error::integrity); + return true; + } + + // verificationprogress is approximated as confirmed/candidate height, the + // best available estimate of the chain tip during sync (1.0 once current). + const auto progress = is_zero(headers) ? 1.0 : + std::min(1.0, static_cast(blocks) / + static_cast(headers)); + + send_result(rpc::object_t + { + { "chain", chain_name(query) }, + { "blocks", static_cast(blocks) }, + { "headers", static_cast(headers) }, + { "bestblockhash", encode_hash(query.get_header_key(link)) }, + { "bits", encode_base16(to_big_endian(header->bits())) }, + { "target", encode_hash(from_uintx( + chain::compact::expand(header->bits()))) }, + { "difficulty", header->difficulty() }, + { "time", header->timestamp() }, + { "mediantime", median_time_past(query, blocks) }, + { "verificationprogress", progress }, + { "initialblockdownload", blocks < headers }, + { "pruned", false }, + { "warnings", std::string{} } + }, 512); return true; } bool protocol_bitcoind_rpc::handle_get_block_count(const code& ec, rpc_interface::get_block_count) NOEXCEPT { - if (stopped(ec)) return false; - send_error(error::not_implemented); + if (stopped(ec)) + return false; + + send_result(rpc::value_t(static_cast( + archive().get_top_confirmed())), 20); return true; } bool protocol_bitcoind_rpc::handle_get_block_filter(const code& ec, - rpc_interface::get_block_filter, const std::string&, + rpc_interface::get_block_filter, const std::string& blockhash, const std::string&) NOEXCEPT { - if (stopped(ec)) return false; - send_error(error::not_implemented); + if (stopped(ec)) + return false; + + hash_digest hash{}; + if (!decode_hash(hash, blockhash)) + { + send_error(error::not_found, blockhash, blockhash.size()); + return true; + } + + const auto& query = archive(); + if (!query.filter_enabled()) + { + send_error(error::not_implemented); + return true; + } + + const auto link = query.to_header(hash); + data_chunk filter{}; + hash_digest filter_header{}; + if (!query.get_filter_body(filter, link) || + !query.get_filter_head(filter_header, link)) + { + send_error(error::not_found, blockhash, blockhash.size()); + return true; + } + + send_result(rpc::object_t + { + { "filter", encode_base16(filter) }, + { "header", encode_hash(filter_header) } + }, two * filter.size()); return true; } bool protocol_bitcoind_rpc::handle_get_block_hash(const code& ec, - rpc_interface::get_block_hash, network::rpc::number_t) NOEXCEPT + rpc_interface::get_block_hash, double height) NOEXCEPT { - if (stopped(ec)) return false; - send_error(error::not_implemented); + if (stopped(ec)) + return false; + + if (height < 0.0) + { + send_error(error::invalid_argument); + return true; + } + + const auto& query = archive(); + const auto link = query.to_confirmed(static_cast(height)); + if (link.is_terminal()) + { + send_error(error::not_found); + return true; + } + + send_result(encode_hash(query.get_header_key(link)), two * hash_size); return true; } bool protocol_bitcoind_rpc::handle_get_block_header(const code& ec, - rpc_interface::get_block_header, const std::string&, bool) NOEXCEPT + rpc_interface::get_block_header, const std::string& blockhash, + bool verbose) NOEXCEPT { - if (stopped(ec)) return false; - send_error(error::not_implemented); + if (stopped(ec)) + return false; + + hash_digest hash{}; + if (!decode_hash(hash, blockhash)) + { + send_error(error::not_found, blockhash, blockhash.size()); + return true; + } + + const auto& query = archive(); + const auto link = query.to_header(hash); + const auto header = query.get_header(link); + if (is_null(header)) + { + send_error(error::not_found, blockhash, blockhash.size()); + return true; + } + + if (!verbose) + { + send_text(to_hex(*header, chain::header::serialized_size())); + return true; + } + + auto out = header_to_bitcoind(*header); + out["nTx"] = static_cast(query.get_tx_count(link)); + inject_block_context(out, query, link, *header); + send_result(rpc::value_t(boost::json::value(std::move(out))), 512); return true; } bool protocol_bitcoind_rpc::handle_get_block_stats(const code& ec, - rpc_interface::get_block_stats, const std::string&, + rpc_interface::get_block_stats, const network::rpc::value_t&, const network::rpc::array_t&) NOEXCEPT { if (stopped(ec)) return false; @@ -290,10 +414,51 @@ bool protocol_bitcoind_rpc::handle_get_chain_work(const code& ec, } bool protocol_bitcoind_rpc::handle_get_tx_out(const code& ec, - rpc_interface::get_tx_out, const std::string&, double, bool) NOEXCEPT + rpc_interface::get_tx_out, const std::string& txid, double n, + bool) NOEXCEPT { - if (stopped(ec)) return false; - send_error(error::not_implemented); + if (stopped(ec)) + return false; + + hash_digest hash{}; + if (!decode_hash(hash, txid) || n < 0.0) + { + send_error(error::invalid_argument); + return true; + } + + const auto& query = archive(); + const auto index = static_cast(n); + const auto output_fk = query.to_output(hash, index); + + // Core returns json null for a missing or spent output (mempool ignored). + if (output_fk.is_terminal() || query.is_spent(output_fk)) + { + send_result(rpc::value_t{}, 4); + return true; + } + + const auto output = query.get_output(output_fk); + if (is_null(output)) + { + send_result(rpc::value_t{}, 4); + return true; + } + + const auto tx_fk = query.to_tx(hash); + size_t tx_height{}; + const auto have_height = query.get_tx_height(tx_height, tx_fk); + const auto top = query.get_top_confirmed(); + + send_result(rpc::object_t + { + { "bestblock", encode_hash(query.get_top_confirmed_hash()) }, + { "confirmations", have_height ? + static_cast(top - tx_height + 1u) : int64_t{ 0 } }, + { "value", static_cast(output->value()) / 100000000.0 }, + { "scriptPubKey", value_from(bitcoind(output->script())) }, + { "coinbase", query.is_coinbase(tx_fk) } + }, 256); return true; } @@ -349,8 +514,121 @@ bool protocol_bitcoind_rpc::handle_verify_tx_out_set(const code& ec, bool protocol_bitcoind_rpc::handle_get_network_info(const code& ec, rpc_interface::get_network_info) NOEXCEPT { - if (stopped(ec)) return false; - send_error(error::not_implemented); + if (stopped(ec)) + return false; + + // libbitcoin-server is a node, not a wallet/peer-introspection service; + // peer-dependent fields (connections, addresses) are reported as empty. + // TODO: surface live connection count and relay fee from node settings. + send_result(rpc::object_t + { + { "version", 0 }, + { "subversion", std::string{ "/libbitcoin:server/" } }, + { "protocolversion", 70016 }, + { "localrelay", true }, + { "timeoffset", 0 }, + { "connections", 0 }, + { "networkactive", true }, + { "networks", rpc::array_t{} }, + { "relayfee", 0.00001 }, + { "incrementalfee", 0.00001 }, + { "localaddresses", rpc::array_t{} }, + { "warnings", std::string{} } + }, 256); + return true; +} + +// Rawtransactions methods. +// ---------------------------------------------------------------------------- + +bool protocol_bitcoind_rpc::handle_get_raw_transaction(const code& ec, + rpc_interface::get_raw_transaction, const std::string& txid, + double verbose, const std::string&) NOEXCEPT +{ + if (stopped(ec)) + return false; + + // The blockhash hint is unused: libbitcoin archives all tx (global index). + hash_digest hash{}; + if (!decode_hash(hash, txid)) + { + send_error(error::not_found, txid, txid.size()); + return true; + } + + constexpr auto witness = true; + auto& query = archive(); + const auto link = query.to_tx(hash); + const auto tx = query.get_transaction(link, witness); + if (is_null(tx)) + { + send_error(error::not_found, txid, txid.size()); + return true; + } + + if (verbose == 0.0) + { + send_text(to_hex(*tx, tx->serialized_size(witness), witness)); + return true; + } + + // bitcoind() (not bitcoind_verbose) yields Core's tx fields: txid/hash/ + // size/vsize/weight/vin/vout/hex (bitcoind_verbose on a standalone tx + // falls back to libbitcoin's plain inputs/outputs form). + auto model = value_from(bitcoind(*tx)); + inject_tx_context(model.as_object(), query, link); + send_result(rpc::value_t(std::move(model)), + two * tx->serialized_size(witness)); + return true; +} + +bool protocol_bitcoind_rpc::handle_send_raw_transaction(const code& ec, + rpc_interface::send_raw_transaction, const std::string& hexstring, + double) NOEXCEPT +{ + if (stopped(ec)) + return false; + + data_chunk data{}; + if (!decode_base16(data, hexstring)) + { + send_error(error::invalid_argument); + return true; + } + + const auto tx = std::make_shared(data, true); + if (!tx->is_valid()) + { + send_error(error::invalid_argument); + return true; + } + + auto& query = archive(); + const auto txid = tx->hash(false); + + // Archive (so the out-relay can serve getdata) only if not already known. + // TODO: contextual validation (populate_with_metadata + connect) for policy. + if (query.to_tx(txid).is_terminal()) + { + if (tx->check()) + { + send_error(error::invalid_argument); + return true; + } + + if (query.set_code(*tx)) + { + send_error(database::error::integrity); + return true; + } + } + + // Announce to peers; protocol_transaction_out_106 serves the tx on getdata. + broadcast( + std::make_shared( + messages::peer::transaction{ tx })); + + send_result(encode_hash(txid), two * system::hash_size); return true; } diff --git a/src/protocols/bitcoind/protocol_bitcoind_rpc_json.cpp b/src/protocols/bitcoind/protocol_bitcoind_rpc_json.cpp new file mode 100644 index 00000000..5267a4af --- /dev/null +++ b/src/protocols/bitcoind/protocol_bitcoind_rpc_json.cpp @@ -0,0 +1,139 @@ +/** + * Copyright (c) 2011-2026 libbitcoin developers (see AUTHORS) + * + * This file is part of libbitcoin. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +#include + +#include +#include +#include +#include + +namespace libbitcoin { +namespace server { + +using namespace system; + +uint32_t protocol_bitcoind_rpc::median_time_past(const node::query& query, + size_t height) NOEXCEPT +{ + constexpr size_t window = 11; + const auto count = std::min(window, height + 1u); + std::vector times{}; + times.reserve(count); + for (size_t index = 0; index < count; ++index) + { + const auto header = query.get_header(query.to_confirmed(height - index)); + if (header) + times.push_back(header->timestamp()); + } + + if (times.empty()) + return 0; + + std::sort(times.begin(), times.end()); + return times.at(times.size() / 2u); +} + +void protocol_bitcoind_rpc::inject_block_context(boost::json::object& out, + const node::query& query, const database::header_link& link, + const chain::header& header) NOEXCEPT +{ + size_t height{}; + if (!query.get_height(height, link)) + return; + + const auto top = query.get_top_confirmed(); + const auto confirmed = query.is_confirmed_block(link); + out["height"] = static_cast(height); + out["confirmations"] = confirmed ? + static_cast(top - height + 1u) : int64_t{ -1 }; + out["mediantime"] = median_time_past(query, height); + + if (header.previous_block_hash() != null_hash) + out["previousblockhash"] = encode_hash(header.previous_block_hash()); + + if (confirmed && height < top) + out["nextblockhash"] = encode_hash( + query.get_header_key(query.to_confirmed(height + 1u))); +} + +void protocol_bitcoind_rpc::inject_tx_context(boost::json::object& out, + const node::query& query, const database::tx_link& link) NOEXCEPT +{ + size_t height{}; + if (!query.is_confirmed_tx(link) || !query.get_tx_height(height, link)) + { + out["confirmations"] = 0; + return; + } + + const auto block = query.to_confirmed(height); + const auto top = query.get_top_confirmed(); + const auto header = query.get_header(block); + out["in_active_chain"] = true; + out["blockhash"] = encode_hash(query.get_header_key(block)); + out["confirmations"] = static_cast(top - height + 1u); + if (header) + { + out["blocktime"] = header->timestamp(); + out["time"] = header->timestamp(); + } +} + +boost::json::object protocol_bitcoind_rpc::header_to_bitcoind( + const chain::header& header) NOEXCEPT +{ + return boost::json::object + { + { "hash", encode_hash(header.hash()) }, + { "version", header.version() }, + { "versionHex", encode_base16(to_big_endian(header.version())) }, + { "merkleroot", encode_hash(header.merkle_root()) }, + { "time", header.timestamp() }, + { "nonce", header.nonce() }, + { "bits", encode_base16(to_big_endian(header.bits())) }, + { "difficulty", header.difficulty() } + }; +} + +std::string protocol_bitcoind_rpc::chain_name(const node::query& query) NOEXCEPT +{ + const auto genesis = query.get_header_key(query.to_confirmed(0)); + + using selection = chain::selection; + const std::pair networks[] + { + { selection::mainnet, "main" }, + { selection::testnet, "test" }, + { selection::regtest, "regtest" } + }; + + for (const auto& [network, name]: networks) + if (system::settings{ network }.genesis_block.hash() == genesis) + return name; + + // Signet is not yet modeled in system::settings (stubbed by genesis hash). + if (encode_hash(genesis) == + "00000008819873e925422c1ff0f99f7cc9bbb232af63a077a480a3633bee1ef6") + return "signet"; + + return "unknown"; +} + +} // namespace server +} // namespace libbitcoin diff --git a/test/parsers/bitcoind_target.cpp b/test/parsers/bitcoind_target.cpp index 8a2a0830..be23743a 100644 --- a/test/parsers/bitcoind_target.cpp +++ b/test/parsers/bitcoind_target.cpp @@ -20,9 +20,234 @@ BOOST_AUTO_TEST_SUITE(bitcoind_target_tests) -BOOST_AUTO_TEST_CASE(bitcoind_target_test) +using namespace system; +using namespace network::rpc; +using object_t = network::rpc::object_t; +using media_type = network::http::media_type; + +static const std::string test_hash = + "0000000000000000000000000000000000000000000000000000000000000042"; + +static const object_t& params_of(const request_t& request) NOEXCEPT +{ + BOOST_REQUIRE(request.params.has_value()); + BOOST_REQUIRE(std::holds_alternative(request.params.value())); + return std::get(request.params.value()); +} + +static hash_cptr hash_of(const object_t& object) NOEXCEPT +{ + const auto& any = std::get(object.at("hash").value()); + BOOST_REQUIRE(any.holds_alternative()); + const auto cptr = any.get(); + BOOST_REQUIRE(cptr); + return cptr; +} + +static uint8_t media_of(const object_t& object) NOEXCEPT +{ + return std::get(object.at("media").value()); +} + +// general + +BOOST_AUTO_TEST_CASE(parsers__bitcoind_target__valid__jsonrpc_v2_named_params) +{ + request_t out{}; + BOOST_REQUIRE(!bitcoind_target(out, "/rest/chaininfo.json")); + BOOST_REQUIRE(out.jsonrpc == network::rpc::version::v2); + BOOST_REQUIRE(std::holds_alternative(out.params.value())); +} + +BOOST_AUTO_TEST_CASE(parsers__bitcoind_target__error_paths__expected) +{ + const std::vector> cases + { + { "", server::error::empty_path }, + { "?foo=bar", server::error::empty_path }, + { "/rest", server::error::missing_target }, + { "/rest/bogus", server::error::invalid_target }, + { "/bogus", server::error::invalid_target }, + { "/rest/block", server::error::missing_hash }, + { "/rest/block/" + test_hash, server::error::invalid_target }, + { "/rest/block/" + test_hash + ".txt", server::error::invalid_target }, + { "/rest/block/nothex.json", server::error::invalid_hash }, + { "/rest/block/notxdetails", server::error::missing_hash }, + { "/rest/block/spent", server::error::missing_hash }, + { "/rest/blockhashbyheight", server::error::missing_target }, + { "/rest/blockhashbyheight/abc.json", server::error::invalid_number }, + { "/rest/blockhashbyheight/01.json", server::error::invalid_number }, + { "/rest/headers", server::error::missing_target }, + { "/rest/headers/abc/" + test_hash + ".json", server::error::invalid_number }, + { "/rest/headers/3", server::error::missing_hash }, + { "/rest/headers/3/nothex.json", server::error::invalid_hash }, + { "/rest/blockfilter", server::error::missing_target }, + { "/rest/blockfilter/extended/" + test_hash + ".json", server::error::invalid_target }, + { "/rest/blockfilter/basic", server::error::missing_hash }, + { "/rest/blockfilterheaders/basic", server::error::missing_hash }, + { "/rest/blockpart", server::error::missing_hash }, + { "/rest/blockpart/nothex/0/80.bin", server::error::invalid_hash }, + { "/rest/blockpart/" + test_hash, server::error::missing_target }, + { "/rest/blockpart/" + test_hash + "/abc/80.bin", server::error::invalid_number }, + { "/rest/blockpart/" + test_hash + "/0", server::error::missing_target }, + { "/rest/blockpart/" + test_hash + "/0/abc.bin", server::error::invalid_number } + }; + + for (const auto& [path, expected]: cases) + { + request_t out{}; + BOOST_REQUIRE_MESSAGE(bitcoind_target(out, path) == expected, path); + } +} + +// chaininfo + +BOOST_AUTO_TEST_CASE(parsers__bitcoind_target__chaininfo_variants__chain_information) +{ + const std::vector paths + { + "/rest/chaininfo.json", "/rest/chaininfo", "/chaininfo.json", "chaininfo" + }; + + for (const auto& path: paths) + { + request_t out{}; + BOOST_REQUIRE_MESSAGE(!bitcoind_target(out, path), path); + BOOST_REQUIRE_EQUAL(out.method, "chain_information"); + BOOST_REQUIRE(params_of(out).empty()); + } +} + +// block + +BOOST_AUTO_TEST_CASE(parsers__bitcoind_target__block_json__expected) +{ + request_t out{}; + BOOST_REQUIRE(!bitcoind_target(out, "/rest/block/" + test_hash + ".json")); + BOOST_REQUIRE_EQUAL(out.method, "block"); + + const auto& object = params_of(out); + BOOST_REQUIRE_EQUAL(object.size(), 2u); + BOOST_REQUIRE_EQUAL(media_of(object), to_value(media_type::application_json)); + BOOST_REQUIRE_EQUAL(to_uintx(*hash_of(object)), uint256_t{ 0x42 }); +} + +BOOST_AUTO_TEST_CASE(parsers__bitcoind_target__block_media__mapped) { - BOOST_REQUIRE(true); + const std::vector> cases + { + { "json", media_type::application_json }, + { "hex", media_type::text_plain }, + { "bin", media_type::application_octet_stream } + }; + + for (const auto& [extension, type]: cases) + { + request_t out{}; + const auto path = "/rest/block/" + test_hash + "." + extension; + BOOST_REQUIRE_MESSAGE(!bitcoind_target(out, path), extension); + BOOST_REQUIRE_EQUAL(media_of(params_of(out)), to_value(type)); + } +} + +BOOST_AUTO_TEST_CASE(parsers__bitcoind_target__block_notxdetails__block_txs) +{ + request_t out{}; + const auto path = "/rest/block/notxdetails/" + test_hash + ".json"; + BOOST_REQUIRE(!bitcoind_target(out, path)); + BOOST_REQUIRE_EQUAL(out.method, "block_txs"); + + const auto& object = params_of(out); + BOOST_REQUIRE_EQUAL(object.size(), 2u); + BOOST_REQUIRE_EQUAL(to_uintx(*hash_of(object)), uint256_t{ 0x42 }); +} + +BOOST_AUTO_TEST_CASE(parsers__bitcoind_target__block_spent__block_spent_tx_outputs) +{ + request_t out{}; + const auto path = "/rest/block/spent/" + test_hash + ".json"; + BOOST_REQUIRE(!bitcoind_target(out, path)); + BOOST_REQUIRE_EQUAL(out.method, "block_spent_tx_outputs"); + BOOST_REQUIRE_EQUAL(to_uintx(*hash_of(params_of(out))), uint256_t{ 0x42 }); +} + +// blockhashbyheight + +BOOST_AUTO_TEST_CASE(parsers__bitcoind_target__blockhashbyheight__block_hash) +{ + request_t out{}; + BOOST_REQUIRE(!bitcoind_target(out, "/rest/blockhashbyheight/5.json")); + BOOST_REQUIRE_EQUAL(out.method, "block_hash"); + + const auto& object = params_of(out); + BOOST_REQUIRE_EQUAL(object.size(), 2u); + BOOST_REQUIRE_EQUAL(std::get(object.at("height").value()), 5u); + BOOST_REQUIRE_EQUAL(media_of(object), to_value(media_type::application_json)); +} + +BOOST_AUTO_TEST_CASE(parsers__bitcoind_target__blockhashbyheight_zero__block_hash) +{ + request_t out{}; + BOOST_REQUIRE(!bitcoind_target(out, "/rest/blockhashbyheight/0.json")); + BOOST_REQUIRE_EQUAL(out.method, "block_hash"); + BOOST_REQUIRE_EQUAL(std::get(params_of(out).at("height").value()), 0u); +} + +// headers + +BOOST_AUTO_TEST_CASE(parsers__bitcoind_target__headers__block_headers) +{ + request_t out{}; + const auto path = "/rest/headers/3/" + test_hash + ".json"; + BOOST_REQUIRE(!bitcoind_target(out, path)); + BOOST_REQUIRE_EQUAL(out.method, "block_headers"); + + const auto& object = params_of(out); + BOOST_REQUIRE_EQUAL(object.size(), 3u); + BOOST_REQUIRE_EQUAL(std::get(object.at("count").value()), 3u); + BOOST_REQUIRE_EQUAL(to_uintx(*hash_of(object)), uint256_t{ 0x42 }); + BOOST_REQUIRE_EQUAL(media_of(object), to_value(media_type::application_json)); +} + +// blockfilter + +BOOST_AUTO_TEST_CASE(parsers__bitcoind_target__blockfilter_basic__block_filter) +{ + request_t out{}; + const auto path = "/rest/blockfilter/basic/" + test_hash + ".json"; + BOOST_REQUIRE(!bitcoind_target(out, path)); + BOOST_REQUIRE_EQUAL(out.method, "block_filter"); + + const auto& object = params_of(out); + BOOST_REQUIRE_EQUAL(object.size(), 3u); + BOOST_REQUIRE_EQUAL(std::get(object.at("type").value()), 0u); + BOOST_REQUIRE_EQUAL(to_uintx(*hash_of(object)), uint256_t{ 0x42 }); +} + +BOOST_AUTO_TEST_CASE(parsers__bitcoind_target__blockfilterheaders_basic__block_filter_headers) +{ + request_t out{}; + const auto path = "/rest/blockfilterheaders/basic/" + test_hash + ".json"; + BOOST_REQUIRE(!bitcoind_target(out, path)); + BOOST_REQUIRE_EQUAL(out.method, "block_filter_headers"); + BOOST_REQUIRE_EQUAL(std::get(params_of(out).at("type").value()), 0u); +} + +// blockpart + +BOOST_AUTO_TEST_CASE(parsers__bitcoind_target__blockpart__block_part) +{ + request_t out{}; + const auto path = "/rest/blockpart/" + test_hash + "/0/80.bin"; + BOOST_REQUIRE(!bitcoind_target(out, path)); + BOOST_REQUIRE_EQUAL(out.method, "block_part"); + + const auto& object = params_of(out); + BOOST_REQUIRE_EQUAL(object.size(), 4u); + BOOST_REQUIRE_EQUAL(std::get(object.at("offset").value()), 0u); + BOOST_REQUIRE_EQUAL(std::get(object.at("size").value()), 80u); + BOOST_REQUIRE_EQUAL(to_uintx(*hash_of(object)), uint256_t{ 0x42 }); + BOOST_REQUIRE_EQUAL(media_of(object), to_value(media_type::application_octet_stream)); } BOOST_AUTO_TEST_SUITE_END() diff --git a/test/protocols/bitcoind/bitcoind_json.cpp b/test/protocols/bitcoind/bitcoind_json.cpp new file mode 100644 index 00000000..33bae17a --- /dev/null +++ b/test/protocols/bitcoind/bitcoind_json.cpp @@ -0,0 +1,235 @@ +/** + * Copyright (c) 2011-2026 libbitcoin developers (see AUTHORS) + * + * This file is part of libbitcoin. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +#include "../../test.hpp" +#include "../../mocks/blocks.hpp" +#include +#include +#include + +using namespace system; + +static std::string as_text(const boost::json::value& value) NOEXCEPT +{ + return { value.as_string().c_str() }; +} + +// Exposes the protected static json helpers for direct testing. +struct json + : server::protocol_bitcoind_rpc +{ + using protocol_bitcoind_rpc::median_time_past; + using protocol_bitcoind_rpc::inject_block_context; + using protocol_bitcoind_rpc::inject_tx_context; + using protocol_bitcoind_rpc::header_to_bitcoind; + using protocol_bitcoind_rpc::chain_name; +}; + +// header_to_bitcoind +// ---------------------------------------------------------------------------- + +BOOST_AUTO_TEST_SUITE(bitcoind_header_to_bitcoind_tests) + +BOOST_AUTO_TEST_CASE(bitcoind_json__header_to_bitcoind__block1_header__maps_fields) +{ + const auto& header = test::block1.header(); + const auto out = json::header_to_bitcoind(header); + + BOOST_REQUIRE_EQUAL(as_text(out.at("hash")), encode_hash(header.hash())); + BOOST_REQUIRE_EQUAL(out.at("version").to_number(), header.version()); + BOOST_REQUIRE_EQUAL(as_text(out.at("versionHex")), + encode_base16(to_big_endian(header.version()))); + BOOST_REQUIRE_EQUAL(as_text(out.at("merkleroot")), + encode_hash(header.merkle_root())); + BOOST_REQUIRE_EQUAL(out.at("time").to_number(), header.timestamp()); + BOOST_REQUIRE_EQUAL(out.at("nonce").to_number(), header.nonce()); + BOOST_REQUIRE_EQUAL(as_text(out.at("bits")), + encode_base16(to_big_endian(header.bits()))); + BOOST_REQUIRE(out.at("difficulty").is_number()); + BOOST_REQUIRE(!out.contains("height")); + BOOST_REQUIRE(!out.contains("confirmations")); +} + +BOOST_AUTO_TEST_SUITE_END() + +// ---------------------------------------------------------------------------- + +struct bitcoind_json_setup_fixture +{ + DELETE_COPY_MOVE(bitcoind_json_setup_fixture); + + bitcoind_json_setup_fixture() + : config_ + { + system::chain::selection::mainnet, + test::web_pages, + test::web_pages + }, + store_ + { + [this]() NOEXCEPT -> const database::settings& + { + config_.database.path = TEST_DIRECTORY; + return config_.database; + }() + }, + query_{ store_ } + { + BOOST_REQUIRE_MESSAGE(test::clear(test::directory), "json setup"); + config_.database.interval_depth = 2; + + const auto ec = store_.create([](auto, auto) {}); + BOOST_REQUIRE_MESSAGE(!ec, ec.message()); + BOOST_REQUIRE_MESSAGE(test::setup_ten_block_store(query_), + "json initialize"); + } + + ~bitcoind_json_setup_fixture() + { + const auto ec = store_.close([](auto, auto) {}); + BOOST_WARN_MESSAGE(!ec, ec.message()); + BOOST_WARN_MESSAGE(test::clear(test::directory), "json cleanup"); + } + + configuration config_; + test::store_t store_; + test::query_t query_; +}; + +BOOST_FIXTURE_TEST_SUITE(bitcoind_json_tests, bitcoind_json_setup_fixture) + +// chain_name + +BOOST_AUTO_TEST_CASE(bitcoind_json__chain_name__mainnet_genesis__main) +{ + BOOST_REQUIRE_EQUAL(json::chain_name(query_), "main"); +} + +// median_time_past + +BOOST_AUTO_TEST_CASE(bitcoind_json__median_time_past__genesis__genesis_time) +{ + BOOST_REQUIRE_EQUAL(json::median_time_past(query_, 0), + test::genesis.header().timestamp()); +} + +BOOST_AUTO_TEST_CASE(bitcoind_json__median_time_past__height_nine__sorted_median) +{ + std::vector times + { + test::genesis.header().timestamp(), + test::block1.header().timestamp(), + test::block2.header().timestamp(), + test::block3.header().timestamp(), + test::block4.header().timestamp(), + test::block5.header().timestamp(), + test::block6.header().timestamp(), + test::block7.header().timestamp(), + test::block8.header().timestamp(), + test::block9.header().timestamp() + }; + + std::sort(times.begin(), times.end()); + BOOST_REQUIRE_EQUAL(json::median_time_past(query_, 9), + times.at(times.size() / 2u)); +} + +// inject_block_context + +BOOST_AUTO_TEST_CASE(bitcoind_json__inject_block_context__middle__height_confirmations_siblings) +{ + const auto link = query_.to_header(test::block5_hash); + const auto header = query_.get_header(link); + BOOST_REQUIRE(header); + + boost::json::object out{}; + json::inject_block_context(out, query_, link, *header); + + BOOST_REQUIRE_EQUAL(out.at("height").to_number(), 5u); + BOOST_REQUIRE_EQUAL(out.at("confirmations").to_number(), 5); + BOOST_REQUIRE_EQUAL(out.at("mediantime").to_number(), + json::median_time_past(query_, 5)); + BOOST_REQUIRE_EQUAL(as_text(out.at("previousblockhash")), + encode_hash(test::block4_hash)); + BOOST_REQUIRE_EQUAL(as_text(out.at("nextblockhash")), + encode_hash(test::block6_hash)); +} + +BOOST_AUTO_TEST_CASE(bitcoind_json__inject_block_context__genesis__no_previous) +{ + const auto link = query_.to_header(test::block0_hash); + const auto header = query_.get_header(link); + BOOST_REQUIRE(header); + + boost::json::object out{}; + json::inject_block_context(out, query_, link, *header); + + BOOST_REQUIRE_EQUAL(out.at("height").to_number(), 0u); + BOOST_REQUIRE_EQUAL(out.at("confirmations").to_number(), 10); + BOOST_REQUIRE(!out.contains("previousblockhash")); + BOOST_REQUIRE_EQUAL(as_text(out.at("nextblockhash")), + encode_hash(test::block1_hash)); +} + +BOOST_AUTO_TEST_CASE(bitcoind_json__inject_block_context__tip__no_next) +{ + const auto link = query_.to_header(test::block9_hash); + const auto header = query_.get_header(link); + BOOST_REQUIRE(header); + + boost::json::object out{}; + json::inject_block_context(out, query_, link, *header); + + BOOST_REQUIRE_EQUAL(out.at("height").to_number(), 9u); + BOOST_REQUIRE_EQUAL(out.at("confirmations").to_number(), 1); + BOOST_REQUIRE_EQUAL(as_text(out.at("previousblockhash")), + encode_hash(test::block8_hash)); + BOOST_REQUIRE(!out.contains("nextblockhash")); +} + +// inject_tx_context + +BOOST_AUTO_TEST_CASE(bitcoind_json__inject_tx_context__confirmed_coinbase__block_context) +{ + const auto txid = test::block1.transactions_ptr()->front()->hash(false); + const auto link = query_.to_tx(txid); + + boost::json::object out{}; + json::inject_tx_context(out, query_, link); + + BOOST_REQUIRE(out.at("in_active_chain").as_bool()); + BOOST_REQUIRE_EQUAL(as_text(out.at("blockhash")), + encode_hash(test::block1_hash)); + BOOST_REQUIRE_EQUAL(out.at("confirmations").to_number(), 9); + BOOST_REQUIRE_EQUAL(out.at("blocktime").to_number(), + test::block1.header().timestamp()); +} + +BOOST_AUTO_TEST_CASE(bitcoind_json__inject_tx_context__unknown__zero_confirmations) +{ + const auto link = query_.to_tx(null_hash); + + boost::json::object out{}; + json::inject_tx_context(out, query_, link); + + BOOST_REQUIRE_EQUAL(out.at("confirmations").to_number(), 0); + BOOST_REQUIRE(!out.contains("blockhash")); + BOOST_REQUIRE(!out.contains("in_active_chain")); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/test/protocols/bitcoind/bitcoind_rest.cpp b/test/protocols/bitcoind/bitcoind_rest.cpp index e2468146..e9404b50 100644 --- a/test/protocols/bitcoind/bitcoind_rest.cpp +++ b/test/protocols/bitcoind/bitcoind_rest.cpp @@ -19,6 +19,114 @@ #include "../../test.hpp" #include "bitcoind_setup_fixture.hpp" -BOOST_FIXTURE_TEST_SUITE(bitcoind_tests, bitcoind_ten_block_setup_fixture) +using namespace system; + +static std::string as_text(const boost::json::value& value) NOEXCEPT +{ + return { value.as_string().c_str() }; +} + +namespace { + +const auto block0 = encode_hash(test::block0_hash); +const auto block5 = encode_hash(test::block5_hash); +const auto block9 = encode_hash(test::block9_hash); +const auto header9 = encode_base16(test::header9_data); + +std::string block_hash_hex(const data_chunk& wire) NOEXCEPT +{ + return encode_hash(bitcoin_hash(chain::header::serialized_size(), + wire.data())); +} + +} // namespace + +BOOST_FIXTURE_TEST_SUITE(bitcoind_rest_tests, bitcoind_ten_block_setup_fixture) + +BOOST_AUTO_TEST_CASE(bitcoind_rest__chaininfo_json__main_nine) +{ + const auto result = rest_json("/rest/chaininfo.json"); + BOOST_REQUIRE_EQUAL(as_text(result.at("chain")), "main"); + BOOST_REQUIRE_EQUAL(result.at("blocks").as_int64(), 9); + BOOST_REQUIRE_EQUAL(as_text(result.at("bestblockhash")), block9); +} + +BOOST_AUTO_TEST_CASE(bitcoind_rest__block_json__block9_with_txs) +{ + const auto result = rest_json("/rest/block/" + block9 + ".json"); + BOOST_REQUIRE_EQUAL(as_text(result.at("hash")), block9); + BOOST_REQUIRE(result.at("tx").is_array()); + BOOST_REQUIRE(result.at("tx").at(0).is_object()); +} + +BOOST_AUTO_TEST_CASE(bitcoind_rest__block_hex__hashes_to_block9) +{ + const auto hex = rest_text("/rest/block/" + block9 + ".hex"); + data_chunk wire{}; + BOOST_REQUIRE(decode_base16(wire, hex)); + BOOST_REQUIRE_EQUAL(block_hash_hex(wire), block9); +} + +BOOST_AUTO_TEST_CASE(bitcoind_rest__block_bin__hashes_to_block9) +{ + const auto wire = rest_data("/rest/block/" + block9 + ".bin"); + BOOST_REQUIRE_EQUAL(block_hash_hex(wire), block9); +} + +BOOST_AUTO_TEST_CASE(bitcoind_rest__block_notxdetails_json__txid_list) +{ + const auto result = rest_json("/rest/block/notxdetails/" + block9 + ".json"); + BOOST_REQUIRE_EQUAL(as_text(result.at("hash")), block9); + BOOST_REQUIRE(result.at("tx").is_array()); + BOOST_REQUIRE(result.at("tx").at(0).is_string()); +} + +BOOST_AUTO_TEST_CASE(bitcoind_rest__block_spent_json__structured) +{ + const auto result = rest_json("/rest/block/spent/" + block9 + ".json"); + BOOST_REQUIRE(result.is_array() || result.is_object()); +} + +BOOST_AUTO_TEST_CASE(bitcoind_rest__blockhashbyheight_json__height_five__block5) +{ + const auto result = rest_json("/rest/blockhashbyheight/5.json"); + BOOST_REQUIRE_EQUAL(as_text(result.at("blockhash")), block5); +} + +BOOST_AUTO_TEST_CASE(bitcoind_rest__blockhashbyheight_json__genesis__block0) +{ + const auto result = rest_json("/rest/blockhashbyheight/0.json"); + BOOST_REQUIRE_EQUAL(as_text(result.at("blockhash")), block0); +} + +BOOST_AUTO_TEST_CASE(bitcoind_rest__headers_json__count_three_from_block5) +{ + const auto result = rest_json("/rest/headers/3/" + block5 + ".json"); + BOOST_REQUIRE(result.is_array()); + BOOST_REQUIRE_EQUAL(result.as_array().size(), 3u); + BOOST_REQUIRE_EQUAL(as_text(result.at(0).at("hash")), block5); +} + +BOOST_AUTO_TEST_CASE(bitcoind_rest__headers_hex__one_header__eighty_bytes) +{ + const auto hex = rest_text("/rest/headers/1/" + block9 + ".hex"); + data_chunk wire{}; + BOOST_REQUIRE(decode_base16(wire, hex)); + BOOST_REQUIRE_EQUAL(wire.size(), 80u); + BOOST_REQUIRE_EQUAL(encode_base16(wire), header9); +} + +BOOST_AUTO_TEST_CASE(bitcoind_rest__blockpart_bin__block9_header) +{ + const auto wire = rest_data("/rest/blockpart/" + block9 + "/0/80.bin"); + BOOST_REQUIRE_EQUAL(wire.size(), 80u); + BOOST_REQUIRE_EQUAL(encode_base16(wire), header9); +} + +BOOST_AUTO_TEST_CASE(bitcoind_rest__blockfilter_basic__filters_disabled__not_ok) +{ + const auto target = "/rest/blockfilter/basic/" + block9 + ".json"; + BOOST_REQUIRE(rest_status(target) != boost::beast::http::status::ok); +} BOOST_AUTO_TEST_SUITE_END() diff --git a/test/protocols/bitcoind/bitcoind_rpc.cpp b/test/protocols/bitcoind/bitcoind_rpc.cpp index e2468146..2eb7f99e 100644 --- a/test/protocols/bitcoind/bitcoind_rpc.cpp +++ b/test/protocols/bitcoind/bitcoind_rpc.cpp @@ -19,6 +19,233 @@ #include "../../test.hpp" #include "bitcoind_setup_fixture.hpp" +using namespace system; + +// Quote a hash as a single-element json-rpc params array. +static std::string hash_param(const hash_digest& hash) NOEXCEPT +{ + return "[\"" + encode_hash(hash) + "\"]"; +} + +// Quote a hash plus a trailing scalar (e.g. verbosity) as a params array. +static std::string hash_param(const hash_digest& hash, + const std::string& tail) NOEXCEPT +{ + return "[\"" + encode_hash(hash) + "\", " + tail + "]"; +} + +// True if the json-rpc response carries a non-null error member. +static bool has_error(const boost::json::value& response) NOEXCEPT +{ + return response.is_object() && response.as_object().contains("error") && + !response.at("error").is_null(); +} + +static std::string as_text(const boost::json::value& value) NOEXCEPT +{ + return std::string{ value.as_string().c_str() }; +} + +// The ten-block store contains mainnet blocks 0..9 (block9 is the tip). BOOST_FIXTURE_TEST_SUITE(bitcoind_tests, bitcoind_ten_block_setup_fixture) +// blockchain +// ---------------------------------------------------------------------------- + +BOOST_AUTO_TEST_CASE(bitcoind_rpc__getblockcount__ten_block_store__nine) +{ + const auto response = rpc("getblockcount"); + BOOST_REQUIRE(response.at("result").is_int64()); + BOOST_REQUIRE_EQUAL(response.at("result").as_int64(), 9); +} + +BOOST_AUTO_TEST_CASE(bitcoind_rpc__getbestblockhash__ten_block_store__block9) +{ + const auto response = rpc("getbestblockhash"); + BOOST_REQUIRE_EQUAL(as_text(response.at("result")), + encode_hash(test::block9_hash)); +} + +BOOST_AUTO_TEST_CASE(bitcoind_rpc__getblockhash__height_five__block5) +{ + const auto response = rpc("getblockhash", "[5]"); + BOOST_REQUIRE_EQUAL(as_text(response.at("result")), + encode_hash(test::block5_hash)); +} + +BOOST_AUTO_TEST_CASE(bitcoind_rpc__getblockhash__genesis__block0) +{ + const auto response = rpc("getblockhash", "[0]"); + BOOST_REQUIRE_EQUAL(as_text(response.at("result")), + encode_hash(test::block0_hash)); +} + +BOOST_AUTO_TEST_CASE(bitcoind_rpc__getblockheader__block9__no_transactions) +{ + const auto response = rpc("getblockheader", hash_param(test::block9_hash)); + const auto& result = response.at("result"); + BOOST_REQUIRE_EQUAL(as_text(result.at("hash")), + encode_hash(test::block9_hash)); + BOOST_REQUIRE_EQUAL(result.at("height").as_int64(), 9); + BOOST_REQUIRE(!result.as_object().contains("tx")); +} + +BOOST_AUTO_TEST_CASE(bitcoind_rpc__getblock__block9_verbosity1__txid_list) +{ + const auto response = rpc("getblock", hash_param(test::block9_hash, "1")); + const auto& result = response.at("result"); + BOOST_REQUIRE_EQUAL(as_text(result.at("hash")), + encode_hash(test::block9_hash)); + BOOST_REQUIRE_EQUAL(result.at("height").as_int64(), 9); + BOOST_REQUIRE(result.at("tx").is_array()); + BOOST_REQUIRE_EQUAL(result.at("tx").as_array().size(), 1u); + BOOST_REQUIRE(result.at("tx").at(0).is_string()); +} + +BOOST_AUTO_TEST_CASE(bitcoind_rpc__getblock__block9_verbosity2__tx_objects) +{ + const auto response = rpc("getblock", hash_param(test::block9_hash, "2")); + const auto& tx = response.at("result").at("tx"); + BOOST_REQUIRE(tx.is_array()); + BOOST_REQUIRE(tx.at(0).is_object()); + BOOST_REQUIRE(tx.at(0).as_object().contains("txid")); +} + +BOOST_AUTO_TEST_CASE(bitcoind_rpc__getblockchaininfo__ten_block_store__expected) +{ + const auto response = rpc("getblockchaininfo"); + const auto& result = response.at("result"); + BOOST_REQUIRE_EQUAL(as_text(result.at("chain")), "main"); + BOOST_REQUIRE_EQUAL(result.at("blocks").as_int64(), 9); + // The mock store populates the confirmed chain only; headers (candidate + // top) reflects that, so assert presence/type rather than a height. + BOOST_REQUIRE(result.at("headers").is_int64()); + BOOST_REQUIRE_EQUAL(as_text(result.at("bestblockhash")), + encode_hash(test::block9_hash)); + + // Fields added alongside these tests. + BOOST_REQUIRE(result.as_object().contains("target")); + BOOST_REQUIRE(result.at("warnings").is_string()); + BOOST_REQUIRE(!result.at("initialblockdownload").as_bool()); +} + +BOOST_AUTO_TEST_CASE(bitcoind_rpc__gettxout__unspent_coinbase__output) +{ + const auto txid = test::block1.transactions_ptr()->front()->hash(false); + const auto response = rpc("gettxout", hash_param(txid, "0")); + const auto& result = response.at("result"); + BOOST_REQUIRE(result.is_object()); + BOOST_REQUIRE(result.as_object().contains("value")); + BOOST_REQUIRE(result.as_object().contains("scriptPubKey")); +} + +// rawtransactions +// ---------------------------------------------------------------------------- + +BOOST_AUTO_TEST_CASE(bitcoind_rpc__getrawtransaction__coinbase_raw__hex) +{ + const auto txid = test::block1.transactions_ptr()->front()->hash(false); + const auto response = rpc("getrawtransaction", hash_param(txid, "0")); + BOOST_REQUIRE(response.at("result").is_string()); +} + +BOOST_AUTO_TEST_CASE(bitcoind_rpc__getrawtransaction__coinbase_verbose__context) +{ + const auto txid = test::block1.transactions_ptr()->front()->hash(false); + const auto response = rpc("getrawtransaction", hash_param(txid, "1")); + const auto& result = response.at("result"); + BOOST_REQUIRE_EQUAL(as_text(result.at("txid")), encode_hash(txid)); + BOOST_REQUIRE(result.at("vin").is_array()); + BOOST_REQUIRE(result.at("vout").is_array()); + + // Chain context injected at the protocol layer. + BOOST_REQUIRE_EQUAL(as_text(result.at("blockhash")), + encode_hash(test::block1_hash)); + BOOST_REQUIRE_EQUAL(result.at("confirmations").as_int64(), 9); +} + +BOOST_AUTO_TEST_CASE(bitcoind_rpc__getrawtransaction__unknown_txid__error) +{ + const auto response = rpc("getrawtransaction", hash_param(null_hash, "1")); + BOOST_REQUIRE(has_error(response)); +} + +BOOST_AUTO_TEST_CASE(bitcoind_rpc__sendrawtransaction__invalid_hex__error) +{ + const auto response = rpc("sendrawtransaction", "[\"nothex\"]"); + BOOST_REQUIRE(has_error(response)); +} + +BOOST_AUTO_TEST_CASE(bitcoind_rpc__sendrawtransaction__malformed__error) +{ + const auto response = rpc("sendrawtransaction", "[\"00\"]"); + BOOST_REQUIRE(has_error(response)); +} + +// network +// ---------------------------------------------------------------------------- + +BOOST_AUTO_TEST_CASE(bitcoind_rpc__getnetworkinfo__fields) +{ + const auto response = rpc("getnetworkinfo"); + const auto& result = response.at("result"); + BOOST_REQUIRE(result.as_object().contains("version")); + BOOST_REQUIRE(result.at("subversion").is_string()); + BOOST_REQUIRE(result.as_object().contains("protocolversion")); + BOOST_REQUIRE(result.at("networks").is_array()); +} + +// declared but deliberately not implemented (structured not_implemented error) +// ---------------------------------------------------------------------------- + +BOOST_AUTO_TEST_CASE(bitcoind_rpc__not_implemented__error) +{ + const std::vector> methods + { + { "getblockstats", "[0]" }, + { "getchaintxstats", "[]" }, + { "getchainwork", "[]" }, + { "gettxoutsetinfo", "[]" }, + { "scantxoutset", "[\"start\", []]" }, + { "verifychain", "[]" }, + { "verifytxoutset", "[\"test\"]" }, + { "pruneblockchain", "[1]" }, + { "savemempool", "[]" } + }; + + for (const auto& [method, params]: methods) + BOOST_REQUIRE_MESSAGE(has_error(rpc(method, params)), method); +} + +// getblockfilter is implemented but requires bip158 (disabled in this store). +BOOST_AUTO_TEST_CASE(bitcoind_rpc__getblockfilter__filters_disabled__error) +{ + const auto response = rpc("getblockfilter", + hash_param(test::block9_hash, "\"basic\"")); + BOOST_REQUIRE(has_error(response)); +} + +BOOST_AUTO_TEST_SUITE_END() + +// segwit (witness store: genesis + two blocks carrying witness transactions) +// ---------------------------------------------------------------------------- + +BOOST_FIXTURE_TEST_SUITE(bitcoind_witness_tests, bitcoind_witness_setup_fixture) + +BOOST_AUTO_TEST_CASE(bitcoind_rpc__getrawtransaction__witness_tx__wtxid_differs) +{ + // block1a's first transaction carries witnesses; wtxid differs from txid. + const auto& tx = *test::block1a.transactions_ptr()->front(); + const auto txid = tx.hash(false); + const auto response = rpc("getrawtransaction", hash_param(txid, "1")); + const auto& result = response.at("result"); + + BOOST_REQUIRE_EQUAL(as_text(result.at("txid")), encode_hash(txid)); + BOOST_REQUIRE_NE(as_text(result.at("hash")), encode_hash(txid)); + + // Segwit weight accounting: vsize == ceil(weight / 4). + const auto weight = result.at("weight").as_int64(); + BOOST_REQUIRE_EQUAL(result.at("vsize").as_int64(), (weight + 3) / 4); +} + BOOST_AUTO_TEST_SUITE_END() diff --git a/test/protocols/bitcoind/bitcoind_setup_fixture.cpp b/test/protocols/bitcoind/bitcoind_setup_fixture.cpp index 6178292c..0b446b90 100644 --- a/test/protocols/bitcoind/bitcoind_setup_fixture.cpp +++ b/test/protocols/bitcoind/bitcoind_setup_fixture.cpp @@ -20,6 +20,26 @@ #include "../../mocks/blocks.hpp" #include "bitcoind_setup_fixture.hpp" #include +#include + +using namespace boost::beast; + +namespace { + +// Internal linkage to avoid colliding with the native fixture's parse_json. +boost::json::value parse_json(std::string_view value) +{ + try + { + return boost::json::parse(value); + } + catch (...) + { + return {}; + } +} + +} // namespace bitcoind_setup_fixture::bitcoind_setup_fixture(const initializer& setup) : config_ @@ -82,23 +102,101 @@ bitcoind_setup_fixture::~bitcoind_setup_fixture() BOOST_WARN_MESSAGE(test::clear(test::directory), "bitcoind cleanup"); } -boost::json::value bitcoind_setup_fixture::get(const std::string& request) +bitcoind_setup_fixture::string_request +bitcoind_setup_fixture::create_get(std::string_view target) { - socket_.send(boost::asio::buffer(request)); - boost::asio::streambuf stream{}; + string_request request{ http::verb::get, target, + network::http::version_1_1 }; + request.set(http::field::host, "localhost"); + request.set(http::field::user_agent, BOOST_BEAST_VERSION_STRING); + request.keep_alive(true); + return request; +} - try - { - boost::asio::read_until(socket_, stream, '\n'); - } - catch (const boost::system::system_error&) - { - ////BOOST_WARN_MESSAGE(false, e.what()); - return boost::json::parse(R"({"dropped":true})"); - } +bitcoind_setup_fixture::string_request +bitcoind_setup_fixture::create_post(std::string_view target, + std::string_view body) +{ + string_request request{ http::verb::post, target, + network::http::version_1_1 }; + request.set(http::field::host, "localhost"); + request.set(http::field::user_agent, BOOST_BEAST_VERSION_STRING); + request.set(http::field::content_type, "application/json"); + request.body() = std::string{ body }; + request.prepare_payload(); + request.keep_alive(true); + return request; +} + +boost::json::value bitcoind_setup_fixture::rpc(std::string_view method, + std::string_view params) +{ + std::ostringstream body{}; + body << R"({"jsonrpc":"2.0","id":0,"method":")" << method + << R"(","params":)" << params << "}"; + http::write(socket_, create_post("/", body.str())); + + flat_buffer buffer{}; + network::boost_code ec{}; + http::response response{}; + http::read(socket_, buffer, response, ec); + BOOST_CHECK_MESSAGE(!ec, ec.message()); + return parse_json(response.body()); +} + +bitcoind_setup_fixture::status +bitcoind_setup_fixture::rest_status(std::string_view target) +{ + http::write(socket_, create_get(target)); + + flat_buffer buffer{}; + network::boost_code ec{}; + http::response response{}; + http::read(socket_, buffer, response, ec); + BOOST_CHECK_MESSAGE(!ec, ec.message()); + return response.result(); +} + +boost::json::value bitcoind_setup_fixture::rest_json(std::string_view target) +{ + http::write(socket_, create_get(target)); + + flat_buffer buffer{}; + network::boost_code ec{}; + http::response response{}; + http::read(socket_, buffer, response, ec); + BOOST_CHECK_MESSAGE(!ec, ec.message()); + BOOST_CHECK_EQUAL(response.result(), http::status::ok); + return parse_json(response.body()); +} + +std::string bitcoind_setup_fixture::rest_text(std::string_view target) +{ + http::write(socket_, create_get(target)); + + flat_buffer buffer{}; + network::boost_code ec{}; + http::response response{}; + http::read(socket_, buffer, response, ec); + BOOST_CHECK_MESSAGE(!ec, ec.message()); + BOOST_CHECK_EQUAL(response.result(), http::status::ok); + + auto body = response.body(); + while (!body.empty() && (body.back() == '\n' || body.back() == '\r')) + body.pop_back(); + + return body; +} + +system::data_chunk bitcoind_setup_fixture::rest_data(std::string_view target) +{ + http::write(socket_, create_get(target)); - std::string response{}; - std::istream response_stream{ &stream }; - std::getline(response_stream, response); - return boost::json::parse(response); + flat_buffer buffer{}; + network::boost_code ec{}; + http::response response{}; + http::read(socket_, buffer, response, ec); + BOOST_CHECK_MESSAGE(!ec, ec.message()); + BOOST_CHECK_EQUAL(response.result(), http::status::ok); + return system::data_chunk(response.body().begin(), response.body().end()); } diff --git a/test/protocols/bitcoind/bitcoind_setup_fixture.hpp b/test/protocols/bitcoind/bitcoind_setup_fixture.hpp index 1d8104ad..a346cf75 100644 --- a/test/protocols/bitcoind/bitcoind_setup_fixture.hpp +++ b/test/protocols/bitcoind/bitcoind_setup_fixture.hpp @@ -24,18 +24,24 @@ #define BITCOIND_ENDPOINT "127.0.0.1:65000" -// TODO: bitcoind is http so use boost::beast. - struct bitcoind_setup_fixture { - DELETE_COPY_MOVE(bitcoind_setup_fixture); - + using status = boost::beast::http::status; using initializer = std::function; + + DELETE_COPY_MOVE(bitcoind_setup_fixture); explicit bitcoind_setup_fixture(const initializer& setup); ~bitcoind_setup_fixture(); - // bitcoind does not implement any protocol version control or negotiation. - boost::json::value get(const std::string& request); + // JSON-RPC 2.0 over HTTP POST to "/". params is a raw json value (array or + // object). Returns the parsed json-rpc response object (with result/error). + boost::json::value rpc(std::string_view method, std::string_view params="[]"); + + // Bitcoin Core REST over HTTP GET (target under "/rest/..."). + status rest_status(std::string_view target); + boost::json::value rest_json(std::string_view target); + std::string rest_text(std::string_view target); + system::data_chunk rest_data(std::string_view target); protected: configuration config_; @@ -43,10 +49,16 @@ struct bitcoind_setup_fixture test::query_t query_; private: + using string_body = network::http::string_body; + using string_request = boost::beast::http::request; + static string_request create_get(std::string_view target); + static string_request create_post(std::string_view target, + std::string_view body); + network::logger log_; server::server_node server_; boost::asio::io_context io{}; - boost::asio::ip::tcp::socket socket_{ io }; + boost::beast::tcp_stream socket_{ io.get_executor() }; }; struct bitcoind_ten_block_setup_fixture @@ -61,4 +73,16 @@ struct bitcoind_ten_block_setup_fixture } }; +struct bitcoind_witness_setup_fixture + : bitcoind_setup_fixture +{ + inline bitcoind_witness_setup_fixture() + : bitcoind_setup_fixture([](test::query_t& query) + { + return test::setup_three_block_witness_store(query); + }) + { + } +}; + #endif