Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
ce31472
add ElectrumX explorer backend
bitcoin-coder-bob Apr 29, 2026
ff7b2fb
fix parity bugs and expand test coverage for electrum explorer
bitcoin-coder-bob Apr 29, 2026
154dd4c
fix electrum explorer: concurrency bugs, goroutine leak, and reliabil…
bitcoin-coder-bob Apr 30, 2026
ae3b4d0
fix race
bitcoin-coder-bob Apr 30, 2026
bc44bb4
fix four concurrency bugs in electrum explorer
bitcoin-coder-bob Apr 30, 2026
e3d048b
test additions, electrum doc addition
bitcoin-coder-bob May 4, 2026
420c11b
lint
bitcoin-coder-bob May 4, 2026
e3f7aff
use of electrum in tests in place of mempool
bitcoin-coder-bob May 4, 2026
eb6bdf5
fix electrum explorer: raw-hex tx decoding, package broadcast, and cl…
bitcoin-coder-bob May 4, 2026
90faff4
retry onchain address subscription and remove flaky sleep in history …
bitcoin-coder-bob May 5, 2026
e1e1caa
skip flaky test, change ARK_ELECTRUM_URL port for integration tests
bitcoin-coder-bob May 5, 2026
5be3bb4
discover boarding-only funded keys during HD wallet restore, un-skip …
bitcoin-coder-bob May 5, 2026
1f13cf7
update arkd client-lib import, GetUtxos to use []string
bitcoin-coder-bob May 5, 2026
fc35876
attempt to fix e2e test hang
bitcoin-coder-bob May 5, 2026
b7ca9ea
fix nil pointer dereference in listen() on shutdown/handshake failure
bitcoin-coder-bob May 5, 2026
4dc2907
merge master, store methods, RBF fix by script, HD type guard
bitcoin-coder-bob May 5, 2026
69a92a0
merge master into feat/electrum-support
bitcoin-coder-bob May 5, 2026
6661470
lint
bitcoin-coder-bob May 5, 2026
9a7dba7
fix e2e test flakiness via pass explorer channel, use recvUtxoEvent, …
bitcoin-coder-bob May 6, 2026
af63b41
test helper fxn
bitcoin-coder-bob May 6, 2026
5f13e0e
test fixes
bitcoin-coder-bob May 6, 2026
ed60090
validate WithElectrumPackageBroadcastURL, fix stale comments
bitcoin-coder-bob May 6, 2026
fa0d351
commenting
bitcoin-coder-bob May 6, 2026
79ad9d6
comments, fix when we cache in broadcastPackage
bitcoin-coder-bob May 7, 2026
49de536
Merge branch 'master' into feat/electrum-support
bitcoin-coder-bob May 11, 2026
2ac7edb
remove discover.go, unused after contract manager merge
bitcoin-coder-bob May 11, 2026
c0e3eeb
comment, context, and locking fixes
bitcoin-coder-bob May 11, 2026
2c9bfc6
add WithoutAutoSettle() to fix flaky tests
bitcoin-coder-bob May 12, 2026
71a907c
track plain onchain addresses in the UTXO event pipeline
bitcoin-coder-bob May 13, 2026
b3c74f9
add initial poll on subscribe to establish UTXO baseline
bitcoin-coder-bob May 13, 2026
e42c735
Merge master into feat/electrum-support
bitcoin-coder-bob May 13, 2026
6e9e047
lint
bitcoin-coder-bob May 13, 2026
7239aa4
fix event-drop race in listenForOnchainTxs
bitcoin-coder-bob May 13, 2026
eab7102
electrum: fall back to esplora for P2TR address queries when electrum…
bitcoin-coder-bob May 13, 2026
0913f08
Merge master into feat/electrum-support (Balance update)
bitcoin-coder-bob May 13, 2026
1ca2ac1
fix esplora fallback on CI integration tests
bitcoin-coder-bob May 13, 2026
facd1cb
change esplora port back to 3000
bitcoin-coder-bob May 13, 2026
feed66a
electrum: add mempool-compatible WebSocket tracker for P2TR addresses
bitcoin-coder-bob May 13, 2026
cc5abc4
fix electrum ws race and restore NewOnchainAddress subscription
bitcoin-coder-bob May 14, 2026
c0201b0
fix integration test electrum port: bitcoin electrs is 50000 not 50001
bitcoin-coder-bob May 14, 2026
7aedffc
remove esplora P2TR fallback and WebSocket tracker added chasing chai…
bitcoin-coder-bob May 14, 2026
7bba8aa
fix SDK regtest explorer default port: bitcoin electrs is 50000 not 5…
bitcoin-coder-bob May 14, 2026
3dbb13b
update remaining 50001 references to 50000 for consistency with regte…
bitcoin-coder-bob May 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ regtestdown:
@docker compose -f test/docker/docker-compose.yml down

