Skip to content

Add electrum-ws WebSocket bridge for browser Electrum clients - #258

Merged
Kukks merged 2 commits into
masterfrom
electrum-ws-bridge
Apr 28, 2026
Merged

Add electrum-ws WebSocket bridge for browser Electrum clients#258
Kukks merged 2 commits into
masterfrom
electrum-ws-bridge

Conversation

@Kukks

@Kukks Kukks commented Apr 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a small websocat container (electrum-ws) that exposes the existing electrs TCP Electrum endpoint as a WebSocket on port 50003. Browser/JS clients (which cannot open raw TCP) can now speak Electrum protocol against nigiri's bitcoin Electrum server with no other changes.

This is a lightweight alternative to PR #257 (fulcrum + mempool stack). Both PRs are intentionally left open so reviewers can pick which way to go:

PR #257 (fulcrum + mempool) This PR
Browser-accessible Electrum (WS) ✅ via fulcrum ✅ via websocat
Modern, fast Electrum server ✅ fulcrum ❌ keeps electrs
New explorer UI / esplora swap ✅ mempool ❌ unchanged
Touches chopsticks/arkd plumbing ✅ (needs nginx relay) ❌ zero changes
New container count +5 +1
Net diff ~190 lines ~40 lines

Why websocat works transparently

Verified by reading the relevant sources:

  • Fulcrum's -w framing (the de-facto Electrum-over-WS spec): one JSON-RPC message per WebSocket text frame, no trailing newlines. ElectrumConnection::on_readyRead calls ws->readNextMessage() and treats each frame as a complete message — it does not split frames on \n.
  • electrs TCP framing: classic newline-delimited JSON-RPC, server writes …json…\n.
  • websocat's default behavior with -t --linemode-strip-newlines:
    • Client → bridge → electrs: one JSON-RPC per WS frame → websocat appends \n writing to TCP → electrs's readLine() reads it cleanly.
    • electrs → bridge → client: electrs writes …json…\n → websocat reads one line, strips \n, emits one WS text frame → client receives exactly what fulcrum would have sent.

So a native Electrum-over-WS client written for fulcrum should connect to ws://localhost:50003 unchanged.

Files touched

  • cmd/nigiri/resources/docker-compose.yml — new electrum-ws service (websocat)
  • cmd/nigiri/start.go — adds electrum-ws to the bitcoin services list (CI + non-CI)
  • internal/docker/mock_client.go — mock endpoint/port entry
  • test/start_stop_test.go — fixtures + expected service lists updated

Liquid stack (electrs-liquid, chopsticks-liquid, esplora-liquid) is untouched.

Test plan

  • go build ./...
  • go vet ./...
  • go test ./test/... -run 'TestDataDirSetup|TestBasicStartStop|TestServiceCombinations|TestStateManagement'
  • docker compose -f cmd/nigiri/resources/docker-compose.yml config --quiet
  • End-to-end smoke: nigiri start boots the new container; a JS Electrum-over-WS client connects to ws://localhost:50003 and gets a server.version response
  • nigiri start --liquid — Liquid stack untouched
  • nigiri start --ci — headless mode includes electrum-ws
  • nigiri start --ark — arkd unaffected (still talks to electrs HTTP via chopsticks)

Notes / follow-ups

  • Existing nigiri datadirs need nigiri forget before upgrade so the new docker-compose.yml is re-provisioned.
  • If a strict client refuses the WS handshake without a sub-protocol header, we can add --server-protocol electrum to the electrum-ws command. Left off by default since it's a no-op for clients that don't ask for one and could surprise clients that ask for a different value.

Adds a small websocat container that exposes electrs's TCP Electrum
endpoint as a WebSocket on port 50003, so browser/JS clients (which
cannot open raw TCP) can speak Electrum protocol against the existing
electrs service.

Framing matches fulcrum's `-w` semantics: one Electrum JSON-RPC message
per WebSocket text frame, no trailing newlines. websocat's default
line-mode plus `--linemode-strip-newlines` translates this to/from
electrs's newline-delimited TCP Electrum protocol.

