mirror of
https://github.com/luxfi/bridge.git
synced 2026-09-24 00:26:46 +00:00
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.
852 lines
27 KiB
Go
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
|