integrationtest:
@go test -v -count=1 -race -timeout 40m ./test/e2e
@ARK_ELECTRUM_URL=$${ARK_ELECTRUM_URL:-tcp://127.0.0.1:50000} ARK_ESPLORA_URL=$${ARK_ESPLORA_URL:-http://localhost:3000} go test -v -count=1 -race -timeout 40m ./test/e2e

## smoketest: runs long-running e2e smoke tests (skipped in CI). Smoke
## test files are gated behind the "smoke" build tag and tests follow the
Expand Down
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,22 @@ if err := wallet.Init(
); err != nil {
return fmt.Errorf("failed to initialize wallet: %s", err)
}

// Use a self-hosted ElectrumX server (plaintext TCP or TLS).
if err := wallet.Init(
ctx, "localhost:7070", "your_seed", "your_password",
arksdk.WithElectrumExplorer("ssl://electrum.example.com:50002"),
); err != nil {
return fmt.Errorf("failed to initialize wallet: %s", err)
}

// ElectrumX over local plaintext TCP (useful for regtest).
if err := wallet.Init(
ctx, "localhost:7070", "your_seed", "your_password",
arksdk.WithElectrumExplorer("tcp://127.0.0.1:50000"),
); err != nil {
return fmt.Errorf("failed to initialize wallet: %s", err)
}
```

After `Init` + `Unlock`, wait for sync to complete before using balances or
Expand Down Expand Up @@ -117,11 +133,25 @@ Init(ctx context.Context, serverUrl, seed, password string, opts ...InitOption)
- `seed` — BIP39 mnemonic. Pass `""` to have the SDK generate a fresh one (recoverable via `Dump`).
- `password` — used to encrypt and protect the identity material at rest.
- `opts` — optional functional options:
- `WithExplorerURL(url string)` — override the default mempool explorer URL for the network.
- `WithExplorerURL(url string)` — override the default explorer URL for the network.
- `WithElectrumExplorer(serverURL string)` — use an ElectrumX server instead of mempool.space. The URL must start with `tcp://` (plaintext) or `ssl://` (TLS). Mutually exclusive with `WithExplorerURL`.
- `WithElectrumPackageBroadcastURL(url string)` — set an esplora-compatible REST URL for broadcasting transaction packages (required for zero-fee v3 / P2A anchor transactions). Only valid when using `WithElectrumExplorer`.

To plug in a non-HD identity (hardware wallet, KMS, remote signer, …), inject
it at construction time via `arksdk.WithIdentity(svc)` — see §1.

**Default explorer URLs per network:**

| Network | Default explorer |
|----------|-----------------|
| mainnet | `https://mempool.space/api` (mempool.space) |
| testnet | `https://mempool.space/testnet/api` (mempool.space) |
| signet | `https://mempool.space/signet/api` (mempool.space) |
| mutinynet | `https://mutinynet.com/api` (mempool.space) |
| regtest | `tcp://127.0.0.1:50000` (ElectrumX) |

> **Regtest migration note:** the regtest default changed from `http://127.0.0.1:3000` (esplora) to `tcp://127.0.0.1:50000` (ElectrumX). If your regtest setup uses an esplora server instead, pass `WithExplorerURL("http://127.0.0.1:3000")` (or your actual URL) to `Init` to restore the previous behaviour.

Note: Always keep your seed and password secure. Never share them or store them in plaintext.

### 3. Wallet Operations
Expand Down
313 changes: 313 additions & 0 deletions explorer/electrum/break_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,313 @@
package electrum_explorer_test

import (
"encoding/binary"
"encoding/json"
"fmt"
"net"
"sync"
"sync/atomic"
"testing"
"time"

arklib "github.com/arkade-os/arkd/pkg/ark-lib"
electrum_explorer "github.com/arkade-os/go-sdk/explorer/electrum"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg"
"github.com/stretchr/testify/require"
)

// TestBreak_ConcurrentSubscribeSameAddress probes the TOCTOU window between
// the RLock pre-check and the Lock confirm in SubscribeForAddresses. If
// electrumClient.subscribe overwrites c.subs[sh] without dedup, the loser
// orphans a notif channel and Stop() blocks on notifWg.Wait().
func TestBreak_ConcurrentSubscribeSameAddress(t *testing.T) {
const addr = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq"

serverURL := startMockServer(t, func(conn net.Conn, req map[string]json.RawMessage) {
switch reqMethod(req) {
case "server.version":
writeResponse(conn, reqID(req), []string{"mock", "1.4"})
case "blockchain.scripthash.listunspent":
time.Sleep(20 * time.Millisecond) // widen the TOCTOU window
writeResponse(conn, reqID(req), []any{})
case "blockchain.scripthash.subscribe":
writeResponse(conn, reqID(req), nil)
}
})

exp, err := electrum_explorer.NewExplorer(serverURL, arklib.Bitcoin,
electrum_explorer.WithTracker(true))
require.NoError(t, err)
exp.Start()

var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func() { defer wg.Done(); _ = exp.SubscribeForAddresses([]string{addr}) }()
}
wg.Wait()

done := make(chan struct{})
go func() { exp.Stop(); close(done) }()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("Stop() hung — orphan notifCh from concurrent SubscribeForAddresses")
}
}

