mirror of
https://github.com/luxfi/bridge.git
synced 2026-09-24 00:26:46 +00:00
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:
+345
-27
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user