No changes to electrs, chopsticks, esplora, or any Liquid service.
The endpoint is auto-derived from docker-compose ports as
`localhost:50003`, but a raw WebSocket URL needs an explicit scheme so
users don't try to point HTTP clients at it.
@Kukks
Kukks merged commit 98ec469 into master Apr 28, 2026
3 checks passed
@Kukks
Kukks deleted the electrum-ws-bridge branch April 28, 2026 09:08
Kukks added a commit to arkade-os/ts-sdk that referenced this pull request Apr 28, 2026
vulpemventures/nigiri#258 (electrum-ws bridge) merged to master, so
arkade-regtest no longer needs the temporary NIGIRI_BRANCH pin to the
PR's branch. Submodule now tracks arkade-regtest's plain master, which
builds nigiri from upstream master and gets the websocat bridge for
free.

Reverts the submodule bump from the previous commit on this branch
(\`96d02a59\` → \`8c27e31b\`).
Kukks added a commit to arkade-os/ts-sdk that referenced this pull request May 1, 2026
Adds test/e2e/electrum.test.ts which exercises every method on
OnchainProvider against nigiri's bitcoin Electrum endpoint, exposed as a
WebSocket on port 50003 by the electrum-ws bridge from
vulpemventures/nigiri#258.

Coverage parallels the esplora e2e tests (test/e2e/onchain.test.ts) so
any divergence between providers shows up side-by-side:

- getChainTip — header-subscription cache reuse across calls
- getFeeRate — accepts both undefined (regtest -1) and a positive sat/vB
- getCoins / getTransactions / getTxStatus — fund a fresh address via
  nigiri faucet and verify each method reports it
- broadcastTransaction — single-tx Alice-to-Bob send roundtrip
- getTxOutspends — spent-output detection on a partially-consumed tx
- watchAddresses — funded-tx delivery via the scripthash subscription
- WsElectrumChainSource.fetchHistories — batch round-trip

The 1P1C broadcast_package path is not exercised here because
nigiri's bridge wraps electrs (which lacks broadcast_package); the
unit suite already covers it. Once nigiri#258 is replaced with a
fulcrum-based stack, the missing cases can be added in a follow-up.

Bumps the regtest submodule to ArkLabsHQ/arkade-regtest#12 which
points NIGIRI_BRANCH at electrum-ws-bridge. After arkade-regtest#12
merges, re-bump the submodule pointer to its master.
Kukks added a commit to arkade-os/ts-sdk that referenced this pull request May 1, 2026
…#461)

* test(e2e): integration tests for ElectrumOnchainProvider over regtest

Adds test/e2e/electrum.test.ts which exercises every method on
OnchainProvider against nigiri's bitcoin Electrum endpoint, exposed as a
WebSocket on port 50003 by the electrum-ws bridge from
vulpemventures/nigiri#258.

Coverage parallels the esplora e2e tests (test/e2e/onchain.test.ts) so
any divergence between providers shows up side-by-side:

- getChainTip — header-subscription cache reuse across calls
- getFeeRate — accepts both undefined (regtest -1) and a positive sat/vB
- getCoins / getTransactions / getTxStatus — fund a fresh address via
  nigiri faucet and verify each method reports it
- broadcastTransaction — single-tx Alice-to-Bob send roundtrip
- getTxOutspends — spent-output detection on a partially-consumed tx
- watchAddresses — funded-tx delivery via the scripthash subscription
- WsElectrumChainSource.fetchHistories — batch round-trip

The 1P1C broadcast_package path is not exercised here because
nigiri's bridge wraps electrs (which lacks broadcast_package); the
unit suite already covers it. Once nigiri#258 is replaced with a
fulcrum-based stack, the missing cases can be added in a follow-up.

Bumps the regtest submodule to ArkLabsHQ/arkade-regtest#12 which
points NIGIRI_BRANCH at electrum-ws-bridge. After arkade-regtest#12
merges, re-bump the submodule pointer to its master.

* fix(electrum): drop verbose-tx dependency for electrs compatibility

The provider was calling blockchain.transaction.get with verbose=true,
which Fulcrum supports but electrs (used by Blockstream, mempool.space,
and the nigiri regtest) explicitly does not — electrs returns "verbose
transactions are currently unsupported" and the call fails.

Refactor the three call sites that needed verbose data to use only
methods every Electrum server supports:

- getTransactions / watchAddresses: already had heights from
  scripthash.get_history; we now batch-fetch raw tx hex via
  blockchain.transaction.get (non-verbose) and parse it for vouts and
  exact sat amounts, then batch-fetch unique block headers via
  blockchain.block.header to derive block_time.

- getTxStatus: switches from verbose-tx confirmations to
  blockchain.transaction.get_merkle for the block height (a standard
  SPV-protocol method supported by every Electrum server). Block_time
  comes from parsing the corresponding block header.

This also tightens money handling — output values now come from parsing
the raw tx bytes (exact bigints), never the floating-point `value`
field. Math.round(value * 1e8) was a known footgun for protocol-level
amounts. The transition to electrum verbose-tx was previously the only
codepath that risked it; with verbose gone, it's fully eliminated.

The fetchVerboseTransaction(s) methods on WsElectrumChainSource remain
public for callers who explicitly target Fulcrum, but the provider no
longer depends on them.

42 electrum unit tests pass (replaced 5 verbose-mocked tests with their
non-verbose equivalents and added new tests asserting that:
  - the provider never calls blockchain.transaction.get with verbose;
  - block-header lookups are deduplicated across history entries that
    share the same height;
  - height=0 history entries are treated as mempool/unconfirmed.

* fix(electrum): address PR #461 review feedback

Addresses three CodeRabbit findings on the verbose-removal refactor and
adopts the project's e2e polling helper.

(1) fetchTxMerkle: scope catch to "not in block" errors only

The previous blanket catch silently mapped every failure — including
auth errors, network outages, and malformed responses — to "mempool /
unconfirmed", freezing any caller polling getTxStatus. Now matches
electrs/Fulcrum's specific "not yet in a block" wording and rethrows
everything else.

(2) watchAddresses: dedupe AFTER successful delivery, not before

Previously a failed historyToExplorerTxs call or a throwing user
callback would still mark the txid as seen, permanently suppressing
re-delivery on the next notification. Moves the known-set update to
after the eventCallback resolves.

(3) buildExplorerTx: propagate parse errors instead of empty vout

Returning an empty vout on a parse failure silently hides outputs —
including incoming deposits. For protocol-level money handling, fail
loud with the offending txid in the error message and let the caller
decide whether to retry or skip.

(4) e2e tests: replace fixed delays with waitFor polling

Aligns test/e2e/electrum.test.ts with the established pattern from
settlement.test.ts / ark.test.ts (waitFor in test/e2e/utils.ts) so
under CI load the tests finish as soon as electrs sees the tx rather
than waiting out a fixed 5-10s budget. Reduces flakiness.

Tests:
- 3 new unit tests cover the three production fixes:
  - getTxStatus propagates non-"not in block" errors
  - watchAddresses re-delivers when first delivery throws
  - getTransactions propagates parse errors with the offending txid
- Total electrum unit tests: 45 (was 42); full suite: 1020 passing
- Lint clean

* fix(electrum): tolerate electrs index lag in block-header lookups

Two e2e failures on the previous commit traced to the same race in
electrs: a tx can show as confirmed via listunspent (height>0) before
its block header is queryable, so block.header(N) rejects with
"missingheight". Two narrow fixes:

(1) historyToExplorerTxs: best-effort header fetch

If the batch block-header request fails wholesale (one bad height
poisoning the batch), retry per-height with Promise.allSettled and
collect what's available. Heights whose headers still won't resolve
land in the result map with no entry, and buildExplorerTx falls back
to block_time = 0 — same semantic the old verbose-tx path had via
`vtx.blocktime || vtx.time || 0`. Txid + confirmation status are still
authoritative, only block_time degrades.

This preserves the "fail loud on real corruption" stance from the
previous review (parse errors still throw with the offending txid);
header indexing lag is a transient infra race, not corruption.

(2) e2e: wrap getTxStatus in waitFor

getTxStatus calls fetchBlockHeader directly and intentionally throws
on failure (single-tx caller can retry; that's the right semantic).
The e2e test now retries via waitFor, swallowing only the specific
"missingheight" / "not in block" wording, until electrs settles. Also
bumps the fetchHistories test timeout to 40s and relaxes its waitFor
to >= 1 coin per address (the assertion only needs that).

Tests:
- 46 electrum unit tests (was 45, +1 for the new header-resilience path)
- Full unit suite still 1020 passing, lint clean

* fix(electrum): tolerate missingheight race in getTxStatus too

The previous commit made historyToExplorerTxs resilient to electrs's
"missingheight" index-lag race but left getTxStatus throwing on the
same race. The e2e wrapper retried via waitFor, but in CI the race
window outlasted waitFor's 25s budget, so the test still timed out.

Apply the same policy to getTxStatus: confirmation status and height
are authoritative; only block_time degrades to 0 when the header
isn't yet indexable. Mirrors the old verbose-tx path's
`blocktime || time || 0` fallback. Genuine errors still propagate via
isMissingHeightError narrowing.

E2e test no longer needs the waitFor wrapper, so it goes back to a
direct call with shape assertions.

Tests:
- Two new unit tests for getTxStatus:
  - returns confirmed=true with blockTime=0 on missingheight
  - propagates non-missingheight errors from block.header
- Total electrum unit tests: 48 (was 46)

* test(e2e): mine block after faucet to stabilize electrs index

Two e2e failures in the previous attempt traced to electrs racing on
the bridge: listunspent/get_merkle would report a tx as confirmed at
height N before block.header(N) was indexable, surfacing as
"missingheight" or as request-timeouts on subsequent calls. The
production-side fix (tolerating missingheight in historyToExplorerTxs
and getTxStatus) didn't help here because the library can't always
forward those errors as Promise rejections — the race manifests as
unhandled errors thrown out of the WebSocket onMessage handler, where
no caller-side try/catch can intercept.

Mine a block immediately after each `nigiri faucet` so electrs has a
stable confirmed state to index. Other e2e suites already do this
(settlement.test.ts mines after every state change for the same
reason). The production tolerance code stays — it's still correct for
real-world use against laggy electrs servers — but the test is no
longer dependent on it.

* fix(electrum): avoid library batchRequest leak on header errors

Root cause for the integration failures: ws-electrumx-client's
batchRequest uses Promise.all internally — when one element rejects,
the other in-flight requests stay pending. When their responses arrive
later (often as more error responses), the library rejects them too,
and nobody is awaiting them. Vitest catches the unhandled rejections
and crashes the test run.

The header-fetch path was the most exposed: a single "missingheight"
from electrs's index lag would leak (N-1) more rejections.

Switch historyToExplorerTxs to per-height ws.request calls under
Promise.allSettled, so every promise has an explicit handler and one
height's failure doesn't poison the others. Same final shape as
before — heights with valid headers populate blockTimeByHeight,
missing ones fall back to block_time = 0 in buildExplorerTx, mirroring
the old verbose-tx code's `vtx.blocktime || vtx.time || 0`.

Performance: N parallel ws.request calls vs 1 batch. allSettled fires
all at once, so wall-clock cost is one round-trip-time, slightly more
WebSocket frame overhead for the request side.

Tests:
- Updated 3 unit tests that mocked the now-removed header batch path
- Added a new test asserting batchRequest is called exactly once
  (for raw tx hex) and never for headers — guards against the
  regression
- 49 electrum unit tests pass (was 48)

* fix(electrum): ban library batchRequest at codebase level

Previous fix only addressed historyToExplorerTxs's header batch. The
same orphan-rejection leak in ws-electrumx-client's batchRequest
affected every other call site too: fetchTransactions,
fetchVerboseTransactions, fetchHistories, fetchBlockHeaders, and the
two batches inside ElectrumOnchainProvider.getTxOutspends. electrs
returning "missingheight" (or any error) for one element of any batch
would leak the other in-flight requests' rejections.

Add WsElectrumChainSource.safeBatchRequest — a drop-in replacement
that issues each request through ws.request (each gets its own
request-promise lifecycle) and aggregates via Promise.allSettled
(every promise has an explicit handler), then surfaces the first
rejection if any. Same wall-clock cost as the library batch (parallel
sends), no orphan rejections.

Replace all six call sites with safeBatchRequest. The library's
batchRequest is now banned in production code — it's still imported
implicitly via the typings but never called.

historyToExplorerTxs reverts to fetchBlockHeaders for the fast path
and falls back to per-height fetchBlockHeader under
Promise.allSettled when one or more heights fail (electrs index lag
race) — preserving the old verbose-tx code's `blocktime || time || 0`
tolerance.

Tests:
- New mockBatch(mock, responses) helper queues N sequential
  ws.request responses (mirroring how safeBatchRequest consumes them)
- Updated 25 batch mock sites to use mockBatch / sequential mocks
- New regression test asserts wsMock.batchRequest is never called by
  the provider's hot path — guards against future re-introduction
- 49 electrum unit tests pass; full suite 1024 passing; lint clean

* feat(providers): default URLs to Ark Labs–operated mempool/electrum

Adds reachable defaults for the SDK's onchain providers so consumers
don't have to wire up every URL manually:

  ESPLORA_URL.bitcoin     -> https://mempool.arkade.sh/api
  ESPLORA_URL.signet      -> https://mempool.signet.arkade.sh/api
  ESPLORA_URL.mutinynet   -> https://mempool.mutinynet.arkade.sh/api

  ELECTRUM_WS_URL.bitcoin     -> wss://electrum.arkade.sh
  ELECTRUM_WS_URL.signet      -> wss://electrum.signet.arkade.sh
  ELECTRUM_WS_URL.mutinynet   -> wss://electrum.mutinynet.arkade.sh
  ELECTRUM_WS_URL.testnet     -> wss://electrum.blockstream.info:60004
  ELECTRUM_WS_URL.regtest     -> ws://localhost:50003

All three Ark Labs Fulcrum 2.1.0 endpoints verified reachable; they
support blockchain.transaction.broadcast_package which the provider
uses for atomic 1P1C TRUC relay (see ElectrumOnchainProvider).

Also adds an informational ELECTRUM_TCP_HOST map for Node-side
consumers who'd build their own TCP transport — the SDK provider is
WebSocket-only because it has to run in browsers, but the hostnames
expose ports 50001 (TCP), 50002 (TCP+TLS), 50003 (WS).

Testnet keeps mempool.space (Ark doesn't host); regtest unchanged.

* fix(electrum): treat missingheight from get_merkle as unconfirmed

The remaining e2e flake traces back here, not the batchRequest leak.
electrs can return "missingheight" from blockchain.transaction.get_merkle
in the same index-lag window where block.header(N) does — the tx is
reportedly confirmed via listunspent but the block isn't fully indexed.

Previously fetchTxMerkle only narrowed the catch to "not in a block"
wording (isTxNotInBlockError), so missingheight propagated up through
getTxStatus and surfaced as an unhandled error in the test runner.

Now also accepts isMissingHeightError as a sign of the same race —
mempool/unknown — so getTxStatus returns {confirmed: false} until
the next poll, which is the right behavior for a transient electrs
race. Genuine errors (auth/network/malformed) still propagate.

50 electrum unit tests pass (was 49); regression test asserts
get_merkle missingheight maps to confirmed=false.

* test(e2e): drive fetchHistories test via the helper itself

Previous waitFor polled `Promise.all([getCoins(a), getCoins(b)])` while
the test target was `chain.fetchHistories([aScript, bScript])`. Under
CI load that occasionally tripped the library's 10s WS request timeout
when two parallel listunspent calls landed during faucet propagation.

Switch the wait loop to call fetchHistories directly — same code path
the assertion uses, no extra concurrent listunspent calls competing
for the connection. Eliminates the only remaining e2e flake.

* test(e2e): retry fetchHistories on transient WS request timeout

Under CI load electrs occasionally wedges for the library's full 10s
REQUEST_TIMEOUT on a single get_history call, causing the previous
attempt's first poll iteration to throw and waitFor to propagate the
error before any retry. Treat library-level request-timeout (and
missingheight) as "not ready yet" inside the wait body; only genuine
errors propagate.

Eliminates the last remaining e2e flake without skipping the test.

* docs: document onchain providers + Electrum support

Adds an 'Onchain Providers' section to README.md (placed before
'Receiving Bitcoin' since onchain queries underpin that flow):

- Comparison table: EsploraProvider (HTTP) vs ElectrumOnchainProvider
  (WebSocket) — when to use each.
- Default URL constants (ESPLORA_URL, ELECTRUM_WS_URL,
  ELECTRUM_TCP_HOST) with the network-by-network bitcoin/signet/
  mutinynet endpoints we ship.
- Working examples for both providers.
- TRUC 1P1C atomic package broadcast notes — Fulcrum requirement, no
  fallback semantics, verified arkade.sh deployments.
- Electrum server compatibility matrix: which methods both Fulcrum and
  electrs support, which are Fulcrum-only (broadcast_package), what
  the provider deliberately avoids (verbose transaction.get).

Also expands the class-level JSDoc on ElectrumOnchainProvider so it's
useful in the typedoc-generated reference docs (default URL example,
both Fulcrum and electrs constraints, exact-sat parsing note).
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