Add electrum-ws WebSocket bridge for browser Electrum clients - #258
Merged
Conversation
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
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).
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a small
websocatcontainer (electrum-ws) that exposes the existingelectrsTCP Electrum endpoint as a WebSocket on port50003. 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+mempoolstack). Both PRs are intentionally left open so reviewers can pick which way to go:Why websocat works transparently
Verified by reading the relevant sources:
-wframing (the de-facto Electrum-over-WS spec): one JSON-RPC message per WebSocket text frame, no trailing newlines.ElectrumConnection::on_readyReadcallsws->readNextMessage()and treats each frame as a complete message — it does not split frames on\n.…json…\n.-t --linemode-strip-newlines:\nwriting to TCP → electrs'sreadLine()reads it cleanly.…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:50003unchanged.Files touched
cmd/nigiri/resources/docker-compose.yml— newelectrum-wsservice (websocat)cmd/nigiri/start.go— addselectrum-wsto the bitcoin services list (CI + non-CI)internal/docker/mock_client.go— mock endpoint/port entrytest/start_stop_test.go— fixtures + expected service lists updatedLiquid 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 --quietnigiri startboots the new container; a JS Electrum-over-WS client connects tows://localhost:50003and gets aserver.versionresponsenigiri start --liquid— Liquid stack untouchednigiri start --ci— headless mode includeselectrum-wsnigiri start --ark— arkd unaffected (still talks to electrs HTTP via chopsticks)Notes / follow-ups
nigiri forgetbefore upgrade so the newdocker-compose.ymlis re-provisioned.--server-protocol electrumto theelectrum-wscommand. 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.