Skip to content

Add btcd-compatible JSON-RPC/WebSocket endpoint (phase A) - #838

Open
eynhaender wants to merge 6 commits into
libbitcoin:masterfrom
eynhaender:feature/btcd
Open

Add btcd-compatible JSON-RPC/WebSocket endpoint (phase A)#838
eynhaender wants to merge 6 commits into
libbitcoin:masterfrom
eynhaender:feature/btcd

Conversation

@eynhaender

@eynhaender eynhaender commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds phase A of a btcd-compatible endpoint (JSON-RPC 1.0 over WebSocket, port 8334 by convention), following the pattern established by the bitcoind endpoint. Full design rationale, phased scope, and an lnd/btcwallet compatibility analysis are in docs/btcd-endpoint.md.

protocol_btcd_rpc inherits protocol_bitcoind_rpc (all standard chain handlers — getblockcount, getblock, getrawtransaction, sendrawtransaction, etc. — come along unchanged) and adds a second dispatcher for btcd's session/notification/filter/admin extension methods, routed through network::protocol_http's existing dispatch_websocket override point.

channel_btcd preselects the same json-rpc request parser for websocket frames that the http post path already uses, so both transports share one parsing/dispatch model.

Implemented (live)

  • authenticate — validates against configured btcd.username/password when set; no-op success otherwise.
  • session — returns the channel identifier.
  • notifyblocks / stopnotifyblocks — subscribes to node::chase::organized/reorganized, pushes blockconnected/blockdisconnected notifications with the real btcd positional wire format ([hash, height, time], verified against btcsuite/btcd/btcjson/chainsvrwsntfns.go — not the filtered variant, which is loadtxfilter's, separately scoped).

Auth over websocket

Basic auth is a per-HTTP-request header, which websocket data frames structurally cannot carry (only the upgrade handshake has headers, and that request never reaches the auth check at all — see channel_http's error::upgraded short-circuit). channel_btcd::unauthorized() bypasses the inherited per-request check for websocket traffic; the plain HTTP POST path (e.g. the inherited chain methods) keeps that check unchanged, identical to bitcoind.

Enforcement over ws instead happens in dispatch_websocket: every method other than authenticate is rejected until the connection has authenticated. A failed authenticate sends a json-rpc error and then closes the connection — deferred to the write-completion callback rather than called synchronously alongside the send, so it can't race the outgoing bytes.

Deliberately not_implemented

  • stop — no secure remote-shutdown path exists; unconditional, no gating config (there's no conditional branch to gate).
  • notifynewtransactions/stopnotifynewtransactions — no mempool in v4.
  • loadtxfilter/rescanblocks — scheduled for phase B (also the two methods most load-bearing for a real lnd/btcwallet client, per the doc's compatibility analysis).
  • Deprecated notifyreceived/stopnotifyreceived/notifyspent/stopnotifyspent/rescan.

Known gap

Standard chain methods are currently reachable only via plain HTTP POST on the same endpoint (unchanged from bitcoind) — not yet over the websocket connection itself. dispatch_websocket only routes to the btcd-only dispatcher; the inherited handlers' response path caches the original http::request for header derivation, which doesn't exist for a ws frame, so bridging them needs its own care. Flagged as a phase B TODO in protocol_btcd_rpc.hpp and the design doc rather than silently deferred.

Test plan

C++ (test/protocols/btcd/)

  • Builds clean (CMake, nix-gnu-debug-static).
  • Full suite passes (635 cases, including the new btcd_rpc_tests).
  • Boost.Test suite exercises: authenticate, session, notifyblocks/stopnotifyblocks, every not_implemented stub, response id echo across multiple requests on one connection, unknown-method handling (json-rpc error reply, connection stays alive).

Python (endpoints/test_btcd_rpc.py)

  • New suite, modeled on test_bitcoind_rpc.py/test_electrum_subscriptions.py. No new pip dependency — the websocket client is a small stdlib-only RFC 6455 implementation, matching this suite's existing preference for hand-rolled wire protocol.
  • Split into real assertions (regression guards) and @pytest.mark.xfail(strict=False) development targets for everything not yet implemented/bridged — pytest -m xfail -rx shows the live checklist.
  • Verified end-to-end against a live bs_btcd instance twice: once with no credentials configured (19 passed, 2 skipped, 20 xfailed, including a real blockconnected push notification received and validated during the run), and again after configuring btcd.username/btcd.password and rebuilding/restarting the server (same result: 19 passed, 2 skipped, 20 xfailed, 0 failures) — the second run is what caught and confirmed the fix for the auth-over-websocket issue described above.

New endpoints/test_btcd_rpc.py covering the btcd JSON-RPC/websocket compatibility interface, modeled on test_bitcoind_rpc.py's request/response pattern and test_electrum_subscriptions.py's persistent-connection/
notification-reading pattern.

No new pip dependency: the websocket client (WebSocketConnection) is a minimal stdlib-only RFC 6455 implementation (handshake, masked text frames, multi-frame message reassembly), matching this suite's existing reference for hand-rolled wire protocol over a library (see Electrum's raw-socket client).

Tests are split into two kinds:

- Real assertions (no marker) — authenticate, session, notifyblocks/stopnotifyblocks, chain methods over http post, deprecated methods and stop staying rejected, response envelope behavior. A failure here is a regression.

- @pytest.mark.xfail(strict=False) — development targets for what isn't implemented yet: chain methods bridged into the ws connection itself (phase B), the wired not-yet-implemented stubs (notifynewtransactions, loadtxfilter, rescanblocks, ...), and the not-yet-wired phase B/C methods (getinfo, getdifficulty, createrawtransaction, ...). Expected to fail now; flips to XPASS once implemented, which is the cue to tighten the assertion and drop the marker.

conftest.py: --btcd-host/--btcd-port/--btcd-username/--btcd-password options and btcd_config fixture. utils.py: DEFAULT_BTCD_PORT. README.md: btcd section (config sample, options, coverage, debug/trigger env vars) alongside the existing per-endpoint documentation.
New test/protocols/btcd/{btcd_rpc.cpp,btcd_setup_fixture.{hpp,cpp}}, in the style of test/protocols/bitcoind: a real websocket connection to a mock ten-block store, covering authenticate, session, notifyblocks/stopnotifyblocks, every not_implemented stub, response id echo across multiple requests on one connection, and unknown-method handling (json-rpc error reply, connection stays alive for the next request).
Adds channel_btcd (channel_http + per-connection auth state, preselects the
json-rpc request parser for websocket frames) and protocol_btcd_rpc (inherits
protocol_bitcoind_rpc for the standard chain handlers, adds a second
dispatcher for btcd's session/notification/filter/admin extension methods
over dispatch_websocket).

Live: authenticate (validates against configured btcd.username/password, or
no-op success if none configured), session (returns the channel identifier),
notifyblocks/stopnotifyblocks (chase::organized/reorganized ->
blockconnected/blockdisconnected, positional [hash, height, time] per real
btcd wire format).

Stubbed not_implemented: stop (no secure remote-shutdown path exists),
notifynewtransactions/stopnotifynewtransactions (no mempool in v4),
loadtxfilter/rescanblocks (phase B), the deprecated notifyreceived/
notifyspent/rescan family. Extends interfaces/btcd.hpp with a stop method.

Wiring: channels.hpp, protocols.hpp, sessions.hpp, server_node.hpp/.cpp
(start_btcd chained after bitcoind), parser.cpp ([btcd] config section
mirroring [bitcoind]), builds/gnu/Makefile.am.

Auth over websocket: basic auth is a per-http-request header, which ws data
frames cannot carry (only the upgrade handshake has headers, and that
request never reaches the auth check -- see channel_http's error::upgraded
short-circuit). channel_btcd::unauthorized() bypasses the inherited
per-request check for websocket traffic; the plain http post path keeps it
unchanged. Enforcement over ws moves into dispatch_websocket instead: every
method other than authenticate is rejected until the connection has
authenticated. handle_authenticate's reject path also no longer calls stop()
synchronously alongside the error send -- that raced the async write and
could close the connection before the error response flushed. It's now
threaded through as the SEND completion reason, so the close only happens
once the write actually finishes.

Known gap: standard chain methods (getblockcount etc.) remain reachable only
via plain http post on the same endpoint, not yet bridged into the ws
dispatcher -- see the phase B TODO on protocol_btcd_rpc.hpp.
conn fixture now authenticates up front when the server has credentials
configured, matching what a real client must do before calling anything
else over ws (every method but authenticate is now rejected until then).
Without this, most of the suite degraded to xfail/failed once credentials
were actually configured on the server, since none of the individual tests
called authenticate themselves.

http_rpc() now sends HTTP Basic Auth when credentials are configured, for
the same reason on the plain-post path (which keeps the normal per-request
check, unlike ws).

test_response_id_matches_request no longer hardcodes absolute request ids
0/1: the fixture's own authenticate call consumes an id first when
credentials are configured, so only the relative ordering (each id is
exactly one more than the last) is a stable guarantee.

Verified against a live bs_btcd instance with btcd.username/password
configured: 19 passed, 2 skipped, 20 xfailed, 0 failures.
…ake stage).

session_handshake<protocol_btcd_auth, protocol_btcd_rpc> (tried in the two
preceding commits, now dropped) modeled btcd's auth on electrum/p2p's version
negotiation, but that pattern assumes the handshake can only complete by
reading a real message. btcd's doesn't: with no credential configured it
completes without reading anything, leaving a real async gap between
"handshake decided it's done" and protocol_btcd_rpc actually subscribing its
handlers. Harmless for websocket (the upgrade round-trip masks it), but a
plain HTTP client can and reliably did land a request in that gap. Real btcd
has no such gap because it has no handshake protocol at all.

Revert session_btcd to session_server<protocol_btcd_rpc> (matching bitcoind
and real btcd) and move 'authenticate' back into protocol_btcd_rpc as an
ordinary extension method, but with the real semantics this time:

- Digest-based credential check (sha256("Basic " + base64(user:pass)), same
  scheme as config::credential) via options_.authorized(), instead of a
  plaintext username/password comparison.
- btcd.username/btcd.password collapse into the same 'btcd.credential =
  user:pass[:method,...]' shape bitcoind already uses, so a credential can be
  scoped to a method allowlist -- channel_btcd::permitted() enforces that
  scope for both http and ws (ws basic-auth-on-upgrade and in-band
  authenticate are two alternative ways to satisfy it, matching real btcd's
  own "authenticate is disallowed once basic auth is already established").
- send_btcd_error/send_btcd_rpc thread a close_reason through to
  handle_complete, so a failed authenticate's error reaches the client before
  the connection closes, without racing the async send.

docs/btcd-endpoint.md documents why the handshake variant was reverted.
Boost.Test (640 cases) and the Python endpoint suite pass against a live
server both with and without a configured credential.
Some endpoint tests (e.g. test_blockconnected_notification) wait on a real
external event -- an actual new block -- up to --subscription-timeout, which
made a normal pytest run slow and made CI/local runs flaky when no block
happened to arrive in time. Mark those @pytest.mark.slow and skip them by
default; --run-slow opts back in for a real (if longer) end-to-end check.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant