Skip to content

Feat/electrum support#160

Open
bitcoin-coder-bob wants to merge 28 commits into
masterfrom
feat/electrum-support
Open

Feat/electrum support#160
bitcoin-coder-bob wants to merge 28 commits into
masterfrom
feat/electrum-support

Conversation

@bitcoin-coder-bob
Copy link
Copy Markdown
Contributor

@bitcoin-coder-bob bitcoin-coder-bob commented Apr 29, 2026

closes #68

What

Adds an ElectrumX-backed Explorer implementation so users can point the SDK at a self-hosted server instead of the third-party mempool.space API. The new backend speaks JSON-RPC over a persistent TCP or SSL connection and covers every operation the SDK requires: boarding, unrolls, and restore.

A new WithElectrumExplorer init option selects the backend. A companion WithElectrumPackageBroadcastURL option supplies an esplora-compatible REST base URL for broadcasting transaction packages (required for zero-fee v3 transactions with P2A anchors that Bitcoin Core rejects via sendrawtransaction). The stored URL is self-describing, so LoadArkClient picks up the right implementation automatically from the persisted config.

HD wallet restore now also queries the explorer for boarding-address transaction history during key discovery, so keys funded only via boarding (on-chain, never receiving an offchain VTXO) are recovered correctly.

Usage

Pass WithElectrumExplorer when calling Init to swap the explorer backend:

// TCP (plaintext) — e.g. local regtest
err := client.Init(ctx, serverURL, seed, password,
    arksdk.WithElectrumExplorer("tcp://127.0.0.1:50001"),
)

// SSL — e.g. public mainnet server
err := client.Init(ctx, serverURL, seed, password,
    arksdk.WithElectrumExplorer("ssl://electrum.blockstream.info:700"),
)

// With package broadcast support (for Ark commitment TXs)
err := client.Init(ctx, serverURL, seed, password,
    arksdk.WithElectrumExplorer("ssl://electrum.blockstream.info:700"),
    arksdk.WithElectrumPackageBroadcastURL("https://mempool.space/api"),
)

LoadArkClient needs no extra option — it reads the tcp:// or ssl:// prefix from the stored config and routes to the electrum backend automatically:

client, err := arksdk.LoadArkClient(datadir)

The default (no option, or WithExplorerURL) continues to use mempool.space unchanged.

Why

The existing mempool.space explorer is convenient but it is a third-party service with rate limits, potential downtime, and no support for private/regtest environments. ElectrumX is the standard self-hosted indexer for Bitcoin and is already used in production by the ocean project. Offering it as a first-class backend gives operators full control over their infrastructure without giving up any SDK functionality.

How

The transport is a self-contained TCP client (no third-party go-electrum dependency) modeled on the ocean project's electrum scanner. It multiplexes all requests over a single connection using atomic JSON-RPC IDs, routes push notifications to per-address channels, and replays subscriptions on reconnect.

A newExplorer factory selects the implementation based on the URL prefix: tcp:// or ssl:// routes to electrum; everything else routes to mempool.space. This keeps the selection logic in one place for both Init and LoadArkClient. The default regtest explorer URL is now tcp://127.0.0.1:50001 (electrum) instead of the previous mempool.space local URL.

The explorer event channel is pre-registered before the sync signal is sent, so events fired immediately after unlock (e.g., from NewBoardingAddress) are not dropped before listenForOnchainTxs starts consuming. SubscribeForAddresses retries with a 3-second backoff instead of bailing on first failure.

The Explorer interface is implemented method-by-method against ElectrumX primitives:

  • GetTxHexblockchain.transaction.get
  • Broadcastblockchain.transaction.broadcast (sequential for CPFP; parent lands in mempool before child is sent)
  • GetTxsblockchain.scripthash.get_history + verbose tx fetch
  • GetUtxosblockchain.scripthash.listunspent
  • GetTxOutspends — derived by scanning scripthash history for each output (ElectrumX has no dedicated endpoint)
  • GetTxBlockTime — verbose tx blocktime field, with fallback to blockchain.block.header parsing
  • GetFeeRateblockchain.estimatefee
  • Address tracking — blockchain.scripthash.subscribe push notifications with a polling fallback

Parity with the mempool explorer

Getting the field mapping right between a float-denominated JSON-RPC protocol and the uint64-denominated internal types requires care. Issues caught and fixed during the audit:

  • BTC float truncation: uint64(value * 1e8) silently drops satoshis for amounts like 1.23456789 BTC (gives 123456788 instead of 123456789). Fixed with math.Round.
  • Redundant block header RPCs: GetTxs and GetTxBlockTime were fetching blockchain.block.header even though the verbose tx response already carries blocktime. Now uses it directly, falling back to the header only when the field is zero.
  • Output.Address missing: addressTxHistoryToUtxos filters outputs by vout.Address != address, so every UTXO would silently drop if Address was empty. Fixed by deriving it from the output script via txscript.ParsePkScript.
  • CreatedAt and SpentBy not populated on events: pollAddress now enriches newly seen, confirmed, and spent UTXOs with block timestamps and spending txids respectively.
  • WithTracker(true) no-op: the option was written as if !withTracker { svc.noTracking = true } and never cleared the flag for the true case.
  • Regtest/mutinynet addresses unrecognised: networkToChainParams was missing cases for BitcoinRegTest and BitcoinMutinyNet, causing address decoding to fail silently.
  • Explorer events dropped on unlock: The event channel was obtained after syncCh was signalled. Any UTXO event fired during the gap (e.g., from NewBoardingAddress called immediately after Unlock) was silently lost. Fixed by pre-registering the channel before the sync signal.
  • SubscribeForAddresses not retried: A transient connection error during subscription would abort the listener permanently. Now retries with a 3-second backoff until the context is cancelled.

Test coverage

All methods are tested against a mock TCP server built with net.Pipe. Tests pin every field of every return type against the interface contract: Status.BlockTime=0 (not -1) for unconfirmed transactions, Input.Txid/Vout/Address/Amount/Script populated from prevout data, per-output Spent/SpentBy correctness for multi-vout transactions, locked vs spendable split in GetRedeemedVtxosBalance, multi-tx Broadcast returning the first txid, GetTxHex served from cache after Broadcast, and push-notification-triggered events carrying correct CreatedAt and SpentBy values.

Integration (e2e) tests now run against an electrum backend rather than mempool.space. TestHDWalletRecoversBoardingOnlyFundedKeys, previously skipped, is now enabled and covers boarding-only funded key recovery.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 29, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds a new ElectrumX-backed explorer and multiplexed Electrum client, wires Electrum selection into SDK init/options (WithElectrumExplorer, optional Esplora package broadcast), updates LoadArkClient/init defaults to electrum regtest, and adds extensive Electrum tests, docs, README examples and test wiring.

Changes

Electrum Explorer + SDK integration

