diff --git a/Makefile b/Makefile index a5fba29d..cf70567c 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 0ba72156..2b4c0e22 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/explorer/electrum/break_test.go b/explorer/electrum/break_test.go new file mode 100644 index 00000000..d022d208 --- /dev/null +++ b/explorer/electrum/break_test.go @@ -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) +} diff --git a/explorer/electrum/client.go b/explorer/electrum/client.go new file mode 100644 index 00000000..5c5dbdc8 --- /dev/null +++ b/explorer/electrum/client.go @@ -0,0 +1,559 @@ +package electrum_explorer + +import ( + "bufio" + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "net" + "strings" + "sync" + "sync/atomic" + "time" + + log "github.com/sirupsen/logrus" +) + +const ( + requestTimeout = 15 * time.Second + keepAliveInterval = 60 * time.Second + reconnectBaseDelay = 5 * time.Second + reconnectMaxDelay = 60 * time.Second + maxScannerBytes = 1 << 20 // 1 MB — large enough for verbose txs +) + +// electrumClient owns the JSON-RPC transport: a single multiplexed TCP/SSL +// connection, a pending-request map keyed by atomic request IDs, and the +// listen + keepAlive goroutine pair that drives the connection. +// +// See doc.go for the package-level reconnection model. The dual-context +// pattern (root c.ctx vs per-cycle cycleCtx) is what bounds goroutine +// lifetimes across reconnects; see the cycleMu/cycleCancel field comment. +type electrumClient struct { + serverURL string + tlsConfig *tls.Config // nil = default TLS config (system roots, TLS 1.2+) + + // conn is the live TCP/SSL connection. Replaced (without nil-ing the old + // pointer) by reconnect() on every successful redial. Reads must go + // through getConn() under connMu's RLock; writes go through setConn(). + conn net.Conn + connMu sync.RWMutex // protects c.conn pointer only + + // writeMu serialises all conn.Write calls so JSON-RPC frames are never + // interleaved on the wire. Held only across the single Write; no slow + // work happens inside its critical section. + writeMu sync.Mutex + + // reqID feeds atomic, monotonically-increasing JSON-RPC request IDs. + // pending maps id => response channel; the listen() goroutine fulfils + // each request by looking up the id and sending on its channel. + reqID atomic.Uint64 + pending map[uint64]chan *jsonRPCResponse + pendMu sync.Mutex + + // subs maps scripthash => channel that receives new status hash strings + // whenever ElectrumX pushes a blockchain.scripthash.subscribe + // notification. Read by listen() under RLock; written by subscribe() and + // unsubscribeLocal() under Lock. Channels are buffered (8) so that a + // momentarily-slow consumer does not block the listen() goroutine. + subs map[string]chan string + subsMu sync.RWMutex + + // storedSubs is the durable list of scripthashes to resubscribe after a + // reconnect. Maintained alongside subs by subscribe()/unsubscribeLocal(). + // + // storedSubsMu is kept separate from subsMu so that subscribe() can drop + // one lock before taking the other (enforcing lock ordering) and so that + // neither lock is held while issuing the blocking + // blockchain.scripthash.subscribe RPC. + storedSubs []string + storedSubsMu sync.Mutex + + // reconnectMu serialises reconnect attempts so only one goroutine ever + // runs the redial loop at a time. Required because both the natural + // disconnect path (listen() exit) and the failed-initial-connect path + // (Start()) can call reconnect concurrently. + reconnectMu sync.Mutex + + // ctx / cancel: the ROOT context. Cancelled only by shutdown() which is + // reached via explorerSvc.Stop(). Used to parent every per-cycle context + // so they all die at shutdown, and to bound the requestTimeout context so + // in-flight requests fail at shutdown rather than waiting their full 15 s. + ctx context.Context + cancel context.CancelFunc + + // cycleMu / cycleCancel: the PER-CONNECTION-CYCLE context handle. + // + // Each successful connect spawns a fresh listen + keepAlive goroutine + // pair under a new context derived from c.ctx. cycleCancel is the cancel + // fn for that derived context. On the next reconnect, newCycleCtx() + // cancels the previous cycleCtx (terminating the old listen + keepAlive + // goroutines) BEFORE spawning the new pair. + // + // Without this, every reconnect would leak two goroutines: the old + // listen() (still trying to read the closed conn) and the old + // keepAlive() (still pinging via c.request). + cycleMu sync.Mutex + cycleCancel context.CancelFunc +} + +func newElectrumClient(serverURL string) *electrumClient { + ctx, cancel := context.WithCancel(context.Background()) + return &electrumClient{ + serverURL: serverURL, + pending: make(map[uint64]chan *jsonRPCResponse), + subs: make(map[string]chan string), + ctx: ctx, + cancel: cancel, + } +} + +// connect dials the server and starts the listen + keepAlive goroutines. +// listen() must start before handshake() so that the server's response is read. +func (c *electrumClient) connect() error { + conn, err := c.dial() + if err != nil { + return err + } + c.setConn(conn) + + // listen must start before handshake so responses can be dispatched. + cycleCtx, _ := c.newCycleCtx() + go c.listen(cycleCtx) + go c.keepAlive(cycleCtx) + + if err := c.handshake(); err != nil { + c.close() // cancels cycleCtx → listen exits without reconnecting + return err + } + return nil +} + +func (c *electrumClient) dial() (net.Conn, error) { + addr := strings.TrimPrefix(strings.TrimPrefix(c.serverURL, "tcp://"), "ssl://") + if strings.HasPrefix(c.serverURL, "ssl://") { + tlsCfg := c.tlsConfig + if tlsCfg == nil { + tlsCfg = &tls.Config{MinVersion: tls.VersionTLS12} + } + return tls.DialWithDialer( + &net.Dialer{Timeout: 10 * time.Second}, + "tcp", addr, tlsCfg, + ) + } + return net.DialTimeout("tcp", addr, 10*time.Second) +} + +func (c *electrumClient) handshake() error { + _, err := c.request("server.version", []any{"go-sdk", "1.4"}) + return err +} + +func (c *electrumClient) setConn(conn net.Conn) { + c.connMu.Lock() + c.conn = conn + c.connMu.Unlock() +} + +func (c *electrumClient) getConn() net.Conn { + c.connMu.RLock() + defer c.connMu.RUnlock() + return c.conn +} + +// newCycleCtx cancels the previous keepAlive goroutine and returns a fresh +// child context (and its cancel) for the new listen+keepAlive cycle. +func (c *electrumClient) newCycleCtx() (context.Context, context.CancelFunc) { + c.cycleMu.Lock() + defer c.cycleMu.Unlock() + if c.cycleCancel != nil { + c.cycleCancel() + } + ctx, cancel := context.WithCancel(c.ctx) + c.cycleCancel = cancel + return ctx, cancel +} + +// close cancels the current connection cycle and drains pending requests, but +// does NOT cancel the root context. Use shutdown() for a terminal teardown. +func (c *electrumClient) close() { + c.cycleMu.Lock() + if c.cycleCancel != nil { + c.cycleCancel() + c.cycleCancel = nil + } + c.cycleMu.Unlock() + + // Acquire writeMu before closing so that any concurrent request() that + // already holds writeMu and called getConn() finishes its Write before we + // close. Without this, conn.Write and conn.Close race, causing "use of + // closed network connection" errors in the caller. + c.writeMu.Lock() + c.connMu.Lock() + conn := c.conn + c.conn = nil + c.connMu.Unlock() + if conn != nil { + conn.Close() // nolint + } + c.writeMu.Unlock() + + c.pendMu.Lock() + for id, ch := range c.pending { + close(ch) + delete(c.pending, id) + } + c.pendMu.Unlock() +} + +// shutdown permanently tears down the client by cancelling the root context +// and then closing the current connection cycle. +func (c *electrumClient) shutdown() { + c.cancel() + c.close() +} + +// listen reads newline-delimited JSON frames and dispatches them. +// Responses (have "id") go to the pending map; notifications (have "method") go to subs. +// On any scanner exit — including a clean EOF from a server restart — it calls +// reconnect unless ctx is cancelled (which signals that reconnect() already owns +// the recovery, e.g. after a failed handshake) or the client context is done. +func (c *electrumClient) listen(ctx context.Context) { + conn := c.getConn() + if conn == nil { + return + } + scanner := bufio.NewScanner(conn) + scanner.Buffer(make([]byte, maxScannerBytes), maxScannerBytes) + for scanner.Scan() { + line := scanner.Bytes() + + // Peek at the "id" field to distinguish response from notification. + var peek struct { + ID *uint64 `json:"id"` + Method string `json:"method"` + } + if err := json.Unmarshal(line, &peek); err != nil { + log.WithError(err).Debug("electrum: failed to parse frame") + continue + } + + if peek.ID != nil { + var resp jsonRPCResponse + if err := json.Unmarshal(line, &resp); err != nil { + log.WithError(err).Debug("electrum: failed to parse response") + continue + } + c.pendMu.Lock() + ch, ok := c.pending[resp.ID] + if ok { + delete(c.pending, resp.ID) + } + c.pendMu.Unlock() + if ok { + ch <- &resp + } + continue + } + + if peek.Method == "blockchain.scripthash.subscribe" { + var notif jsonRPCNotification + if err := json.Unmarshal(line, ¬if); err != nil { + log.WithError(err).Debug("electrum: failed to parse notification") + continue + } + if len(notif.Params) < 2 { + continue + } + var scripthash, status string + // nolint + json.Unmarshal(notif.Params[0], &scripthash) + // nolint + json.Unmarshal(notif.Params[1], &status) + c.subsMu.RLock() + ch, ok := c.subs[scripthash] + if ok { + select { + case ch <- status: + default: + } + } + c.subsMu.RUnlock() + } + } + + // If our cycle context was cancelled, reconnect() already owns recovery + // (e.g., it cancelled us after a failed handshake). Don't double-reconnect. + select { + case <-ctx.Done(): + return + default: + } + + // Scanner exited — reconnect unless the client is shutting down. + // A nil error means clean EOF (e.g. server restarted and sent FIN); + // we must reconnect in that case too, not just on I/O errors. + if !c.contextDone() { + if err := scanner.Err(); err != nil { + log.WithError(err).Debug("electrum: connection error, reconnecting") + } else { + log.Debug("electrum: server closed connection, reconnecting") + } + if err := c.reconnect(); err != nil { + log.WithError(err).Error("electrum: reconnect failed") + } + } +} + +func (c *electrumClient) contextDone() bool { + select { + case <-c.ctx.Done(): + return true + default: + return false + } +} + +// keepAlive sends periodic server.ping RPCs to keep the connection alive. +// It exits when ctx is cancelled (i.e. when a new connection cycle starts). +func (c *electrumClient) keepAlive(ctx context.Context) { + ticker := time.NewTicker(keepAliveInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if _, err := c.request("server.ping", []any{}); err != nil { + if !c.contextDone() { + log.WithError(err).Debug("electrum: ping failed") + } + return + } + } + } +} + +// reconnect re-dials with exponential backoff and replays all subscriptions. +// Only one goroutine runs the reconnect body at a time. +// listen() is started before handshake() so that responses can be dispatched. +// If handshake fails the new cycle is cancelled so the freshly-started listen +// goroutine exits without triggering yet another reconnect. +func (c *electrumClient) reconnect() error { + c.reconnectMu.Lock() + defer c.reconnectMu.Unlock() + + // Fail all in-flight requests immediately so callers get "connection + // closed" errors rather than waiting the full requestTimeout. + c.pendMu.Lock() + for id, ch := range c.pending { + close(ch) + delete(c.pending, id) + } + c.pendMu.Unlock() + + delay := reconnectBaseDelay + for { + if c.contextDone() { + return errors.New("context cancelled") + } + conn, err := c.dial() + if err != nil { + log.WithError(err).Debugf("electrum: reconnect failed, retrying in %s", delay) + select { + case <-c.ctx.Done(): + return errors.New("context cancelled") + case <-time.After(delay): + } + if delay < reconnectMaxDelay { + delay *= 2 + if delay > reconnectMaxDelay { + delay = reconnectMaxDelay + } + } + continue + } + + c.setConn(conn) + + // Start listen before handshake so the handshake response can be read. + cycleCtx, cancelCycle := c.newCycleCtx() + go c.listen(cycleCtx) + go c.keepAlive(cycleCtx) + + if err := c.handshake(); err != nil { + // Cancel the cycle first so listen() won't trigger another reconnect + // when we close the connection below. + cancelCycle() + // Close under writeMu so any concurrent request() that already called + // getConn() and is waiting for writeMu sees the conn closed (write + // error) rather than silently writing to a dead socket. Setting + // c.conn = nil first ensures getConn() returns nil to new callers once + // writeMu is released. + c.writeMu.Lock() + c.connMu.Lock() + if c.conn == conn { + c.conn = nil + } + c.connMu.Unlock() + conn.Close() // nolint + c.writeMu.Unlock() + // Drain requests that arrived after the initial drain at the top of + // this function (e.g., requests that wrote to conn before handshake + // failed). Fail them immediately instead of letting them time out. + c.pendMu.Lock() + for id, ch := range c.pending { + close(ch) + delete(c.pending, id) + } + c.pendMu.Unlock() + continue + } + + // Replay subscriptions in parallel (bounded concurrency) so that a + // wallet with many addresses does not stall recovery linearly. + c.storedSubsMu.Lock() + subs := make([]string, len(c.storedSubs)) + copy(subs, c.storedSubs) + c.storedSubsMu.Unlock() + + const resubParallelism = 8 + sem := make(chan struct{}, resubParallelism) + var resubWg sync.WaitGroup + for _, sh := range subs { + sh := sh + sem <- struct{}{} + resubWg.Add(1) + go func() { + defer resubWg.Done() + defer func() { <-sem }() + if _, err := c.request("blockchain.scripthash.subscribe", []any{sh}); err != nil { + log.WithError(err).Warnf("electrum: failed to resubscribe %s", sh) + } + }() + } + resubWg.Wait() + + log.Debug("electrum: reconnected and replayed subscriptions") + return nil + } +} + +// request sends a single JSON-RPC call and waits up to requestTimeout for the response. +func (c *electrumClient) request(method string, params []any) (json.RawMessage, error) { + id := c.reqID.Add(1) + req := jsonRPCRequest{ID: id, Method: method, Params: params} + + data, err := json.Marshal(req) + if err != nil { + return nil, err + } + data = append(data, '\n') + + ch := make(chan *jsonRPCResponse, 1) + c.pendMu.Lock() + c.pending[id] = ch + c.pendMu.Unlock() + defer func() { + c.pendMu.Lock() + delete(c.pending, id) + c.pendMu.Unlock() + }() + + c.writeMu.Lock() + conn := c.getConn() + if conn == nil { + c.writeMu.Unlock() + return nil, fmt.Errorf("not connected") + } + _, err = conn.Write(data) + c.writeMu.Unlock() + if err != nil { + return nil, fmt.Errorf("write failed: %w", err) + } + + ctx, cancel := context.WithTimeout(c.ctx, requestTimeout) + defer cancel() + select { + case <-ctx.Done(): + return nil, fmt.Errorf("request timed out: %s", method) + case resp, ok := <-ch: + if !ok { + return nil, fmt.Errorf("connection closed waiting for %s", method) + } + if rpcErr := parseRPCError(resp.Error); rpcErr != nil { + return nil, fmt.Errorf("electrum error %d: %s", rpcErr.Code, rpcErr.Message) + } + return resp.Result, nil + } +} + +// subscribe sends blockchain.scripthash.subscribe and returns a channel that +// receives status-hash strings whenever the scripthash state changes. +func (c *electrumClient) subscribe(scripthash string) (<-chan string, error) { + ch := make(chan string, 8) + c.subsMu.Lock() + c.subs[scripthash] = ch + c.subsMu.Unlock() + + c.storedSubsMu.Lock() + c.storedSubs = append(c.storedSubs, scripthash) + c.storedSubsMu.Unlock() + + result, err := c.request("blockchain.scripthash.subscribe", []any{scripthash}) + if err != nil { + c.subsMu.Lock() + delete(c.subs, scripthash) + c.subsMu.Unlock() + + c.storedSubsMu.Lock() + filtered := c.storedSubs[:0] + for _, sh := range c.storedSubs { + if sh != scripthash { + filtered = append(filtered, sh) + } + } + c.storedSubs = filtered + c.storedSubsMu.Unlock() + + return nil, err + } + + // If the initial status is non-null, emit it so the caller sees the current state. + var initialStatus string + if err := json.Unmarshal(result, &initialStatus); err == nil && initialStatus != "" { + select { + case ch <- initialStatus: + default: + } + } + + return ch, nil +} + +// unsubscribeLocal removes a scripthash from the local subs map. +// ElectrumX has no unsubscribe wire message. +func (c *electrumClient) unsubscribeLocal(scripthash string) { + c.subsMu.Lock() + if ch, ok := c.subs[scripthash]; ok { + close(ch) + delete(c.subs, scripthash) + } + c.subsMu.Unlock() + + c.storedSubsMu.Lock() + filtered := c.storedSubs[:0] + for _, sh := range c.storedSubs { + if sh != scripthash { + filtered = append(filtered, sh) + } + } + c.storedSubs = filtered + c.storedSubsMu.Unlock() +} + +func (c *electrumClient) isConnected() bool { + return c.getConn() != nil +} diff --git a/explorer/electrum/doc.go b/explorer/electrum/doc.go new file mode 100644 index 00000000..5a2a6554 --- /dev/null +++ b/explorer/electrum/doc.go @@ -0,0 +1,119 @@ +// Package electrum_explorer provides an Explorer implementation backed by an +// ElectrumX server over TCP or SSL. It is modeled on the ocean project's +// electrum blockchain scanner and requires no third-party ElectrumX library. +// +// # Overview +// +// The package has two layers: +// +// - electrumClient (transport, client.go): owns a single multiplexed +// TCP/SSL connection. Multiplexes JSON-RPC requests via atomic request +// IDs over one socket, runs a listen + keepAlive goroutine pair per +// connection cycle, and reconnects with exponential backoff on any +// disconnect. +// +// - explorerSvc (protocol, explorer.go): implements the +// explorer.Explorer interface. Does Bitcoin-aware encoding/decoding, +// address tracking with a poll loop, and event broadcasting through a +// non-blocking listeners hub. +// +// Both layers are safe to call from any goroutine; internal locks serialise +// concurrent access. Callers do not need to provide their own synchronisation. +// +// # Lifecycle +// +// The Explorer transitions through these states: +// +// created ─Start()─► connecting ─handshake OK─► ready ─Stop()─► stopped +// │ +// └─dial fail─► (background reconnect goroutine) +// +// ready ─clean EOF / I/O err─► reconnecting ─dial OK + handshake─► ready +// │ +// └─dial fail─► (exp. backoff 5 s → 60 s) +// +// Start dials, completes the server.version handshake, and (if tracking is +// enabled) launches the polling loop. Stop cancels the root context, drains +// per-address notification consumers, closes listener channels, and resets +// the subscription map. +// +// # Reconnection model +// +// The connection is owned by electrumClient. On any disconnect, including a +// clean EOF from a server restart, listen() exits and falls through to +// reconnect(). reconnect drains all in-flight requests so they fail fast +// with "connection closed" instead of waiting the full requestTimeout, then +// dials with exponential backoff. On success, it replays every scripthash +// recorded in storedSubs via parallel blockchain.scripthash.subscribe RPCs. +// +// Two contexts cooperate to bound goroutine lifetimes: +// +// - c.ctx / c.cancel: the root context. Cancelled only by shutdown(), +// which is reached via explorerSvc.Stop(). Unblocks the reconnect loop's +// time.After backoff and prevents zombie reconnect goroutines from +// outliving the explorer. +// +// - cycleCtx / cycleCancel: a per-connection-cycle context. Cancelled at +// every reconnect to terminate the listen + keepAlive goroutine pair of +// the previous cycle. The new cycle starts a fresh pair under a fresh +// context. This prevents goroutine accumulation across multiple +// reconnects; without it, every reconnect would leak two goroutines. +// +// # Restart and restore semantics +// +// The explorer is stateless across process boundaries by design. The only +// piece of explorer-related state persisted by the surrounding SDK is the +// server URL (recorded in arkClient's ConfigStore via Init). On a fresh +// process start, the path is: +// +// 1. LoadArkClient reads the persisted URL from ConfigStore. +// 2. newExplorer (init.go) dispatches on URL scheme: +// "tcp://" or "ssl://" => this package; anything else => mempool.space. +// 3. NewExplorer constructs a fresh electrumClient with empty subscription +// and cache state. +// 4. Unlock() calls Explorer().Start(), which dials, runs +// ScanContracts + refreshDb to rebuild wallet state from the +// persistent store + chain, then hands off to the polling loop. +// +// During restore the SDK fires bursts of GetTxs / GetUtxos calls. The +// transport multiplexes them on the single TCP connection via atomic request +// IDs; a slow server stalls the burst rather than parallelising it. +// +// Anyone refactoring this package should NOT introduce on-disk explorer +// state without also adding a corresponding crash-recovery path. The current +// design is deliberate: the persistent state-of-record lives in arkd and the +// SDK's app-data store; the explorer is a fresh, transient view of it. +// +// # Concurrency contract +// +// All exported methods on the Explorer interface are safe to call from any +// goroutine. Implementation notes: +// +// - Address subscriptions are protected by subscribedMu (RWMutex on the +// map) plus a per-address subscribingSet that serialises concurrent +// subscribe attempts for the same address. Only one goroutine may drive +// the RPC for a given address at a time; others see it as in-flight and +// skip. +// - The listeners hub uses an RWMutex around the listener-channel map; +// broadcasts non-blockingly fan out to every consumer and auto-evict +// consumers that fall behind. +// - The transport-level c.subs map (scripthash => notif channel) is +// protected by subsMu and is read/written by listen() under RLock and +// by subscribe/unsubscribeLocal under Lock. +// - storedSubs is replayed on every reconnect; it is protected by its own +// mutex separate from subsMu so that subscribe() can drop one lock +// before acquiring the other (enforces lock ordering and avoids holding +// either lock across a blocking RPC). +// +// # Known limitations vs the mempool.space explorer +// +// - Broadcast of multiple txs is sequential, not atomic. +// - UnsubscribeForAddresses removes the address locally only; ElectrumX +// has no unsubscribe wire message. +// - OnchainAddressEvent.Replacements is always empty (ElectrumX has no +// RBF notification). +// - GetConnectionCount always returns 1 (single multiplexed TCP +// connection). +// - GetTxOutspends is O(outputs x history length) rather than a dedicated +// endpoint. +package electrum_explorer diff --git a/explorer/electrum/explorer.go b/explorer/electrum/explorer.go new file mode 100644 index 00000000..93bb1716 --- /dev/null +++ b/explorer/electrum/explorer.go @@ -0,0 +1,928 @@ +// See doc.go for the package-level overview, lifecycle, reconnection model, +// and restart/restore semantics. +package electrum_explorer + +import ( + "bytes" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "maps" + "net/http" + "slices" + "strings" + "sync" + "time" + + arklib "github.com/arkade-os/arkd/pkg/ark-lib" + "github.com/arkade-os/arkd/pkg/client-lib/explorer" + "github.com/arkade-os/arkd/pkg/client-lib/types" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + log "github.com/sirupsen/logrus" +) + +type addressState struct { + mu sync.Mutex // serializes concurrent pollAddress calls for this address + scripthash string + utxos []electrumUTXO + // notifCh is the channel returned by client.subscribe(); nil when noTracking. + notifCh <-chan string +} + +type explorerSvc struct { + client *electrumClient + serverURL string + esploraURL string // optional HTTP REST base URL for package broadcasts + netParams *chaincfg.Params + + noTracking bool + pollInterval time.Duration + + subscribedMu sync.RWMutex + subscribedMap map[string]*addressState // address => state + // subscribingSet holds addresses currently being subscribed (reservation + // held while the RPC is in flight). Protected by subscribedMu. Prevents + // concurrent goroutines from racing to subscribe the same address, which + // would orphan notification channels and cause Stop() to deadlock. + subscribingSet map[string]struct{} + // stopped is set to true by Stop() under subscribedMu before draining + // subscribedMap. SubscribeForAddresses checks this flag (also under + // subscribedMu) before calling notifWg.Add(1), preventing a race between + // Add and the Wait() call in Stop(). + stopped bool + + // notifWg tracks the per-address goroutines spawned in SubscribeForAddresses. + // Stop() waits on this before returning to ensure no goroutine outlives the svc. + notifWg sync.WaitGroup + + // reverse lookup: scripthash => address + scripthashToAddr map[string]string + scripthashToAddrMu sync.RWMutex + + startOnce sync.Once + stopTracking func() + listeners *listeners + + cacheMu sync.RWMutex + cache map[string]string // txid => hex; bounded to txCacheMaxSize entries +} + +const txCacheMaxSize = 1024 + +// NewExplorer creates a new ElectrumX-backed Explorer. +// serverURL must begin with "tcp://" or "ssl://". +// Connection is established lazily when Start() is called. +func NewExplorer(serverURL string, net arklib.Network, opts ...Option) (explorer.Explorer, error) { + if !strings.HasPrefix(serverURL, "tcp://") && !strings.HasPrefix(serverURL, "ssl://") { + return nil, fmt.Errorf("electrum server url must start with tcp:// or ssl://") + } + + svc := &explorerSvc{ + client: newElectrumClient(serverURL), + serverURL: serverURL, + netParams: networkToChainParams(net), + noTracking: true, // default off; WithTracker(true) enables + pollInterval: 10 * time.Second, + subscribedMap: make(map[string]*addressState), + subscribingSet: make(map[string]struct{}), + scripthashToAddr: make(map[string]string), + listeners: newListeners(), // always initialised so GetAddressesEvents channels are closed on Stop + cache: make(map[string]string), + } + for _, opt := range opts { + opt(svc) + } + return svc, nil +} + +func networkToChainParams(net arklib.Network) *chaincfg.Params { + switch net.Name { + case arklib.BitcoinTestNet.Name: + return &chaincfg.TestNet3Params + case arklib.BitcoinSigNet.Name: + return &chaincfg.SigNetParams + case arklib.BitcoinMutinyNet.Name: + mutinyParams := arklib.MutinyNetSigNetParams + return &mutinyParams + case arklib.BitcoinRegTest.Name: + return &chaincfg.RegressionNetParams + default: + return &chaincfg.MainNetParams + } +} + +// Start dials the ElectrumX server, performs the server.version handshake, +// and (if tracking is enabled via WithTracker(true)) launches the poll loop +// over subscribed addresses. +// +// If the initial connect fails Start does NOT return an error; it logs a +// warning and spawns a background goroutine that retries via the same +// exponential-backoff reconnect path used after a mid-life disconnect. +// +// Concurrency: idempotent. Concurrent Start() calls are safe; only the +// first call dials and launches goroutines. +func (e *explorerSvc) Start() { + e.startOnce.Do(func() { + if err := e.client.connect(); err != nil { + log.WithError(err). + Warn("electrum explorer: initial connect failed, retrying in background") + go func() { + if err := e.client.reconnect(); err != nil { + log.WithError(err).Error("electrum explorer: background reconnect failed") + } + }() + } + + if e.noTracking { + return + } + + stopCh := make(chan struct{}) + e.stopTracking = sync.OnceFunc(func() { close(stopCh) }) + go e.pollLoop(stopCh) + log.Debug("electrum explorer: started") + }) +} + +// Stop terminates the explorer in this order: +// +// 1. Closes the poll-loop stop channel; pollLoop returns on its next tick. +// 2. Calls client.shutdown(); cancels the root context, closes the live +// conn, and drains in-flight pending requests so callers fail fast. +// 3. Calls unsubscribeLocal for every subscribed address; closes each +// per-address notif channel, causing the per-address consumer goroutine +// spawned in SubscribeForAddresses to exit. +// 4. Waits on notifWg until every per-address consumer has exited. +// 5. Clears the listeners hub; closes every consumer event channel. +// +// Concurrency: not safe to call concurrently with itself. Safe to call +// concurrently with one-shot RPC methods (GetTxHex, etc.); those will see +// "connection closed" errors as part of step 2. +func (e *explorerSvc) Stop() { + if e.stopTracking != nil { + e.stopTracking() + e.stopTracking = nil + } + e.client.shutdown() + + // Set stopped and drain subscribedMap under the same lock so that any + // concurrent SubscribeForAddresses call sees stopped=true before calling + // notifWg.Add(1), preventing a race with the notifWg.Wait() below. + e.subscribedMu.Lock() + e.stopped = true + for _, state := range e.subscribedMap { + e.client.unsubscribeLocal(state.scripthash) + } + e.subscribedMap = make(map[string]*addressState) + e.subscribedMu.Unlock() + + e.notifWg.Wait() + + e.listeners.clear() + + e.scripthashToAddrMu.Lock() + e.scripthashToAddr = make(map[string]string) + e.scripthashToAddrMu.Unlock() + + log.Debug("electrum explorer: stopped") +} + +func (e *explorerSvc) BaseUrl() string { return e.serverURL } + +func (e *explorerSvc) GetConnectionCount() int { + if e.client.isConnected() { + return 1 + } + return 0 +} + +func (e *explorerSvc) GetSubscribedAddresses() []string { + e.subscribedMu.RLock() + defer e.subscribedMu.RUnlock() + return slices.Collect(maps.Keys(e.subscribedMap)) +} + +func (e *explorerSvc) IsAddressSubscribed(address string) bool { + e.subscribedMu.RLock() + defer e.subscribedMu.RUnlock() + _, ok := e.subscribedMap[address] + return ok +} + +func (e *explorerSvc) GetAddressesEvents() <-chan types.OnchainAddressEvent { + ch := make(chan types.OnchainAddressEvent, 8) + e.listeners.add(ch) + return ch +} + +func (e *explorerSvc) GetTxHex(txid string) (string, error) { + e.cacheMu.RLock() + if h, ok := e.cache[txid]; ok { + e.cacheMu.RUnlock() + return h, nil + } + e.cacheMu.RUnlock() + + result, err := e.client.request("blockchain.transaction.get", []any{txid, false}) + if err != nil { + return "", err + } + var txHex string + if err := json.Unmarshal(result, &txHex); err != nil { + return "", err + } + e.setCacheTx(txid, txHex) + return txHex, nil +} + +// setCacheTx stores a txid→hex mapping, evicting a random entry if the cache +// is at capacity to keep memory bounded. +func (e *explorerSvc) setCacheTx(txid, hex string) { + e.cacheMu.Lock() + defer e.cacheMu.Unlock() + if _, exists := e.cache[txid]; !exists && len(e.cache) >= txCacheMaxSize { + for k := range e.cache { + delete(e.cache, k) + break + } + } + e.cache[txid] = hex +} + +// Broadcast broadcasts one or more raw transactions sequentially. +// Returns the txid of the first transaction. Multiple txs are not atomic. +func (e *explorerSvc) Broadcast(txs ...string) (string, error) { + if len(txs) == 0 { + return "", fmt.Errorf("no txs to broadcast") + } + + // When broadcasting a package (multiple transactions), use the esplora REST + // /txs/package endpoint if configured. This is required for v3 transactions + // that carry a zero-fee P2A anchor output: Bitcoin Core's sendrawtransaction + // rejects them individually, but submitpackage (which /txs/package calls) + // accepts the parent+child together. + if len(txs) > 1 && e.esploraURL != "" { + return e.broadcastPackage(txs...) + } + + var firstTxid string + for i, tx := range txs { + txHex, txid, err := parseBitcoinTx(tx) + if err != nil { + return "", fmt.Errorf("tx %d: %w", i, err) + } + + result, err := e.client.request("blockchain.transaction.broadcast", []any{txHex}) + if err != nil { + if strings.Contains( + strings.ToLower(err.Error()), + "transaction already in block chain", + ) { + // Tx is confirmed on-chain; safe to cache. + e.setCacheTx(txid, txHex) + if i == 0 { + firstTxid = txid + } + continue + } + return "", err + } + // Cache only after a successful broadcast to avoid false positives on failure. + e.setCacheTx(txid, txHex) + + var returnedTxid string + // nolint + json.Unmarshal(result, &returnedTxid) + if returnedTxid == "" { + returnedTxid = txid + } + if i == 0 { + firstTxid = returnedTxid + } + } + return firstTxid, nil +} + +// broadcastPackage submits multiple transactions as a single package via the +// esplora REST API (POST /txs/package → Bitcoin Core submitpackage). This is +// the only way to broadcast a zero-fee v3 parent with a CPFP child that +// provides the fee. +func (e *explorerSvc) broadcastPackage(txs ...string) (string, error) { + type parsedTx struct{ txid, txHex string } + parsed := make([]parsedTx, 0, len(txs)) + hexes := make([]string, 0, len(txs)) + for i, tx := range txs { + txHex, txid, err := parseBitcoinTx(tx) + if err != nil { + return "", fmt.Errorf("tx %d: %w", i, err) + } + hexes = append(hexes, txHex) + parsed = append(parsed, parsedTx{txid, txHex}) + } + + body, err := json.Marshal(hexes) + if err != nil { + return "", fmt.Errorf("package marshal: %w", err) + } + + url := strings.TrimRight(e.esploraURL, "/") + "/txs/package" + resp, err := http.Post(url, "application/json", bytes.NewReader(body)) // nolint + if err != nil { + return "", fmt.Errorf("package broadcast: %w", err) + } + defer resp.Body.Close() // nolint + + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("package broadcast failed (%d): %s", resp.StatusCode, respBody) + } + + // The response is a JSON object describing per-tx results. We don't parse + // it in detail — success is indicated by a 200 status. Return the first txid. + for _, p := range parsed { + e.setCacheTx(p.txid, p.txHex) + } + return parsed[0].txid, nil +} + +func (e *explorerSvc) GetTxs(addr string) ([]explorer.Tx, error) { + sh, err := addressToScripthash(addr, e.netParams) + if err != nil { + return nil, err + } + result, err := e.client.request("blockchain.scripthash.get_history", []any{sh}) + if err != nil { + return nil, err + } + var history []electrumHistoryEntry + if err := json.Unmarshal(result, &history); err != nil { + return nil, err + } + + txs := make([]explorer.Tx, 0, len(history)) + for _, entry := range history { + txHex, err := e.GetTxHex(entry.TxHash) + if err != nil { + return nil, err + } + tx, err := decodeBitcoinTx(txHex) + if err != nil { + return nil, err + } + confirmed := entry.Height > 0 + var blocktime int64 + if confirmed { + blocktime, _ = e.blockTimestamp(entry.Height) + } + txs = append(txs, wireTxToExplorerTx(entry.TxHash, tx, blocktime, confirmed, e.netParams)) + } + return txs, nil +} + +// GetTxOutspends returns the spent status of each output of a transaction. +// There is no direct ElectrumX equivalent; this resolves by scanning +// the scripthash history of each output. This is O(outputs × history_depth) +// round-trips, which is acceptable for low-traffic Ark outputs. +func (e *explorerSvc) GetTxOutspends(txid string) ([]explorer.SpentStatus, error) { + txHex, err := e.GetTxHex(txid) + if err != nil { + return nil, err + } + tx, err := decodeBitcoinTx(txHex) + if err != nil { + return nil, err + } + result := make([]explorer.SpentStatus, len(tx.TxOut)) + for i, out := range tx.TxOut { + script := hex.EncodeToString(out.PkScript) + if script == "" { + continue + } + sh, err := scriptToScripthash(script) + if err != nil { + log.WithError(err). + Debugf("electrum: scriptToScripthash failed for output %d of %s", i, txid) + continue + } + histResult, err := e.client.request("blockchain.scripthash.get_history", []any{sh}) + if err != nil { + return nil, fmt.Errorf("get_history for output %d of %s: %w", i, txid, err) + } + var history []electrumHistoryEntry + if err := json.Unmarshal(histResult, &history); err != nil { + log.WithError(err). + Debugf("electrum: unmarshal history failed for output %d of %s", i, txid) + continue + } + for _, entry := range history { + if entry.TxHash == txid { + continue + } + spendingHex, err := e.GetTxHex(entry.TxHash) + if err != nil { + continue + } + spendingTx, err := decodeBitcoinTx(spendingHex) + if err != nil { + continue + } + for _, vin := range spendingTx.TxIn { + if vin.PreviousOutPoint.Hash.String() == txid && + vin.PreviousOutPoint.Index == uint32(i) { + result[i] = explorer.SpentStatus{Spent: true, SpentBy: entry.TxHash} + break + } + } + if result[i].Spent { + break + } + } + } + return result, nil +} + +func (e *explorerSvc) GetUtxos(addresses []string) ([]explorer.Utxo, error) { + btCache := make(map[int64]int64) + var utxos []explorer.Utxo + for _, addr := range addresses { + sh, err := addressToScripthash(addr, e.netParams) + if err != nil { + return nil, err + } + script, err := addrToScript(addr, e.netParams) + if err != nil { + return nil, err + } + electrumUtxos, err := e.listUnspent(sh) + if err != nil { + return nil, err + } + for _, u := range electrumUtxos { + var blocktime int64 + confirmed := u.Height > 0 + if confirmed { + if t, ok := btCache[u.Height]; ok { + blocktime = t + } else { + blocktime, _ = e.blockTimestamp(u.Height) + btCache[u.Height] = blocktime + } + } + utxos = append(utxos, explorer.Utxo{ + Txid: u.TxHash, + Vout: u.TxPos, + Amount: u.Value, + Script: script, + Status: explorer.ConfirmedStatus{Confirmed: confirmed, BlockTime: blocktime}, + }) + } + } + return utxos, nil +} + +func (e *explorerSvc) GetRedeemedVtxosBalance( + addr string, unilateralExitDelay arklib.RelativeLocktime, +) (spendableBalance uint64, lockedBalance map[int64]uint64, err error) { + utxos, err := e.GetUtxos([]string{addr}) + if err != nil { + return + } + lockedBalance = make(map[int64]uint64) + now := time.Now() + for _, utxo := range utxos { + blocktime := now + if utxo.Status.Confirmed { + blocktime = time.Unix(utxo.Status.BlockTime, 0) + } + availableAt := blocktime.Add(time.Duration(unilateralExitDelay.Seconds()) * time.Second) + if availableAt.After(now) { + lockedBalance[availableAt.Unix()] += utxo.Amount + } else { + spendableBalance += utxo.Amount + } + } + return +} + +func (e *explorerSvc) GetTxBlockTime(txid string) (confirmed bool, blocktime int64, err error) { + // blockchain.transaction.get does not return blocktime directly, so we decode + // the raw TX to get an output script, then look up the tx in the scripthash + // history to determine block height. + txHex, err := e.GetTxHex(txid) + if err != nil { + return false, 0, err + } + tx, err := decodeBitcoinTx(txHex) + if err != nil { + return false, 0, err + } + if len(tx.TxOut) == 0 { + return false, 0, nil + } + // Ark txs always have a spendable output first, so TxOut[0] is never OP_RETURN. + script := hex.EncodeToString(tx.TxOut[0].PkScript) + sh, err := scriptToScripthash(script) + if err != nil { + return false, 0, err + } + histResult, err := e.client.request("blockchain.scripthash.get_history", []any{sh}) + if err != nil { + return false, 0, err + } + var history []electrumHistoryEntry + if err := json.Unmarshal(histResult, &history); err != nil { + return false, 0, err + } + for _, entry := range history { + if entry.TxHash != txid || entry.Height <= 0 { + continue + } + bt, err := e.blockTimestamp(entry.Height) + if err != nil { + return false, 0, err + } + return true, bt, nil + } + return false, 0, nil +} + +func (e *explorerSvc) GetFeeRate() (float64, error) { + result, err := e.client.request("blockchain.estimatefee", []any{1}) + if err != nil { + return 1, err + } + var btcPerKB float64 + if err := json.Unmarshal(result, &btcPerKB); err != nil { + return 1, err + } + if btcPerKB <= 0 { + return 1, nil + } + // BTC/kB → sat/vB + return btcPerKB * 1e8 / 1000, nil +} + +// SubscribeForAddresses subscribes to ElectrumX push notifications. +// A background goroutine per address forwards notifications into the polling loop +// by triggering an immediate poll when ElectrumX reports a state change. +func (e *explorerSvc) SubscribeForAddresses(addresses []string) error { + if e.noTracking { + return nil + } + for _, addr := range addresses { + // Reserve the address slot before doing expensive work. subscribingSet + // prevents a second concurrent goroutine from racing through subscribe + // for the same address, which would orphan notification channels and + // cause Stop() to deadlock on notifWg.Wait(). + e.subscribedMu.Lock() + _, already := e.subscribedMap[addr] + _, inFlight := e.subscribingSet[addr] + if already || inFlight { + e.subscribedMu.Unlock() + continue + } + e.subscribingSet[addr] = struct{}{} + e.subscribedMu.Unlock() + + sh, err := addressToScripthash(addr, e.netParams) + if err != nil { + e.subscribedMu.Lock() + delete(e.subscribingSet, addr) + e.subscribedMu.Unlock() + return fmt.Errorf("invalid address %s: %w", addr, err) + } + notifCh, err := e.client.subscribe(sh) + if err != nil { + e.subscribedMu.Lock() + delete(e.subscribingSet, addr) + e.subscribedMu.Unlock() + return fmt.Errorf("failed to subscribe for %s: %w", addr, err) + } + + // Move notifWg.Add(1) inside subscribedMu so it is serialised with + // Stop()'s stopped=true assignment. This prevents a race between Add + // and the notifWg.Wait() call in Stop(). + e.subscribedMu.Lock() + delete(e.subscribingSet, addr) + if e.stopped { + e.subscribedMu.Unlock() + return fmt.Errorf("electrum explorer is stopped") + } + // Start with nil so pollAddress treats all current UTXOs as new on first + // poll. Capturing initialUTXOs before subscribe risks a race where funds + // arrive during a retry delay and get silently absorbed as the baseline. + state := &addressState{scripthash: sh, utxos: nil, notifCh: notifCh} + e.subscribedMap[addr] = state + e.notifWg.Add(1) + e.subscribedMu.Unlock() + + e.scripthashToAddrMu.Lock() + e.scripthashToAddr[sh] = addr + e.scripthashToAddrMu.Unlock() + + // When ElectrumX pushes a notification for this scripthash, immediately poll + // the address rather than waiting for the next ticker cycle. The initial + // pollAddress call establishes the UTXO baseline so that the first push + // notification correctly detects changes rather than comparing against nil. + go func(addr, sh string, notifCh <-chan string) { + defer e.notifWg.Done() + e.pollAddress(addr, sh) + for range notifCh { + e.pollAddress(addr, sh) + } + }(addr, sh, notifCh) + } + return nil +} + +func (e *explorerSvc) UnsubscribeForAddresses(addresses []string) error { + if e.noTracking { + return nil + } + for _, addr := range addresses { + e.subscribedMu.Lock() + state, ok := e.subscribedMap[addr] + if ok { + delete(e.subscribedMap, addr) + } + e.subscribedMu.Unlock() + if ok { + e.scripthashToAddrMu.Lock() + delete(e.scripthashToAddr, state.scripthash) + e.scripthashToAddrMu.Unlock() + e.client.unsubscribeLocal(state.scripthash) + } + } + return nil +} + +// pollLoop periodically polls all subscribed addresses for UTXO changes. +// ElectrumX push notifications trigger immediate polls via pollAddress. +func (e *explorerSvc) pollLoop(stopCh <-chan struct{}) { + ticker := time.NewTicker(e.pollInterval) + defer ticker.Stop() + for { + select { + case <-stopCh: + return + case <-ticker.C: + e.pollAll() + } + } +} + +func (e *explorerSvc) pollAll() { + e.subscribedMu.RLock() + snapshot := make(map[string]string, len(e.subscribedMap)) + for addr, state := range e.subscribedMap { + snapshot[addr] = state.scripthash + } + e.subscribedMu.RUnlock() + + for addr, sh := range snapshot { + e.pollAddress(addr, sh) + } +} + +func (e *explorerSvc) pollAddress(addr, scripthash string) { + e.subscribedMu.RLock() + state, ok := e.subscribedMap[addr] + e.subscribedMu.RUnlock() + if !ok { + return + } + + // Serialize concurrent polls for the same address so that two goroutines + // cannot both read state.utxos, independently decide there is a diff, and + // then each broadcast a partial or duplicated event. + state.mu.Lock() + defer state.mu.Unlock() + + newUTXOs, err := e.listUnspent(scripthash) + if err != nil { + log.WithError(err).Errorf("electrum: poll failed for %s", addr) + go e.listeners.broadcast(types.OnchainAddressEvent{ + Error: fmt.Errorf("failed to poll %s: %w", addr, err), + }) + return + } + + script, err := addrToScript(addr, e.netParams) + if err != nil { + log.WithError(err).Errorf("electrum: failed to derive script for %s", addr) + return + } + event, changed := diffUTXOs(state.utxos, newUTXOs, script) + if !changed { + return + } + + // Build outpoint → electrumUTXO lookup for height-based enrichment. + newByOutpoint := make(map[types.Outpoint]electrumUTXO, len(newUTXOs)) + for _, u := range newUTXOs { + newByOutpoint[types.Outpoint{Txid: u.TxHash, VOut: u.TxPos}] = u + } + + // Cache block header timestamps to avoid redundant RPC calls within one poll. + btCache := make(map[int64]int64) + blocktime := func(height int64) time.Time { + if height <= 0 { + return time.Time{} + } + if t, ok := btCache[height]; ok { + return time.Unix(t, 0) + } + t, err := e.blockTimestamp(height) + if err != nil { + return time.Time{} + } + btCache[height] = t + return time.Unix(t, 0) + } + + // Populate CreatedAt for newly seen UTXOs that are already confirmed. + for i := range event.NewUtxos { + if eu, ok := newByOutpoint[event.NewUtxos[i].Outpoint]; ok && eu.Height > 0 { + event.NewUtxos[i].CreatedAt = blocktime(eu.Height) + } + } + + // Populate CreatedAt for UTXOs that just confirmed (unconfirmed → confirmed). + for i := range event.ConfirmedUtxos { + if eu, ok := newByOutpoint[event.ConfirmedUtxos[i].Outpoint]; ok && eu.Height > 0 { + event.ConfirmedUtxos[i].CreatedAt = blocktime(eu.Height) + } + } + + // Populate SpentBy by querying the outspend status of each spent UTXO. + for i := range event.SpentUtxos { + op := event.SpentUtxos[i].Outpoint + statuses, err := e.GetTxOutspends(op.Txid) + if err == nil && int(op.VOut) < len(statuses) { + event.SpentUtxos[i].SpentBy = statuses[op.VOut].SpentBy + } + } + + state.utxos = newUTXOs + + go e.listeners.broadcast(event) +} + +func diffUTXOs(old, new []electrumUTXO, script string) (types.OnchainAddressEvent, bool) { + type key struct { + txid string + vout uint32 + } + oldMap := make(map[key]electrumUTXO, len(old)) + for _, u := range old { + oldMap[key{u.TxHash, u.TxPos}] = u + } + newMap := make(map[key]electrumUTXO, len(new)) + for _, u := range new { + newMap[key{u.TxHash, u.TxPos}] = u + } + + var spent, received, confirmed []types.OnchainOutput + + for k, u := range oldMap { + if _, exists := newMap[k]; !exists { + spent = append(spent, types.OnchainOutput{ + Outpoint: types.Outpoint{Txid: u.TxHash, VOut: u.TxPos}, + Script: script, + Amount: u.Value, + Spent: true, + }) + } + } + for k, u := range newMap { + oldU, existed := oldMap[k] + if !existed { + // CreatedAt is zero here; pollAddress fills it in from blockTimestamp. + received = append(received, types.OnchainOutput{ + Outpoint: types.Outpoint{Txid: u.TxHash, VOut: u.TxPos}, + Script: script, + Amount: u.Value, + }) + } else if oldU.Height == 0 && u.Height > 0 { + // CreatedAt is zero here; pollAddress fills it in from blockTimestamp. + confirmed = append(confirmed, types.OnchainOutput{ + Outpoint: types.Outpoint{Txid: u.TxHash, VOut: u.TxPos}, + Script: script, + Amount: u.Value, + }) + } + } + + if len(spent) == 0 && len(received) == 0 && len(confirmed) == 0 { + return types.OnchainAddressEvent{}, false + } + return types.OnchainAddressEvent{ + SpentUtxos: spent, + NewUtxos: received, + ConfirmedUtxos: confirmed, + Replacements: make(map[string]string), + }, true +} + +func (e *explorerSvc) listUnspent(scripthash string) ([]electrumUTXO, error) { + raw, err := e.client.request("blockchain.scripthash.listunspent", []any{scripthash}) + if err != nil { + return nil, err + } + var utxos []electrumUTXO + return utxos, json.Unmarshal(raw, &utxos) +} + +// decodeBitcoinTx deserializes a hex-encoded raw Bitcoin transaction. +func decodeBitcoinTx(txHex string) (*wire.MsgTx, error) { + b, err := hex.DecodeString(txHex) + if err != nil { + return nil, err + } + tx := wire.NewMsgTx(wire.TxVersion) + if err := tx.Deserialize(bytes.NewReader(b)); err != nil { + return nil, err + } + return tx, nil +} + +// wireTxToExplorerTx converts a decoded wire.MsgTx into the explorer.Tx format. +// Input prevout fields (script, address, amount) are not populated because +// electrs-esplora does not support verbose transactions. +func wireTxToExplorerTx( + txid string, + tx *wire.MsgTx, + blocktime int64, + confirmed bool, + params *chaincfg.Params, +) explorer.Tx { + vins := make([]explorer.Input, 0, len(tx.TxIn)) + for _, in := range tx.TxIn { + vins = append(vins, explorer.Input{ + Txid: in.PreviousOutPoint.Hash.String(), + Vout: in.PreviousOutPoint.Index, + }) + } + vouts := make([]explorer.Output, 0, len(tx.TxOut)) + for _, out := range tx.TxOut { + script := hex.EncodeToString(out.PkScript) + vouts = append(vouts, explorer.Output{ + Script: script, + Address: scriptToAddress(script, params), + Amount: uint64(out.Value), + }) + } + return explorer.Tx{ + Txid: txid, + Vin: vins, + Vout: vouts, + Status: explorer.ConfirmedStatus{ + Confirmed: confirmed, + BlockTime: blocktime, + }, + } +} + +// blockTimestamp returns the Unix timestamp of a block at the given height by +// parsing the 80-byte raw block header returned by blockchain.block.header. +func (e *explorerSvc) blockTimestamp(height int64) (int64, error) { + result, err := e.client.request("blockchain.block.header", []any{height, 0}) + if err != nil { + return 0, err + } + var headerHex string + if err := json.Unmarshal(result, &headerHex); err != nil { + return 0, err + } + headerBytes, err := hex.DecodeString(headerHex) + if err != nil { + return 0, err + } + + var hdr wire.BlockHeader + if err := hdr.Deserialize(bytes.NewReader(headerBytes)); err != nil { + // fallback: timestamp is at bytes 68-72 (LE uint32) in the 80-byte header + if len(headerBytes) >= 72 { + return int64(binary.LittleEndian.Uint32(headerBytes[68:72])), nil + } + return 0, err + } + return hdr.Timestamp.Unix(), nil +} + +func addrToScript(addr string, params *chaincfg.Params) (string, error) { + decoded, err := btcutil.DecodeAddress(addr, params) + if err != nil { + return "", err + } + script, err := txscript.PayToAddrScript(decoded) + if err != nil { + return "", err + } + return hex.EncodeToString(script), nil +} diff --git a/explorer/electrum/explorer_test.go b/explorer/electrum/explorer_test.go new file mode 100644 index 00000000..0376c52e --- /dev/null +++ b/explorer/electrum/explorer_test.go @@ -0,0 +1,1707 @@ +package electrum_explorer_test + +import ( + "encoding/json" + "fmt" + "net" + "runtime" + "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/stretchr/testify/require" +) + +// mockServer starts a local TCP listener and returns its address. +// The returned handler func is called once per complete JSON-RPC request line. +// It should write the full JSON-RPC response (including newline) to the conn. +type requestHandler func(conn net.Conn, req map[string]json.RawMessage) + +func startMockServer(t *testing.T, handler requestHandler) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { ln.Close() }) // nolint + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return // listener closed + } + go serveConn(conn, handler) + } + }() + return "tcp://" + ln.Addr().String() +} + +func serveConn(conn net.Conn, handler requestHandler) { + defer conn.Close() // nolint + buf := make([]byte, 4096) + var partial []byte + for { + n, err := conn.Read(buf) + if err != nil { + return + } + partial = append(partial, buf[:n]...) + for { + idx := -1 + for i, b := range partial { + if b == '\n' { + idx = i + break + } + } + if idx < 0 { + break + } + line := partial[:idx] + partial = partial[idx+1:] + + var req map[string]json.RawMessage + if err := json.Unmarshal(line, &req); err != nil { + continue + } + handler(conn, req) + } + } +} + +func writeResponse(conn net.Conn, id uint64, result any) { + data, _ := json.Marshal(result) + resp := fmt.Sprintf(`{"id":%d,"result":%s}`, id, string(data)) + conn.Write([]byte(resp + "\n")) // nolint +} + +func reqID(req map[string]json.RawMessage) uint64 { + var id uint64 + json.Unmarshal(req["id"], &id) // nolint + return id +} + +func reqMethod(req map[string]json.RawMessage) string { + var m string + json.Unmarshal(req["method"], &m) // nolint + return m +} + +// TestNewExplorerValidation checks that NewExplorer rejects bad URLs. +func TestNewExplorerValidation(t *testing.T) { + t.Run("rejects http URL", func(t *testing.T) { + _, err := electrum_explorer.NewExplorer("http://example.com", arklib.Bitcoin) + require.ErrorContains(t, err, "tcp:// or ssl://") + }) + + t.Run("rejects empty URL", func(t *testing.T) { + _, err := electrum_explorer.NewExplorer("", arklib.Bitcoin) + require.ErrorContains(t, err, "tcp:// or ssl://") + }) + + t.Run("accepts tcp:// URL", func(t *testing.T) { + exp, err := electrum_explorer.NewExplorer("tcp://127.0.0.1:50000", arklib.Bitcoin) + require.NoError(t, err) + require.NotNil(t, exp) + }) + + t.Run("accepts ssl:// URL", func(t *testing.T) { + exp, err := electrum_explorer.NewExplorer( + "ssl://electrum.example.com:50002", + arklib.Bitcoin, + ) + require.NoError(t, err) + require.NotNil(t, exp) + }) +} + +// TestBaseUrl checks that BaseUrl returns the configured server URL. +func TestBaseUrl(t *testing.T) { + url := "tcp://127.0.0.1:50000" + exp, err := electrum_explorer.NewExplorer(url, arklib.Bitcoin) + require.NoError(t, err) + require.Equal(t, url, exp.BaseUrl()) +} + +// TestGetTxHex tests the GetTxHex method against a mock ElectrumX server. +func TestGetTxHex(t *testing.T) { + const txid = "abc123" + const txHex = "0200000000" + + 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), txHex) + } + }) + + exp, err := electrum_explorer.NewExplorer( + serverURL, + arklib.Bitcoin, + electrum_explorer.WithTracker(false), + ) + require.NoError(t, err) + exp.Start() + defer exp.Stop() + + got, err := exp.GetTxHex(txid) + require.NoError(t, err) + require.Equal(t, txHex, got) +} + +// TestGetTxHexCached verifies that a second call for the same txid does not +// make a second network request. +func TestGetTxHexCached(t *testing.T) { + const txHex = "deadbeef" + callCount := 0 + + 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": + callCount++ + writeResponse(conn, reqID(req), txHex) + } + }) + + exp, err := electrum_explorer.NewExplorer( + serverURL, + arklib.Bitcoin, + electrum_explorer.WithTracker(false), + ) + require.NoError(t, err) + exp.Start() + defer exp.Stop() + + _, err = exp.GetTxHex("txid1") + require.NoError(t, err) + _, err = exp.GetTxHex("txid1") + require.NoError(t, err) + require.Equal(t, 1, callCount, "second call should use cache") +} + +// TestBroadcast verifies that Broadcast sends blockchain.transaction.broadcast +// and returns the txid. +func TestBroadcast(t *testing.T) { + // Minimal valid raw transaction (version 2, no inputs, no outputs, locktime 0). + const rawTx = "02000000000000000000" + const returnedTxid = "aaaa" + + 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.broadcast": + writeResponse(conn, reqID(req), returnedTxid) + } + }) + + exp, err := electrum_explorer.NewExplorer( + serverURL, + arklib.Bitcoin, + electrum_explorer.WithTracker(false), + ) + require.NoError(t, err) + exp.Start() + defer exp.Stop() + + txid, err := exp.Broadcast(rawTx) + require.NoError(t, err) + require.Equal(t, returnedTxid, txid) +} + +// TestBroadcastAlreadyInChain verifies that "transaction already in block chain" +// errors are treated as success. +func TestBroadcastAlreadyInChain(t *testing.T) { + const rawTx = "02000000000000000000" + + 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.broadcast": + // ElectrumX error for already-confirmed tx. + resp := fmt.Sprintf( + `{"id":%d,"error":{"code":2,"message":"transaction already in block chain"}}`, + reqID(req), + ) + conn.Write([]byte(resp + "\n")) // nolint + } + }) + + exp, err := electrum_explorer.NewExplorer( + serverURL, + arklib.Bitcoin, + electrum_explorer.WithTracker(false), + ) + require.NoError(t, err) + exp.Start() + defer exp.Stop() + + _, err = exp.Broadcast(rawTx) + require.NoError(t, err, "already-in-chain should not be an error") +} + +// TestGetFeeRate verifies that GetFeeRate converts BTC/kB to sat/vB correctly. +func TestGetFeeRate(t *testing.T) { + // 0.00010000 BTC/kB = 10 sat/vB (0.0001 * 1e8 / 1000) + const btcPerKB = 0.00010000 + const expectedSatPerVB = 10.0 + + 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.estimatefee": + writeResponse(conn, reqID(req), btcPerKB) + } + }) + + exp, err := electrum_explorer.NewExplorer( + serverURL, + arklib.Bitcoin, + electrum_explorer.WithTracker(false), + ) + require.NoError(t, err) + exp.Start() + defer exp.Stop() + + rate, err := exp.GetFeeRate() + require.NoError(t, err) + require.InDelta(t, expectedSatPerVB, rate, 0.001) +} + +// TestGetFeeRateUnknown verifies that a -1 response from the server returns 1 sat/vB. +func TestGetFeeRateUnknown(t *testing.T) { + 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.estimatefee": + writeResponse(conn, reqID(req), -1.0) + } + }) + + exp, err := electrum_explorer.NewExplorer( + serverURL, + arklib.Bitcoin, + electrum_explorer.WithTracker(false), + ) + require.NoError(t, err) + exp.Start() + defer exp.Stop() + + rate, err := exp.GetFeeRate() + require.NoError(t, err) + require.Equal(t, 1.0, rate) +} + +// TestGetConnectionCount verifies GetConnectionCount returns 1 when connected and 0 when not. +func TestGetConnectionCount(t *testing.T) { + serverURL := startMockServer(t, 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( + serverURL, + arklib.Bitcoin, + electrum_explorer.WithTracker(false), + ) + require.NoError(t, err) + + require.Equal(t, 0, exp.GetConnectionCount(), "not connected yet") + exp.Start() + defer exp.Stop() + require.Equal(t, 1, exp.GetConnectionCount(), "connected after Start") +} + +// TestGetSubscribedAddresses verifies address subscription tracking. +func TestGetSubscribedAddresses(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": + writeResponse(conn, reqID(req), []any{}) + case "blockchain.scripthash.subscribe": + writeResponse(conn, reqID(req), nil) // null status = no history + } + }) + + exp, err := electrum_explorer.NewExplorer( + serverURL, + arklib.Bitcoin, + electrum_explorer.WithTracker(true), + ) + require.NoError(t, err) + exp.Start() + defer exp.Stop() + + require.Empty(t, exp.GetSubscribedAddresses()) + require.False(t, exp.IsAddressSubscribed(addr)) + + err = exp.SubscribeForAddresses([]string{addr}) + require.NoError(t, err) + + require.Contains(t, exp.GetSubscribedAddresses(), addr) + require.True(t, exp.IsAddressSubscribed(addr)) +} + +// TestUnsubscribeForAddresses verifies that unsubscribed addresses are removed from tracking. +func TestUnsubscribeForAddresses(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": + 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() + defer exp.Stop() + + err = exp.SubscribeForAddresses([]string{addr}) + require.NoError(t, err) + require.True(t, exp.IsAddressSubscribed(addr)) + + err = exp.UnsubscribeForAddresses([]string{addr}) + require.NoError(t, err) + require.False(t, exp.IsAddressSubscribed(addr)) + require.Empty(t, exp.GetSubscribedAddresses()) +} + +// TestGetAddressesEvents verifies that GetAddressesEvents returns a readable channel. +func TestGetAddressesEvents(t *testing.T) { + serverURL := startMockServer(t, 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( + serverURL, + arklib.Bitcoin, + electrum_explorer.WithTracker(true), + ) + require.NoError(t, err) + exp.Start() + defer exp.Stop() + + ch := exp.GetAddressesEvents() + require.NotNil(t, ch) + + // Channel should be non-nil and not immediately closed. + select { + case _, ok := <-ch: + if !ok { + t.Fatal("channel closed immediately") + } + default: + // expected: no event yet + } +} + +// TestDuplicateSubscription verifies that subscribing to the same address twice +// does not result in duplicate entries. +func TestDuplicateSubscription(t *testing.T) { + const addr = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq" + subscribeCount := 0 + + 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": + writeResponse(conn, reqID(req), []any{}) + case "blockchain.scripthash.subscribe": + subscribeCount++ + writeResponse(conn, reqID(req), nil) + } + }) + + exp, err := electrum_explorer.NewExplorer( + serverURL, + arklib.Bitcoin, + electrum_explorer.WithTracker(true), + ) + require.NoError(t, err) + exp.Start() + defer exp.Stop() + + err = exp.SubscribeForAddresses([]string{addr}) + require.NoError(t, err) + err = exp.SubscribeForAddresses([]string{addr}) + require.NoError(t, err) + + require.Len(t, exp.GetSubscribedAddresses(), 1) + require.Equal(t, 1, subscribeCount, "server should receive subscribe only once") +} + +// TestRequestTimeout verifies that a request returns an error if the server never responds. +func TestRequestTimeout(t *testing.T) { + // Server accepts the connection but never sends responses after handshake. + handshakeDone := make(chan struct{}) + serverURL := startMockServer(t, func(conn net.Conn, req map[string]json.RawMessage) { + if reqMethod(req) == "server.version" { + writeResponse(conn, reqID(req), []string{"mock", "1.4"}) + select { + case <-handshakeDone: + default: + close(handshakeDone) + } + } + // All other requests: no response (simulate timeout). + }) + + exp, err := electrum_explorer.NewExplorer( + serverURL, + arklib.Bitcoin, + electrum_explorer.WithTracker(false), + ) + require.NoError(t, err) + exp.Start() + defer exp.Stop() + + // Wait for handshake. + select { + case <-handshakeDone: + case <-time.After(5 * time.Second): + t.Fatal("handshake timed out") + } + + // GetTxHex should time out since the server won't respond. + _, err = exp.GetTxHex("sometxid") + require.Error(t, err) + require.Contains(t, err.Error(), "timed out") +} + +// TestCleanEOFTriggersReconnect verifies that a clean TCP close (FIN, EOF) from the +// server causes the client to reconnect, not silently stop. This covers the case +// where an ElectrumX server restarts gracefully. +func TestCleanEOFTriggersReconnect(t *testing.T) { + var connectCount atomic.Int32 + reconnectCh := make(chan struct{}, 1) + + serverURL := startMockServer(t, func(conn net.Conn, req map[string]json.RawMessage) { + switch reqMethod(req) { + case "server.version": + n := connectCount.Add(1) + writeResponse(conn, reqID(req), []string{"mock", "1.4"}) + if n == 1 { + // Close the connection cleanly after the first handshake. + conn.Close() // nolint + } else { + select { + case reconnectCh <- struct{}{}: + default: + } + } + } + }) + + exp, err := electrum_explorer.NewExplorer( + serverURL, + arklib.Bitcoin, + electrum_explorer.WithTracker(false), + ) + require.NoError(t, err) + exp.Start() + defer exp.Stop() + + select { + case <-reconnectCh: + // Client reconnected after the server's clean close. + case <-time.After(10 * time.Second): + t.Fatal("client did not reconnect after clean server close (EOF)") + } +} + +// TestKeepaliveGoroutineDoesNotLeak verifies that keepAlive goroutines do not +// accumulate across reconnects. Without the cycle-context fix, every reconnect +// spawned a new keepAlive without stopping the previous one; after N reconnects +// there would be N+1 keepAlive goroutines all pinging the same connection. +// +// Strategy: record goroutine count after the first stable connection, force N +// reconnects by having the server close connections, wait for the final stable +// connection, then record again. With the fix the delta is ≈0; with the bug it +// grows by N (one leaked keepAlive per reconnect). +func TestKeepaliveGoroutineDoesNotLeak(t *testing.T) { + const reconnects = 3 + + var connectCount int32 + // connReadyCh carries connections so the test can close them on demand. + connReadyCh := make(chan net.Conn, reconnects+1) + lastConnectedCh := make(chan struct{}, 1) + + 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"}) + n := int(atomic.AddInt32(&connectCount, 1)) + if n <= reconnects { + // Hand the connection to the test goroutine so it can close it. + connReadyCh <- conn + } else { + // Final stable connection — signal ready. + select { + case lastConnectedCh <- struct{}{}: + default: + } + } + case "server.ping": + writeResponse(conn, reqID(req), true) + } + }) + + exp, err := electrum_explorer.NewExplorer( + serverURL, + arklib.Bitcoin, + electrum_explorer.WithTracker(false), + ) + require.NoError(t, err) + exp.Start() + defer exp.Stop() + + // Wait for the first connection and snapshot goroutine count. + var firstConn net.Conn + select { + case firstConn = <-connReadyCh: + case <-time.After(5 * time.Second): + t.Fatal("initial connect timed out") + } + time.Sleep(50 * time.Millisecond) // let listen+keepAlive goroutines settle + afterFirst := runtime.NumGoroutine() + + // Close each connection in turn to trigger successive reconnects. + firstConn.Close() // nolint + for i := 1; i < reconnects; i++ { + var c net.Conn + select { + case c = <-connReadyCh: + case <-time.After(5 * time.Second): + t.Fatalf("reconnect %d timed out", i) + } + c.Close() // nolint + } + + // Wait for the final stable connection. + select { + case <-lastConnectedCh: + case <-time.After(10 * time.Second): + t.Fatalf("client did not complete all %d reconnects", reconnects) + } + time.Sleep(100 * time.Millisecond) // let old keepAlive goroutines exit + runtime.Gosched() + + afterAll := runtime.NumGoroutine() + + // With the fix: each reconnect cancels the old keepAlive before starting the + // new one, so the net goroutine delta should be ≈0. With the bug: N leaked + // keepAlives remain, making the delta ≈N. Slack of 2 for Go runtime fluctuation. + require.LessOrEqual(t, afterAll-afterFirst, 2, + "goroutine count grew by %d after %d reconnects — likely keepAlive goroutine leak", + afterAll-afterFirst, reconnects) +} + +// TestConcurrentRequestsDoNotInterleave verifies that concurrent JSON-RPC requests +// are serialised on the wire so that frames are never interleaved. The server +// echoes each request's id back; if bytes interleaved, the JSON parser would +// return errors or mismatched ids, which would cause the test to fail or hang. +func TestConcurrentRequestsDoNotInterleave(t *testing.T) { + const workers = 20 + + 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), "deadbeef") + } + }) + + exp, err := electrum_explorer.NewExplorer( + serverURL, + arklib.Bitcoin, + electrum_explorer.WithTracker(false), + ) + require.NoError(t, err) + exp.Start() + defer exp.Stop() + + var wg sync.WaitGroup + errs := make(chan error, workers) + for i := 0; i < workers; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + txHex, err := exp.GetTxHex(fmt.Sprintf("txid%d", i)) + if err != nil { + errs <- fmt.Errorf("worker %d: %w", i, err) + return + } + if txHex != "deadbeef" { + errs <- fmt.Errorf("worker %d: unexpected txHex %q", i, txHex) + } + }(i) + } + wg.Wait() + close(errs) + + for err := range errs { + require.NoError(t, err) + } +} + +// TestBroadcastNoTxs verifies that Broadcast with no arguments returns an error. +func TestBroadcastNoTxs(t *testing.T) { + exp, err := electrum_explorer.NewExplorer("tcp://127.0.0.1:1", arklib.Bitcoin) + require.NoError(t, err) + + _, err = exp.Broadcast() + require.ErrorContains(t, err, "no txs to broadcast") +} + +// fakeBlockHeader is an 80-byte Bitcoin block header (160 hex chars). +// Timestamp is at bytes 68-71 LE: 0x78563412 = big-endian 0x12345678 = 305419896. +const fakeBlockHeader = "0100000000000000000000000000000000000000000000000000000000000000" + + "0000000000000000000000000000000000000000000000000000000000000000" + + "00000000785634120000000000000000" + +const fakeBlockTime = int64(305419896) + +// P2WPKH script for bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq (mainnet). +const addrScript = "0014e8df018c7e326cc253faac7e46cdc51e68542c42" + +// minimalTxHex: 1 null-outpoint input, 1 OP_1 output (1000 sat). Used when +// only a syntactically valid TX is needed (e.g. GetTxOutspends error paths). +const minimalTxHex = "020000000100000000000000000000000000000000000000000000000000000000000000000000000000ffffffff01e803000000000000015100000000" + +// addrTxHex: 1 null-outpoint input, 1 P2WPKH output (100000 sat, addrScript). +// When decoded, Vout[0].Address == "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq". +const addrTxHex = "020000000100000000000000000000000000000000000000000000000000000000000000000000000000ffffffff01a086010000000000160014e8df018c7e326cc253faac7e46cdc51e68542c4200000000" + +// precisionTxHex: same structure as addrTxHex but with 123456789 sat output. +const precisionTxHex = "020000000100000000000000000000000000000000000000000000000000000000000000000000000000ffffffff0115cd5b0700000000160014e8df018c7e326cc253faac7e46cdc51e68542c4200000000" + +// twoOutputTxHex: 1 null-outpoint input, 2 outputs (OP_1 @ 1000sat, OP_2 @ 2000sat). +const twoOutputTxHex = "020000000100000000000000000000000000000000000000000000000000000000000000000000000000ffffffff02e8030000000000000151d007000000000000015200000000" + +// Proper 64-hex-char txids (all same nibble so byte-reversing is a no-op, making +// Hash.String() return the same string as the txid literal — required for +// PreviousOutPoint.Hash.String() comparisons inside GetTxOutspends). +const ( + parentTxid = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + existTxid = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + multivoutTxid = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + prevTxid = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" +) + +// spendingTxHex builds a raw TX that spends output vout of hash (64-hex-char txid). +// The TX has no outputs. hash must be a 64-char all-same-nibble hex string so that +// big-endian and little-endian byte orderings are identical. +func spendingTxHex(hash string, vout uint32) string { + // vout as 4-byte little-endian hex + indexHex := fmt.Sprintf("%02x%02x%02x%02x", + vout&0xff, (vout>>8)&0xff, (vout>>16)&0xff, (vout>>24)&0xff) + return "02000000" + "01" + + hash + indexHex + "00" + "ffffffff" + + "00" + "00000000" +} + +// inputTxHex builds a raw TX with one P2WPKH output (value in satoshis) and +// one input spending hash:vout. Used for TestGetTxsInputFields. +func inputTxHex(hash string, vout uint32, valueSat int64) string { + indexHex := fmt.Sprintf("%02x%02x%02x%02x", + vout&0xff, (vout>>8)&0xff, (vout>>16)&0xff, (vout>>24)&0xff) + valueHex := fmt.Sprintf("%02x%02x%02x%02x%02x%02x%02x%02x", + valueSat&0xff, (valueSat>>8)&0xff, (valueSat>>16)&0xff, (valueSat>>24)&0xff, + (valueSat>>32)&0xff, (valueSat>>40)&0xff, (valueSat>>48)&0xff, (valueSat>>56)&0xff) + return "02000000" + "01" + + hash + indexHex + "00" + "ffffffff" + + "01" + valueHex + "16" + addrScript + "00000000" +} + +// reqStringParam extracts the string at index idx from the params array of a request. +func reqStringParam(req map[string]json.RawMessage, idx int) string { + var params []json.RawMessage + json.Unmarshal(req["params"], ¶ms) // nolint + if idx >= len(params) { + return "" + } + var s string + json.Unmarshal(params[idx], &s) // nolint + return s +} + +// TestGetTxBlockTime verifies that GetTxBlockTime fetches the block header and +// parses the correct timestamp from the 80-byte header. +func TestGetTxBlockTime(t *testing.T) { + const txid = "abc" + + 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), addrTxHex) + case "blockchain.scripthash.get_history": + writeResponse(conn, reqID(req), []map[string]any{ + {"tx_hash": txid, "height": 100}, + }) + case "blockchain.block.header": + writeResponse(conn, reqID(req), fakeBlockHeader) + } + }) + + exp, err := electrum_explorer.NewExplorer( + serverURL, + arklib.Bitcoin, + electrum_explorer.WithTracker(false), + ) + require.NoError(t, err) + exp.Start() + defer exp.Stop() + + confirmed, blocktime, err := exp.GetTxBlockTime(txid) + require.NoError(t, err) + require.True(t, confirmed) + require.Equal(t, fakeBlockTime, blocktime) +} + +// TestGetTxs verifies that GetTxs returns correctly assembled transaction history, +// including the Address field on each output (required by addressTxHistoryToUtxos). +func TestGetTxs(t *testing.T) { + const addr = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq" + const txid = "deadbeef" + + 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.get_history": + writeResponse(conn, reqID(req), []map[string]any{ + {"tx_hash": txid, "height": 100}, + }) + case "blockchain.transaction.get": + writeResponse(conn, reqID(req), addrTxHex) + case "blockchain.block.header": + writeResponse(conn, reqID(req), fakeBlockHeader) + } + }) + + exp, err := electrum_explorer.NewExplorer( + serverURL, + arklib.Bitcoin, + electrum_explorer.WithTracker(false), + ) + require.NoError(t, err) + exp.Start() + defer exp.Stop() + + txs, err := exp.GetTxs(addr) + require.NoError(t, err) + require.Len(t, txs, 1) + require.Equal(t, txid, txs[0].Txid) + require.True(t, txs[0].Status.Confirmed) + require.Equal(t, fakeBlockTime, txs[0].Status.BlockTime) + require.Len(t, txs[0].Vout, 1) + // Address must be populated so that addressTxHistoryToUtxos can match outputs. + require.Equal(t, addr, txs[0].Vout[0].Address) +} + +// TestGetUtxos verifies that GetUtxos returns confirmed UTXOs with correct blocktime. +func TestGetUtxos(t *testing.T) { + const addr = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq" + const txid = "utxotx" + + 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": + writeResponse(conn, reqID(req), []map[string]any{ + {"tx_hash": txid, "tx_pos": 0, "value": 5000, "height": 100}, + }) + case "blockchain.block.header": + writeResponse(conn, reqID(req), fakeBlockHeader) + } + }) + + exp, err := electrum_explorer.NewExplorer( + serverURL, + arklib.Bitcoin, + electrum_explorer.WithTracker(false), + ) + require.NoError(t, err) + exp.Start() + defer exp.Stop() + + utxos, err := exp.GetUtxos([]string{addr}) + require.NoError(t, err) + require.Len(t, utxos, 1) + require.Equal(t, txid, utxos[0].Txid) + require.Equal(t, uint32(0), utxos[0].Vout) + require.Equal(t, uint64(5000), utxos[0].Amount) + require.NotEmpty(t, utxos[0].Script) + require.True(t, utxos[0].Status.Confirmed) + require.Equal(t, fakeBlockTime, utxos[0].Status.BlockTime) +} + +// TestGetTxOutspends verifies that GetTxOutspends identifies the spending transaction +// by scanning the scripthash history of each output. +func TestGetTxOutspends(t *testing.T) { + const spenderTxid = "spender_tx" + + 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": + switch reqStringParam(req, 0) { + case parentTxid: + writeResponse(conn, reqID(req), minimalTxHex) + case spenderTxid: + writeResponse(conn, reqID(req), spendingTxHex(parentTxid, 0)) + } + case "blockchain.scripthash.get_history": + writeResponse(conn, reqID(req), []map[string]any{ + {"tx_hash": parentTxid, "height": 100}, + {"tx_hash": spenderTxid, "height": 101}, + }) + } + }) + + exp, err := electrum_explorer.NewExplorer( + serverURL, + arklib.Bitcoin, + electrum_explorer.WithTracker(false), + ) + require.NoError(t, err) + exp.Start() + defer exp.Stop() + + outspends, err := exp.GetTxOutspends(parentTxid) + require.NoError(t, err) + require.Len(t, outspends, 1) + require.True(t, outspends[0].Spent) + require.Equal(t, spenderTxid, outspends[0].SpentBy) +} + +// TestPushNotificationTriggersEvent verifies that an ElectrumX scripthash push +// notification causes an immediate address poll and fires an OnchainAddressEvent. +func TestPushNotificationTriggersEvent(t *testing.T) { + const addr = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq" + + connCh := make(chan net.Conn, 1) + scripthashCh := make(chan string, 1) + listunspentCount := 0 + + serverURL := startMockServer(t, func(conn net.Conn, req map[string]json.RawMessage) { + select { + case connCh <- conn: + default: + } + switch reqMethod(req) { + case "server.version": + writeResponse(conn, reqID(req), []string{"mock", "1.4"}) + case "blockchain.scripthash.listunspent": + listunspentCount++ + if listunspentCount == 1 { + writeResponse(conn, reqID(req), []any{}) + } else { + writeResponse(conn, reqID(req), []map[string]any{ + {"tx_hash": "newtx", "tx_pos": 0, "value": 1000, "height": 0}, + }) + } + case "blockchain.scripthash.subscribe": + select { + case scripthashCh <- reqStringParam(req, 0): + default: + } + writeResponse(conn, reqID(req), nil) + } + }) + + exp, err := electrum_explorer.NewExplorer( + serverURL, + arklib.Bitcoin, + electrum_explorer.WithTracker(true), + ) + require.NoError(t, err) + exp.Start() + defer exp.Stop() + + eventCh := exp.GetAddressesEvents() + + err = exp.SubscribeForAddresses([]string{addr}) + require.NoError(t, err) + + var conn net.Conn + select { + case conn = <-connCh: + case <-time.After(2 * time.Second): + t.Fatal("did not receive connection from mock") + } + + var scripthash string + select { + case scripthash = <-scripthashCh: + case <-time.After(2 * time.Second): + t.Fatal("did not receive scripthash from mock") + } + + // Push an ElectrumX notification — the client's listen() goroutine will read + // it and forward the new status to the per-address notifCh, triggering a poll. + notif := fmt.Sprintf( + `{"method":"blockchain.scripthash.subscribe","params":[%q,"newstatus"]}`, + scripthash, + ) + conn.Write([]byte(notif + "\n")) // nolint + + select { + case event := <-eventCh: + require.NoError(t, event.Error) + require.Len(t, event.NewUtxos, 1) + require.Equal(t, "newtx", event.NewUtxos[0].Txid) + case <-time.After(2 * time.Second): + t.Fatal("expected OnchainAddressEvent from push notification, got none") + } +} + +// TestNewConfirmedUTXOHasCreatedAt verifies that when a push notification triggers +// a poll and reveals a new confirmed UTXO, the event's NewUtxos entry has CreatedAt +// populated from the block header timestamp (not from the block height). +func TestNewConfirmedUTXOHasCreatedAt(t *testing.T) { + const addr = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq" + + connCh := make(chan net.Conn, 1) + scripthashCh := make(chan string, 1) + listunspentCount := 0 + + serverURL := startMockServer(t, func(conn net.Conn, req map[string]json.RawMessage) { + select { + case connCh <- conn: + default: + } + switch reqMethod(req) { + case "server.version": + writeResponse(conn, reqID(req), []string{"mock", "1.4"}) + case "blockchain.scripthash.listunspent": + listunspentCount++ + if listunspentCount == 1 { + writeResponse(conn, reqID(req), []any{}) + } else { + // New UTXO that is already confirmed at height 100. + writeResponse(conn, reqID(req), []map[string]any{ + {"tx_hash": "newtx", "tx_pos": 0, "value": 1000, "height": 100}, + }) + } + case "blockchain.scripthash.subscribe": + select { + case scripthashCh <- reqStringParam(req, 0): + default: + } + writeResponse(conn, reqID(req), nil) + case "blockchain.block.header": + writeResponse(conn, reqID(req), fakeBlockHeader) + } + }) + + exp, err := electrum_explorer.NewExplorer( + serverURL, + arklib.Bitcoin, + electrum_explorer.WithTracker(true), + ) + require.NoError(t, err) + exp.Start() + defer exp.Stop() + + eventCh := exp.GetAddressesEvents() + err = exp.SubscribeForAddresses([]string{addr}) + require.NoError(t, err) + + var conn net.Conn + select { + case conn = <-connCh: + case <-time.After(2 * time.Second): + t.Fatal("no connection") + } + var scripthash string + select { + case scripthash = <-scripthashCh: + case <-time.After(2 * time.Second): + t.Fatal("no scripthash") + } + + notif := fmt.Sprintf( + `{"method":"blockchain.scripthash.subscribe","params":[%q,"newstatus"]}`, + scripthash, + ) + conn.Write([]byte(notif + "\n")) // nolint + + select { + case event := <-eventCh: + require.NoError(t, event.Error) + require.Len(t, event.NewUtxos, 1) + // CreatedAt must come from the block header timestamp, not from block height. + require.False(t, event.NewUtxos[0].CreatedAt.IsZero(), "CreatedAt should be set") + require.Equal(t, time.Unix(fakeBlockTime, 0), event.NewUtxos[0].CreatedAt) + case <-time.After(2 * time.Second): + t.Fatal("expected event with CreatedAt, got none") + } +} + +// TestConfirmedUTXOEventHasCreatedAt verifies that when an unconfirmed UTXO confirms, +// the ConfirmedUtxos entry has CreatedAt populated from the block header. +func TestConfirmedUTXOEventHasCreatedAt(t *testing.T) { + const addr = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq" + + connCh := make(chan net.Conn, 1) + scripthashCh := make(chan string, 1) + listunspentCount := 0 + + serverURL := startMockServer(t, func(conn net.Conn, req map[string]json.RawMessage) { + select { + case connCh <- conn: + default: + } + switch reqMethod(req) { + case "server.version": + writeResponse(conn, reqID(req), []string{"mock", "1.4"}) + case "blockchain.scripthash.listunspent": + listunspentCount++ + if listunspentCount == 1 { + // Initial state: one unconfirmed UTXO. + writeResponse(conn, reqID(req), []map[string]any{ + {"tx_hash": "pendtx", "tx_pos": 0, "value": 2000, "height": 0}, + }) + } else { + // Same UTXO is now confirmed at height 100. + writeResponse(conn, reqID(req), []map[string]any{ + {"tx_hash": "pendtx", "tx_pos": 0, "value": 2000, "height": 100}, + }) + } + case "blockchain.scripthash.subscribe": + select { + case scripthashCh <- reqStringParam(req, 0): + default: + } + writeResponse(conn, reqID(req), nil) + case "blockchain.block.header": + writeResponse(conn, reqID(req), fakeBlockHeader) + } + }) + + exp, err := electrum_explorer.NewExplorer( + serverURL, + arklib.Bitcoin, + electrum_explorer.WithTracker(true), + ) + require.NoError(t, err) + exp.Start() + defer exp.Stop() + + eventCh := exp.GetAddressesEvents() + err = exp.SubscribeForAddresses([]string{addr}) + require.NoError(t, err) + + var conn net.Conn + select { + case conn = <-connCh: + case <-time.After(2 * time.Second): + t.Fatal("no connection") + } + var scripthash string + select { + case scripthash = <-scripthashCh: + case <-time.After(2 * time.Second): + t.Fatal("no scripthash") + } + + notif := fmt.Sprintf( + `{"method":"blockchain.scripthash.subscribe","params":[%q,"newstatus"]}`, + scripthash, + ) + conn.Write([]byte(notif + "\n")) // nolint + + // The initial poll fires a NewUtxos event for the pre-existing pending UTXO. + // Keep reading until we see the ConfirmedUtxos event from the push notification. + deadline := time.After(3 * time.Second) + for { + select { + case event := <-eventCh: + require.NoError(t, event.Error) + if len(event.ConfirmedUtxos) == 0 { + continue + } + require.Len(t, event.ConfirmedUtxos, 1, "expected a confirmed UTXO event") + require.Equal(t, "pendtx", event.ConfirmedUtxos[0].Txid) + require.Equal(t, time.Unix(fakeBlockTime, 0), event.ConfirmedUtxos[0].CreatedAt) + return + case <-deadline: + t.Fatal("expected confirmed event with CreatedAt, got none") + } + } +} + +// TestSpentUTXOEventHasSpentBy verifies that when a UTXO disappears from the UTXO +// set, the SpentUtxos entry has SpentBy populated via GetTxOutspends. +func TestSpentUTXOEventHasSpentBy(t *testing.T) { + const addr = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq" + const spenderTxid = "spender_tx" + + connCh := make(chan net.Conn, 1) + scripthashCh := make(chan string, 1) + listunspentCount := 0 + + serverURL := startMockServer(t, func(conn net.Conn, req map[string]json.RawMessage) { + select { + case connCh <- conn: + default: + } + switch reqMethod(req) { + case "server.version": + writeResponse(conn, reqID(req), []string{"mock", "1.4"}) + case "blockchain.scripthash.listunspent": + listunspentCount++ + if listunspentCount == 1 { + writeResponse(conn, reqID(req), []map[string]any{ + {"tx_hash": existTxid, "tx_pos": 0, "value": 3000, "height": 100}, + }) + } else { + writeResponse(conn, reqID(req), []any{}) + } + case "blockchain.scripthash.subscribe": + select { + case scripthashCh <- reqStringParam(req, 0): + default: + } + writeResponse(conn, reqID(req), nil) + case "blockchain.transaction.get": + switch reqStringParam(req, 0) { + case existTxid: + writeResponse(conn, reqID(req), minimalTxHex) + case spenderTxid: + writeResponse(conn, reqID(req), spendingTxHex(existTxid, 0)) + } + case "blockchain.block.header": + writeResponse(conn, reqID(req), fakeBlockHeader) + case "blockchain.scripthash.get_history": + writeResponse(conn, reqID(req), []map[string]any{ + {"tx_hash": existTxid, "height": 100}, + {"tx_hash": spenderTxid, "height": 101}, + }) + } + }) + + exp, err := electrum_explorer.NewExplorer( + serverURL, + arklib.Bitcoin, + electrum_explorer.WithTracker(true), + ) + require.NoError(t, err) + exp.Start() + defer exp.Stop() + + eventCh := exp.GetAddressesEvents() + err = exp.SubscribeForAddresses([]string{addr}) + require.NoError(t, err) + + var conn net.Conn + select { + case conn = <-connCh: + case <-time.After(2 * time.Second): + t.Fatal("no connection") + } + var scripthash string + select { + case scripthash = <-scripthashCh: + case <-time.After(2 * time.Second): + t.Fatal("no scripthash") + } + + notif := fmt.Sprintf( + `{"method":"blockchain.scripthash.subscribe","params":[%q,"newstatus"]}`, + scripthash, + ) + conn.Write([]byte(notif + "\n")) // nolint + + // The initial poll fires a NewUtxos event for the pre-existing confirmed UTXO. + // Keep reading until we see the SpentUtxos event from the push notification. + deadline := time.After(3 * time.Second) + for { + select { + case event := <-eventCh: + require.NoError(t, event.Error) + if len(event.SpentUtxos) == 0 { + continue + } + require.Len(t, event.SpentUtxos, 1, "expected a spent UTXO event") + require.Equal(t, existTxid, event.SpentUtxos[0].Txid) + require.Equal(t, spenderTxid, event.SpentUtxos[0].SpentBy, + "SpentBy must be populated via GetTxOutspends") + return + case <-deadline: + t.Fatal("expected spent event with SpentBy, got none") + } + } +} + +// TestGetTxsAmountPrecision verifies that BTC float values are rounded to the nearest +// satoshi rather than truncated. For example, 1.23456789 BTC * 1e8 in IEEE-754 float64 +// is 123456788.99..., which truncation would convert to 123456788 instead of 123456789. +func TestGetTxsAmountPrecision(t *testing.T) { + const addr = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq" + const txid = "precisiontx" + + 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.get_history": + writeResponse(conn, reqID(req), []map[string]any{ + {"tx_hash": txid, "height": 0}, + }) + case "blockchain.transaction.get": + writeResponse(conn, reqID(req), precisionTxHex) + } + }) + + exp, err := electrum_explorer.NewExplorer( + serverURL, + arklib.Bitcoin, + electrum_explorer.WithTracker(false), + ) + require.NoError(t, err) + exp.Start() + defer exp.Stop() + + txs, err := exp.GetTxs(addr) + require.NoError(t, err) + require.Len(t, txs, 1) + require.Equal(t, uint64(123456789), txs[0].Vout[0].Amount, + "amount must be rounded, not truncated") +} + +// TestGetTxsUnconfirmed verifies that a transaction with height=0 in the history +// is returned with Status.Confirmed=false and Status.BlockTime=0 (not -1). +// BlockTime=0 is the sentinel value used by callers to detect unconfirmed transactions. +func TestGetTxsUnconfirmed(t *testing.T) { + const addr = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq" + const txid = "mempooltx" + + 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.get_history": + writeResponse(conn, reqID(req), []map[string]any{ + {"tx_hash": txid, "height": 0}, + }) + case "blockchain.transaction.get": + writeResponse(conn, reqID(req), addrTxHex) + } + }) + + exp, err := electrum_explorer.NewExplorer( + serverURL, + arklib.Bitcoin, + electrum_explorer.WithTracker(false), + ) + require.NoError(t, err) + exp.Start() + defer exp.Stop() + + txs, err := exp.GetTxs(addr) + require.NoError(t, err) + require.Len(t, txs, 1) + require.False(t, txs[0].Status.Confirmed, "unconfirmed tx must have Confirmed=false") + require.Equal(t, int64(0), txs[0].Status.BlockTime, + "unconfirmed tx must have BlockTime=0, not -1") +} + +// TestGetTxsInputFields verifies that input prevout fields (Txid, Vout) are populated +// from the decoded raw transaction. Script/Address/Amount are not available because +// electrs-esplora does not support verbose transactions. +func TestGetTxsInputFields(t *testing.T) { + const addr = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq" + const spendingTxid = "spendingtx" + const prevVout = uint32(2) + + // inputTxHex encodes prevTxid ("eeee...ee") as the prevout hash. Since all bytes + // are identical, byte-reversal is a no-op, so Hash.String() == prevTxid. + spendingHex := inputTxHex(prevTxid, prevVout, 50000) + + 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.get_history": + writeResponse(conn, reqID(req), []map[string]any{ + {"tx_hash": spendingTxid, "height": 0}, + }) + case "blockchain.transaction.get": + writeResponse(conn, reqID(req), spendingHex) + } + }) + + exp, err := electrum_explorer.NewExplorer( + serverURL, + arklib.Bitcoin, + electrum_explorer.WithTracker(false), + ) + require.NoError(t, err) + exp.Start() + defer exp.Stop() + + txs, err := exp.GetTxs(addr) + require.NoError(t, err) + require.Len(t, txs, 1) + require.Len(t, txs[0].Vin, 1) + + in := txs[0].Vin[0] + require.Equal(t, prevTxid, in.Txid, "Input.Txid must match prevout tx") + require.Equal(t, prevVout, in.Vout, "Input.Vout must match prevout index") +} + +// TestGetTxBlockTimeUnconfirmed verifies that GetTxBlockTime returns (false, 0, nil) +// for an unconfirmed transaction. 0 is the same sentinel used by GetTxs and GetUtxos. +func TestGetTxBlockTimeUnconfirmed(t *testing.T) { + const txid = "mempooltx" + + 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": + writeResponse(conn, reqID(req), []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, blocktime, err := exp.GetTxBlockTime(txid) + require.NoError(t, err) + require.False(t, confirmed) + require.Equal(t, int64(0), blocktime, + "unconfirmed tx must return blocktime=0, consistent with GetTxs and GetUtxos") +} + +// TestGetTxBlockTimeUsesVerboseBlocktime verifies that GetTxBlockTime always calls +// blockchain.block.header to get the block timestamp for confirmed transactions. +// (The previous optimization that skipped the header fetch when verbose TX had blocktime +// no longer applies because electrs-esplora does not support verbose transactions.) +func TestGetTxBlockTimeUsesVerboseBlocktime(t *testing.T) { + const txid = "confirmedtx" + blockHeaderCalled := false + + 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), addrTxHex) + case "blockchain.scripthash.get_history": + writeResponse(conn, reqID(req), []map[string]any{ + {"tx_hash": txid, "height": 100}, + }) + case "blockchain.block.header": + blockHeaderCalled = true + writeResponse(conn, reqID(req), fakeBlockHeader) + } + }) + + exp, err := electrum_explorer.NewExplorer( + serverURL, + arklib.Bitcoin, + electrum_explorer.WithTracker(false), + ) + require.NoError(t, err) + exp.Start() + defer exp.Stop() + + confirmed, blocktime, err := exp.GetTxBlockTime(txid) + require.NoError(t, err) + require.True(t, confirmed) + require.Equal(t, fakeBlockTime, blocktime) + require.True(t, blockHeaderCalled, + "blockchain.block.header must be called for confirmed tx") +} + +// TestGetTxOutspendsUnspent verifies that an output with no spenders returns +// SpentStatus{Spent: false, SpentBy: ""}, matching mempool explorer behavior. +func TestGetTxOutspendsUnspent(t *testing.T) { + const txid = "mytx" + + 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": + // Only the creating tx — no spenders. + writeResponse(conn, reqID(req), []map[string]any{ + {"tx_hash": txid, "height": 100}, + }) + } + }) + + exp, err := electrum_explorer.NewExplorer( + serverURL, + arklib.Bitcoin, + electrum_explorer.WithTracker(false), + ) + require.NoError(t, err) + exp.Start() + defer exp.Stop() + + outspends, err := exp.GetTxOutspends(txid) + require.NoError(t, err) + require.Len(t, outspends, 1) + require.False(t, outspends[0].Spent, "unspent output must have Spent=false") + require.Empty(t, outspends[0].SpentBy, "unspent output must have SpentBy empty") +} + +// TestGetTxOutspendsMultipleOutputs verifies per-output tracking when a tx has multiple +// outputs and only one is spent — the spent vout index must match the spender's input. +func TestGetTxOutspendsMultipleOutputs(t *testing.T) { + const spenderTxid = "spender" + + // GetTxOutspends processes outputs in index order (0, 1), so the first + // blockchain.scripthash.get_history call is for vout 0 and the second for vout 1. + historyCallCount := 0 + + 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": + switch reqStringParam(req, 0) { + case multivoutTxid: + writeResponse(conn, reqID(req), twoOutputTxHex) + case spenderTxid: + // spendingTxHex encodes multivoutTxid as the prevout hash; since all + // bytes are identical ("dd"), Hash.String() == multivoutTxid. + writeResponse(conn, reqID(req), spendingTxHex(multivoutTxid, 0)) + } + case "blockchain.scripthash.get_history": + historyCallCount++ + if historyCallCount == 1 { + // First call is for vout 0 (OP_1 script) — it has a spender. + writeResponse(conn, reqID(req), []map[string]any{ + {"tx_hash": multivoutTxid, "height": 100}, + {"tx_hash": spenderTxid, "height": 101}, + }) + } else { + // Second call is for vout 1 (OP_2 script) — no spenders. + writeResponse(conn, reqID(req), []map[string]any{ + {"tx_hash": multivoutTxid, "height": 100}, + }) + } + } + }) + + exp, err := electrum_explorer.NewExplorer( + serverURL, + arklib.Bitcoin, + electrum_explorer.WithTracker(false), + ) + require.NoError(t, err) + exp.Start() + defer exp.Stop() + + outspends, err := exp.GetTxOutspends(multivoutTxid) + require.NoError(t, err) + require.Len(t, outspends, 2) + require.True(t, outspends[0].Spent, "vout 0 must be spent") + require.Equal(t, spenderTxid, outspends[0].SpentBy) + require.False(t, outspends[1].Spent, "vout 1 must be unspent") + require.Empty(t, outspends[1].SpentBy) +} + +// TestGetUtxosUnconfirmed verifies that a UTXO with height=0 is returned with +// Status.Confirmed=false and Status.BlockTime=0, matching mempool explorer behavior. +func TestGetUtxosUnconfirmed(t *testing.T) { + const addr = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq" + const txid = "mempoolutxo" + + 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": + writeResponse(conn, reqID(req), []map[string]any{ + {"tx_hash": txid, "tx_pos": 0, "value": 7000, "height": 0}, + }) + } + }) + + exp, err := electrum_explorer.NewExplorer( + serverURL, + arklib.Bitcoin, + electrum_explorer.WithTracker(false), + ) + require.NoError(t, err) + exp.Start() + defer exp.Stop() + + utxos, err := exp.GetUtxos([]string{addr}) + require.NoError(t, err) + require.Len(t, utxos, 1) + require.False(t, utxos[0].Status.Confirmed, "unconfirmed UTXO must have Confirmed=false") + require.Equal(t, int64(0), utxos[0].Status.BlockTime, + "unconfirmed UTXO must have BlockTime=0 so callers use time.Now() for delay calculation") +} + +// TestGetRedeemedVtxosBalance verifies the spendable/locked split. +// An old confirmed UTXO (fakeBlockTime = 1979) with a 1000-second delay is spendable now. +// An unconfirmed UTXO with a 1000-second delay is locked (availableAt = now+1000s). +func TestGetRedeemedVtxosBalance(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": + writeResponse(conn, reqID(req), []map[string]any{ + // Confirmed at a very old block (fakeBlockTime) — always spendable. + {"tx_hash": "oldtx", "tx_pos": 0, "value": 10000, "height": 100}, + // Unconfirmed — locked for the full delay duration. + {"tx_hash": "newpending", "tx_pos": 0, "value": 5000, "height": 0}, + }) + case "blockchain.block.header": + writeResponse(conn, reqID(req), fakeBlockHeader) + } + }) + + exp, err := electrum_explorer.NewExplorer( + serverURL, + arklib.Bitcoin, + electrum_explorer.WithTracker(false), + ) + require.NoError(t, err) + exp.Start() + defer exp.Stop() + + delay := arklib.RelativeLocktime{Type: arklib.LocktimeTypeSecond, Value: 1000} + + spendable, locked, err := exp.GetRedeemedVtxosBalance(addr, delay) + require.NoError(t, err) + require.Equal(t, uint64(10000), spendable, + "old confirmed UTXO must be spendable (delay already elapsed)") + require.Len(t, locked, 1, "unconfirmed UTXO must be locked for the delay duration") + for _, amt := range locked { + require.Equal(t, uint64(5000), amt) + } +} + +// TestBroadcastMultipleReturnsFirstTxid verifies that when multiple transactions are +// broadcast, the returned txid is that of the first transaction. +func TestBroadcastMultipleReturnsFirstTxid(t *testing.T) { + // Two minimal valid raw transactions (version 2 and version 1, each with no inputs/outputs). + const tx1 = "02000000000000000000" + const tx2 = "01000000000000000000" + const firstTxid = "aaaa" + + callCount := 0 + 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.broadcast": + callCount++ + if callCount == 1 { + writeResponse(conn, reqID(req), firstTxid) + } else { + writeResponse(conn, reqID(req), "bbbb") + } + } + }) + + exp, err := electrum_explorer.NewExplorer( + serverURL, + arklib.Bitcoin, + electrum_explorer.WithTracker(false), + ) + require.NoError(t, err) + exp.Start() + defer exp.Stop() + + txid, err := exp.Broadcast(tx1, tx2) + require.NoError(t, err) + require.Equal(t, firstTxid, txid, "must return txid of first broadcast") + require.Equal(t, 2, callCount, "must broadcast both transactions") +} + +// TestGetTxHexServedFromBroadcastCache verifies that after a successful Broadcast, +// a subsequent GetTxHex call for the same txid is served from cache without an RPC call. +// The server returns null for the broadcast response so that Broadcast falls back to the +// locally computed txid — the same key under which the tx hex was cached. +func TestGetTxHexServedFromBroadcastCache(t *testing.T) { + const rawTx = "02000000000000000000" + getTxHexCalled := false + + 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.broadcast": + // Return null so Broadcast falls back to the locally parsed txid, + // which matches the cache key set during broadcast. + writeResponse(conn, reqID(req), nil) + case "blockchain.transaction.get": + getTxHexCalled = true + writeResponse(conn, reqID(req), rawTx) + } + }) + + exp, err := electrum_explorer.NewExplorer( + serverURL, + arklib.Bitcoin, + electrum_explorer.WithTracker(false), + ) + require.NoError(t, err) + exp.Start() + defer exp.Stop() + + txid, err := exp.Broadcast(rawTx) + require.NoError(t, err) + require.NotEmpty(t, txid) + + _, err = exp.GetTxHex(txid) + require.NoError(t, err) + require.False(t, getTxHexCalled, "GetTxHex must use cache after Broadcast, no RPC needed") +} diff --git a/explorer/electrum/opts.go b/explorer/electrum/opts.go new file mode 100644 index 00000000..c165a18a --- /dev/null +++ b/explorer/electrum/opts.go @@ -0,0 +1,49 @@ +package electrum_explorer + +import ( + "crypto/tls" + "time" +) + +// Option is a functional option for configuring the ElectrumX Explorer. +type Option func(*explorerSvc) + +// WithPollInterval sets how often the explorer polls for address updates when +// push subscriptions are not reliable (e.g. regtest). Default: 10 seconds. +func WithPollInterval(interval time.Duration) Option { + return func(svc *explorerSvc) { + svc.pollInterval = interval + } +} + +// WithTracker enables or disables address tracking via ElectrumX subscriptions. +// When disabled the explorer only handles one-shot queries (GetTxHex, Broadcast, etc.). +// Default: tracking is disabled. +func WithTracker(withTracker bool) Option { + return func(svc *explorerSvc) { + svc.noTracking = !withTracker + } +} + +// WithTLSConfig sets a custom TLS configuration for ssl:// connections. +// Use this to supply a self-signed CA certificate or to disable certificate +// verification when connecting to a self-hosted ElectrumX server. +// If not set, the default system roots are used with TLS 1.2 as the minimum version. +func WithTLSConfig(cfg *tls.Config) Option { + return func(svc *explorerSvc) { + svc.client.tlsConfig = cfg + } +} + +// WithEsploraURL sets an esplora-compatible REST base URL used exclusively for +// broadcasting transaction packages (multiple transactions submitted as a +// single unit via POST /txs/package). This is required when broadcasting v3 +// transactions that carry a P2A (pay-to-anchor) output with zero fee: Bitcoin +// Core rejects them individually via sendrawtransaction but accepts them via +// submitpackage. When not set, Broadcast falls back to individual electrum +// broadcasts (which will fail for zero-fee v3 parent transactions). +func WithEsploraURL(url string) Option { + return func(svc *explorerSvc) { + svc.esploraURL = url + } +} diff --git a/explorer/electrum/types.go b/explorer/electrum/types.go new file mode 100644 index 00000000..0973c1d1 --- /dev/null +++ b/explorer/electrum/types.go @@ -0,0 +1,46 @@ +package electrum_explorer + +import "encoding/json" + +// JSON-RPC wire types for the ElectrumX protocol. + +type jsonRPCRequest struct { + ID uint64 `json:"id"` + Method string `json:"method"` + Params []any `json:"params"` +} + +type jsonRPCResponse struct { + ID uint64 `json:"id"` + Result json.RawMessage `json:"result"` + // Error is kept as RawMessage because electrs-esplora sends a plain string + // while the JSON-RPC spec and ElectrumX send a {code, message} object. + // Parsing is deferred to parseRPCError so listen() never drops valid responses + // due to an unexpected error-field type. + Error json.RawMessage `json:"error"` +} + +type jsonRPCError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// jsonRPCNotification is a server-pushed message with no "id" field. +type jsonRPCNotification struct { + Method string `json:"method"` + Params []json.RawMessage `json:"params"` +} + +// ElectrumX result types. + +type electrumHistoryEntry struct { + TxHash string `json:"tx_hash"` + Height int64 `json:"height"` +} + +type electrumUTXO struct { + TxHash string `json:"tx_hash"` + TxPos uint32 `json:"tx_pos"` + Value uint64 `json:"value"` + Height int64 `json:"height"` +} diff --git a/explorer/electrum/utils.go b/explorer/electrum/utils.go new file mode 100644 index 00000000..bcf391df --- /dev/null +++ b/explorer/electrum/utils.go @@ -0,0 +1,188 @@ +package electrum_explorer + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "strings" + "sync" + + "github.com/arkade-os/arkd/pkg/client-lib/types" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + log "github.com/sirupsen/logrus" +) + +// parseRPCError interprets a JSON-RPC error value. ElectrumX sends a standard +// {"code": N, "message": "..."} object; electrs-esplora sends a plain string +// (e.g. "sendrawtransaction RPC error: {...}"). Both are accepted; nil is +// returned when raw is absent or null so callers can use a nil check. +func parseRPCError(raw json.RawMessage) *jsonRPCError { + if len(raw) == 0 || string(raw) == "null" { + return nil + } + var obj jsonRPCError + if err := json.Unmarshal(raw, &obj); err == nil { + return &obj + } + // electrs-esplora: error is a plain string, possibly embedding JSON like + // "sendrawtransaction RPC error: {\"code\":-22,\"message\":\"...\"}". + var msg string + if err := json.Unmarshal(raw, &msg); err == nil { + if i := strings.Index(msg, "{"); i >= 0 { + var inner jsonRPCError + if err := json.Unmarshal([]byte(msg[i:]), &inner); err == nil && inner.Code != 0 { + return &inner + } + } + return &jsonRPCError{Message: msg} + } + return &jsonRPCError{Message: string(raw)} +} + +// addressToScripthash converts a Bitcoin address to the ElectrumX scripthash format: +// SHA256(outputScript) with bytes reversed (little-endian), hex-encoded. +func addressToScripthash(addr string, params *chaincfg.Params) (string, error) { + decoded, err := btcutil.DecodeAddress(addr, params) + if err != nil { + return "", err + } + script, err := txscript.PayToAddrScript(decoded) + if err != nil { + return "", err + } + hash := sha256.Sum256(script) + for i, j := 0, 31; i < j; i, j = i+1, j-1 { + hash[i], hash[j] = hash[j], hash[i] + } + return hex.EncodeToString(hash[:]), nil +} + +// scriptToScripthash converts a raw script (hex-encoded) to the ElectrumX scripthash format. +func scriptToScripthash(scriptHex string) (string, error) { + script, err := hex.DecodeString(scriptHex) + if err != nil { + return "", err + } + hash := sha256.Sum256(script) + for i, j := 0, 31; i < j; i, j = i+1, j-1 { + hash[i], hash[j] = hash[j], hash[i] + } + return hex.EncodeToString(hash[:]), nil +} + +// scriptToAddress converts a hex-encoded output script to a Bitcoin address string. +// Returns empty string for unrecognised or non-standard scripts. +func scriptToAddress(scriptHex string, params *chaincfg.Params) string { + if scriptHex == "" { + return "" + } + script, err := hex.DecodeString(scriptHex) + if err != nil { + return "" + } + pkScript, err := txscript.ParsePkScript(script) + if err != nil { + return "" + } + addr, err := pkScript.Address(params) + if err != nil { + return "" + } + return addr.EncodeAddress() +} + +func parseBitcoinTx(txStr string) (string, string, error) { + var tx wire.MsgTx + if err := tx.Deserialize(hex.NewDecoder(strings.NewReader(txStr))); err != nil { + // Witness deserialization can misread a 0-input tx as a bad segwit + // marker; try non-witness before falling back to PSBT. + tx = wire.MsgTx{} + if err2 := tx.DeserializeNoWitness(hex.NewDecoder(strings.NewReader(txStr))); err2 != nil { + ptx, err3 := psbt.NewFromRawBytes(strings.NewReader(txStr), true) + if err3 != nil { + return "", "", err3 + } + txFromPartial, err3 := psbt.Extract(ptx) + if err3 != nil { + return "", "", err3 + } + tx = *txFromPartial + } + } + var txBuf bytes.Buffer + if err := tx.Serialize(&txBuf); err != nil { + return "", "", err + } + return hex.EncodeToString(txBuf.Bytes()), tx.TxHash().String(), nil +} + +// listeners is a non-blocking broadcast hub for OnchainAddressEvent. +// Slow or blocked listeners are removed automatically. +type listeners struct { + mu sync.RWMutex + listeners map[chan types.OnchainAddressEvent]int + index int +} + +func newListeners() *listeners { + return &listeners{ + listeners: make(map[chan types.OnchainAddressEvent]int), + } +} + +func (l *listeners) add(ch chan types.OnchainAddressEvent) { + l.mu.Lock() + defer l.mu.Unlock() + l.listeners[ch] = l.index + l.index++ +} + +func (l *listeners) broadcast(event types.OnchainAddressEvent) { + l.mu.RLock() + defer l.mu.RUnlock() + var toRemove []chan types.OnchainAddressEvent + var ids []int + for ch, id := range l.listeners { + select { + case ch <- event: + default: + toRemove = append(toRemove, ch) + ids = append(ids, id) + } + } + if len(toRemove) > 0 { + go func() { + l.remove(toRemove) + log.WithFields(log.Fields{ + "ids": ids, + "event": event, + }).Warn("electrum explorer: slow listener(s) removed") + }() + } +} + +func (l *listeners) remove(chs []chan types.OnchainAddressEvent) { + l.mu.Lock() + defer l.mu.Unlock() + for _, ch := range chs { + if _, ok := l.listeners[ch]; !ok { + continue + } + close(ch) + delete(l.listeners, ch) + } +} + +func (l *listeners) clear() { + l.mu.Lock() + defer l.mu.Unlock() + for ch := range l.listeners { + close(ch) + } + l.listeners = make(map[chan types.OnchainAddressEvent]int) +} diff --git a/funding.go b/funding.go index b9dc8fee..6053f668 100644 --- a/funding.go +++ b/funding.go @@ -91,7 +91,21 @@ func (w *wallet) NewOnchainAddress(ctx context.Context) (string, error) { } onchainAddr, _, _, err := w.client.Receive(ctx) - return onchainAddr, err + if err != nil { + return "", err + } + + // listenForOnchainTxs only subscribes to boarding and offchain-translated + // addresses at startup; plain onchain addresses from Receive() aren't + // in either set, so faucet deposits to them wouldn't surface as UTXO + // events. Subscribe here so the wallet's onchain pipeline tracks them too. + go func() { + if err := w.Explorer().SubscribeForAddresses([]string{onchainAddr}); err != nil { + log.WithError(err).Warn("failed to subscribe for onchain address") + } + }() + + return onchainAddr, nil } func (w *wallet) Balance(ctx context.Context) (*types.Balance, error) { diff --git a/init.go b/init.go index a965b2d1..b5f6e7ef 100644 --- a/init.go +++ b/init.go @@ -5,13 +5,16 @@ import ( "errors" "fmt" "sort" + "strings" "time" arklib "github.com/arkade-os/arkd/pkg/ark-lib" clientwallet "github.com/arkade-os/arkd/pkg/client-lib" grpcclient "github.com/arkade-os/arkd/pkg/client-lib/client/grpc" - mempoolexplorer "github.com/arkade-os/arkd/pkg/client-lib/explorer/mempool" + clientexplorer "github.com/arkade-os/arkd/pkg/client-lib/explorer" + mempool_explorer "github.com/arkade-os/arkd/pkg/client-lib/explorer/mempool" "github.com/arkade-os/go-sdk/contract" + electrum_explorer "github.com/arkade-os/go-sdk/explorer/electrum" "github.com/arkade-os/go-sdk/types" log "github.com/sirupsen/logrus" ) @@ -19,7 +22,7 @@ import ( var ( defaultExplorerUrl = map[string]string{ arklib.Bitcoin.Name: "https://mempool.space/api", - arklib.BitcoinRegTest.Name: "http://127.0.0.1:3000", + arklib.BitcoinRegTest.Name: "tcp://127.0.0.1:50000", arklib.BitcoinTestNet.Name: "https://mempool.space/testnet/api", arklib.BitcoinSigNet.Name: "https://mempool.space/signet/api", arklib.BitcoinMutinyNet.Name: "https://mutinynet.com/api", @@ -50,15 +53,26 @@ func (w *wallet) Init( } explorerUrl := initOpts.explorerUrl - if initOpts.explorerUrl == "" { + if explorerUrl == "" { explorerUrl = defaultExplorerUrl[info.Network] } - explorerOpts := []mempoolexplorer.Option{mempoolexplorer.WithTracker(true)} + if initOpts.electrumEsploraURL != "" && + !strings.HasPrefix(explorerUrl, "tcp://") && + !strings.HasPrefix(explorerUrl, "ssl://") { + return fmt.Errorf( + "WithElectrumPackageBroadcastURL requires the main explorer to be an electrum node (set explorer URL to tcp:// or ssl://)", + ) + } + var pollInterval time.Duration if info.Network == arklib.BitcoinRegTest.Name { - explorerOpts = append(explorerOpts, mempoolexplorer.WithPollInterval(2*time.Second)) + pollInterval = 2 * time.Second } - explorer, err := mempoolexplorer.NewExplorer( - explorerUrl, network, explorerOpts..., + explorerSvc, err := newExplorer( + explorerUrl, + networkFromString(info.Network), + true, + pollInterval, + initOpts.electrumEsploraURL, ) if err != nil { return fmt.Errorf("failed to init explorer: %v", err) @@ -68,7 +82,7 @@ func (w *wallet) Init( ServerUrl: serverUrl, Seed: seed, Password: password, - Explorer: explorer, + Explorer: explorerSvc, }); err != nil { return err } @@ -156,6 +170,10 @@ func (w *wallet) Unlock(ctx context.Context, password string) error { } err := w.refreshDb(ctx) + // Register the explorer listener before signaling IsSynced so events + // fired immediately after (e.g. from NewBoardingAddress) are not + // dropped before listenForOnchainTxs can start consuming. + explorerCh := w.Explorer().GetAddressesEvents() if err == nil { w.scheduleNextSettlement() } @@ -163,7 +181,7 @@ func (w *wallet) Unlock(ctx context.Context, password string) error { close(w.syncCh) w.bgWg.Go(func() { w.listenForArkTxs(ctx) }) - w.bgWg.Go(func() { w.listenForOnchainTxs(ctx, w.network) }) + w.bgWg.Go(func() { w.listenForOnchainTxs(ctx, w.network, explorerCh) }) w.bgWg.Go(func() { w.listenDbEvents(ctx) }) w.bgWg.Go(func() { w.periodicRefreshDb(ctx) }) }) @@ -176,15 +194,14 @@ func (w *wallet) Lock(ctx context.Context) error { return err } + if w.stopFn != nil { + w.stopFn() + } w.Explorer().Stop() if w.scheduler != nil { w.scheduler.Stop() } - if w.stopFn != nil { - w.stopFn() - } - if w.contractManager != nil { w.contractManager.Close() w.contractManager = nil @@ -201,6 +218,30 @@ func (w *wallet) Lock(ctx context.Context) error { return nil } +// newExplorer creates either an ElectrumX or mempool.space Explorer depending +// on the URL scheme. URLs starting with "tcp://" or "ssl://" use ElectrumX; +// all others use the mempool.space REST/WebSocket implementation. +func newExplorer( + url string, net arklib.Network, tracker bool, pollInterval time.Duration, + esploraURL string, +) (clientexplorer.Explorer, error) { + if strings.HasPrefix(url, "tcp://") || strings.HasPrefix(url, "ssl://") { + opts := []electrum_explorer.Option{electrum_explorer.WithTracker(tracker)} + if pollInterval > 0 { + opts = append(opts, electrum_explorer.WithPollInterval(pollInterval)) + } + if esploraURL != "" { + opts = append(opts, electrum_explorer.WithEsploraURL(esploraURL)) + } + return electrum_explorer.NewExplorer(url, net, opts...) + } + opts := []mempool_explorer.Option{mempool_explorer.WithTracker(tracker)} + if pollInterval > 0 { + opts = append(opts, mempool_explorer.WithPollInterval(pollInterval)) + } + return mempool_explorer.NewExplorer(url, net, opts...) +} + func (w *wallet) IsLocked(_ context.Context) bool { if w.client == nil { return true diff --git a/init_opts.go b/init_opts.go index 2ed10599..57b820ef 100644 --- a/init_opts.go +++ b/init_opts.go @@ -2,6 +2,7 @@ package arksdk import ( "fmt" + "strings" ) type InitOption func(options *initOptions) error @@ -14,6 +15,7 @@ func ApplyInitOptions(opts ...InitOption) error { return err } +// WithExplorerURL overrides the default mempool.space URL used for on-chain queries. func WithExplorerURL(explorerUrl string) InitOption { return func(o *initOptions) error { if o.explorerUrl != "" { @@ -27,6 +29,25 @@ func WithExplorerURL(explorerUrl string) InitOption { } } +// WithElectrumExplorer configures the SDK to use an ElectrumX server for +// on-chain queries instead of the default mempool.space REST/WebSocket API. +// serverURL must begin with "tcp://" or "ssl://". +func WithElectrumExplorer(serverURL string) InitOption { + return func(o *initOptions) error { + if o.explorerUrl != "" { + return fmt.Errorf("explorer url already set") + } + if serverURL == "" { + return fmt.Errorf("electrum server url cannot be empty") + } + if !strings.HasPrefix(serverURL, "tcp://") && !strings.HasPrefix(serverURL, "ssl://") { + return fmt.Errorf("electrum server url must start with tcp:// or ssl://") + } + o.explorerUrl = serverURL + return nil + } +} + func applyInitOptions(opts ...InitOption) (*initOptions, error) { o := newDefaultInitOptions() for _, opt := range opts { @@ -40,8 +61,23 @@ func applyInitOptions(opts ...InitOption) (*initOptions, error) { return o, nil } +// WithElectrumPackageBroadcastURL sets an esplora-compatible REST base URL used +// when broadcasting transaction packages via the electrum explorer. Required for +// zero-fee v3 transactions (Ark commitment TXs with P2A anchors) that Bitcoin +// Core rejects via sendrawtransaction but accepts via submitpackage. +func WithElectrumPackageBroadcastURL(url string) InitOption { + return func(o *initOptions) error { + if url == "" { + return fmt.Errorf("esplora url cannot be empty") + } + o.electrumEsploraURL = url + return nil + } +} + type initOptions struct { - explorerUrl string + explorerUrl string + electrumEsploraURL string } func newDefaultInitOptions() *initOptions { diff --git a/init_opts_test.go b/init_opts_test.go index 02c4b2dd..ebd0f098 100644 --- a/init_opts_test.go +++ b/init_opts_test.go @@ -20,6 +20,16 @@ func TestInitOptions(t *testing.T) { name: "WithExplorerURL", opts: []arksdk.InitOption{arksdk.WithExplorerURL("https://example.com")}, }, + { + name: "WithElectrumExplorer tcp", + opts: []arksdk.InitOption{arksdk.WithElectrumExplorer("tcp://127.0.0.1:50000")}, + }, + { + name: "WithElectrumExplorer ssl", + opts: []arksdk.InitOption{ + arksdk.WithElectrumExplorer("ssl://electrum.example.com:50002"), + }, + }, } for _, f := range fixtures { @@ -54,6 +64,26 @@ func TestInitOptions(t *testing.T) { }, wantErrContains: "explorer url already set", }, + { + name: "WithElectrumExplorer empty string", + opts: []arksdk.InitOption{arksdk.WithElectrumExplorer("")}, + wantErrContains: "electrum server url cannot be empty", + }, + { + name: "WithElectrumExplorer bad scheme", + opts: []arksdk.InitOption{ + arksdk.WithElectrumExplorer("http://example.com"), + }, + wantErrContains: "must start with tcp:// or ssl://", + }, + { + name: "WithElectrumExplorer conflicts with WithExplorerURL", + opts: []arksdk.InitOption{ + arksdk.WithExplorerURL("https://example.com"), + arksdk.WithElectrumExplorer("tcp://127.0.0.1:50000"), + }, + wantErrContains: "explorer url already set", + }, } for _, f := range fixtures { diff --git a/test/docker/docker-compose.yml b/test/docker/docker-compose.yml index 1d2a6e1f..629f36fa 100644 --- a/test/docker/docker-compose.yml +++ b/test/docker/docker-compose.yml @@ -4,7 +4,7 @@ services: image: postgres:16 container_name: pgnbxplorer ports: - - 5432:5432 + - 5433:5432 environment: - POSTGRES_HOST_AUTH_METHOD=trust volumes: diff --git a/test/e2e/batch_session_test.go b/test/e2e/batch_session_test.go index a516f6f8..49258075 100644 --- a/test/e2e/batch_session_test.go +++ b/test/e2e/batch_session_test.go @@ -32,8 +32,8 @@ func TestBatchSession(t *testing.T) { faucetOnchain(t, bobBoardingAddr, 0.00021) // next event received by bob and alice utxo channel should be the added events related to boarding inputs - bobUtxoEvent := <-bobUtxoCh - aliceUtxoEvent := <-aliceUtxoCh + bobUtxoEvent := recvUtxoEvent(t, bobUtxoCh) + aliceUtxoEvent := recvUtxoEvent(t, aliceUtxoCh) require.Equal(t, types.UtxosAdded, bobUtxoEvent.Type) require.Equal(t, types.UtxosAdded, aliceUtxoEvent.Type) require.Len(t, bobUtxoEvent.Utxos, 1) @@ -85,8 +85,8 @@ func TestBatchSession(t *testing.T) { // next event received by alice and bob vtxo channel should be the added events // related to new vtxos created by the batch - aliceVtxoEvent := <-aliceVtxoCh - bobVtxoEvent := <-bobVtxoCh + aliceVtxoEvent := recvVtxoEvent(t, aliceVtxoCh) + bobVtxoEvent := recvVtxoEvent(t, bobVtxoCh) require.Equal(t, types.VtxosAdded, aliceVtxoEvent.Type) require.Equal(t, types.VtxosAdded, bobVtxoEvent.Type) require.Len(t, aliceVtxoEvent.Vtxos, 1) @@ -108,8 +108,8 @@ func TestBatchSession(t *testing.T) { // next event received by bob and alice utxo channel should be the spent events // related to boarding inputs - bobUtxoEvent = <-bobUtxoCh - aliceUtxoEvent = <-aliceUtxoCh + bobUtxoEvent = recvUtxoEvent(t, bobUtxoCh) + aliceUtxoEvent = recvUtxoEvent(t, aliceUtxoCh) require.Equal(t, types.UtxosSpent, bobUtxoEvent.Type) require.Equal(t, types.UtxosSpent, aliceUtxoEvent.Type) require.Len(t, bobUtxoEvent.Utxos, 1) @@ -136,8 +136,8 @@ func TestBatchSession(t *testing.T) { require.Equal(t, aliceCommitmentTx, bobCommitmentTx) // the event channel should he notified about the new vtxos - aliceVtxoEvent = <-aliceVtxoCh - bobVtxoEvent = <-bobVtxoCh + aliceVtxoEvent = recvVtxoEvent(t, aliceVtxoCh) + bobVtxoEvent = recvVtxoEvent(t, bobVtxoCh) require.Equal(t, types.VtxosAdded, aliceVtxoEvent.Type) require.Equal(t, types.VtxosAdded, bobVtxoEvent.Type) require.Len(t, aliceVtxoEvent.Vtxos, 1) @@ -148,8 +148,8 @@ func TestBatchSession(t *testing.T) { require.Equal(t, 21000, int(bobRefreshVtxo.Amount)) // the event channel should he notified about the spent vtxos - aliceVtxoEvent = <-aliceVtxoCh - bobVtxoEvent = <-bobVtxoCh + aliceVtxoEvent = recvVtxoEvent(t, aliceVtxoCh) + bobVtxoEvent = recvVtxoEvent(t, bobVtxoCh) require.Equal(t, types.VtxoSettled, aliceVtxoEvent.Type) require.Equal(t, types.VtxoSettled, bobVtxoEvent.Type) require.Len(t, aliceVtxoEvent.Vtxos, 1) @@ -199,7 +199,7 @@ func TestBatchSession(t *testing.T) { // next event received by alice vtxo channel should be the added event // related to new vtxo created by the redemption - aliceVtxoEvent := <-aliceVtxoCh + aliceVtxoEvent := recvVtxoEvent(t, aliceVtxoCh) require.Equal(t, types.VtxosAdded, aliceVtxoEvent.Type) require.Len(t, aliceVtxoEvent.Vtxos, 1) aliceVtxo := aliceVtxoEvent.Vtxos[0] @@ -236,7 +236,7 @@ func TestBatchSession(t *testing.T) { vtxoCh := alice.GetVtxoEventChannel(ctx) faucetOffchain(t, alice, 0.00005) - vtxoEvent := <-vtxoCh + vtxoEvent := recvVtxoEvent(t, vtxoCh) require.Equal(t, types.VtxosAdded, vtxoEvent.Type) require.Len(t, vtxoEvent.Vtxos, 1) require.Equal(t, 5000, int(vtxoEvent.Vtxos[0].Amount)) @@ -249,12 +249,18 @@ func TestBatchSession(t *testing.T) { require.Len(t, res.Vtxos, 1) require.False(t, res.Vtxos[0].Swept) - // Make the offchain funds expire - generateBlocks(t, 21) + // Make the offchain funds expire. ARKD_VTXO_TREE_EXPIRY=20 means 21 blocks + // minimum (1 to confirm + 20 to expire). Use 25 for a small buffer. + // Fewer blocks here means fewer electrum notifications, keeping CI's electrs + // responsive for subsequent tests. + generateBlocks(t, 25) - vtxoEvent = <-vtxoCh + vtxoEvent = recvVtxoEvent(t, vtxoCh) require.Equal(t, types.VtxosSwept, vtxoEvent.Type) + // Allow electrs to finish indexing all block notifications before proceeding. + time.Sleep(10 * time.Second) + res, err = alice.Indexer().GetVtxos(t.Context(), opts) require.NoError(t, err) require.NotNil(t, res) @@ -264,7 +270,7 @@ func TestBatchSession(t *testing.T) { // Repeat the operation to have many funds that are going to be swept and renewed faucetOffchain(t, alice, 0.00003) - vtxoEvent = <-vtxoCh + vtxoEvent = recvVtxoEvent(t, vtxoCh) require.Equal(t, types.VtxosAdded, vtxoEvent.Type) require.Len(t, vtxoEvent.Vtxos, 1) require.Equal(t, 3000, int(vtxoEvent.Vtxos[0].Amount)) @@ -273,7 +279,7 @@ func TestBatchSession(t *testing.T) { faucetOnchain(t, boardingAddr, 0.00021) - utxoEvent := <-utxoCh + utxoEvent := recvUtxoEvent(t, utxoCh) require.Equal(t, types.UtxosAdded, utxoEvent.Type) require.Len(t, utxoEvent.Utxos, 1) require.Equal(t, 21000, int(utxoEvent.Utxos[0].Amount)) @@ -285,7 +291,7 @@ func TestBatchSession(t *testing.T) { // next event received by alice and bob vtxo channel should be the added events // related to new vtxos created by the batch - vtxoEvent = <-vtxoCh + vtxoEvent = recvVtxoEvent(t, vtxoCh) require.Equal(t, types.VtxosAdded, vtxoEvent.Type) require.Len(t, vtxoEvent.Vtxos, 1) vtxo := vtxoEvent.Vtxos[0] diff --git a/test/e2e/exit_test.go b/test/e2e/exit_test.go index 8249e736..6f1d0c07 100644 --- a/test/e2e/exit_test.go +++ b/test/e2e/exit_test.go @@ -50,7 +50,7 @@ func TestCollaborativeExit(t *testing.T) { // next event received by bob utxo channel should be the added event // related to the collaborative exit - bobUtxoEvent := <-bobUtxoCh + bobUtxoEvent := recvUtxoEvent(t, bobUtxoCh) require.Equal(t, types.UtxosAdded, bobUtxoEvent.Type) require.Len(t, bobUtxoEvent.Utxos, 1) bobUtxo := bobUtxoEvent.Utxos[0] @@ -62,7 +62,7 @@ func TestCollaborativeExit(t *testing.T) { // next event received by bob utxo channel should be the confirmed event // related to the commitment transaction - bobUtxoEvent = <-bobUtxoCh + bobUtxoEvent = recvUtxoEvent(t, bobUtxoCh) require.Equal(t, types.UtxosConfirmed, bobUtxoEvent.Type) require.Len(t, bobUtxoEvent.Utxos, 1) bobConfirmedUtxo := bobUtxoEvent.Utxos[0] @@ -119,7 +119,7 @@ func TestCollaborativeExit(t *testing.T) { // next event received by bob utxo channel should be the added event // related to the collaborative exit - bobUtxoEvent := <-bobUtxoCh + bobUtxoEvent := recvUtxoEvent(t, bobUtxoCh) require.Equal(t, types.UtxosAdded, bobUtxoEvent.Type) require.Len(t, bobUtxoEvent.Utxos, 1) bobUtxo := bobUtxoEvent.Utxos[0] @@ -131,7 +131,7 @@ func TestCollaborativeExit(t *testing.T) { // next event received by bob utxo channel should be the confirmed event // related to the commitment transaction - bobUtxoEvent = <-bobUtxoCh + bobUtxoEvent = recvUtxoEvent(t, bobUtxoCh) require.Equal(t, types.UtxosConfirmed, bobUtxoEvent.Type) require.Len(t, bobUtxoEvent.Utxos, 1) bobConfirmedUtxo := bobUtxoEvent.Utxos[0] @@ -161,9 +161,10 @@ func TestUnilateralExit(t *testing.T) { alice := setupClient(t, "", arksdk.WithoutAutoSettle()) aliceVtxoCh := alice.GetVtxoEventChannel(ctx) + aliceUtxoCh := alice.GetUtxoEventChannel(ctx) vtxoToUnroll := faucetOffchain(t, alice, 0.00021) - aliceVtxoEvent := <-aliceVtxoCh + aliceVtxoEvent := recvVtxoEvent(t, aliceVtxoCh) require.Equal(t, types.VtxosAdded, aliceVtxoEvent.Type) aliceOnchainAddr, err := alice.NewOnchainAddress(ctx) @@ -172,8 +173,14 @@ func TestUnilateralExit(t *testing.T) { // Faucet onchain addr to cover network fees for the unroll. faucetOnchain(t, aliceOnchainAddr, 0.0001) - // Give the explorer time to catch up - time.Sleep(5 * time.Second) + + // next event received by alice utxo channel should be the added event + // related to the faucet + aliceUtxoEvent := recvUtxoEvent(t, aliceUtxoCh) + require.Equal(t, types.UtxosAdded, aliceUtxoEvent.Type) + require.Len(t, aliceUtxoEvent.Utxos, 1) + aliceUtxo := aliceUtxoEvent.Utxos[0] + require.Equal(t, 10000, int(aliceUtxo.Amount)) for { err = alice.Unroll(ctx) @@ -256,14 +263,20 @@ func TestUnilateralExit(t *testing.T) { }}) require.NoError(t, err) - bobVtxoEvent := <-bobVtxoCh + bobVtxoEvent := recvVtxoEvent(t, bobVtxoCh) require.Equal(t, types.VtxosAdded, bobVtxoEvent.Type) require.Len(t, bobVtxoEvent.Vtxos, 1) vtxoToUnroll := bobVtxoEvent.Vtxos[0] require.Equal(t, 21000, int(vtxoToUnroll.Amount)) + bobUtxoCh := bob.GetUtxoEventChannel(ctx) faucetOnchain(t, bobOnchainAddr, 0.0001) - time.Sleep(5 * time.Second) + + bobUtxoEvent := recvUtxoEvent(t, bobUtxoCh) + require.Equal(t, types.UtxosAdded, bobUtxoEvent.Type) + require.Len(t, bobUtxoEvent.Utxos, 1) + bobUtxo := bobUtxoEvent.Utxos[0] + require.Equal(t, 10000, int(bobUtxo.Amount)) for { err = bob.Unroll(ctx) diff --git a/test/e2e/hd_wallet_test.go b/test/e2e/hd_wallet_test.go index cd6f8974..f3f629b1 100644 --- a/test/e2e/hd_wallet_test.go +++ b/test/e2e/hd_wallet_test.go @@ -325,7 +325,7 @@ func TestHDWalletEventStreams(t *testing.T) { t.Run("boarding receive and settlement", func(t *testing.T) { ctx := t.Context() - aliceClientHD := setupClient(t, "") + aliceClientHD := setupClient(t, "", sdk.WithoutAutoSettle()) boardingAddr, err := aliceClientHD.NewBoardingAddress(ctx) require.NoError(t, err) @@ -394,49 +394,6 @@ func TestHDWalletEventStreams(t *testing.T) { }) } -// TestHDWalletRecoversBoardingOnlyFundedKeys covers the case: -// a key whose ONLY activity is a boarding UTXO (never any offchain VTXO at -// the matching offchain script). After dumping the seed and restoring into a -// fresh client, discovery must still find the key so the boarding UTXO is reachable. -// -// This currently exposes review issue H1: discoverHDWalletKeys only checks -// offchain VTXO activity, so boarding-only funded keys are missed. -func TestHDWalletRecoversBoardingOnlyFundedKeys(t *testing.T) { - ctx := t.Context() - - alice := setupClient(t, "") - - boardingAddr, err := alice.NewBoardingAddress(ctx) - require.NoError(t, err) - require.NotEmpty(t, boardingAddr) - - const boardingAmount = 0.00021 - faucetOnchain(t, boardingAddr, boardingAmount) - generateBlocks(t, 1) - - waitForExplorerHistory(t, alice, []string{boardingAddr}) - - seed, err := alice.Dump(ctx) - require.NoError(t, err) - require.NotEmpty(t, seed) - - alice.Stop() - - restoredAlice := setupClient(t, seed) - - // The restored wallet should re-discover the key that backs the funded - // boarding address and surface the UTXO in its onchain balance. - require.Eventually(t, func() bool { - balance, err := restoredAlice.Balance(ctx) - if err != nil { - return false - } - return sumLockedAmounts(balance.OnchainBalance.LockedAmount) >= uint64(boardingAmount*1e8) - }, 30*time.Second, 500*time.Millisecond, - "restored wallet did not recover the boarding-only funded key — "+ - "this is review H1: discoverHDWalletKeys only scans offchain VTXOs") -} - func waitForExplorerHistory(t *testing.T, client sdk.Wallet, addresses []string) { t.Helper() @@ -545,6 +502,45 @@ func waitForTxEvent( } } +// TestHDWalletRecoversBoardingOnlyFundedKeys covers the case: +// a key whose ONLY activity is a boarding UTXO (never any offchain VTXO at +// the matching offchain script). After dumping the seed and restoring into a +// fresh client, discovery must still find the key so the boarding UTXO is reachable. +func TestHDWalletRecoversBoardingOnlyFundedKeys(t *testing.T) { + ctx := t.Context() + + alice := setupClient(t, "", sdk.WithoutAutoSettle()) + + boardingAddr, err := alice.NewBoardingAddress(ctx) + require.NoError(t, err) + require.NotEmpty(t, boardingAddr) + + const boardingAmount = 0.00021 + faucetOnchain(t, boardingAddr, boardingAmount) + generateBlocks(t, 1) + + waitForExplorerHistory(t, alice, []string{boardingAddr}) + + seed, err := alice.Dump(ctx) + require.NoError(t, err) + require.NotEmpty(t, seed) + + alice.Stop() + + restoredAlice := setupClient(t, seed, sdk.WithoutAutoSettle()) + + // The restored wallet should re-discover the key that backs the funded + // boarding address and surface the UTXO in its onchain balance. + require.Eventually(t, func() bool { + balance, err := restoredAlice.Balance(ctx) + if err != nil { + return false + } + return sumLockedAmounts(balance.OnchainBalance.LockedAmount) >= uint64(boardingAmount*1e8) + }, 30*time.Second, 500*time.Millisecond, + "restored wallet did not recover the boarding-only funded key") +} + func sumVtxoAmounts(vtxos []clientTypes.Vtxo) uint64 { var total uint64 for _, vtxo := range vtxos { diff --git a/test/e2e/history_test.go b/test/e2e/history_test.go index 6b297762..2850c5d8 100644 --- a/test/e2e/history_test.go +++ b/test/e2e/history_test.go @@ -33,13 +33,13 @@ func TestTransactionHistory(t *testing.T) { faucetOnchain(t, aliceBoardingAddr, 0.00021) // should receive the utxo added event - utxoEvent := <-utxoCh + utxoEvent := recvUtxoEvent(t, utxoCh) require.Equal(t, types.UtxosAdded, utxoEvent.Type) require.Len(t, utxoEvent.Utxos, 1) require.Equal(t, 21000, int(utxoEvent.Utxos[0].Amount)) // should receive the boarding tx event - event := <-aliceTxChan + event := recvTxEvent(t, aliceTxChan) require.Equal(t, types.TxsAdded, event.Type) require.Len(t, event.Txs, 1) boardingTx := event.Txs[0] @@ -98,7 +98,7 @@ func TestTransactionHistory(t *testing.T) { requireTxEqual(t, settledBoardingTx, history[0], commitmentTxid) // wait for the utxo to be detected as spent and settle again - utxoEvent = <-utxoCh + utxoEvent = recvUtxoEvent(t, utxoCh) require.Equal(t, types.UtxosSpent, utxoEvent.Type) require.Len(t, utxoEvent.Utxos, 1) require.Equal(t, 21000, int(utxoEvent.Utxos[0].Amount)) @@ -133,10 +133,8 @@ func TestTransactionHistory(t *testing.T) { }}) require.NoError(t, err) - time.Sleep(10 * time.Second) - // should receive the ark tx event - event = <-aliceTxChan + event = recvTxEvent(t, aliceTxChan) require.Equal(t, types.TxsAdded, event.Type) require.Len(t, event.Txs, 1) offchainTx := event.Txs[0] @@ -153,7 +151,7 @@ func TestTransactionHistory(t *testing.T) { requireTxEqual(t, offchainTx, history[0], "") requireTxEqual(t, settledBoardingTx, history[1], "") - event = <-bobTxChan + event = recvTxEvent(t, bobTxChan) require.Equal(t, types.TxsAdded, event.Type) require.Len(t, event.Txs, 1) @@ -172,6 +170,11 @@ func TestTransactionHistory(t *testing.T) { require.Len(t, history, 1) requireTxEqual(t, offchainReceivedTx, history[0], "") + // bob settles to get a non-recoverable VTXO before sending + generateBlocks(t, 1) + _, err = bob.Settle(ctx) + require.NoError(t, err) + // bob sends funds to alice arkTxid, err = bob.SendOffChain(ctx, []clientTypes.Receiver{{ To: aliceOffchainAddr, @@ -180,7 +183,7 @@ func TestTransactionHistory(t *testing.T) { require.NoError(t, err) // should receive the ark tx event - event = <-aliceTxChan + event = recvTxEvent(t, aliceTxChan) require.Equal(t, types.TxsAdded, event.Type) require.Len(t, event.Txs, 1) offchainReceivedTx = event.Txs[0] @@ -206,7 +209,7 @@ func TestTransactionHistory(t *testing.T) { require.NotEmpty(t, commitmentTxid) // should receive the offchain settled tx event - event = <-aliceTxChan + event = recvTxEvent(t, aliceTxChan) require.Equal(t, types.TxsAdded, event.Type) require.Len(t, event.Txs, 1) collabExitTx := event.Txs[0] diff --git a/test/e2e/rbf_test.go b/test/e2e/rbf_test.go index fcba60a6..32d05148 100644 --- a/test/e2e/rbf_test.go +++ b/test/e2e/rbf_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + arksdk "github.com/arkade-os/go-sdk" "github.com/stretchr/testify/require" ) @@ -19,7 +20,7 @@ import ( // vout index changes in the replacement transaction. func TestSettleAfterRBFBumpFee(t *testing.T) { ctx := t.Context() - client := setupClient(t, "") + client := setupClient(t, "", arksdk.WithoutAutoSettle()) // Get the boarding address. boardingAddr, err := client.NewBoardingAddress(ctx) diff --git a/test/e2e/utils_test.go b/test/e2e/utils_test.go index 493db25a..72d50515 100644 --- a/test/e2e/utils_test.go +++ b/test/e2e/utils_test.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "os" "os/exec" "strings" "sync" @@ -19,18 +20,36 @@ import ( ) const ( - password = "secret" - serverUrl = "127.0.0.1:7070" - explorerUrl = "http://127.0.0.1:3000" + password = "secret" + serverUrl = "127.0.0.1:7070" ) +var explorerUrl = func() string { + if u := os.Getenv("ARK_ELECTRUM_URL"); u != "" { + return u + } + return "tcp://127.0.0.1:50000" +}() + +var esploraUrl = os.Getenv("ARK_ESPLORA_URL") + func setupClient(t *testing.T, seed string, opts ...sdk.WalletOption) sdk.Wallet { t.Helper() arkClient, err := sdk.NewWallet(t.TempDir(), opts...) require.NoError(t, err) - err = arkClient.Init(t.Context(), serverUrl, seed, password) + initOpts := []sdk.InitOption{sdk.WithElectrumExplorer(explorerUrl)} + if esploraUrl != "" { + initOpts = append(initOpts, sdk.WithElectrumPackageBroadcastURL(esploraUrl)) + } + err = arkClient.Init( + t.Context(), + serverUrl, + seed, + password, + initOpts..., + ) require.NoError(t, err) err = arkClient.Unlock(t.Context(), password) @@ -176,3 +195,36 @@ func generateBlocks(t *testing.T, n int) { _, err := runCommand("nigiri", "rpc", "--generate", fmt.Sprintf("%d", n)) require.NoError(t, err) } + +func recvUtxoEvent(t *testing.T, ch <-chan types.UtxoEvent) types.UtxoEvent { + t.Helper() + select { + case e := <-ch: + return e + case <-time.After(60 * time.Second): + t.Fatal("timed out waiting for utxo event") + return types.UtxoEvent{} + } +} + +func recvVtxoEvent(t *testing.T, ch <-chan types.VtxoEvent) types.VtxoEvent { + t.Helper() + select { + case e := <-ch: + return e + case <-time.After(60 * time.Second): + t.Fatal("timed out waiting for vtxo event") + return types.VtxoEvent{} + } +} + +func recvTxEvent(t *testing.T, ch <-chan types.TransactionEvent) types.TransactionEvent { + t.Helper() + select { + case e := <-ch: + return e + case <-time.After(60 * time.Second): + t.Fatal("timed out waiting for tx event") + return types.TransactionEvent{} + } +} diff --git a/wallet.go b/wallet.go index df572b99..c97c4046 100644 --- a/wallet.go +++ b/wallet.go @@ -19,7 +19,6 @@ import ( clientwallet "github.com/arkade-os/arkd/pkg/client-lib" "github.com/arkade-os/arkd/pkg/client-lib/client" "github.com/arkade-os/arkd/pkg/client-lib/explorer" - mempoolexplorer "github.com/arkade-os/arkd/pkg/client-lib/explorer/mempool" "github.com/arkade-os/arkd/pkg/client-lib/identity" "github.com/arkade-os/arkd/pkg/client-lib/indexer" clientstore "github.com/arkade-os/arkd/pkg/client-lib/store" @@ -191,10 +190,9 @@ func LoadWallet(datadir string, opts ...WalletOption) (Wallet, error) { clientOpts = append(clientOpts, clientwallet.WithVerbose()) } - // client.LoadArkClient defaults to noTracking=true, which leaves the explorer's - // listeners field nil. When listenForOnchainTxs calls GetAddressesEvents() it - // dereferences that nil field and panics. Pre-create a tracking-enabled explorer - // from the stored config and inject it so the underlying call skips creating its own. + // Pre-create a tracking-enabled explorer from the stored config and inject it + // so GetAddressesEvents() works correctly after Unlock. Pre-registering before + // the sync sequence avoids the event-drop race on boarding address creation. cfgData, err := clientDb.ConfigStore().GetData(context.Background()) if err != nil { return nil, err @@ -204,13 +202,11 @@ func LoadWallet(datadir string, opts ...WalletOption) (Wallet, error) { if len(explorerUrl) == 0 { explorerUrl = defaultExplorerUrl[cfgData.Network.Name] } - explorerOpts := []mempoolexplorer.Option{mempoolexplorer.WithTracker(true)} + var pollInterval time.Duration if cfgData.Network.Name == arklib.BitcoinRegTest.Name { - explorerOpts = append(explorerOpts, mempoolexplorer.WithPollInterval(2*time.Second)) + pollInterval = 2 * time.Second } - explorerSvc, err = mempoolexplorer.NewExplorer( - explorerUrl, cfgData.Network, explorerOpts..., - ) + explorerSvc, err = newExplorer(explorerUrl, cfgData.Network, true, pollInterval, "") if err != nil { return nil, fmt.Errorf("failed to init explorer: %v", err) } @@ -363,6 +359,13 @@ func (w *wallet) Stop() { w.stopOnce.Do(func() { w.client.Stop() + // Cancel the background context before stopping the explorer so that + // background goroutines (listenForOnchainTxs, etc.) start exiting and + // stop calling explorer methods. Explorer().Stop() then shuts down the + // electrum client which makes any in-flight RPC fail fast. + if w.stopFn != nil { + w.stopFn() + } if explorer := w.Explorer(); explorer != nil { explorer.Stop() @@ -380,9 +383,6 @@ func (w *wallet) Stop() { w.syncErr = nil w.syncMu.Unlock() - if w.stopFn != nil { - w.stopFn() - } if w.syncListeners != nil { w.syncListeners.broadcast(fmt.Errorf("service stopped while restoring")) w.syncListeners.clear() @@ -963,7 +963,10 @@ func (w *wallet) listenForArkTxs(ctx context.Context) { } } -func (w *wallet) listenForOnchainTxs(ctx context.Context, network arklib.Network) { +func (w *wallet) listenForOnchainTxs( + ctx context.Context, network arklib.Network, + ch <-chan clienttypes.OnchainAddressEvent, +) { explorer := w.client.Explorer() if explorer == nil { // Should be unreachable @@ -997,13 +1000,19 @@ func (w *wallet) listenForOnchainTxs(ctx context.Context, network arklib.Network addresses = append(addresses, toOnchainAddress(contract.Address, network)) } - if err := explorer.SubscribeForAddresses(addresses); err != nil { - log.WithError(err).Error("failed to subscribe for onchain addresses") - return + for { + if err := explorer.SubscribeForAddresses(addresses); err == nil { + break + } else { + log.WithError(err).Warn("failed to subscribe for onchain addresses, retrying...") + } + select { + case <-ctx.Done(): + return + case <-time.After(3 * time.Second): + } } - ch := explorer.GetAddressesEvents() - log.Debugf("subscribed for %d addresses", len(addresses)) for { select { @@ -1147,7 +1156,23 @@ func (w *wallet) listenForOnchainTxs(ctx context.Context, network arklib.Network continue } if len(contracts) <= 0 { - log.Warnf("contract not found for utxo %s", u.Outpoint) + // Plain onchain address (no Ark contract), e.g. from + // NewOnchainAddress. Track the UTXO for fee payment + // with no tapscripts or exit delay. + txHex, err := explorer.GetTxHex(u.Txid) + if err != nil { + log.WithError(err). + Warnf("failed to fetch tx for onchain utxo %s", u.Outpoint) + continue + } + utxosToAdd = append(utxosToAdd, clienttypes.Utxo{ + Outpoint: u.Outpoint, + Amount: u.Amount, + Script: u.Script, + Tx: txHex, + CreatedAt: u.CreatedAt, + SpendableAt: u.CreatedAt, + }) continue } diff --git a/wallet_test.go b/wallet_test.go index d13a5a52..ad3083ec 100644 --- a/wallet_test.go +++ b/wallet_test.go @@ -208,7 +208,7 @@ func seedIdentity(t *testing.T, datadir string) { SignerPubKey: randomKey.PubKey(), ForfeitPubKey: randomKey.PubKey(), Network: arklib.BitcoinRegTest, - ExplorerURL: "http://127.0.0.1:3000", + ExplorerURL: "tcp://127.0.0.1:50000", }) require.NoError(t, err) }