fix(bridge): BTC release confirmation gate + RBF rebuild on stale fee

Unlike every other family, BTC mempool acceptance isn't finality: a
release tx can be admitted at a fee too low for current conditions and
then never get mined (fee market rises, or the tx is evicted), while the
driver previously marked it Completed the instant broadcast succeeded.
A stuck tx also retried the identical signed blob forever, since the
reject reason (fee) is baked into the signature.

Add SwapStatusAwaitingConfirmation: BTC releases park here after
broadcast instead of completing immediately; a confirmer (wired from the
BTC provider in main.go, nil-safe so non-BTC/no-provider deploys keep
the old immediate-Completed path) polls until mined, then promotes to
Completed. isStaleBTCFee/handleStaleBTCFee mirror the existing
stale-XRP-sequence pattern to reset+re-sign on a node-level reject (min
relay fee / mempool min fee / insufficient fee for RBF).
handleBTCConfirmTimeout rebuilds a parked tx that's sat unconfirmed past
30min at a bumped feerate (+25%+1 via bumpBTCFeeRate, threaded through
PreSignBTC as MinFeeRate) so the replacement is a valid BIP-125
substitution (nSequence=0xfffffffd already signals RBF). Both rebuild
paths share the existing maxRebuilds ceiling and fall through to
refund_pending past it.