// TestBreak_GetAddressesEventsLeaksWhenTrackerOff is a regression guard: before
// the fix, GetAddressesEvents with tracker=false returned a channel that was
// never closed by Stop(), leaking any consumer goroutine blocked on it.
func TestBreak_GetAddressesEventsLeaksWhenTrackerOff(t *testing.T) {
exp, err := electrum_explorer.NewExplorer("tcp://127.0.0.1:1", arklib.Bitcoin,
electrum_explorer.WithTracker(false))
require.NoError(t, err)
ch := exp.GetAddressesEvents()
require.NotNil(t, ch)

exp.Stop()

select {
case _, ok := <-ch:
require.False(t, ok, "channel should be closed after Stop")
case <-time.After(500 * time.Millisecond):
t.Fatal("GetAddressesEvents channel never closes — consumer goroutine leaks forever")
}
}

// TestBreak_GetTxOutspendsHidesRPCError verifies that an RPC error in the
// per-output history scan is surfaced rather than being interpreted as
// "output is unspent".
func TestBreak_GetTxOutspendsHidesRPCError(t *testing.T) {
const txid = "tgt"
// Minimal non-segwit TX: 1 input (null outpoint), 1 output (OP_1 script = 0x51).
const minimalTxHex = "020000000100000000000000000000000000000000000000000000000000000000000000000000000000ffffffff01e803000000000000015100000000"
serverURL := startMockServer(t, func(conn net.Conn, req map[string]json.RawMessage) {
switch reqMethod(req) {
case "server.version":
writeResponse(conn, reqID(req), []string{"mock", "1.4"})
case "blockchain.transaction.get":
writeResponse(conn, reqID(req), minimalTxHex)
case "blockchain.scripthash.get_history":
resp := fmt.Sprintf(
`{"id":%d,"error":{"code":-32603,"message":"internal error"}}`+"\n",
reqID(req))
_, _ = conn.Write([]byte(resp))
}
})

exp, err := electrum_explorer.NewExplorer(serverURL, arklib.Bitcoin,
electrum_explorer.WithTracker(false))
require.NoError(t, err)
exp.Start()
defer exp.Stop()

_, err = exp.GetTxOutspends(txid)
require.Error(
t,
err,
"RPC error must be surfaced — Spent=false is indistinguishable from unspent",
)
}

