Files
venuswhispersandzeekay b24ee7f0a2 fix(bridge): BTC release confirmation gate + RBF rebuild on stale fee
Port of f3e25de from preserve/lux-bridge-golive onto main's
architecture. A BTC release now parks in
bridge_transfer_pending_confirmation on mempool admission and only
completes once mined; a release unconfirmed past --btc-confirm-timeout
(or fee-rejected at submit) is reset for re-sign at a bumped RBF
feerate — PreSign takes MinFeeRateSatPerVB as a floor over the live
estimate, and the paid rate persists on the swap (LastFeeRate) so each
rebuild strictly out-bids the last (BIP-125). Rebuild cycles share one
ceiling (default 5); past it the swap fails loudly. The broadcast
client answers confirmation status from the same mempool.space base it
posts to; /metrics gains bridge_btc_confirm_checks_total /
_timeouts_total / bridge_broadcast_rebuilds_total.
2026-08-07 22:19:29 -07:00

852 lines
27 KiB
Go

// btc_test.go: BTC transaction assembler tests.
//
// Coverage:
// - selectBTCUTXOs: greedy correctness, insufficient funds, fee scaling
// - p2wpkhScriptCode: well-formed input + rejects malformed scripts
// - chainHashFromTxID: display ↔ wire ordering round-trip
// - encodeDERSignature: low-s output, high-bit padding, malleability
// - ParseBTCRSV: hex variants accepted
// - PreSign: builds a wire tx that decodes, sighashes are 32 bytes,
// change output appears / disappears at the dust threshold
// - Finalize: produces a wire-deserializable raw tx with the correct
// witness layout
// - MempoolSpaceClient: UTXO list + fee feed + balance probe shape
// parsing against an httptest stub
package txassembler
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"math/big"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg"
"github.com/btcsuite/btcd/wire"
"golang.org/x/crypto/ripemd160"
)
// =============================================================================
// Fixtures
// =============================================================================
// A real-looking compressed pubkey for use in PreSign/Finalize tests.
// Derived from secp256k1 generator * 1 — pubkey-of-1 is 02 79be667e...
var sampleCompressedPubKey = mustHex("02" + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798")
// Helper: derive the matching bech32 P2WPKH for a compressed pubkey on
// mainnet. Used by tests that need a self-consistent (pubkey, address)
// pair.
func bech32AddressFor(t *testing.T, params *chaincfg.Params, compressed []byte) string {
t.Helper()
sha := sha256.Sum256(compressed)
ripemd := ripemd160.New()
ripemd.Write(sha[:])
pkh := ripemd.Sum(nil)
addr, err := btcutil.NewAddressWitnessPubKeyHash(pkh, params)
if err != nil {
t.Fatalf("derive address: %v", err)
}
return addr.EncodeAddress()
}
func mustHex(s string) []byte {
b, err := hex.DecodeString(s)
if err != nil {
panic(err)
}
return b
}
// A canonical 32-byte txid we can use as a fake prevout.
const sampleTxIDHex = "3a1b89b3a9e2d8f0c4b71d1a4e2c8f7b5d3c9a7e6f1b4a2c8d9e5f3a1b6c8d4e"
// =============================================================================
// stubBTCFetcher: returns a programmable UTXO set.
// =============================================================================
type stubBTCFetcher struct {
utxos map[string][]BTCUTXO // keyed by address
err error
lastNetw BTCNetwork
}
func (s *stubBTCFetcher) UTXOs(_ context.Context, n BTCNetwork, addr string) ([]BTCUTXO, error) {
s.lastNetw = n
if s.err != nil {
return nil, s.err
}
if list, ok := s.utxos[addr]; ok {
out := make([]BTCUTXO, len(list))
copy(out, list)
return out, nil
}
return nil, nil
}
type stubFeeEstimator struct {
rate int64
err error
}
func (s *stubFeeEstimator) FeeRateSatPerVB(_ context.Context, _ BTCNetwork) (int64, error) {
if s.err != nil {
return 0, s.err
}
return s.rate, nil
}
// =============================================================================
// selectBTCUTXOs
// =============================================================================
func TestSelectBTCUTXOs_GreedyLargestFirst(t *testing.T) {
utxos := []BTCUTXO{
{TxID: sampleTxIDHex, Vout: 0, Value: 10_000},
{TxID: sampleTxIDHex, Vout: 1, Value: 50_000},
{TxID: sampleTxIDHex, Vout: 2, Value: 30_000},
}
picked, change, fee, err := selectBTCUTXOs(utxos, 40_000, 5)
if err != nil {
t.Fatalf("selectBTCUTXOs: %v", err)
}
if len(picked) != 1 || picked[0].Value != 50_000 {
t.Errorf("expected single 50_000 pick, got %+v", picked)
}
if fee <= 0 {
t.Errorf("fee must be positive; got %d", fee)
}
if change <= 0 || change >= 10_000 {
t.Errorf("change should be 50000 - 40000 - fee ≈ ~%d-fee; got %d (fee=%d)", 10_000, change, fee)
}
}
func TestSelectBTCUTXOs_MultipleInputs(t *testing.T) {
utxos := []BTCUTXO{
{TxID: sampleTxIDHex, Vout: 0, Value: 5_000},
{TxID: sampleTxIDHex, Vout: 1, Value: 5_000},
{TxID: sampleTxIDHex, Vout: 2, Value: 5_000},
}
picked, _, _, err := selectBTCUTXOs(utxos, 12_000, 1)
if err != nil {
t.Fatalf("selectBTCUTXOs: %v", err)
}
if len(picked) < 3 {
t.Errorf("expected ≥3 inputs to cover 12_000 from 3x5_000, got %d", len(picked))
}
}
func TestSelectBTCUTXOs_InsufficientFunds(t *testing.T) {
utxos := []BTCUTXO{
{TxID: sampleTxIDHex, Vout: 0, Value: 1_000},
{TxID: sampleTxIDHex, Vout: 1, Value: 2_000},
}
_, _, _, err := selectBTCUTXOs(utxos, 100_000, 10)
if !errors.Is(err, ErrBTCInsufficientFunds) {
t.Errorf("expected ErrBTCInsufficientFunds, got %v", err)
}
}
func TestSelectBTCUTXOs_FeeScalesWithInputs(t *testing.T) {
// All same value, force multi-input selection — fee should grow.
utxos := []BTCUTXO{
{TxID: sampleTxIDHex, Vout: 0, Value: 10_000},
{TxID: sampleTxIDHex, Vout: 1, Value: 10_000},
}
_, _, feeMulti, err := selectBTCUTXOs(utxos, 15_000, 5)
if err != nil {
t.Fatalf("multi: %v", err)
}
// Single-input version: bigger UTXO that fits in one.
singleUtxos := []BTCUTXO{{TxID: sampleTxIDHex, Vout: 0, Value: 20_000}}
_, _, feeSingle, err := selectBTCUTXOs(singleUtxos, 15_000, 5)
if err != nil {
t.Fatalf("single: %v", err)
}
if feeMulti <= feeSingle {
t.Errorf("fee for 2 inputs (%d) should exceed fee for 1 input (%d)", feeMulti, feeSingle)
}
}
func TestSelectBTCUTXOs_RejectsBadInputs(t *testing.T) {
if _, _, _, err := selectBTCUTXOs(nil, 0, 5); err == nil {
t.Error("expected error for value=0")
}
if _, _, _, err := selectBTCUTXOs(nil, 100, 0); err == nil {
t.Error("expected error for feeRate=0")
}
}
// =============================================================================
// p2wpkhScriptCode
// =============================================================================
func TestP2WPKHScriptCode_WellFormed(t *testing.T) {
pkScript := append([]byte{0x00, 0x14}, bytes.Repeat([]byte{0x11}, 20)...)
got, err := p2wpkhScriptCode(pkScript)
if err != nil {
t.Fatalf("p2wpkhScriptCode: %v", err)
}
// Expect: OP_DUP OP_HASH160 PUSH20 <20 bytes> OP_EQUALVERIFY OP_CHECKSIG
want := []byte{0x76, 0xa9, 0x14}
want = append(want, bytes.Repeat([]byte{0x11}, 20)...)
want = append(want, 0x88, 0xac)
if !bytes.Equal(got, want) {
t.Errorf("scriptCode = %x, want %x", got, want)
}
}
func TestP2WPKHScriptCode_RejectsNonP2WPKH(t *testing.T) {
cases := [][]byte{
nil,
{0x00, 0x14}, // missing PKH
{0x51, 0x14}, // wrong opcode (OP_1)
bytes.Repeat([]byte{0x00}, 22), // wrong push len marker
}
for i, c := range cases {
if _, err := p2wpkhScriptCode(c); err == nil {
t.Errorf("case %d: expected error for malformed script", i)
}
}
}
// =============================================================================
// chainHashFromTxID
// =============================================================================
func TestChainHashFromTxID_RoundTrip(t *testing.T) {
h, err := chainHashFromTxID(sampleTxIDHex)
if err != nil {
t.Fatalf("chainHashFromTxID: %v", err)
}
// chainhash.Hash.String() reverses internally for display — so
// String() should give us back the original display form.
if h.String() != sampleTxIDHex {
t.Errorf("round trip: got %q, want %q", h.String(), sampleTxIDHex)
}
}
func TestChainHashFromTxID_RejectsBadLength(t *testing.T) {
if _, err := chainHashFromTxID("dead"); err == nil {
t.Error("expected error for short txid")
}
if _, err := chainHashFromTxID("notvalid"); err == nil {
t.Error("expected error for non-hex")
}
}
func TestChainHashFromTxID_TrimsZeroXPrefix(t *testing.T) {
if _, err := chainHashFromTxID("0x" + sampleTxIDHex); err != nil {
t.Errorf("expected 0x prefix to be tolerated, got %v", err)
}
}
// =============================================================================
// encodeDERSignature
// =============================================================================
func TestEncodeDERSignature_Basic(t *testing.T) {
r, _ := new(big.Int).SetString("01", 16)
s, _ := new(big.Int).SetString("02", 16)
der, err := encodeDERSignature(r, s)
if err != nil {
t.Fatalf("encodeDERSignature: %v", err)
}
// Expected: 30 06 02 01 01 02 01 02
want := []byte{0x30, 0x06, 0x02, 0x01, 0x01, 0x02, 0x01, 0x02}
if !bytes.Equal(der, want) {
t.Errorf("der = %x, want %x", der, want)
}
}
func TestEncodeDERSignature_HighBitPadding(t *testing.T) {
// 0x80 has the high bit set; DER must prepend 0x00 to keep the
// INTEGER positive.
r, _ := new(big.Int).SetString("80", 16)
s, _ := new(big.Int).SetString("01", 16)
der, err := encodeDERSignature(r, s)
if err != nil {
t.Fatalf("encodeDERSignature: %v", err)
}
// SEQUENCE 0x30 LEN 02 02 00 80 02 01 01 = 9 bytes body, total 11.
want := []byte{0x30, 0x07, 0x02, 0x02, 0x00, 0x80, 0x02, 0x01, 0x01}
if !bytes.Equal(der, want) {
t.Errorf("der = %x, want %x", der, want)
}
}
func TestEncodeDERSignature_RejectsZero(t *testing.T) {
if _, err := encodeDERSignature(big.NewInt(0), big.NewInt(1)); err == nil {
t.Error("expected error for r=0")
}
if _, err := encodeDERSignature(big.NewInt(1), big.NewInt(0)); err == nil {
t.Error("expected error for s=0")
}
if _, err := encodeDERSignature(nil, big.NewInt(1)); err == nil {
t.Error("expected error for nil r")
}
}
// =============================================================================
// ParseBTCRSV
// =============================================================================
func TestParseBTCRSV_VariousFormats(t *testing.T) {
cases := []struct {
name string
rHex, sHex string
wantRWidth int
wantSWidth int
wantErr bool
}{
{"plain", "0123", "abcd", 0, 0, false},
{"prefixed", "0x0123", "0xabcd", 0, 0, false},
{"empty", "", "abcd", 0, 0, true},
{"junk", "zzz", "abcd", 0, 0, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := ParseBTCRSV(tc.rHex, tc.sHex)
if tc.wantErr {
if err == nil {
t.Errorf("expected error for %s", tc.name)
}
return
}
if err != nil {
t.Fatalf("unexpected: %v", err)
}
if got == nil || got.R == nil || got.S == nil {
t.Fatal("nil result")
}
})
}
}
func TestParseBTCRSV_Padding(t *testing.T) {
// A short r hex string is left-padded to 32 bytes worth before
// parsing. Confirm via comparison to manual big.Int.
got, err := ParseBTCRSV("01", "ff")
if err != nil {
t.Fatal(err)
}
if got.R.Cmp(big.NewInt(1)) != 0 {
t.Errorf("R = %s, want 1", got.R)
}
if got.S.Cmp(big.NewInt(0xff)) != 0 {
t.Errorf("S = %s, want 255", got.S)
}
}
// =============================================================================
// PreSign — happy path
// =============================================================================
func TestBTCAssembler_PreSign_HappyPath(t *testing.T) {
params := &chaincfg.MainNetParams
addr := bech32AddressFor(t, params, sampleCompressedPubKey)
// Recipient: same pubkey for simplicity (still a valid distinct
// hash chain entry).
to := addr
fetch := &stubBTCFetcher{utxos: map[string][]BTCUTXO{
addr: {
{TxID: sampleTxIDHex, Vout: 0, Value: 100_000, BlockHeight: 800_000},
},
}}
asm := NewBTCAssembler(BTCMainnet, fetch, &stubFeeEstimator{rate: 5})
sighashes, unsigned, err := asm.PreSign(context.Background(), BTCSpec{
FromAddress: addr,
FromPubKey: sampleCompressedPubKey,
ToAddress: to,
ValueSat: 50_000,
})
if err != nil {
t.Fatalf("PreSign: %v", err)
}
if len(sighashes) != 1 {
t.Fatalf("expected 1 sighash, got %d", len(sighashes))
}
if len(sighashes[0]) != 32 {
t.Errorf("sighash must be 32 bytes; got %d", len(sighashes[0]))
}
if unsigned == nil || unsigned.Tx == nil {
t.Fatal("unsigned tx is nil")
}
if len(unsigned.Tx.TxIn) != 1 {
t.Errorf("expected 1 input, got %d", len(unsigned.Tx.TxIn))
}
// 2 outputs: recipient + change (change is well above dust).
if len(unsigned.Tx.TxOut) != 2 {
t.Errorf("expected 2 outputs (recipient + change), got %d", len(unsigned.Tx.TxOut))
}
if unsigned.FeeSat <= 0 {
t.Errorf("FeeSat must be positive; got %d", unsigned.FeeSat)
}
if unsigned.FeeRateSatPerVB != 5 {
t.Errorf("FeeRateSatPerVB = %d, want 5", unsigned.FeeRateSatPerVB)
}
}
func TestBTCAssembler_PreSign_DropsChangeBelowDust(t *testing.T) {
params := &chaincfg.MainNetParams
addr := bech32AddressFor(t, params, sampleCompressedPubKey)
// Tight UTXO: value + fee leaves <546 sat in change.
fetch := &stubBTCFetcher{utxos: map[string][]BTCUTXO{
addr: {
// At feeRate=1 sat/vB and 1 input, 2 outputs, vsize ≈ 141 vB
// → fee ≈ 141 sat. Pick 50_000 + 141 + 100 (dust) = 50_241.
// Then change = 50_241 - 50_000 - 141 = 100 (< 546 dust).
{TxID: sampleTxIDHex, Vout: 0, Value: 50_241},
},
}}
asm := NewBTCAssembler(BTCMainnet, fetch, &stubFeeEstimator{rate: 1})
_, unsigned, err := asm.PreSign(context.Background(), BTCSpec{
FromAddress: addr,
FromPubKey: sampleCompressedPubKey,
ToAddress: addr,
ValueSat: 50_000,
})
if err != nil {
t.Fatalf("PreSign: %v", err)
}
// Either 1 (no change) or 2 (with change) outputs are valid
// depending on exact UTXO arithmetic; assert it's NOT 2 here only
// when change is below dust. We constructed values so change should
// fall under 546.
if len(unsigned.Tx.TxOut) != 1 {
t.Errorf("expected dust-change to be dropped (1 output), got %d", len(unsigned.Tx.TxOut))
}
}
func TestBTCAssembler_PreSign_InsufficientFundsBubbles(t *testing.T) {
params := &chaincfg.MainNetParams
addr := bech32AddressFor(t, params, sampleCompressedPubKey)
fetch := &stubBTCFetcher{utxos: map[string][]BTCUTXO{
addr: {{TxID: sampleTxIDHex, Vout: 0, Value: 1_000}},
}}
asm := NewBTCAssembler(BTCMainnet, fetch, &stubFeeEstimator{rate: 5})
_, _, err := asm.PreSign(context.Background(), BTCSpec{
FromAddress: addr,
FromPubKey: sampleCompressedPubKey,
ToAddress: addr,
ValueSat: 50_000,
})
if !errors.Is(err, ErrBTCInsufficientFunds) {
t.Errorf("expected ErrBTCInsufficientFunds, got %v", err)
}
}
func TestBTCAssembler_PreSign_ValidatesPubKeyLength(t *testing.T) {
params := &chaincfg.MainNetParams
addr := bech32AddressFor(t, params, sampleCompressedPubKey)
fetch := &stubBTCFetcher{utxos: map[string][]BTCUTXO{
addr: {{TxID: sampleTxIDHex, Vout: 0, Value: 100_000}},
}}
asm := NewBTCAssembler(BTCMainnet, fetch, &stubFeeEstimator{rate: 5})
_, _, err := asm.PreSign(context.Background(), BTCSpec{
FromAddress: addr,
FromPubKey: []byte{0x02},
ToAddress: addr,
ValueSat: 50_000,
})
if err == nil || !strings.Contains(err.Error(), "FromPubKey") {
t.Errorf("expected FromPubKey length error, got %v", err)
}
}
func TestBTCAssembler_PreSign_RejectsLegacyAddress(t *testing.T) {
// A P2PKH "1..." address should be rejected — bridge only supports
// P2WPKH bech32 v0.
fetch := &stubBTCFetcher{}
asm := NewBTCAssembler(BTCMainnet, fetch, &stubFeeEstimator{rate: 5})
_, _, err := asm.PreSign(context.Background(), BTCSpec{
FromAddress: "1BoatSLRHtKNngkdXEeobR76b53LETtpyT", // famous donation address
FromPubKey: sampleCompressedPubKey,
ToAddress: bech32AddressFor(t, &chaincfg.MainNetParams, sampleCompressedPubKey),
ValueSat: 50_000,
})
if err == nil || !strings.Contains(err.Error(), "P2WPKH") {
t.Errorf("expected P2WPKH validation error, got %v", err)
}
}
func TestBTCAssembler_PreSign_FeeRateOverride(t *testing.T) {
params := &chaincfg.MainNetParams
addr := bech32AddressFor(t, params, sampleCompressedPubKey)
fetch := &stubBTCFetcher{utxos: map[string][]BTCUTXO{
addr: {{TxID: sampleTxIDHex, Vout: 0, Value: 100_000}},
}}
// Estimator returns 5, but spec.FeeRateSatPerVB=10 should override.
asm := NewBTCAssembler(BTCMainnet, fetch, &stubFeeEstimator{rate: 5})
_, unsigned, err := asm.PreSign(context.Background(), BTCSpec{
FromAddress: addr,
FromPubKey: sampleCompressedPubKey,
ToAddress: addr,
ValueSat: 50_000,
FeeRateSatPerVB: 10,
})
if err != nil {
t.Fatal(err)
}
if unsigned.FeeRateSatPerVB != 10 {
t.Errorf("expected override to win; got %d", unsigned.FeeRateSatPerVB)
}
}
// The RBF floor wins over a LOWER live estimate: a rebuild must
// strictly out-bid the stuck tx it replaces (BIP-125), even when the
// fee market has since relaxed.
func TestBTCAssembler_PreSign_MinFeeRateFloorApplied(t *testing.T) {
params := &chaincfg.MainNetParams
addr := bech32AddressFor(t, params, sampleCompressedPubKey)
fetch := &stubBTCFetcher{utxos: map[string][]BTCUTXO{
addr: {{TxID: sampleTxIDHex, Vout: 0, Value: 100_000}},
}}
// Live estimate 2 sat/vB, but the prior attempt's bumped floor is 10.
asm := NewBTCAssembler(BTCMainnet, fetch, &stubFeeEstimator{rate: 2})
_, unsigned, err := asm.PreSign(context.Background(), BTCSpec{
FromAddress: addr,
FromPubKey: sampleCompressedPubKey,
ToAddress: addr,
ValueSat: 50_000,
MinFeeRateSatPerVB: 10,
})
if err != nil {
t.Fatal(err)
}
if unsigned.FeeRateSatPerVB != 10 {
t.Errorf("FeeRateSatPerVB = %d, want 10 (floor wins over estimate 2)", unsigned.FeeRateSatPerVB)
}
}
// A floor BELOW the live estimate must not lower the rate — the floor
// only ever raises.
func TestBTCAssembler_PreSign_MinFeeRateBelowEstimateIgnored(t *testing.T) {
params := &chaincfg.MainNetParams
addr := bech32AddressFor(t, params, sampleCompressedPubKey)
fetch := &stubBTCFetcher{utxos: map[string][]BTCUTXO{
addr: {{TxID: sampleTxIDHex, Vout: 0, Value: 100_000}},
}}
asm := NewBTCAssembler(BTCMainnet, fetch, &stubFeeEstimator{rate: 7})
_, unsigned, err := asm.PreSign(context.Background(), BTCSpec{
FromAddress: addr,
FromPubKey: sampleCompressedPubKey,
ToAddress: addr,
ValueSat: 50_000,
MinFeeRateSatPerVB: 3,
})
if err != nil {
t.Fatal(err)
}
if unsigned.FeeRateSatPerVB != 7 {
t.Errorf("FeeRateSatPerVB = %d, want 7 (live estimate above floor wins)", unsigned.FeeRateSatPerVB)
}
}
// =============================================================================
// Finalize — happy path + serialization
// =============================================================================
func TestBTCAssembler_Finalize_ProducesValidSerializedTx(t *testing.T) {
params := &chaincfg.MainNetParams
addr := bech32AddressFor(t, params, sampleCompressedPubKey)
fetch := &stubBTCFetcher{utxos: map[string][]BTCUTXO{
addr: {{TxID: sampleTxIDHex, Vout: 0, Value: 100_000}},
}}
asm := NewBTCAssembler(BTCMainnet, fetch, &stubFeeEstimator{rate: 5})
_, unsigned, err := asm.PreSign(context.Background(), BTCSpec{
FromAddress: addr,
FromPubKey: sampleCompressedPubKey,
ToAddress: addr,
ValueSat: 50_000,
})
if err != nil {
t.Fatalf("PreSign: %v", err)
}
// Synthesize a signature. We don't actually need a valid ECDSA
// signature for the test — Finalize just packages the bytes; the
// chain will reject invalid sigs at broadcast time but the wire
// format is well-defined regardless.
sigs := make([]BTCECDSASig, len(unsigned.Tx.TxIn))
for i := range sigs {
sigs[i] = BTCECDSASig{
R: new(big.Int).SetUint64(1),
S: new(big.Int).SetUint64(2),
}
}
raw, txid, err := asm.Finalize(unsigned, sigs)
if err != nil {
t.Fatalf("Finalize: %v", err)
}
if len(raw) == 0 {
t.Error("raw tx is empty")
}
if len(txid) != 64 {
t.Errorf("txid should be 64 hex chars, got %q (%d)", txid, len(txid))
}
// Sanity-decode with wire.MsgTx.Deserialize — confirms it's a
// well-formed BTC transaction.
var decoded wire.MsgTx
if err := decoded.Deserialize(bytes.NewReader(raw)); err != nil {
t.Fatalf("wire.MsgTx.Deserialize failed: %v", err)
}
if len(decoded.TxIn) != 1 {
t.Errorf("decoded has %d inputs, want 1", len(decoded.TxIn))
}
// Each input should now carry a witness of length 2 (DER sig + pubkey).
for i, in := range decoded.TxIn {
if len(in.Witness) != 2 {
t.Errorf("input %d: witness len = %d, want 2", i, len(in.Witness))
}
if !bytes.Equal(in.Witness[1], sampleCompressedPubKey) {
t.Errorf("input %d: witness pubkey = %x, want %x", i, in.Witness[1], sampleCompressedPubKey)
}
// witness[0] = DER || SIGHASH_ALL (0x01)
if len(in.Witness[0]) < 8 {
t.Errorf("input %d: DER sig too short: %x", i, in.Witness[0])
}
if in.Witness[0][len(in.Witness[0])-1] != 0x01 {
t.Errorf("input %d: last witness byte should be SIGHASH_ALL=0x01; got 0x%x",
i, in.Witness[0][len(in.Witness[0])-1])
}
}
}
func TestBTCAssembler_Finalize_SignatureCountMismatch(t *testing.T) {
asm := NewBTCAssembler(BTCMainnet, &stubBTCFetcher{}, nil)
// Build a minimal unsigned with 1 input.
tx := wire.NewMsgTx(2)
h, _ := chainHashFromTxID(sampleTxIDHex)
tx.AddTxIn(wire.NewTxIn(wire.NewOutPoint(h, 0), nil, nil))
unsigned := &BTCUnsigned{
Tx: tx,
Inputs: []BTCInputCtx{{
TxID: sampleTxIDHex,
Vout: 0,
PrevValue: 1000,
PKScript: append([]byte{0x00, 0x14}, bytes.Repeat([]byte{0x11}, 20)...),
PubKey: sampleCompressedPubKey,
}},
}
// Pass 0 sigs for 1 input.
if _, _, err := asm.Finalize(unsigned, nil); err == nil {
t.Error("expected error for sig count mismatch")
}
// Pass 2 sigs for 1 input.
if _, _, err := asm.Finalize(unsigned, []BTCECDSASig{
{R: big.NewInt(1), S: big.NewInt(1)},
{R: big.NewInt(1), S: big.NewInt(1)},
}); err == nil {
t.Error("expected error for sig count mismatch")
}
}
func TestBTCAssembler_Finalize_NilSignatureFields(t *testing.T) {
asm := NewBTCAssembler(BTCMainnet, &stubBTCFetcher{}, nil)
tx := wire.NewMsgTx(2)
h, _ := chainHashFromTxID(sampleTxIDHex)
tx.AddTxIn(wire.NewTxIn(wire.NewOutPoint(h, 0), nil, nil))
unsigned := &BTCUnsigned{
Tx: tx,
Inputs: []BTCInputCtx{{
TxID: sampleTxIDHex, Vout: 0, PrevValue: 1000,
PKScript: append([]byte{0x00, 0x14}, bytes.Repeat([]byte{0x11}, 20)...),
PubKey: sampleCompressedPubKey,
}},
}
if _, _, err := asm.Finalize(unsigned, []BTCECDSASig{{R: nil, S: big.NewInt(1)}}); err == nil {
t.Error("expected error for nil r")
}
}
// =============================================================================
// MempoolSpaceClient
// =============================================================================
func TestMempoolSpaceClient_UTXOs(t *testing.T) {
utxos := []map[string]any{
{
"txid": sampleTxIDHex,
"vout": 0,
"value": 100_000,
"status": map[string]any{
"confirmed": true,
"block_height": 800_000,
},
},
// One unconfirmed — should be filtered out.
{
"txid": sampleTxIDHex,
"vout": 1,
"value": 9_999,
"status": map[string]any{
"confirmed": false,
"block_height": 0,
},
},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.URL.Path, "/address/") || !strings.HasSuffix(r.URL.Path, "/utxo") {
http.Error(w, "wrong path", 404)
return
}
_ = json.NewEncoder(w).Encode(utxos)
}))
t.Cleanup(srv.Close)
c := &MempoolSpaceClient{MainnetURL: srv.URL, Timeout: time.Second, HTTPClient: srv.Client()}
got, err := c.UTXOs(context.Background(), BTCMainnet, "bc1qexample")
if err != nil {
t.Fatalf("UTXOs: %v", err)
}
if len(got) != 1 {
t.Errorf("expected 1 confirmed UTXO, got %d", len(got))
}
if got[0].Value != 100_000 {
t.Errorf("Value = %d, want 100000", got[0].Value)
}
}
func TestMempoolSpaceClient_FeeRate(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"fastestFee": 25.0,
"halfHourFee": 18.0,
"hourFee": 12.0,
"economyFee": 5.0,
"minimumFee": 1.0,
})
}))
t.Cleanup(srv.Close)
c := &MempoolSpaceClient{MainnetURL: srv.URL, Timeout: time.Second, HTTPClient: srv.Client()}
got, err := c.FeeRateSatPerVB(context.Background(), BTCMainnet)
if err != nil {
t.Fatalf("FeeRateSatPerVB: %v", err)
}
if got != 18 {
t.Errorf("expected halfHourFee=18, got %d", got)
}
}
func TestMempoolSpaceClient_FeeRate_FallbackToFastestWhenHalfHourZero(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"fastestFee": 30.0,
"halfHourFee": 0.0,
})
}))
t.Cleanup(srv.Close)
c := &MempoolSpaceClient{MainnetURL: srv.URL, Timeout: time.Second, HTTPClient: srv.Client()}
got, err := c.FeeRateSatPerVB(context.Background(), BTCMainnet)
if err != nil {
t.Fatalf("FeeRateSatPerVB: %v", err)
}
if got != 30 {
t.Errorf("expected fastestFee fallback=30, got %d", got)
}
}
func TestMempoolSpaceClient_Balance(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"chain_stats": map[string]any{
"funded_txo_sum": 500_000,
"spent_txo_sum": 300_000,
},
})
}))
t.Cleanup(srv.Close)
c := &MempoolSpaceClient{MainnetURL: srv.URL, Timeout: time.Second, HTTPClient: srv.Client()}
bal, err := c.BalanceSat(context.Background(), BTCMainnet, "bc1qexample")
if err != nil {
t.Fatalf("BalanceSat: %v", err)
}
if bal != 200_000 {
t.Errorf("expected balance 200_000, got %d", bal)
}
}
func TestMempoolSpaceClient_NetworkRouting(t *testing.T) {
var seenPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seenPath = r.URL.Path
_, _ = w.Write([]byte(`[]`))
}))
t.Cleanup(srv.Close)
c := &MempoolSpaceClient{
MainnetURL: srv.URL,
TestnetURL: srv.URL + "/testnet",
Timeout: time.Second,
HTTPClient: srv.Client(),
}
_, _ = c.UTXOs(context.Background(), BTCTestnet, "tb1qexample")
if !strings.HasPrefix(seenPath, "/testnet/") {
t.Errorf("expected testnet path prefix, got %q", seenPath)
}
}
// =============================================================================
// BTCNetwork.HRP + chainParams
// =============================================================================
func TestBTCNetwork_HRP(t *testing.T) {
if BTCMainnet.HRP() != "bc" {
t.Errorf("mainnet HRP = %q, want bc", BTCMainnet.HRP())
}
if BTCTestnet.HRP() != "tb" {
t.Errorf("testnet HRP = %q, want tb", BTCTestnet.HRP())
}
}
// =============================================================================
// Estimated vsize
// =============================================================================
func TestEstimatedP2WPKHVSize_Range(t *testing.T) {
// Single-input, single-output P2WPKH is ≈ 110 vB; 2-out ≈ 141 vB.
one := estimatedP2WPKHVSize(1, 1)
two := estimatedP2WPKHVSize(1, 2)
if one < 100 || one > 130 {
t.Errorf("1-in/1-out vsize estimate = %d, want ~110", one)
}
if two < 130 || two > 160 {
t.Errorf("1-in/2-out vsize estimate = %d, want ~141", two)
}
if two <= one {
t.Errorf("adding an output should grow vsize: 1-out=%d, 2-out=%d", one, two)
}
}
// =============================================================================
// Suppress unused-import warning for fmt
// =============================================================================
var _ = fmt.Sprintf