quoting nevent1q…lk5qFrom d307275490adbc6b012399e2d5200c4b963a4ba6 Mon Sep 17 00:00:00 2001
From: M
Date: Sun, 19 Apr 2026 21:57:33 +1000
Subject: [PATCH] feat(nip05/namecoin): add WebSocket (WSS) transport
Adds WS/WSS as an alternative transport to TCP+TLS for ElectrumX
name resolution. Enables browser/wasm use of nip05/namecoin and
improves resilience on networks where raw TCP is blocked.
New: Transport enum on ElectrumxServer, DefaultElectrumXServersWSS,
WebSocket client via github.com/coder/websocket (zero transitive
deps, already a transitive dep of nostrlib). Pinned-cert TLS trust
is shared between TCP+TLS and WSS \u2014 the public operators serve the
same certificate on the adjacent ports (:50002 / :50004 on testls,
:57002 / :57004 on nmc2.bitcoins.sk).
Zero API break: the new Transport field on ElectrumxServer is a
zero-valued enum that preserves the v1 UseSSL semantics. Every
call site from the previous patch compiles and behaves unchanged.
Tests: new unit test stands up an httptest WebSocket server via
coder/websocket's accept helper and exercises the whole dial \u2192
read \u2192 parse flow over WS framing, offline. Integration tests
(build tag integration) add two WSS cases against the live public
servers on :50004, mirroring the existing TCP test fixtures.
Follow-up to the patch event at
nevent1q…8pyq
(original subpackage). Rebase-clean on top of it.
Signed-off-by: M
---
nip05/namecoin/doc.go | 8 +-
nip05/namecoin/electrumx.go | 139 +++++++++----
nip05/namecoin/electrumx_integration_test.go | 50 +++++
nip05/namecoin/servers.go | 70 +++++++
nip05/namecoin/websocket.go | 105 ++++++++++
nip05/namecoin/websocket_test.go | 195 +++++++++++++++++++
6 files changed, 524 insertions(+), 43 deletions(-)
create mode 100644 nip05/namecoin/websocket.go
create mode 100644 nip05/namecoin/websocket_test.go
diff --git a/nip05/namecoin/doc.go b/nip05/namecoin/doc.go
index 7189a38..82bbce2 100644
--- a/nip05/namecoin/doc.go
+++ b/nip05/namecoin/doc.go
@@ -28,6 +28,10 @@
// // ...
// }
//
-// No external dependencies beyond the Go standard library and the
-// fiatjaf.com/nostr types.
+// Two transports are supported: raw TCP+TLS (the default, selected
+// by the zero value of ElectrumxServer.Transport) and WebSocket over
+// TLS (TransportWSS). The WSS path uses github.com/coder/websocket —
+// a zero-transitive-dep library that also compiles cleanly under
+// GOOS=js GOARCH=wasm, enabling browser use. Pinned-cert trust is
+// shared between the two transports.
package namecoin
diff --git a/nip05/namecoin/electrumx.go b/nip05/namecoin/electrumx.go
index 4fbabdb..b7757d5 100644
--- a/nip05/namecoin/electrumx.go
+++ b/nip05/namecoin/electrumx.go
@@ -42,9 +42,24 @@ var (
ErrServersUnreachable = errors.New("namecoin: all ElectrumX servers unreachable")
)
+// rpcConn is the transport-agnostic RPC connection abstraction. Both
+// TCP (newline-delimited) and WebSocket (pre-framed) implementations
+// satisfy it so that the name_show flow can be written once.
+type rpcConn interface {
+ // writeRequest serialises and sends a single JSON-RPC request.
+ // Implementations are responsible for any framing the wire format
+ // requires (TCP appends a newline, WS does not).
+ writeRequest(ctx context.Context, method string, params []any, id int64) error
+ // readResponse returns one JSON-RPC response, blocking until one
+ // is available or the context/deadline fires.
+ readResponse(ctx context.Context) (string, error)
+ // close releases the underlying transport.
+ close() error
+}
+
// ElectrumClient is a minimal, query-only Namecoin ElectrumX client.
-// It opens a short-lived TCP/TLS socket per request, which is plenty
-// for interactive CLI use.
+// It opens a short-lived socket per request, which is plenty for
+// interactive CLI use.
type ElectrumClient struct {
ConnectTimeout time.Duration
ReadTimeout time.Duration
@@ -68,18 +83,22 @@ func (c *ElectrumClient) NameShow(ctx context.Context, identifier string, server
if err != nil {
return nil, err
}
- defer conn.Close()
+ defer conn.close()
- _ = conn.SetDeadline(time.Now().Add(c.ReadTimeout))
+ // Give the whole request sequence a deadline. For TCP this is
+ // enforced by net.Conn.SetDeadline; for WS the per-call context
+ // carries the budget instead.
+ reqCtx, cancel := context.WithTimeout(ctx, c.ReadTimeout)
+ defer cancel()
- reader := bufio.NewReader(conn)
+ next := func() int64 { return c.requestID.Add(1) }
// 1. Negotiate protocol version. The response is consumed and
// discarded — we only care that the socket is alive.
- if err := c.sendRPC(conn, "server.version", []any{"nak-namecoin/0.1", electrumProtocolVersion}); err != nil {
+ if err := conn.writeRequest(reqCtx, "server.version", []any{"nak-namecoin/0.1", electrumProtocolVersion}, next()); err != nil {
return nil, err
}
- if _, err := reader.ReadString('\n'); err != nil {
+ if _, err := conn.readResponse(reqCtx); err != nil {
return nil, fmt.Errorf("namecoin: read version response: %w", err)
}
@@ -88,10 +107,10 @@ func (c *ElectrumClient) NameShow(ctx context.Context, identifier string, server
scriptHash := electrumScriptHash(script)
// 3. Fetch transaction history for that scripthash.
- if err := c.sendRPC(conn, "blockchain.scripthash.get_history", []any{scriptHash}); err != nil {
+ if err := conn.writeRequest(reqCtx, "blockchain.scripthash.get_history", []any{scriptHash}, next()); err != nil {
return nil, err
}
- histLine, err := reader.ReadString('\n')
+ histLine, err := conn.readResponse(reqCtx)
if err != nil {
return nil, fmt.Errorf("namecoin: read history response: %w", err)
}
@@ -108,19 +127,19 @@ func (c *ElectrumClient) NameShow(ctx context.Context, identifier string, server
latest := entries[len(entries)-1]
// 4. Fetch the verbose transaction.
- if err := c.sendRPC(conn, "blockchain.transaction.get", []any{latest.TxHash, true}); err != nil {
+ if err := conn.writeRequest(reqCtx, "blockchain.transaction.get", []any{latest.TxHash, true}, next()); err != nil {
return nil, err
}
- txLine, err := reader.ReadString('\n')
+ txLine, err := conn.readResponse(reqCtx)
if err != nil {
return nil, fmt.Errorf("namecoin: read transaction response: %w", err)
}
// 5. Get the current block height so we can compute expiry.
- if err := c.sendRPC(conn, "blockchain.headers.subscribe", []any{}); err != nil {
+ if err := conn.writeRequest(reqCtx, "blockchain.headers.subscribe", []any{}, next()); err != nil {
return nil, err
}
- headerLine, _ := reader.ReadString('\n')
+ headerLine, _ := conn.readResponse(reqCtx)
currentHeight := parseBlockHeight(headerLine)
if currentHeight > 0 && latest.Height > 0 {
@@ -161,10 +180,23 @@ func (c *ElectrumClient) NameShowWithFallback(ctx context.Context, identifier st
return nil, fmt.Errorf("%w: last error: %v", ErrServersUnreachable, lastErr)
}
-// dial opens a TCP connection to the server, upgrading to TLS when
-// UseSSL is set. Honours both context cancellation and our connect
-// timeout, whichever fires first.
-func (c *ElectrumClient) dial(ctx context.Context, server ElectrumxServer) (net.Conn, error) {
+// dial picks the right transport implementation based on the server
+// configuration and returns a ready-to-use rpcConn.
+func (c *ElectrumClient) dial(ctx context.Context, server ElectrumxServer) (rpcConn, error) {
+ switch effectiveTransport(server) {
+ case TransportTCPTLS, TransportTCP:
+ return c.dialTCP(ctx, server)
+ case TransportWSS, TransportWS:
+ return c.dialWebSocket(ctx, server)
+ default:
+ return nil, fmt.Errorf("namecoin: unknown transport %d", server.Transport)
+ }
+}
+
+// dialTCP opens a raw TCP connection to the server, upgrading to TLS
+// when the effective transport is TCPTLS. Honours both context
+// cancellation and our connect timeout, whichever fires first.
+func (c *ElectrumClient) dialTCP(ctx context.Context, server ElectrumxServer) (rpcConn, error) {
dialer := &net.Dialer{Timeout: c.ConnectTimeout}
address := net.JoinHostPort(server.Host, strconv.Itoa(server.Port))
@@ -173,34 +205,41 @@ func (c *ElectrumClient) dial(ctx context.Context, server ElectrumxServer) (net.
return nil, fmt.Errorf("namecoin: dial %s: %w", address, err)
}
- if !server.UseSSL {
- return raw, nil
+ var nc net.Conn = raw
+ if effectiveTransport(server) == TransportTCPTLS {
+ cfg := tlsConfigFor(server)
+ tlsConn := tls.Client(raw, cfg)
+
+ // Run the handshake with a deadline so a silent server doesn't hang
+ // the call beyond what the caller asked for.
+ if deadline, ok := ctx.Deadline(); ok {
+ _ = tlsConn.SetDeadline(deadline)
+ } else {
+ _ = tlsConn.SetDeadline(time.Now().Add(c.ConnectTimeout))
+ }
+ if err := tlsConn.HandshakeContext(ctx); err != nil {
+ raw.Close()
+ return nil, fmt.Errorf("namecoin: TLS handshake with %s: %w", address, err)
+ }
+ // Clear the handshake deadline — we'll set fresh per-request
+ // deadlines below.
+ _ = tlsConn.SetDeadline(time.Time{})
+ nc = tlsConn
}
- cfg := tlsConfigFor(server)
- tlsConn := tls.Client(raw, cfg)
+ _ = nc.SetDeadline(time.Now().Add(c.ReadTimeout))
+ return &tcpRPCConn{conn: nc, reader: bufio.NewReader(nc)}, nil
+}
- // Run the handshake with a deadline so a silent server doesn't hang
- // the call beyond what the caller asked for.
- if deadline, ok := ctx.Deadline(); ok {
- _ = tlsConn.SetDeadline(deadline)
- } else {
- _ = tlsConn.SetDeadline(time.Now().Add(c.ConnectTimeout))
- }
- if err := tlsConn.HandshakeContext(ctx); err != nil {
- raw.Close()
- return nil, fmt.Errorf("namecoin: TLS handshake with %s: %w", address, err)
- }
- // Clear the handshake deadline — the caller sets its own read
- // deadline afterwards.
- _ = tlsConn.SetDeadline(time.Time{})
- return tlsConn, nil
+// tcpRPCConn is the newline-delimited JSON-RPC transport over raw
+// TCP or TCP+TLS. This matches the shape every Electrum / ElectrumX
+// client has used for the last decade.
+type tcpRPCConn struct {
+ conn net.Conn
+ reader *bufio.Reader
}
-// sendRPC writes a JSON-RPC 2.0 request (newline-terminated, per
-// Electrum's line-delimited protocol) to the connection.
-func (c *ElectrumClient) sendRPC(w net.Conn, method string, params []any) error {
- id := c.requestID.Add(1)
+func (t *tcpRPCConn) writeRequest(ctx context.Context, method string, params []any, id int64) error {
payload := map[string]any{
"jsonrpc": "2.0",
"id": id,
@@ -212,12 +251,30 @@ func (c *ElectrumClient) sendRPC(w net.Conn, method string, params []any) error
return fmt.Errorf("namecoin: marshal rpc request: %w", err)
}
encoded = append(encoded, '\n')
- if _, err := w.Write(encoded); err != nil {
+ if deadline, ok := ctx.Deadline(); ok {
+ _ = t.conn.SetWriteDeadline(deadline)
+ }
+ if _, err := t.conn.Write(encoded); err != nil {
return fmt.Errorf("namecoin: write rpc request: %w", err)
}
return nil
}
+func (t *tcpRPCConn) readResponse(ctx context.Context) (string, error) {
+ if deadline, ok := ctx.Deadline(); ok {
+ _ = t.conn.SetReadDeadline(deadline)
+ }
+ line, err := t.reader.ReadString('\n')
+ if err != nil {
+ return "", err
+ }
+ return line, nil
+}
+
+func (t *tcpRPCConn) close() error {
+ return t.conn.Close()
+}
+
// historyEntry is one row of `blockchain.scripthash.get_history`.
type historyEntry struct {
TxHash string
diff --git a/nip05/namecoin/electrumx_integration_test.go b/nip05/namecoin/electrumx_integration_test.go
index 1458d4d..64ac17c 100644
--- a/nip05/namecoin/electrumx_integration_test.go
+++ b/nip05/namecoin/electrumx_integration_test.go
@@ -49,3 +49,53 @@ func TestIntegration_ResolveAtTestlsBit(t *testing.T) {
t.Errorf("m@testls.bit pubkey = %s, want %s", got, testlsMPubkey)
}
}
+
+// --- WSS (WebSocket over TLS) integration tests -----------------------
+//
+// These hit the same ElectrumX operators as the TCP tests above, but
+// over the WebSocket endpoints (ports 500xx+2 on testls, 570xx+2 on
+// nmc2.bitcoins.sk). Same pinned certs, same JSON-RPC protocol, just
+// different framing. The assertions match the TCP path because the
+// Namecoin blockchain is the source of truth.
+
+func TestIntegration_ResolveTestlsBit_WSS(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ client := NewElectrumClient()
+ result, err := client.NameShowWithFallback(ctx, "d/testls", DefaultElectrumXServersWSS)
+ if err != nil {
+ t.Fatalf("NameShowWithFallback(d/testls, WSS): %v", err)
+ }
+ if result == nil {
+ t.Fatal("nil result")
+ }
+ pk, _, err := extractNostrFromValue(result.Value, &parsedIdentifier{"d/testls", "_", true})
+ if err != nil {
+ t.Fatalf("extractNostrFromValue: %v", err)
+ }
+ if pk != testlsRootPubkey {
+ t.Errorf("testls.bit (WSS) pubkey = %s, want %s", pk, testlsRootPubkey)
+ }
+}
+
+func TestIntegration_ResolveAtTestlsBit_WSS(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ client := NewElectrumClient()
+ result, err := client.NameShowWithFallback(ctx, "d/testls", DefaultElectrumXServersWSS)
+ if err != nil {
+ t.Fatalf("NameShowWithFallback(d/testls, WSS): %v", err)
+ }
+ if result == nil {
+ t.Fatal("nil result")
+ }
+ pk, _, err := extractNostrFromValue(result.Value, &parsedIdentifier{"d/testls", "m", true})
+ if err != nil {
+ t.Fatalf("extractNostrFromValue: %v", err)
+ }
+ if pk != testlsMPubkey {
+ t.Errorf("m@testls.bit (WSS) pubkey = %s, want %s", pk, testlsMPubkey)
+ }
+}
diff --git a/nip05/namecoin/servers.go b/nip05/namecoin/servers.go
index b76c889..71c8551 100644
--- a/nip05/namecoin/servers.go
+++ b/nip05/namecoin/servers.go
@@ -1,15 +1,59 @@
package namecoin
+// Transport selects how an ElectrumX server is dialed. The zero value
+// is TransportTCPTLS, matching the traditional Electrum client path
+// and the behaviour shipped in v0.1.x. Callers that want WebSocket
+// (for example, browser/wasm targets or restricted networks that
+// block raw TCP) set this to TransportWSS.
+type Transport int
+
+const (
+ // TransportTCPTLS is raw TCP + TLS with newline-delimited JSON-RPC.
+ // This is the default and what most Namecoin ElectrumX clients use.
+ TransportTCPTLS Transport = iota
+ // TransportTCP is plaintext TCP — rarely used in practice but
+ // supported for completeness / local testing.
+ TransportTCP
+ // TransportWSS is WebSocket over TLS, using the ElectrumX WS
+ // endpoint (ports 500xx+2 on typical public servers). Messages
+ // are pre-framed by the WebSocket layer and MUST NOT carry a
+ // trailing newline.
+ TransportWSS
+ // TransportWS is plaintext WebSocket.
+ TransportWS
+)
+
// ElectrumxServer is a single Namecoin ElectrumX endpoint.
type ElectrumxServer struct {
Host string
Port int
// UseSSL indicates TLS should be negotiated over the raw socket.
+ // Kept for backwards compat with v0.1.x callers. When Transport
+ // is the zero value, UseSSL selects between TransportTCPTLS
+ // (UseSSL=true) and TransportTCP (UseSSL=false).
UseSSL bool
// UsePinnedTrustStore enables the pinned-cert trust anchor bundle
// for servers that present self-signed certificates. This is the
// norm for the public Namecoin ElectrumX ecosystem.
UsePinnedTrustStore bool
+ // Transport selects the dial transport. Zero value keeps v0.1.x
+ // semantics: if UseSSL is true it's TCP+TLS, otherwise plain TCP.
+ // Set explicitly to TransportWSS / TransportWS for WebSocket.
+ Transport Transport
+}
+
+// effectiveTransport resolves the transport to use for a server,
+// reconciling the legacy UseSSL bool with the explicit Transport
+// field. The explicit Transport wins when non-zero.
+func effectiveTransport(s ElectrumxServer) Transport {
+ if s.Transport != TransportTCPTLS {
+ return s.Transport
+ }
+ // Zero value: fall back to UseSSL for v0.1.x compatibility.
+ if s.UseSSL {
+ return TransportTCPTLS
+ }
+ return TransportTCP
}
// DefaultElectrumXServers is the built-in list of public Namecoin
@@ -24,11 +68,37 @@ var DefaultElectrumXServers = []ElectrumxServer{
{Host: "46.229.238.187", Port: 57002, UseSSL: true, UsePinnedTrustStore: true},
}
+// DefaultElectrumXServersWSS is the WSS-preferred server list. It
+// points at the same operators as DefaultElectrumXServers but uses
+// their WebSocket-over-TLS endpoints. Useful for:
+//
+// - Browser / wasm targets, where raw TCP is unavailable and the
+// only viable transport is the browser's WebSocket API (which
+// `github.com/coder/websocket` targets natively when compiled
+// with GOOS=js GOARCH=wasm).
+// - Restricted networks that block the traditional ElectrumX ports
+// but allow outbound HTTPS/WSS.
+// - Environments that prefer WSS on policy grounds (aligns with the
+// broader Nostr ecosystem, which is WSS-everywhere).
+//
+// Ports are the ElectrumX convention of ELECTRUM_PORT+2 for the WSS
+// endpoint. Both listed operators expose these alongside their
+// TCP+TLS ports, sharing the same TLS certificate.
+var DefaultElectrumXServersWSS = []ElectrumxServer{
+ {Host: "electrumx.testls.space", Port: 50004, Transport: TransportWSS, UsePinnedTrustStore: true},
+ {Host: "nmc2.bitcoins.sk", Port: 57004, Transport: TransportWSS, UsePinnedTrustStore: true},
+ {Host: "46.229.238.187", Port: 57004, Transport: TransportWSS, UsePinnedTrustStore: true},
+}
+
// PinnedElectrumXCerts is the PEM-encoded bundle of server
// certificates we trust in addition to the system roots. These are
// copied verbatim from the Kotlin Amethyst reference implementation.
//
// To refresh: `echo | openssl s_client -connect HOST:PORT 2>/dev/null | openssl x509 -outform PEM`
+//
+// The same certificates are served on the WSS endpoints (adjacent
+// ports on the same host), so a single pinned bundle covers both
+// transports.
var PinnedElectrumXCerts = []string{
// electrumx.testls.space:50002 — expires 2027-05-04.
// Also covers the i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion:50002
diff --git a/nip05/namecoin/websocket.go b/nip05/namecoin/websocket.go
new file mode 100644
index 0000000..5e849d7
--- /dev/null
+++ b/nip05/namecoin/websocket.go
@@ -0,0 +1,105 @@
+package namecoin
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net"
+ "net/http"
+ "strconv"
+
+ "github.com/coder/websocket"
+)
+
+// wsReadLimit is the maximum size of a single WebSocket message we are
+// willing to accept from an ElectrumX server. Verbose transactions
+// from `blockchain.transaction.get` can legitimately run to tens of
+// kilobytes; 1 MiB is generous but keeps us safely bounded.
+const wsReadLimit int64 = 1 << 20
+
+// dialWebSocket opens a WebSocket (plain or TLS) RPC connection to
+// the given ElectrumX server. It reuses the same pinned-cert TLS
+// config as the TCP+TLS transport so operators who serve a single
+// self-signed certificate across both ports are handled identically.
+func (c *ElectrumClient) dialWebSocket(ctx context.Context, server ElectrumxServer) (rpcConn, error) {
+ transport := effectiveTransport(server)
+ scheme := "ws"
+ if transport == TransportWSS {
+ scheme = "wss"
+ }
+ address := net.JoinHostPort(server.Host, strconv.Itoa(server.Port))
+ url := scheme + "://" + address
+
+ // Bound the dial to our connect timeout, in addition to honouring
+ // any deadline on the caller's context.
+ dialCtx, cancel := context.WithTimeout(ctx, c.ConnectTimeout)
+ defer cancel()
+
+ opts := &websocket.DialOptions{}
+ if transport == TransportWSS {
+ // Build an http.Transport that carries the same *tls.Config
+ // the TCP+TLS path uses. The custom VerifyPeerCertificate is
+ // transport-agnostic, so pinned-cert trust continues to work
+ // over WebSocket's HTTP upgrade handshake.
+ tlsCfg := tlsConfigFor(server)
+ opts.HTTPClient = &http.Client{
+ Timeout: c.ConnectTimeout + c.ReadTimeout,
+ Transport: &http.Transport{
+ TLSClientConfig: tlsCfg,
+ TLSHandshakeTimeout: c.ConnectTimeout,
+ },
+ }
+ }
+
+ conn, _, err := websocket.Dial(dialCtx, url, opts)
+ if err != nil {
+ return nil, fmt.Errorf("namecoin: websocket dial %s: %w", url, err)
+ }
+ conn.SetReadLimit(wsReadLimit)
+
+ return &wsRPCConn{conn: conn}, nil
+}
+
+// wsRPCConn is the WebSocket JSON-RPC transport. Unlike the TCP
+// variant, WebSocket messages are pre-framed by the protocol layer,
+// so requests MUST NOT carry a trailing newline and responses arrive
+// as whole JSON messages per frame.
+type wsRPCConn struct {
+ conn *websocket.Conn
+}
+
+func (w *wsRPCConn) writeRequest(ctx context.Context, method string, params []any, id int64) error {
+ payload := map[string]any{
+ "jsonrpc": "2.0",
+ "id": id,
+ "method": method,
+ "params": params,
+ }
+ encoded, err := json.Marshal(payload)
+ if err != nil {
+ return fmt.Errorf("namecoin: marshal rpc request: %w", err)
+ }
+ if err := w.conn.Write(ctx, websocket.MessageText, encoded); err != nil {
+ return fmt.Errorf("namecoin: write rpc request: %w", err)
+ }
+ return nil
+}
+
+func (w *wsRPCConn) readResponse(ctx context.Context) (string, error) {
+ _, data, err := w.conn.Read(ctx)
+ if err != nil {
+ return "", err
+ }
+ return string(data), nil
+}
+
+func (w *wsRPCConn) close() error {
+ return w.conn.Close(websocket.StatusNormalClosure, "")
+}
+
+// Compile-time interface checks — catch drift early if the rpcConn
+// signature ever evolves.
+var (
+ _ rpcConn = (*tcpRPCConn)(nil)
+ _ rpcConn = (*wsRPCConn)(nil)
+)
diff --git a/nip05/namecoin/websocket_test.go b/nip05/namecoin/websocket_test.go
new file mode 100644
index 0000000..ad5682a
--- /dev/null
+++ b/nip05/namecoin/websocket_test.go
@@ -0,0 +1,195 @@
+package namecoin
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/coder/websocket"
+)
+
+// fakeElectrumXWSHandler returns an http.Handler that upgrades incoming
+// connections to WebSocket and serves canned JSON-RPC responses for the
+// sequence the ElectrumClient issues during NameShow.
+//
+// The response ordering mirrors the real RPC sequence:
+// 1. server.version → any non-error result
+// 2. blockchain.scripthash.get_history → one entry pointing at a fake txid
+// 3. blockchain.transaction.get → one vout with a NAME_UPDATE that
+// matches the requested name and a
+// value whose "nostr" field is a
+// known hex pubkey
+// 4. blockchain.headers.subscribe → a height far enough below our
+// fake tx height that the name is
+// well within its expiry window
+//
+// This exercises the whole WS dial → read → parse path end-to-end
+// without hitting the network.
+func fakeElectrumXWSHandler(t *testing.T, identifier, pubkeyHex string) http.Handler {
+ // Build the NAME_UPDATE script hex we want the client to parse
+ // back out of the "transaction".
+ value := `{"nostr":"` + pubkeyHex + `"}`
+ script := []byte{opNameUpdate}
+ script = append(script, pushData([]byte(identifier))...)
+ script = append(script, pushData([]byte(value))...)
+ script = append(script, op2Drop, opDrop)
+ // Stub P2PKH-ish tail so the parser's "we don't care what follows
+ // the DROPs" assumption holds.
+ script = append(script, 0x76, 0xa9, 0x14)
+ scriptHex := hexString(script)
+
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ conn, err := websocket.Accept(w, r, nil)
+ if err != nil {
+ t.Logf("ws accept: %v", err)
+ return
+ }
+ defer conn.Close(websocket.StatusInternalError, "test server shutting down")
+
+ ctx := r.Context()
+
+ respond := func(id any, result any) {
+ payload := map[string]any{
+ "jsonrpc": "2.0",
+ "id": id,
+ "result": result,
+ }
+ raw, _ := json.Marshal(payload)
+ if err := conn.Write(ctx, websocket.MessageText, raw); err != nil {
+ t.Logf("ws write: %v", err)
+ }
+ }
+
+ for {
+ _, data, err := conn.Read(ctx)
+ if err != nil {
+ return
+ }
+ var req struct {
+ ID any `json:"id"`
+ Method string `json:"method"`
+ }
+ if err := json.Unmarshal(data, &req); err != nil {
+ t.Logf("ws request parse: %v", err)
+ return
+ }
+ switch req.Method {
+ case "server.version":
+ respond(req.ID, []string{"ElectrumX 1.16.0", electrumProtocolVersion})
+ case "blockchain.scripthash.get_history":
+ respond(req.ID, []map[string]any{
+ {"tx_hash": "deadbeefcafebabe", "height": 600000},
+ })
+ case "blockchain.transaction.get":
+ respond(req.ID, map[string]any{
+ "txid": "deadbeefcafebabe",
+ "vout": []map[string]any{
+ {"scriptPubKey": map[string]any{"hex": scriptHex}},
+ },
+ })
+ case "blockchain.headers.subscribe":
+ respond(req.ID, map[string]any{"height": 600010})
+ default:
+ respond(req.ID, nil)
+ }
+ }
+ })
+}
+
+func hexString(b []byte) string {
+ const hexDigits = "0123456789abcdef"
+ out := make([]byte, 0, len(b)*2)
+ for _, v := range b {
+ out = append(out, hexDigits[v>>4], hexDigits[v&0x0f])
+ }
+ return string(out)
+}
+
+func TestWebSocketTransport_NameShow(t *testing.T) {
+ identifier := "d/wsfake"
+ pubkey := "aaaa000000000000000000000000000000000000000000000000000000000001"
+
+ ts := httptest.NewServer(fakeElectrumXWSHandler(t, identifier, pubkey))
+ defer ts.Close()
+
+ // Parse the httptest URL so we can point the client at it as
+ // host+port with the plain-WS transport. httptest.Server is HTTP,
+ // so TransportWS (not WSS) is what we want here — it verifies the
+ // framing/routing independently of the TLS plumbing.
+ u, err := url.Parse(ts.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ port, err := strconv.Atoi(u.Port())
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ srv := ElectrumxServer{
+ Host: u.Hostname(),
+ Port: port,
+ Transport: TransportWS,
+ }
+
+ client := NewElectrumClient()
+ client.ConnectTimeout = 3 * time.Second
+ client.ReadTimeout = 5 * time.Second
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ result, err := client.NameShow(ctx, identifier, srv)
+ if err != nil {
+ t.Fatalf("NameShow over WS: %v", err)
+ }
+ if result == nil {
+ t.Fatal("NameShow returned nil result")
+ }
+ if result.Name != identifier {
+ t.Errorf("name = %q, want %q", result.Name, identifier)
+ }
+ if !strings.Contains(result.Value, pubkey) {
+ t.Errorf("value = %q, want to contain %q", result.Value, pubkey)
+ }
+}
+
+func TestEffectiveTransport(t *testing.T) {
+ cases := []struct {
+ in ElectrumxServer
+ want Transport
+ }{
+ // v0.1.x shape: UseSSL=true, Transport unset → TCPTLS.
+ {ElectrumxServer{UseSSL: true}, TransportTCPTLS},
+ // v0.1.x shape: UseSSL=false, Transport unset → plain TCP.
+ {ElectrumxServer{UseSSL: false}, TransportTCP},
+ // Explicit Transport wins over UseSSL when non-zero.
+ {ElectrumxServer{UseSSL: true, Transport: TransportWSS}, TransportWSS},
+ {ElectrumxServer{UseSSL: false, Transport: TransportWS}, TransportWS},
+ {ElectrumxServer{UseSSL: false, Transport: TransportTCPTLS}, TransportTCP}, // TCPTLS is zero value; falls back to UseSSL
+ }
+ for i, tc := range cases {
+ if got := effectiveTransport(tc.in); got != tc.want {
+ t.Errorf("case %d: effectiveTransport(%+v) = %d, want %d", i, tc.in, got, tc.want)
+ }
+ }
+}
+
+func TestDefaultElectrumXServersWSS(t *testing.T) {
+ if len(DefaultElectrumXServersWSS) == 0 {
+ t.Fatal("DefaultElectrumXServersWSS is empty")
+ }
+ for _, srv := range DefaultElectrumXServersWSS {
+ if srv.Transport != TransportWSS {
+ t.Errorf("%s:%d has Transport=%d, want WSS", srv.Host, srv.Port, srv.Transport)
+ }
+ if !srv.UsePinnedTrustStore {
+ t.Errorf("%s:%d: WSS default servers must pin the trust store", srv.Host, srv.Port)
+ }
+ }
+}
--
2.50.1 (Apple Git-155)
mstrofnone on Nostr: follow-up patch adds WebSocket (WSS) transport — enables browser/wasm use of ...
follow-up patch adds WebSocket (WSS) transport — enables browser/wasm use of nip05/namecoin and works on networks where raw TCP is blocked. same pinned certs, same RPC, just different framing.