diff --git a/builds/gnu/Makefile.am b/builds/gnu/Makefile.am index feda0b550..6d17b4db5 100644 --- a/builds/gnu/Makefile.am +++ b/builds/gnu/Makefile.am @@ -64,6 +64,7 @@ src_libbitcoin_server_la_SOURCES = \ ${srcdir}/../../src/protocols/bitcoind/protocol_bitcoind_rest.cpp \ ${srcdir}/../../src/protocols/bitcoind/protocol_bitcoind_rpc.cpp \ ${srcdir}/../../src/protocols/bitcoind/protocol_bitcoind_rpc_json.cpp \ + ${srcdir}/../../src/protocols/btcd/protocol_btcd_rpc.cpp \ ${srcdir}/../../src/protocols/electrum/protocol_electrum.cpp \ ${srcdir}/../../src/protocols/electrum/protocol_electrum_addresses.cpp \ ${srcdir}/../../src/protocols/electrum/protocol_electrum_fees.cpp \ @@ -108,6 +109,7 @@ include_bitcoin_server_channelsdir = \ include_bitcoin_server_channels_HEADERS = \ ${srcdir}/../../include/bitcoin/server/channels/channel.hpp \ + ${srcdir}/../../include/bitcoin/server/channels/channel_btcd.hpp \ ${srcdir}/../../include/bitcoin/server/channels/channel_electrum.hpp \ ${srcdir}/../../include/bitcoin/server/channels/channel_http.hpp \ ${srcdir}/../../include/bitcoin/server/channels/channel_stratum_v1.hpp \ @@ -155,6 +157,7 @@ include_bitcoin_server_protocols_HEADERS = \ ${srcdir}/../../include/bitcoin/server/protocols/protocol_admin.hpp \ ${srcdir}/../../include/bitcoin/server/protocols/protocol_bitcoind_rest.hpp \ ${srcdir}/../../include/bitcoin/server/protocols/protocol_bitcoind_rpc.hpp \ + ${srcdir}/../../include/bitcoin/server/protocols/protocol_btcd_rpc.hpp \ ${srcdir}/../../include/bitcoin/server/protocols/protocol_electrum.hpp \ ${srcdir}/../../include/bitcoin/server/protocols/protocol_electrum_version.hpp \ ${srcdir}/../../include/bitcoin/server/protocols/protocol_html.hpp \ @@ -217,6 +220,8 @@ test_libbitcoin_server_test_SOURCES = \ ${srcdir}/../../test/protocols/bitcoind/bitcoind_rest.cpp \ ${srcdir}/../../test/protocols/bitcoind/bitcoind_rpc.cpp \ ${srcdir}/../../test/protocols/bitcoind/bitcoind_setup_fixture.cpp \ + ${srcdir}/../../test/protocols/btcd/btcd_rpc.cpp \ + ${srcdir}/../../test/protocols/btcd/btcd_setup_fixture.cpp \ ${srcdir}/../../test/protocols/electrum/electrum_addresses.cpp \ ${srcdir}/../../test/protocols/electrum/electrum_disabled.cpp \ ${srcdir}/../../test/protocols/electrum/electrum_fees.cpp \ diff --git a/docs/btcd-endpoint.md b/docs/btcd-endpoint.md new file mode 100644 index 000000000..7db2f76e6 --- /dev/null +++ b/docs/btcd-endpoint.md @@ -0,0 +1,271 @@ +# btcd-Compatible Endpoint + +This document specifies the `btcd` JSON-RPC/WebSocket compatibility endpoint for +libbitcoin-server, modeled on the existing `bitcoind` endpoint (see +`src/protocols/bitcoind/`, added in #795) and on +[btcd's JSON-RPC API](https://btcd.readthedocs.io/en/latest/json_rpc_api.html). + +## Background + +btcd is a Bitcoin full node written in Go that intentionally separates wallet and +chain services into independent processes: unlike Core/bitcoind, a direct +connection to btcd exposes only chain RPCs, never wallet methods. That matches +libbitcoin-server's own scope (no wallet), which is what makes btcd a good second +compatibility target after bitcoind. + +btcd exposes two transports on the same port (default `8334`): + +- **HTTP POST**, one JSON-RPC request/response per connection (same shape as the + existing `bitcoind` endpoint). +- **WebSocket** (`/ws`), btcd's preferred transport: a single connection carries + many requests plus asynchronous push notifications (new blocks, filtered + transactions, rescan progress). + +Real btcd authenticates via TLS + HTTP Basic Auth, with two credential tiers: +`rpcuser`/`rpcpass` (full access) and `rpclimituser`/`rpclimitpass` (restricted to +a fixed allowlist of chain-read methods). **Decision: this endpoint does not build +a bespoke two-tier scheme.** It reuses `network::settings::http_server`'s +credential model as-is (`btcd.credential = username:password[:method,...]`, same +config shape as `bitcoind.*`), which happens to already support scoping a +credential to a method allowlist generically; `channel_btcd::permitted()` enforces +that scope over both http and ws. Sensitive methods (`stop`, mining/peer +management) are stubbed to `not_implemented` unconditionally regardless of scope, +so this is revisited only if a future phase implements a real `stop` path. + +## Architecture + +``` +network::protocol_http +└── server::protocol_bitcoind_rpc + └── server::protocol_btcd_rpc (new) + +network::channel_http +└── server::channel_http + └── server::channel_btcd (new) + +server::session_btcd = session_server (new) +``` + +`protocol_btcd_rpc` inherits `protocol_bitcoind_rpc`, which already implements the +chain-read/rawtx handlers both APIs share (`getbestblockhash`, `getblock`, +`getblockhash`, `getblockheader`, `getblockcount`, `gettxout`, `getrawtransaction`, +`sendrawtransaction`, ...). It adds a second dispatcher for `interface::btcd` +(btcd-only methods) and overrides `network::protocol_http::dispatch_websocket()` — +the extension point `protocol_http` already exposes for handling an HTTP→WS +upgrade — to route btcd's WS-only notification/subscription methods. + +`channel_btcd` extends `channel_http` with an `authenticated_` flag, set once the +WS `authenticate` command (or HTTP Basic Auth header) succeeds. + +**A `session_handshake` variant of this was +tried and reverted.** The idea was to model btcd's auth on +`protocol_electrum_version`'s handshake pattern: a dedicated protocol object that +gates attachment of the real one. It doesn't fit. `session_handshake` calls +`channel->resume()` immediately after the handshake protocol attaches, on the +assumption that the handshake can only complete by reading a real message +(true for electrum's `server.version`, true for p2p's version message) — but +btcd's handshake, when no credential is configured, completes without reading +anything at all. That created a real async gap between "handshake decided +it's done" and "`protocol_btcd_rpc` has actually subscribed its handlers" — +harmless for a websocket client (the upgrade round-trip masks it), but a +plain HTTP client can land a request in that gap before anything is +listening, and did, reliably, in testing. Real btcd has no such gap because +it has no handshake protocol at all: `session_server` (used here) never calls +`resume()` until *after* `attach_protocols()` has synchronously subscribed +the real handlers — the same shape bitcoind already uses, and the reason +bitcoind never had this problem. `authenticate` is just an ordinary +extension method on `protocol_btcd_rpc`, exactly matching real btcd, where it +is described as "disallowed when basic auth has already been established" — +an alternative to Basic Auth for the one transport that can't carry headers +per-message, not a mandatory step every connection passes through. + +Notification/subscription state (watched blocks, watched scripts/outpoints) +follows the pattern already established in `protocol_electrum` +(`src/protocols/electrum/protocol_electrum.cpp`): a small map guarded by a +dedicated notification strand, populated by `node::chase` event callbacks, fed to +clients via `do_`/`complete_*` handlers. Model the btcd notifier on that +shape rather than inventing a new one. + +New files: + +- `include/bitcoin/server/channels/channel_btcd.hpp` +- `include/bitcoin/server/protocols/protocol_btcd_rpc.hpp` +- `include/bitcoin/server/interfaces/btcd.hpp` (method table, see + `interfaces/bitcoind_rpc.hpp` for the pattern) +- `src/protocols/btcd/protocol_btcd_rpc.cpp` +- `src/protocols/btcd/protocol_btcd_rpc_subscribe.cpp` +- `test/protocols/btcd/btcd_rpc.cpp` + +Modified: `channels.hpp`, `protocols.hpp`, `sessions.hpp`, `settings.hpp`, +`server_node.hpp`/`.cpp`, `parser.cpp` (all following the `bitcoind` wiring as a +template). + +## Method Scope + +### Already covered (inherited from `protocol_bitcoind_rpc`) + +`getbestblockhash`, `getblock`, `getblockhash`, `getblockheader`, `getblockcount`, +`gettxout`, `getrawtransaction`, `sendrawtransaction`. No btcd-specific work +needed; response shapes already match Core/btcd conventions closely enough for +Phase A. **Caveat verified during Phase A implementation**: these are +currently reachable only via plain http post to the same endpoint (unchanged +from `bitcoind`), not yet over the ws connection itself. `dispatch_websocket` +only routes to the btcd-only extension dispatcher; bridging the inherited +handlers' post-oriented private send path (which caches the original +`http::request` for header derivation, unavailable for a ws frame) into a +ws-reachable form is real work, scheduled for Phase B. Until then, a +ws-connected client (required for `authenticate`/`session`/`notifyblocks`) +cannot also call `getblockcount` etc. on that same connection — a real +lnd/btcwallet client would need this bridged to be useful in practice. + +### Phase A — wiring + +`channel_btcd`, `protocol_btcd_rpc`, `authenticate`, `session` (must be +functionally correct, not stubbed — see [lnd Compatibility](#lnd-compatibility)), +`notifyblocks`/`stopnotifyblocks`, `not_implemented` stubs for every other btcd +method, Boost.Test suite. + +### Phase B — lnd-critical, promoted ahead of generic filler + +`getcurrentnet` (network-match check lnd/btcwallet performs once at connect), +`loadtxfilter`, `rescanblocks` (guard behind `address_enabled`, same guard used +by the address-indexing-dependent electrum methods). None of these are on the +critical path for a generic btcd client — they're promoted here specifically +because `btcwallet`'s `chain.RPCClient` and lnd's own `chainntnfs/btcdnotify` +depend on them (see [lnd Compatibility](#lnd-compatibility)). + +### Phase C — remaining plain RPC methods (generic btcd-tooling compatibility) + +`getdifficulty`, `getinfo`, `getnettotals`, `getnetworkhashps`, +`createrawtransaction`, `decoderawtransaction`, `decodescript`, +`validateaddress`, `help`. No specific named consumer — these round out +compatibility for generic btcd-speaking tooling (block explorers, mining-pool +stats, monitoring dashboards, tx-building/debug scripts), not lnd. + +### Permanently stubbed (`not_implemented`, all phases) + +- **`stop`** — no secure remote-shutdown path exists in libbitcoin-server; always + returns `not_implemented` regardless of auth tier or config. +- **`notifynewtransactions`**, and the deprecated WS methods `notifyreceived`, + `stopnotifyreceived`, `notifyspent`, `stopnotifyspent`, `rescan` (all superseded + upstream by `loadtxfilter`/`rescanblocks`) — no mempool exists in v4, revisit in + v5. +- **`submitblock`** — future-phase only, not in current scope. +- Mining (`getgenerate`, `gethashespersec`, `getmininginfo`, `setgenerate`) and + peer-management (`addnode`, `getaddednodeinfo`, `getconnectioncount`, + `getpeerinfo`) — out of scope; libbitcoin-server is not a miner and peer + management is a node-config concern, not an RPC surface here. + +## lnd Compatibility + +btcd's own websocket API is one of lnd's three supported chain backends (the +others being `bitcoind` via ZMQ, and `neutrino`). Hooking an `lnd` node up to +libbitcoin-server through this endpoint means satisfying the calls +`btcwallet`'s `chain.RPCClient` (`btcsuite/btcwallet/chain/btcd.go`) and lnd's own +`chainntnfs/btcdnotify` package make against a btcd-style server. Verified against +both sources: + +**Plain RPC** (all either inherited from bitcoind or trivial to add): +`getbestblockhash`/`getblockcount` (chain tip), `getblockhash`, `getblockheader` +(chain-view / `IsCurrent` checks), `getblock` (verbose, block scanning), +`getrawtransaction`, `sendrawtransaction` (broadcasting funding/commitment/ +sweep/justice transactions), `gettxout` (UTXO-existence checks), `getcurrentnet` +(network-match validation performed once at connect — scheduled in Phase B). + +**WebSocket notification surface — the real gap.** Both `btcwallet`'s RPCClient +and lnd's `btcdnotify` depend on: + +- `NotifyBlocks` → `OnBlockConnected`/`OnBlockDisconnected` — covered by Phase A. +- A working `session` on connect — `btcwallet` uses the session id to detect a + reconnect to a fresh server instance, which must trigger re-registration of all + filters/rescans. If this is left as a stub, reconnect handling is undefined; it + should be implemented for real in Phase A, not deferred. +- **`loadtxfilter`** (address/outpoint watch-list registration) plus + block-filtered notification delivery (`OnFilteredBlockConnected`/ + `OnRedeemingTx`/`OnRecvTx` equivalents) — this is the mechanism both the wallet + and lnd's own notifier use to detect channel-funding confirmations and + on-chain spends of channel outputs (breach/force-close detection). It is not + optional legacy behavior; modern `btcwallet`/`lnd` don't fall back to the + deprecated `notifyreceived`/`notifyspent` path. This is why it's scheduled in + Phase B, ahead of Phase C's generic filler methods — it's more load-bearing + for lnd than any Phase C method. +- **`rescan`/`rescanblocks`** — used at wallet startup (birthday scan) and after + any downtime to replay historical blocks against the loaded filter. Also + scheduled in Phase B for the same reason. + +**Hard blocker: mempool.** Both sources confirm unconfirmed-transaction awareness +is an exercised, not merely optional, code path: `OnRecvTx`/`OnRedeemingTx` fire +for mempool-resident transactions (distinguished by a nil `BlockDetails`), and +lnd's notifier has a dedicated mempool-spend lookup path +(`LookupInputMempoolSpend`) used for fast breach/force-close reaction ahead of +confirmation. **libbitcoin-server v4 has no mempool**, so this notification class +cannot be implemented now — confirmed-only fallback is straightforward (fire the +same callbacks only on block connection), but it means lnd would lose +unconfirmed-spend awareness and any zero-conf-style UX. Final safety in Lightning +still rests on confirmation + CSV/CLTV timeout margins, so this is a +responsiveness/UX degradation rather than a correctness break, but it's a real, +user-visible limitation worth stating plainly rather than glossing over. Revisit +once a v5 mempool exists. + +**Not needed for lnd**: fee estimation — lnd's default fee estimator for the +`btcd` backend is a web API (`chainfee.WebAPIEstimator`), not an RPC call to the +node, so `estimatesmartfee`-equivalent support is not on the critical path. + +## Configuration + +New `[btcd]` section, mirroring `[bitcoind]`'s shape in `parser.cpp`/ +`settings.hpp` (`bind`, `safe`, `cert_auth`, `cert_path`, `key_path`, `key_pass`, +`credential`, `connections`, `inactivity_minutes`, +`expiration_minutes`, `minimum_buffer`, `maximum_request`, `host`, `origin`, +`allow_opaque_origin`). No dedicated `stop`-gating config key: `stop` has no +conditional path at all right now (always `not_implemented`, unconditionally), +so a flag controlling it would be dead configuration. If a real guarded `stop` +path is ever implemented, add the gating key then. + +Default port: `8334` (matches upstream btcd). + +## Code Conventions (apply from the outset, per #795 review feedback) + +The `bitcoind` PR (#795) went through several review rounds with @evoskuil before +merging; apply these proactively on the btcd PR to avoid repeating the same +cycle: + +- No inline (header-implemented) non-template method bodies — put them in + `.cpp` files; keeps build/rebuild times down for a developer-facing lib. +- Protocol-specific json/serialization helpers are protected static methods on + the base protocol class (inherited by subclasses), not free functions in the + `server` namespace. Split into a `_json.cpp` for organization if it grows. +- Pass `hash_cptr` and other small shared_ptr objects as `const&`, never by + value — a copy needlessly bumps the refcount and hits a shared lock. +- Prefer `to_shared(std::move(x))` over manual move-then-construct. +- Use `std::from_chars` (C++17) directly; add an explicit leading-zero guard if + canonical parsing matters (`from_chars` alone accepts `"01"`). +- Arithmetic: `add1()`, `floored_subtract()` — never raw `-`, the store is + concurrent and `top >= height` is not assured — `possible_sign_cast<>`, + `possible_wide_cast<>`, `is_lesser()` over raw casts, + `ceilinged_add()`/`is_overflow()` for additions that could overflow, + `std::next()` instead of pointer arithmetic. +- Cache constants that are invariant for the process lifetime (e.g. genesis + hash lookups) as function-local `static`; use `constexpr base16_hash("...")` + instead of runtime `encode_hash`/string comparisons in hot paths. +- Use the query layer's existing fluent methods instead of re-deriving values: + `get_confirmed_headers` + `get_wire_header` instead of hand-looping + `chain::header` objects, `get_tx_height(height, link)` alone instead of extra + containment checks, the stored context field (single `get_context` query) for + median-time-past instead of recomputing over 11 headers. +- Stream into one pre-sized buffer (`stream::out::fast` + a typed writer) + instead of looping and copying per item. Don't mask a per-item store failure + inside such a loop as "not found" — that's `database::error::integrity` + (`send_internal_server_error`), a different failure class. +- Naming: `send_text()`/`send_json()`/`to_data()`/`to_text()` — "data" means + byte array, "text" means base16 string; avoid "hex"/"bin" abbreviations. +- Pure type→json serialization belongs in **libbitcoin-system** as a + `chain/json/` serializer (see `system/chain/json/block.hpp`+`.cpp`+ + test), not hand-rolled long-term in the protocol layer. File a system-repo PR + if a new btcd-only json shape is needed, rather than keeping it local. +- Tests: Boost.Test only for Unit/Component/Functional tiers, no external + dependencies (no Python) — that's `test/protocols/btcd/`. Data-driven loops + only over genuine external vectors; no line-wrapping; precompute `constexpr` + hashes/expectations at file scope. The Python suite under `endpoints/` is a + separate, optional **acceptance** tier (run against a live compiled node), + not a merge requirement. diff --git a/endpoints/README.md b/endpoints/README.md index 0110e899f..4eb77b4a8 100644 --- a/endpoints/README.md +++ b/endpoints/README.md @@ -41,6 +41,11 @@ bind = 0.0.0.0:8332 bind = [::]:8332 connections = 10 +[btcd] +bind = 0.0.0.0:8334 +bind = [::]:8334 +connections = 10 + [electrum] bind = 0.0.0.0:50001 bind = [::]:50001 @@ -80,6 +85,9 @@ pytest test_native.py # Test only bitcoind RPC pytest test_bitcoind_rpc.py +# Test only btcd JSON-RPC/websocket +pytest test_btcd_rpc.py + # Test only Electrum protocol pytest test_electrum.py ``` @@ -130,6 +138,34 @@ pytest test_bitcoind_rpc.py \ - `--bitcoind-auth` — Enable authentication (cookie file) - `--bitcoind-cookie` — Path to cookie file (default: /mnt/core/.cookie) +### btcd JSON-RPC/WebSocket Options + +```bash +pytest test_btcd_rpc.py \ + --btcd-host=localhost \ + --btcd-port=8334 +``` + +**With basic auth / ws `authenticate` credentials configured:** + +```bash +pytest test_btcd_rpc.py \ + --btcd-username=user \ + --btcd-password=pass +``` + +**Available options:** +- `--btcd-host` — Host for btcd JSON-RPC/websocket (default: localhost) +- `--btcd-port` — Port for btcd JSON-RPC/websocket (default: 8334) +- `--btcd-username` / `--btcd-password` — Credentials to test `authenticate` + against, if the server has `btcd.credential=username:password` configured + (default: none — the credential-specific tests are skipped) + +No extra Python dependency is required: the websocket client is a small +stdlib-only implementation (see `WebSocketConnection` in `test_btcd_rpc.py`), +matching this suite's existing preference for hand-rolled wire protocol over +Electrum's raw TCP handling. + ### Electrum Protocol Options ```bash @@ -169,6 +205,16 @@ pytest test_native.py \ --timeout=30 ``` +- `--run-slow` — Include tests marked `@pytest.mark.slow`: these wait on a + real external event (e.g. `test_blockconnected_notification` waits up to + `--subscription-timeout` for an actual new block) and are skipped by + default so a normal run stays fast. + +```bash +# Include slow/live-event tests, e.g. against a node expecting a block soon +pytest test_btcd_rpc.py --run-slow --subscription-timeout=120 +``` + ## Test Organization ### test_native.py — Native REST @@ -221,6 +267,60 @@ pytest test_bitcoind_rpc.py \ pytest test_bitcoind_rpc.py -k "getblock" ``` +### test_btcd_rpc.py — btcd JSON-RPC/WebSocket Compatibility + +Tests the btcd-compatible endpoint: JSON-RPC 1.0 over a persistent websocket +connection (session/notification/filter/admin extension methods) plus plain +http post (chain methods inherited from `protocol_bitcoind_rpc`, same shape +as `bitcoind`). See `docs/btcd-endpoint.md` for the full design and phase +scope this suite tracks against. + +Unlike the other endpoint suites, this one is explicitly split into two +kinds of test: +- **No `xfail` marker** — asserts real, currently-implemented behavior. A + failure here is a regression. +- **`@pytest.mark.xfail(strict=False)`** — describes intended behavior that + isn't implemented (or not yet reachable over ws) yet. Expected to fail + today; flips to XPASS once implemented, which is the cue to tighten the + assertion and remove the marker. Use `pytest -m xfail -rx` to see the + current development checklist. + +**Coverage:** +- ✅ Session management — `authenticate` (no-op / credential match / credential + mismatch), `session` +- ✅ Block subscription — `notifyblocks`/`stopnotifyblocks` (ack), a real + `blockconnected` push-notification test (positional `[hash, height, time]`, + verified against upstream btcd's actual wire format) +- ✅ Chain methods over http post (positive control — `getbestblockhash`, + `getblockcount`, `getblockhash`, `getblockheader`, `gettxout`, + `getrawtransaction`) +- ✅ Deprecated methods stay rejected — `notifyreceived`, `stopnotifyreceived`, + `notifyspent`, `stopnotifyspent`, `rescan`; `stop` (permanent, no + dev-target xfail — these are guarded against ever silently "working") +- ✅ Response envelope — id echo across requests, unknown-method error + without dropping the connection +- 🚧 (xfail, phase B) Chain methods bridged into the *ws* connection itself +- 🚧 (xfail, phase B) `notifynewtransactions`/`stopnotifynewtransactions`, + `loadtxfilter`, `rescanblocks` +- 🚧 (xfail, phase B/C) `getcurrentnet`, `getdifficulty`, `getinfo`, + `getnettotals`, `getnetworkhashps`, `createrawtransaction`, + `decoderawtransaction`, `decodescript`, `validateaddress`, `help` + +**Example test runs:** + +```bash +pytest test_btcd_rpc.py +pytest test_btcd_rpc.py --btcd-port=8334 +pytest test_btcd_rpc.py -k "not xfail" # only currently-real behavior +pytest test_btcd_rpc.py -m xfail -rx # what's left to implement +BTCD_DEBUG=1 pytest test_btcd_rpc.py -s -k session + +# blockconnected waits for a real block; trigger one instead of waiting out +# the timeout (regtest/testnet), or just let it wait on a synced mainnet node +BTCD_TRIGGER_BLOCK="bitcoin-cli generatetoaddress 1
" \ + pytest test_btcd_rpc.py -k blockconnected --subscription-timeout=120 +``` + ### test_electrum.py — Electrum Protocol Tests Electrum Protocol JSON-RPC over TCP. All assertions are spec-driven — they @@ -508,6 +608,7 @@ Each test module supports a debug environment variable that enables pretty-print | `test_electrum.py` | `ELECTRUM_DEBUG=1` | `>>>` JSON-RPC payload / `<<<` response with elapsed time | | `test_native.py` | `NATIVE_DEBUG=1` | `>>> GET ` / `<<<` response JSON with elapsed time | | `test_bitcoind_rpc.py` | `BITCOIND_DEBUG=1` | `>>>` JSON-RPC payload / `<<<` response with elapsed time | +| `test_btcd_rpc.py` | `BTCD_DEBUG=1` | `>>>` JSON-RPC payload / `<<<` response over websocket | ```bash # Electrum — pretty-print all requests and responses diff --git a/endpoints/conftest.py b/endpoints/conftest.py index f28ce0a7a..ad965d73b 100644 --- a/endpoints/conftest.py +++ b/endpoints/conftest.py @@ -65,6 +65,32 @@ def pytest_addoption(parser): help="Port for bitcoind REST (default: 8332)" ) + # btcd options + parser.addoption( + "--btcd-host", + action="store", + default="localhost", + help="Host for btcd JSON-RPC/websocket (default: localhost)" + ) + parser.addoption( + "--btcd-port", + action="store", + default="8334", + help="Port for btcd JSON-RPC/websocket (default: 8334)" + ) + parser.addoption( + "--btcd-username", + action="store", + default=None, + help="Username for btcd basic auth / ws authenticate (default: none configured)" + ) + parser.addoption( + "--btcd-password", + action="store", + default=None, + help="Password for btcd basic auth / ws authenticate (default: none configured)" + ) + # Electrum options parser.addoption( "--electrum-host", @@ -132,6 +158,30 @@ def pytest_addoption(parser): default="30", help="Default timeout for requests in seconds (default: 30)" ) + parser.addoption( + "--run-slow", + action="store_true", + default=False, + help="Run tests marked @pytest.mark.slow (wait on a real external " + "event, e.g. a live block, up to --subscription-timeout)" + ) + + +def pytest_configure(config): + config.addinivalue_line( + "markers", + "slow: waits on a real external event (e.g. a live block); " + "skipped by default, use --run-slow to include" + ) + + +def pytest_collection_modifyitems(config, items): + if config.getoption("--run-slow"): + return + skip_slow = pytest.mark.skip(reason="needs --run-slow (waits on a real external event)") + for item in items: + if "slow" in item.keywords: + item.add_marker(skip_slow) def pytest_report_header(config): @@ -197,6 +247,19 @@ def bitcoind_rest_config(request): } +@pytest.fixture(scope="session") +def btcd_config(request): + """Configuration for btcd JSON-RPC/websocket tests.""" + return { + "host": request.config.getoption("--btcd-host"), + "port": int(request.config.getoption("--btcd-port")), + "username": request.config.getoption("--btcd-username"), + "password": request.config.getoption("--btcd-password"), + "timeout": float(request.config.getoption("--timeout")), + "subscription_timeout": float(request.config.getoption("--subscription-timeout")), + } + + @pytest.fixture(scope="session") def electrum_config(request): """Configuration for Electrum protocol tests.""" diff --git a/endpoints/test_btcd_rpc.py b/endpoints/test_btcd_rpc.py new file mode 100644 index 000000000..33e84191d --- /dev/null +++ b/endpoints/test_btcd_rpc.py @@ -0,0 +1,583 @@ +""" +Tests for libbitcoin-server btcd JSON-RPC/websocket compatibility interface. + +btcd speaks JSON-RPC 1.0 over a persistent websocket connection (preferred, +required for the session/notification extension methods) or plain HTTP POST +(same request/response shape as the bitcoind endpoint, for the chain methods +inherited from protocol_bitcoind_rpc). See docs/btcd-endpoint.md for the full +design and phased scope. + +This suite is split into what's real today and what's a development target: + + - Tests with no xfail marker assert real, currently-implemented behavior. + A failure here is a regression. + - Tests marked `@pytest.mark.xfail(strict=False)` describe the *intended* + behavior of something not yet implemented (or not yet reachable over + ws). They're expected to fail now. When a method is implemented, the + test starts passing (reported as XPASS, not a hard failure since + strict=False) -- that's the cue to tighten the assertion and drop the + marker. + +Run with: + pytest test_btcd_rpc.py + pytest test_btcd_rpc.py --btcd-host=localhost --btcd-port=8334 + pytest test_btcd_rpc.py -k "not xfail" # only currently-real behavior + pytest test_btcd_rpc.py -m xfail -rx # see what's left to implement +""" + +import base64 +import hashlib +import json +import os +import select +import socket +import struct +import time +import warnings +from typing import Any, Optional + +import pytest +import requests + +from utils import ReferenceData, TestConfig + +_WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + + +# ─── Minimal stdlib websocket client (RFC 6455, text frames only) ───────────── +# No external websocket dependency is added here, matching the rest of this +# suite's preference for hand-rolled wire protocol over a client library (see +# ElectrumConnection in test_electrum_subscriptions.py for the same approach +# applied to Electrum's newline-delimited TCP protocol). + +class WebSocketConnection: + """Bare-bones RFC 6455 client: handshake + masked text frame I/O.""" + + def __init__(self, host: str, port: int, target: str = "/", + connect_timeout: float = 5.0): + self.sock = socket.create_connection((host, port), timeout=connect_timeout) + self.sock.settimeout(None) # blocking; timeouts handled via select() + self._buf = b"" + self._handshake(host, target) + + def _handshake(self, host: str, target: str) -> None: + key = base64.b64encode(os.urandom(16)).decode() + request = ( + f"GET {target} HTTP/1.1\r\n" + f"Host: {host}\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Key: {key}\r\n" + "Sec-WebSocket-Version: 13\r\n" + "\r\n" + ) + self.sock.sendall(request.encode("ascii")) + + headers = self._read_headers() + lines = headers.splitlines() + if not lines or " 101 " not in lines[0]: + raise ConnectionError(f"websocket upgrade rejected: {lines[:1]!r}") + + expected = base64.b64encode( + hashlib.sha1((key + _WS_GUID).encode("ascii")).digest() + ).decode() + accept = None + for line in lines[1:]: + name, _, value = line.partition(":") + if name.strip().lower() == "sec-websocket-accept": + accept = value.strip() + if accept != expected: + raise ConnectionError("websocket handshake accept key mismatch") + + def _read_headers(self) -> str: + while b"\r\n\r\n" not in self._buf: + chunk = self.sock.recv(4096) + if not chunk: + raise ConnectionError("connection closed during ws handshake") + self._buf += chunk + head, _, rest = self._buf.partition(b"\r\n\r\n") + self._buf = rest + return head.decode("iso-8859-1") + + def close(self) -> None: + try: + self.sock.close() + except OSError: + pass + + def _recv_exact(self, size: int, timeout_s: Optional[float]) -> Optional[bytes]: + while len(self._buf) < size: + if timeout_s is not None: + ready, _, _ = select.select([self.sock], [], [], timeout_s) + if not ready: + return None + chunk = self.sock.recv(65536) + if not chunk: + return None + self._buf += chunk + data, self._buf = self._buf[:size], self._buf[size:] + return data + + def send_text(self, message: str) -> None: + payload = message.encode("utf-8") + length = len(payload) + mask_key = os.urandom(4) + masked = bytes(b ^ mask_key[i % 4] for i, b in enumerate(payload)) + + header = bytearray([0x80 | 0x1]) # FIN=1, opcode=text + if length < 126: + header.append(0x80 | length) + elif length < 65536: + header.append(0x80 | 126) + header += struct.pack(">H", length) + else: + header.append(0x80 | 127) + header += struct.pack(">Q", length) + header += mask_key + + self.sock.sendall(bytes(header) + masked) + + def recv_message(self, timeout_s: Optional[float] = None) -> Optional[str]: + """Read one complete (possibly multi-frame) text message.""" + parts: list[bytes] = [] + deadline = time.monotonic() + timeout_s if timeout_s is not None else None + + while True: + remaining = None + if deadline is not None: + remaining = deadline - time.monotonic() + if remaining <= 0: + return None + + head = self._recv_exact(2, remaining) + if head is None: + return None + + fin = bool(head[0] & 0x80) + opcode = head[0] & 0x0F + masked = bool(head[1] & 0x80) + length = head[1] & 0x7F + + if length == 126: + ext = self._recv_exact(2, remaining) + if ext is None: + return None + length = struct.unpack(">H", ext)[0] + elif length == 127: + ext = self._recv_exact(8, remaining) + if ext is None: + return None + length = struct.unpack(">Q", ext)[0] + + mask_key = b"" + if masked: + mask_key = self._recv_exact(4, remaining) + if mask_key is None: + return None + + payload = self._recv_exact(length, remaining) if length else b"" + if payload is None: + return None + if masked: + payload = bytes(b ^ mask_key[i % 4] for i, b in enumerate(payload)) + + if opcode == 0x8: # close + return None + if opcode in (0x9, 0xA): # ping/pong: not a message, keep reading + continue + + parts.append(payload) + if fin: + return b"".join(parts).decode("utf-8", errors="replace") + + +# ─── btcd JSON-RPC 1.0 connection ────────────────────────────────────────────── + +class BtcdConnection: + """A single btcd websocket connection with JSON-RPC 1.0 request/response.""" + + def __init__(self, host: str, port: int, connect_timeout: float): + self.ws = WebSocketConnection(host, port, "/", connect_timeout) + self._next_id = 0 + + def close(self) -> None: + self.ws.close() + + def raw_rpc(self, method: str, params: Optional[list] = None, + timeout_s: float = 10.0) -> dict: + """Send a request and return the full parsed response, error or not.""" + payload = { + "jsonrpc": "1.0", + "id": self._next_id, + "method": method, + "params": params if params is not None else [], + } + self._next_id += 1 + + if os.getenv("BTCD_DEBUG"): + print(">>>", json.dumps(payload, indent=2), flush=True) + + self.ws.send_text(json.dumps(payload)) + raw = self.ws.recv_message(timeout_s) + if raw is None: + pytest.fail(f"{method}: no response (connection closed or timed out)") + + if os.getenv("BTCD_DEBUG"): + print(f"<<< {method}:", raw, flush=True) + + try: + return json.loads(raw) + except json.JSONDecodeError as e: + pytest.fail(f"Invalid JSON from server for {method}: {raw!r} -> {e}") + + def send_rpc(self, method: str, params: Optional[list] = None, + timeout_s: float = 10.0) -> dict: + """Like raw_rpc, but xfail()s on a valid json-rpc error response.""" + data = self.raw_rpc(method, params, timeout_s) + if "error" in data and data["error"] is not None: + pytest.xfail(f"Server sent valid error response: {data['error']}") + return data + + def read_notification(self, timeout_s: float) -> Optional[dict]: + """Read the next server-pushed notification (has 'method', no 'id').""" + deadline = time.monotonic() + timeout_s + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + return None + raw = self.ws.recv_message(remaining) + if raw is None: + return None + try: + obj = json.loads(raw) + except json.JSONDecodeError: + continue + if isinstance(obj, dict) and "method" in obj and "id" not in obj: + return obj + + +def http_rpc(config: dict, method: str, params: Optional[list] = None) -> dict: + """Plain http post json-rpc call to the same btcd endpoint (no ws upgrade). + + The chain methods inherited from protocol_bitcoind_rpc are reachable this + way today even though they aren't yet bridged into the ws dispatcher. + """ + payload = { + "jsonrpc": "2.0", + "id": 0, + "method": method, + "params": params if params is not None else [], + } + url = f"http://{config['host']}:{config['port']}/" + auth = None + if config.get("username"): + auth = (config["username"], config.get("password") or "") + response = requests.post( + url, json=payload, + headers={"Content-Type": "application/json", "Connection": "close"}, + auth=auth, + timeout=config.get("timeout", TestConfig.DEFAULT_RPC_TIMEOUT), + ) + response.raise_for_status() + return response.json() + + +# ─── Fixture ────────────────────────────────────────────────────────────────── + +@pytest.fixture +def conn(btcd_config: dict) -> BtcdConnection: + """Fresh btcd websocket connection per test, authenticated up front if + the server has credentials configured. + + Every method other than 'authenticate' itself is rejected over ws until + the connection has authenticated (see protocol_btcd_rpc::dispatch_ + websocket) -- a real client always does this handshake first, so this + fixture does it here rather than in every single test. + + The authenticate-specific tests (test_authenticate_*) intentionally + don't rely on this and drive their own connections/authenticate calls, + since they're testing that handshake itself. + """ + host, port = btcd_config["host"], btcd_config["port"] + timeout = btcd_config.get("timeout", TestConfig.DEFAULT_SOCKET_TIMEOUT) + try: + c = BtcdConnection(host, port, connect_timeout=timeout) + except (OSError, ConnectionError) as exc: + pytest.skip(f"Cannot connect to btcd at {host}:{port}: {exc}") + + username = btcd_config.get("username") + if username: + auth = c.raw_rpc("authenticate", [username, btcd_config.get("password") or ""]) + if auth.get("error") is not None: + c.close() + pytest.fail(f"authenticate failed in fixture setup: {auth['error']}") + + yield c + c.close() + + +# ═══════════════════════════════════════════════════════════════════════════════ +# SESSION MANAGEMENT (implemented) +# ═══════════════════════════════════════════════════════════════════════════════ + +def test_authenticate_no_credentials_configured(conn, btcd_config): + """authenticate is a no-op success when the server has no configured + username/password, regardless of what's sent.""" + if btcd_config.get("username"): + pytest.skip("server has credentials configured; see " + "test_authenticate_with_configured_credentials") + response = conn.send_rpc("authenticate", ["anyuser", "anypass"]) + assert response.get("error") is None + + +def test_authenticate_with_configured_credentials(conn, btcd_config): + """When btcd.username/password are configured, authenticate must accept + the matching credentials. The mismatch case is covered separately by + test_authenticate_wrong_password_rejected. + + Note: the conn fixture already authenticated once during setup (it must, + to run anything else) -- this repeats the call explicitly to verify + authenticate itself is idempotent and still reports success. + """ + username = btcd_config.get("username") + password = btcd_config.get("password") + if not username: + pytest.skip("--btcd-username not set; server has no configured " + "credential to test against") + + response = conn.send_rpc("authenticate", [username, password]) + assert response.get("error") is None + + +def test_authenticate_wrong_password_rejected(btcd_config): + """A wrong password must be rejected and the connection closed.""" + username = btcd_config.get("username") + if not username: + pytest.skip("--btcd-username not set; nothing to mismatch against") + + host, port = btcd_config["host"], btcd_config["port"] + timeout = btcd_config.get("timeout", TestConfig.DEFAULT_SOCKET_TIMEOUT) + try: + c = BtcdConnection(host, port, connect_timeout=timeout) + except (OSError, ConnectionError) as exc: + pytest.skip(f"Cannot connect to btcd at {host}:{port}: {exc}") + + try: + data = c.raw_rpc("authenticate", [username, "definitely-wrong"]) + assert data.get("error") is not None + finally: + c.close() + + +def test_session_returns_id(conn): + """session returns an object containing an 'id' field (the channel + identifier), usable by clients to detect a reconnect to a fresh server. + """ + response = conn.send_rpc("session") + result = response.get("result") + assert isinstance(result, dict) + assert "id" in result + + +# ═══════════════════════════════════════════════════════════════════════════════ +# BLOCK SUBSCRIPTION (implemented) +# ═══════════════════════════════════════════════════════════════════════════════ + +def test_notifyblocks_acknowledges(conn): + response = conn.send_rpc("notifyblocks") + assert response.get("error") is None + + +def test_stopnotifyblocks_acknowledges(conn): + conn.send_rpc("notifyblocks") + response = conn.send_rpc("stopnotifyblocks") + assert response.get("error") is None + + +@pytest.mark.slow +def test_blockconnected_notification(conn, btcd_config): + """ + Subscribe via notifyblocks and wait for a real blockconnected push. + + Notification format (verified against btcsuite/btcd/btcjson): + {"method": "blockconnected", "params": [hash, height, time]} + + Requires a live node with a new block arriving during the timeout window. + Set BTCD_TRIGGER_BLOCK to a shell command that produces one (e.g. bumps + a regtest node), or just wait out the window on a synced mainnet node. + """ + sub_timeout = btcd_config.get("subscription_timeout", 60.0) + + ack = conn.send_rpc("notifyblocks") + assert ack.get("error") is None + + cmd = os.getenv("BTCD_TRIGGER_BLOCK") + if cmd: + os.system(cmd) + else: + print(f"\n Waiting up to {sub_timeout:.0f}s for a blockconnected " + "notification (set BTCD_TRIGGER_BLOCK to trigger one).", + flush=True) + + notif = conn.read_notification(timeout_s=sub_timeout) + if notif is None: + pytest.skip(f"No blockconnected notification within {sub_timeout:.0f}s. " + "Increase --subscription-timeout or set BTCD_TRIGGER_BLOCK.") + + assert notif.get("method") == "blockconnected" + params = notif.get("params", []) + assert isinstance(params, list) and len(params) == 3, ( + f"blockconnected params must be [hash, height, time], got {params!r}" + ) + block_hash, height, block_time = params + assert isinstance(block_hash, str) and len(block_hash) == 64 + assert isinstance(height, int) and height > 0 + assert isinstance(block_time, int) and block_time > 0 + + +# ═══════════════════════════════════════════════════════════════════════════════ +# STANDARD CHAIN METHODS -- reachable today only via plain http post. +# Development target: bridge these into the ws dispatcher (phase B). +# ═══════════════════════════════════════════════════════════════════════════════ + +CHAIN_METHODS = [ + ("getbestblockhash", []), + ("getblockcount", []), + ("getblockhash", [ReferenceData.GENESIS_HEIGHT]), + ("getblockheader", [ReferenceData.GENESIS_HASH]), + ("gettxout", [ReferenceData.KNOWN_TX_HASH, 0, False]), + ("getrawtransaction", [ReferenceData.FIRST_TX_HASH, 0]), +] + + +@pytest.mark.parametrize("method,params", CHAIN_METHODS) +def test_chain_method_over_http_post(btcd_config, method, params): + """Positive control: the chain logic itself works today, reachable over + plain http post to the same endpoint (unchanged from bitcoind).""" + data = http_rpc(btcd_config, method, params) + if data.get("error") is not None: + pytest.xfail(f"Server sent valid error response: {data['error']}") + assert "result" in data + + +@pytest.mark.xfail(reason="phase B: standard chain methods not yet bridged " + "into the ws dispatcher (see docs/btcd-endpoint.md)", + strict=False) +@pytest.mark.parametrize("method,params", CHAIN_METHODS) +def test_chain_method_over_websocket(conn, method, params): + """Development target: once bridged, chain methods should also work over + the same ws connection used for session/notifyblocks/etc -- this is what + a real lnd/btcwallet client needs (it can't open a second plain-http + connection once it has upgraded to ws).""" + response = conn.send_rpc(method, params) + assert "result" in response + + +# ═══════════════════════════════════════════════════════════════════════════════ +# WIRED BUT NOT YET IMPLEMENTED (phase B/C development targets) +# ═══════════════════════════════════════════════════════════════════════════════ +# These methods are present in interfaces/btcd.hpp's method table today, but +# every handler unconditionally returns not_implemented. Each xfail here is a +# checklist item: implement the handler and the test flips to XPASS. + +NOT_YET_IMPLEMENTED_STUBS = [ + ("notifynewtransactions", [False]), + ("stopnotifynewtransactions", []), + ("loadtxfilter", [False, [], []]), + ("rescanblocks", [[]]), +] + +# Deprecated upstream (superseded by loadtxfilter/rescanblocks) but still +# wired -- included so a regression can't silently start "working" in a way +# that contradicts the deliberate scope decision to leave these stubbed. +DEPRECATED_STUBS = [ + ("notifyreceived", [[]]), + ("stopnotifyreceived", [[]]), + ("notifyspent", [[]]), + ("stopnotifyspent", [[]]), + ("rescan", ["", [""], [""], ""]), +] + + +@pytest.mark.xfail(reason="phase B: wired stub, handler not yet implemented", + strict=False) +@pytest.mark.parametrize("method,params", NOT_YET_IMPLEMENTED_STUBS) +def test_stub_not_yet_implemented(conn, method, params): + data = conn.raw_rpc(method, params) + assert data.get("error") is None, ( + f"{method} still returns not_implemented: {data.get('error')}" + ) + + +@pytest.mark.parametrize("method,params", DEPRECATED_STUBS) +def test_deprecated_method_stays_not_implemented(conn, method, params): + """Regression guard, not a development target: these are deliberately + never implemented (superseded upstream by loadtxfilter/rescanblocks).""" + data = conn.raw_rpc(method, params) + assert data.get("error") is not None + + +def test_stop_always_not_implemented(conn): + """Regression guard, not a development target: no secure remote-shutdown + path exists, so stop is permanently rejected regardless of auth state.""" + data = conn.raw_rpc("stop") + assert data.get("error") is not None + + +# ═══════════════════════════════════════════════════════════════════════════════ +# NOT YET WIRED AT ALL (phase B/C development targets) +# ═══════════════════════════════════════════════════════════════════════════════ +# These aren't in interfaces/btcd.hpp's method table yet, so today they hit +# "unexpected method". Each is a checklist item for the generic-tooling +# compatibility phase (see docs/btcd-endpoint.md, Phase C). + +NOT_YET_WIRED = [ + ("getcurrentnet", []), + ("getdifficulty", []), + ("getinfo", []), + ("getnettotals", []), + ("getnetworkhashps", []), + ("createrawtransaction", [[], {}]), + ("decoderawtransaction", ["00"]), + ("decodescript", [""]), + ("validateaddress", [ReferenceData.EXAMPLE_ADDRESS]), + ("help", []), +] + + +@pytest.mark.xfail(reason="phase B/C: method not yet wired in interfaces/btcd.hpp", + strict=False) +@pytest.mark.parametrize("method,params", NOT_YET_WIRED) +def test_method_not_yet_wired(conn, method, params): + data = conn.raw_rpc(method, params) + assert data.get("error") is None, ( + f"{method} still unwired: {data.get('error')}" + ) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# RESPONSE ENVELOPE / ERROR HANDLING (implemented) +# ═══════════════════════════════════════════════════════════════════════════════ + +def test_response_id_matches_request(conn): + """Each response echoes its own request's id, in order. + + Not asserted as absolute ids 0/1: when credentials are configured the + conn fixture already consumed one id authenticating, so the starting + id here depends on whether that happened -- only the relative order + (each new request's id is exactly one more than the last) is a stable + guarantee. + """ + r0 = conn.send_rpc("session") + r1 = conn.send_rpc("notifyblocks") + assert r1.get("id") == r0.get("id") + 1 + + +def test_unknown_method_errors_without_dropping_connection(conn): + """An unrecognized method must return a json-rpc error and keep the ws + connection open for the next request (verified by the follow-up call).""" + unknown = conn.raw_rpc("nosuchmethod") + assert unknown.get("error") is not None + + follow_up = conn.send_rpc("session") + assert follow_up.get("error") is None diff --git a/endpoints/utils.py b/endpoints/utils.py index affca9444..f828e39dd 100644 --- a/endpoints/utils.py +++ b/endpoints/utils.py @@ -136,6 +136,7 @@ class TestConfig: DEFAULT_BITCOIND_RPC_PORT = 8332 DEFAULT_BITCOIND_REST_PORT = 8332 DEFAULT_ELECTRUM_PORT = 50001 + DEFAULT_BTCD_PORT = 8334 DEFAULT_STRATUM_V1_PORT = 3333 DEFAULT_STRATUM_V2_PORT = 3336 diff --git a/include/bitcoin/server.hpp b/include/bitcoin/server.hpp index 669f2e2ed..5b39999f9 100644 --- a/include/bitcoin/server.hpp +++ b/include/bitcoin/server.hpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -49,6 +50,7 @@ #include #include #include +#include #include #include #include diff --git a/include/bitcoin/server/channels/channel_btcd.hpp b/include/bitcoin/server/channels/channel_btcd.hpp new file mode 100644 index 000000000..f58016707 --- /dev/null +++ b/include/bitcoin/server/channels/channel_btcd.hpp @@ -0,0 +1,129 @@ +/** + * Copyright (c) 2011-2026 libbitcoin developers + * + * 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 . + */ +#ifndef LIBBITCOIN_SERVER_CHANNELS_CHANNEL_BTCD_HPP +#define LIBBITCOIN_SERVER_CHANNELS_CHANNEL_BTCD_HPP + +#include +#include +#include +#include + +namespace libbitcoin { +namespace server { + +class BCS_API channel_btcd + : public server::channel, + public network::channel_http, + protected network::tracker +{ +public: + typedef std::shared_ptr ptr; + + inline channel_btcd(const network::logger& log, + const network::socket::ptr& socket, uint64_t identifier, + const node::configuration& config, const options_t& options) NOEXCEPT + : server::channel(log, socket, identifier, config), + network::channel_http(log, socket, identifier, config.network, options), + network::tracker(log), + options_(options) + { + } + + /// True once the client has authenticated over the ws 'authenticate' + /// handshake (or basic auth was not configured, requiring none). + inline bool authenticated() const NOEXCEPT + { + return authenticated_; + } + + /// Latch the credential digest that satisfied the in-band 'authenticate' + /// call (see protocol_btcd_rpc::handle_authenticate), so that permitted() + /// can later apply that credential's method scoping to ws traffic. + inline void set_authenticated(const system::hash_digest& digest) NOEXCEPT + { + authenticated_ = true; + authenticated_digest_ = digest; + } + + /// True if the credential that authorized this channel permits the rpc + /// method. Plain http traffic defers entirely to channel_http's own + /// header-latched digest (unchanged from bitcoind). For ws traffic, real + /// btcd recognizes two alternative ways a connection can be authorized -- + /// basic auth already established on the ws upgrade request (also + /// channel_http's header-latched digest, since the upgrade request has + /// real headers even though subsequent ws data frames don't), or the + /// in-band 'authenticate' call (digest latched above); its own docs + /// describe 'authenticate' as "disallowed when basic auth has already + /// been established" -- i.e. an alternative to it, not an additional + /// requirement on top of it. + inline bool permitted(const std::string& method) const NOEXCEPT override + { + if (!websocket()) + return network::channel_http::permitted(method); + + if (!options_.authorize()) + return true; + + if (network::channel_http::permitted(method)) + return true; + + return authenticated_ && options_.permitted(authenticated_digest_, method); + } + +protected: + using value_type = network::http::body::value_type; + + /// Preselect the json-rpc request parser for websocket frames, so that + /// each btcd ws message is parsed the same way as an http post body. + inline value_type websocket_body() const NOEXCEPT override + { + // There is no forwarding constructor so assign and move. + network::http::body::value_type value{}; + value = network::rpc::request{}; + return value; + } + + /// channel_http's authorized() only latches basic-auth from the ws + /// upgrade request (the upgrade request has real headers); subsequent ws + /// data frames are synthesized and structurally cannot carry a + /// per-frame Authorization header. Deferring to the base check here + /// would therefore reject every ws frame once a credential is + /// configured, unless basic auth happened to be presented on the + /// upgrade itself -- permitted() (above) is the real per-method gate for + /// ws traffic, so this base check must never reject a ws frame on its + /// own. The plain http post path (e.g. inherited bitcoind-compatible + /// chain methods) keeps the normal per-request enforcement. + inline bool authorized() const NOEXCEPT override + { + return websocket() ? true : network::channel_http::authorized(); + } + +private: + // This is thread safe. + const options_t& options_; + + // These are protected by strand. + bool authenticated_{}; + system::hash_digest authenticated_digest_{}; +}; + +} // namespace server +} // namespace libbitcoin + +#endif diff --git a/include/bitcoin/server/channels/channels.hpp b/include/bitcoin/server/channels/channels.hpp index 0a3cf0d6a..8698a76f9 100644 --- a/include/bitcoin/server/channels/channels.hpp +++ b/include/bitcoin/server/channels/channels.hpp @@ -20,6 +20,7 @@ #define LIBBITCOIN_SERVER_CHANNELS_CHANNELS_HPP #include +#include #include #include #include @@ -39,6 +40,7 @@ network::channel │ └── [server::channel_electrum] ├── channel_http │ ├── [server::channel_http] +│ └── [server::channel_btcd] └── channel_peer └── [node::channel_peer] @@ -51,5 +53,6 @@ server::channel → node::channel ├── channel_stratum_v1 → network::channel_rpc ├── channel_electrum → network::channel_rpc ├── channel_http → network::channel_http +├── channel_btcd → network::channel_http */ diff --git a/include/bitcoin/server/interfaces/btcd.hpp b/include/bitcoin/server/interfaces/btcd.hpp index 616cb9055..989926c83 100644 --- a/include/bitcoin/server/interfaces/btcd.hpp +++ b/include/bitcoin/server/interfaces/btcd.hpp @@ -49,6 +49,9 @@ struct btcd_methods method<"loadtxfilter", boolean_t, value_t, value_t>{ "reload", "addresses", "outpoints" }, method<"rescanblocks", value_t>{ "blockhashes" }, + /// Admin (not_implemented; no secure remote-shutdown path exists). + method<"stop">{}, + /// Deprecated (address/outpoint filtering). method<"notifyreceived", value_t>{ "addresses" }, method<"stopnotifyreceived", value_t>{ "addresses" }, @@ -72,11 +75,12 @@ struct btcd_methods using stop_notify_new_transactions = at<5>; using load_tx_filter = at<6>; using rescan_blocks = at<7>; - using notify_received = at<8>; - using stop_notify_received = at<9>; - using notify_spent = at<10>; - using stop_notify_spent = at<11>; - using rescan = at<12>; + using stop = at<8>; + using notify_received = at<9>; + using stop_notify_received = at<10>; + using notify_spent = at<11>; + using stop_notify_spent = at<12>; + using rescan = at<13>; }; } // namespace interface diff --git a/include/bitcoin/server/protocols/protocol_btcd_rpc.hpp b/include/bitcoin/server/protocols/protocol_btcd_rpc.hpp new file mode 100644 index 000000000..dc52d5618 --- /dev/null +++ b/include/bitcoin/server/protocols/protocol_btcd_rpc.hpp @@ -0,0 +1,195 @@ +/** + * Copyright (c) 2011-2026 libbitcoin developers + * + * 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 . + */ +#ifndef LIBBITCOIN_SERVER_PROTOCOLS_PROTOCOL_BTCD_RPC_HPP +#define LIBBITCOIN_SERVER_PROTOCOLS_PROTOCOL_BTCD_RPC_HPP + +#include +#include +#include +#include +#include +#include + +namespace libbitcoin { +namespace server { + +/// btcd-compatible json-rpc-v1 over websocket. Inherits the standard chain +/// handlers (getblockcount, getblock, etc.) from protocol_bitcoind_rpc, which +/// remain reachable via the inherited http post path (unchanged). Adds the +/// btcd-only extension methods (session/notification/filter/admin) over +/// websocket via a second dispatcher (dispatch_websocket, below). +/// +/// Attaches immediately on connect (session_btcd = session_server< +/// protocol_btcd_rpc>, no separate handshake protocol/stage) -- matching real +/// btcd's own model, which has no handshake either: HTTP Basic Auth is +/// checked synchronously per plain-http request (channel_http::authorized(), +/// unchanged from bitcoind), and ws 'authenticate' is just an ordinary +/// extension method that lets a client establish the same authorization +/// in-band, for the one transport (websocket data frames) that structurally +/// cannot carry a per-message Authorization header. See docs/btcd-endpoint.md +/// for why an earlier session_handshake +/// design (modeled on electrum/p2p's version negotiation) was reverted: those +/// protocols' handshakes always wait for a real message to complete, but +/// btcd's has no credential-required case to wait for -- modeling it on that +/// pattern introduced an async attach gap real btcd never has, which a plain +/// http client (no upgrade round-trip to mask the gap, unlike ws) could land +/// in before this class ever subscribed its handlers. +/// +/// TODO (phase B): bridge the standard chain handlers into the ws dispatcher +/// too, so a single ws connection can also reach them (real btcd/btcwallet +/// clients expect this); requires reconciling protocol_bitcoind_rpc's +/// post-oriented private send path with this class's ws-oriented senders. +class BCS_API protocol_btcd_rpc + : public server::protocol_bitcoind_rpc, + protected network::tracker +{ +public: + // Replace channel_t so session_server creates channel_btcd instances. + using channel_t = channel_btcd; + + typedef std::shared_ptr ptr; + using btcd_interface = interface::btcd; + using btcd_dispatcher = network::rpc::dispatcher; + + inline protocol_btcd_rpc(const auto& session, + const network::channel::ptr& channel, + const options_t& options) NOEXCEPT + : server::protocol_bitcoind_rpc(session, channel, options), + network::tracker(session->log), + options_(options), + btcd_channel_(std::dynamic_pointer_cast(channel)) + { + } + + void start() NOEXCEPT override; + void stopping(const code& ec) NOEXCEPT override; + +protected: + /// Dispatch btcd ws frames (standard chain requests dispatch normally + /// over http post via the inherited handle_receive_post). + void dispatch_websocket( + const network::http::request& request) NOEXCEPT override; + + /// Handlers (session/handshake). 'authenticate' is a real, ordinary + /// extension method here (not a separate handshake stage, see class + /// comment): it hashes the given credential the same way + /// config::credential does, checks it via options_.authorized(), and on + /// success latches the digest onto channel_btcd so permitted()'s method + /// scoping can apply to subsequent ws calls. interface::btcd's method + /// table has no notion of "not yet authenticated", so this always runs + /// regardless of current state -- matches real btcd's own tolerance of a + /// repeat authenticate call (only disallowed once basic auth has already + /// been established on the ws upgrade, not enforced here since that + /// case is harmless to allow). A failed attempt ends the session + /// (dispatch_websocket sends the error before stopping), matching real + /// btcd's documented behavior of closing the connection on invalid + /// credentials rather than leaving it open for a retry. + bool handle_authenticate(const code& ec, btcd_interface::authenticate, + const std::string& username, const std::string& password) NOEXCEPT; + bool handle_session(const code& ec, btcd_interface::session) NOEXCEPT; + + /// Handlers (block subscription). + bool handle_notify_blocks(const code& ec, + btcd_interface::notify_blocks) NOEXCEPT; + bool handle_stop_notify_blocks(const code& ec, + btcd_interface::stop_notify_blocks) NOEXCEPT; + + /// Handlers (mempool subscription, not_implemented pending v5 mempool). + bool handle_notify_new_transactions(const code& ec, + btcd_interface::notify_new_transactions, bool verbose) NOEXCEPT; + bool handle_stop_notify_new_transactions(const code& ec, + btcd_interface::stop_notify_new_transactions) NOEXCEPT; + + /// Handlers (address/outpoint filtering, not_implemented pending phase B). + bool handle_load_tx_filter(const code& ec, + btcd_interface::load_tx_filter, bool reload, + const network::rpc::value_t& addresses, + const network::rpc::value_t& outpoints) NOEXCEPT; + bool handle_rescan_blocks(const code& ec, + btcd_interface::rescan_blocks, + const network::rpc::value_t& blockhashes) NOEXCEPT; + + /// Handler (admin, permanently not_implemented). + bool handle_stop(const code& ec, btcd_interface::stop) NOEXCEPT; + + /// Handlers (deprecated, permanently not_implemented). + bool handle_notify_received(const code& ec, + btcd_interface::notify_received, + const network::rpc::value_t& addresses) NOEXCEPT; + bool handle_stop_notify_received(const code& ec, + btcd_interface::stop_notify_received, + const network::rpc::value_t& addresses) NOEXCEPT; + bool handle_notify_spent(const code& ec, + btcd_interface::notify_spent, + const network::rpc::value_t& outpoints) NOEXCEPT; + bool handle_stop_notify_spent(const code& ec, + btcd_interface::stop_notify_spent, + const network::rpc::value_t& outpoints) NOEXCEPT; + bool handle_rescan(const code& ec, btcd_interface::rescan, + const std::string& beginblock, const network::rpc::value_t& addresses, + const network::rpc::value_t& outpoints, + const std::string& endblock) NOEXCEPT; + + /// Chase event subscription (block connect/disconnect for notifyblocks). + bool handle_chase(const code& ec, node::chase event_, + node::event_value value) NOEXCEPT; + void do_block_connected(node::header_t link) NOEXCEPT; + void do_block_disconnected(node::header_t link) NOEXCEPT; + + /// Senders (btcd ws envelope, distinct id/version cache from the http + /// json-rpc-v2 senders inherited from protocol_bitcoind_rpc). The + /// close_reason overloads complete via handle_complete's (ec, reason) + /// idiom: the channel only stops (if reason is truthy) once the write + /// has actually completed, so a failed authenticate's error reaches the + /// client before the connection closes. + void send_btcd_result(network::rpc::value_option&& result, + size_t size_hint) NOEXCEPT; + void send_btcd_error(const code& ec) NOEXCEPT; + void send_btcd_error(const code& ec, size_t size_hint) NOEXCEPT; + void send_btcd_error(const code& ec, size_t size_hint, + const code& close_reason) NOEXCEPT; + void send_btcd_notification(const std::string& method, + network::rpc::array_t&& params, size_t size_hint) NOEXCEPT; + +private: + template + inline void btcd_subscribe(Method&& method, Args&&... args) NOEXCEPT + { + btcd_dispatcher_.subscribe(BIND_SHARED(method, args)); + } + + // Senders. + void send_btcd_rpc(network::rpc::response_t&& model, size_t size_hint, + const code& close_reason) NOEXCEPT; + + // These are thread safe. + std::atomic_bool subscribed_blocks_{}; + const options_t& options_; + const channel_btcd::ptr btcd_channel_; + + // These are protected by strand. + btcd_dispatcher btcd_dispatcher_{}; + network::rpc::version btcd_version_{}; + network::rpc::id_option btcd_id_{}; +}; + +} // namespace server +} // namespace libbitcoin + +#endif diff --git a/include/bitcoin/server/protocols/protocols.hpp b/include/bitcoin/server/protocols/protocols.hpp index 5a7242544..0f4d6bc9f 100644 --- a/include/bitcoin/server/protocols/protocols.hpp +++ b/include/bitcoin/server/protocols/protocols.hpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -49,6 +50,7 @@ network::protocol │ └── [server::protocol_rpc] ├── protocol_http │ ├── [server::protocol_bitcoind_rpc] +│ │ └── [server::protocol_btcd_rpc] │ └── [server::protocol_http] └── protocol_peer ├── [node::protocol_peer] @@ -93,6 +95,7 @@ server::protocol → node::protocol │ ├── protocol_admin │ └── protocol_native └── protocol_bitcoind_rpc - └── protocol_bitcoind_rest + ├── protocol_bitcoind_rest + └── protocol_btcd_rpc */ diff --git a/include/bitcoin/server/server_node.hpp b/include/bitcoin/server/server_node.hpp index 604783c28..de89f39c6 100644 --- a/include/bitcoin/server/server_node.hpp +++ b/include/bitcoin/server/server_node.hpp @@ -72,6 +72,7 @@ class BCS_API server_node virtual session_admin::ptr attach_admin_session() NOEXCEPT; virtual session_native::ptr attach_native_session() NOEXCEPT; virtual session_bitcoind::ptr attach_bitcoind_session() NOEXCEPT; + virtual session_btcd::ptr attach_btcd_session() NOEXCEPT; virtual session_electrum::ptr attach_electrum_session() NOEXCEPT; virtual session_stratum_v1::ptr attach_stratum_v1_session() NOEXCEPT; virtual session_stratum_v2::ptr attach_stratum_v2_session() NOEXCEPT; @@ -84,6 +85,7 @@ class BCS_API server_node void start_admin(const code& ec, const result_handler& handler) NOEXCEPT; void start_native(const code& ec, const result_handler& handler) NOEXCEPT; void start_bitcoind(const code& ec, const result_handler& handler) NOEXCEPT; + void start_btcd(const code& ec, const result_handler& handler) NOEXCEPT; void start_electrum(const code& ec, const result_handler& handler) NOEXCEPT; void start_stratum_v1(const code& ec, const result_handler& handler) NOEXCEPT; void start_stratum_v2(const code& ec, const result_handler& handler) NOEXCEPT; diff --git a/include/bitcoin/server/sessions/sessions.hpp b/include/bitcoin/server/sessions/sessions.hpp index 8f6a90b5b..5c96c7cab 100644 --- a/include/bitcoin/server/sessions/sessions.hpp +++ b/include/bitcoin/server/sessions/sessions.hpp @@ -37,6 +37,15 @@ using session_stratum_v2 = session_server; using session_electrum = session_handshake; +// No session_handshake here: unlike electrum/p2p, btcd has no negotiation to +// wait for -- real btcd authenticates via plain HTTP Basic Auth (checked +// synchronously per request, same as bitcoind) or an in-band ws +// 'authenticate' method that is just an ordinary extension method, not a +// separate handshake stage (see protocol_btcd_rpc's class comment and +// docs/btcd-endpoint.md for why session_handshake was tried and reverted). +using session_btcd = session_server; + } // namespace server } // namespace libbitcoin @@ -71,11 +80,15 @@ node::session server::session → node::session └── server::session_server<...Protocols> → network::session_server - ╞══ session_admin = server::session_server + ╞══ session_admin = server::session_server ╞══ session_native = server::session_server ╞══ session_bitcoind = server::session_server ╞══ session_stratum_v1 = server::session_server ╞══ session_stratum_v2 = server::session_server + ╞══ session_btcd = server::session_server + │ (no handshake stage -- real btcd has no negotiation to wait + │ for; auth is a synchronous per-request/per-connection check, + │ same shape as bitcoind, not a separate protocol) └── server::session_handshake<...Protocols> ╘══ session_electrum = server::session_handshake< protocol_electrum_version, protocol_electrum> diff --git a/include/bitcoin/server/settings.hpp b/include/bitcoin/server/settings.hpp index 985b3367e..e27438486 100644 --- a/include/bitcoin/server/settings.hpp +++ b/include/bitcoin/server/settings.hpp @@ -167,6 +167,9 @@ class BCS_API settings /// bitcoind compat interface (http/s, stateless json-rpc-v2) network::settings::http_server bitcoind{ "bitcoind" }; + /// btcd compat interface (http/s + websocket, json-rpc-v1) + network::settings::http_server btcd{ "btcd" }; + /// electrum compat interface (tcp/s, json-rpc-v2) electrum_server electrum{ "electrum" }; diff --git a/src/parser.cpp b/src/parser.cpp index 4647e12e1..04e7039eb 100644 --- a/src/parser.cpp +++ b/src/parser.cpp @@ -1093,6 +1093,88 @@ options_metadata parser::load_settings() THROWS "Allow requests from opaque origin (see CORS), multiple allowed, defaults to false." ) + /* [btcd] */ + ( + "btcd.bind", + value(&configured.server.btcd.binds), + "IP address to bind, multiple allowed, defaults to empty (disabled)." + ) + ( + "btcd.safe", + value(&configured.server.btcd.safes), + "IP address to secure bind, multiple allowed, defaults to empty (disabled)." + ) + ( + "btcd.cert_auth", + value(&configured.server.btcd.cert_auth), + "The certificate authority directory (*.PEM), enables client authentication." + ) + ( + "btcd.cert_path", + value(&configured.server.btcd.cert_path), + "The path to the server certificate file (.PEM), defaults to unused." + ) + ( + "btcd.key_path", + value(&configured.server.btcd.key_path), + "The path to the server private key file (.PEM), defaults to unused." + ) + ( + "btcd.key_pass", + value(&configured.server.btcd.key_pass), + "The password to decrypt the server private key file (.PEM), optional." + ) + ( + "btcd.credential", + value(&configured.server.btcd.credentials), + "The 'username:password[:method,...]' authorization (not secure), also used for the ws 'authenticate' method, multiple allowed." + ) + ( + "btcd.connections", + value(&configured.server.btcd.connections), + "The required maximum number of connections, defaults to '0'." + ) + ( + "btcd.inactivity_minutes", + value(&configured.server.btcd.inactivity_minutes), + "The idle timeout (http/ws keep-alive), defaults to '10'." + ) + ( + "btcd.expiration_minutes", + value(&configured.server.btcd.expiration_minutes), + "The idle timeout (http/ws keep-alive), defaults to '60'." + ) + ( + "btcd.minimum_buffer", + value(&configured.server.btcd.minimum_buffer), + "The minimum retained read buffer size, defaults to '4000000'." + ) + ( + "btcd.maximum_request", + value(&configured.server.btcd.maximum_request), + "The maximum allowed request size, defaults to '4000000'." + ) + ( + "btcd.server", + value(&configured.server.btcd.server), + "The server name (http header), defaults to '" BC_HTTP_SERVER_NAME "'." + ) + ( + "btcd.host", + value(&configured.server.btcd.hosts), + "The host name (http verification), multiple allowed, defaults to empty (disabled)." + ) + ( + "btcd.origin", + value(&configured.server.btcd.origins), + "The allowed origin (see CORS), multiple allowed, defaults to empty (disabled)." + ) + ( + "btcd.allow_opaque_origin", + value(&configured.server.btcd.allow_opaque_origin), + "Allow requests from opaque origin (see CORS), multiple allowed, defaults to false." + ) + /* [electrum] */ ( "electrum.bind", diff --git a/src/protocols/btcd/protocol_btcd_rpc.cpp b/src/protocols/btcd/protocol_btcd_rpc.cpp new file mode 100644 index 000000000..11721079c --- /dev/null +++ b/src/protocols/btcd/protocol_btcd_rpc.cpp @@ -0,0 +1,508 @@ +/** + * Copyright (c) 2011-2026 libbitcoin developers + * + * 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 + +namespace libbitcoin { +namespace server { + +#define CLASS protocol_btcd_rpc +#define SUBSCRIBE_BTCD(method, ...) \ + btcd_subscribe(&CLASS::method, __VA_ARGS__) + +// protocol_bitcoind_rpc declares 'using post = network::http::method::post', +// which shadows network::protocol::post. Qualify explicitly. +#define POST_BTCD(method, ...) \ + this->network::protocol::template post(&CLASS::method, __VA_ARGS__) + +using namespace system; +using namespace network; +using namespace network::rpc; +using namespace std::placeholders; +using namespace boost::json; + +BC_PUSH_WARNING(NO_THROW_IN_NOEXCEPT) +BC_PUSH_WARNING(SMART_PTR_NOT_NEEDED) +BC_PUSH_WARNING(NO_VALUE_OR_CONST_REF_SHARED_PTR) + +// Start. +// ---------------------------------------------------------------------------- + +void protocol_btcd_rpc::start() NOEXCEPT +{ + BC_ASSERT(stranded()); + + if (started()) + return; + + subscribe_chase(BIND(handle_chase, _1, _2, _3)); + + SUBSCRIBE_BTCD(handle_authenticate, _1, _2, _3, _4); + SUBSCRIBE_BTCD(handle_session, _1, _2); + SUBSCRIBE_BTCD(handle_notify_blocks, _1, _2); + SUBSCRIBE_BTCD(handle_stop_notify_blocks, _1, _2); + SUBSCRIBE_BTCD(handle_notify_new_transactions, _1, _2, _3); + SUBSCRIBE_BTCD(handle_stop_notify_new_transactions, _1, _2); + SUBSCRIBE_BTCD(handle_load_tx_filter, _1, _2, _3, _4, _5); + SUBSCRIBE_BTCD(handle_rescan_blocks, _1, _2, _3); + SUBSCRIBE_BTCD(handle_stop, _1, _2); + SUBSCRIBE_BTCD(handle_notify_received, _1, _2, _3); + SUBSCRIBE_BTCD(handle_stop_notify_received, _1, _2, _3); + SUBSCRIBE_BTCD(handle_notify_spent, _1, _2, _3); + SUBSCRIBE_BTCD(handle_stop_notify_spent, _1, _2, _3); + SUBSCRIBE_BTCD(handle_rescan, _1, _2, _3, _4, _5, _6); + + // Base registers all standard chain rpc handlers and starts the listener. + protocol_bitcoind_rpc::start(); +} + +void protocol_btcd_rpc::stopping(const code& ec) NOEXCEPT +{ + BC_ASSERT(stranded()); + btcd_dispatcher_.stop(ec); + unsubscribe_chase(); + protocol_bitcoind_rpc::stopping(ec); +} + +// Websocket dispatch. +// ---------------------------------------------------------------------------- +// btcd extension methods (session/notify/filter/admin, including +// authenticate) arrive as ws frames here. Standard chain methods +// (getblockcount etc.) remain reachable via plain http post on the same +// endpoint, unchanged, via the inherited handle_receive_post. They are NOT +// yet bridged into this ws dispatcher: that requires reconciling +// protocol_bitcoind_rpc's post-oriented private send path (which caches the +// original http::request for header derivation) with this class's +// ws-oriented senders. Tracked for phase B. +// +// A credential may be scoped to a subset of methods (see config::credential +// / channel_btcd::permitted()), so each call is checked against that scope -- +// except 'authenticate' itself, which must always reach handle_authenticate +// regardless of current auth state: permitted() requires authentication to +// already be established, so gating 'authenticate' on it would make +// authenticating impossible in the first place. + +void protocol_btcd_rpc::dispatch_websocket( + const network::http::request& request) NOEXCEPT +{ + BC_ASSERT(stranded()); + + // channel_btcd::websocket_body() preselects the json-rpc request parser. + if (!request.body().contains()) + { + send_btcd_error(error::invalid_argument); + return; + } + + const auto& body = request.body().get(); + const auto& message = body.message; + + // Cache request context for response building (version + id). + btcd_version_ = message.jsonrpc; + btcd_id_ = message.id; + + if (message.method != btcd_interface::authenticate::name && + !permitted(message.method)) + { + send_btcd_error(network::error::unauthorized); + return; + } + + const auto code = btcd_dispatcher_.notify(message); + if (!code) + return; + + // Unknown method: reply with a json-rpc error and keep the ws connection + // alive (matches real btcd behavior, and the same guard already applied + // to protocol_bitcoind_rpc::handle_receive_post for its own dispatcher). + if (code == network::error::unexpected_method) + send_btcd_error(code); + else + stop(code); +} + +// Handlers (session/handshake). +// ---------------------------------------------------------------------------- + +bool protocol_btcd_rpc::handle_authenticate(const code& ec, + btcd_interface::authenticate, const std::string& username, + const std::string& password) NOEXCEPT +{ + if (stopped(ec)) + return false; + + // No credential configured: nothing to check against, so a no-op + // success (matches real btcd's own no-auth-required mode -- there is no + // "wrong" answer when authorization was never required in the first + // place). + if (!options_.authorize()) + { + send_btcd_result({}, 4); + return true; + } + + // Same digest scheme as config::credential (settings::http_server has no + // direct username:password lookup, only digest-keyed authorized()). + const auto digest = sha256_hash( + "Basic " + encode_base64(username + ":" + password)); + + if (options_.authorized(digest)) + { + btcd_channel_->set_authenticated(digest); + send_btcd_result({}, 4); + return true; + } + + // A failed authenticate ends the session (matches real btcd: invalid + // credentials close the connection rather than leave it open for a + // retry) -- but only once the error has actually reached the client. + const code unauthorized{ network::error::unauthorized }; + send_btcd_error(unauthorized, two * unauthorized.message().size(), + unauthorized); + return true; +} + +bool protocol_btcd_rpc::handle_session(const code& ec, + btcd_interface::session) NOEXCEPT +{ + if (stopped(ec)) + return false; + + // The channel identifier is unique per connection for the process + // lifetime, satisfying btcd's reconnect-detection use of session ids. + object_t result{}; + result.emplace("id", value_t{ possible_sign_cast(identifier()) }); + send_btcd_result(value_t{ std::move(result) }, 32); + return true; +} + +// Handlers (block subscription). +// ---------------------------------------------------------------------------- + +bool protocol_btcd_rpc::handle_notify_blocks(const code& ec, + btcd_interface::notify_blocks) NOEXCEPT +{ + if (stopped(ec)) + return false; + + subscribed_blocks_.store(true, std::memory_order_relaxed); + send_btcd_result({}, 4); + return true; +} + +bool protocol_btcd_rpc::handle_stop_notify_blocks(const code& ec, + btcd_interface::stop_notify_blocks) NOEXCEPT +{ + if (stopped(ec)) + return false; + + subscribed_blocks_.store(false, std::memory_order_relaxed); + send_btcd_result({}, 4); + return true; +} + +// Handlers (mempool subscription, not_implemented pending v5 mempool). +// ---------------------------------------------------------------------------- + +bool protocol_btcd_rpc::handle_notify_new_transactions(const code& ec, + btcd_interface::notify_new_transactions, bool) NOEXCEPT +{ + if (stopped(ec)) return false; + send_btcd_error(error::not_implemented); + return true; +} + +bool protocol_btcd_rpc::handle_stop_notify_new_transactions(const code& ec, + btcd_interface::stop_notify_new_transactions) NOEXCEPT +{ + if (stopped(ec)) return false; + send_btcd_error(error::not_implemented); + return true; +} + +// Handlers (address/outpoint filtering, not_implemented pending phase B). +// ---------------------------------------------------------------------------- + +bool protocol_btcd_rpc::handle_load_tx_filter(const code& ec, + btcd_interface::load_tx_filter, bool, const value_t&, + const value_t&) NOEXCEPT +{ + if (stopped(ec)) return false; + send_btcd_error(error::not_implemented); + return true; +} + +bool protocol_btcd_rpc::handle_rescan_blocks(const code& ec, + btcd_interface::rescan_blocks, const value_t&) NOEXCEPT +{ + if (stopped(ec)) return false; + send_btcd_error(error::not_implemented); + return true; +} + +// Handler (admin, permanently not_implemented). +// ---------------------------------------------------------------------------- + +bool protocol_btcd_rpc::handle_stop(const code& ec, + btcd_interface::stop) NOEXCEPT +{ + if (stopped(ec)) return false; + + // No secure remote-shutdown path exists; always rejected regardless of + // configuration or auth state. + send_btcd_error(error::not_implemented); + return true; +} + +// Handlers (deprecated, permanently not_implemented). +// ---------------------------------------------------------------------------- + +bool protocol_btcd_rpc::handle_notify_received(const code& ec, + btcd_interface::notify_received, const value_t&) NOEXCEPT +{ + if (stopped(ec)) return false; + send_btcd_error(error::not_implemented); + return true; +} + +bool protocol_btcd_rpc::handle_stop_notify_received(const code& ec, + btcd_interface::stop_notify_received, const value_t&) NOEXCEPT +{ + if (stopped(ec)) return false; + send_btcd_error(error::not_implemented); + return true; +} + +bool protocol_btcd_rpc::handle_notify_spent(const code& ec, + btcd_interface::notify_spent, const value_t&) NOEXCEPT +{ + if (stopped(ec)) return false; + send_btcd_error(error::not_implemented); + return true; +} + +bool protocol_btcd_rpc::handle_stop_notify_spent(const code& ec, + btcd_interface::stop_notify_spent, const value_t&) NOEXCEPT +{ + if (stopped(ec)) return false; + send_btcd_error(error::not_implemented); + return true; +} + +bool protocol_btcd_rpc::handle_rescan(const code& ec, + btcd_interface::rescan, const std::string&, const value_t&, + const value_t&, const std::string&) NOEXCEPT +{ + if (stopped(ec)) return false; + send_btcd_error(error::not_implemented); + return true; +} + +// Chase events. +// ---------------------------------------------------------------------------- + +bool protocol_btcd_rpc::handle_chase(const code& ec, node::chase event_, + node::event_value value) NOEXCEPT +{ + // Do not pass ec to stopped, it is not a call status. + if (stopped()) + return false; + + switch (event_) + { + case node::chase::organized: + if (subscribed_blocks_.load(std::memory_order_relaxed)) + { + BC_ASSERT(std::holds_alternative(value)); + POST_BTCD(do_block_connected, std::get(value)); + } + break; + + case node::chase::reorganized: + if (subscribed_blocks_.load(std::memory_order_relaxed)) + { + BC_ASSERT(std::holds_alternative(value)); + POST_BTCD(do_block_disconnected, std::get(value)); + } + break; + + default: + break; + } + + return !stopped(); +} + +void protocol_btcd_rpc::do_block_connected(node::header_t link_value) NOEXCEPT +{ + BC_ASSERT(stranded()); + + if (stopped() || !subscribed_blocks_.load(std::memory_order_relaxed)) + return; + + const database::header_link link{ link_value }; + const auto& query = archive(); + + size_t height{}; + if (!query.get_height(height, link)) + return; + + const auto header = query.get_header(link); + if (!header) + return; + + // btcd 'blockconnected' notification: [hash, height, time] (positional, + // matching btcjson.BlockConnectedNtfn -- the unfiltered notification + // fired by notifyblocks, distinct from loadtxfilter's filtered variant). + array_t params{}; + params.emplace_back(value_t{ encode_hash(query.get_header_key(link)) }); + params.emplace_back(value_t{ possible_sign_cast(height) }); + params.emplace_back(value_t{ possible_sign_cast( + header->timestamp()) }); + + send_btcd_notification("blockconnected", std::move(params), 256); +} + +void protocol_btcd_rpc::do_block_disconnected( + node::header_t link_value) NOEXCEPT +{ + BC_ASSERT(stranded()); + + if (stopped() || !subscribed_blocks_.load(std::memory_order_relaxed)) + return; + + const database::header_link link{ link_value }; + const auto& query = archive(); + + size_t height{}; + if (!query.get_height(height, link)) + return; + + const auto header = query.get_header(link); + if (!header) + return; + + array_t params{}; + params.emplace_back(value_t{ encode_hash(query.get_header_key(link)) }); + params.emplace_back(value_t{ possible_sign_cast(height) }); + params.emplace_back(value_t{ possible_sign_cast( + header->timestamp()) }); + + send_btcd_notification("blockdisconnected", std::move(params), 256); +} + +// Senders. +// ---------------------------------------------------------------------------- + +void protocol_btcd_rpc::send_btcd_result(value_option&& result, + size_t size_hint) NOEXCEPT +{ + BC_ASSERT(stranded()); + send_btcd_rpc( + { + .jsonrpc = btcd_version_, + .id = btcd_id_, + .result = std::move(result) + }, size_hint, error::success); +} + +void protocol_btcd_rpc::send_btcd_error(const code& ec) NOEXCEPT +{ + send_btcd_error(ec, two * ec.message().size()); +} + +void protocol_btcd_rpc::send_btcd_error(const code& ec, + size_t size_hint) NOEXCEPT +{ + send_btcd_error(ec, size_hint, error::success); +} + +void protocol_btcd_rpc::send_btcd_error(const code& ec, size_t size_hint, + const code& close_reason) NOEXCEPT +{ + BC_ASSERT(stranded()); + send_btcd_rpc( + { + .jsonrpc = btcd_version_, + .id = btcd_id_, + .error = result_t + { + .code = ec.value(), + .message = ec.message() + } + }, size_hint, close_reason); +} + +void protocol_btcd_rpc::send_btcd_notification(const std::string& method, + array_t&& params, size_t size_hint) NOEXCEPT +{ + BC_ASSERT(stranded()); + using namespace network::http; + static const auto json = from_media_type(media_type::application_json); + + // Server-push notification: json-rpc-v1 request shape, no id. + rpc::request notification{ { .size_hint = size_hint } }; + notification.message.jsonrpc = version::v1; + notification.message.method = method; + notification.message.params = params_t{ std::move(params) }; + + http::response message{ status::ok, 11 }; + message.set(field::content_type, json); + message.body() = std::move(notification); + message.prepare_payload(); + + // NOTIFY does not restart the reader (the ws reader is already active, + // independent of this unprompted push). + NOTIFY(std::move(message), handle_complete, _1, error::success); +} + +// private +void protocol_btcd_rpc::send_btcd_rpc(response_t&& model, size_t size_hint, + const code& close_reason) NOEXCEPT +{ + BC_ASSERT(stranded()); + using namespace network::http; + static const auto json = from_media_type(media_type::application_json); + + btcd_id_.reset(); + btcd_version_ = version::undefined; + + http::response message{ status::ok, 11 }; + message.set(field::content_type, json); + message.body() = rpc::response + { + { .size_hint = size_hint }, std::move(model), + }; + message.prepare_payload(); + + // handle_complete only stops (if close_reason is truthy) once this write + // has actually completed -- SEND still restarts the ws reader on success, + // matching the base behavior for every other response this class sends. + SEND(std::move(message), handle_complete, _1, close_reason); +} + +BC_POP_WARNING() +BC_POP_WARNING() +BC_POP_WARNING() + +} // namespace server +} // namespace libbitcoin diff --git a/src/server_node.cpp b/src/server_node.cpp index 1a59ff963..cfdf2fce5 100644 --- a/src/server_node.cpp +++ b/src/server_node.cpp @@ -117,6 +117,21 @@ void server_node::start_bitcoind(const code& ec, } attach_bitcoind_session()->start( + std::bind(&server_node::start_btcd, this, _1, handler)); +} + +void server_node::start_btcd(const code& ec, + const result_handler& handler) NOEXCEPT +{ + BC_ASSERT(stranded()); + + if (ec) + { + handler(ec); + return; + } + + attach_btcd_session()->start( std::bind(&server_node::start_electrum, this, _1, handler)); } @@ -185,6 +200,12 @@ session_bitcoind::ptr server_node::attach_bitcoind_session() NOEXCEPT config_.server.bitcoind); } +session_btcd::ptr server_node::attach_btcd_session() NOEXCEPT +{ + return net::attach(*this, config_, + config_.server.btcd); +} + session_electrum::ptr server_node::attach_electrum_session() NOEXCEPT { return net::attach(*this, config_, diff --git a/test/protocols/btcd/btcd_rpc.cpp b/test/protocols/btcd/btcd_rpc.cpp new file mode 100644 index 000000000..fa118ad31 --- /dev/null +++ b/test/protocols/btcd/btcd_rpc.cpp @@ -0,0 +1,202 @@ +/** + * Copyright (c) 2011-2026 libbitcoin developers + * + * 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 "btcd_setup_fixture.hpp" + +using namespace system; + +namespace { + +bool has_error(const boost::json::value& response) NOEXCEPT +{ + return response.is_object() && response.as_object().contains("error") && + !response.at("error").is_null(); +} + +bool has_result(const boost::json::value& response) NOEXCEPT +{ + return response.is_object() && response.as_object().contains("result") && + !response.at("result").is_null(); +} + +} // namespace + +BOOST_FIXTURE_TEST_SUITE(btcd_rpc_tests, btcd_ten_block_setup_fixture) + +// session management +// ---------------------------------------------------------------------------- + +BOOST_AUTO_TEST_CASE(btcd_rpc__authenticate__no_auth_configured__null_result) +{ + // Without basic auth configured, authenticate is a no-op success. + const auto response = rpc("authenticate", R"(["user","pass"])"); + BOOST_REQUIRE(!has_error(response)); +} + +BOOST_AUTO_TEST_CASE(btcd_rpc__session__returns_id) +{ + const auto response = rpc("session"); + BOOST_REQUIRE(!has_error(response)); + BOOST_REQUIRE(has_result(response)); + BOOST_REQUIRE(response.at("result").is_object()); + BOOST_REQUIRE(response.at("result").as_object().contains("id")); +} + +// block subscription +// ---------------------------------------------------------------------------- + +BOOST_AUTO_TEST_CASE(btcd_rpc__notifyblocks__returns_null_result) +{ + const auto response = rpc("notifyblocks"); + BOOST_REQUIRE(!has_error(response)); +} + +BOOST_AUTO_TEST_CASE(btcd_rpc__stopnotifyblocks__returns_null_result) +{ + // Subscribe first so stop is meaningful. + rpc("notifyblocks"); + const auto response = rpc("stopnotifyblocks"); + BOOST_REQUIRE(!has_error(response)); +} + +// Standard chain methods (getblockcount etc.) are inherited from +// protocol_bitcoind_rpc and remain reachable via plain http post on the same +// endpoint (see test/protocols/bitcoind), but are not yet bridged into this +// ws dispatcher -- see the phase B TODO on protocol_btcd_rpc.hpp. + +// not implemented stubs +// ---------------------------------------------------------------------------- + +BOOST_AUTO_TEST_CASE(btcd_rpc__not_implemented__error) +{ + const std::vector> methods + { + { "stop", "[]" }, + { "notifynewtransactions", "[false]" }, + { "stopnotifynewtransactions", "[]" }, + { "loadtxfilter", R"([false,[],[]])" }, + { "rescanblocks", "[[]]" }, + { "notifyreceived", "[[]]" }, + { "stopnotifyreceived", "[[]]" }, + { "notifyspent", "[[]]" }, + { "stopnotifyspent", "[[]]" }, + { "rescan", R"(["",[""],[""],""])" } + }; + + for (const auto& method: methods) + BOOST_REQUIRE_MESSAGE(has_error(rpc(method.first, method.second)), + method.first); +} + +// response envelope +// ---------------------------------------------------------------------------- + +BOOST_AUTO_TEST_CASE(btcd_rpc__response__id_matches_request) +{ + // request_id_ increments each call: first call uses id=0, second id=1. + const auto r0 = rpc("session"); + const auto r1 = rpc("notifyblocks"); + BOOST_REQUIRE_EQUAL(r0.at("id").as_int64(), 0); + BOOST_REQUIRE_EQUAL(r1.at("id").as_int64(), 1); +} + +BOOST_AUTO_TEST_CASE(btcd_rpc__unknown_method__error_keeps_connection_alive) +{ + // Unrecognized method returns a json-rpc error rather than dropping the + // ws connection (matches real btcd, verified by the follow-up call). + const auto unknown = rpc("nosuchmethod"); + BOOST_REQUIRE(has_error(unknown)); + + const auto follow_up = rpc("session"); + BOOST_REQUIRE(!has_error(follow_up)); +} + +BOOST_AUTO_TEST_SUITE_END() + +// Method-scoped credentials (channel_btcd::permitted()): a credential's +// optional 'user:pass:method,...' suffix restricts which methods it may +// call, enforced over ws by protocol_btcd_rpc::dispatch_websocket. +// ---------------------------------------------------------------------------- + +BOOST_FIXTURE_TEST_SUITE(btcd_scoped_credential_tests, + btcd_scoped_credential_setup_fixture) + +BOOST_AUTO_TEST_CASE(btcd_scoped_credential__listed_method__permitted) +{ + const auto auth = rpc("authenticate", + R"([")" BTCD_TEST_USERNAME R"(", ")" BTCD_TEST_PASSWORD R"("])"); + BOOST_REQUIRE(!has_error(auth)); + + // BTCD_TEST_SCOPED_METHOD ("session") is the credential's only permitted + // method. + const auto session = rpc("session"); + BOOST_REQUIRE(!has_error(session)); +} + +BOOST_AUTO_TEST_CASE(btcd_scoped_credential__unlisted_method__rejected) +{ + const auto auth = rpc("authenticate", + R"([")" BTCD_TEST_USERNAME R"(", ")" BTCD_TEST_PASSWORD R"("])"); + BOOST_REQUIRE(!has_error(auth)); + + // notifyblocks is a real, implemented handler, not scoped by this + // credential -- confirms rejection is permitted()'s doing, not the + // method being unimplemented. + const auto response = rpc("notifyblocks"); + BOOST_REQUIRE(has_error(response)); +} + +BOOST_AUTO_TEST_SUITE_END() + +// authenticate (protocol_btcd_rpc::handle_authenticate): exercises the +// credential-configured branch, not covered by btcd_rpc_tests above (whose +// fixture leaves btcd.credential unset, so every one of those connections +// takes the no-op path). +// ---------------------------------------------------------------------------- + +BOOST_FIXTURE_TEST_SUITE(btcd_auth_tests, btcd_credentialed_setup_fixture) + +BOOST_AUTO_TEST_CASE(btcd_auth__correct_credentials__succeeds_then_session_works) +{ + const auto auth = rpc("authenticate", + R"([")" BTCD_TEST_USERNAME R"(", ")" BTCD_TEST_PASSWORD R"("])"); + BOOST_REQUIRE(!has_error(auth)); + + // Connection survives a successful handshake; other methods now work. + const auto session = rpc("session"); + BOOST_REQUIRE(!has_error(session)); +} + +BOOST_AUTO_TEST_CASE(btcd_auth__wrong_password__rejected) +{ + const auto response = rpc("authenticate", + R"([")" BTCD_TEST_USERNAME R"(", "definitely-wrong"])"); + BOOST_REQUIRE(has_error(response)); +} + +BOOST_AUTO_TEST_CASE(btcd_auth__other_method_before_authenticating__rejected) +{ + // 'session' instead of 'authenticate' as the first message: the + // handshake requires authenticate specifically when a credential is + // configured. + const auto response = rpc("session"); + BOOST_REQUIRE(has_error(response)); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/test/protocols/btcd/btcd_setup_fixture.cpp b/test/protocols/btcd/btcd_setup_fixture.cpp new file mode 100644 index 000000000..d19e6b340 --- /dev/null +++ b/test/protocols/btcd/btcd_setup_fixture.cpp @@ -0,0 +1,139 @@ +/** + * Copyright (c) 2011-2026 libbitcoin developers + * + * 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 "btcd_setup_fixture.hpp" +#include +#include + +using namespace system; +using namespace boost::beast; + +BC_PUSH_WARNING(NO_THROW_IN_NOEXCEPT) + +btcd_setup_fixture::btcd_setup_fixture(const initializer& setup, + std::string_view username, std::string_view password, + std::string_view methods) + : config_ + { + system::chain::selection::mainnet, + test::web_pages, + test::web_pages + }, + store_ + { + [&]() NOEXCEPT -> const database::settings& + { + config_.database.path = TEST_DIRECTORY; + return config_.database; + }() + }, + query_{ store_ }, log_{}, + server_{ query_, config_, log_ } +{ + test::clear(test::directory); + + auto& database_settings = config_.database; + auto& network_settings = config_.network; + auto& node_settings = config_.node; + auto& btcd = config_.server.btcd; + + btcd.binds = { { BTCD_ENDPOINT } }; + btcd.connections = 1; + if (!username.empty()) + btcd.credentials.emplace_back(std::string{ username } + ":" + + std::string{ password } + + (methods.empty() ? "" : ":" + std::string{ methods })); + database_settings.interval_depth = 2; + node_settings.delay_inbound = false; + node_settings.minimum_fee_rate = 99.0; + network_settings.inbound.connections = 0; + network_settings.outbound.connections = 0; + + // Create and populate the store. + auto ec = store_.create([](auto, auto) {}); + BOOST_REQUIRE_MESSAGE(!ec, ec.message()); + setup(query_); + + // Run the server. + std::promise running{}; + server_.run([&](const code& ec) NOEXCEPT + { + running.set_value(ec); + }); + + // Block until server is running. + ec = running.get_future().get(); + BOOST_REQUIRE_MESSAGE(!ec, ec.message()); + + socket_.connect(btcd.binds.back().to_endpoint()); + + network::boost_code wec{}; + websocket_.text(true); + websocket_.handshake("localhost", "/", wec); + BOOST_REQUIRE_MESSAGE(!wec, wec.message()); +} + +btcd_setup_fixture::~btcd_setup_fixture() +{ + network::boost_code ec{}; + websocket_.close(websocket::close_code::normal, ec); + + // Expected and harmless during fixture teardown. + if (ec && + ec != boost::beast::websocket::error::closed && + ec != boost::asio::error::operation_aborted) + { + BOOST_WARN_MESSAGE(false, ec.message()); + } + + server_.close(); + ec = store_.close([](auto, auto){}); + BOOST_WARN_MESSAGE(!ec, ec.message()); + test::clear(test::directory); +} + +BC_POP_WARNING() + +boost::json::value btcd_setup_fixture::rpc(std::string_view method, + std::string_view params) +{ + std::ostringstream body{}; + body << R"({"jsonrpc":"1.0","id":)" << request_id_++ + << R"(,"method":")" << method + << R"(","params":)" << params << "}"; + + network::boost_code ec{}; + websocket_.write(net::buffer(body.str()), ec); + BOOST_REQUIRE_MESSAGE(!ec, ec.message()); + + flat_buffer buffer{}; + websocket_.read(buffer, ec); + BOOST_REQUIRE_MESSAGE(!ec, ec.message()); + return test::parse_json(buffers_to_string(buffer.data())); +} + +boost::json::value btcd_setup_fixture::receive_notification() +{ + flat_buffer buffer{}; + network::boost_code ec{}; + websocket_.read(buffer, ec); + BOOST_REQUIRE_MESSAGE(!ec, ec.message()); + return test::parse_json(buffers_to_string(buffer.data())); +} diff --git a/test/protocols/btcd/btcd_setup_fixture.hpp b/test/protocols/btcd/btcd_setup_fixture.hpp new file mode 100644 index 000000000..d50f358a9 --- /dev/null +++ b/test/protocols/btcd/btcd_setup_fixture.hpp @@ -0,0 +1,117 @@ +/** + * Copyright (c) 2011-2026 libbitcoin developers + * + * 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 . + */ +#ifndef LIBBITCOIN_SERVER_TEST_PROTOCOLS_BTCD_BTCD_SETUP_FIXTURE +#define LIBBITCOIN_SERVER_TEST_PROTOCOLS_BTCD_BTCD_SETUP_FIXTURE + +#include "../../test.hpp" +#include "../../mocks/blocks.hpp" + +#define BTCD_ENDPOINT "127.0.0.1:65004" + +struct btcd_setup_fixture +{ + using initializer = std::function; + + DELETE_COPY_MOVE(btcd_setup_fixture); + + // username/password configure a single btcd.credential entry (no auth + // required if username is empty), matching the one-tier credential model. + // methods, if non-empty, scopes that credential to a comma-separated + // method list (config::credential's own "user:pass:method,..." syntax); + // empty (the default) leaves the credential unscoped (all methods). + explicit btcd_setup_fixture(const initializer& setup, + std::string_view username={}, std::string_view password={}, + std::string_view methods={}); + ~btcd_setup_fixture(); + + // JSON-RPC 1.0 over websocket. params must be a json array (json-rpc-v1 + // does not support 'null' or omitted params; use "[]" for no arguments). + // Returns the parsed json-rpc response object (result/error). + boost::json::value rpc(std::string_view method, + std::string_view params = "[]"); + + // Read one further (unprompted) server push, e.g. a blockconnected + // notification. Returns the parsed json-rpc notification object. + boost::json::value receive_notification(); + +protected: + configuration config_; + test::store_t store_; + test::query_t query_; + +private: + using tcp_stream = boost::beast::tcp_stream; + using websocket_stream = boost::beast::websocket::stream; + + network::logger log_; + server::server_node server_; + boost::asio::io_context io_{}; + tcp_stream socket_{ io_.get_executor() }; + websocket_stream websocket_{ socket_ }; + int request_id_{}; +}; + +struct btcd_ten_block_setup_fixture + : btcd_setup_fixture +{ + inline btcd_ten_block_setup_fixture() + : btcd_setup_fixture([](test::query_t& query) + { + return test::setup_ten_block_store(query); + }) + { + } +}; + +#define BTCD_TEST_USERNAME "user" +#define BTCD_TEST_PASSWORD "pass" + +// Configured with a credential but does not authenticate -- for tests that +// exercise the in-band 'authenticate' call itself (success, wrong-password +// rejection, calling another method before authenticating). +struct btcd_credentialed_setup_fixture + : btcd_setup_fixture +{ + inline btcd_credentialed_setup_fixture() + : btcd_setup_fixture([](test::query_t& query) + { + return test::setup_ten_block_store(query); + }, BTCD_TEST_USERNAME, BTCD_TEST_PASSWORD) + { + } +}; + +#define BTCD_TEST_SCOPED_METHOD "session" + +// Configured with a credential scoped to a single method -- for tests that +// verify channel_btcd::permitted() enforces per-method credential scoping +// over ws once authenticated. +struct btcd_scoped_credential_setup_fixture + : btcd_setup_fixture +{ + inline btcd_scoped_credential_setup_fixture() + : btcd_setup_fixture([](test::query_t& query) + { + return test::setup_ten_block_store(query); + }, BTCD_TEST_USERNAME, BTCD_TEST_PASSWORD, BTCD_TEST_SCOPED_METHOD) + { + } +}; + +#endif