Layer / File(s) Summary
Wire / Types / Utilities
explorer/electrum/types.go, explorer/electrum/utils.go
Adds Electrum/JSON-RPC wire types and helpers: normalized RPC error parsing, address↔scripthash conversion, script→address, canonical tx parsing, and a non-blocking listeners hub.
Transport / Client
explorer/electrum/client.go
New multiplexed Electrum TCP/SSL JSON-RPC client: dial (tcp:///ssl:// with TLS1.2+), server.version handshake, newline-scanner listener, keepalive ping, per-request channels/timeouts, subscription notifications, in-flight failure on reconnect, exponential backoff reconnect, bounded resubscribe replay, and shutdown.
Explorer Service (Core)
explorer/electrum/explorer.go, explorer/electrum/opts.go
New electrum_explorer service and Option API: NewExplorer, Start/Stop, metadata APIs, GetTxHex/Broadcast/GetTxs/GetTxOutspends/GetUtxos/GetRedeemedVtxosBalance/GetTxBlockTime/GetFeeRate, Subscribe/Unsubscribe, caching, broadcast (sequential + optional Esplora package), history/UTXO/spend scanning, push-driven polling and event diffing.
Init / SDK Wiring
init.go, init_opts.go, client.go, init_opts_test.go, client_test.go
Adds WithElectrumExplorer and WithElectrumPackageBroadcastURL, validates scheme/conflict, refactors SDK Init to choose electrum vs mempool by URL scheme, computes regtest pollInterval and defaults, updates LoadArkClient to use newExplorer, and updates tests/defaults to tcp://127.0.0.1:50001.
Tests (unit/integration)
explorer/electrum/explorer_test.go, explorer/electrum/break_test.go, explorer/electrum/*
Extensive mock-server tests covering URL validation, RPC behavior, caching, broadcast semantics, fee conversion, subscription lifecycle, concurrency/timeouts, reconnection/resubscribe behavior, goroutine-leak regression, UTXO/spend logic, and push-triggered events.
Documentation / README / Examples
README.md, explorer/electrum/doc.go
README examples for WithElectrumExplorer (ssl/tcp) and package doc describing architecture, lifecycle, reconnection, and concurrency contracts.
Build / Test config & e2e
Makefile, test/docker/docker-compose.yml, test/e2e/*
integrationtest target now exports ARK_ELECTRUM_URL/ARK_ESPLORA_URL; docker-compose host PG port changed to 5433; e2e helpers read env vars and pass WithElectrumPackageBroadcastURL when set; an e2e balance assertion relaxed.

Sequence Diagram(s)

sequenceDiagram
    rect rgba(200,200,255,0.5)
    participant SDK as SDK Client
    participant Init as Explorer Factory
    participant ElecSvc as ElectrumX Explorer
    participant Server as ElectrumX Server
    participant Cache as Local Cache
    end
    SDK->>Init: newExplorer(url, network, opts)
    Init->>Init: detect scheme (tcp:// or ssl://)
    Init->>ElecSvc: NewExplorer(url,...)
    SDK->>ElecSvc: Start()
    ElecSvc->>Server: TCP/TLS connect & server.version
    Server-->>ElecSvc: version response
    ElecSvc->>ElecSvc: start listener & keepAlive
    SDK->>ElecSvc: SubscribeForAddresses(addrs)
    ElecSvc->>Server: blockchain.scripthash.subscribe
    Server-->>ElecSvc: initial status
    Server->>ElecSvc: push notification
    ElecSvc->>ElecSvc: pollAddress() -> listunspent
    ElecSvc->>Cache: update cache
    ElecSvc->>SDK: OnchainAddressEvent
Loading
sequenceDiagram
    rect rgba(200,255,200,0.5)
    participant Client as ElectrumX Client
    participant Conn as TCP/TLS Conn
    participant Reader as JSON Reader
    participant Handler as Response Handler
    end
    Client->>Conn: dial (tcp/ssl)
    Client->>Conn: send server.version
    loop read loop
        Reader->>Conn: read newline JSON frame
        Conn-->>Reader: frame
        Reader->>Handler: parse JSON
        alt response with id
            Handler->>Handler: dispatch to pending request channel
        else notification
            Handler->>Handler: deliver to subscriber listeners (non-blocking)
        end
    end
    par keepalive
        Client->>Conn: server.ping (periodic)
    and reconnect
        Client->>Client: reconnect/backoff, replay subscriptions (bounded parallelism)
    end
Loading
sequenceDiagram
    rect rgba(255,200,200,0.5)
    participant Explorer as ElectrumX Explorer
    participant Cache as TX Cache
    participant Server as ElectrumX Server
    participant Esplora as Esplora REST
    end
    Explorer->>Cache: GetTxHex(txid) -> cache hit?
    alt cache hit
        Cache-->>Explorer: return tx hex
    else cache miss
        Explorer->>Server: blockchain.transaction.get(txid, verbose=true)
        Server-->>Explorer: tx hex
        Explorer->>Cache: cache tx hex
        Explorer-->>Caller: return tx hex
    end
    Caller->>Explorer: Broadcast(txHexs...)
    alt multiple txs + esplora configured
        Explorer->>Esplora: POST /txs/package
        Esplora-->>Explorer: package response (txids)
    else sequential broadcast
        Explorer->>Server: blockchain.transaction.broadcast (per tx)
        Server-->>Explorer: txid or error
    end
    Explorer->>Cache: cache broadcast tx hex on success
    Explorer-->>Caller: txid / error
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

  • arkade-os/go-sdk#28: Overlaps explorer connection/subscription architecture and public explorer API changes.
  • arkade-os/go-sdk#6: Adds event-driven explorer surface and Option API similar to this PR.
  • arkade-os/go-sdk#35: Related explorer implementation/refactor and WithPollInterval changes.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 79.27% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Feat/electrum support' is directly related to the main changeset, which implements ElectrumX-backed Explorer support as the primary objective.
Linked Issues check ✅ Passed The PR fully implements issue #68 requirements: provides ElectrumX as an alternative on-chain backend for boarding/unrolls/restore operations via WithElectrumExplorer(url), enables self-hosted server support, and removes reliance on third-party HTTP explorers.
Out of Scope Changes check ✅ Passed All changes are within scope: ElectrumX client/explorer implementation, init option APIs, test infrastructure updates, documentation, and supporting fixes (BTC rounding, address derivation, balance calculations) directly enable ElectrumX support.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/electrum-support

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@bitcoin-coder-bob bitcoin-coder-bob marked this pull request as ready for review April 29, 2026 22:38
@bitcoin-coder-bob
Copy link
Copy Markdown
Contributor Author

@arkanaai @coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 29, 2026

@bitcoin-coder-bob: I'll review the changes in this PR now.


🧠 Learnings used
Learnt from: bitcoin-coder-bob
Repo: arkade-os/go-sdk PR: 0
File: :0-0
Timestamp: 2026-04-22T16:50:05.830Z
Learning: In arkade-os/go-sdk PR `#153` (contract watcher), the following MEDIUM findings were explicitly deferred to follow-up PRs by maintainer bitcoin-coder-bob: `#7` TOCTOU in Load() (functionally safe due to upsert), `#9` SELECT * in contract store (fragile on schema changes), `#10` contract reload per ark tx event (O(n) per event, consider caching), `#11` missing tests for new public API methods.
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@arkanaai
Copy link
Copy Markdown

arkanaai Bot commented Apr 29, 2026

Arkana Review — ElectrumX Explorer

Solid piece of work. The self-contained TCP client, the newExplorer factory dispatch, the math.Round BTC-float fix, and the WithTracker inversion fix are all correct and well-motivated. The mock-server test suite is thorough. A few things worth addressing before merging:


🔴 Bug: pollInterval not forwarded to the Electrum explorer

In init.go / client.go, newExplorer computes pollInterval = 2 * time.Second for regtest but only passes it to the mempool path:

// init.go — newExplorer
if strings.HasPrefix(url, "tcp://") || strings.HasPrefix(url, "ssl://") {
    return electrum_explorer.NewExplorer(
        url, net, electrum_explorer.WithTracker(tracker),
        // ← pollInterval silently dropped
    )
}

WithPollInterval exists in opts.go but is never called from newExplorer. On regtest with ElectrumX the poll interval silently stays at 10 s instead of 2 s.

Fix: pass it through.

opts := []electrum_explorer.Option{electrum_explorer.WithTracker(tracker)}
if pollInterval > 0 {
    opts = append(opts, electrum_explorer.WithPollInterval(pollInterval))
}
return electrum_explorer.NewExplorer(url, net, opts...)

🔴 Bug: clean server close doesn't trigger reconnect

In client.go → listen():

if err := scanner.Err(); err != nil && !c.contextDone() {
    // reconnect
}

scanner.Err() returns nil on a clean EOF (graceful TCP close). An ElectrumX server restart sends FIN before the new process is up; the SDK will silently stop receiving events without ever reconnecting. Change the condition to:

if !c.contextDone() {
    // reconnect regardless of whether Err is nil
}

🟡 Unbounded tx hex cache

explorerSvc.cache (txid → hex) is populated by GetTxHex and Broadcast but never evicted. A long-running node process fetching thousands of transactions will accumulate them all in memory indefinitely. An LRU with a reasonable cap (say 10 000 entries) or a simple TTL sweep would fix this.


🟡 GetTxOutspends performance for Ark use-cases

Acknowledged in the package doc, but worth flagging here: Ark connector trees produce transactions with many outputs, each of which can have deep scripthash histories. GetTxOutspends makes N_outputs × history_fetch round-trips. This will be slow on anything other than regtest. A sync.WaitGroup-based parallel fan-out would help significantly.


🟡 Broadcast populates cache before network call

e.cache[txid] = txHex  // ← added here
result, err := e.client.request("blockchain.transaction.broadcast", ...)
if err != nil { ... return "", err }  // cache entry remains on failure

If broadcast fails with a non-already-in-chain error, the tx hex is still in the cache. A subsequent GetTxHex(txid) call will return the hex without going to the server. This could mask broadcast failures during CPFP chains. Consider populating the cache only on success (or only for already-in-chain).


🟡 Two subscription mutexes

subsMu and subsMu2 protect conceptually related state (subs and storedSubs). The split was probably done to avoid holding both during the RPC call inside subscribe(), which is correct. A brief comment explaining why they're separate would save future readers from wondering if it's a bug.


🟢 Minor / non-blocking

  • keepAlive goroutines briefly overlap during reconnect (old one exits after the next failed ping, new one starts immediately). Fine for now, but something to watch if keepAlive pings become more expensive.
  • The WIP title — is this ready for full review? Happy to re-review once the WIP marker is removed.

Overall the implementation is well-structured. The two 🔴 issues (regtest poll interval and clean-disconnect reconnect) should be fixed before this ships. The rest are improvements worth doing but not blockers.

Copy link
Copy Markdown

@arkanaai arkanaai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Electrum Explorer Review — #160

Solid piece of work overall. The test coverage is thorough, the parity bugs caught during the audit are real, and the mock-server test harness is well designed. That said, I found several issues that need to be fixed before merge — two are concurrency bugs that will bite in production.


🔴 CRITICAL — Goroutine leak on reconnect

explorer/electrum/client.go:246-253

When reconnect() succeeds it spawns fresh go c.listen() and go c.keepAlive() goroutines, but it never cancels or signals the old keepAlive() goroutine. The old listen() is the caller of reconnect(), so it exits on return — fine. But keepAlive() is a separate long-running goroutine that uses c.request()c.getConn(), so after reconnect it happily pings on the new connection. After N reconnects you have N+1 keepAlive goroutines all firing pings on the same connection.

Fix: give each connect/reconnect cycle its own cancellable context (child of c.ctx), and cancel the previous cycle's context before starting new goroutines.

// sketch
c.cycleMu.Lock()
if c.cycleCancel != nil {
    c.cycleCancel()
}
cycleCtx, cycleCancel := context.WithCancel(c.ctx)
c.cycleCancel = cycleCancel
c.cycleMu.Unlock()
go c.listen(cycleCtx)
go c.keepAlive(cycleCtx)

🔴 CRITICAL — Concurrent writes to TCP connection under RLock

explorer/electrum/client.go:293-299

conn := c.getConn()          // RLock
// ...
c.connMu.RLock()
_, err = conn.Write(data)    // multiple goroutines can reach here concurrently
c.connMu.RUnlock()

connMu is an RWMutex, and request() takes RLock for the write. Since multiple goroutines can hold RLock simultaneously, concurrent conn.Write() calls can interleave bytes on the wire when the JSON-RPC message exceeds the kernel's atomic write threshold. JSON-RPC framing will be corrupted.

Fix: use a dedicated writeMu sync.Mutex for serializing writes to the connection, separate from connMu (which protects the conn pointer).


🟡 HIGH — Start() silently swallows connection failure

explorer/electrum/explorer.go:108-112

func (e *explorerSvc) Start() {
    if err := e.client.connect(); err != nil {
        log.WithError(err).Error("electrum explorer: failed to connect")
        return  // caller has no idea
    }

If the ElectrumX server is unreachable, Start() logs and returns. Every subsequent RPC call will hit "not connected" — but the caller (e.g. Unlock() in client.go:108) has no indication the explorer is broken. For an L2 wallet, silently running with a dead explorer means the user thinks they're online but UTXOs, broadcasts, and event tracking are all non-functional.

Options: (a) return an error from Start() (breaking interface change), or (b) set a flag and surface it via GetConnectionCount() == 0, or (c) retry connection in background with backoff (like reconnect does).


🟡 HIGH — Electrum explorer ignores regtest poll interval

init.go:55-66

var pollInterval time.Duration
if info.Network == arklib.BitcoinRegTest.Name {
    pollInterval = 2 * time.Second
}
explorerSvc, err := newExplorer(explorerUrl, networkFromString(info.Network), true, pollInterval)

Then in newExplorer() at line 168-171:

if strings.HasPrefix(url, "tcp://") || strings.HasPrefix(url, "ssl://") {
    return electrum_explorer.NewExplorer(
        url, net, electrum_explorer.WithTracker(tracker),
    )  // pollInterval is silently dropped!
}

The 2-second regtest poll interval is forwarded to mempool but never to electrum. Electrum defaults to 10 seconds. On regtest, this means 5× slower UTXO detection when using the electrum backend — the opposite of what operators expect.

Fix: pass electrum_explorer.WithPollInterval(pollInterval) when pollInterval > 0.


🟡 HIGH — Broadcast caches tx hex before confirming success

explorer/electrum/explorer.go:210-212

e.cacheMu.Lock()
e.cache[txid] = txHex
e.cacheMu.Unlock()
// ... then broadcast, which might fail

The tx hex is cached before the broadcast RPC. If broadcast fails (e.g. invalid tx, fee too low), the cache still holds the entry. A subsequent GetTxHex(txid) will return the hex of a transaction that was never actually broadcast. Downstream code that checks GetTxHex to determine if a tx exists will get false positives.

Fix: cache only after successful broadcast, or clear cache on broadcast failure.


🟡 HIGH — Unbounded tx cache (memory leak)

explorer/electrum/explorer.go:62

cache   map[string]string // txid → hex

This cache grows without bound. A long-running SDK instance processing boarding, unrolls, and restores will accumulate every tx hex it has ever seen. A typical Ark tx is ~500 bytes hex, so 100k txs ≈ 50MB. There's no eviction policy.

Fix: use an LRU cache with a bounded size, or periodically prune entries older than some threshold.


🟠 MEDIUM — Inconsistent unconfirmed BlockTime sentinel values

GetTxBlockTime returns (false, -1, nil) for unconfirmed txs (explorer.go:389), but GetTxs returns BlockTime: 0 for unconfirmed txs (explorer.go:269 — blocktime stays 0 when not confirmed). The test at line 1306 of the test file confirms this intentional mismatch.

The consumer code in client.go uses CreatedAt.IsZero() to detect unconfirmed, so this probably doesn't cause bugs today, but it's a behavioral inconsistency that differs from what the PR description promises ("Status.BlockTime=0 (not -1) for unconfirmed").


🟠 MEDIUM — No option for custom TLS certificates

explorer/electrum/client.go:84-87

return tls.DialWithDialer(
    &net.Dialer{Timeout: 10 * time.Second},
    "tcp", addr, &tls.Config{MinVersion: tls.VersionTLS12},
)

Self-hosted ElectrumX servers commonly use self-signed certificates. This will fail with x509: certificate signed by unknown authority. Consider adding a WithTLSConfig(*tls.Config) option, or at minimum documenting this limitation.


🟢 LOW — No scanner buffer limit

explorer/electrum/client.go:127-128

scanner := bufio.NewScanner(conn)

Default bufio.Scanner max token size is 64KB. A malicious or buggy ElectrumX server sending a line >64KB will cause scanner.Scan() to return false with bufio.ErrTooLong, triggering a reconnect loop. For robustness, consider scanner.Buffer(buf, maxSize) with a reasonable limit (e.g. 1MB for large verbose txs).


🟢 LOW — subsMu2 naming

explorer/electrum/client.go:43-44

storedSubs []string
subsMu2    sync.Mutex // protects storedSubs

subsMu2 is a code smell. If storedSubs and subs have different lifecycles that require separate locks, rename to something descriptive like storedSubsMu.


✅ What looks good

  • math.Round for BTC→sat conversion — correct fix for float truncation
  • scriptToAddress for deriving Output.Address — fixes the silent UTXO drop in addressTxHistoryToUtxos
  • WithTracker(true/false) logic is now correct
  • networkToChainParams covers regtest and mutinynet
  • Test coverage is thorough: mock server approach exercises the full stack
  • diffUTXOs logic is clean and correct
  • parseBitcoinTx handles witness/non-witness/PSBT fallback chain
  • Cross-repo impact: no breaking changes to the explorer.Explorer interface or public API — this is purely additive

Cross-repo impact assessment

  • explorer.Explorer interface unchanged — no downstream breakage
  • WithExplorerURL still works, WithElectrumExplorer is additive
  • LoadArkClient auto-detects tcp:///ssl:// prefix — backward compatible
  • introspector-review test suite (primary consumer) unaffected

Bottom line: Fix the goroutine leak and the concurrent write race. Those are production bugs. The poll interval and cache issues are important but less urgent. Everything else is cleanup.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🧹 Nitpick comments (4)
explorer/electrum/explorer.go (3)

108-122: ⚖️ Poor tradeoff

Consider returning error from Start() to indicate connection failure.

If connect() fails, Start() logs the error and returns without any indication to the caller. This makes it impossible for callers to handle connection failures gracefully (e.g., retry with different URL, fail fast).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@explorer/electrum/explorer.go` around lines 108 - 122, Change
explorerSvc.Start from a void method to return an error so callers can handle
connection failures: have Start call e.client.connect() and if it returns an
error, return that error instead of only logging it; keep existing behavior for
e.noTracking/e.stopTracking (return nil when not starting tracking), continue to
initialize stopCh, set e.stopTracking with sync.OnceFunc and start go
e.pollLoop(stopCh) on success, and update callers of Start to handle the
returned error accordingly (propagate/retry/fail fast as needed).

529-533: 💤 Low value

Ignored error from addrToScript could cause empty script in events.

If addrToScript fails at line 529, script will be an empty string, and all UTXOs in the diff event will have empty Script fields. This could cause issues for callers that rely on the script.

Consider logging the error
-	script, _ := addrToScript(addr, e.netParams)
+	script, err := addrToScript(addr, e.netParams)
+	if err != nil {
+		log.WithError(err).Warnf("electrum: failed to convert address to script: %s", addr)
+	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@explorer/electrum/explorer.go` around lines 529 - 533, The call to
addrToScript in explorer.go ignores its error and can produce an empty script in
the diff event; change the code to capture the error (script, err :=
addrToScript(...)), and if err != nil log or return early instead of proceeding
to diffUTXOs so you don't emit events with empty Script fields — update the
handling around addrToScript, diffUTXOs, and the surrounding function
(references: addrToScript, diffUTXOs, variables script/newUTXOs/state.utxos) to
either propagate the error or skip emitting the event after logging the failure.

283-298: 💤 Low value

Silent error handling may mask issues in spend detection.

Errors at lines 284-290 and 292-298 are silently ignored with continue. This could cause GetTxOutspends to return Spent: false for outputs that are actually spent if there's a transient RPC error or malformed data. Consider logging errors at debug level for troubleshooting.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@explorer/electrum/explorer.go` around lines 283 - 298, The loop in
GetTxOutspends silently skips errors from scriptToScripthash,
e.client.request("blockchain.scripthash.get_history") and json.Unmarshal which
can hide transient RPC or parse failures; update the error handling in the loop
around scriptToScripthash(out.ScriptPubKey.Hex), the request call, and the
json.Unmarshal into electrumHistoryEntry to emit debug-level logs (including
error, txid/vtx.Txid or current tx id, vout index i, and the script hex) before
continuing so failures are visible during troubleshooting while preserving the
continue behavior.
explorer/electrum/client.go (1)

246-254: ⚖️ Poor tradeoff

Multiple listen/keepAlive goroutines may run concurrently during reconnect.

When reconnect() is called (e.g., from the old listen() goroutine after connection loss), it starts new listen() and keepAlive() goroutines at lines 252-253. However, if the old keepAlive() goroutine hasn't exited yet (its next ping hasn't failed), it may continue running until its next tick. This could cause duplicate pings or unexpected behavior.

Consider adding a mechanism to signal the old goroutines to exit before starting new ones, such as using a per-connection context that gets cancelled on reconnect.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@explorer/electrum/client.go` around lines 246 - 254, The reconnect path can
spawn duplicate listen/keepAlive goroutines; introduce a per-connection context
on the client struct (e.g., c.connCtx and cancelConnCtx) that you cancel before
replacing the connection in setConn(conn) or before launching new goroutines in
reconnect(), then pass that context into listen() and keepAlive() so those
functions return when ctx.Done() is signalled; ensure reconnect() cancels the
previous context, sets up a fresh context, calls c.setConn(conn)/c.handshake(),
and only then starts go c.listen(ctx) and go c.keepAlive(ctx).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@explorer/electrum/client.go`:
- Around line 328-338: The scripthash is appended to c.storedSubs before calling
c.request, but on request error the entry is removed from c.subs only, leaving a
failed scripthash in c.storedSubs to be replayed on reconnect; update the error
path in the subscribe flow (the method that calls
c.request("blockchain.scripthash.subscribe", ...)) to also remove the scripthash
from c.storedSubs (under the c.subsMu2 lock) when c.request returns an error,
ensuring both maps/slices are cleaned consistently.
- Around line 293-302: The current code calls c.getConn(), then uses
c.connMu.RLock() around conn.Write(data), which permits concurrent writers and
can interleave JSON-RPC frames; change this to serialize writes by using a full
write lock: either replace c.connMu.RLock()/RUnlock() with
c.connMu.Lock()/Unlock() around conn.Write(data) or introduce a dedicated write
mutex (e.g., c.writeMu) and Lock/Unlock it around conn.Write(data) so all writes
to the connection (in the send function that uses conn and connMu) are
serialized and cannot interleave.
- Around line 147-153: The send to the per-request channel can panic if another
goroutine closes it after you release c.pendMu; change the lookup/send logic in
the response loop to atomically remove the channel from c.pending while holding
c.pendMu (e.g., lock, ch, ok := c.pending[resp.ID]; if ok { delete(c.pending,
resp.ID) }; unlock) and then perform a guarded send (use a non-blocking select
or a done check) so a closed channel or racing close won't panic; alternatively,
stop closing individual channels in the close() method (only delete entries) and
let request timeouts clean up to avoid the race—apply this change around the
code referencing c.pendMu, c.pending, resp.ID, ch and the close()
implementation.

In `@explorer/electrum/explorer_test.go`:
- Around line 89-114: The two sub-tests in TestAddressToScripthash are
placeholders with no assertions; either remove the test entirely (if coverage
exists via GetUtxos) or export a test helper that calls the internal
addressToScripthash and assert its behavior: add an exported
AddressToScripthashForTest that wraps addressToScripthash, then in
TestAddressToScripthash call AddressToScripthashForTest(addr,
&chaincfg.MainNetParams) and assert no error, length == 64 and lowercase hex
regexp, and also assert addr != addr2 produce different scripthashes to validate
determinism and uniqueness.

In `@explorer/electrum/explorer.go`:
- Around line 423-429: The TOCTOU bug in SubscribeForAddresses arises because
SubscribeForAddresses currently uses e.subscribedMu.RLock() to check
e.subscribedMap[addr] then releases before adding, allowing concurrent calls to
create duplicates; fix by ensuring the check-and-add is atomic: acquire
e.subscribedMu.Lock() (not RLock) around the check and insertion into
e.subscribedMap in SubscribeForAddresses (or use the pattern of preparing any
RPC work outside the lock and then re-acquiring e.subscribedMu.Lock() to
re-check and insert to avoid holding the lock during RPCs), ensuring you never
perform RPC calls while holding the lock and referencing the methods/fields
e.subscribedMu, subscribedMap, and SubscribeForAddresses.

In `@explorer/electrum/utils.go`:
- Around line 131-133: The async call to l.remove(from broadcast) can
double-close listener channels when clear closes them first; update remove to
avoid closing an already-closed channel (or make close idempotent) by adding a
per-listener closed flag or removing-and-nil-ing the channel before closing, and
use that same guard when clear and any teardown path close channels; modify the
remove, clear and any goroutine that calls remove (e.g., broadcast) to consult
that guard (or atomically swap out and close only the previously-held channel)
so close of closed channel panics cannot occur.
- Around line 78-85: The PSBT fallback branch currently discards the PSBT
parse/extract errors by returning the earlier deserialize error variable `err`;
update the error handling in the `psbt.NewFromRawBytes` and `psbt.Extract`
blocks to return the actual error variable `err3` (or rename to a clearer name)
so the function returns the real PSBT parsing/extraction error when `ptx, err3
:= psbt.NewFromRawBytes(strings.NewReader(txStr), true)` or `txFromPartial, err3
:= psbt.Extract(ptx)` fail; ensure both error returns use the PSBT error
variable instead of `err`.

In `@init.go`:
- Around line 165-171: newExplorer currently ignores the pollInterval parameter
for the Electrum path; update the electrum branch so the
electrum_explorer.NewExplorer call includes a poll interval option (e.g.,
electrum_explorer.WithPollInterval(pollInterval)) in addition to
electrum_explorer.WithTracker(tracker), so that newExplorer(url, net, tracker,
pollInterval) actually applies the polling setting for tcp:// and ssl:// URLs;
modify the electrum_explorer.NewExplorer invocation in the newExplorer function
(and the adjacent electrum branch lines around the existing
electrum_explorer.WithTracker call) to pass the pollInterval option.

---

Nitpick comments:
In `@explorer/electrum/client.go`:
- Around line 246-254: The reconnect path can spawn duplicate listen/keepAlive
goroutines; introduce a per-connection context on the client struct (e.g.,
c.connCtx and cancelConnCtx) that you cancel before replacing the connection in
setConn(conn) or before launching new goroutines in reconnect(), then pass that
context into listen() and keepAlive() so those functions return when ctx.Done()
is signalled; ensure reconnect() cancels the previous context, sets up a fresh
context, calls c.setConn(conn)/c.handshake(), and only then starts go
c.listen(ctx) and go c.keepAlive(ctx).

In `@explorer/electrum/explorer.go`:
- Around line 108-122: Change explorerSvc.Start from a void method to return an
error so callers can handle connection failures: have Start call
e.client.connect() and if it returns an error, return that error instead of only
logging it; keep existing behavior for e.noTracking/e.stopTracking (return nil
when not starting tracking), continue to initialize stopCh, set e.stopTracking
with sync.OnceFunc and start go e.pollLoop(stopCh) on success, and update
callers of Start to handle the returned error accordingly (propagate/retry/fail
fast as needed).
- Around line 529-533: The call to addrToScript in explorer.go ignores its error
and can produce an empty script in the diff event; change the code to capture
the error (script, err := addrToScript(...)), and if err != nil log or return
early instead of proceeding to diffUTXOs so you don't emit events with empty
Script fields — update the handling around addrToScript, diffUTXOs, and the
surrounding function (references: addrToScript, diffUTXOs, variables
script/newUTXOs/state.utxos) to either propagate the error or skip emitting the
event after logging the failure.
- Around line 283-298: The loop in GetTxOutspends silently skips errors from
scriptToScripthash, e.client.request("blockchain.scripthash.get_history") and
json.Unmarshal which can hide transient RPC or parse failures; update the error
handling in the loop around scriptToScripthash(out.ScriptPubKey.Hex), the
request call, and the json.Unmarshal into electrumHistoryEntry to emit
debug-level logs (including error, txid/vtx.Txid or current tx id, vout index i,
and the script hex) before continuing so failures are visible during
troubleshooting while preserving the continue behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 648fc3ff-1e45-4b7a-bb9b-c62dcc914e89

📥 Commits

Reviewing files that changed from the base of the PR and between 6ce96d1 and ff7b2fb.

📒 Files selected for processing (11)
  • README.md
  • client.go
  • explorer/electrum/client.go
  • explorer/electrum/explorer.go
  • explorer/electrum/explorer_test.go
  • explorer/electrum/opts.go
  • explorer/electrum/types.go
  • explorer/electrum/utils.go
  • init.go
  • init_opts.go
  • init_opts_test.go

Comment thread explorer/electrum/client.go
Comment thread explorer/electrum/client.go
Comment thread explorer/electrum/client.go Outdated
Comment thread explorer/electrum/explorer_test.go Outdated
Comment thread explorer/electrum/explorer.go
Comment thread explorer/electrum/utils.go
Comment thread explorer/electrum/utils.go
Comment thread init.go Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@explorer/electrum/client.go`:
- Around line 141-159: The close() implementation currently calls c.cancel()
which permanently cancels the client's root context and breaks subsequent
Start()/Stop() cycles; remove the call to c.cancel() and instead cancel only the
per-cycle context (use c.cycleMu and c.cycleCancel) and any connection-specific
context so that c.ctx remains usable for future request()/connect()/reconnect()
calls; ensure close() still closes the active net.Conn from getConn(), cancels
and nils c.cycleCancel, and drains/cleans c.pending exactly as before so the
client instance can be restarted without a canceled root context.
- Around line 215-223: The read lock is released before sending into the
channel, allowing unsubscribeLocal() to close ch under subsMu.Lock() and cause a
panic; keep c.subsMu.RLock() held for the duration of the non-blocking send so
the channel cannot be closed concurrently. Concretely, in the block that reads
c.subs[scripthash] (use symbols subsMu, subs, ch, unsubscribeLocal), move the
c.subsMu.RUnlock() to after the select that attempts to send status (or
otherwise perform the send while still holding the RLock), preserving the
non-blocking select path and then release the RLock once the send attempt
completes. Ensure no additional blocking send is introduced.

In `@explorer/electrum/explorer.go`:
- Around line 130-147: Stop() currently only clears maps and doesn't signal
per-address subscription goroutines spawned by SubscribeForAddresses (the
goroutines that range over notifCh), causing goroutine leaks; modify Stop() to
iterate over active subscriptions (use subscribedMap and/or scripthashToAddr),
close each subscription's notifCh or send a shutdown signal and/or call the
corresponding client unsubscribe method to terminate the goroutine, then
wait/ensure those goroutines have exited (e.g., by removing entries only after
closing channels or using a waitgroup), and finally clear listeners and reset
maps under the existing subscribedMu and scripthashToAddrMu to avoid races.
- Around line 543-624: pollAddress can run concurrently for the same addr and
double-diff/broadcast because it reads state.utxos then later writes; fix by
serializing per-address: add a per-subscription mutex to the subscription state
type (e.g., add mu sync.Mutex to the struct stored in subscribedMap and make
subscribedMap hold pointers, e.g., map[string]*subscriptionState), then in
pollAddress after loading state from subscribedMap lock state.mu (defer unlock)
before reading state.utxos, computing diffUTXOs, and updating state.utxos; this
ensures only one poll/notification path mutates and diffs a given address at a
time while keeping other addresses concurrent.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 34876ebd-7540-48c2-8f2f-3ccd66b5f2c8

📥 Commits

Reviewing files that changed from the base of the PR and between ff7b2fb and 154dd4c.

📒 Files selected for processing (6)
  • explorer/electrum/client.go
  • explorer/electrum/explorer.go
  • explorer/electrum/explorer_test.go
  • explorer/electrum/opts.go
  • explorer/electrum/utils.go
  • init.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • explorer/electrum/opts.go

Comment thread explorer/electrum/client.go
Comment thread explorer/electrum/client.go
Comment thread explorer/electrum/explorer.go
Comment thread explorer/electrum/explorer.go
Copy link
Copy Markdown

@arkanaai arkanaai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Arkana Re-Review — New Commits (154dd4c, ae3b4d0, bc44bb4)

All critical and high issues from my previous review have been addressed. The fixes are well-structured with matching test coverage. Approving from the code side — still needs a human sign-off since this touches protocol-adjacent infrastructure (explorer backend for UTXO tracking).

✅ Issues Fixed

Previous Finding Status
🔴 Goroutine leak on reconnect Fixed — cycle-context pattern (newCycleCtx/cycleCancel) cancels old keepAlive before starting new pair. TestKeepaliveGoroutineDoesNotLeak validates.
🔴 Concurrent writes under RLock Fixed — dedicated writeMu sync.Mutex serializes conn.Write(). TestConcurrentRequestsDoNotInterleave validates with 20 concurrent workers.
🔴 Clean EOF doesn't trigger reconnect Fixedlisten() now reconnects on any scanner exit (nil error or not), gated by cycle context. TestCleanEOFTriggersReconnect validates.
🟡 Start() swallows connection failure Fixed — falls through to reconnect() in background goroutine with exponential backoff.
🟡 Regtest poll interval not forwarded Fixed in init.go:168-173WithPollInterval passed to electrum path.
🟡 Broadcast caches before success FixedsetCacheTx called only after successful broadcast or already-in-chain.
🟡 Unbounded tx cache FixedtxCacheMaxSize = 1024 with random eviction in setCacheTx.
🟠 subsMu2 naming Fixed — renamed to storedSubsMu with comment explaining the separation.
🟠 No custom TLS config FixedWithTLSConfig(*tls.Config) option added in opts.go.
🟢 No scanner buffer limit FixedmaxScannerBytes = 1 << 20 (1 MB).

✅ Additional Improvements (Not Previously Flagged)

  • close() vs shutdown() separation (client.go:141-167): Clean split — close() tears down the current cycle without killing the root context (used during reconnect), shutdown() is terminal. Correct design.
  • listen() notification send under RLock (client.go:223-231): subsMu.RUnlock() moved after the channel send, preventing a race with unsubscribeLocal closing the channel. Good catch.
  • listen() deletes pending entry before unlock (client.go:197-205): Prevents close() from closing a channel that listen() is about to send on. Correct.
  • Per-address mutex in pollAddress (explorer.go:569-570): state.mu serializes concurrent polls for the same address, preventing duplicate events. Correct.
  • SubscribeForAddresses double-check (explorer.go:481-487): Re-checks under write lock to handle concurrent subscriptions. Properly unsubscribes on conflict.
  • notifWg in Stop() (explorer.go:151): Ensures all per-address notification goroutines exit before Stop returns. No leaked goroutines.
  • subscribe() rollback on failure (client.go:416-424): Cleans up both subs and storedSubs if the RPC fails.
  • parseBitcoinTx error propagation (utils.go:79,83): Now returns err3 instead of the stale err from the first deserialization attempt.
  • listeners.remove nil check (utils.go:145-147): Prevents double-close panic.

🟡 Minor — Non-Blocking

  1. isConnected() stale after shutdown()close() calls conn.Close() but doesn't nil out c.conn (client.go:151-153). After Stop(), GetConnectionCount() returns 1 instead of 0. Consider c.setConn(nil) in close().

  2. Failed resubscription on reconnect is warn-only (client.go:340-341) — If blockchain.scripthash.subscribe fails during reconnect, the address silently loses push notifications until the next reconnect. The poll loop still runs so this isn't catastrophic, but the address falls back to poll-interval-only detection. Worth a TODO for retry logic.

  3. GetTxBlockTime vs GetTxs unconfirmed sentinel — Still returns -1 vs 0 respectively. Documented in tests, not a functional bug today, but an API consistency wart.

LGTM. Ship it after human sign-off.

Copy link
Copy Markdown

@arkanaai arkanaai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Arkana Re-Review — New Commits (154dd4c, ae3b4d0, bc44bb4)

All critical and high issues from my previous review have been addressed. The fixes are well-structured with matching test coverage. Approving from the code side — still needs a human sign-off since this touches protocol-adjacent infrastructure (explorer backend for UTXO tracking).

✅ Issues Fixed

Previous Finding Status
🔴 Goroutine leak on reconnect Fixed — cycle-context pattern (newCycleCtx/cycleCancel) cancels old keepAlive before starting new pair. TestKeepaliveGoroutineDoesNotLeak validates.
🔴 Concurrent writes under RLock Fixed — dedicated writeMu sync.Mutex serializes conn.Write(). TestConcurrentRequestsDoNotInterleave validates with 20 concurrent workers.
🔴 Clean EOF doesn't trigger reconnect Fixedlisten() now reconnects on any scanner exit (nil error or not), gated by cycle context. TestCleanEOFTriggersReconnect validates.
🟡 Start() swallows connection failure Fixed — falls through to reconnect() in background goroutine with exponential backoff.
🟡 Regtest poll interval not forwarded Fixed in init.go:168-173WithPollInterval passed to electrum path.
🟡 Broadcast caches before success FixedsetCacheTx called only after successful broadcast or already-in-chain.
🟡 Unbounded tx cache FixedtxCacheMaxSize = 1024 with random eviction in setCacheTx.
🟠 subsMu2 naming Fixed — renamed to storedSubsMu with comment explaining the separation.
🟠 No custom TLS config FixedWithTLSConfig(*tls.Config) option added in opts.go.
🟢 No scanner buffer limit FixedmaxScannerBytes = 1 << 20 (1 MB).

✅ Additional Improvements (Not Previously Flagged)

  • close() vs shutdown() separation (client.go:141-167): Clean split — close() tears down the current cycle without killing the root context (used during reconnect), shutdown() is terminal. Correct design.
  • listen() notification send under RLock (client.go:223-231): subsMu.RUnlock() moved after the channel send, preventing a race with unsubscribeLocal closing the channel. Good catch.
  • listen() deletes pending entry before unlock (client.go:197-205): Prevents close() from closing a channel that listen() is about to send on. Correct.
  • Per-address mutex in pollAddress (explorer.go:569-570): state.mu serializes concurrent polls for the same address, preventing duplicate events. Correct.
  • SubscribeForAddresses double-check (explorer.go:481-487): Re-checks under write lock to handle concurrent subscriptions. Properly unsubscribes on conflict.
  • notifWg in Stop() (explorer.go:151): Ensures all per-address notification goroutines exit before Stop returns. No leaked goroutines.
  • subscribe() rollback on failure (client.go:416-424): Cleans up both subs and storedSubs if the RPC fails.
  • parseBitcoinTx error propagation (utils.go:79,83): Now returns err3 instead of the stale err from the first deserialization attempt.
  • listeners.remove nil check (utils.go:145-147): Prevents double-close panic.

🟡 Minor — Non-Blocking

  1. isConnected() stale after shutdown()close() calls conn.Close() but doesn't nil out c.conn (client.go:151-153). After Stop(), GetConnectionCount() returns 1 instead of 0. Consider c.setConn(nil) in close().

  2. Failed resubscription on reconnect is warn-only (client.go:340-341) — If blockchain.scripthash.subscribe fails during reconnect, the address silently loses push notifications until the next reconnect. The poll loop still runs so this isn't catastrophic, but the address falls back to poll-interval-only detection. Worth a TODO for retry logic.

  3. GetTxBlockTime vs GetTxs unconfirmed sentinel — Still returns -1 vs 0 respectively. Documented in tests, not a functional bug today, but an API consistency wart.

LGTM. Ship it after human sign-off.

@bitcoin-coder-bob
Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 30, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@bitcoin-coder-bob bitcoin-coder-bob changed the title WIP: Feat/electrum support Feat/electrum support Apr 30, 2026
@sekulicd
Copy link
Copy Markdown
Contributor

sekulicd commented May 1, 2026

Added a review companion at #165(draft) with 7 failing tests + a docs commit. Each test repros a bug deterministically; severity table in the description. @bob

@sekulicd
Copy link
Copy Markdown
Contributor

sekulicd commented May 1, 2026

Also @bitcoin-coder-bob we should replace mempool explorer and use electrum one in all of our test cases.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test/e2e/hd_wallet_test.go (1)

539-580: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Skip the test or confirm boarding-only key discovery is working.

The test TestHDWalletRecoversBoardingOnlyFundedKeys will fail CI. The comment correctly identifies that discoverHDWalletKeys only queries the indexer for offchain VTXO activity (via deriveOffchainScriptsBatch and queryUsedScripts); it does not check boarding addresses. Keys with only boarding UTXO activity are never discovered and never allocated via w.NewKey(), so they won't appear in getAllocatedAddresses() during wallet restore. The restored wallet's boarding address tracking is only set up via listenForOnchainTxs, which registers only addresses for already-allocated keys. Consequently, require.Eventually will timeout waiting for the boarding UTXO in the balance.

Either skip this test with t.Skip() and a link to the tracking issue, or confirm that the Electrum explorer changes inadvertently enable boarding-only key discovery before merging.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/e2e/hd_wallet_test.go` around lines 539 - 580, The test
TestHDWalletRecoversBoardingOnlyFundedKeys is expected to fail because
discoverHDWalletKeys (via deriveOffchainScriptsBatch and queryUsedScripts) does
not detect boarding-only UTXOs and keys are only allocated via w.NewKey(), so
the restored wallet never registers the boarding address (listenForOnchainTxs
only tracks already-allocated keys); either skip this flaky test by adding
t.Skip("boarding-only key discovery not supported; see <tracking-issue-link>")
at the top of TestHDWalletRecoversBoardingOnlyFundedKeys, or alternatively
validate that the Electrum explorer/indexer changes actually cause
discoverHDWalletKeys to detect boarding-only addresses before merging (i.e., add
a note in the test asserting the explorer behavior or remove the skip once
discoverHDWalletKeys/getAllocatedAddresses are updated).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Makefile`:
- Line 61: The Makefile default ARK_ELECTRUM_URL is set to tcp://127.0.0.1:50000
which mismatches the test and SDK defaults and causes integration tests to
target the wrong Electrum port; update the Makefile command that sets
ARK_ELECTRUM_URL (the line with
"@ARK_ELECTRUM_URL=$${ARK_ELECTRUM_URL:-tcp://127.0.0.1:50000} ...") to use
tcp://127.0.0.1:50001 so it matches test/e2e/utils_test.go and init.go and the
common nigiri/electrs port.

---

Outside diff comments:
In `@test/e2e/hd_wallet_test.go`:
- Around line 539-580: The test TestHDWalletRecoversBoardingOnlyFundedKeys is
expected to fail because discoverHDWalletKeys (via deriveOffchainScriptsBatch
and queryUsedScripts) does not detect boarding-only UTXOs and keys are only
allocated via w.NewKey(), so the restored wallet never registers the boarding
address (listenForOnchainTxs only tracks already-allocated keys); either skip
this flaky test by adding t.Skip("boarding-only key discovery not supported; see
<tracking-issue-link>") at the top of
TestHDWalletRecoversBoardingOnlyFundedKeys, or alternatively validate that the
Electrum explorer/indexer changes actually cause discoverHDWalletKeys to detect
boarding-only addresses before merging (i.e., add a note in the test asserting
the explorer behavior or remove the skip once
discoverHDWalletKeys/getAllocatedAddresses are updated).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 03b0640d-d1ba-47b3-adaa-82c1b32aa588

📥 Commits

Reviewing files that changed from the base of the PR and between 154dd4c and eb6bdf5.

📒 Files selected for processing (16)
  • Makefile
  • client.go
  • client_test.go
  • explorer/electrum/break_test.go
  • explorer/electrum/client.go
  • explorer/electrum/doc.go
  • explorer/electrum/explorer.go
  • explorer/electrum/explorer_test.go
  • explorer/electrum/opts.go
  • explorer/electrum/types.go
  • explorer/electrum/utils.go
  • init.go
  • init_opts.go
  • test/docker/docker-compose.yml
  • test/e2e/hd_wallet_test.go
  • test/e2e/utils_test.go
✅ Files skipped from review due to trivial changes (2)
  • explorer/electrum/doc.go
  • explorer/electrum/types.go
🚧 Files skipped from review as they are similar to previous changes (5)
  • init_opts.go
  • explorer/electrum/opts.go
  • client.go
  • explorer/electrum/utils.go
  • explorer/electrum/explorer.go

Comment thread Makefile Outdated
@bitcoin-coder-bob
Copy link
Copy Markdown
Contributor Author

Responding to the comments from: #165

I have made electrum the default for tests, addressed the test concerns outlined, and brought in the electrum doc provided. The only test I considered push back against was TestBreak_GetTxBlockTimeUnconfirmedSentinelMismatch since in other places we recognize the -1 (so the potential issue being consistency) as a "bad" value, but we can lean on an additional returned value as a check instead. I still need to address the concerns coderabbitai has raised. Thank you for the review @sekulicd

@bitcoin-coder-bob bitcoin-coder-bob force-pushed the feat/electrum-support branch from 68c4fc4 to 69a92a0 Compare May 5, 2026 17:34
@bitcoin-coder-bob
Copy link
Copy Markdown
Contributor Author

bitcoin-coder-bob commented May 6, 2026

@coderabbitai @arkanaai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 6, 2026

Tip

For best results, initiate chat on the files or code changes.

@bitcoin-coder-bob: I'll review the latest changes now.

[review]

@arkanaai
Copy link
Copy Markdown

arkanaai Bot commented May 6, 2026

Arkana Review — ElectrumX Explorer

Good PR overall. The transport layer is well-engineered and the eight bug-fix callouts in the description are all real and correctly fixed. A few things worth discussing before merging:


🔴 Breaking: default regtest URL changed

defaultExplorerUrl[BitcoinRegTest] flipped from http://127.0.0.1:3000 (mempool/esplora) to tcp://127.0.0.1:50001 (Electrum). Any existing regtest setup that calls Init without an explicit explorer option — including CI environments not updated alongside this PR — will silently fail to connect. The README and docker-compose changes cover the test harness, but the change is invisible to SDK consumers running their own regtest. At minimum this needs a prominent note in the changelog/PR body and probably a migration guide blurb in README.md.


🟡 WithElectrumPackageBroadcastURL without WithElectrumExplorer is silently ignored

// initOptions.electrumEsploraURL is set, but newExplorer() only passes it
// through when the URL starts with tcp:// or ssl://.
// A mempool.space URL + WithElectrumPackageBroadcastURL → silent no-op.

newExplorer discards esploraURL when routing to the mempool backend. The user gets no error and no indication the option did nothing. Either add a validation guard in WithElectrumPackageBroadcastURL that returns an error if explorerUrl is not an electrum URL, or check after applyInitOptions that the combination is valid.


🟡 TestBreak_PendingRequestHangsOnDisconnect doesn't enforce the fast-fail guarantee

The comment says "With reconnect() draining pending on disconnect ⇒ ~50 ms", but the test only asserts err != nil. If a future regression re-introduces the 15 s hang, the test still passes. Add a timing assertion:

require.Less(t, elapsed, 2*time.Second,
    "request should fail fast on disconnect, not wait requestTimeout")

🟡 GetTxOutspends RPC fan-out

For each output, GetTxOutspends fetches the full scripthash history and then fetches the raw hex of every transaction in that history to find the spender. For a tx with many outputs touching active addresses, this is O(outputs × history_depth) round-trips. Fine for current Ark usage (connector/commitment outputs are typically low-traffic), but worth a // NOTE: O(outputs × history_depth) RPCs comment so future readers know this is intentional rather than an oversight.


🟢 Things done well

  • Dual-context pattern (root c.ctx vs per-cycle cycleCtx) correctly bounds goroutine lifetimes across reconnects without leaking old listen/keepAlive pairs.
  • subscribingSet dedup prevents the TOCTOU race on concurrent SubscribeForAddresses calls and the break_test.go covers it well.
  • math.Round for BTC float satoshi conversion — the silent truncation bug this fixes is exactly the kind of off-by-one that loses real satoshis. Good catch.
  • explorerCh pre-registered before syncCh in Unlock() — the event-drop race on boarding address creation is subtle and the fix is correct.
  • Reconnect drains pending immediately — callers fail fast instead of waiting requestTimeout per in-flight request on disconnect.
  • Mock TCP server tests — testing against net.Pipe rather than real ElectrumX keeps unit tests hermetic and fast. The break tests are a nice addition.
  • deriveBoardingAddressBatch + queryUsedBoardingAddresses for HD wallet restore correctly handles the case where a key was funded only via on-chain boarding and never received an offchain VTXO.

Minor nits

  • client.go:186 — the comment // Pre-create a tracking-enabled explorer from the stored config still refers to the old noTracking panic rationale. Now that listenForOnchainTxs takes the channel as a parameter, the real reason is simpler: pre-registering avoids the event-drop race. Worth updating.
  • GetTxBlockTime uses only tx.TxOut[0]'s script for the scripthash lookup. If the first output is OP_RETURN, scriptToScripthash will fail and the function returns false, 0, nil silently. Probably fine in practice (Ark txs always have spendable outputs first) but a comment would help.

Summary: The implementation is solid. The regtest default URL change and the silent WithElectrumPackageBroadcastURL no-op are the two items I'd want resolved before merge. Everything else is optional-but-recommended polish.

@bitcoin-coder-bob
Copy link
Copy Markdown
Contributor Author

Arkana Review — ElectrumX Explorer

Good PR overall. The transport layer is well-engineered and the eight bug-fix callouts in the description are all real and correctly fixed. A few things worth discussing before merging:

🔴 Breaking: default regtest URL changed

defaultExplorerUrl[BitcoinRegTest] flipped from http://127.0.0.1:3000 (mempool/esplora) to tcp://127.0.0.1:50001 (Electrum). Any existing regtest setup that calls Init without an explicit explorer option — including CI environments not updated alongside this PR — will silently fail to connect. The README and docker-compose changes cover the test harness, but the change is invisible to SDK consumers running their own regtest. At minimum this needs a prominent note in the changelog/PR body and probably a migration guide blurb in README.md.

🟡 WithElectrumPackageBroadcastURL without WithElectrumExplorer is silently ignored

// initOptions.electrumEsploraURL is set, but newExplorer() only passes it
// through when the URL starts with tcp:// or ssl://.
// A mempool.space URL + WithElectrumPackageBroadcastURL → silent no-op.

newExplorer discards esploraURL when routing to the mempool backend. The user gets no error and no indication the option did nothing. Either add a validation guard in WithElectrumPackageBroadcastURL that returns an error if explorerUrl is not an electrum URL, or check after applyInitOptions that the combination is valid.

🟡 TestBreak_PendingRequestHangsOnDisconnect doesn't enforce the fast-fail guarantee

The comment says "With reconnect() draining pending on disconnect ⇒ ~50 ms", but the test only asserts err != nil. If a future regression re-introduces the 15 s hang, the test still passes. Add a timing assertion:

require.Less(t, elapsed, 2*time.Second,
    "request should fail fast on disconnect, not wait requestTimeout")

🟡 GetTxOutspends RPC fan-out

For each output, GetTxOutspends fetches the full scripthash history and then fetches the raw hex of every transaction in that history to find the spender. For a tx with many outputs touching active addresses, this is O(outputs × history_depth) round-trips. Fine for current Ark usage (connector/commitment outputs are typically low-traffic), but worth a // NOTE: O(outputs × history_depth) RPCs comment so future readers know this is intentional rather than an oversight.

🟢 Things done well

  • Dual-context pattern (root c.ctx vs per-cycle cycleCtx) correctly bounds goroutine lifetimes across reconnects without leaking old listen/keepAlive pairs.
  • subscribingSet dedup prevents the TOCTOU race on concurrent SubscribeForAddresses calls and the break_test.go covers it well.
  • math.Round for BTC float satoshi conversion — the silent truncation bug this fixes is exactly the kind of off-by-one that loses real satoshis. Good catch.
  • explorerCh pre-registered before syncCh in Unlock() — the event-drop race on boarding address creation is subtle and the fix is correct.
  • Reconnect drains pending immediately — callers fail fast instead of waiting requestTimeout per in-flight request on disconnect.
  • Mock TCP server tests — testing against net.Pipe rather than real ElectrumX keeps unit tests hermetic and fast. The break tests are a nice addition.
  • deriveBoardingAddressBatch + queryUsedBoardingAddresses for HD wallet restore correctly handles the case where a key was funded only via on-chain boarding and never received an offchain VTXO.

Minor nits

  • client.go:186 — the comment // Pre-create a tracking-enabled explorer from the stored config still refers to the old noTracking panic rationale. Now that listenForOnchainTxs takes the channel as a parameter, the real reason is simpler: pre-registering avoids the event-drop race. Worth updating.
  • GetTxBlockTime uses only tx.TxOut[0]'s script for the scripthash lookup. If the first output is OP_RETURN, scriptToScripthash will fail and the function returns false, 0, nil silently. Probably fine in practice (Ark txs always have spendable outputs first) but a comment would help.

Summary: The implementation is solid. The regtest default URL change and the silent WithElectrumPackageBroadcastURL no-op are the two items I'd want resolved before merge. Everything else is optional-but-recommended polish.

  • break_test.go:227 (require.Lessf(t, elapsed, 2*time.Second, ...)) covers the issue you raised about TestBreak_PendingRequestHangsOnDisconnect doesn't enforce the fast-fail guarantee

@bitcoin-coder-bob
Copy link
Copy Markdown
Contributor Author

@coderabbitai @arkanaai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 7, 2026

Tip

For best results, initiate chat on the files or code changes.

@bitcoin-coder-bob: I'll review the latest changes now.

[review]

@arkanaai
Copy link
Copy Markdown

arkanaai Bot commented May 7, 2026

Arkana Code Review — ElectrumX Explorer Support

Solid addition overall. The transport is clean and self-contained (no third-party go-electrum dependency), the goroutine lifecycle is well thought-out, and the bug fixes bundled in are legitimate catches. A few things worth discussing before merge:


🔴 Issues

1. broadcastPackage caches txids before the broadcast succeeds

In explorer.go, broadcastPackage calls e.setCacheTx(txid, txHex) for every tx in the loop before the HTTP Post call returns:

hexes = append(hexes, txHex)
...
e.setCacheTx(txid, txHex)  // ← cached here
...
resp, err := http.Post(url, ...)   // ← but this can fail

If the package broadcast fails, the cache has stale entries and subsequent GetTxHex calls will return hex for transactions that were never broadcast. The single-tx Broadcast() method correctly caches only on success. Suggest moving setCacheTx calls to after the 200-status check.

2. Stale comment in GetTxBlockTime

// electrs-esplora does not support verbose transactions, so we decode the
// raw TX to get an output script, then look up the tx in the scripthash history

This comment says "electrs-esplora" but we're in the Electrum explorer. The real reason is that blockchain.transaction.get with verbose=false (which is what this package uses) doesn't return blocktime directly, so the workaround is correct — the comment just has the wrong attribution. Minor but will confuse future readers.


🟡 Design notes

3. TestBreak_* tests — clarify intent

break_test.go is well-written and thoughtful, but it mixes two different categories that aren't clearly labelled:

  • Tests that document a fixed bug (e.g., TestBreak_PendingRequestHangsOnDisconnect, TestBreak_ResubscribeIsSerial) — these should pass now.
  • Tests that document a known limitation (e.g., TestBreak_GetAddressesEventsLeaksWhenTrackerOff, TestBreak_GetTxOutspendsHidesRPCError) — are these expected to pass? If they expose unfixed issues, they should be clearly marked with a comment (or t.Skip) rather than asserting behaviour that may or may not be implemented.

Worth a quick pass to confirm all TestBreak_* tests pass in CI, and to add a comment to any that intentionally document unfixed behaviour.

4. GetTxOutspends on protocol-critical paths

The PR description correctly notes this is O(outputs × history_depth) round-trips. During exit/unroll flows, GetTxOutspends gets called on commitment transaction outputs. For heavily-used connector trees, the history depth on each output can grow. Not a blocker, but worth a // TODO: optimize if this becomes a bottleneck comment with a note about which flows call it, so a future reader knows where to look.

5. Regtest default URL change is a breaking change

Changing the regtest default from http://127.0.0.1:3000 (esplora) to tcp://127.0.0.1:50001 (electrum) will break any existing regtest setup using esplora. The README migration note is good — just making sure this is called out at the PR level for ops teams to catch.

6. WithElectrumPackageBroadcastURL validation deferred to Init()

The validation that electrumEsploraURL requires an electrum explorer URL happens in init.go during Init(), not when the option is applied. This is inherently correct (you can't know at option-creation time if another option sets the URL), but the error message could be clearer: currently it says the URL must start with tcp:// or ssl://, which a user might interpret as needing to change the esplora URL. Maybe: "WithElectrumPackageBroadcastURL requires WithElectrumExplorer to be set".


✅ What's well done

  • Goroutine lifecycle — The dual-context pattern (root c.ctx vs per-cycle cycleCtx) is the right approach. Goroutine leaks on reconnect are a common failure mode in TCP-multiplexing code; this handles it correctly.
  • math.Round fix for BTC float truncation — A real correctness bug, fixed properly.
  • Pre-registration of explorer channel — The event-drop race on Unlock() (channel registered after syncCh signal) was a subtle bug. The fix (pass the pre-registered channel into listenForOnchainTxs) is clean.
  • Parallel resubscription — 8-worker bounded parallelism on reconnect avoids the O(n) serial stall during wallet restore with many addresses.
  • Subscription retry with backoff — Correct; a transient subscribe failure should not permanently abort the listener.
  • doc.go — Unusually thorough. Worth keeping as a navigation aid for anyone modifying the transport in future.
  • subscribingSet TOCTOU guard — Correctly prevents two concurrent goroutines from racing to subscribe the same address and orphaning notification channels.
  • Boarding address recovery in discoverHDWalletKeys — The fix is correctly scoped: both indexer (offchain VTXO scripts) and explorer (boarding address history) are queried, and "unused" is only declared when both return empty.

Summary

Fix the broadcastPackage cache-before-success bug and the stale comment in GetTxBlockTime, then this is good to merge. The design and implementation quality is high.

🤖 Arkana

Copy link
Copy Markdown

@arkanaai arkanaai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Arkana Re-Review — Commit 79ad9d6

All issues from the previous review are resolved:

  1. broadcastPackage cache-before-success — ✅ Fixed. setCacheTx now runs only after the HTTP 200 check, so failed broadcasts don't pollute the cache.
  2. Stale GetTxBlockTime comment — ✅ Fixed. Now correctly references blockchain.transaction.get.
  3. Break test clarity — ✅ Comments now clearly state whether each test is a regression guard or documents current behaviour.
  4. Error message — ✅ WithElectrumPackageBroadcastURL validation message is clearer.
  5. GetTxOutspends test — ✅ Now uses require.Error instead of conditional assertion — strictly better.

No new concerns. LGTM.

⚠️ Standard reminder: this PR touches explorer/transaction handling paths. Human approval still required before merge.

🤖 Arkana

@bitcoin-coder-bob
Copy link
Copy Markdown
Contributor Author

Arkana Code Review — ElectrumX Explorer Support

Solid addition overall. The transport is clean and self-contained (no third-party go-electrum dependency), the goroutine lifecycle is well thought-out, and the bug fixes bundled in are legitimate catches. A few things worth discussing before merge:

🔴 Issues

1. broadcastPackage caches txids before the broadcast succeeds

In explorer.go, broadcastPackage calls e.setCacheTx(txid, txHex) for every tx in the loop before the HTTP Post call returns:

hexes = append(hexes, txHex)
...
e.setCacheTx(txid, txHex)  // ← cached here
...
resp, err := http.Post(url, ...)   // ← but this can fail

If the package broadcast fails, the cache has stale entries and subsequent GetTxHex calls will return hex for transactions that were never broadcast. The single-tx Broadcast() method correctly caches only on success. Suggest moving setCacheTx calls to after the 200-status check.

2. Stale comment in GetTxBlockTime

// electrs-esplora does not support verbose transactions, so we decode the
// raw TX to get an output script, then look up the tx in the scripthash history

This comment says "electrs-esplora" but we're in the Electrum explorer. The real reason is that blockchain.transaction.get with verbose=false (which is what this package uses) doesn't return blocktime directly, so the workaround is correct — the comment just has the wrong attribution. Minor but will confuse future readers.

🟡 Design notes

3. TestBreak_* tests — clarify intent

break_test.go is well-written and thoughtful, but it mixes two different categories that aren't clearly labelled:

  • Tests that document a fixed bug (e.g., TestBreak_PendingRequestHangsOnDisconnect, TestBreak_ResubscribeIsSerial) — these should pass now.
  • Tests that document a known limitation (e.g., TestBreak_GetAddressesEventsLeaksWhenTrackerOff, TestBreak_GetTxOutspendsHidesRPCError) — are these expected to pass? If they expose unfixed issues, they should be clearly marked with a comment (or t.Skip) rather than asserting behaviour that may or may not be implemented.

Worth a quick pass to confirm all TestBreak_* tests pass in CI, and to add a comment to any that intentionally document unfixed behaviour.

4. GetTxOutspends on protocol-critical paths

The PR description correctly notes this is O(outputs × history_depth) round-trips. During exit/unroll flows, GetTxOutspends gets called on commitment transaction outputs. For heavily-used connector trees, the history depth on each output can grow. Not a blocker, but worth a // TODO: optimize if this becomes a bottleneck comment with a note about which flows call it, so a future reader knows where to look.

5. Regtest default URL change is a breaking change

Changing the regtest default from http://127.0.0.1:3000 (esplora) to tcp://127.0.0.1:50001 (electrum) will break any existing regtest setup using esplora. The README migration note is good — just making sure this is called out at the PR level for ops teams to catch.

6. WithElectrumPackageBroadcastURL validation deferred to Init()

The validation that electrumEsploraURL requires an electrum explorer URL happens in init.go during Init(), not when the option is applied. This is inherently correct (you can't know at option-creation time if another option sets the URL), but the error message could be clearer: currently it says the URL must start with tcp:// or ssl://, which a user might interpret as needing to change the esplora URL. Maybe: "WithElectrumPackageBroadcastURL requires WithElectrumExplorer to be set".

✅ What's well done

  • Goroutine lifecycle — The dual-context pattern (root c.ctx vs per-cycle cycleCtx) is the right approach. Goroutine leaks on reconnect are a common failure mode in TCP-multiplexing code; this handles it correctly.
  • math.Round fix for BTC float truncation — A real correctness bug, fixed properly.
  • Pre-registration of explorer channel — The event-drop race on Unlock() (channel registered after syncCh signal) was a subtle bug. The fix (pass the pre-registered channel into listenForOnchainTxs) is clean.
  • Parallel resubscription — 8-worker bounded parallelism on reconnect avoids the O(n) serial stall during wallet restore with many addresses.
  • Subscription retry with backoff — Correct; a transient subscribe failure should not permanently abort the listener.
  • doc.go — Unusually thorough. Worth keeping as a navigation aid for anyone modifying the transport in future.
  • subscribingSet TOCTOU guard — Correctly prevents two concurrent goroutines from racing to subscribe the same address and orphaning notification channels.
  • Boarding address recovery in discoverHDWalletKeys — The fix is correctly scoped: both indexer (offchain VTXO scripts) and explorer (boarding address history) are queried, and "unused" is only declared when both return empty.

Summary

Fix the broadcastPackage cache-before-success bug and the stale comment in GetTxBlockTime, then this is good to merge. The design and implementation quality is high.

🤖 Arkana

addressed most of your changes in 79ad9d6, pushing back on:

  • "GetTxOutspends on protocol-critical paths" the function already has a comment explaining the complexity and why it is ok
  • "Regtest default URL change is a breaking change" intentional and documented in README

Copy link
Copy Markdown

@arkanaai arkanaai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Arkana Re-Review — New Commits (13a47ef, c3c73c0)

Scope of changes since last review (79ad9d6):

  1. Merge from master — brings in Contract Manager (#145) and Auto Settle (#169)
  2. discover.go removal — dead code after contract manager merge

Verification:

  • go build ./... — compiles clean, no errors
  • ✅ No dangling code references to removed functions (discoverHDWalletKeys, deriveOffchainScriptsBatch, etc.)
  • ✅ Merge resolution looks correct — no conflicts with electrum code

Minor nit (non-blocking): Two stale comment references to discoverHDWalletKeys remain:

  • explorer/electrum/break_test.go:196
  • explorer/electrum/doc.go:75

These are documentation-only — can be cleaned up in a follow-up.

LGTM. Previous approval still stands.

🤖 Arkana

Copy link
Copy Markdown

@arkanaai arkanaai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Arkana Re-Review — New Commits (c0e3eeb, 2c9bfc6)

🤖 Arkana

Scope: Two commits since last review — concurrency/lifecycle fixes and test stabilization.

c0e3eeb — "comment, context, and locking fixes"

1. Stop()/Lock() ordering fix ✅ Correct.
stopFn() (context cancel) now fires before Explorer().Stop() in both client.go:Stop() and init.go:Lock(). This is the right order: cancel the background context first so listenForOnchainTxs, periodicRefreshDb, etc. start exiting and stop calling explorer methods, then tear down the explorer. Previously the reverse order created a window where goroutines called methods on a stopped explorer.

2. notifWg.Add(1) race fix (explorer.go:617-628) ✅ Correct.
Moving notifWg.Add(1) inside subscribedMu and gating it on e.stopped prevents the classic WaitGroup panic where Add races with Wait. The stopped flag is set under the same lock in Stop() before notifWg.Wait(), so the two are now properly serialized.

3. NewBoardingAddress retry loop (funding.go:81-101) ✅ Correct.
Subscribe now retries with 3s backoff using bgCtx for cancellation. Previously a transient failure silently dropped the subscription forever — the user would never see boarding deposits. The bgCtx nil guard (context.Background() fallback) is defensive and correct; safeCheck() ensures bgCtx is always set in practice.

4. bgCtx field (client.go:57, init.go:142) ✅ Clean.
Stored during Unlock(), used only by the retry goroutine in NewBoardingAddress. No synchronization issue — safeCheck() enforces happens-before.

5. Stale comments (break_test.go:196, doc.go:75) ✅ Fixed per previous nit — discoverHDWalletKeysScanContracts.

2c9bfc6 — "add WithoutAutoSettle() to fix flaky tests"

Test-only change. Adds WithoutAutoSettle() to tests that don't exercise auto-settle, preventing the auto-settle scheduler from interfering with test assertions. Straightforward and correct.

No issues found.

Previous approval stands. All concurrency fixes are well-reasoned and correctly implemented.

⚠️ Standard reminder: this PR touches explorer lifecycle and transaction subscription paths. Human approval required before merge.

Copy link
Copy Markdown

@arkanaai arkanaai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Arkana Re-Review — New Commits (c0e3eeb, 2c9bfc6)

🤖 Arkana

Scope: Two commits since last review — concurrency/lifecycle fixes and test stabilization.

c0e3eeb — "comment, context, and locking fixes"

1. Stop()/Lock() ordering fix ✅ Correct.
stopFn() (context cancel) now fires before Explorer().Stop() in both client.go:Stop() and init.go:Lock(). This is the right order: cancel the background context first so listenForOnchainTxs, periodicRefreshDb, etc. start exiting and stop calling explorer methods, then tear down the explorer. Previously the reverse order created a window where goroutines called methods on a stopped explorer.

2. notifWg.Add(1) race fix (explorer.go:617-628) ✅ Correct.
Moving notifWg.Add(1) inside subscribedMu and gating it on e.stopped prevents the classic WaitGroup panic where Add races with Wait. The stopped flag is set under the same lock in Stop() before notifWg.Wait(), so the two are now properly serialized.

3. NewBoardingAddress retry loop (funding.go:81-101) ✅ Correct.
Subscribe now retries with 3s backoff using bgCtx for cancellation. Previously a transient failure silently dropped the subscription forever — the user would never see boarding deposits. The bgCtx nil guard (context.Background() fallback) is defensive and correct; safeCheck() ensures bgCtx is always set in practice.

4. bgCtx field (client.go:57, init.go:142) ✅ Clean.
Stored during Unlock(), used only by the retry goroutine in NewBoardingAddress. No synchronization issue — safeCheck() enforces happens-before.

5. Stale comments (break_test.go:196, doc.go:75) ✅ Fixed per previous nit — discoverHDWalletKeysScanContracts.

2c9bfc6 — "add WithoutAutoSettle() to fix flaky tests"

Test-only change. Adds WithoutAutoSettle() to tests that don't exercise auto-settle, preventing the auto-settle scheduler from interfering with test assertions. Straightforward and correct.

No issues found.

Previous approval stands. All concurrency fixes are well-reasoned and correctly implemented.

⚠️ Standard reminder: this PR touches explorer lifecycle and transaction subscription paths. Human approval required before merge.

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.

Support Electrum for on-chain operation

2 participants