Skip to content
2 changes: 1 addition & 1 deletion btc/batcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import (
var (
GuardianSpendWeight = 1 + 1 + 1 + 73 + 1 + 73 + 1 + 1 + 1 + 200 // TODO: update Guardian Script Size to the right number
SegwitSpendWeight = txsizes.RedeemP2WPKHInputWitnessWeight
DefaultAPITimeout = 5 * time.Second
DefaultAPITimeout = 300 * time.Second
)

var (
Expand Down
56 changes: 34 additions & 22 deletions btc/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -273,32 +273,44 @@ var _ = Describe("bitcoin client", func() {
})

Context("Test BitcoinRPCClient", func() {
bitcoinClient := btc.NewBitcoinClient("admin1", "123", "http://0.0.0.0:18443")
chainParams := chaincfg.RegressionNetParams
indexer := localnet.BTCIndexer()
feeEstimator := btc.NewFixFeeEstimator(16)
var (
bitcoinClient btc.BitcoinClient
chainParams chaincfg.Params
indexer btc.IndexerClient
feeEstimator btc.FeeEstimator
alice, bob btc.Wallet
send func(wallet btc.Wallet, to btcutil.Address, amount int64) (string, btc.Transaction)
)

BeforeEach(func() {
bitcoinClient = btc.NewBitcoinClient("admin1", "123", "http://0.0.0.0:18443")
chainParams = chaincfg.RegressionNetParams
indexer = localnet.BTCIndexer()
feeEstimator = btc.NewFixFeeEstimator(16)

newWallet := func() btc.Wallet {
privKey, err := btcec.NewPrivateKey()
Expect(err).To(BeNil())
wallet, err := btc.NewSimpleWallet(privKey, &chainParams, indexer, feeEstimator, btc.HighFee)
Expect(err).To(BeNil())
return wallet
}

newWallet := func() btc.Wallet {
privKey, err := btcec.NewPrivateKey()
Expect(err).To(BeNil())
wallet, err := btc.NewSimpleWallet(privKey, &chainParams, indexer, feeEstimator, btc.HighFee)
Expect(err).To(BeNil())
return wallet
}
alice = newWallet()
bob = newWallet()

alice := newWallet()
bob := newWallet()
_, err := localnet.FundBitcoin(alice.Address().EncodeAddress(), indexer)
Expect(err).To(BeNil())

_, err := localnet.FundBitcoin(alice.Address().EncodeAddress(), indexer)
Expect(err).To(BeNil())
send = func(wallet btc.Wallet, to btcutil.Address, amount int64) (string, btc.Transaction) {
txid, err := wallet.Send(context.Background(), []btc.SendRequest{{Amount: amount, To: to}}, nil, nil)
Expect(err).To(BeNil())
tx, _, err := wallet.Status(context.Background(), txid)
Expect(err).To(BeNil())
return txid, tx
}
})

send := func(wallet btc.Wallet, to btcutil.Address, amount int64) (string, btc.Transaction) {
txid, err := wallet.Send(context.Background(), []btc.SendRequest{{Amount: amount, To: to}}, nil, nil)
Expect(err).To(BeNil())
tx, _, err := wallet.Status(context.Background(), txid)
Expect(err).To(BeNil())
return txid, tx
}

It("should return mempool entry", func(ctx context.Context) {
// Helper to check mempool entry fee
Expand Down
61 changes: 53 additions & 8 deletions btc/indexer.go
Original file line number Diff line number Diff line change
Expand Up @@ -298,15 +298,28 @@ func (client *electrsIndexerClient) GetTxHex(ctx context.Context, txid string) (
}

func (client *electrsIndexerClient) GetTx(ctx context.Context, txid string) (Transaction, error) {
if _, ok := ctx.Deadline(); !ok {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, 60*time.Second)
defer cancel()
}

endpoint, err := url.JoinPath(client.url, "tx", txid)
if err != nil {
return Transaction{}, err
}

// Send the request
var tx Transaction
if err := retry(client.logger, ctx, client.retryInterval, func() error {
resp, err := http.Get(endpoint)
baseBackoff := 2 * time.Second
if err := exponentialBackoffRetry(client.logger, ctx, baseBackoff, func(t time.Duration) error {
reqCtx, reqCancel := context.WithTimeout(ctx, t)
defer reqCancel()

req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, endpoint, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
Expand All @@ -315,14 +328,16 @@ func (client *electrsIndexerClient) GetTx(ctx context.Context, txid string) (Tra
if resp.StatusCode != http.StatusOK {
errMsg, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("fail to read response from %s: %w", endpoint, err)
return NewNoRetryError(fmt.Errorf("failed to read response from %s: %w", endpoint, err))
}
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusBadRequest {
return NewNoRetryError(fmt.Errorf("GetTx: %s", errMsg))
}
return fmt.Errorf("GetTx : %v", string(errMsg))
return fmt.Errorf("GetTx: status %d: %s", resp.StatusCode, errMsg)
}

// Decode response
if err := json.NewDecoder(resp.Body).Decode(&tx); err != nil {
return fmt.Errorf("failed to decode UTXOs: %w", err)
return NewNoRetryError(fmt.Errorf("failed to decode transaction: %w", err))
}
return nil
}); err != nil {
Expand Down Expand Up @@ -433,7 +448,7 @@ func retry(logger *zap.Logger, ctx context.Context, dur time.Duration, f func()
return err
}

logger.Debug("retrying", zap.Any("error", err.Error()))
logger.Warn("retrying", zap.Any("error", err.Error()))
select {
case <-ctx.Done():
return fmt.Errorf("%v : %v", ctx.Err(), err)
Expand All @@ -444,6 +459,36 @@ func retry(logger *zap.Logger, ctx context.Context, dur time.Duration, f func()
return nil
}

// exponentialBackoffRetry runs f until it succeeds, ctx is done, or f returns
// a NoRetryError. Backoff doubles on each failure starting from base.
func exponentialBackoffRetry(logger *zap.Logger, ctx context.Context, base time.Duration, f func(t time.Duration) error) error {
backoff := base

for {
err := f(backoff)
if err == nil {
return nil
}

var e *NoRetryError
if errors.As(err, &e) {
return err
}

logger.Warn("retrying", zap.Error(err), zap.Duration("backoff", backoff))
timer := time.NewTimer(backoff)
select {
case <-ctx.Done():
if !timer.Stop() {
<-timer.C
}
return fmt.Errorf("%v : %w", ctx.Err(), err)
case <-timer.C:
backoff *= 2
}
}
}

type NoRetryError struct {
err error
}
Expand Down
82 changes: 82 additions & 0 deletions btc/indexer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import (
"encoding/hex"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"sync/atomic"
"time"

"github.com/btcsuite/btcd/btcec/v2"
Expand All @@ -13,12 +16,91 @@ import (
"github.com/catalogfi/blockchain/btc"
"github.com/catalogfi/blockchain/localnet"
"github.com/fatih/color"
"go.uber.org/zap"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

var _ = Describe("Indexer client", func() {
Context("electrs HTTP retry", func() {
It("retries with backoff until the indexer responds successfully", func() {
var attempts atomic.Int64
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := attempts.Add(1)
if r.URL.Path != "/blocks/tip/height" {
http.NotFound(w, r)
return
}
if n < 3 {
w.WriteHeader(http.StatusInternalServerError)
return
}
_, _ = w.Write([]byte("42"))
}))
defer srv.Close()

c := btc.NewElectrsIndexerClient(zap.NewNop(), srv.URL, 5*time.Millisecond)
tip, err := c.GetTipBlockHeight(context.Background())
Expect(err).To(BeNil())
Expect(tip).To(Equal(uint64(42)))
Expect(attempts.Load()).To(BeNumerically(">=", 3))
})

It("stops waiting and returns when the context is cancelled during backoff", func() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()

c := btc.NewElectrsIndexerClient(zap.NewNop(), srv.URL, time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
_, err := c.GetTipBlockHeight(ctx)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring(context.DeadlineExceeded.Error()))
})
})

Context("GetTx deadline guard", func() {
It("applies DefaultAPITimeout when context has no deadline", func() {
// Server returns a valid tx JSON immediately, so the test completes fast
// even though GetTx internally adds a 300s timeout.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"txid":"deadbeef","version":2,"locktime":0,"vin":[],"vout":[],"size":0,"weight":0,"fee":0,"status":{"confirmed":false}}`)
}))
defer srv.Close()

c := btc.NewElectrsIndexerClient(zap.NewNop(), srv.URL, 5*time.Millisecond)

// Pass context.Background() directly — no deadline.
// GetTx should add its own DefaultAPITimeout and succeed.
tx, err := c.GetTx(context.Background(), "deadbeef")
Expect(err).To(BeNil())
Expect(tx.TxID).To(Equal("deadbeef"))
})

It("does not double-wrap when context already carries a deadline", func() {
// Server returns a valid tx JSON on the first attempt.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"txid":"abc123","version":2,"locktime":0,"vin":[],"vout":[],"size":0,"weight":0,"fee":0,"status":{"confirmed":false}}`)
}))
defer srv.Close()

c := btc.NewElectrsIndexerClient(zap.NewNop(), srv.URL, 5*time.Millisecond)

// Caller already sets a tight deadline — GetTx should skip wrapping.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

tx, err := c.GetTx(ctx, "abc123")
Expect(err).To(BeNil())
Expect(tx.TxID).To(Equal("abc123"))
})
})

Context("When using electrs API", func() {
It("should be able to fetch the utxos of an address ", func() {
By("GetUTXOs()")
Expand Down
Loading
Loading