// TestBreak_StartIsIdempotent stresses concurrent Start() calls and asserts
// only one TCP connection is established. A second connection means the
// first one was leaked.
func TestBreak_StartIsIdempotent(t *testing.T) {
var conns atomic.Int32
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() { ln.Close() }) // nolint

go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
conns.Add(1)
go serveConn(c, func(conn net.Conn, req map[string]json.RawMessage) {
if reqMethod(req) == "server.version" {
writeResponse(conn, reqID(req), []string{"mock", "1.4"})
}
})
}
}()

exp, err := electrum_explorer.NewExplorer("tcp://"+ln.Addr().String(), arklib.Bitcoin,
electrum_explorer.WithTracker(false))
require.NoError(t, err)

var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func() { defer wg.Done(); exp.Start() }()
}
wg.Wait()
defer exp.Stop()
time.Sleep(300 * time.Millisecond)

require.LessOrEqualf(t, conns.Load(), int32(1),
"Start() spawned %d TCP connections; second connection leaked", conns.Load())
}

// TestBreak_GetTxBlockTimeUnconfirmedReturnsZero verifies that GetTxBlockTime
// returns blocktime=0 for an unconfirmed transaction, consistent with GetTxs
// and GetUtxos. electrs-esplora does not support verbose transactions, so we
// derive confirmation status from blockchain.scripthash.get_history instead.
func TestBreak_GetTxBlockTimeUnconfirmedReturnsZero(t *testing.T) {
const txid = "u"
// Minimal non-segwit TX: 1 input (null outpoint), 1 P2WPKH output (20 zero bytes).
const minimalTxHex = "020000000100000000000000000000000000000000000000000000000000000000000000000000000000ffffffff01e803000000000000160014000000000000000000000000000000000000000000000000"

serverURL := startMockServer(t, func(conn net.Conn, req map[string]json.RawMessage) {
switch reqMethod(req) {
case "server.version":
writeResponse(conn, reqID(req), []string{"mock", "1.4"})
case "blockchain.transaction.get":
writeResponse(conn, reqID(req), minimalTxHex)
case "blockchain.scripthash.get_history":
// height=0 means unconfirmed in the electrum protocol.
writeResponse(conn, reqID(req), []any{
map[string]any{"tx_hash": txid, "height": 0},
})
}
})

exp, err := electrum_explorer.NewExplorer(serverURL, arklib.Bitcoin,
electrum_explorer.WithTracker(false))
require.NoError(t, err)
exp.Start()
defer exp.Stop()

confirmed, bt, err := exp.GetTxBlockTime(txid)
require.NoError(t, err)
require.False(t, confirmed, "unconfirmed tx must report confirmed=false")
require.Equal(t, int64(0), bt, "unconfirmed tx must report blocktime=0")
}

// TestBreak_PendingRequestHangsOnDisconnect verifies that an in-flight request
// fails fast when the connection drops, instead of waiting the full 15 s
// requestTimeout. listen()'s reconnect path currently does NOT flush c.pending,
// so callers wait for their individual timeouts before learning the conn died.
//
// Real-world impact: during wallet restore (ScanContracts fires hundreds
// of GetTxs in succession), if the server restarts mid-restore, every in-flight
// request blocks the restore for 15 s × in-flight count.
func TestBreak_PendingRequestHangsOnDisconnect(t *testing.T) {
const dropDelay = 50 * time.Millisecond

serverURL := startMockServer(t, func(conn net.Conn, req map[string]json.RawMessage) {
switch reqMethod(req) {
case "server.version":
writeResponse(conn, reqID(req), []string{"mock", "1.4"})
case "blockchain.transaction.get":
// Simulate a server crash mid-request: never respond, just close.
go func() {
time.Sleep(dropDelay)
_ = conn.Close()
}()
}
})

exp, err := electrum_explorer.NewExplorer(serverURL, arklib.Bitcoin,
electrum_explorer.WithTracker(false))
require.NoError(t, err)
exp.Start()
defer exp.Stop()

start := time.Now()
_, err = exp.GetTxHex("any-txid-the-server-wont-answer")
elapsed := time.Since(start)

require.Error(t, err, "request should fail after disconnect")

// With reconnect() draining pending on disconnect => ~50 ms (dropDelay).
// Without the drain => 15 s (requestTimeout).
require.Lessf(t, elapsed, 2*time.Second,
"GetTxHex took %v after server disconnect; in-flight requests should fail "+
"fast when the connection dies, not wait the 15 s requestTimeout", elapsed)
}