New tests cover the reject-path reset, timeout-path rebuild, both
maxRebuilds->refund transitions, the confirmer-nil legacy fallback, and
the FeeRate/MinFeeRate math in PreSignBTC. go build/vet/test green
across cmd/bridge and internal/txassembler.
This commit is contained in:
venuswhispers
2026-07-21 08:47:10 +02:00
parent 4979b46327
commit f3e25dea7d
8 changed files with 916 additions and 35 deletions
+345 -27
View File
@@ -11,6 +11,7 @@ import (
luxlog "github.com/luxfi/log"
"github.com/luxfi/bridge/internal/broadcast"
"github.com/luxfi/bridge/internal/mchain"
)
// broadcast_driver.go: background goroutine that pushes signed swaps
@@ -51,6 +52,18 @@ type Broadcaster interface {
Broadcast(ctx context.Context, network, rawTxHex string) (*broadcast.BroadcastResult, error)
}
// ConfirmationChecker reports whether a destination tx has been mined.
// Only the BTC release path uses it: Bitcoin's "broadcast accepted"
// (mempool admission) is not final, so a BTC swap parks in
// SwapStatusAwaitingConfirmation until this says confirmed (or the
// rebuild timeout fires). *btc.Provider's GetTxStatus is adapted to
// this in main.go. nil ⇒ the driver keeps the legacy behaviour of
// marking every broadcast Completed immediately (no BTC confirmation
// gate), so a deploy without a BTC confirmer never strands swaps.
type ConfirmationChecker interface {
ConfirmationStatus(ctx context.Context, network, txid string) (confirmed bool, err error)
}
// BroadcastDriver polls SwapStatusBroadcasting swaps and pushes them
// to the destination chain. Concurrency-safe.
type BroadcastDriver struct {
@@ -72,15 +85,29 @@ type BroadcastDriver struct {
// disables the cap (legacy behaviour).
maxRebuilds int
// confirmer gates the BTC release path: a BTC tx parks in
// SwapStatusAwaitingConfirmation and is promoted to Completed only
// when this reports it mined. nil ⇒ legacy immediate-Completed for
// every family (no BTC confirmation gate). Wired in main.go from the
// BTC provider; left nil in non-BTC deploys and most unit tests.
confirmer ConfirmationChecker
// btcConfirmTimeout is how long a BTC release may sit unconfirmed in
// the mempool before the watcher rebuilds it with a higher (RBF)
// feerate. Measured from Swap.BroadcastAt and reset on each rebuild.
btcConfirmTimeout time.Duration
running atomic.Bool
ticks atomic.Uint64
attempts atomic.Uint64
successes atomic.Uint64
failures atomic.Uint64
skippedNoRawTx atomic.Uint64
rebuilds atomic.Uint64
listErrors atomic.Uint64
ticks atomic.Uint64
attempts atomic.Uint64
successes atomic.Uint64
failures atomic.Uint64
skippedNoRawTx atomic.Uint64
rebuilds atomic.Uint64
listErrors atomic.Uint64
confirmChecks atomic.Uint64
confirmTimeouts atomic.Uint64
stopOnce sync.Once
cancelRunning context.CancelFunc
@@ -104,6 +131,15 @@ const DefaultPerBroadcastTimeout = 15 * time.Second
// signal if we still can't land a tx after that.
const DefaultBroadcastMaxRebuilds = 5
// DefaultBTCConfirmationTimeout is how long a BTC release tx may sit
// unconfirmed in the mempool before the watcher rebuilds it at a higher
// (RBF) feerate. 30 min ≈ 3 expected blocks: long enough that a tx with
// a sane fee confirms normally, short enough to react before a user
// gives up. Each rebuild bumps the feerate and restarts this clock; the
// shared maxRebuilds cap eventually routes a persistently stuck swap to
// refund rather than spinning forever.
const DefaultBTCConfirmationTimeout = 30 * time.Minute
// NewBroadcastDriver constructs a driver with sensible defaults.
func NewBroadcastDriver(store SwapStore, bcaster Broadcaster, interval time.Duration, logger luxlog.Logger) *BroadcastDriver {
if interval <= 0 {
@@ -116,6 +152,21 @@ func NewBroadcastDriver(store SwapStore, bcaster Broadcaster, interval time.Dura
logger: logger,
perBroadcastTimeout: DefaultPerBroadcastTimeout,
maxRebuilds: DefaultBroadcastMaxRebuilds,
btcConfirmTimeout: DefaultBTCConfirmationTimeout,
}
}
// SetConfirmer attaches the BTC confirmation checker. When set, BTC
// releases gate through SwapStatusAwaitingConfirmation instead of
// completing the instant the mempool accepts them. No-op for other
// families. Wired in main.go from the BTC provider.
func (d *BroadcastDriver) SetConfirmer(c ConfirmationChecker) { d.confirmer = c }
// SetBTCConfirmTimeout overrides the unconfirmed-mempool timeout before
// an RBF rebuild. Primarily for tests; production uses the default.
func (d *BroadcastDriver) SetBTCConfirmTimeout(t time.Duration) {
if t > 0 {
d.btcConfirmTimeout = t
}
}
@@ -124,25 +175,29 @@ func (d *BroadcastDriver) Running() bool { return d.running.Load() }
// BroadcastDriverStats is a point-in-time view of the driver's counters.
type BroadcastDriverStats struct {
Ticks uint64 `json:"ticks"`
Attempts uint64 `json:"attempts"`
Successes uint64 `json:"successes"`
Failures uint64 `json:"failures"`
SkippedNoRawTx uint64 `json:"skipped_no_raw_tx"`
Rebuilds uint64 `json:"rebuilds"`
ListErrors uint64 `json:"list_errors"`
Ticks uint64 `json:"ticks"`
Attempts uint64 `json:"attempts"`
Successes uint64 `json:"successes"`
Failures uint64 `json:"failures"`
SkippedNoRawTx uint64 `json:"skipped_no_raw_tx"`
Rebuilds uint64 `json:"rebuilds"`
ListErrors uint64 `json:"list_errors"`
ConfirmChecks uint64 `json:"confirm_checks"`
ConfirmTimeouts uint64 `json:"confirm_timeouts"`
}
// Stats snapshots the counters. Safe for concurrent reads.
func (d *BroadcastDriver) Stats() BroadcastDriverStats {
return BroadcastDriverStats{
Ticks: d.ticks.Load(),
Attempts: d.attempts.Load(),
Successes: d.successes.Load(),
Failures: d.failures.Load(),
SkippedNoRawTx: d.skippedNoRawTx.Load(),
Rebuilds: d.rebuilds.Load(),
ListErrors: d.listErrors.Load(),
Ticks: d.ticks.Load(),
Attempts: d.attempts.Load(),
Successes: d.successes.Load(),
Failures: d.failures.Load(),
SkippedNoRawTx: d.skippedNoRawTx.Load(),
Rebuilds: d.rebuilds.Load(),
ListErrors: d.listErrors.Load(),
ConfirmChecks: d.confirmChecks.Load(),
ConfirmTimeouts: d.confirmTimeouts.Load(),
}
}
@@ -203,19 +258,39 @@ func (d *BroadcastDriver) tick(ctx context.Context) {
if d.logger != nil {
d.logger.Warn("broadcast driver: list broadcasting swaps", "err", err)
}
// Don't return — the broadcasting list failing shouldn't starve
// the confirmation pass below (independent state, independent RPC).
} else {
if len(swaps) > 0 && d.logger != nil {
d.logger.Debug("broadcast driver tick", "broadcasting", len(swaps))
}
for _, sw := range swaps {
if ctx.Err() != nil {
return
}
d.broadcastOne(ctx, sw)
}
}
// Second pass: BTC releases parked awaiting on-chain confirmation.
// Only the BTC path ever produces this state, and only when a
// confirmer is wired — skip the list query entirely otherwise.
if d.confirmer == nil {
return
}
if len(swaps) == 0 {
pending, err := d.store.List(ctx, SwapFilter{Status: SwapStatusAwaitingConfirmation})
if err != nil {
d.listErrors.Add(1)
if d.logger != nil {
d.logger.Warn("broadcast driver: list awaiting-confirmation swaps", "err", err)
}
return
}
if d.logger != nil {
d.logger.Debug("broadcast driver tick", "broadcasting", len(swaps))
}
for _, sw := range swaps {
for _, sw := range pending {
if ctx.Err() != nil {
return
}
d.broadcastOne(ctx, sw)
d.confirmOne(ctx, sw)
}
}
@@ -284,6 +359,17 @@ func (d *BroadcastDriver) broadcastOne(ctx context.Context, sw *Swap) {
d.handleStaleXRPSequence(ctx, sw)
return
}
// BTC equivalent: bitcoind / mempool.space rejected the release tx
// because its feerate is below the relay or mempool floor (or an
// RBF bump didn't beat the tx it replaces). The fee is baked into
// the signed tx, so retrying the same blob is rejected identically
// forever — reset so PreSignBTC re-quotes a fresh, higher feerate
// and re-signs. RBF (nSequence=0xfffffffd, internal/btc/payment.go)
// makes the replacement a valid BIP-125 substitution.
if isStaleBTCFee(err) {
d.handleStaleBTCFee(ctx, sw)
return
}
// Surface the error so the SDK/UI can show the user what's
// blocking (e.g. "insufficient funds for gas" — the release
@@ -305,6 +391,41 @@ func (d *BroadcastDriver) broadcastOne(ctx context.Context, sw *Swap) {
return
}
// BTC is the one family where mempool admission is NOT final: a
// low-fee tx is accepted then may never confirm (fee market rises or
// the tx is evicted). Park it in awaiting_confirmation and let the
// watcher promote it to Completed once it's mined — but only when a
// confirmer is wired, otherwise fall through to immediate-Completed
// so the swap can't strand. Every other family completes on accept.
if d.confirmer != nil && destIsBTC(sw.DestinationNetwork) {
patched, perr := d.store.Patch(ctx, sw.ID, func(s *Swap) {
if s.Status != SwapStatusBroadcasting {
return
}
s.DestTxHash = res.TxHash
s.Status = SwapStatusAwaitingConfirmation
s.BroadcastAt = time.Now().UTC()
s.LastError = "" // clear: tx accepted, now awaiting a block.
s.LastErrorAt = time.Time{}
})
if perr != nil {
d.failures.Add(1)
if d.logger != nil {
d.logger.Warn("persist DestTxHash (btc awaiting confirmation)",
"swap_id", sw.ID, "err", perr)
}
return
}
if d.logger != nil && patched != nil {
d.logger.Info("btc release accepted → awaiting confirmation",
"swap_id", sw.ID,
"network", sw.DestinationNetwork,
"tx_hash", res.TxHash,
)
}
return
}
_, err = d.store.Patch(ctx, sw.ID, func(s *Swap) {
if s.Status != SwapStatusBroadcasting {
return
@@ -472,6 +593,203 @@ func (d *BroadcastDriver) handleStaleXRPSequence(ctx context.Context, sw *Swap)
}
}
// isStaleBTCFee matches the bitcoind / mempool.space rejections that mean
// the release tx's fee is too low for the node to accept it right now:
//
// "min relay fee not met, A < B" — below the node's static minrelaytxfee
// "mempool min fee not met, A < B" — below the dynamic mempool floor (mempool full)
// "insufficient fee, rejecting replacement …" — an RBF fee-bump that didn't
// beat the tx it replaces by enough (BIP-125)
//
// Unlike Solana / TON / XRP, a too-low BTC fee is usually ACCEPTED into the
// mempool (no error — the tx just sits unconfirmed); these strings are the
// subset of fee problems the node rejects at submit time. The feerate is baked
// into the signed tx, so retrying the SAME DestRawTx is futile — it gets
// rejected identically forever. Resetting for re-assembly fixes it: PreSignBTC
// re-quotes mempool.space's current recommended feerate and re-selects the
// still-unconfirmed UTXO, so the rebuilt tx carries a fresh, higher fee. RBF is
// always signalled (nSequence = 0xfffffffd, internal/btc/payment.go) so the
// replacement is a valid BIP-125 substitution of the prior attempt.
//
// "insufficient funds" (an unfunded release wallet) is deliberately NOT matched
// here — humanizeBroadcastErr handles that, keeping the swap at broadcasting and
// telling the operator to fund the address. Rebuilding can't help, and the geth
// phrase "insufficient funds" never contains the substring "insufficient fee".
func isStaleBTCFee(err error) bool {
if err == nil {
return false
}
low := strings.ToLower(err.Error())
return strings.Contains(low, "min relay fee not met") ||
strings.Contains(low, "mempool min fee not met") ||
strings.Contains(low, "insufficient fee")
}
// handleStaleBTCFee mirrors handleStaleXRPSequence for Bitcoin. Same rebuild
// ceiling + refund fallback: if the node keeps rejecting our feerate past
// maxRebuilds — a persistently overfull mempool, or a release wallet whose only
// confirmed UTXO is too small to carry a relayable fee — route to refund rather
// than spin forever. Otherwise reset to bridge_transfer_pending and clear the
// assembled tx + sign artifacts so the signing driver re-quotes the fee and
// re-signs on its next tick.
func (d *BroadcastDriver) handleStaleBTCFee(ctx context.Context, sw *Swap) {
patched, _ := d.store.Patch(ctx, sw.ID, func(s *Swap) {
if s.Status != SwapStatusBroadcasting {
return
}
s.BroadcastRebuilds++
if d.maxRebuilds > 0 && s.BroadcastRebuilds >= d.maxRebuilds {
s.Status = SwapStatusRefundPending
s.LastError = fmt.Sprintf(
"Bitcoin node rejected %d successive broadcasts (fee below relay / mempool floor) — routing to refund.",
s.BroadcastRebuilds,
)
s.LastErrorAt = time.Now().UTC()
return
}
s.Status = SwapStatusBridgeTransferPending
s.DestRawTx = ""
s.Signature = ""
s.MPCSessionID = ""
s.DestTxHash = ""
s.LastError = "Bitcoin fee below relay floor — rebuilding with a fresh feerate"
s.LastErrorAt = time.Now().UTC()
})
d.rebuilds.Add(1)
if d.logger != nil && patched != nil {
if patched.Status == SwapStatusRefundPending {
d.logger.Warn("btc broadcast rebuilds maxed out → refund_pending",
"swap_id", sw.ID,
"rebuilds", patched.BroadcastRebuilds,
"max", d.maxRebuilds,
)
} else {
d.logger.Info("broadcast: stale btc fee → reset for re-sign",
"swap_id", sw.ID,
"rebuilds", patched.BroadcastRebuilds,
)
}
}
}
// confirmOne polls the destination chain for a BTC release parked in
// SwapStatusAwaitingConfirmation and either promotes it to Completed
// (mined) or — once btcConfirmTimeout has elapsed since BroadcastAt —
// rebuilds it at a higher RBF feerate. A confirmer RPC error is treated
// as transient: log and leave the swap parked for the next tick.
func (d *BroadcastDriver) confirmOne(ctx context.Context, sw *Swap) {
if sw.DestTxHash == "" {
// We only park swaps after recording the txid, so this is a
// can't-happen guard — nothing to poll, leave for operator triage.
return
}
d.confirmChecks.Add(1)
checkCtx, cancel := context.WithTimeout(ctx, d.perBroadcastTimeout)
defer cancel()
confirmed, err := d.confirmer.ConfirmationStatus(checkCtx, sw.DestinationNetwork, sw.DestTxHash)
if err != nil {
// Transient RPC error — don't disturb the swap, retry next tick.
if d.logger != nil {
d.logger.Debug("btc confirmation check failed (will retry)",
"swap_id", sw.ID, "tx_hash", sw.DestTxHash, "err", err)
}
return
}
if confirmed {
patched, _ := d.store.Patch(ctx, sw.ID, func(s *Swap) {
if s.Status != SwapStatusAwaitingConfirmation {
return
}
s.Status = SwapStatusCompleted
s.LastError = ""
s.LastErrorAt = time.Time{}
s.BroadcastRebuilds = 0 // clear: terminal success.
})
if patched != nil && patched.Status == SwapStatusCompleted {
d.successes.Add(1)
if d.logger != nil {
d.logger.Info("btc release confirmed → swap completed",
"swap_id", sw.ID, "tx_hash", sw.DestTxHash)
}
}
return
}
// Not yet mined. If it's been parked past the timeout, the feerate
// is likely too low for current conditions (or the tx was evicted)
// — rebuild with a higher RBF fee. Otherwise give the block time.
if d.btcConfirmTimeout > 0 && !sw.BroadcastAt.IsZero() &&
time.Since(sw.BroadcastAt) >= d.btcConfirmTimeout {
d.handleBTCConfirmTimeout(ctx, sw)
}
}
// handleBTCConfirmTimeout rebuilds a BTC release that sat unconfirmed
// past btcConfirmTimeout. Same rebuild ceiling as the submit-reject
// path (handleStaleBTCFee): past maxRebuilds the swap routes to refund
// rather than bumping the fee forever. The reset deliberately KEEPS
// LastFeeRate so the signing driver's next PreSignBTC (via
// bumpBTCFeeRate) bids strictly above the stuck tx — a valid BIP-125
// replacement that RBF (nSequence=0xfffffffd) lets evict the original.
func (d *BroadcastDriver) handleBTCConfirmTimeout(ctx context.Context, sw *Swap) {
patched, _ := d.store.Patch(ctx, sw.ID, func(s *Swap) {
if s.Status != SwapStatusAwaitingConfirmation {
return
}
s.BroadcastRebuilds++
if d.maxRebuilds > 0 && s.BroadcastRebuilds >= d.maxRebuilds {
s.Status = SwapStatusRefundPending
s.LastError = fmt.Sprintf(
"Bitcoin release tx unconfirmed after %d rebuild attempts (feerate too low for the current mempool) — routing to refund.",
s.BroadcastRebuilds,
)
s.LastErrorAt = time.Now().UTC()
return
}
s.Status = SwapStatusBridgeTransferPending
s.DestRawTx = ""
s.Signature = ""
s.MPCSessionID = ""
s.DestTxHash = ""
// KEEP LastFeeRate — the next PreSignBTC bumps strictly above it.
s.LastError = "Bitcoin release unconfirmed — rebuilding with a higher RBF feerate"
s.LastErrorAt = time.Now().UTC()
})
d.rebuilds.Add(1)
d.confirmTimeouts.Add(1)
if d.logger != nil && patched != nil {
if patched.Status == SwapStatusRefundPending {
d.logger.Warn("btc confirmation rebuilds maxed out → refund_pending",
"swap_id", sw.ID, "rebuilds", patched.BroadcastRebuilds, "max", d.maxRebuilds)
} else {
d.logger.Info("broadcast: btc unconfirmed → reset for higher-fee re-sign",
"swap_id", sw.ID, "rebuilds", patched.BroadcastRebuilds,
"prev_fee_rate", sw.LastFeeRate)
}
}
}
// bumpBTCFeeRate returns the sat/vB floor for an RBF replacement of a
// tx that paid prev. Zero in ⇒ zero out (first attempt: no prior tx to
// beat, so PreSignBTC just uses the live mempool estimate). Otherwise
// +25% +1, so the replacement strictly out-bids the prior fee even for
// tiny rates where integer truncation would otherwise tie — BIP-125
// requires a higher absolute fee, not merely equal.
func bumpBTCFeeRate(prev uint64) uint64 {
if prev == 0 {
return 0
}
return prev + prev/4 + 1
}
// destIsBTC reports whether the destination network releases over the
// Bitcoin family (BITCOIN_MAINNET / BITCOIN_TESTNET), the one family
// whose mempool admission isn't final and needs a confirmation gate.
func destIsBTC(network string) bool {
return mchain.AddressTypeFor(network) == mchain.AddressTypeBTC
}
// isStaleSolanaBlockhash matches the cluster's rejection of a signed
// tx whose recent_blockhash has expired. Solana's RPC returns code
// -32002 with a message containing "Blockhash not found" when the
+394 -6
View File
@@ -19,12 +19,12 @@ import (
// =============================================================================
type fakeBroadcaster struct {
mu sync.Mutex
results map[string]string // key = network|rawTx — value = txHash on success
errors map[string]error
calls atomic.Int64
lastNet string
lastRaw string
mu sync.Mutex
results map[string]string // key = network|rawTx — value = txHash on success
errors map[string]error
calls atomic.Int64
lastNet string
lastRaw string
}
func newFakeBroadcaster() *fakeBroadcaster {
@@ -344,6 +344,105 @@ func TestIsStaleXRPSequence_Matchers(t *testing.T) {
}
}
// TestBroadcast_StaleBTCFee_ResetsForResign mirrors the Solana-blockhash /
// XRP-sequence recovery for Bitcoin. When bitcoind / mempool.space rejects
// the release tx because its feerate is below the relay or mempool floor,
// the broadcast driver clears DestRawTx + sign artifacts and routes back to
// bridge_transfer_pending so the signing driver re-quotes mempool.space's
// current feerate and re-signs. The original tx already signalled RBF
// (nSequence=0xfffffffd) so the higher-fee rebuild is a valid BIP-125
// replacement. Without this the same too-low-fee blob loops forever and the
// user's BTC never lands.
func TestBroadcast_StaleBTCFee_ResetsForResign(t *testing.T) {
store := NewInMemoryStore()
bc := newFakeBroadcaster()
sw := seedBroadcastingSwap(t, store, "BITCOIN_TESTNET", "0200000001deadbeef")
bc.failFor("BITCOIN_TESTNET", "0200000001deadbeef",
errors.New("broadcast: btc POST /tx HTTP 400: sendrawtransaction RPC error: min relay fee not met, 250 < 1000"))
d := NewBroadcastDriver(store, bc, time.Hour, nil)
d.Tick(t.Context())
got, _ := store.Get(t.Context(), sw.ID)
if got.Status != SwapStatusBridgeTransferPending {
t.Fatalf("stale-fee should reset to bridge_transfer_pending for re-sign, got %q", got.Status)
}
if got.DestRawTx != "" || got.Signature != "" || got.MPCSessionID != "" {
t.Errorf("DestRawTx/Signature/MPCSessionID should be cleared; got DestRawTx=%q Signature=%q MPCSessionID=%q",
got.DestRawTx, got.Signature, got.MPCSessionID)
}
if got.BroadcastRebuilds != 1 {
t.Errorf("BroadcastRebuilds should be 1 after first rebuild, got %d", got.BroadcastRebuilds)
}
if !strings.Contains(strings.ToLower(got.LastError), "fee below relay floor") {
t.Errorf("LastError should explain the cause; got %q", got.LastError)
}
if d.Stats().Rebuilds != 1 {
t.Errorf("Rebuilds stat should be 1, got %d", d.Stats().Rebuilds)
}
}
// TestBroadcast_StaleBTCFee_MaxRebuilds_RoutesToRefund pins the ceiling:
// after maxRebuilds consecutive fee rejections (a persistently overfull
// mempool, or a UTXO too small to carry a relayable fee), the swap moves to
// refund_pending so the deposit gets returned instead of looping forever.
func TestBroadcast_StaleBTCFee_MaxRebuilds_RoutesToRefund(t *testing.T) {
store := NewInMemoryStore()
bc := newFakeBroadcaster()
sw := seedBroadcastingSwap(t, store, "BITCOIN_TESTNET", "0200000001deadbeef")
_, _ = store.Patch(t.Context(), sw.ID, func(s *Swap) {
s.BroadcastRebuilds = DefaultBroadcastMaxRebuilds - 1
})
bc.failFor("BITCOIN_TESTNET", "0200000001deadbeef",
errors.New("broadcast: btc POST /tx HTTP 400: mempool min fee not met, 500 < 4200"))
d := NewBroadcastDriver(store, bc, time.Hour, nil)
d.Tick(t.Context())
got, _ := store.Get(t.Context(), sw.ID)
if got.Status != SwapStatusRefundPending {
t.Fatalf("hitting rebuild ceiling should move to refund_pending, got %q", got.Status)
}
if got.BroadcastRebuilds != DefaultBroadcastMaxRebuilds {
t.Errorf("BroadcastRebuilds should reach the ceiling, got %d", got.BroadcastRebuilds)
}
if !strings.Contains(strings.ToLower(got.LastError), "routing to refund") {
t.Errorf("LastError should explain the ceiling hit, got %q", got.LastError)
}
}
// TestIsStaleBTCFee_Matchers verifies the matcher recognizes each fee-floor
// rejection without grabbing the unfunded-wallet case (which must stay at
// broadcasting + humanize, not rebuild) or unrelated errors.
func TestIsStaleBTCFee_Matchers(t *testing.T) {
cases := []struct {
name string
err error
want bool
}{
{"nil", nil, false},
{"min relay fee not met", errors.New("sendrawtransaction RPC error: min relay fee not met, 250 < 1000"), true},
{"mempool min fee not met", errors.New("mempool min fee not met, 500 < 4200"), true},
{"rbf insufficient fee", errors.New("insufficient fee, rejecting replacement abc123; new feerate 1.0 <= old feerate 2.0"), true},
{"uppercase", errors.New("MIN RELAY FEE NOT MET"), true},
// Must NOT match: an unfunded release wallet is a different remedy
// (fund the address) — rebuilding can't help. Pins that the geth
// phrase "insufficient funds" doesn't collide with "insufficient fee".
{"insufficient funds", errors.New("insufficient funds for gas * price + value"), false},
// Must NOT match: a non-replaceable conflict isn't a fee problem.
{"txn-mempool-conflict", errors.New("txn-mempool-conflict"), false},
{"unrelated blockhash", errors.New("Blockhash not found"), false},
{"random text", errors.New("connection refused"), false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := isStaleBTCFee(tc.err); got != tc.want {
t.Errorf("isStaleBTCFee(%v) = %v, want %v", tc.err, got, tc.want)
}
})
}
}
// TestBroadcast_SurfacesLastErrorOnInsufficientFunds pins the UX
// contract: when the destination chain rejects with "insufficient
// funds for gas * price + value", the swap stays at broadcasting
@@ -715,3 +814,292 @@ func TestEndToEnd_AllDriversChained(t *testing.T) {
t.Errorf("DestTxHash = %q, want 0xfinal-tx-hash-e2e", got.DestTxHash)
}
}
// =============================================================================
// Part B — BTC confirmation watcher (accepted-but-never-confirms / eviction)
// =============================================================================
// fakeConfirmer is a deterministic ConfirmationChecker for the BTC
// confirmation-gate tests. Keyed by network|txid so a test can stage
// distinct verdicts (confirmed / unconfirmed / RPC error) per tx.
type fakeConfirmer struct {
mu sync.Mutex
confirmed map[string]bool
errs map[string]error
calls atomic.Int64
}
func newFakeConfirmer() *fakeConfirmer {
return &fakeConfirmer{confirmed: map[string]bool{}, errs: map[string]error{}}
}
func (f *fakeConfirmer) setConfirmed(network, txid string, v bool) {
f.mu.Lock()
f.confirmed[network+"|"+txid] = v
f.mu.Unlock()
}
func (f *fakeConfirmer) setErr(network, txid string, e error) {
f.mu.Lock()
f.errs[network+"|"+txid] = e
f.mu.Unlock()
}
func (f *fakeConfirmer) ConfirmationStatus(_ context.Context, network, txid string) (bool, error) {
f.calls.Add(1)
f.mu.Lock()
defer f.mu.Unlock()
key := network + "|" + txid
if err, ok := f.errs[key]; ok {
return false, err
}
return f.confirmed[key], nil
}
// seedAwaitingConfirmationSwap creates a BTC swap already parked in
// SwapStatusAwaitingConfirmation with a recorded release txid. Tests
// further Patch BroadcastAt / LastFeeRate / BroadcastRebuilds as needed.
func seedAwaitingConfirmationSwap(t *testing.T, store SwapStore, destNet, txid string) *Swap {
t.Helper()
sw := &Swap{
Status: SwapStatusAwaitingConfirmation,
Amount: 0.01,
SourceNetwork: "ETHEREUM_SEPOLIA",
SourceAsset: "ETH",
DestinationNetwork: destNet,
DestinationAsset: "BTC",
DestinationAddress: "tb1qrecipient",
Signature: "0xsig",
MPCSessionID: "sess",
DestRawTx: "0200000001deadbeef",
DestTxHash: txid,
BroadcastAt: time.Now().UTC(),
LastFeeRate: 10,
}
if err := store.Create(t.Context(), sw); err != nil {
t.Fatalf("seed awaiting-confirmation swap: %v", err)
}
return sw
}
// TestBroadcast_BTC_ParksAwaitingConfirmation pins the core Part B
// behaviour: a BTC release that the node ACCEPTS does NOT complete
// immediately — it parks in awaiting_confirmation (mempool admission is
// not final for Bitcoin) with BroadcastAt stamped for the watcher.
func TestBroadcast_BTC_ParksAwaitingConfirmation(t *testing.T) {
store := NewInMemoryStore()
bc := newFakeBroadcaster()
sw := seedBroadcastingSwap(t, store, "BITCOIN_TESTNET", "0200000001deadbeef")
bc.okFor("BITCOIN_TESTNET", "0200000001deadbeef", "btctxid123")
d := NewBroadcastDriver(store, bc, time.Hour, nil)
d.SetConfirmer(newFakeConfirmer())
d.Tick(t.Context())
got, _ := store.Get(t.Context(), sw.ID)
if got.Status != SwapStatusAwaitingConfirmation {
t.Fatalf("status = %q, want awaiting_confirmation", got.Status)
}
if got.DestTxHash != "btctxid123" {
t.Errorf("DestTxHash = %q, want btctxid123", got.DestTxHash)
}
if got.BroadcastAt.IsZero() {
t.Error("BroadcastAt not stamped")
}
// Not a terminal success yet — successes increments only on confirm.
if s := d.Stats(); s.Successes != 0 {
t.Errorf("Successes = %d, want 0 (not confirmed yet)", s.Successes)
}
}
// TestBroadcast_BTC_NoConfirmer_CompletesImmediately guards back-compat:
// a deploy without a BTC confirmer must NOT strand BTC swaps in the new
// state — it falls back to the legacy immediate-Completed behaviour.
func TestBroadcast_BTC_NoConfirmer_CompletesImmediately(t *testing.T) {
store := NewInMemoryStore()
bc := newFakeBroadcaster()
sw := seedBroadcastingSwap(t, store, "BITCOIN_TESTNET", "0200000001deadbeef")
bc.okFor("BITCOIN_TESTNET", "0200000001deadbeef", "btctxid123")
d := NewBroadcastDriver(store, bc, time.Hour, nil) // no SetConfirmer
d.Tick(t.Context())
got, _ := store.Get(t.Context(), sw.ID)
if got.Status != SwapStatusCompleted {
t.Fatalf("status = %q, want completed (no confirmer ⇒ legacy path)", got.Status)
}
}
// TestConfirm_BTC_ConfirmedPromotesToCompleted: once the watcher sees
// the release tx mined, the swap reaches the terminal Completed state
// and the rebuild counter clears.
func TestConfirm_BTC_ConfirmedPromotesToCompleted(t *testing.T) {
store := NewInMemoryStore()
bc := newFakeBroadcaster()
sw := seedAwaitingConfirmationSwap(t, store, "BITCOIN_TESTNET", "btctxidABC")
_, _ = store.Patch(t.Context(), sw.ID, func(s *Swap) { s.BroadcastRebuilds = 2 })
fc := newFakeConfirmer()
fc.setConfirmed("BITCOIN_TESTNET", "btctxidABC", true)
d := NewBroadcastDriver(store, bc, time.Hour, nil)
d.SetConfirmer(fc)
d.Tick(t.Context())
got, _ := store.Get(t.Context(), sw.ID)
if got.Status != SwapStatusCompleted {
t.Fatalf("status = %q, want completed", got.Status)
}
if got.BroadcastRebuilds != 0 {
t.Errorf("BroadcastRebuilds = %d, want 0 (cleared on success)", got.BroadcastRebuilds)
}
if s := d.Stats(); s.Successes != 1 || s.ConfirmChecks != 1 {
t.Errorf("stats = %+v, want Successes=1 ConfirmChecks=1", s)
}
}
// TestConfirm_BTC_WithinTimeoutStaysParked: an unconfirmed tx still
// inside its timeout window is left alone — give the block a chance.
func TestConfirm_BTC_WithinTimeoutStaysParked(t *testing.T) {
store := NewInMemoryStore()
bc := newFakeBroadcaster()
sw := seedAwaitingConfirmationSwap(t, store, "BITCOIN_TESTNET", "btctxidABC") // BroadcastAt = now
fc := newFakeConfirmer()
fc.setConfirmed("BITCOIN_TESTNET", "btctxidABC", false)
d := NewBroadcastDriver(store, bc, time.Hour, nil)
d.SetConfirmer(fc) // default 30m timeout — not elapsed
d.Tick(t.Context())
got, _ := store.Get(t.Context(), sw.ID)
if got.Status != SwapStatusAwaitingConfirmation {
t.Fatalf("status = %q, want still awaiting_confirmation", got.Status)
}
if s := d.Stats(); s.Rebuilds != 0 || s.ConfirmTimeouts != 0 {
t.Errorf("stats = %+v, want no rebuilds/timeouts", s)
}
}
// TestConfirm_BTC_TimeoutRebuildsWithHigherFee: past the timeout, an
// unconfirmed tx is reset for re-sign — and crucially LastFeeRate is
// PRESERVED so PreSignBTC bumps strictly above the stuck tx (RBF).
func TestConfirm_BTC_TimeoutRebuildsWithHigherFee(t *testing.T) {
store := NewInMemoryStore()
bc := newFakeBroadcaster()
sw := seedAwaitingConfirmationSwap(t, store, "BITCOIN_TESTNET", "btctxidABC")
// Push BroadcastAt into the past so the default timeout has elapsed.
_, _ = store.Patch(t.Context(), sw.ID, func(s *Swap) {
s.BroadcastAt = time.Now().Add(-time.Hour).UTC()
s.LastFeeRate = 42
})
fc := newFakeConfirmer()
fc.setConfirmed("BITCOIN_TESTNET", "btctxidABC", false)
d := NewBroadcastDriver(store, bc, time.Hour, nil)
d.SetConfirmer(fc)
d.Tick(t.Context())
got, _ := store.Get(t.Context(), sw.ID)
if got.Status != SwapStatusBridgeTransferPending {
t.Fatalf("status = %q, want bridge_transfer_pending (rebuild)", got.Status)
}
if got.DestRawTx != "" || got.Signature != "" || got.MPCSessionID != "" || got.DestTxHash != "" {
t.Errorf("re-sign artifacts not cleared: rawtx=%q sig=%q sess=%q txhash=%q",
got.DestRawTx, got.Signature, got.MPCSessionID, got.DestTxHash)
}
if got.LastFeeRate != 42 {
t.Errorf("LastFeeRate = %d, want 42 preserved across rebuild (RBF bump floor)", got.LastFeeRate)
}
if got.BroadcastRebuilds != 1 {
t.Errorf("BroadcastRebuilds = %d, want 1", got.BroadcastRebuilds)
}
if !strings.Contains(got.LastError, "higher RBF feerate") {
t.Errorf("LastError = %q, want mention of higher RBF feerate", got.LastError)
}
if s := d.Stats(); s.Rebuilds != 1 || s.ConfirmTimeouts != 1 {
t.Errorf("stats = %+v, want Rebuilds=1 ConfirmTimeouts=1", s)
}
}
// TestConfirm_BTC_TimeoutMaxRebuildsRoutesToRefund: a tx that stays
// unconfirmed through the rebuild ceiling routes to refund rather than
// bumping the fee forever.
func TestConfirm_BTC_TimeoutMaxRebuildsRoutesToRefund(t *testing.T) {
store := NewInMemoryStore()
bc := newFakeBroadcaster()
sw := seedAwaitingConfirmationSwap(t, store, "BITCOIN_TESTNET", "btctxidABC")
_, _ = store.Patch(t.Context(), sw.ID, func(s *Swap) {
s.BroadcastAt = time.Now().Add(-time.Hour).UTC()
s.BroadcastRebuilds = DefaultBroadcastMaxRebuilds - 1
})
fc := newFakeConfirmer()
fc.setConfirmed("BITCOIN_TESTNET", "btctxidABC", false)
d := NewBroadcastDriver(store, bc, time.Hour, nil)
d.SetConfirmer(fc)
d.Tick(t.Context())
got, _ := store.Get(t.Context(), sw.ID)
if got.Status != SwapStatusRefundPending {
t.Fatalf("status = %q, want refund_pending at ceiling", got.Status)
}
if got.BroadcastRebuilds != DefaultBroadcastMaxRebuilds {
t.Errorf("BroadcastRebuilds = %d, want %d", got.BroadcastRebuilds, DefaultBroadcastMaxRebuilds)
}
if !strings.Contains(got.LastError, "routing to refund") {
t.Errorf("LastError = %q, want 'routing to refund'", got.LastError)
}
}
// TestConfirm_BTC_ConfirmerErrorStaysParked: a transient confirmer RPC
// error must not disturb the swap — it stays parked for the next tick.
func TestConfirm_BTC_ConfirmerErrorStaysParked(t *testing.T) {
store := NewInMemoryStore()
bc := newFakeBroadcaster()
sw := seedAwaitingConfirmationSwap(t, store, "BITCOIN_TESTNET", "btctxidABC")
_, _ = store.Patch(t.Context(), sw.ID, func(s *Swap) {
s.BroadcastAt = time.Now().Add(-time.Hour).UTC() // past timeout, but error wins
})
fc := newFakeConfirmer()
fc.setErr("BITCOIN_TESTNET", "btctxidABC", errors.New("mempool.space HTTP 503"))
d := NewBroadcastDriver(store, bc, time.Hour, nil)
d.SetConfirmer(fc)
d.Tick(t.Context())
got, _ := store.Get(t.Context(), sw.ID)
if got.Status != SwapStatusAwaitingConfirmation {
t.Fatalf("status = %q, want unchanged awaiting_confirmation on RPC error", got.Status)
}
if s := d.Stats(); s.Rebuilds != 0 || s.ConfirmTimeouts != 0 {
t.Errorf("stats = %+v, want no rebuild on transient error", s)
}
}
// TestBumpBTCFeeRate pins the RBF bump policy: zero passes through (first
// attempt), otherwise +25% +1 so the replacement strictly out-bids even
// for tiny feerates where integer truncation would tie.
func TestBumpBTCFeeRate(t *testing.T) {
cases := []struct {
prev uint64
want uint64
}{
{0, 0}, // first attempt — no floor
{1, 2}, // 1 + 0 + 1
{4, 6}, // 4 + 1 + 1
{10, 13}, // 10 + 2 + 1
{100, 126}, // 100 + 25 + 1
}
for _, tc := range cases {
if got := bumpBTCFeeRate(tc.prev); got != tc.want {
t.Errorf("bumpBTCFeeRate(%d) = %d, want %d", tc.prev, got, tc.want)
}
if tc.prev > 0 && bumpBTCFeeRate(tc.prev) <= tc.prev {
t.Errorf("bumpBTCFeeRate(%d) did not strictly exceed input", tc.prev)
}
}
}
+29
View File
@@ -789,6 +789,11 @@ func main() {
// bridge_transfer_pending through MPC threshold signing. Requires
// an mchain client; without one the driver has nothing to call.
var signer *SigningDriver
// Captured from the signer's BTC provider so the broadcast driver
// can reuse it as the BTC confirmation checker (Part B: gate BTC
// releases through awaiting_confirmation until mined). Nil when no
// BTC provider is configured.
var btcConfirmProvider *btcprov.Provider
signerCtx, signerCancel := context.WithCancel(context.Background())
if !*disableSigningDriver && mchainPool != nil {
// Settlement leg signs FROM the release wallet (treasury) on the
@@ -866,6 +871,7 @@ func main() {
if *btcRPCMainnetURL != "" || *btcRPCTestnetURL != "" {
btcClient := btcprov.NewProvider(*btcRPCMainnetURL, *btcRPCTestnetURL, *btcRPCTimeout)
signer.SetBTCProvider(btcClient)
btcConfirmProvider = btcClient
logger.Info("btc provider attached to signer",
"mainnet_url", btcClient.MainnetURL,
"testnet_url", btcClient.TestnetURL,
@@ -948,6 +954,15 @@ func main() {
bcastCtx, bcastCancel := context.WithCancel(context.Background())
if !*disableBroadcastDriver {
bcastDriver = NewBroadcastDriver(swapStore, bcastClient, *broadcastInterval, logger)
// BTC confirmation gate (Part B): when a BTC provider is wired,
// BTC releases park in awaiting_confirmation until mined instead
// of completing on mempool admission. Without it, BTC behaves as
// before (immediate Completed) — no stranding.
if btcConfirmProvider != nil {
bcastDriver.SetConfirmer(btcConfirmAdapter{p: btcConfirmProvider})
logger.Info("btc confirmation watcher enabled",
"timeout", DefaultBTCConfirmationTimeout)
}
api.SetBroadcastDriver(bcastDriver)
go func() {
_ = bcastDriver.Run(bcastCtx)
@@ -1175,3 +1190,17 @@ func selectProfile(name string) (*bridge.BridgeProfile, error) {
return nil, fmt.Errorf("unknown bridge profile %q (valid: strict-pq, classical-compat)", name)
}
}
// btcConfirmAdapter bridges *btc.Provider's GetTxStatus to the
// broadcast driver's ConfirmationChecker interface (which deliberately
// avoids importing internal/btc). Returns confirmed=false on any RPC
// error so the driver treats it as "not yet" and retries next tick.
type btcConfirmAdapter struct{ p *btcprov.Provider }
func (a btcConfirmAdapter) ConfirmationStatus(ctx context.Context, network, txid string) (bool, error) {
st, err := a.p.GetTxStatus(ctx, network, txid)
if err != nil {
return false, err
}
return st.Confirmed, nil
}
+11
View File
@@ -652,6 +652,11 @@ func (d *SigningDriver) signOne(ctx context.Context, sw *Swap) {
DestinationAddress: sw.DestinationAddress,
Amount: releaseAmount(sw),
SenderAddress: senderAddr,
// On a rebuild (BroadcastRebuilds>0 with a recorded
// LastFeeRate), force the new tx to out-bid the stuck
// attempt so it's a valid BIP-125 replacement. Zero on
// the first attempt → PreSignBTC uses the live estimate.
MinFeeRate: bumpBTCFeeRate(sw.LastFeeRate),
}, d.btcProvider, releasePubKey)
if aerr != nil {
d.failures.Add(1)
@@ -1002,6 +1007,12 @@ func (d *SigningDriver) signOne(ctx context.Context, sw *Swap) {
if destRawTx != "" {
s.DestRawTx = destRawTx
}
// Persist the BTC feerate this attempt pays so the broadcast
// driver's confirmation watcher (and the submit-reject handler)
// can bump strictly above it on a later RBF rebuild.
if unsignedBTC != nil {
s.LastFeeRate = unsignedBTC.FeeRate
}
s.Status = SwapStatusBroadcasting
// Clear any prior transient error — we're past the signing
// stage. The broadcast driver will set a fresh LastError if
+27
View File
@@ -42,6 +42,17 @@ const (
SwapStatusSigning SwapStatus = "bridge_transfer_pending_signing"
// Signed payload broadcast on destination chain; waiting for inclusion.
SwapStatusBroadcasting SwapStatus = "bridge_transfer_pending_broadcasting"
// BTC-only intermediate state: the release tx was accepted into the
// mempool but isn't confirmed yet. For every other family "broadcast
// accepted" is effectively final, so they skip straight to Completed.
// Bitcoin can accept a low-fee tx into the mempool and then never
// mine it (fee market rises, or the tx is evicted), so a BTC release
// only reaches Completed once the confirmation watcher in the
// broadcast driver sees it in a block. While here, the watcher either
// promotes it to Completed or — after a timeout — rebuilds it with a
// higher (RBF) feerate. Non-terminal; the SDK/UI must render this as
// "in progress", NOT done.
SwapStatusAwaitingConfirmation SwapStatus = "bridge_transfer_pending_confirmation"
// Final settlement: funds arrived at destination address.
SwapStatusCompleted SwapStatus = "completed"
// Quote committed at create time has aged past the configured
@@ -283,6 +294,22 @@ type Swap struct {
// the swap moves to refund_pending.
BroadcastRebuilds int `json:"broadcast_rebuilds,omitempty"`
// LastFeeRate is the sat/vB feerate of the most recent BTC release
// attempt, persisted by the signing driver when it finalizes a BTC
// tx. The confirmation watcher / submit-reject handler use it as the
// floor for the next rebuild so an RBF replacement strictly out-bids
// the tx it replaces (BIP-125 requires a higher absolute fee). Zero
// for non-BTC swaps and for the first BTC attempt (no prior fee to
// beat — PreSignBTC just uses the live mempool estimate). Survives a
// rebuild reset so the bump compounds across successive attempts.
LastFeeRate uint64 `json:"last_fee_rate,omitempty"`
// BroadcastAt stamps when the current destination tx was accepted
// into the mempool. The BTC confirmation watcher measures its
// rebuild timeout from here; refreshed on every (re)broadcast so the
// clock restarts for each replacement tx. Unused by non-BTC families.
BroadcastAt time.Time `json:"broadcast_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+7
View File
@@ -177,6 +177,13 @@ type SwapIntent struct {
// Threaded onto the on-chain Payment by PreSignXRP when present.
// Ignored for non-XRP destinations.
DestinationTag *uint32
// MinFeeRate is an optional sat/vB floor for the BTC release tx.
// When >0, PreSignBTC uses max(live mempool estimate, MinFeeRate)
// instead of the bare estimate, so a rebuilt (RBF) tx out-bids the
// stuck attempt it replaces. Set by the signing driver from the
// swap's persisted LastFeeRate on a rebuild; zero on the first
// attempt. Ignored for non-BTC destinations.
MinFeeRate uint64
}
// PreSign builds the unsigned transaction and returns the keccak256
+22 -2
View File
@@ -75,8 +75,13 @@ type BTCUnsigned struct {
RecipientSats uint64
ChangeSats uint64
FeeSats uint64
PrevTxID string
PrevVout uint32
// FeeRate is the effective sat/vB this tx pays (FeeSats / vsize,
// rounded up, after the min-fee floor and any dust-swept change).
// The signing driver persists it on Swap.LastFeeRate so a later RBF
// rebuild can bump strictly above it. Zero on the refund path.
FeeRate uint64
PrevTxID string
PrevVout uint32
}
// estimatedRefundVsize is the vsize for a one-input single-output
@@ -213,6 +218,15 @@ func (a *Assembler) PreSignBTC(
if feeRate == 0 {
feeRate = 1
}
// RBF replacement floor: on a rebuild the caller passes the prior
// attempt's (already-bumped) feerate as MinFeeRate. Honour it even
// when the live mempool estimate has since dropped, otherwise the
// replacement wouldn't out-bid the stuck tx and the node rejects it
// with "insufficient fee" (BIP-125). max() so a genuinely risen
// market still wins when it exceeds the floor.
if in.MinFeeRate > feeRate {
feeRate = in.MinFeeRate
}
feeSats := feeRate * estimatedVsize
if feeSats < 250 {
feeSats = 250 // upstream min relay fee threshold
@@ -263,6 +277,11 @@ func (a *Assembler) PreSignBTC(
return nil, fmt.Errorf("SigHash: %w", err)
}
// Effective feerate the tx actually pays (post dust-sweep + floor),
// rounded up so the persisted value never understates the on-chain
// fee — the next RBF bump must beat what this tx really paid.
effFeeRate := (feeSats + estimatedVsize - 1) / estimatedVsize
return &BTCUnsigned{
Network: in.DestinationNetwork,
Inner: pay,
@@ -271,6 +290,7 @@ func (a *Assembler) PreSignBTC(
RecipientSats: amountSats,
ChangeSats: change,
FeeSats: feeSats,
FeeRate: effFeeRate,
PrevTxID: spend.TxID,
PrevVout: spend.Vout,
}, nil
+81
View File
@@ -195,3 +195,84 @@ func TestPreSignBTCRefund_UnknownNetwork(t *testing.T) {
t.Fatalf("expected unknown-network error, got %v", err)
}
}
// ───────────────────────────────────────────────────────────────────
// PreSignBTC — release path: RBF MinFeeRate floor + FeeRate surfacing
// ───────────────────────────────────────────────────────────────────
// btcReleaseIntent is the release-direction SwapIntent for the matched
// mqEJ…xr8 fixture wallet (sender == release wallet) paying testRecipient.
func btcReleaseIntent(minFeeRate uint64) SwapIntent {
return SwapIntent{
DestinationNetwork: "BITCOIN_TESTNET",
DestinationAsset: "BTC",
DestinationAddress: testRecipientAddr,
Amount: 0.001, // 100_000 sats
SenderAddress: testDepositAddress,
MinFeeRate: minFeeRate,
}
}
// releaseProvider funds the release wallet (sender) with one big UTXO so
// change stays well above dust (no fee-sweep distortion of FeeRate).
func releaseProvider(halfHour uint64) *fakeBTCProvider {
return &fakeBTCProvider{
utxos: map[string][]btc.UTXO{
testDepositAddress: {
confirmedUTXO("bbbb000000000000000000000000000000000000000000000000000000000001", 0, 1_000_000),
},
},
fees: &btc.FeeRates{HalfHour: halfHour},
}
}
// TestPreSignBTC_FeeRateSurfaced: with no floor, PreSignBTC uses the live
// mempool estimate and reports the effective sat/vB on FeeRate so the
// signing driver can persist it for a later RBF bump.
func TestPreSignBTC_FeeRateSurfaced(t *testing.T) {
a := newTestAssembler()
u, err := a.PreSignBTC(context.Background(), btcReleaseIntent(0), releaseProvider(3), testDepositPubKeyHex)
if err != nil {
t.Fatalf("PreSignBTC: %v", err)
}
// 3 sat/vB × 250 vsize = 750 sat; effective rate = 750/250 = 3.
if u.FeeSats != 750 {
t.Errorf("FeeSats = %d, want 750", u.FeeSats)
}
if u.FeeRate != 3 {
t.Errorf("FeeRate = %d, want 3", u.FeeRate)
}
}
// TestPreSignBTC_MinFeeRateFloorApplied: a rebuild passes MinFeeRate above
// the live estimate; PreSignBTC must honour the floor so the replacement
// out-bids the stuck tx (BIP-125), even though the market estimate dropped.
func TestPreSignBTC_MinFeeRateFloorApplied(t *testing.T) {
a := newTestAssembler()
// Live estimate 2 sat/vB, but the prior attempt's bumped floor is 10.
u, err := a.PreSignBTC(context.Background(), btcReleaseIntent(10), releaseProvider(2), testDepositPubKeyHex)
if err != nil {
t.Fatalf("PreSignBTC: %v", err)
}
if u.FeeRate != 10 {
t.Errorf("FeeRate = %d, want 10 (floor wins over estimate 2)", u.FeeRate)
}
if u.FeeSats != 2500 {
t.Errorf("FeeSats = %d, want 2500 (10 × 250)", u.FeeSats)
}
}
// TestPreSignBTC_MinFeeRateBelowEstimateIgnored: when the live estimate
// already exceeds the floor (market rose on its own), the estimate wins —
// max(), not blind override.
func TestPreSignBTC_MinFeeRateBelowEstimateIgnored(t *testing.T) {
a := newTestAssembler()
// Floor 4, but the market estimate is 9 → tx should pay 9.
u, err := a.PreSignBTC(context.Background(), btcReleaseIntent(4), releaseProvider(9), testDepositPubKeyHex)
if err != nil {
t.Fatalf("PreSignBTC: %v", err)
}
if u.FeeRate != 9 {
t.Errorf("FeeRate = %d, want 9 (estimate exceeds floor)", u.FeeRate)
}
}