// TestBreak_ResubscribeIsSerial verifies that scripthash resubscription after
// a reconnect runs in parallel rather than one-RPC-at-a-time. With a wallet
// that has many subscribed addresses (boarding + unrolled VTXOs + delegate
// addresses), a serial resubscribe makes recovery time scale linearly with
// the number of subscriptions.
//
// Strategy: subscribe to N addresses, force a reconnect by closing the first
// connection, then time how long the resubscribe phase takes. With a 40 ms
// per-subscribe simulated server delay, a serial implementation needs
// N×40 ms; bounded parallelism (e.g. 8 workers) takes ≈80 ms regardless of N.
func TestBreak_ResubscribeIsSerial(t *testing.T) {
const N = 12
const subscribeDelay = 40 * time.Millisecond

addrs := make([]string, 0, N)
for i := 1; i <= N; i++ {
prog := make([]byte, 20)
binary.BigEndian.PutUint64(prog[12:], uint64(i))
addr, err := btcutil.NewAddressWitnessPubKeyHash(prog, &chaincfg.MainNetParams)
require.NoError(t, err)
addrs = append(addrs, addr.EncodeAddress())
}

var subscribeCount atomic.Int32
// writeMu protects concurrent conn.Write calls from the async subscribe
// handlers. serveConn calls each handler synchronously, so we return
// immediately from the subscribe case and do the sleep + write in a
// goroutine, letting serveConn pipeline subsequent requests.
var writeMu sync.Mutex
firstConnCh := make(chan net.Conn, 1)
serverURL := startMockServer(t, func(conn net.Conn, req map[string]json.RawMessage) {
select {
case firstConnCh <- conn:
default:
}
switch reqMethod(req) {
case "server.version":
writeResponse(conn, reqID(req), []string{"mock", "1.4"})
case "blockchain.scripthash.listunspent":
writeResponse(conn, reqID(req), []any{})
case "blockchain.scripthash.subscribe":
subscribeCount.Add(1)
id := reqID(req)
go func() {
time.Sleep(subscribeDelay)
writeMu.Lock()
writeResponse(conn, id, nil)
writeMu.Unlock()
}()
}
})

exp, err := electrum_explorer.NewExplorer(serverURL, arklib.Bitcoin,
electrum_explorer.WithTracker(true))
require.NoError(t, err)
exp.Start()
defer exp.Stop()

require.NoError(t, exp.SubscribeForAddresses(addrs))
require.Eventually(t, func() bool { return subscribeCount.Load() >= int32(N) },
15*time.Second, 50*time.Millisecond, "initial subscribes never completed")

// Reset for the resubscribe phase, then drop the first connection.
subscribeCount.Store(0)
initial := <-firstConnCh
_ = initial.Close()

start := time.Now()
require.Eventually(t, func() bool { return subscribeCount.Load() >= int32(N) },
20*time.Second, 50*time.Millisecond, "resubscribe never completed")
elapsed := time.Since(start)

serialBaseline := time.Duration(N) * subscribeDelay // 12 × 40 ms = 480 ms
parallelThreshold := serialBaseline / 3 // ≈ 160 ms (allows for partial overlap)

require.Lessf(t, elapsed, parallelThreshold,
"resubscribe of %d addresses took %v; serial baseline = %v; "+
"reconnect should parallelise resubscribes (target < %v)",
N, elapsed, serialBaseline, parallelThreshold)
}
Loading
Loading