diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 26d663ea5..0e24b3f07 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -36,6 +36,7 @@ package may import from a higher layer. | [`chainsource`](chainsource/) | `ChainBackend` interface: fee estimation, block/conf/spend notifications | | [`chainbackends`](chainbackends/) | LND-backed `ChainBackend` implementation plus lndclient adapters (`TxBroadcaster`, `PackageSubmitter`) | | [`chain`](chain/) | Bitcoind RPC utilities (package relay, `SubmitPackage`) | +| [`txconfirm`](txconfirm/) | Generic "broadcast + CPFP fee-bump + notify on confirm" actor with per-parent fee-input reservations and BIP-125 Rule 3/4 enforcement | | [`lndbackend`](lndbackend/) | `BoardingBackend` implementation via LND's wallet kit | | [`lwwallet`](lwwallet/) | Lightweight in-process wallet (btcwallet + Esplora, no external LND) | | [`btcwbackend`](btcwbackend/) | Neutrino-backed wallet backend (btcwallet + compact block filters) | diff --git a/btcwbackend/boarding_backend.go b/btcwbackend/boarding_backend.go index 052e663eb..21a71df59 100644 --- a/btcwbackend/boarding_backend.go +++ b/btcwbackend/boarding_backend.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" "math" + "time" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" @@ -12,6 +13,7 @@ import ( "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btclog/v2" + "github.com/btcsuite/btcwallet/wtxmgr" "github.com/lightninglabs/darepo-client/wallet" "github.com/lightninglabs/darepo-client/walletcore" "github.com/lightninglabs/neutrino" @@ -211,6 +213,38 @@ func (b *BoardingBackendAdapter) GetBlock(ctx context.Context, return block, nil } +// LeaseOutput forwards the output lock to btcwallet's native +// coin-selection lock table. btcwallet persists leases across +// restarts, so once a caller (e.g. the txconfirm CPFP broadcaster) +// reserves a UTXO via LeaseOutput it stays excluded from coin +// selection even if the daemon restarts mid-bump. +// +// The darepo-local wallet.LockID is re-interpreted as wtxmgr.LockID: +// both are [32]byte, so the translation is a direct type cast rather +// than a mapping table. This keeps the LockID stable across the +// interface boundary so ReleaseOutput can use the same identifier +// without any broker-side state. +func (b *BoardingBackendAdapter) LeaseOutput(_ context.Context, + id wallet.LockID, op wire.OutPoint, + expiry time.Duration) (time.Time, error) { + + return b.BtcWallet.LeaseOutput(wtxmgr.LockID(id), op, expiry) +} + +// ReleaseOutput forwards the unlock to btcwallet's native +// coin-selection lock table. The supplied LockID must match the one +// used at lease time; mismatches surface as an error from btcwallet +// so misuse fails loudly rather than silently releasing someone +// else's lease. +func (b *BoardingBackendAdapter) ReleaseOutput(_ context.Context, + id wallet.LockID, op wire.OutPoint) error { + + return b.BtcWallet.ReleaseOutput(wtxmgr.LockID(id), op) +} + // Compile-time check that BoardingBackendAdapter implements -// wallet.BoardingBackend. -var _ wallet.BoardingBackend = (*BoardingBackendAdapter)(nil) +// wallet.BoardingBackend and wallet.OutputLeaser. +var ( + _ wallet.BoardingBackend = (*BoardingBackendAdapter)(nil) + _ wallet.OutputLeaser = (*BoardingBackendAdapter)(nil) +) diff --git a/btcwbackend/chain_backend.go b/btcwbackend/chain_backend.go index 693d11111..f16e33cb3 100644 --- a/btcwbackend/chain_backend.go +++ b/btcwbackend/chain_backend.go @@ -254,9 +254,9 @@ func (b *ChainBackend) BestBlock(ctx context.Context) (int32, // TestMempoolAccept is not supported by the neutrino backend since // neutrino does not maintain a mempool. func (b *ChainBackend) TestMempoolAccept(_ context.Context, - _ *wire.MsgTx) (bool, string, error) { + _ ...*wire.MsgTx) ([]chainsource.MempoolAcceptResult, error) { - return false, "", fmt.Errorf( + return nil, fmt.Errorf( "test mempool accept not supported by neutrino backend", ) } diff --git a/chainbackends/lnd.go b/chainbackends/lnd.go index d15a035f9..39c511621 100644 --- a/chainbackends/lnd.go +++ b/chainbackends/lnd.go @@ -165,16 +165,18 @@ func (b *LNDBackend) BestBlock(ctx context.Context) (int32, chainhash.Hash, } } -// TestMempoolAccept tests whether a transaction would be accepted by the -// mempool. Note that this is not directly supported by lnd's interfaces, so we -// return an error indicating the operation is not supported. -func (b *LNDBackend) TestMempoolAccept(ctx context.Context, - tx *wire.MsgTx) (bool, string, error) { +// TestMempoolAccept tests whether one or more transactions would be +// accepted by the mempool. LND's WalletController does not expose a +// testmempoolaccept equivalent, so every call returns "not supported" +// here — callers that treat preflight as best-effort should log and +// continue. +func (b *LNDBackend) TestMempoolAccept(_ context.Context, + _ ...*wire.MsgTx) ([]chainsource.MempoolAcceptResult, error) { // LND's WalletController doesn't provide a test mempool accept // interface. This would require direct RPC access to the underlying // Bitcoin node. - return false, "", fmt.Errorf("test mempool accept not supported by " + + return nil, fmt.Errorf("test mempool accept not supported by " + "LND backend") } diff --git a/chainsource/backend.go b/chainsource/backend.go index cf57867c7..f919cd33d 100644 --- a/chainsource/backend.go +++ b/chainsource/backend.go @@ -2,12 +2,40 @@ package chainsource import ( "context" + "errors" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" ) +// ErrPackageMempoolAcceptUnsupported is returned by ChainBackend +// implementations whose underlying RPC cannot test a multi-transaction +// package for mempool acceptance. It is distinct from a per-tx +// "rejected" outcome: the backend never evaluated the package at all. +// Callers that treat package preflight as best-effort should downgrade +// this error to a soft-miss; callers that require package validation +// should surface it as a hard failure. +var ErrPackageMempoolAcceptUnsupported = errors.New( + "package testmempoolaccept not supported by backend", +) + +// MempoolAcceptResult is the per-transaction outcome of a +// TestMempoolAccept call. One result is returned for each input tx, in +// the same order. +type MempoolAcceptResult struct { + // Txid is the transaction hash the result applies to. + Txid chainhash.Hash + + // Accepted reports whether the backend would accept the + // transaction into its mempool. + Accepted bool + + // Reason carries the backend's human-readable rejection reason + // when Accepted is false. Empty on acceptance. + Reason string +} + // ChainBackend defines the interface that must be implemented by all // blockchain backend providers. This abstraction allows the ChainSource actor // to work with different backends (lnd's chainntnfs, block explorers like @@ -28,12 +56,20 @@ type ChainBackend interface { // according to the backend's view. BestBlock(ctx context.Context) (int32, chainhash.Hash, error) - // TestMempoolAccept tests whether a transaction would be accepted by - // the mempool without actually broadcasting it. Returns true if the - // transaction would be accepted, false with a rejection reason if not. - // Not all backends may support this operation. + // TestMempoolAccept tests whether one or more transactions would be + // accepted by the mempool without actually broadcasting them. When + // len(txs) > 1 the backend must evaluate the transactions as a + // package (matching Bitcoin Core's testmempoolaccept JSON array + // form); backends that can only validate individual transactions + // must return ErrPackageMempoolAcceptUnsupported rather than + // silently evaluating the first tx in isolation. + // + // The returned slice has one entry per input tx, in the same + // order. Not all backends may support this operation at all; those + // should return a non-nil error from the single-tx call so callers + // can distinguish "rejected" from "not evaluated". TestMempoolAccept(ctx context.Context, - tx *wire.MsgTx) (bool, string, error) + txs ...*wire.MsgTx) ([]MempoolAcceptResult, error) // BroadcastTx broadcasts a transaction to the network. The label // parameter is optional and may be used for wallet tracking. Returns diff --git a/chainsource/chainsource.go b/chainsource/chainsource.go index 5e977bce9..7db070eb9 100644 --- a/chainsource/chainsource.go +++ b/chainsource/chainsource.go @@ -148,20 +148,28 @@ func (a *ChainSourceActor) handleBestHeight(ctx context.Context, } // handleTestMempoolAccept processes a mempool acceptance test request by -// checking if the given transaction would be accepted by the mempool without -// actually broadcasting it. +// checking if one or more transactions would be accepted by the mempool +// without actually broadcasting them. Multi-transaction requests are +// forwarded to the backend as a package test; backends that do not +// support package evaluation return ErrPackageMempoolAcceptUnsupported +// and the error is surfaced to the caller. func (a *ChainSourceActor) handleTestMempoolAccept(ctx context.Context, req *TestMempoolAcceptRequest) fn.Result[ChainSourceResp] { - accepted, reason, err := a.cfg.Backend.TestMempoolAccept(ctx, req.Tx) + if len(req.Txs) == 0 { + return fn.Err[ChainSourceResp](fmt.Errorf( + "TestMempoolAcceptRequest.Txs must have at least " + + "one transaction")) + } + + results, err := a.cfg.Backend.TestMempoolAccept(ctx, req.Txs...) if err != nil { return fn.Err[ChainSourceResp](fmt.Errorf("failed to test "+ "mempool accept: %w", err)) } return fn.Ok[ChainSourceResp](&TestMempoolAcceptResponse{ - Accepted: accepted, - Reason: reason, + Results: results, }) } @@ -194,12 +202,19 @@ func (a *ChainSourceActor) handleBroadcastTx(ctx context.Context, // If supported by the backend, test mempool acceptance as a // best-effort signal that the transaction is already known. // This is useful for backends that return non-standard error - // strings - // from BroadcastTx but provide a structured reject reason via - // testmempoolaccept. - accepted, reason, acceptErr := a.cfg.Backend.TestMempoolAccept( + // strings from BroadcastTx but provide a structured reject + // reason via testmempoolaccept. + results, acceptErr := a.cfg.Backend.TestMempoolAccept( ctx, req.Tx, ) + var ( + accepted bool + reason string + ) + if acceptErr == nil && len(results) > 0 { + accepted = results[0].Accepted + reason = results[0].Reason + } switch { case acceptErr == nil && accepted: a.logger(ctx).DebugS(ctx, diff --git a/chainsource/chainsource_errors_test.go b/chainsource/chainsource_errors_test.go index 216926c45..e11f91fbb 100644 --- a/chainsource/chainsource_errors_test.go +++ b/chainsource/chainsource_errors_test.go @@ -114,10 +114,10 @@ func (b *errorBackend) BestBlock(ctx context.Context) (int32, return 0, chainhash.Hash{}, b.err } -func (b *errorBackend) TestMempoolAccept(ctx context.Context, - tx *wire.MsgTx) (bool, string, error) { +func (b *errorBackend) TestMempoolAccept(_ context.Context, + _ ...*wire.MsgTx) ([]MempoolAcceptResult, error) { - return false, "", b.err + return nil, b.err } func (b *errorBackend) BroadcastTx(ctx context.Context, tx *wire.MsgTx, @@ -190,7 +190,7 @@ func TestChainSourceActorBackendErrors(t *testing.T) { tx := wire.NewMsgTx(2) mempoolResult := ref.Ask( ctx, &TestMempoolAcceptRequest{ - Tx: tx, + Txs: []*wire.MsgTx{tx}, }, ).Await(ctx) diff --git a/chainsource/chainsource_test.go b/chainsource/chainsource_test.go index 543629b91..af40dda1e 100644 --- a/chainsource/chainsource_test.go +++ b/chainsource/chainsource_test.go @@ -52,10 +52,18 @@ func (m *mockBackend) BestBlock(ctx context.Context) (int32, chainhash.Hash, return m.bestHeight, m.bestHash, nil } -func (m *mockBackend) TestMempoolAccept(ctx context.Context, - tx *wire.MsgTx) (bool, string, error) { +func (m *mockBackend) TestMempoolAccept(_ context.Context, + txs ...*wire.MsgTx) ([]MempoolAcceptResult, error) { + + results := make([]MempoolAcceptResult, len(txs)) + for i, tx := range txs { + results[i] = MempoolAcceptResult{ + Txid: tx.TxHash(), + Accepted: true, + } + } - return true, "", nil + return results, nil } func (m *mockBackend) BroadcastTx(ctx context.Context, tx *wire.MsgTx, @@ -92,10 +100,23 @@ func (b *broadcastErrorBackend) BroadcastTx(ctx context.Context, tx *wire.MsgTx, } // TestMempoolAccept returns the configured mempool acceptance values. -func (b *broadcastErrorBackend) TestMempoolAccept(ctx context.Context, - tx *wire.MsgTx) (bool, string, error) { +func (b *broadcastErrorBackend) TestMempoolAccept(_ context.Context, + txs ...*wire.MsgTx) ([]MempoolAcceptResult, error) { + + if b.mempoolErr != nil { + return nil, b.mempoolErr + } - return b.mempoolAccepted, b.mempoolReason, b.mempoolErr + results := make([]MempoolAcceptResult, len(txs)) + for i, tx := range txs { + results[i] = MempoolAcceptResult{ + Txid: tx.TxHash(), + Accepted: b.mempoolAccepted, + Reason: b.mempoolReason, + } + } + + return results, nil } func (m *mockBackend) RegisterConf(ctx context.Context, @@ -191,7 +212,9 @@ func TestChainSourceActorTestMempoolAccept(t *testing.T) { ctx := t.Context() tx := wire.NewMsgTx(2) - future := ref.Ask(ctx, &TestMempoolAcceptRequest{Tx: tx}) + future := ref.Ask(ctx, &TestMempoolAcceptRequest{ + Txs: []*wire.MsgTx{tx}, + }) result := future.Await(ctx) require.True(t, result.IsOk()) @@ -200,8 +223,9 @@ func TestChainSourceActorTestMempoolAccept(t *testing.T) { require.NoError(t, err) acceptResp, ok := resp.(*TestMempoolAcceptResponse) require.True(t, ok) - require.True(t, acceptResp.Accepted) - require.Empty(t, acceptResp.Reason) + require.Len(t, acceptResp.Results, 1) + require.True(t, acceptResp.Results[0].Accepted) + require.Empty(t, acceptResp.Results[0].Reason) } // TestChainSourceActorBroadcastTx tests transaction broadcasting through the diff --git a/chainsource/messages.go b/chainsource/messages.go index 98cec13d2..9f13e98c2 100644 --- a/chainsource/messages.go +++ b/chainsource/messages.go @@ -95,14 +95,18 @@ func (m *BestHeightResponse) MessageType() string { // chainSourceRespSealed implements the sealed ChainSourceResp interface. func (m *BestHeightResponse) chainSourceRespSealed() {} -// TestMempoolAcceptRequest requests a test of whether a transaction would be -// accepted by the mempool without actually broadcasting it. This is useful for -// validating transactions before broadcast. +// TestMempoolAcceptRequest requests a test of whether one or more +// transactions would be accepted by the mempool without actually +// broadcasting them. Passing more than one transaction asks the backend +// to evaluate the set as a package (per Bitcoin Core's +// testmempoolaccept RPC). type TestMempoolAcceptRequest struct { actor.BaseMessage - // Tx is the transaction to test for mempool acceptance. - Tx *wire.MsgTx + // Txs are the transactions to test for mempool acceptance. One + // transaction performs a single-tx test; multiple transactions + // request a package test. + Txs []*wire.MsgTx } // MessageType returns the message type identifier for logging and debugging. @@ -113,17 +117,14 @@ func (m *TestMempoolAcceptRequest) MessageType() string { // chainSourceMsgSealed implements the sealed ChainSourceMsg interface. func (m *TestMempoolAcceptRequest) chainSourceMsgSealed() {} -// TestMempoolAcceptResponse contains the result of a mempool acceptance test. +// TestMempoolAcceptResponse contains the per-transaction results of a +// mempool acceptance test. type TestMempoolAcceptResponse struct { actor.BaseMessage - // Accepted indicates whether the transaction would be accepted by the - // mempool. - Accepted bool - - // Reason contains a human-readable explanation if the transaction was - // rejected, or is empty if accepted. - Reason string + // Results has one entry per tx in the original request, in the + // same order. + Results []MempoolAcceptResult } // MessageType returns the message type identifier for logging and debugging. diff --git a/chainsource/messages_test.go b/chainsource/messages_test.go index 64a670ab0..859b97433 100644 --- a/chainsource/messages_test.go +++ b/chainsource/messages_test.go @@ -55,7 +55,7 @@ func TestMessageTypes(t *testing.T) { { name: "TestMempoolAcceptRequest", msg: &TestMempoolAcceptRequest{ - Tx: wire.NewMsgTx(2), + Txs: []*wire.MsgTx{wire.NewMsgTx(2)}, }, expectedType: "TestMempoolAcceptRequest", isChainSourceMsg: true, diff --git a/darepod/logging.go b/darepod/logging.go index 8de2a8964..fefe76e27 100644 --- a/darepod/logging.go +++ b/darepod/logging.go @@ -37,6 +37,7 @@ var allSubsystems = []string{ lndbackend.Subsystem, indexer.Subsystem, db.Subsystem, + "TXCF", } // SetupLoggersWithShutdownFn registers all subsystem loggers using a plain diff --git a/lndbackend/boarding_backend.go b/lndbackend/boarding_backend.go index 26537ed81..78637ac52 100644 --- a/lndbackend/boarding_backend.go +++ b/lndbackend/boarding_backend.go @@ -6,12 +6,14 @@ import ( "context" "fmt" "log/slog" + "time" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btclog/v2" "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/wtxmgr" "github.com/lightninglabs/darepo-client/build" "github.com/lightninglabs/darepo-client/wallet" "github.com/lightninglabs/lndclient" @@ -193,5 +195,32 @@ func (l *BoardingBackend) GetBlock(ctx context.Context, return block, nil } -// Compile-time check that BoardingBackend implements wallet.BoardingBackend. -var _ wallet.BoardingBackend = (*BoardingBackend)(nil) +// LeaseOutput forwards the reservation to LND's WalletKit, which +// translates into an RPC call that sets the coin-selection lock on +// the remote LND node. The darepo-local wallet.LockID is passed +// through as wtxmgr.LockID — both are [32]byte so the translation is +// a direct cast rather than a mapping table, and the LockID therefore +// round-trips across restarts for release. +func (l *BoardingBackend) LeaseOutput(ctx context.Context, + id wallet.LockID, op wire.OutPoint, + expiry time.Duration) (time.Time, error) { + + return l.walletKit.LeaseOutput(ctx, wtxmgr.LockID(id), op, expiry) +} + +// ReleaseOutput forwards the unlock to LND's WalletKit. The supplied +// LockID must match the one used at lease time; mismatches surface as +// an RPC error from LND so misuse fails loudly rather than silently +// releasing someone else's lease. +func (l *BoardingBackend) ReleaseOutput(ctx context.Context, + id wallet.LockID, op wire.OutPoint) error { + + return l.walletKit.ReleaseOutput(ctx, wtxmgr.LockID(id), op) +} + +// Compile-time check that BoardingBackend implements wallet.BoardingBackend +// and wallet.OutputLeaser. +var ( + _ wallet.BoardingBackend = (*BoardingBackend)(nil) + _ wallet.OutputLeaser = (*BoardingBackend)(nil) +) diff --git a/lwwallet/boarding_backend.go b/lwwallet/boarding_backend.go index 9b3f4ff09..bd5a4139c 100644 --- a/lwwallet/boarding_backend.go +++ b/lwwallet/boarding_backend.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" "math" + "time" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" @@ -12,6 +13,7 @@ import ( "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btclog/v2" + "github.com/btcsuite/btcwallet/wtxmgr" "github.com/lightninglabs/darepo-client/wallet" "github.com/lightninglabs/darepo-client/walletcore" "github.com/lightningnetwork/lnd/lnwallet/btcwallet" @@ -202,6 +204,33 @@ func (b *BoardingBackendAdapter) GetBlock(ctx context.Context, return block, nil } +// LeaseOutput forwards the reservation to btcwallet's native +// coin-selection lock table. Even though lwwallet relies on Esplora +// for UTXO enumeration, the underlying btcwallet still owns key +// material and signs transactions, so coin-selection locks held there +// correctly exclude leased outputs from any subsequent signing or +// broadcast path. +// +// The darepo-local wallet.LockID is re-interpreted as wtxmgr.LockID: +// both are [32]byte, so the translation is a direct cast. +func (b *BoardingBackendAdapter) LeaseOutput(_ context.Context, + id wallet.LockID, op wire.OutPoint, + expiry time.Duration) (time.Time, error) { + + return b.BtcWallet.LeaseOutput(wtxmgr.LockID(id), op, expiry) +} + +// ReleaseOutput forwards the unlock to btcwallet. The LockID must +// match the one used at lease time. +func (b *BoardingBackendAdapter) ReleaseOutput(_ context.Context, + id wallet.LockID, op wire.OutPoint) error { + + return b.BtcWallet.ReleaseOutput(wtxmgr.LockID(id), op) +} + // Compile-time check that BoardingBackendAdapter implements -// wallet.BoardingBackend. -var _ wallet.BoardingBackend = (*BoardingBackendAdapter)(nil) +// wallet.BoardingBackend and wallet.OutputLeaser. +var ( + _ wallet.BoardingBackend = (*BoardingBackendAdapter)(nil) + _ wallet.OutputLeaser = (*BoardingBackendAdapter)(nil) +) diff --git a/lwwallet/chain_backend.go b/lwwallet/chain_backend.go index e8443641f..5fa0588f0 100644 --- a/lwwallet/chain_backend.go +++ b/lwwallet/chain_backend.go @@ -245,9 +245,9 @@ func (b *ChainBackend) BestBlock(_ context.Context) (int32, // TestMempoolAccept is not supported by the Esplora backend. This matches // the LND backend behavior. func (b *ChainBackend) TestMempoolAccept(_ context.Context, - _ *wire.MsgTx) (bool, string, error) { + _ ...*wire.MsgTx) ([]chainsource.MempoolAcceptResult, error) { - return false, "", fmt.Errorf("test mempool accept not supported " + + return nil, fmt.Errorf("test mempool accept not supported " + "by Esplora backend") } diff --git a/lwwallet/chain_backend_test.go b/lwwallet/chain_backend_test.go index 69841d85b..1d0717c34 100644 --- a/lwwallet/chain_backend_test.go +++ b/lwwallet/chain_backend_test.go @@ -261,12 +261,9 @@ func TestChainBackendTestMempoolAccept(t *testing.T) { esplora := NewEsploraClient("http://unused", btclog.Disabled) backend := NewChainBackend(esplora, time.Hour, btclog.Disabled) - ok, reason, err := backend.TestMempoolAccept( - t.Context(), nil, - ) + results, err := backend.TestMempoolAccept(t.Context()) require.Error(t, err) - require.False(t, ok) - require.Empty(t, reason) + require.Nil(t, results) require.Contains(t, err.Error(), "not supported") } diff --git a/txconfirm/AGENTS.md b/txconfirm/AGENTS.md new file mode 100644 index 000000000..d29c6685a --- /dev/null +++ b/txconfirm/AGENTS.md @@ -0,0 +1,132 @@ +# txconfirm + +## Purpose + +Generic "broadcast this signed tx, tell me when it confirms, and fee-bump it +via CPFP until it does" actor. Subsystem-neutral: no unroll/, vtxo/, oor/, or +round/ semantics leak in. Callers submit a signed v3/TRUC parent via +`EnsureConfirmedReq` and receive a terminal `TxConfirmed` or `TxFailed` +notification. Dedup is by txid: two callers asking to confirm the same txid +share a single confirmation watch, broadcast attempt, and CPFP child, but +each still receives its own terminal notification. + +## Key Types + +- `TxBroadcasterActor` — Message-driven orchestrator (in `actor.go`). Holds + a txid-keyed tracked-tx map, runs a protofsm lifecycle per txid, and + fans chainsource callbacks (confirmations, block epochs) back into + per-txid state transitions. +- `CPFPBroadcaster` — Actor-free helper (in `broadcaster.go`) that handles + broadcast mechanics: direct submission for txs without anchors, CPFP + child construction for anchor parents, fee estimation, script-aware + child vsize estimation, fee-input selection and reservation, BIP-125 + Rule 3/4 replacement floor enforcement, and optional TestMempoolAccept + preflight. Usable standalone if a caller only needs the broadcast + primitives. +- `Wallet` — Wallet interface the broadcaster requires: `ListUnspent`, + `NewWalletPkScript`, `FinalizePsbt`, plus `wallet.OutputLeaser` + (`LeaseOutput` / `ReleaseOutput`) for cross-subsystem UTXO lock + coordination. +- `EnsureConfirmedReq` / `EnsureConfirmedResp` — Public Ask API: register + interest in a txid with a `TargetConfs`, `ConfirmationPkScript`, and a + subscriber that receives the terminal notification. +- `CancelInterestReq` / `CancelInterestResp` — Public Ask API: drop a + subscriber; the last subscriber's cancel also tears down tracking. +- `TxConfirmed` / `TxFailed` — Terminal `Notification` types delivered to + each subscriber. +- `TxState` (`New`, `Broadcasting`, `AwaitingConfirmation`, `FeeBumping`, + `Confirmed`, `Failed`) — Public view of the per-txid protofsm state. +- `ErrNonTRUCParent` — Sentinel returned by `Submit` when the parent is + not v3/TRUC. +- `ErrCPFPFeeInputUnavailable` — Sentinel returned when no confirmed + wallet UTXO is available for the CPFP fee input. +- `ErrEnsureParamsMismatch` — Sentinel returned when a second caller + asks to confirm an already-tracked txid with a different `TargetConfs` + or `ConfirmationPkScript`. + +## Relationships + +- **Depends on**: + - `baselib/actor` — actor framework for the orchestrator. + - `baselib/protofsm` — per-txid state machine engine. + - `chainsource` — confirmation watches, block epochs, broadcast, + package submission, fee estimation, preflight. + - `wallet` — `Utxo`, `OutputLeaser`, `LockID` types for fee-input + selection and wallet-level lease coordination. + - `lib/tx/arktx` — canonical `TxVersion` (v3/TRUC) constant and + `IsAnchorOutput` predicate for CPFP targeting. +- **Depended on by**: (currently no internal callers — new package; future + wiring will plug `TxBroadcasterActor` into unroll / refresh / oor flows + that previously rolled their own broadcast loops). +- **Sends**: + - → `chainsource` (Ask): `BestHeightRequest`, `SubscribeBlocksRequest`, + `RegisterConfRequest`, `UnregisterConfRequest`, `BroadcastTxRequest`, + `SubmitPackageRequest`, `TestMempoolAcceptRequest`, + `FeeEstimateRequest`. + - → `Wallet` (direct call): `ListUnspent`, `NewWalletPkScript`, + `FinalizePsbt`, `LeaseOutput`, `ReleaseOutput`. + - → Caller-supplied subscriber (Tell): `TxConfirmed`, `TxFailed`. +- **Receives**: + - ← `chainsource` (via mapped Tell refs): `BlockEpoch` (re-wrapped as + `blockEpochObservedMsg`), `ConfirmationEvent` (re-wrapped as + `confirmationObservedMsg`). + - ← API: `EnsureConfirmedReq`, `CancelInterestReq`. + +## Invariants + +- **Dedup check is strict**: two `EnsureConfirmedReq` for the same txid + must agree on `TargetConfs` and `ConfirmationPkScript`; mismatches are + rejected with `ErrEnsureParamsMismatch` rather than silently reusing the + existing watch. +- **TRUC version gate**: `CPFPBroadcaster.Submit` rejects parents whose + `Tx.Version != arktx.TxVersion` (v3). Pattern-based anchor detection + on non-v3 parents is structurally unsafe. +- **Replacement floor**: every fee bump runs through + `applyReplacementFloor` before selecting a fee input, enforcing + BIP-125 Rule 4 (strictly higher feerate) and Rule 3 (strictly higher + absolute fee by at least `IncrementalRelayFeeSatPerVByte * + packageVSize`) against the last successful submission for the same + parent txid. +- **Per-parent fee-input reservation**: each parent txid reserves the + wallet UTXO(s) it has committed to. Reservations survive block + boundaries and are released only when the parent is evicted + (terminal state) or when the CPFP child never reaches the mempool + (fallback / preflight reject / package error). A parent IS allowed + to re-pick UTXOs from its own reserved set, because TRUC package + RBF requires the new child to double-spend the previous child's fee + input. +- **Wallet-level lease coordination**: every reserved fee UTXO is also + leased via `Wallet.LeaseOutput` (caller-scoped `txconfirmLockID`) + and released on eviction / fallback. Lease errors are soft — the + in-memory reservation map is the source of truth — but the lease + closes a narrow cross-subsystem race. +- **Child vsize is script-aware**: `estimateChildVSize` uses + `input.TxWeightEstimator` with the actual fee-input and change + pkScripts (P2TR, P2WKH, nested-P2WKH, …) to size the CPFP child, + not a hard-coded constant. Unknown script classes fall back to + P2WKH (which over-estimates for P2TR, safe for Rule 4). +- **Child fee input signals RBF** (`MaxTxInSequenceNum - 2 = + 0xfffffffd`) as belt-and-suspenders; the anchor input keeps the + sentinel sequence value. +- **PSBT finalization matches by outpoint, not position**: + `signCPFPChild` locates the wallet-owned input by + `PreviousOutPoint`, so wallets that reorder inputs (e.g. BIP-69) or + add fee-bump inputs do not panic or silently mis-wire witnesses. +- **Service-key symmetry**: `RegisterConfRequest` and + `UnregisterConfRequest` both carry `PkScript` so chainsource's + txid+script keyed service-actor lookup resolves symmetrically; one + conf sub-actor per tracked tx. +- **Terminal eviction**: on Confirmed or Failed, the actor unregisters + chainsource subscriptions, stops the per-txid FSM goroutine, + releases per-parent broadcaster state (fee-bump history + + reservations + wallet leases), and deletes the tracked-tx entry. + Late callers arriving after eviction re-register from scratch and + receive an immediate `TxConfirmed` via the normal path if the tx is + already on chain. + +## Deep Docs + +- [`doc.go`](doc.go) — Package-level literate-programming overview + covering architecture, lifecycle, CPFP correctness invariants, PSBT + finalization, service-key round trip, and eviction. +- [ARCHITECTURE.md](../ARCHITECTURE.md) — System-wide package map. diff --git a/txconfirm/CLAUDE.md b/txconfirm/CLAUDE.md new file mode 100644 index 000000000..d29c6685a --- /dev/null +++ b/txconfirm/CLAUDE.md @@ -0,0 +1,132 @@ +# txconfirm + +## Purpose + +Generic "broadcast this signed tx, tell me when it confirms, and fee-bump it +via CPFP until it does" actor. Subsystem-neutral: no unroll/, vtxo/, oor/, or +round/ semantics leak in. Callers submit a signed v3/TRUC parent via +`EnsureConfirmedReq` and receive a terminal `TxConfirmed` or `TxFailed` +notification. Dedup is by txid: two callers asking to confirm the same txid +share a single confirmation watch, broadcast attempt, and CPFP child, but +each still receives its own terminal notification. + +## Key Types + +- `TxBroadcasterActor` — Message-driven orchestrator (in `actor.go`). Holds + a txid-keyed tracked-tx map, runs a protofsm lifecycle per txid, and + fans chainsource callbacks (confirmations, block epochs) back into + per-txid state transitions. +- `CPFPBroadcaster` — Actor-free helper (in `broadcaster.go`) that handles + broadcast mechanics: direct submission for txs without anchors, CPFP + child construction for anchor parents, fee estimation, script-aware + child vsize estimation, fee-input selection and reservation, BIP-125 + Rule 3/4 replacement floor enforcement, and optional TestMempoolAccept + preflight. Usable standalone if a caller only needs the broadcast + primitives. +- `Wallet` — Wallet interface the broadcaster requires: `ListUnspent`, + `NewWalletPkScript`, `FinalizePsbt`, plus `wallet.OutputLeaser` + (`LeaseOutput` / `ReleaseOutput`) for cross-subsystem UTXO lock + coordination. +- `EnsureConfirmedReq` / `EnsureConfirmedResp` — Public Ask API: register + interest in a txid with a `TargetConfs`, `ConfirmationPkScript`, and a + subscriber that receives the terminal notification. +- `CancelInterestReq` / `CancelInterestResp` — Public Ask API: drop a + subscriber; the last subscriber's cancel also tears down tracking. +- `TxConfirmed` / `TxFailed` — Terminal `Notification` types delivered to + each subscriber. +- `TxState` (`New`, `Broadcasting`, `AwaitingConfirmation`, `FeeBumping`, + `Confirmed`, `Failed`) — Public view of the per-txid protofsm state. +- `ErrNonTRUCParent` — Sentinel returned by `Submit` when the parent is + not v3/TRUC. +- `ErrCPFPFeeInputUnavailable` — Sentinel returned when no confirmed + wallet UTXO is available for the CPFP fee input. +- `ErrEnsureParamsMismatch` — Sentinel returned when a second caller + asks to confirm an already-tracked txid with a different `TargetConfs` + or `ConfirmationPkScript`. + +## Relationships + +- **Depends on**: + - `baselib/actor` — actor framework for the orchestrator. + - `baselib/protofsm` — per-txid state machine engine. + - `chainsource` — confirmation watches, block epochs, broadcast, + package submission, fee estimation, preflight. + - `wallet` — `Utxo`, `OutputLeaser`, `LockID` types for fee-input + selection and wallet-level lease coordination. + - `lib/tx/arktx` — canonical `TxVersion` (v3/TRUC) constant and + `IsAnchorOutput` predicate for CPFP targeting. +- **Depended on by**: (currently no internal callers — new package; future + wiring will plug `TxBroadcasterActor` into unroll / refresh / oor flows + that previously rolled their own broadcast loops). +- **Sends**: + - → `chainsource` (Ask): `BestHeightRequest`, `SubscribeBlocksRequest`, + `RegisterConfRequest`, `UnregisterConfRequest`, `BroadcastTxRequest`, + `SubmitPackageRequest`, `TestMempoolAcceptRequest`, + `FeeEstimateRequest`. + - → `Wallet` (direct call): `ListUnspent`, `NewWalletPkScript`, + `FinalizePsbt`, `LeaseOutput`, `ReleaseOutput`. + - → Caller-supplied subscriber (Tell): `TxConfirmed`, `TxFailed`. +- **Receives**: + - ← `chainsource` (via mapped Tell refs): `BlockEpoch` (re-wrapped as + `blockEpochObservedMsg`), `ConfirmationEvent` (re-wrapped as + `confirmationObservedMsg`). + - ← API: `EnsureConfirmedReq`, `CancelInterestReq`. + +## Invariants + +- **Dedup check is strict**: two `EnsureConfirmedReq` for the same txid + must agree on `TargetConfs` and `ConfirmationPkScript`; mismatches are + rejected with `ErrEnsureParamsMismatch` rather than silently reusing the + existing watch. +- **TRUC version gate**: `CPFPBroadcaster.Submit` rejects parents whose + `Tx.Version != arktx.TxVersion` (v3). Pattern-based anchor detection + on non-v3 parents is structurally unsafe. +- **Replacement floor**: every fee bump runs through + `applyReplacementFloor` before selecting a fee input, enforcing + BIP-125 Rule 4 (strictly higher feerate) and Rule 3 (strictly higher + absolute fee by at least `IncrementalRelayFeeSatPerVByte * + packageVSize`) against the last successful submission for the same + parent txid. +- **Per-parent fee-input reservation**: each parent txid reserves the + wallet UTXO(s) it has committed to. Reservations survive block + boundaries and are released only when the parent is evicted + (terminal state) or when the CPFP child never reaches the mempool + (fallback / preflight reject / package error). A parent IS allowed + to re-pick UTXOs from its own reserved set, because TRUC package + RBF requires the new child to double-spend the previous child's fee + input. +- **Wallet-level lease coordination**: every reserved fee UTXO is also + leased via `Wallet.LeaseOutput` (caller-scoped `txconfirmLockID`) + and released on eviction / fallback. Lease errors are soft — the + in-memory reservation map is the source of truth — but the lease + closes a narrow cross-subsystem race. +- **Child vsize is script-aware**: `estimateChildVSize` uses + `input.TxWeightEstimator` with the actual fee-input and change + pkScripts (P2TR, P2WKH, nested-P2WKH, …) to size the CPFP child, + not a hard-coded constant. Unknown script classes fall back to + P2WKH (which over-estimates for P2TR, safe for Rule 4). +- **Child fee input signals RBF** (`MaxTxInSequenceNum - 2 = + 0xfffffffd`) as belt-and-suspenders; the anchor input keeps the + sentinel sequence value. +- **PSBT finalization matches by outpoint, not position**: + `signCPFPChild` locates the wallet-owned input by + `PreviousOutPoint`, so wallets that reorder inputs (e.g. BIP-69) or + add fee-bump inputs do not panic or silently mis-wire witnesses. +- **Service-key symmetry**: `RegisterConfRequest` and + `UnregisterConfRequest` both carry `PkScript` so chainsource's + txid+script keyed service-actor lookup resolves symmetrically; one + conf sub-actor per tracked tx. +- **Terminal eviction**: on Confirmed or Failed, the actor unregisters + chainsource subscriptions, stops the per-txid FSM goroutine, + releases per-parent broadcaster state (fee-bump history + + reservations + wallet leases), and deletes the tracked-tx entry. + Late callers arriving after eviction re-register from scratch and + receive an immediate `TxConfirmed` via the normal path if the tx is + already on chain. + +## Deep Docs + +- [`doc.go`](doc.go) — Package-level literate-programming overview + covering architecture, lifecycle, CPFP correctness invariants, PSBT + finalization, service-key round trip, and eviction. +- [ARCHITECTURE.md](../ARCHITECTURE.md) — System-wide package map. diff --git a/txconfirm/actor.go b/txconfirm/actor.go new file mode 100644 index 000000000..1e56fe46e --- /dev/null +++ b/txconfirm/actor.go @@ -0,0 +1,1049 @@ +package txconfirm + +import ( + "bytes" + "context" + "errors" + "fmt" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/chainsource" + fn "github.com/lightningnetwork/lnd/fn/v2" +) + +const ( + // DefaultFeeBumpIntervalBlocks is the default number of new + // blocks to wait before retrying a still-unconfirmed transaction + // with a fresh CPFP child. + DefaultFeeBumpIntervalBlocks int32 = 2 +) + +// ErrEnsureParamsMismatch is returned by EnsureConfirmedReq when a second +// caller asks to confirm a txid that is already being tracked, but with +// different confirmation parameters (TargetConfs or ConfirmationPkScript) +// than the in-flight tracker. Silently reusing the existing entry would +// cause one subscriber to receive a notification that does not match the +// criteria it asked for, so the second request is rejected outright and +// the caller is responsible for reconciling. +var ErrEnsureParamsMismatch = errors.New( + "ensure params mismatch existing tracker", +) + +// Config configures the generic shared tx confirmation actor. +type Config struct { + // ChainSource provides the blockchain interface for best-height + // queries, + // confirmation watches, block subscriptions, fee estimation, and + // broadcast. + ChainSource actor.ActorRef[ + chainsource.ChainSourceMsg, chainsource.ChainSourceResp, + ] + + // Wallet provides confirmed fee inputs and PSBT finalization for anchor + // based CPFP children. + Wallet Wallet + + // Log is an optional logger. + Log fn.Option[btclog.Logger] + + // FeeBumpIntervalBlocks controls how many new blocks the actor waits + // before retrying an unconfirmed transaction. Zero falls back to + // DefaultFeeBumpIntervalBlocks. + FeeBumpIntervalBlocks int32 + + // MaxFeeRateSatPerVByte caps fee estimates used by the internal CPFP + // broadcaster. Zero falls back to DefaultMaxFeeRateSatPerVByte. + MaxFeeRateSatPerVByte int64 + + // IncrementalRelayFeeSatPerVByte is forwarded to the internal CPFP + // broadcaster to enforce BIP-125 Rule 4 on fee-bump replacements. + // Zero falls back to DefaultIncrementalRelayFeeSatPerVByte. + IncrementalRelayFeeSatPerVByte int64 + + // PreSubmitTestMempoolAccept is forwarded to the internal CPFP + // broadcaster. When true, every broadcast attempt is preflighted + // against ChainSource.TestMempoolAccept and rejected locally if + // the backend reports a policy violation. Safe to leave enabled on + // backends that do not implement testmempoolaccept — the + // unsupported case is downgraded to a soft-miss. + PreSubmitTestMempoolAccept bool +} + +// TxBroadcasterActor is a generic shared actor that deduplicates +// confirmation requests by txid and ensures transactions confirm on-chain. +// +// The actor is intentionally not tied to unrolling. Any subsystem can +// reuse it by providing signed transactions, an optional wallet for +// anchor-backed CPFP, and a subscriber reference for terminal +// notifications. +// +// Invariants upheld by this type (cross-reference the package doc): +// +// - Receive is single-threaded. All mutation of a.tracked, +// a.bestHeight, etc. happens from a single goroutine. +// +// - For every non-terminal entry in a.tracked, exactly one +// chainsource confirmation watch is registered and exactly one +// tracked-tx FSM goroutine is alive. Terminal entries hold neither. +// +// - A.tracked never contains terminal entries: evictTerminal is +// called immediately after the terminal notification fan-out. +// +// - The shared block subscription is started lazily on the first +// ensure request and torn down on OnStop. +type TxBroadcasterActor struct { + cfg Config + log btclog.Logger + + // selfRef receives mapped chainsource callbacks. + selfRef actor.TellOnlyRef[Msg] + + // broadcaster handles direct broadcast and anchor-aware CPFP package + // submission. + broadcaster *CPFPBroadcaster + + // tracked maps txid to its shared confirmation state. + tracked map[chainhash.Hash]*trackedTx + + // bestHeight is the last observed best block height. + bestHeight int32 + + // hasBestHeight reports whether bestHeight has been initialized. + hasBestHeight bool + + // blockSubscriptionActive reports whether the shared block subscription + // is active. + blockSubscriptionActive bool +} + +// trackedTx stores the actor-owned handle for one tracked txid. +// +// The struct is the actor's single source of truth about a tracked +// transaction: callers never hold a *trackedTx directly, they interact +// only via actor messages. Mutation happens exclusively from the actor +// goroutine so the fields are not mutex-guarded. +type trackedTx struct { + data trackedTxData + fsm *trackedTxStateMachine + + subscribers map[string]actor.TellOnlyRef[Notification] + + // confWatchRegistered reports whether a chainsource confirmation + // watch is currently active for this txid. It is flipped true by + // registerConfWatch on success and false by unregisterConfWatch on + // success. Terminal cleanup uses it to avoid redundant unregister + // round trips for entries whose watch was never registered (e.g. + // entries that failed during block-subscription setup). + confWatchRegistered bool +} + +// confirmationObservedMsg routes a chainsource confirmation callback back into +// the actor mailbox. +type confirmationObservedMsg struct { + actor.BaseMessage + txid chainhash.Hash + blockHeight int32 + numConfs uint32 +} + +// MessageType returns the stable message type identifier. +func (m *confirmationObservedMsg) MessageType() string { + return "confirmationObservedMsg" +} + +// txConfirmMsgSealed seals confirmationObservedMsg into the package message +// set. +func (m *confirmationObservedMsg) txConfirmMsgSealed() {} + +// blockEpochObservedMsg routes a chainsource block callback back into the +// actor mailbox. +type blockEpochObservedMsg struct { + actor.BaseMessage + height int32 +} + +// MessageType returns the stable message type identifier. +func (m *blockEpochObservedMsg) MessageType() string { + return "blockEpochObservedMsg" +} + +// txConfirmMsgSealed seals blockEpochObservedMsg into the package message set. +func (m *blockEpochObservedMsg) txConfirmMsgSealed() {} + +// NewTxBroadcasterActor creates a new generic shared tx confirmation actor +// behavior. +func NewTxBroadcasterActor(cfg Config) *TxBroadcasterActor { + if cfg.FeeBumpIntervalBlocks <= 0 { + cfg.FeeBumpIntervalBlocks = DefaultFeeBumpIntervalBlocks + } + + return &TxBroadcasterActor{ + cfg: cfg, + log: cfg.Log.UnwrapOr(btclog.Disabled), + broadcaster: NewCPFPBroadcaster(BroadcasterConfig{ + ChainSource: cfg.ChainSource, + Wallet: cfg.Wallet, + Log: cfg.Log, + MaxFeeRateSatPerVByte: cfg.MaxFeeRateSatPerVByte, + IncrementalRelayFeeSatPerVByte: cfg.IncrementalRelayFeeSatPerVByte, + PreSubmitTestMempoolAccept: cfg.PreSubmitTestMempoolAccept, + }), + tracked: make(map[chainhash.Hash]*trackedTx), + } +} + +// SetSelfRef sets the actor's self-reference so chainsource callbacks can be +// mapped back into the actor mailbox. +func (a *TxBroadcasterActor) SetSelfRef(ref actor.TellOnlyRef[Msg]) { + a.selfRef = ref +} + +// Receive processes one tx confirmation actor message. +func (a *TxBroadcasterActor) Receive(ctx context.Context, + msg Msg) fn.Result[Resp] { + + switch req := msg.(type) { + case *EnsureConfirmedReq: + resp, err := a.handleEnsure(ctx, req) + if err != nil { + return fn.Err[Resp](err) + } + + return fn.Ok[Resp](resp) + + case *CancelInterestReq: + resp, err := a.handleCancel(ctx, req) + if err != nil { + return fn.Err[Resp](err) + } + + return fn.Ok[Resp](resp) + + case *confirmationObservedMsg: + a.handleConfirmationObserved(ctx, req) + return fn.Ok[Resp](&EnsureConfirmedResp{ + Txid: req.txid, + State: TxStateConfirmed, + }) + + case *blockEpochObservedMsg: + a.handleBlockObserved(ctx, req) + return fn.Ok[Resp](&EnsureConfirmedResp{ + State: TxStateAwaitingConfirmation, + }) + + default: + return fn.Err[Resp](fmt.Errorf("unknown txconfirm message: %T", + msg)) + } +} + +// OnStop cleans up block and confirmation subscriptions held by the actor. +func (a *TxBroadcasterActor) OnStop(ctx context.Context) error { + var firstErr error + + if a.blockSubscriptionActive && a.selfRef != nil { + _, err := a.cfg.ChainSource.Ask( + ctx, &chainsource.UnsubscribeBlocksRequest{ + CallerID: a.blockCallerID(), + }, + ).Await(ctx).Unpack() + if err != nil && firstErr == nil { + firstErr = fmt.Errorf("unsubscribe blocks: %w", err) + } + } + + for _, entry := range a.tracked { + state, err := entry.currentTxState() + if err != nil { + if firstErr == nil { + firstErr = fmt.Errorf( + "current tx state %s: %w", + entry.data.Txid, err, + ) + } + + continue + } + + if state == TxStateConfirmed || state == TxStateFailed { + if entry.fsm != nil { + entry.fsm.Stop() + } + + // Still evict here: terminal entries can hold + // parent state between notifyConfirmed and the + // tracked-map delete if OnStop races against the + // tail end of a confirmation, and Evict is a + // no-op when parentStates has no entry. + a.broadcaster.Evict(ctx, entry.data.Txid) + + continue + } + + if err := a.unregisterConfWatch(ctx, entry); err != nil && + firstErr == nil { + + firstErr = err + } + + if entry.fsm != nil { + entry.fsm.Stop() + } + + // Release the broadcaster's per-parent bump state and any + // wallet-level fee-input lease it holds. Without this, a + // daemon restart leaves lease rows in backends that persist + // them across restarts (btcwallet, lndclient WalletKit) until + // their configured expiry fires, blocking unrelated wallet + // coin selection after restart. + a.broadcaster.Evict(ctx, entry.data.Txid) + } + + return firstErr +} + +// handleEnsure creates or reuses confirmation tracking for one txid. +func (a *TxBroadcasterActor) handleEnsure(ctx context.Context, + req *EnsureConfirmedReq) (*EnsureConfirmedResp, error) { + + if req == nil { + return nil, fmt.Errorf("ensure request required") + } + + if req.Tx == nil { + return nil, fmt.Errorf("ensure request tx required") + } + + if req.Subscriber == nil { + return nil, fmt.Errorf("ensure request subscriber required") + } + + if a.selfRef == nil { + return nil, fmt.Errorf("self ref must be set before use") + } + + txid := req.Tx.TxHash() + if existing, ok := a.tracked[txid]; ok { + if err := validateEnsureMatch(req, existing); err != nil { + return nil, err + } + + return a.attachExistingSubscriber( + ctx, existing, req.Subscriber, + ), nil + } + + if err := a.ensureBestHeight(ctx); err != nil { + return nil, fmt.Errorf("best height: %w", err) + } + + entry, err := a.newTrackedTx(ctx, req) + if err != nil { + return nil, err + } + a.tracked[txid] = entry + + if err := a.ensureBlockSubscription(ctx); err != nil { + a.failTrackedTx(ctx, entry, fmt.Sprintf( + "subscribe blocks: %v", err, + )) + + return a.ensureResp(entry, true), nil + } + + if err := a.registerConfWatch(ctx, entry); err != nil { + a.failTrackedTx(ctx, entry, fmt.Sprintf( + "register conf: %v", err, + )) + + return a.ensureResp(entry, true), nil + } + + if err := a.broadcastTrackedTx( + ctx, entry, TxStateBroadcasting, + ); err != nil { + if errors.Is(err, ErrCPFPFeeInputUnavailable) { + a.log.WarnS(ctx, + "Initial anchor broadcast waiting for CPFP fee input", + err, "txid", entry.data.Txid, + ) + + progress := trackedTxProgress{ + LastBroadcastHeight: a.bestHeight, + } + _ = a.advanceTrackedTxFSM( + ctx, entry, &trackedTxBroadcastAccepted{ + Progress: progress, + }, + ) + + return a.ensureResp(entry, true), nil + } + + a.failTrackedTx(ctx, entry, fmt.Sprintf( + "broadcast: %v", err, + )) + } + + return a.ensureResp(entry, true), nil +} + +// handleCancel removes one subscriber from one tracked txid. +func (a *TxBroadcasterActor) handleCancel(ctx context.Context, + req *CancelInterestReq) (*CancelInterestResp, error) { + + if req == nil { + return nil, fmt.Errorf("cancel request required") + } + + entry, ok := a.tracked[req.Txid] + if !ok { + return &CancelInterestResp{ + Txid: req.Txid, + }, nil + } + + _, removed := entry.subscribers[req.SubscriberID] + delete(entry.subscribers, req.SubscriberID) + + resp := &CancelInterestResp{ + Txid: req.Txid, + Removed: removed, + RemainingSubscribers: len(entry.subscribers), + } + + if len(entry.subscribers) != 0 { + return resp, nil + } + + state, err := entry.currentTxState() + if err != nil { + return nil, err + } + + if state == TxStateConfirmed || state == TxStateFailed { + return resp, nil + } + + if err := a.unregisterConfWatch(ctx, entry); err != nil { + a.log.WarnS(ctx, "Failed to unregister confirmation watch", + err, "txid", entry.data.Txid) + } + + if entry.fsm != nil { + entry.fsm.Stop() + } + + // Release the broadcaster's per-parent state so any wallet-level + // fee-input lease we took during broadcastWithCPFP is released + // immediately, rather than lingering until the wallet's auto-expiry. + // Without this, a caller who cancels before confirmation can starve + // subsequent broadcasts of the same UTXO for up to an hour. + a.broadcaster.Evict(ctx, entry.data.Txid) + + delete(a.tracked, entry.data.Txid) + resp.StoppedTracking = true + + return resp, nil +} + +// handleConfirmationObserved marks a tracked txid as confirmed and fans the +// result out to all subscribers. +func (a *TxBroadcasterActor) handleConfirmationObserved(ctx context.Context, + msg *confirmationObservedMsg) { + + entry, ok := a.tracked[msg.txid] + if !ok { + return + } + + state, err := entry.currentTxState() + if err != nil { + a.log.WarnS(ctx, "Failed to read tracked tx state", + err, "txid", entry.data.Txid) + return + } + + if state == TxStateConfirmed || state == TxStateFailed { + return + } + + if err := a.advanceTrackedTxFSM(ctx, entry, &trackedTxConfirmed{ + BlockHeight: msg.blockHeight, + }); err != nil { + a.log.WarnS(ctx, "Failed to confirm tracked tx FSM", + err, "txid", entry.data.Txid) + return + } + + if err := a.unregisterConfWatch(ctx, entry); err != nil { + a.log.WarnS(ctx, "Failed to unregister confirmation watch", + err, "txid", entry.data.Txid) + } + + a.notifyConfirmed(ctx, entry, msg.blockHeight, msg.numConfs) + a.evictTerminal(ctx, entry) +} + +// handleBlockObserved records a new best height and fee-bumps any +// eligible pending transactions. +// +// Fee-bump failures are intentionally non-terminal: the original +// broadcast is still live on the network and the confirmation watch +// remains active, so the tracked tx may still confirm on its own. We +// recover the FSM back to AwaitingConfirmation with the new height so +// the next block observation evaluates shouldFeeBump freshly — a bump +// attempt that failed at height H is not retried until at least +// FeeBumpIntervalBlocks have elapsed since H. +func (a *TxBroadcasterActor) handleBlockObserved(ctx context.Context, + msg *blockEpochObservedMsg) { + + if !a.hasBestHeight || msg.height > a.bestHeight { + a.bestHeight = msg.height + a.hasBestHeight = true + } + + for _, entry := range a.tracked { + if !a.shouldFeeBump(entry) { + continue + } + + if err := a.broadcastTrackedTx( + ctx, entry, TxStateFeeBumping, + ); err != nil { + // Fee-bump failures are non-terminal. The original + // broadcast is still live and the confirmation watch + // remains active, so the tx may still confirm without + // the bump. Recover the FSM back to + // AwaitingConfirmation with an updated broadcast + // height so the next bump waits the full interval. + a.log.WarnS(ctx, "Fee bump failed, will retry", + err, "txid", entry.data.Txid) + + progress := trackedTxProgress{ + LastBroadcastHeight: a.bestHeight, + } + _ = a.advanceTrackedTxFSM( + ctx, entry, &trackedTxBroadcastAccepted{ + Progress: progress, + }, + ) + } + } +} + +// attachExistingSubscriber attaches a new subscriber to an already-tracked +// txid or immediately replays a terminal result. +func (a *TxBroadcasterActor) attachExistingSubscriber( + ctx context.Context, entry *trackedTx, + subscriber actor.TellOnlyRef[Notification], +) *EnsureConfirmedResp { + + state, err := entry.currentFSMState() + if err != nil { + a.notifyOneFailed(ctx, subscriber, entry.data.Txid, + fmt.Sprintf("tracked tx state: %v", err)) + return &EnsureConfirmedResp{ + Txid: entry.data.Txid, + State: TxStateFailed, + } + } + + switch state := state.(type) { + case *trackedTxStateConfirmed: + confirmHeight, _ := trackedTxConfirmHeight(state) + a.notifyOneConfirmed(ctx, subscriber, entry.data.Txid, + confirmHeight, entry.data.TargetConfs) + + case *trackedTxStateFailed: + reason, _ := trackedTxFailureReason(state) + a.notifyOneFailed(ctx, subscriber, entry.data.Txid, reason) + + default: + entry.subscribers[subscriber.ID()] = subscriber + } + + return a.ensureResp(entry, false) +} + +// ensureResp constructs one EnsureConfirmedResp from the current entry state. +func (a *TxBroadcasterActor) ensureResp(entry *trackedTx, + created bool) *EnsureConfirmedResp { + + state, err := entry.currentTxState() + if err != nil { + state = TxStateFailed + } + + return &EnsureConfirmedResp{ + Txid: entry.data.Txid, + State: state, + Created: created, + } +} + +// newTrackedTx constructs the initial state for a newly-tracked txid. +func (a *TxBroadcasterActor) newTrackedTx(ctx context.Context, + req *EnsureConfirmedReq) (*trackedTx, error) { + + targetConfs := normalizeTargetConfs(req) + + txCopy := req.Tx.Copy() + txid := txCopy.TxHash() + confirmationPkScript, err := confirmationPkScriptForRequest(req, txCopy) + if err != nil { + return nil, err + } + heightHint := req.HeightHint + if heightHint == 0 { + heightHint = defaultHeightHint(a.bestHeight) + } + + fsmLog := a.log.WithPrefix("trackedtx(" + txid.String() + ")") + data := trackedTxData{ + Tx: txCopy, + Txid: txid, + ConfirmationPkScript: append( + []byte(nil), confirmationPkScript..., + ), + Label: req.Label, + HeightHint: heightHint, + TargetConfs: targetConfs, + } + fsm := newTrackedTxStateMachine(fsmLog, data) + fsm.Start(ctx) + + return &trackedTx{ + data: data, + fsm: fsm, + subscribers: map[string]actor.TellOnlyRef[Notification]{ + req.Subscriber.ID(): req.Subscriber, + }, + }, nil +} + +// defaultHeightHint derives a nonzero confirmation height hint from the +// actor's latest observed best height. +func defaultHeightHint(bestHeight int32) uint32 { + if bestHeight <= 0 { + return 1 + } + + return uint32(bestHeight) +} + +// normalizeTargetConfs returns the effective TargetConfs the actor will +// track for a request, applying the zero-value default (1) consistently +// with newTrackedTx. +func normalizeTargetConfs(req *EnsureConfirmedReq) uint32 { + if req.TargetConfs == 0 { + return 1 + } + + return req.TargetConfs +} + +// validateEnsureMatch checks that an incoming EnsureConfirmedReq is +// compatible with the already-tracked entry for the same txid. Two +// callers that share a txid must also agree on TargetConfs and +// ConfirmationPkScript, otherwise the confirmation notification one of +// them receives would not match the criteria it asked for. +func validateEnsureMatch(req *EnsureConfirmedReq, + existing *trackedTx) error { + + reqConfs := normalizeTargetConfs(req) + if reqConfs != existing.data.TargetConfs { + return fmt.Errorf("%w: txid=%s existing=%d incoming=%d", + ErrEnsureParamsMismatch, existing.data.Txid, + existing.data.TargetConfs, reqConfs) + } + + reqScript, err := confirmationPkScriptForRequest(req, req.Tx) + if err != nil { + return err + } + + if !bytes.Equal(reqScript, existing.data.ConfirmationPkScript) { + return fmt.Errorf("%w: txid=%s pkscript mismatch", + ErrEnsureParamsMismatch, existing.data.Txid) + } + + return nil +} + +// confirmationPkScriptForRequest returns the script txconfirm should watch for +// confirmations of the tracked transaction. +func confirmationPkScriptForRequest(req *EnsureConfirmedReq, + tx *wire.MsgTx) ([]byte, error) { + + if len(req.ConfirmationPkScript) != 0 { + return append([]byte(nil), req.ConfirmationPkScript...), nil + } + + if tx == nil { + return nil, fmt.Errorf("ensure request tx required") + } + + if len(tx.TxOut) == 0 { + return nil, fmt.Errorf("confirmation pkscript required") + } + + if len(tx.TxOut[0].PkScript) == 0 { + return nil, fmt.Errorf("confirmation pkscript required") + } + + return append([]byte(nil), tx.TxOut[0].PkScript...), nil +} + +// ensureBestHeight loads the current best block height on first use. +func (a *TxBroadcasterActor) ensureBestHeight(ctx context.Context) error { + if a.hasBestHeight { + return nil + } + + resp, err := a.cfg.ChainSource.Ask( + ctx, &chainsource.BestHeightRequest{}, + ).Await(ctx).Unpack() + if err != nil { + return err + } + + bestResp, ok := resp.(*chainsource.BestHeightResponse) + if !ok { + return fmt.Errorf("unexpected best height response %T", resp) + } + + a.bestHeight = bestResp.Height + a.hasBestHeight = true + + return nil +} + +// ensureBlockSubscription starts the shared block epoch subscription on first +// use. +func (a *TxBroadcasterActor) ensureBlockSubscription( + ctx context.Context) error { + + if a.blockSubscriptionActive { + return nil + } + + notifyRef := chainsource.MapBlockEpoch( + a.selfRef, + func(epoch chainsource.BlockEpoch) Msg { + return &blockEpochObservedMsg{ + height: epoch.Height, + } + }, + ) + + _, err := a.cfg.ChainSource.Ask( + ctx, &chainsource.SubscribeBlocksRequest{ + CallerID: a.blockCallerID(), + NotifyActor: fn.Some(notifyRef), + }, + ).Await(ctx).Unpack() + if err != nil { + return err + } + + a.blockSubscriptionActive = true + + return nil +} + +// registerConfWatch registers a confirmation watch for one tracked txid. +func (a *TxBroadcasterActor) registerConfWatch(ctx context.Context, + entry *trackedTx) error { + + txid := entry.data.Txid + notifyRef := chainsource.MapConfirmationEvent( + a.selfRef, + func(event chainsource.ConfirmationEvent) Msg { + return &confirmationObservedMsg{ + txid: event.Txid, + blockHeight: event.BlockHeight, + numConfs: event.NumConfs, + } + }, + ) + + _, err := a.cfg.ChainSource.Ask( + ctx, &chainsource.RegisterConfRequest{ + CallerID: a.confCallerID(entry.data.Txid), + Txid: &txid, + PkScript: append( + []byte(nil), entry.data.ConfirmationPkScript..., + ), + TargetConfs: entry.data.TargetConfs, + HeightHint: entry.data.HeightHint, + NotifyActor: fn.Some(notifyRef), + }, + ).Await(ctx).Unpack() + if err != nil { + return err + } + + entry.confWatchRegistered = true + + return nil +} + +// unregisterConfWatch unregisters the confirmation watch for one tracked +// txid. +// +// The unregister request must supply the same fields that were used at +// registration time — CallerID, Txid, PkScript, and TargetConfs — because +// chainsource derives the sub-actor's service key by hashing all four +// together. Omitting PkScript here (as an earlier revision of this file +// did) produces a different service key and silently leaks the conf +// sub-actor for every tracked txid. +func (a *TxBroadcasterActor) unregisterConfWatch(ctx context.Context, + entry *trackedTx) error { + + txid := entry.data.Txid + _, err := a.cfg.ChainSource.Ask( + ctx, &chainsource.UnregisterConfRequest{ + CallerID: a.confCallerID(entry.data.Txid), + Txid: &txid, + PkScript: append( + []byte(nil), entry.data.ConfirmationPkScript..., + ), + TargetConfs: entry.data.TargetConfs, + }, + ).Await(ctx).Unpack() + if err != nil { + return fmt.Errorf("unregister conf %s: %w", + entry.data.Txid, err) + } + + entry.confWatchRegistered = false + + return nil +} + +// broadcastTrackedTx submits one tracked transaction and records the latest +// broadcast metadata. +func (a *TxBroadcasterActor) broadcastTrackedTx(ctx context.Context, + entry *trackedTx, nextState TxState) error { + + var startEvent trackedTxEvent + switch nextState { + case TxStateBroadcasting: + startEvent = &trackedTxBroadcastStarted{} + + case TxStateFeeBumping: + startEvent = &trackedTxFeeBumpStarted{} + + default: + return fmt.Errorf("unexpected broadcast state %v", nextState) + } + + if err := a.advanceTrackedTxFSM(ctx, entry, startEvent); err != nil { + return err + } + + result, err := a.broadcaster.Submit( + ctx, a.bestHeight, &BroadcastRequest{ + Tx: entry.data.Tx, + Label: entry.data.Label, + }, + ) + if err != nil { + return err + } + + if err := a.advanceTrackedTxFSM( + ctx, entry, &trackedTxBroadcastAccepted{ + Progress: trackedTxProgress{ + LastBroadcastHeight: a.bestHeight, + CurrentFeeRate: result.FeeRate, + ChildTxid: copyHash(result.ChildTxid), + }, + }, + ); err != nil { + return err + } + + return nil +} + +// shouldFeeBump reports whether a tracked transaction is eligible for another +// broadcast attempt at the current height. +func (a *TxBroadcasterActor) shouldFeeBump(entry *trackedTx) bool { + state, err := entry.currentTxState() + if err != nil { + return false + } + + if state != TxStateAwaitingConfirmation { + return false + } + + currentState, err := entry.currentFSMState() + if err != nil { + return false + } + + lastBroadcastHeight := trackedTxLastBroadcastHeight(currentState) + if lastBroadcastHeight == 0 { + return false + } + + return a.bestHeight-lastBroadcastHeight >= + a.cfg.FeeBumpIntervalBlocks +} + +// failTrackedTx moves one tracked txid into terminal failure, notifies all +// current subscribers, and evicts the entry from the tracking map so the +// actor does not retain per-tx FSM goroutines and cached tx bytes for the +// rest of its lifetime. +func (a *TxBroadcasterActor) failTrackedTx(ctx context.Context, + entry *trackedTx, reason string) { + + if err := a.advanceTrackedTxFSM(ctx, entry, &trackedTxFailed{ + Reason: reason, + }); err != nil { + a.log.WarnS(ctx, "Failed to move tracked tx into terminal state", + err, "txid", entry.data.Txid) + } + a.notifyFailed(ctx, entry, reason) + a.evictTerminal(ctx, entry) +} + +// evictTerminal releases all resources held for one tracked tx that has +// reached a terminal state. +// +// Callers must have already moved the FSM into Confirmed/Failed and +// delivered all terminal notifications before calling evictTerminal. +// +// We unregister any still-held confirmation watch (the confirmation +// path already unregisters eagerly, but failure paths do not and the +// watch may still be outstanding), stop the per-tx FSM goroutine, and +// drop the entry from the tracking map. Without this step, a +// long-lived daemon accumulates one live FSM goroutine and one cached +// *wire.MsgTx per terminal txid — an O(total_txs_ever) leak even when +// the actor is otherwise idle. +// +// Eviction is unconditional in terminal paths, which means a late +// EnsureConfirmedReq for the same txid will start fresh tracking +// rather than replaying a cached result. That fresh tracking +// re-registers a conf watch with chainsource; if the tx is already +// confirmed on-chain chainsource fires the confirmation notification +// immediately, so the late subscriber still receives TxConfirmed at +// the cost of one extra chainsource round trip per late ensure. +func (a *TxBroadcasterActor) evictTerminal(ctx context.Context, + entry *trackedTx) { + + if entry.confWatchRegistered { + if err := a.unregisterConfWatch(ctx, entry); err != nil { + a.log.WarnS(ctx, "Failed to unregister confirmation "+ + "watch during terminal eviction", + err, "txid", entry.data.Txid) + } + } + + if entry.fsm != nil { + entry.fsm.Stop() + } + + // Release the broadcaster's per-parent bump state (fee-bump history + // used for BIP-125 Rule 3/4 enforcement) so it doesn't accumulate + // alongside the actor's own leak fix. The broadcaster also drops + // any wallet-level leases held on the parent's fee UTXOs so they + // become immediately available to other subsystems. + a.broadcaster.Evict(ctx, entry.data.Txid) + + delete(a.tracked, entry.data.Txid) +} + +// notifyConfirmed fans a confirmation result out to all current subscribers. +func (a *TxBroadcasterActor) notifyConfirmed(ctx context.Context, + entry *trackedTx, blockHeight int32, numConfs uint32) { + + for id, subscriber := range entry.subscribers { + a.notifyOneConfirmed( + ctx, subscriber, entry.data.Txid, blockHeight, numConfs, + ) + delete(entry.subscribers, id) + } +} + +// notifyFailed fans a terminal failure result out to all current subscribers. +func (a *TxBroadcasterActor) notifyFailed(ctx context.Context, + entry *trackedTx, reason string) { + + for id, subscriber := range entry.subscribers { + a.notifyOneFailed(ctx, subscriber, entry.data.Txid, reason) + delete(entry.subscribers, id) + } +} + +// notifyOneConfirmed delivers one confirmation notification. +func (a *TxBroadcasterActor) notifyOneConfirmed(ctx context.Context, + subscriber actor.TellOnlyRef[Notification], txid chainhash.Hash, + blockHeight int32, numConfs uint32) { + + if err := subscriber.Tell(ctx, &TxConfirmed{ + Txid: txid, + BlockHeight: blockHeight, + NumConfs: numConfs, + }); err != nil { + a.log.WarnS(ctx, "Failed to deliver tx confirmation", + err, "txid", txid, "subscriber_id", subscriber.ID()) + } +} + +// notifyOneFailed delivers one terminal failure notification. +func (a *TxBroadcasterActor) notifyOneFailed(ctx context.Context, + subscriber actor.TellOnlyRef[Notification], txid chainhash.Hash, + reason string) { + + if err := subscriber.Tell(ctx, &TxFailed{ + Txid: txid, + Reason: reason, + }); err != nil { + a.log.WarnS(ctx, "Failed to deliver tx failure", + err, "txid", txid, "subscriber_id", subscriber.ID()) + } +} + +// advanceTrackedTxFSM applies one event to the tracked-tx protofsm. +func (a *TxBroadcasterActor) advanceTrackedTxFSM(ctx context.Context, + entry *trackedTx, event trackedTxEvent) error { + + if entry.fsm == nil { + return fmt.Errorf("tracked tx fsm not initialized") + } + + _, err := entry.fsm.AskEvent(ctx, event).Await(ctx).Unpack() + + return err +} + +// confCallerID returns the deterministic chainsource caller ID for one txid +// confirmation watch. +func (a *TxBroadcasterActor) confCallerID(txid chainhash.Hash) string { + return a.selfRef.ID() + "-conf-" + txid.String() +} + +// blockCallerID returns the deterministic chainsource caller ID for the shared +// block subscription. +func (a *TxBroadcasterActor) blockCallerID() string { + return a.selfRef.ID() + "-blocks" +} + +// copyHash returns a heap-independent copy of an optional hash. +func copyHash(hash *chainhash.Hash) *chainhash.Hash { + if hash == nil { + return nil + } + + hashCopy := *hash + + return &hashCopy +} diff --git a/txconfirm/actor_test.go b/txconfirm/actor_test.go new file mode 100644 index 000000000..12dc63429 --- /dev/null +++ b/txconfirm/actor_test.go @@ -0,0 +1,1056 @@ +package txconfirm + +import ( + "bytes" + "context" + "fmt" + "sync" + "testing" + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/chainsource" + "github.com/lightninglabs/darepo-client/lib/arkscript" + "github.com/lightninglabs/darepo-client/wallet" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" +) + +// testTimeout is the default timeout used by txconfirm actor tests. +const testTimeout = time.Second + +// confNotifyRef is the confirmation-event notification ref type used in +// the fake chainsource test double. +type confNotifyRef = actor.TellOnlyRef[chainsource.ConfirmationEvent] + +// fakeChainSourceRef is a controllable chainsource actor ref used by unit +// tests. +type fakeChainSourceRef struct { + mu sync.Mutex + + bestHeight int32 + feeRate btcutil.Amount + + broadcastErr error + packageErr error + + // mempoolAcceptFn lets tests control the outcome of + // TestMempoolAcceptRequest. If nil, the fake returns + // "not supported" for every call so preflight code paths that + // treat unsupported as a soft-miss can still be exercised. + mempoolAcceptFn func( + txs []*wire.MsgTx, + ) ([]chainsource.MempoolAcceptResult, error) + + blockNotify actor.TellOnlyRef[chainsource.BlockEpoch] + confNotify map[chainhash.Hash]confNotifyRef + confConfs map[chainhash.Hash]uint32 + + alreadyConfirmed map[chainhash.Hash]chainsource.ConfirmationEvent + + broadcastCalls []*chainsource.BroadcastTxRequest + packageCalls []*chainsource.SubmitPackageRequest + registerConfs []*chainsource.RegisterConfRequest + unregisterConfs []*chainsource.UnregisterConfRequest + subscribeBlocks []*chainsource.SubscribeBlocksRequest + unsubscribeBlocks []*chainsource.UnsubscribeBlocksRequest + mempoolAcceptCalls [][]*wire.MsgTx +} + +// newFakeChainSourceRef creates a new controllable chainsource test double. +func newFakeChainSourceRef(bestHeight int32) *fakeChainSourceRef { + return &fakeChainSourceRef{ + bestHeight: bestHeight, + feeRate: 5, + confNotify: make(map[chainhash.Hash]confNotifyRef), + confConfs: make(map[chainhash.Hash]uint32), + alreadyConfirmed: make( + map[chainhash.Hash]chainsource.ConfirmationEvent, + ), + } +} + +// packageCallCount returns the number of recorded package submissions. +func (f *fakeChainSourceRef) packageCallCount() int { + f.mu.Lock() + defer f.mu.Unlock() + + return len(f.packageCalls) +} + +// broadcastCallCount returns the number of recorded direct broadcasts. +func (f *fakeChainSourceRef) broadcastCallCount() int { + f.mu.Lock() + defer f.mu.Unlock() + + return len(f.broadcastCalls) +} + +// registerConfCount returns the number of confirmation registrations. +func (f *fakeChainSourceRef) registerConfCount() int { + f.mu.Lock() + defer f.mu.Unlock() + + return len(f.registerConfs) +} + +// unregisterConfCount returns the number of confirmation unregistrations. +func (f *fakeChainSourceRef) unregisterConfCount() int { + f.mu.Lock() + defer f.mu.Unlock() + + return len(f.unregisterConfs) +} + +// ID returns the fake actor ID. +func (f *fakeChainSourceRef) ID() string { + return "fake-chainsource" +} + +// Tell satisfies the actor.ActorRef interface. +func (f *fakeChainSourceRef) Tell(_ context.Context, + _ chainsource.ChainSourceMsg) error { + + return nil +} + +// Ask handles the chainsource request synchronously and returns an already +// completed future. +func (f *fakeChainSourceRef) Ask(ctx context.Context, + msg chainsource.ChainSourceMsg, +) actor.Future[chainsource.ChainSourceResp] { + + promise := actor.NewPromise[chainsource.ChainSourceResp]() + resp, err := f.handleAsk(ctx, msg) + if err != nil { + promise.Complete(fn.Err[chainsource.ChainSourceResp](err)) + } else { + promise.Complete(fn.Ok(resp)) + } + + return promise.Future() +} + +// handleAsk handles one chainsource message for the fake backend. +func (f *fakeChainSourceRef) handleAsk(_ context.Context, + msg chainsource.ChainSourceMsg) (chainsource.ChainSourceResp, error) { + + f.mu.Lock() + defer f.mu.Unlock() + + switch req := msg.(type) { + case *chainsource.BestHeightRequest: + return &chainsource.BestHeightResponse{ + Height: f.bestHeight, + }, nil + + case *chainsource.FeeEstimateRequest: + return &chainsource.FeeEstimateResponse{ + SatPerVByte: f.feeRate, + }, nil + + case *chainsource.BroadcastTxRequest: + f.broadcastCalls = append(f.broadcastCalls, req) + if f.broadcastErr != nil { + return nil, f.broadcastErr + } + + return &chainsource.BroadcastTxResponse{ + Txid: req.Tx.TxHash(), + }, nil + + case *chainsource.SubmitPackageRequest: + f.packageCalls = append(f.packageCalls, req) + if f.packageErr != nil { + return nil, f.packageErr + } + + return &chainsource.SubmitPackageResponse{}, nil + + case *chainsource.RegisterConfRequest: + f.registerConfs = append(f.registerConfs, req) + if req.Txid != nil && req.NotifyActor.IsSome() { + f.confNotify[*req.Txid] = req.NotifyActor.UnwrapOr(nil) + f.confConfs[*req.Txid] = req.TargetConfs + if event, ok := f.alreadyConfirmed[*req.Txid]; ok { + notifyRef := req.NotifyActor.UnwrapOr(nil) + _ = notifyRef.Tell(context.Background(), event) + } + } + + return &chainsource.RegisterConfResponse{}, nil + + case *chainsource.UnregisterConfRequest: + f.unregisterConfs = append(f.unregisterConfs, req) + if req.Txid != nil { + delete(f.confNotify, *req.Txid) + delete(f.confConfs, *req.Txid) + } + + return &chainsource.UnregisterConfResponse{}, nil + + case *chainsource.SubscribeBlocksRequest: + f.subscribeBlocks = append(f.subscribeBlocks, req) + f.blockNotify = req.NotifyActor.UnwrapOr(nil) + return &chainsource.SubscribeBlocksResponse{}, nil + + case *chainsource.UnsubscribeBlocksRequest: + f.unsubscribeBlocks = append(f.unsubscribeBlocks, req) + f.blockNotify = nil + return &chainsource.UnsubscribeBlocksResponse{}, nil + + case *chainsource.TestMempoolAcceptRequest: + f.mempoolAcceptCalls = append(f.mempoolAcceptCalls, req.Txs) + + if f.mempoolAcceptFn == nil { + return nil, fmt.Errorf( + "test mempool accept not supported") + } + + results, err := f.mempoolAcceptFn(req.Txs) + if err != nil { + return nil, err + } + + return &chainsource.TestMempoolAcceptResponse{ + Results: results, + }, nil + + default: + return nil, fmt.Errorf( + "unsupported chainsource message %T", msg, + ) + } +} + +// emitConfirmation delivers a confirmation event for one tracked txid. +func (f *fakeChainSourceRef) emitConfirmation(t *testing.T, + txid chainhash.Hash, blockHeight int32) { + + t.Helper() + + f.mu.Lock() + notifyRef := f.confNotify[txid] + targetConfs := f.confConfs[txid] + f.mu.Unlock() + + require.NotNil(t, notifyRef) + err := notifyRef.Tell(t.Context(), chainsource.ConfirmationEvent{ + Txid: txid, + BlockHeight: blockHeight, + NumConfs: targetConfs, + }) + require.NoError(t, err) +} + +// emitBlock delivers a new block epoch to the shared block subscriber. +func (f *fakeChainSourceRef) emitBlock(t *testing.T, height int32) { + t.Helper() + + f.mu.Lock() + f.bestHeight = height + notifyRef := f.blockNotify + f.mu.Unlock() + + require.NotNil(t, notifyRef) + err := notifyRef.Tell(t.Context(), chainsource.BlockEpoch{ + Height: height, + }) + require.NoError(t, err) +} + +// fakeWallet is a minimal wallet test double for CPFP child construction. +type fakeWallet struct { + listErr error + utxos []*wallet.Utxo + + leaseErr error + leaseCalls []wire.OutPoint + leaseExpiryLast time.Duration + leaseLockID wallet.LockID + + releaseErr error + releaseCalls []wire.OutPoint + releaseLockID wallet.LockID +} + +// ListUnspent returns the configured confirmed UTXOs. +func (w *fakeWallet) ListUnspent(_ context.Context, + _, _ int32) ([]*wallet.Utxo, error) { + + return w.utxos, w.listErr +} + +// NewWalletPkScript returns a fresh deterministic change script. +func (w *fakeWallet) NewWalletPkScript(_ context.Context) ([]byte, error) { + return []byte{txscript.OP_TRUE}, nil +} + +// FinalizePsbt finalizes the PSBT with dummy witnesses for all wallet-owned +// inputs. +func (w *fakeWallet) FinalizePsbt(_ context.Context, + packetBytes []byte) (*wire.MsgTx, error) { + + packet, err := psbt.NewFromRawBytes(bytes.NewReader(packetBytes), false) + if err != nil { + return nil, err + } + + tx := packet.UnsignedTx.Copy() + for i := range tx.TxIn { + if len(packet.Inputs[i].FinalScriptWitness) > 0 { + tx.TxIn[i].Witness = wire.TxWitness{} + continue + } + + tx.TxIn[i].Witness = wire.TxWitness{ + make([]byte, 64), + } + } + + return tx, nil +} + +// LeaseOutput records the lease call and returns a fixed expiry plus +// the configured error (if any). Tests that care about lease behaviour +// can inspect leaseCalls and leaseLockID. +func (w *fakeWallet) LeaseOutput(_ context.Context, id wallet.LockID, + op wire.OutPoint, expiry time.Duration) (time.Time, error) { + + w.leaseCalls = append(w.leaseCalls, op) + w.leaseExpiryLast = expiry + w.leaseLockID = id + if w.leaseErr != nil { + return time.Time{}, w.leaseErr + } + + return time.Now().Add(expiry), nil +} + +// ReleaseOutput records the release call and returns the configured +// error (if any). +func (w *fakeWallet) ReleaseOutput(_ context.Context, id wallet.LockID, + op wire.OutPoint) error { + + w.releaseCalls = append(w.releaseCalls, op) + w.releaseLockID = id + + return w.releaseErr +} + +// newTestActor creates and starts a txconfirm actor plus its behavior. +func newTestActor(t *testing.T, cfg Config) (*actor.Actor[Msg, Resp], + *TxBroadcasterActor) { + + t.Helper() + + behavior := NewTxBroadcasterActor(cfg) + actorInstance := actor.NewActor(actor.ActorConfig[Msg, Resp]{ + ID: "txconfirm-test", + Behavior: behavior, + MailboxSize: 64, + }) + behavior.SetSelfRef(actorInstance.TellRef()) + actorInstance.Start() + t.Cleanup(actorInstance.Stop) + + return actorInstance, behavior +} + +// mustEnsure sends an EnsureConfirmedReq and returns the typed response. +func mustEnsure(t *testing.T, ref actor.ActorRef[Msg, Resp], + req *EnsureConfirmedReq) *EnsureConfirmedResp { + + t.Helper() + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + resp, err := ref.Ask(ctx, req).Await(ctx).Unpack() + require.NoError(t, err) + + typed, ok := resp.(*EnsureConfirmedResp) + require.True(t, ok) + + return typed +} + +// mustCancel sends a CancelInterestReq and returns the typed response. +func mustCancel(t *testing.T, ref actor.ActorRef[Msg, Resp], + req *CancelInterestReq) *CancelInterestResp { + + t.Helper() + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + resp, err := ref.Ask(ctx, req).Await(ctx).Unpack() + require.NoError(t, err) + + typed, ok := resp.(*CancelInterestResp) + require.True(t, ok) + + return typed +} + +// mustAwaitNotification waits for exactly one notification from the supplied +// subscriber channel. +func mustAwaitNotification(t *testing.T, + ref *actor.ChannelTellOnlyRef[Notification]) Notification { + + t.Helper() + + msg, ok := ref.AwaitMessage(testTimeout) + require.True(t, ok, "expected notification") + + return msg +} + +// mustHaveNoNotification verifies that no notification arrives before the +// timeout expires. +func mustHaveNoNotification(t *testing.T, + ref *actor.ChannelTellOnlyRef[Notification]) { + + t.Helper() + + msg, ok := ref.AwaitMessage(100 * time.Millisecond) + require.False(t, ok, "unexpected notification: %v", msg) +} + +// mustEventually packages a polling assertion with the default test timeout. +func mustEventually(t *testing.T, predicate func() bool, msgAndArgs ...any) { + t.Helper() + + require.Eventually(t, predicate, testTimeout, 10*time.Millisecond, + msgAndArgs...) +} + +// makeTestTx constructs a simple signed transaction for tests. +func makeTestTx(withAnchor bool) *wire.MsgTx { + tx := wire.NewMsgTx(3) + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: 0, + }, + }) + tx.AddTxOut(&wire.TxOut{ + Value: 10_000, + PkScript: []byte{txscript.OP_TRUE}, + }) + if withAnchor { + tx.AddTxOut(arkscript.AnchorOutput()) + } + + return tx +} + +// makeWalletUTXO constructs a confirmed wallet UTXO suitable for CPFP +// fee-input selection. The PkScript is a real P2TR script so fee +// estimation against this UTXO exercises the script-aware vsize path +// rather than the non-standard fallback. +func makeWalletUTXO() *wallet.Utxo { + return &wallet.Utxo{ + Outpoint: wire.OutPoint{ + Hash: chainhash.Hash{2}, + Index: 1, + }, + Amount: 50_000, + PkScript: p2trTestPkScript(), + } +} + +// p2trTestPkScript returns a fixed, canonical P2TR pkScript +// (OP_1 <32-byte x-only key>) used across broadcaster tests that need +// a realistic wallet output shape for fee estimation. +func p2trTestPkScript() []byte { + var xOnly [32]byte + for i := range xOnly { + xOnly[i] = byte(i + 1) + } + script, err := txscript.NewScriptBuilder(). + AddOp(txscript.OP_1). + AddData(xOnly[:]). + Script() + if err != nil { + panic(err) + } + + return script +} + +// TestEnsureConfirmedDedupesTwoSubscribers verifies that the actor deduplicates +// by txid while notifying all subscribers on confirmation. +func TestEnsureConfirmedDedupesTwoSubscribers(t *testing.T) { + chain := newFakeChainSourceRef(100) + ref, _ := newTestActor(t, Config{ + ChainSource: chain, + }) + + tx := makeTestTx(false) + subA := actor.NewChannelTellOnlyRef[Notification]("sub-a", 4) + subB := actor.NewChannelTellOnlyRef[Notification]("sub-b", 4) + + firstResp := mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ + Tx: tx, + Subscriber: subA, + }) + secondResp := mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ + Tx: tx, + Subscriber: subB, + }) + + require.True(t, firstResp.Created) + require.False(t, secondResp.Created) + require.Equal(t, TxStateAwaitingConfirmation, firstResp.State) + require.Equal(t, TxStateAwaitingConfirmation, secondResp.State) + require.Equal(t, 1, chain.broadcastCallCount()) + require.Equal(t, 1, chain.registerConfCount()) + + chain.emitConfirmation(t, tx.TxHash(), 101) + + confirmedA := mustAwaitNotification(t, subA) + confirmedB := mustAwaitNotification(t, subB) + + require.IsType(t, &TxConfirmed{}, confirmedA) + require.IsType(t, &TxConfirmed{}, confirmedB) + mustEventually(t, func() bool { + return chain.unregisterConfCount() == 1 + }) +} + +// TestEnsureConfirmedRejectsMismatchedTargetConfs verifies that a second +// caller asking to confirm the same txid with a different TargetConfs +// value than the in-flight tracker is rejected with +// ErrEnsureParamsMismatch instead of silently sharing the existing +// tracker. +func TestEnsureConfirmedRejectsMismatchedTargetConfs(t *testing.T) { + chain := newFakeChainSourceRef(100) + ref, _ := newTestActor(t, Config{ + ChainSource: chain, + }) + + tx := makeTestTx(false) + subA := actor.NewChannelTellOnlyRef[Notification]("sub-a", 4) + subB := actor.NewChannelTellOnlyRef[Notification]("sub-b", 4) + + firstResp := mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ + Tx: tx, + TargetConfs: 1, + Subscriber: subA, + }) + require.True(t, firstResp.Created) + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + _, err := ref.Ref().Ask(ctx, &EnsureConfirmedReq{ + Tx: tx, + TargetConfs: 3, + Subscriber: subB, + }).Await(ctx).Unpack() + require.ErrorIs(t, err, ErrEnsureParamsMismatch) +} + +// TestEnsureConfirmedRejectsMismatchedPkScript verifies that a second +// caller asking to confirm the same txid with a different +// ConfirmationPkScript than the in-flight tracker is rejected rather +// than silently reusing the existing watch (which keys on the original +// script). +func TestEnsureConfirmedRejectsMismatchedPkScript(t *testing.T) { + chain := newFakeChainSourceRef(100) + ref, _ := newTestActor(t, Config{ + ChainSource: chain, + }) + + tx := makeTestTx(false) + subA := actor.NewChannelTellOnlyRef[Notification]("sub-a", 4) + subB := actor.NewChannelTellOnlyRef[Notification]("sub-b", 4) + + firstResp := mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ + Tx: tx, + ConfirmationPkScript: tx.TxOut[0].PkScript, + Subscriber: subA, + }) + require.True(t, firstResp.Created) + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + defer cancel() + + _, err := ref.Ref().Ask(ctx, &EnsureConfirmedReq{ + Tx: tx, + ConfirmationPkScript: []byte{0x00, 0x20, 0x01, 0x02}, + Subscriber: subB, + }).Await(ctx).Unpack() + require.ErrorIs(t, err, ErrEnsureParamsMismatch) +} + +// TestEnsureConfirmedAlreadyConfirmedUsesSuccessPath verifies that a +// transaction already confirmed elsewhere is treated as success. +func TestEnsureConfirmedAlreadyConfirmedUsesSuccessPath(t *testing.T) { + chain := newFakeChainSourceRef(100) + tx := makeTestTx(false) + txid := tx.TxHash() + chain.alreadyConfirmed[txid] = chainsource.ConfirmationEvent{ + Txid: txid, + BlockHeight: 99, + NumConfs: 1, + } + chain.broadcastErr = fmt.Errorf("already in block chain") + + ref, _ := newTestActor(t, Config{ + ChainSource: chain, + }) + + subA := actor.NewChannelTellOnlyRef[Notification]("sub-a", 4) + subB := actor.NewChannelTellOnlyRef[Notification]("sub-b", 4) + + resp := mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ + Tx: tx, + Subscriber: subA, + }) + require.True(t, resp.Created) + + msg := mustAwaitNotification(t, subA) + confirmed, ok := msg.(*TxConfirmed) + require.True(t, ok) + require.Equal(t, int32(99), confirmed.BlockHeight) + + // Once subA's TxConfirmed has been delivered, terminal eviction drops + // the tracked entry. A subsequent EnsureConfirmedReq for the same + // txid therefore starts fresh tracking rather than replaying cached + // state. Chainsource immediately re-fires the confirmation for the + // already-confirmed tx, so subB still receives TxConfirmed. + replayResp := mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ + Tx: tx, + Subscriber: subB, + }) + require.True(t, replayResp.Created) + + replayed := mustAwaitNotification(t, subB) + require.IsType(t, &TxConfirmed{}, replayed) + require.Equal(t, 2, chain.broadcastCallCount()) + require.Equal(t, 2, chain.registerConfCount()) +} + +// TestEnsureConfirmedBroadcastFailureNotifiesFailure verifies that terminal +// broadcast errors transition the tracked txid to failed and notify the +// subscriber. +func TestEnsureConfirmedBroadcastFailureNotifiesFailure(t *testing.T) { + chain := newFakeChainSourceRef(100) + chain.broadcastErr = fmt.Errorf("mempool reject") + ref, _ := newTestActor(t, Config{ + ChainSource: chain, + }) + + tx := makeTestTx(false) + subA := actor.NewChannelTellOnlyRef[Notification]("sub-a", 4) + subB := actor.NewChannelTellOnlyRef[Notification]("sub-b", 4) + + resp := mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ + Tx: tx, + Subscriber: subA, + }) + require.Equal(t, TxStateFailed, resp.State) + + failed := mustAwaitNotification(t, subA) + failedMsg, ok := failed.(*TxFailed) + require.True(t, ok) + require.Contains(t, failedMsg.Reason, "broadcast") + + // Terminal eviction means the subsequent ensure creates a fresh + // tracked entry. The second broadcast hits the same configured + // mempool reject and the fresh entry transitions into Failed, so + // subB still receives TxFailed. + replayResp := mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ + Tx: tx, + Subscriber: subB, + }) + require.True(t, replayResp.Created) + require.Equal(t, TxStateFailed, replayResp.State) + + replayed := mustAwaitNotification(t, subB) + require.IsType(t, &TxFailed{}, replayed) +} + +// TestCancelInterestStopsTracking verifies that removing the final subscriber +// drops the active watch and prevents later callbacks from notifying it. +func TestCancelInterestStopsTracking(t *testing.T) { + chain := newFakeChainSourceRef(100) + walletRef := &fakeWallet{ + utxos: []*wallet.Utxo{makeWalletUTXO()}, + } + ref, _ := newTestActor(t, Config{ + ChainSource: chain, + Wallet: walletRef, + FeeBumpIntervalBlocks: 1, + }) + + tx := makeTestTx(true) + sub := actor.NewChannelTellOnlyRef[Notification]("sub-a", 4) + mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ + Tx: tx, + Subscriber: sub, + }) + + cancelResp := mustCancel(t, ref.Ref(), &CancelInterestReq{ + Txid: tx.TxHash(), + SubscriberID: sub.ID(), + }) + require.True(t, cancelResp.Removed) + require.True(t, cancelResp.StoppedTracking) + mustEventually(t, func() bool { + return chain.unregisterConfCount() == 1 + }) + require.Equal(t, 1, chain.packageCallCount()) + + // The anchor tx held a wallet-level lease on its CPFP fee input. + // Canceling the last subscriber must release that lease under the + // same LockID — otherwise the UTXO stays locked until the wallet's + // configured expiry and starves later broadcasts. + require.Equal(t, walletRef.leaseCalls, walletRef.releaseCalls, + "every leased outpoint must be released on cancel") + require.Equal(t, txconfirmLockID, walletRef.releaseLockID) + + chain.emitBlock(t, 101) + require.Equal(t, 1, chain.packageCallCount()) + mustHaveNoNotification(t, sub) +} + +// TestOnStopEvictsWalletLeases verifies that stopping the actor while an +// anchor-bearing tracked tx is still in flight releases the wallet-level +// fee-input lease. Without this, a restart leaves the lease pinned until +// the backend's configured expiry, blocking unrelated coin selection. +func TestOnStopEvictsWalletLeases(t *testing.T) { + chain := newFakeChainSourceRef(100) + walletRef := &fakeWallet{ + utxos: []*wallet.Utxo{makeWalletUTXO()}, + } + ref, behavior := newTestActor(t, Config{ + ChainSource: chain, + Wallet: walletRef, + FeeBumpIntervalBlocks: 1, + }) + + tx := makeTestTx(true) + sub := actor.NewChannelTellOnlyRef[Notification]("sub-a", 4) + mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ + Tx: tx, + Subscriber: sub, + }) + + // Sanity check: the CPFP path should have leased a UTXO. + require.Len(t, walletRef.leaseCalls, 1) + require.Empty(t, walletRef.releaseCalls, + "lease must still be held before OnStop") + + require.NoError(t, behavior.OnStop(t.Context())) + + // Every previously-leased outpoint must have a matching release + // call under the same txconfirm LockID. + require.Equal(t, walletRef.leaseCalls, walletRef.releaseCalls, + "OnStop must release every active fee-input lease") + require.Equal(t, txconfirmLockID, walletRef.releaseLockID) +} + +// TestFeeBumpOnNewBlocks verifies that block-height observations trigger a +// rebroadcast after the configured interval. +func TestFeeBumpOnNewBlocks(t *testing.T) { + chain := newFakeChainSourceRef(100) + walletRef := &fakeWallet{ + utxos: []*wallet.Utxo{makeWalletUTXO()}, + } + ref, _ := newTestActor(t, Config{ + ChainSource: chain, + Wallet: walletRef, + FeeBumpIntervalBlocks: 2, + }) + + tx := makeTestTx(true) + sub := actor.NewChannelTellOnlyRef[Notification]("sub-a", 4) + resp := mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ + Tx: tx, + Subscriber: sub, + }) + require.Equal(t, TxStateAwaitingConfirmation, resp.State) + require.Equal(t, 1, chain.packageCallCount()) + + chain.emitBlock(t, 101) + require.Equal(t, 1, chain.packageCallCount()) + + chain.emitBlock(t, 102) + mustEventually(t, func() bool { + return chain.packageCallCount() == 2 + }) + + chain.emitConfirmation(t, tx.TxHash(), 103) + confirmed := mustAwaitNotification(t, sub) + require.IsType(t, &TxConfirmed{}, confirmed) +} + +// TestEnsureConfirmedWaitsForInitialCPFPInput verifies that an anchor parent +// stays retryable when its first broadcast attempt lacks a confirmed fee +// input. +func TestEnsureConfirmedWaitsForInitialCPFPInput(t *testing.T) { + chain := newFakeChainSourceRef(100) + walletRef := &fakeWallet{ + listErr: fmt.Errorf("list failed"), + } + ref, _ := newTestActor(t, Config{ + ChainSource: chain, + Wallet: walletRef, + FeeBumpIntervalBlocks: 2, + }) + + tx := makeTestTx(true) + sub := actor.NewChannelTellOnlyRef[Notification]("sub-a", 4) + resp := mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ + Tx: tx, + Subscriber: sub, + }) + require.Equal(t, TxStateAwaitingConfirmation, resp.State) + require.Equal(t, 0, chain.packageCallCount()) + require.Equal(t, 0, chain.broadcastCallCount()) + mustHaveNoNotification(t, sub) + + chain.emitBlock(t, 101) + require.Equal(t, 0, chain.packageCallCount()) + + chain.emitBlock(t, 102) + require.Equal(t, 0, chain.packageCallCount()) +} + +// TestEnsureConfirmedRepeatedEnsureIsIdempotent verifies that repeating the +// same ensure request for one subscriber does not duplicate work. +func TestEnsureConfirmedRepeatedEnsureIsIdempotent(t *testing.T) { + chain := newFakeChainSourceRef(100) + ref, _ := newTestActor(t, Config{ + ChainSource: chain, + }) + + tx := makeTestTx(false) + sub := actor.NewChannelTellOnlyRef[Notification]("sub-a", 4) + + firstResp := mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ + Tx: tx, + Subscriber: sub, + }) + secondResp := mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ + Tx: tx, + Subscriber: sub, + }) + + require.True(t, firstResp.Created) + require.False(t, secondResp.Created) + require.Equal(t, 1, chain.broadcastCallCount()) + require.Equal(t, 1, chain.registerConfCount()) + + chain.emitConfirmation(t, tx.TxHash(), 101) + confirmed := mustAwaitNotification(t, sub) + require.IsType(t, &TxConfirmed{}, confirmed) + mustHaveNoNotification(t, sub) +} + +// TestEnsureConfirmedRegistersConfirmationPkScript verifies that txconfirm +// registers the same confirmation script old unroller used: an explicit caller +// override when present, otherwise the first tx output script. +func TestEnsureConfirmedRegistersConfirmationPkScript(t *testing.T) { + chain := newFakeChainSourceRef(100) + ref, _ := newTestActor(t, Config{ + ChainSource: chain, + }) + + tx := makeTestTx(false) + subA := actor.NewChannelTellOnlyRef[Notification]("sub-a", 4) + + mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ + Tx: tx, + Subscriber: subA, + }) + + require.Len(t, chain.registerConfs, 1) + require.Equal( + t, tx.TxOut[0].PkScript, chain.registerConfs[0].PkScript, + ) + require.Equal(t, uint32(100), chain.registerConfs[0].HeightHint) + + explicitPkScript := []byte{txscript.OP_FALSE, txscript.OP_TRUE} + subB := actor.NewChannelTellOnlyRef[Notification]("sub-b", 4) + overrideTx := makeTestTx(false) + overrideTx.TxIn[0].PreviousOutPoint.Hash = chainhash.Hash{9} + + mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ + Tx: overrideTx, + ConfirmationPkScript: explicitPkScript, + HeightHint: 55, + Subscriber: subB, + }) + + require.Len(t, chain.registerConfs, 2) + require.Equal(t, explicitPkScript, chain.registerConfs[1].PkScript) + require.Equal(t, uint32(55), chain.registerConfs[1].HeightHint) +} + +// TestUnregisterConfMatchesRegisterServiceKey verifies that every field +// chainsource hashes into a conf-actor service key (CallerID, Txid, +// PkScript, TargetConfs) is present in both the Register and Unregister +// requests with identical values. An earlier revision of this package +// omitted PkScript from the unregister request, producing a service key +// that did not match the one chainsource created at register time and +// silently leaking one conf sub-actor per tracked tx. This test is the +// white-box guard against that regression. +func TestUnregisterConfMatchesRegisterServiceKey(t *testing.T) { + chain := newFakeChainSourceRef(100) + ref, _ := newTestActor(t, Config{ + ChainSource: chain, + }) + + tx := makeTestTx(false) + sub := actor.NewChannelTellOnlyRef[Notification]("sub-a", 4) + + mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ + Tx: tx, + Subscriber: sub, + }) + + chain.emitConfirmation(t, tx.TxHash(), 101) + + confirmed := mustAwaitNotification(t, sub) + require.IsType(t, &TxConfirmed{}, confirmed) + + mustEventually(t, func() bool { + return chain.unregisterConfCount() == 1 + }) + + require.Len(t, chain.registerConfs, 1) + require.Len(t, chain.unregisterConfs, 1) + + reg := chain.registerConfs[0] + unreg := chain.unregisterConfs[0] + + require.Equal(t, reg.CallerID, unreg.CallerID, + "unregister must reuse the register CallerID") + require.Equal(t, reg.Txid, unreg.Txid, + "unregister must reuse the register Txid") + require.Equal(t, reg.PkScript, unreg.PkScript, + "unregister must include the same PkScript as the register; "+ + "dropping it produces a different service key and "+ + "leaks the conf sub-actor") + require.Equal(t, reg.TargetConfs, unreg.TargetConfs, + "unregister must reuse the register TargetConfs") +} + +// TestTerminalEntriesEvictedAfterConfirmation verifies that once a tracked +// transaction reaches Confirmed and all subscribers have been notified, the +// actor evicts the entry and does not retain per-tx FSM goroutines or +// cached transaction bytes. This guards against the unbounded a.tracked +// growth pattern flagged by review finding H-1. +func TestTerminalEntriesEvictedAfterConfirmation(t *testing.T) { + chain := newFakeChainSourceRef(100) + ref, _ := newTestActor(t, Config{ + ChainSource: chain, + }) + + // Track three independent transactions end-to-end. Once all three + // confirm, the actor should have zero entries retained. A single + // txid would verify the eviction mechanism; using a batch ensures + // we are not accidentally re-observing the same slot. + const numTxs = 3 + subs := make([]*actor.ChannelTellOnlyRef[Notification], numTxs) + txids := make([]chainhash.Hash, numTxs) + for i := 0; i < numTxs; i++ { + tx := makeTestTx(false) + tx.TxIn[0].PreviousOutPoint.Hash = chainhash.Hash{byte(i + 10)} + txids[i] = tx.TxHash() + + id := fmt.Sprintf("sub-%d", i) + subs[i] = actor.NewChannelTellOnlyRef[Notification](id, 4) + + mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ + Tx: tx, + Subscriber: subs[i], + }) + } + + for i := 0; i < numTxs; i++ { + chain.emitConfirmation(t, txids[i], 101) + + confirmed := mustAwaitNotification(t, subs[i]) + require.IsType(t, &TxConfirmed{}, confirmed) + } + + // Every confirmation should have produced exactly one unregister. + mustEventually(t, func() bool { + return chain.unregisterConfCount() == numTxs + }) + + // If eviction worked, issuing a Cancel for any of the confirmed + // txids finds no tracked entry, so Removed is false and the + // returned txid simply mirrors the request. This is the + // externally-observable proxy for "len(a.tracked) == 0" without + // racing against the actor goroutine. + for i := 0; i < numTxs; i++ { + cancelResp := mustCancel(t, ref.Ref(), &CancelInterestReq{ + Txid: txids[i], + SubscriberID: subs[i].ID(), + }) + require.False(t, cancelResp.Removed, + "terminal entry %d should have been evicted before "+ + "cancel", i) + require.Equal(t, 0, cancelResp.RemainingSubscribers) + } + + // A fresh EnsureConfirmedReq for an already-terminated txid must + // create a new entry rather than attach to a cached terminal one. + // This is the other side of the eviction contract. + freshSub := actor.NewChannelTellOnlyRef[Notification]("sub-fresh", 4) + fresh := makeTestTx(false) + fresh.TxIn[0].PreviousOutPoint.Hash = chainhash.Hash{byte(10)} + require.Equal(t, txids[0], fresh.TxHash()) + + resp := mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ + Tx: fresh, + Subscriber: freshSub, + }) + require.True(t, resp.Created, + "late ensure for a previously-confirmed txid should start "+ + "fresh tracking after terminal eviction") +} + +// TestTerminalEntryEvictedAfterFailure verifies that failTrackedTx evicts +// the entry from the actor's tracking map, matching the confirmation path +// so long-lived daemons do not accumulate failed entries indefinitely. +func TestTerminalEntryEvictedAfterFailure(t *testing.T) { + chain := newFakeChainSourceRef(100) + chain.broadcastErr = fmt.Errorf("mempool reject") + + ref, _ := newTestActor(t, Config{ + ChainSource: chain, + }) + + tx := makeTestTx(false) + sub := actor.NewChannelTellOnlyRef[Notification]("sub-a", 4) + + resp := mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ + Tx: tx, + Subscriber: sub, + }) + require.Equal(t, TxStateFailed, resp.State) + + failed := mustAwaitNotification(t, sub) + require.IsType(t, &TxFailed{}, failed) + + // Cancel-as-probe: if the failed entry was evicted, the cancel + // finds nothing and reports Removed=false. + cancelResp := mustCancel(t, ref.Ref(), &CancelInterestReq{ + Txid: tx.TxHash(), + SubscriberID: sub.ID(), + }) + require.False(t, cancelResp.Removed, + "failed entry should have been evicted before cancel") +} diff --git a/txconfirm/broadcaster.go b/txconfirm/broadcaster.go new file mode 100644 index 000000000..ebdb92336 --- /dev/null +++ b/txconfirm/broadcaster.go @@ -0,0 +1,1335 @@ +package txconfirm + +import ( + "bytes" + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/chainsource" + "github.com/lightninglabs/darepo-client/lib/tx/arktx" + "github.com/lightninglabs/darepo-client/wallet" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/input" +) + +const ( + // DefaultMaxFeeRateSatPerVByte clamps fee estimates to a sane ceiling. + // On regtest or freshly synced nodes, the estimator can return wildly + // inflated rates. + DefaultMaxFeeRateSatPerVByte int64 = 100 + + // DustLimit is the minimum useful value for the CPFP child change + // output. Values below this are donated to fees. + DustLimit = btcutil.Amount(330) + + // DefaultIncrementalRelayFeeSatPerVByte is the default per-vbyte + // bandwidth cost a fee-bump replacement must pay in addition to + // the original package's absolute fee. It matches Bitcoin Core's + // default node setting and is used by the CPFP fee-bump loop to + // enforce BIP-125 Rule 4 (replacement must pay at least its own + // relay bandwidth on top of the replaced package's fee). + DefaultIncrementalRelayFeeSatPerVByte int64 = 1 + + // DefaultFeeInputLeaseExpiry is how long the broadcaster asks the + // wallet to lease a CPFP fee-input UTXO. The lease is explicitly + // released on terminal eviction and on fallback paths, so this + // expiry is a backstop against leaks in the unlikely event the + // owning actor disappears before calling Evict. + DefaultFeeInputLeaseExpiry = time.Hour +) + +var ( + // ErrCPFPFeeInputUnavailable indicates that an anchor + // parent still needs a confirmed wallet fee input before + // it can be broadcast safely. + ErrCPFPFeeInputUnavailable = errors.New( + "cpfp fee input unavailable", + ) + + // ErrNonTRUCParent indicates that the caller submitted a parent + // transaction whose version is not v3 (TRUC). txconfirm relies on + // BIP-431 ephemeral-anchor and TRUC-package semantics for its + // CPFP fee-bump strategy: without v3, package RBF replacement + // rules and the zero-fee anchor are not policy-legal on a + // standard Bitcoin Core mempool, and anchor detection becomes + // structurally ambiguous (a legitimate output script could match + // the anyone-can-spend anchor pattern by accident). We therefore + // reject non-v3 parents at the Submit boundary rather than + // silently attaching a CPFP child that would never relay. + ErrNonTRUCParent = errors.New( + "parent transaction must be v3 (TRUC) for CPFP broadcast", + ) +) + +// txconfirmLockID is the package-scoped LockID used by CPFPBroadcaster +// when leasing fee-input UTXOs via the Wallet interface. It is derived +// from the ASCII string "darepo-client:txconfirm" padded to 32 bytes +// so concurrent subsystems using a different LockID prefix cannot +// release txconfirm's leases by mistake. The value is a compile-time +// constant: callers do not need to synchronise LockIDs across restarts +// because the broadcaster already rebuilds its in-memory reservation +// state from per-parent FSM progress on recovery. +var txconfirmLockID = func() wallet.LockID { + var id wallet.LockID + copy(id[:], "darepo-client:txconfirm") + + return id +}() + +// Wallet provides the wallet operations needed by the broadcaster for +// CPFP fee payment. The OutputLeaser contract (LeaseOutput / +// ReleaseOutput with an explicit caller-scoped LockID) matches the +// canonical shape exposed by btcwallet and lndclient's WalletKit, so +// concrete backends can delegate directly to their underlying wallet. +// Wallets that lack native lease support may return nil from the +// lease/release methods: the broadcaster's own per-parent reservation +// map still prevents intra-package races, and lease errors are +// treated as soft misses by the caller. +type Wallet interface { + // ListUnspent returns confirmed wallet UTXOs usable as CPFP fee inputs. + ListUnspent(ctx context.Context, + minConfs, maxConfs int32) ([]*wallet.Utxo, error) + + // NewWalletPkScript returns a fresh wallet-managed pkScript + // suitable for + // change outputs. + NewWalletPkScript(ctx context.Context) ([]byte, error) + + // FinalizePsbt signs and finalizes a PSBT packet. The wallet signs all + // inputs it owns and returns the finalized wire tx. + FinalizePsbt(ctx context.Context, packet []byte) (*wire.MsgTx, error) + + wallet.OutputLeaser +} + +// FeeInput is a confirmed wallet UTXO selected for CPFP fee payment. +type FeeInput struct { + // Outpoint identifies the wallet UTXO. + Outpoint wire.OutPoint + + // Output is the output being spent for fees. + Output *wire.TxOut + + // Confirmed indicates whether this UTXO is confirmed. + Confirmed bool +} + +// BroadcastRequest describes a signed transaction to broadcast. +type BroadcastRequest struct { + // Tx is the fully signed parent transaction. + Tx *wire.MsgTx + + // Label is a human-readable label for logging. + Label string +} + +// BroadcastResult describes the outcome of one broadcast attempt. +type BroadcastResult struct { + // Txid is the parent transaction hash. + Txid chainhash.Hash + + // ChildTxid is set when a CPFP child was built and submitted. + ChildTxid *chainhash.Hash + + // FeeRate is the fee rate used in sat/vB. + FeeRate int64 +} + +// BroadcasterConfig configures the generic CPFP broadcaster helper. +type BroadcasterConfig struct { + // ChainSource provides fee estimation, package submission, and direct + // transaction broadcast. + ChainSource actor.ActorRef[ + chainsource.ChainSourceMsg, chainsource.ChainSourceResp, + ] + + // Wallet provides UTXO selection and PSBT signing for CPFP children. + Wallet Wallet + + // Log is an optional logger. + Log fn.Option[btclog.Logger] + + // MaxFeeRateSatPerVByte caps fee estimates. Zero falls back to + // DefaultMaxFeeRateSatPerVByte. + MaxFeeRateSatPerVByte int64 + + // IncrementalRelayFeeSatPerVByte is the minimum additional feerate + // a fee-bump replacement must pay on top of the package it replaces + // (BIP-125 Rule 4). Zero falls back to + // DefaultIncrementalRelayFeeSatPerVByte. Operators whose Bitcoin + // Core nodes override -incrementalrelayfee should pass the matching + // value here so our bumps always clear the local node's policy. + IncrementalRelayFeeSatPerVByte int64 + + // PreSubmitTestMempoolAccept enables an opt-in pre-submit call to + // ChainSource.TestMempoolAccept before every broadcast attempt. + // When set, the broadcaster asks the backend to validate each + // transaction (or the full parent+child package for CPFP paths) + // against local node policy; a backend "accepted = false" result + // aborts the submission with a clear error containing the reject + // reason. Backends that do not implement testmempoolaccept return + // "not supported" which is logged and treated as a soft-miss so + // this flag is safe to leave enabled across heterogeneous + // deployments. + PreSubmitTestMempoolAccept bool +} + +// parentBumpState records everything the broadcaster needs to enforce +// BIP-125 Rule 3 (absolute-fee) and Rule 4 (incremental-feerate) across +// successive fee-bump submissions for the same parent transaction, and +// to keep a stable set of fee-input reservations across blocks so two +// concurrent parents never race for the same wallet UTXO. +// +// A fresh parent has no entry; the first Submit establishes one when it +// selects a fee input. Subsequent submissions for the same parent txid +// read this state, floor their fee/feerate high enough to replace the +// previous package, submit, and overwrite the fee-rate/fee fields with +// the new values. The UsedFeeOutpoints set is additive: once a UTXO has +// been committed to a parent's submissions it stays reserved for that +// parent (and off-limits to other parents) until the parent is evicted. +type parentBumpState struct { + // LastFeeRate is the sat/vB feerate of the most recent successful + // package submission for this parent. + LastFeeRate int64 + + // LastPackageFee is the absolute package fee (parent + child, in + // sats) paid by the most recent successful submission for this + // parent. + LastPackageFee btcutil.Amount + + // UsedFeeOutpoints is the set of wallet UTXOs this parent's child + // packages have consumed across the parent's submission history. + // It survives block boundaries: a UTXO we already committed to a + // parent's child stays reserved for that parent (and excluded from + // other parents' selections) until Evict fires on terminal. + // + // For the parent itself, re-picking a UTXO from its own reserved + // set is allowed — that is how TRUC package RBF replaces an + // earlier child by double-spending the same fee input, which is + // the mechanism the replacement actually lands on. + UsedFeeOutpoints map[wire.OutPoint]struct{} +} + +// CPFPBroadcaster broadcasts signed transactions and automatically attaches a +// CPFP child when the transaction contains an anchor output. +// +// CPFPBroadcaster is not safe for concurrent use. The outer txconfirm actor +// serializes access. +type CPFPBroadcaster struct { + cfg BroadcasterConfig + log btclog.Logger + + // parentStates records per-parent-txid fee-bump history (for + // BIP-125 Rule 3/4 enforcement) and per-parent fee-input + // reservations (so two parents never race for the same wallet + // UTXO). Entries are populated as parents first select fee inputs + // and released via Evict when the caller's FSM learns the parent + // has terminally confirmed or failed. + parentStates map[chainhash.Hash]*parentBumpState +} + +// NewCPFPBroadcaster creates a new generic CPFP broadcaster helper. +func NewCPFPBroadcaster(cfg BroadcasterConfig) *CPFPBroadcaster { + if cfg.MaxFeeRateSatPerVByte <= 0 { + cfg.MaxFeeRateSatPerVByte = DefaultMaxFeeRateSatPerVByte + } + if cfg.IncrementalRelayFeeSatPerVByte <= 0 { + cfg.IncrementalRelayFeeSatPerVByte = + DefaultIncrementalRelayFeeSatPerVByte + } + + return &CPFPBroadcaster{ + cfg: cfg, + log: cfg.Log.UnwrapOr(btclog.Disabled), + parentStates: make(map[chainhash.Hash]*parentBumpState), + } +} + +// Evict releases all per-parent state (fee-bump history and fee-input +// reservations) recorded for the supplied parent txid. Callers must +// invoke Evict once the tracked tx reaches a terminal state (Confirmed +// or Failed) so the broadcaster does not retain state indefinitely and +// so the parent's reserved UTXOs become available to other parents. +// +// Evict also releases any wallet-level leases held on the parent's +// reserved UTXOs (best effort; release failures are logged but do not +// block eviction, since the in-memory reservation map is the source +// of truth and wallet-level leases will auto-expire). +func (b *CPFPBroadcaster) Evict(ctx context.Context, + txid chainhash.Hash) { + + state, ok := b.parentStates[txid] + if !ok { + return + } + + for op := range state.UsedFeeOutpoints { + b.releaseWalletLease(ctx, op) + } + + delete(b.parentStates, txid) +} + +// reserveFeeOutpoint records that the given parent txid is consuming the +// supplied wallet outpoint and asks the wallet to lease it so that the +// wallet's own coin selection will not hand it to another subsystem +// while the CPFP child is in flight. A failure from the wallet-level +// lease is logged but does not abort the broadcast: the in-memory +// reservation map in parentStates is authoritative for intra-package +// coordination. +func (b *CPFPBroadcaster) reserveFeeOutpoint(ctx context.Context, + parentTxid chainhash.Hash, op wire.OutPoint) { + + state := b.parentStates[parentTxid] + if state == nil { + state = &parentBumpState{ + UsedFeeOutpoints: make(map[wire.OutPoint]struct{}), + } + b.parentStates[parentTxid] = state + } + if state.UsedFeeOutpoints == nil { + state.UsedFeeOutpoints = make(map[wire.OutPoint]struct{}) + } + + // If we already reserved this outpoint for this parent, the + // wallet already has a lease; re-leasing extends the expiry, + // which is exactly what we want on a fee bump that re-picks the + // same UTXO for TRUC RBF double-spend. + state.UsedFeeOutpoints[op] = struct{}{} + + if b.cfg.Wallet == nil { + return + } + + _, err := b.cfg.Wallet.LeaseOutput( + ctx, txconfirmLockID, op, DefaultFeeInputLeaseExpiry, + ) + if err != nil { + b.log.WarnS(ctx, "Wallet-level lease failed; relying on "+ + "in-memory reservation only", + err, "parent", parentTxid, "outpoint", op) + } +} + +// releaseWalletLease calls wallet.ReleaseOutput with the package-scoped +// LockID, silently ignoring "unknown output" / "not leased" class +// errors since those are a normal consequence of a wallet that is +// already past this lease (expired or garbage-collected). +func (b *CPFPBroadcaster) releaseWalletLease(ctx context.Context, + op wire.OutPoint) { + + if b.cfg.Wallet == nil { + return + } + + err := b.cfg.Wallet.ReleaseOutput(ctx, txconfirmLockID, op) + if err != nil { + b.log.WarnS(ctx, "Wallet-level lease release failed", + err, "outpoint", op) + } +} + +// releaseFeeOutpoint removes the given wallet outpoint from the parent's +// reserved set and releases the wallet-level lease held on it. Called +// on fallback/failure paths where the CPFP child that would have spent +// the outpoint never actually reached the mempool, so holding the +// reservation just starves other parents without any TRUC RBF +// double-spend to protect. +func (b *CPFPBroadcaster) releaseFeeOutpoint(ctx context.Context, + parentTxid chainhash.Hash, op wire.OutPoint) { + + state, ok := b.parentStates[parentTxid] + if !ok || state.UsedFeeOutpoints == nil { + return + } + + if _, held := state.UsedFeeOutpoints[op]; !held { + return + } + + delete(state.UsedFeeOutpoints, op) + + b.releaseWalletLease(ctx, op) + + // If the parent has no fee history and no remaining reservations, + // drop the empty entry entirely so parentStates does not accumulate + // zero-value shells. + if state.LastFeeRate == 0 && state.LastPackageFee == 0 && + len(state.UsedFeeOutpoints) == 0 { + + delete(b.parentStates, parentTxid) + } +} + +// excludedOutpointsForOtherParents returns the set of wallet UTXOs +// currently reserved by parents other than the supplied one. The caller +// uses this to exclude those UTXOs from fee-input selection so two +// concurrent parents can never try to spend the same UTXO. +func (b *CPFPBroadcaster) excludedOutpointsForOtherParents( + parentTxid chainhash.Hash) map[wire.OutPoint]struct{} { + + excluded := make(map[wire.OutPoint]struct{}) + for otherTxid, state := range b.parentStates { + if otherTxid == parentTxid { + continue + } + for op := range state.UsedFeeOutpoints { + excluded[op] = struct{}{} + } + } + + return excluded +} + +// Submit broadcasts a signed transaction. If the transaction contains an +// anchor output, Submit constructs a CPFP child and submits the package. +// +// Submit deliberately takes a single *wire.MsgTx rather than a +// caller-assembled package. The CPFP child is always derived here from +// wallet state the caller does not (and should not) have direct access +// to: a freshly-derived change pkScript, a confirmed wallet fee input +// that has to be coordinated against the per-parent reservation map to +// avoid cross-parent UTXO races, PSBT finalization via the wallet +// interface, and BIP-125 Rule 3/4 floor arithmetic that depends on the +// previous submission for the same parent txid. Letting the caller +// pre-build a package would force those concerns to leak out of this +// package; letting the caller hand us a pre-signed child would break +// deduplication (the broadcaster must own the child so it can regenerate +// a fresh one on every fee bump). The single-parent signature therefore +// models the only contract the broadcaster is prepared to guarantee: +// "give me a signed parent, I'll handle the rest, including the CPFP +// child and its fee-bump lifecycle." +// +// Parents that are not v3 (TRUC) are rejected with ErrNonTRUCParent: the +// whole CPFP fee-bump strategy in this package assumes BIP-431 semantics +// for anchor-bearing transactions, and relying on pattern-based anchor +// detection against non-v3 parents is structurally unsafe (a coincidental +// anyone-can-spend-looking output would silently receive a CPFP child +// that the mempool then rejects, burning the caller's fee input). +func (b *CPFPBroadcaster) Submit(ctx context.Context, height int32, + req *BroadcastRequest) (*BroadcastResult, error) { + + if req == nil || req.Tx == nil { + return nil, fmt.Errorf("broadcast request and tx required") + } + + if req.Tx.Version != arktx.TxVersion { + return nil, fmt.Errorf("%w: got version %d, want %d", + ErrNonTRUCParent, req.Tx.Version, arktx.TxVersion) + } + + txid := req.Tx.TxHash() + anchorIdx := findAnchorOutput(req.Tx) + if anchorIdx < 0 { + return b.broadcastDirect(ctx, req, txid) + } + + return b.broadcastWithCPFP(ctx, height, req, txid, anchorIdx) +} + +// broadcastDirect broadcasts a transaction without CPFP. +func (b *CPFPBroadcaster) broadcastDirect(ctx context.Context, + req *BroadcastRequest, txid chainhash.Hash) (*BroadcastResult, error) { + + if err := b.preflightIfEnabled(ctx, req.Tx); err != nil { + return nil, err + } + + _, err := b.cfg.ChainSource.Ask( + ctx, &chainsource.BroadcastTxRequest{ + Tx: req.Tx, + Label: req.Label, + }, + ).Await(ctx).Unpack() + if err != nil && !IsIgnorableBroadcastError(err) { + return nil, fmt.Errorf("broadcast %s: %w", txid, err) + } + + return &BroadcastResult{Txid: txid}, nil +} + +// Preflight asks the chain backend whether the supplied transactions +// would be accepted by the local mempool without broadcasting them. +// Multiple transactions are submitted as a package (matching Bitcoin +// Core's testmempoolaccept RPC array form). +// +// Preflight distinguishes three outcomes: +// +// - All transactions accepted: returns nil. +// - At least one transaction rejected: returns an error that includes +// the backend's human-readable reject reason and the rejected +// txid(s). Callers should treat this as a hard failure and surface +// it to the FSM. +// - Backend does not support testmempoolaccept (or +// ErrPackageMempoolAcceptUnsupported on a package request): returns +// a sentinel chainsource.ErrPackageMempoolAcceptUnsupported wrapper +// so callers that treat preflight as best-effort can `errors.Is`- +// check and continue. +func (b *CPFPBroadcaster) Preflight(ctx context.Context, + txs ...*wire.MsgTx) error { + + if len(txs) == 0 { + return fmt.Errorf("preflight requires at least one tx") + } + + resp, err := b.cfg.ChainSource.Ask( + ctx, &chainsource.TestMempoolAcceptRequest{Txs: txs}, + ).Await(ctx).Unpack() + if err != nil { + return err + } + + result, ok := resp.(*chainsource.TestMempoolAcceptResponse) + if !ok { + return fmt.Errorf("unexpected testmempoolaccept response %T", + resp) + } + + for _, r := range result.Results { + if r.Accepted { + continue + } + + return fmt.Errorf( + "testmempoolaccept rejected %s: %s", r.Txid, r.Reason, + ) + } + + return nil +} + +// preflightIfEnabled runs Preflight when the caller enabled +// PreSubmitTestMempoolAccept. Backends that report +// "not supported" (or the sentinel ErrPackageMempoolAcceptUnsupported +// for package requests) are downgraded to a warning so the flag is safe +// to set across heterogeneous deployments. +func (b *CPFPBroadcaster) preflightIfEnabled(ctx context.Context, + txs ...*wire.MsgTx) error { + + if !b.cfg.PreSubmitTestMempoolAccept { + return nil + } + + err := b.Preflight(ctx, txs...) + switch { + case err == nil: + return nil + + case errors.Is(err, chainsource.ErrPackageMempoolAcceptUnsupported): + b.log.DebugS(ctx, + "Skipping preflight: backend does not support "+ + "package testmempoolaccept", "err", err) + + return nil + + case strings.Contains(err.Error(), "not supported"): + b.log.DebugS(ctx, + "Skipping preflight: backend does not support "+ + "testmempoolaccept", "err", err) + + return nil + } + + return err +} + +// broadcastWithCPFP builds a CPFP child and submits the parent+child package. +// +// On the first submission for a given parent txid, the fee rate and total +// package fee come straight from EstimateFeeRate + EstimatePackageFee. On +// every subsequent submission (fee bump), we compare against the previous +// submission's feerate and absolute fee stored in parentStates: +// +// - BIP-125 Rule 4: the new feerate must strictly exceed the previous +// feerate, so we floor it at prev.LastFeeRate + 1 if the estimator is +// flat or dips. +// - BIP-125 Rule 3: the new absolute package fee must exceed the +// previous package fee by at least IncrementalRelayFeeSatPerVByte * +// packageVSize. If the naive feerate * packageVSize calculation +// doesn't clear that threshold, we bump totalFee up to satisfy it. +// +// Without these, a flat-fee-estimator cycle regenerates a byte-identical +// package (rejected as "already in mempool") and a decreasing-fee-estimator +// cycle produces a BIP-125-non-compliant replacement that the mempool +// rejects outright. +func (b *CPFPBroadcaster) broadcastWithCPFP(ctx context.Context, + height int32, req *BroadcastRequest, txid chainhash.Hash, + anchorIdx int) (*BroadcastResult, error) { + + // Derive the change pkScript first so its script class can inform + // the child's vsize estimate. A failure here means we cannot build + // a CPFP child at all, so we fall straight through to broadcasting + // the parent directly; no fee-input reservation has been made yet, + // so there is nothing to release. + changePkScript, err := b.deriveChangePkScript(ctx) + if err != nil { + return b.fallbackDirectBroadcast( + ctx, req, txid, wire.OutPoint{}, + "derive_change_pkscript", err, + ) + } + + // Use the change pkScript as the proxy for the fee-input's script + // class too: wallets hand out consistent address types, so this + // keeps the estimate accurate on P2TR-only, P2WKH-only, and + // nested-P2WKH-only wallets without requiring a second wallet + // round-trip to probe UTXOs before selecting one. Rare mixed-type + // wallets will get a slight over-estimate on the non-matching + // side, which is safer than under-estimating. + childVSize := estimateChildVSize(changePkScript, changePkScript) + + feeRate, err := b.EstimateFeeRate(ctx) + if err != nil { + return nil, fmt.Errorf("estimate fee: %w", err) + } + + totalFee, err := computePackageFee( + req.Tx, btcutil.Amount(feeRate), childVSize, + ) + if err != nil { + return nil, fmt.Errorf("estimate package fee: %w", err) + } + + feeRate, totalFee = b.applyReplacementFloor( + req.Tx, txid, feeRate, totalFee, childVSize, + ) + + feeInput, err := b.selectFeeInput(ctx, txid, totalFee) + if err != nil { + return nil, fmt.Errorf("%w: %w", + ErrCPFPFeeInputUnavailable, err, + ) + } + + b.reserveFeeOutpoint(ctx, txid, feeInput.Outpoint) + + // If the chosen fee-input's actual script class differs from the + // change script's (wallets that genuinely mix types), recompute + // with the real inputs and top up the fee if the new estimate is + // larger. We never lower the fee here: the replacement-floor work + // above is already locked in, so only a higher-than-floor fee is + // safe. + preciseChildVSize := estimateChildVSize( + feeInput.Output.PkScript, changePkScript, + ) + if preciseChildVSize > childVSize { + preciseFee, feeErr := computePackageFee( + req.Tx, btcutil.Amount(feeRate), preciseChildVSize, + ) + if feeErr == nil && preciseFee > totalFee { + totalFee = preciseFee + } + } + + anchorOutpoint := wire.OutPoint{Hash: txid, Index: uint32(anchorIdx)} + anchorOutput := req.Tx.TxOut[anchorIdx] + + child, err := BuildCPFPChild( + req.Tx.Version, anchorOutpoint, anchorOutput, feeInput, + changePkScript, totalFee, + ) + if err != nil { + return b.fallbackDirectBroadcast( + ctx, req, txid, feeInput.Outpoint, + "build_cpfp_child", err, + ) + } + + err = b.signCPFPChild( + ctx, child, anchorOutpoint, anchorOutput, feeInput, + ) + if err != nil { + return b.fallbackDirectBroadcast( + ctx, req, txid, feeInput.Outpoint, + "sign_cpfp_child", err, + ) + } + + // Preflight the package (parent + signed child) against local node + // policy before asking the backend to relay it. A rejection here is + // treated as a hard failure because the caller has already paid to + // sign the child and we'd otherwise submit a package we know the + // mempool will reject. Release the reservation so the next retry + // (after the caller decides what to do) can re-select freely. + if err := b.preflightIfEnabled(ctx, req.Tx, child); err != nil { + b.releaseFeeOutpoint(ctx, txid, feeInput.Outpoint) + + return nil, fmt.Errorf("preflight package: %w", err) + } + + _, pkgErr := b.cfg.ChainSource.Ask( + ctx, &chainsource.SubmitPackageRequest{ + Parents: []*wire.MsgTx{req.Tx}, + Child: child, + }, + ).Await(ctx).Unpack() + if pkgErr != nil { + switch { + case IsIgnorableBroadcastError(pkgErr): + b.log.DebugS( + ctx, + "Package already known for "+ + txid.String(), + ) + + case isPackageSubmissionUnsupported(pkgErr): + if err := b.broadcastIndividually( + ctx, req.Tx, child, req.Label, + ); err != nil { + b.releaseFeeOutpoint( + ctx, txid, feeInput.Outpoint, + ) + + return nil, fmt.Errorf( + "broadcast fallback: %w", + err, + ) + } + + default: + // The package was rejected wholesale: the child + // did not land in the mempool, so the fee input + // is a stale reservation that should be released. + b.releaseFeeOutpoint(ctx, txid, feeInput.Outpoint) + + return nil, fmt.Errorf( + "submit package: %w", pkgErr, + ) + } + } + + childTxid := child.TxHash() + + // Record the submission so the next fee bump for this parent can + // enforce BIP-125 Rule 3/4 against it. The parentStates entry was + // already created (or updated) by reserveFeeOutpoint above, so we + // update the fee-history fields in place to preserve the + // UsedFeeOutpoints reservation accumulated over all prior bumps. + state := b.parentStates[txid] + state.LastFeeRate = feeRate + state.LastPackageFee = totalFee + + return &BroadcastResult{ + Txid: txid, + ChildTxid: &childTxid, + FeeRate: feeRate, + }, nil +} + +// applyReplacementFloor returns feerate and totalFee values adjusted so +// that a package submitted with them will satisfy BIP-125 Rule 3 and Rule +// 4 against whatever package was previously submitted for the same parent +// txid. A parent with no recorded prior submission is passed through +// unchanged (first submission has nothing to replace). +// +// childVSize is the caller's current estimate of the CPFP child's +// vsize; the caller supplies it because the class of the fee input and +// change output (and therefore the vsize) depends on wallet-specific +// details this helper does not have visibility into. +func (b *CPFPBroadcaster) applyReplacementFloor(parent *wire.MsgTx, + txid chainhash.Hash, feeRate int64, + totalFee btcutil.Amount, + childVSize int64) (int64, btcutil.Amount) { + + prev, havePrev := b.parentStates[txid] + if !havePrev { + return feeRate, totalFee + } + + // Rule 4: replacement feerate must strictly exceed the prior + // package's feerate. If the estimator returned a flat or lower + // value, ratchet the feerate up by one sat/vB. + if feeRate <= prev.LastFeeRate { + feeRate = prev.LastFeeRate + 1 + } + + // Recompute totalFee at the (possibly bumped) feerate so the + // following Rule 3 check compares apples to apples. + parentWeight := EstimateWeight(parent) + parentVSize := (parentWeight + 3) / 4 + packageVSize := parentVSize + childVSize + + naiveFee := btcutil.Amount(feeRate) * btcutil.Amount(packageVSize) + if totalFee < naiveFee { + totalFee = naiveFee + } + + // Rule 3: additional fee (new_total - old_total) must cover the + // replacement's own bandwidth at the node's incremental relay + // feerate. If the straight feerate bump doesn't clear that + // threshold (typical when the vsize grew but the feerate only + // ticked up by 1), pay the shortfall as a flat fee bump. + minAdditional := btcutil.Amount( + b.cfg.IncrementalRelayFeeSatPerVByte * packageVSize, + ) + minRequired := prev.LastPackageFee + minAdditional + if totalFee < minRequired { + totalFee = minRequired + } + + return feeRate, totalFee +} + +// fallbackDirectBroadcast logs one CPFP setup failure and falls back to +// broadcasting the parent transaction directly. +// +// releaseOutpoint is the wallet outpoint that was tentatively reserved +// for the CPFP child before the setup failure. Because the child never +// reaches the mempool on this path, the reservation is stale and would +// otherwise starve concurrent parents of UTXOs until the tracked tx +// terminally evicts. +func (b *CPFPBroadcaster) fallbackDirectBroadcast(ctx context.Context, + req *BroadcastRequest, txid chainhash.Hash, + releaseOutpoint wire.OutPoint, stage string, + err error) (*BroadcastResult, error) { + + b.releaseFeeOutpoint(ctx, txid, releaseOutpoint) + + b.log.WarnS(ctx, "CPFP unavailable; broadcasting parent directly", + err, "txid", txid, "stage", stage, "label", req.Label) + + return b.broadcastDirect(ctx, req, txid) +} + +// EstimateFeeRate returns the current fee rate in sat/vbyte, clamped by the +// configured maximum. On regtest (or when the chain backend has no fee +// history), estimation may fail — in that case we fall back to a minimum +// floor so the CPFP broadcast can still proceed. +func (b *CPFPBroadcaster) EstimateFeeRate(ctx context.Context) (int64, error) { + const minFeeRateSatPerVByte int64 = 2 + + resp, err := b.cfg.ChainSource.Ask( + ctx, &chainsource.FeeEstimateRequest{ + TargetConf: 6, + }, + ).Await(ctx).Unpack() + if err != nil { + b.log.Warnf("Fee estimation failed, using fallback "+ + "%d sat/vB: %v", minFeeRateSatPerVByte, err) + + return minFeeRateSatPerVByte, nil + } + + feeResp, ok := resp.(*chainsource.FeeEstimateResponse) + if !ok { + return 0, fmt.Errorf("unexpected fee response type %T", resp) + } + + rate := int64(feeResp.SatPerVByte) + if rate < minFeeRateSatPerVByte { + rate = minFeeRateSatPerVByte + } + if rate > b.cfg.MaxFeeRateSatPerVByte { + rate = b.cfg.MaxFeeRateSatPerVByte + } + + return rate, nil +} + +// selectFeeInput finds the smallest confirmed wallet UTXO that covers the +// required fee amount for the supplied parent txid. +// +// The exclusion set is built from outpoints reserved by every *other* +// parent in parentStates. UTXOs already reserved by the current parent +// are deliberately *not* excluded: TRUC package RBF relies on the new +// child double-spending the previous child's fee input, and that is how +// the replacement actually lands on the mempool. +func (b *CPFPBroadcaster) selectFeeInput(ctx context.Context, + parentTxid chainhash.Hash, + minAmount btcutil.Amount) (*FeeInput, error) { + + if b.cfg.Wallet == nil { + return nil, fmt.Errorf("wallet must be provided") + } + + excluded := b.excludedOutpointsForOtherParents(parentTxid) + + deadline := time.Now().Add(2 * time.Second) + + for { + utxos, err := b.cfg.Wallet.ListUnspent(ctx, 1, 9999999) + if err != nil { + return nil, fmt.Errorf("list unspent: %w", err) + } + + var best *wallet.Utxo + for _, utxo := range utxos { + if _, skip := excluded[utxo.Outpoint]; skip { + continue + } + + if utxo.Amount < minAmount { + continue + } + + if best == nil || utxo.Amount < best.Amount { + best = utxo + } + } + + if best != nil { + return &FeeInput{ + Outpoint: best.Outpoint, + Output: &wire.TxOut{ + Value: int64(best.Amount), + PkScript: best.PkScript, + }, + Confirmed: true, + }, nil + } + + // After one CPFP package confirms, wallet backends + // with asynchronous chain ingestion can lag briefly + // before the confirmed change output becomes visible. + // Poll through that handoff instead of immediately + // falling back to direct broadcast of a zero-fee + // parent. + if len(excluded) == 0 || time.Now().After(deadline) { + break + } + + select { + case <-ctx.Done(): + return nil, ctx.Err() + + case <-time.After(100 * time.Millisecond): + } + } + + return nil, fmt.Errorf("no confirmed wallet UTXOs available "+ + "(need >= %d sats)", int64(minAmount)) +} + +// deriveChangePkScript obtains a fresh wallet-managed pkScript for use as a +// CPFP child change output. +func (b *CPFPBroadcaster) deriveChangePkScript( + ctx context.Context) ([]byte, error) { + + if b.cfg.Wallet == nil { + return nil, fmt.Errorf("wallet must be provided") + } + + pkScript, err := b.cfg.Wallet.NewWalletPkScript(ctx) + if err != nil { + return nil, fmt.Errorf("new wallet pkscript: %w", err) + } + + if len(pkScript) == 0 { + return nil, fmt.Errorf("wallet returned empty pkscript") + } + + return append([]byte(nil), pkScript...), nil +} + +// signCPFPChild signs the CPFP child via PSBT. +// +// The child is a caller-constructed transaction whose inputs always include +// the parent's anchor output (which is anyone-can-spend and needs a +// pre-finalized empty witness) and at least one wallet-owned fee input +// (which the wallet finalizes during FinalizePsbt). +// +// We match inputs by outpoint — both when attaching WitnessUtxo metadata to +// the PSBT before finalization and when copying finalized witnesses back +// into the child — rather than by positional index. This makes the +// function robust to two classes of wallet behaviour that positional +// indexing does not survive: +// +// - The wallet returning the finalized transaction with inputs in a +// different order than the PSBT presented (some backends reorder +// inputs by BIP 69 or internal heuristics). +// - A future caller adding a second fee input or rearranging child +// construction — positional `packet.Inputs[0]` / `[1]` would silently +// miswire WitnessUtxo values across the wrong inputs. +// +// Failures on either side of finalization return a clean error instead of +// panicking on an out-of-bounds index. +func (b *CPFPBroadcaster) signCPFPChild(ctx context.Context, + child *wire.MsgTx, anchorOutpoint wire.OutPoint, + anchorOutput *wire.TxOut, feeInput *FeeInput) error { + + if b.cfg.Wallet == nil { + return fmt.Errorf("wallet must be provided") + } + + if feeInput == nil || feeInput.Output == nil { + return fmt.Errorf("fee input and output required") + } + + inputs := make([]*wire.OutPoint, len(child.TxIn)) + sequences := make([]uint32, len(child.TxIn)) + for i, txIn := range child.TxIn { + op := txIn.PreviousOutPoint + inputs[i] = &op + sequences[i] = txIn.Sequence + } + + // Locate the anchor and fee inputs by outpoint. We deliberately do + // not trust positional assumptions: BuildCPFPChild today places the + // anchor at index 0 and fee at index 1, but refactors that change + // the order must not silently corrupt the PSBT metadata. + anchorIdx, feeIdx := -1, -1 + for i, op := range inputs { + switch *op { + case anchorOutpoint: + anchorIdx = i + + case feeInput.Outpoint: + feeIdx = i + } + } + if anchorIdx < 0 { + return fmt.Errorf("child is missing anchor input %s", + anchorOutpoint) + } + if feeIdx < 0 { + return fmt.Errorf("child is missing fee input %s", + feeInput.Outpoint) + } + + packet, err := psbt.New( + inputs, child.TxOut, child.Version, child.LockTime, sequences, + ) + if err != nil { + return fmt.Errorf("create PSBT: %w", err) + } + + packet.Inputs[anchorIdx].WitnessUtxo = anchorOutput + packet.Inputs[anchorIdx].FinalScriptWitness = []byte{0x00} + packet.Inputs[feeIdx].WitnessUtxo = feeInput.Output + + var buf bytes.Buffer + if err := packet.Serialize(&buf); err != nil { + return fmt.Errorf("serialize PSBT: %w", err) + } + + finalTx, err := b.cfg.Wallet.FinalizePsbt(ctx, buf.Bytes()) + if err != nil { + return fmt.Errorf("finalize PSBT: %w", err) + } + + // Copy witnesses back into the child by matching outpoints, not + // positions. A length mismatch (wallet added or dropped inputs) or + // a missing outpoint (wallet replaced an input we requested) is a + // hard error: we cannot safely broadcast a package whose witnesses + // do not correspond to the PSBT we asked the wallet to sign. + if len(finalTx.TxIn) != len(child.TxIn) { + return fmt.Errorf("finalized tx has %d inputs, expected %d", + len(finalTx.TxIn), len(child.TxIn)) + } + + witnesses := make(map[wire.OutPoint]wire.TxWitness, len(finalTx.TxIn)) + for _, txIn := range finalTx.TxIn { + witnesses[txIn.PreviousOutPoint] = txIn.Witness + } + + for i := range child.TxIn { + w, ok := witnesses[child.TxIn[i].PreviousOutPoint] + if !ok { + return fmt.Errorf( + "finalized tx missing input for outpoint %s", + child.TxIn[i].PreviousOutPoint, + ) + } + child.TxIn[i].Witness = w + } + + return nil +} + +// broadcastIndividually broadcasts the parent and child transactions one at a +// time as a fallback for backends without package relay. +func (b *CPFPBroadcaster) broadcastIndividually(ctx context.Context, + parent, child *wire.MsgTx, label string) error { + + parentTxid := parent.TxHash() + _, parentErr := b.cfg.ChainSource.Ask( + ctx, &chainsource.BroadcastTxRequest{ + Tx: parent, + Label: label + "-parent", + }, + ).Await(ctx).Unpack() + if parentErr != nil && !IsIgnorableBroadcastError(parentErr) { + return fmt.Errorf("broadcast parent %s: %w", + parentTxid, parentErr) + } + + childTxid := child.TxHash() + _, childErr := b.cfg.ChainSource.Ask( + ctx, &chainsource.BroadcastTxRequest{ + Tx: child, + Label: label + "-child", + }, + ).Await(ctx).Unpack() + if childErr != nil && !IsIgnorableBroadcastError(childErr) { + return fmt.Errorf("broadcast child %s: %w", childTxid, childErr) + } + + return nil +} + +// findAnchorOutput returns the index of the anchor output in the transaction +// or -1 if none is found. +func findAnchorOutput(tx *wire.MsgTx) int { + for i, out := range tx.TxOut { + if arktx.IsAnchorOutput(out) { + return i + } + } + + return -1 +} + +// estimateChildVSize returns the vbyte size of the CPFP child this +// package constructs: one ephemeral BIP-431 anchor input, one +// confirmed wallet fee input, and one wallet change output. The +// witness/output sizes for the fee input and change output are +// inferred from the actual pkScripts via txscript.GetScriptClass so +// wallets that hand out taproot, nested-p2wkh, or legacy p2wkh +// outputs all produce an accurate estimate (and therefore correct +// fee-bump rule 3/4 arithmetic) without the caller having to guess a +// constant. +// +// Unknown / non-standard script classes fall back to P2WKH on the +// input side and to a generic pkScript-length-based accounting on the +// output side (via AddOutput), which over-estimates rather than +// under-estimates and keeps the caller inside relay policy. +func estimateChildVSize(feeInputPkScript, changePkScript []byte) int64 { + var est input.TxWeightEstimator + + // Ephemeral BIP-431 anchor: zero-value P2A output spent with an + // empty witness. AddWitnessInput(0) accounts for the base input + // bytes (outpoint + sequence + empty scriptSig) without any + // witness items. + est.AddWitnessInput(0) + + addInputForScript(&est, feeInputPkScript) + addOutputForScript(&est, changePkScript) + + return int64(est.VSize()) +} + +// addInputForScript adds an input of the appropriate witness/script +// class to the supplied estimator based on the pkScript being spent. +// Unrecognised scripts fall through to a P2WKH-sized input so that the +// estimate is never smaller than a realistic wallet input — the goal +// of this helper is to never under-estimate the child's vsize, which +// would violate BIP-125 Rule 4 on the next fee bump. +func addInputForScript(est *input.TxWeightEstimator, pkScript []byte) { + switch txscript.GetScriptClass(pkScript) { + case txscript.WitnessV0PubKeyHashTy: + est.AddP2WKHInput() + + case txscript.WitnessV1TaprootTy: + // Key-spend path with SIGHASH_DEFAULT: 64-byte Schnorr + // signature, no control-block / tapleaf data. + est.AddTaprootKeySpendInput(txscript.SigHashDefault) + + case txscript.ScriptHashTy: + // Assume the nested form most wallets use. + est.AddNestedP2WKHInput() + + case txscript.PubKeyHashTy: + est.AddP2PKHInput() + + default: + est.AddP2WKHInput() + } +} + +// addOutputForScript adds an output of the appropriate class to the +// supplied estimator. When the pkScript is non-empty, AddOutput sizes +// it from the actual pkScript length, which is correct for any +// recognised or unrecognised class. When no script is available (e.g. +// a callers passing nil to get a pre-derivation estimate), fall back +// to a P2WKH-sized output so we never under-count and break the Rule +// 3 floor arithmetic. +func addOutputForScript(est *input.TxWeightEstimator, pkScript []byte) { + if len(pkScript) == 0 { + est.AddP2WKHOutput() + + return + } + + est.AddOutput(pkScript) +} + +// computePackageFee computes the total package fee for one parent+child +// submission at the given fee rate and caller-supplied child vsize. +// The child vsize is injected because it depends on wallet-specific +// script classes (P2TR, P2WKH, nested-P2WKH, …) that this helper has +// no visibility into. +func computePackageFee(parentTx *wire.MsgTx, feeRate btcutil.Amount, + childVSize int64) (btcutil.Amount, error) { + + if parentTx == nil { + return 0, fmt.Errorf("parent tx cannot be nil") + } + + if feeRate <= 0 { + return 0, fmt.Errorf("fee rate must be positive") + } + + if childVSize <= 0 { + return 0, fmt.Errorf("child vsize must be positive") + } + + parentWeight := EstimateWeight(parentTx) + parentVSize := (parentWeight + 3) / 4 + totalFee := feeRate * btcutil.Amount(parentVSize+childVSize) + if totalFee < 1 { + return 1, nil + } + + return totalFee, nil +} + +// EstimatePackageFee computes a total package fee for a parent+child +// submission at the given fee rate, using a default child shape +// (ephemeral anchor + P2WKH wallet input + P2WKH change) for the child +// vsize. Callers inside CPFPBroadcaster pass the actual script-derived +// vsize; this exported form exists for tests and callers that only +// need a rough pre-submission estimate. +func EstimatePackageFee(parentTx *wire.MsgTx, + feeRate btcutil.Amount) (btcutil.Amount, error) { + + defaultChildVSize := estimateChildVSize(nil, nil) + + return computePackageFee(parentTx, feeRate, defaultChildVSize) +} + +// BuildCPFPChild constructs an unsigned CPFP child that spends an anchor +// output and one confirmed wallet fee input. +func BuildCPFPChild(parentVersion int32, + anchorOutpoint wire.OutPoint, anchorOutput *wire.TxOut, + feeInput *FeeInput, changePkScript []byte, + totalFee btcutil.Amount) (*wire.MsgTx, error) { + + if feeInput == nil || feeInput.Output == nil { + return nil, fmt.Errorf("fee input and output required") + } + + if !feeInput.Confirmed { + return nil, fmt.Errorf("fee input must be confirmed") + } + + childTx := wire.NewMsgTx(parentVersion) + childTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: anchorOutpoint, + Sequence: wire.MaxTxInSequenceNum, + }) + + // The fee input signals RBF (sequence MaxTxInSequenceNum - 2 = + // 0xfffffffd). For a v3 / TRUC parent this is redundant — v3 + // itself implies replaceability — but it acts as a belt-and- + // suspenders for any future caller that somehow slips past the + // Submit-time version gate with a v2 parent: the child would + // still signal BIP-125 replacement so the next fee bump could + // RBF it on non-TRUC relays. The anchor input keeps its sentinel + // sequence because the anchor is anyone-can-spend with no + // timelock semantics, so its sequence value is not load-bearing. + childTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: feeInput.Outpoint, + Sequence: wire.MaxTxInSequenceNum - 2, + }) + + changeValue := btcutil.Amount(feeInput.Output.Value) - totalFee + if changeValue < 0 { + return nil, fmt.Errorf("fee input value %d insufficient for "+ + "fee %d", feeInput.Output.Value, int64(totalFee)) + } + + if changeValue >= DustLimit { + childTx.AddTxOut(&wire.TxOut{ + Value: int64(changeValue), + PkScript: append([]byte(nil), changePkScript...), + }) + } + + return childTx, nil +} + +// EstimateWeight computes the transaction weight including witness data. +func EstimateWeight(tx *wire.MsgTx) int64 { + baseSize := int64(tx.SerializeSizeStripped()) + totalSize := int64(tx.SerializeSize()) + + return baseSize*3 + totalSize +} + +// SelectFeeInput selects the smallest confirmed fee input that meets the +// minimum value, excluding any outpoints in the exclude set. +func SelectFeeInput(inputs []FeeInput, minValue btcutil.Amount, + exclude map[wire.OutPoint]bool) (*FeeInput, error) { + + var best *FeeInput + for i := range inputs { + input := &inputs[i] + if !input.Confirmed || input.Output == nil { + continue + } + + if exclude != nil && exclude[input.Outpoint] { + continue + } + + if btcutil.Amount(input.Output.Value) < minValue { + continue + } + + if best == nil || input.Output.Value < best.Output.Value { + cp := *input + best = &cp + } + } + + if best == nil { + return nil, fmt.Errorf("no confirmed fee input "+ + "with at least %d sat", int64(minValue)) + } + + return best, nil +} + +// IsIgnorableBroadcastError returns true for errors that indicate the +// transaction is already known to the network. +func IsIgnorableBroadcastError(err error) bool { + if err == nil { + return false + } + + errStr := err.Error() + ignorable := []string{ + "already in block chain", + "already known", + "txn-already-in-mempool", + "transaction already exists", + } + + for _, pattern := range ignorable { + if strings.Contains(errStr, pattern) { + return true + } + } + + return false +} + +// isPackageSubmissionUnsupported returns true if the error indicates the chain +// backend does not support atomic package submission. +func isPackageSubmissionUnsupported(err error) bool { + if err == nil { + return false + } + + return strings.Contains(err.Error(), "not supported") +} diff --git a/txconfirm/broadcaster_test.go b/txconfirm/broadcaster_test.go new file mode 100644 index 000000000..1e3cd8d63 --- /dev/null +++ b/txconfirm/broadcaster_test.go @@ -0,0 +1,1577 @@ +package txconfirm + +import ( + "bytes" + "context" + "fmt" + "testing" + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/baselib/protofsm" + "github.com/lightninglabs/darepo-client/chainsource" + "github.com/lightninglabs/darepo-client/wallet" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" +) + +// staticChainSourceRef is a small programmable chainsource actor ref for unit +// tests that need precise responses. +type staticChainSourceRef struct { + handler func(context.Context, + chainsource.ChainSourceMsg) (chainsource.ChainSourceResp, error) +} + +// ID returns the fake actor ID. +func (s *staticChainSourceRef) ID() string { + return "static-chainsource" +} + +// Tell satisfies the actor.ActorRef interface. +func (s *staticChainSourceRef) Tell(_ context.Context, + _ chainsource.ChainSourceMsg) error { + + return nil +} + +// Ask handles the chainsource request synchronously and returns an already +// completed future. +func (s *staticChainSourceRef) Ask(ctx context.Context, + msg chainsource.ChainSourceMsg, +) actor.Future[chainsource.ChainSourceResp] { + + promise := actor.NewPromise[chainsource.ChainSourceResp]() + resp, err := s.handler(ctx, msg) + if err != nil { + promise.Complete(fn.Err[chainsource.ChainSourceResp](err)) + } else { + promise.Complete(fn.Ok(resp)) + } + + return promise.Future() +} + +// failingWallet is a programmable wallet test double for broadcaster tests. +type failingWallet struct { + listErr error + changeErr error + finalizeErr error + changeScript []byte + utxos []*wallet.Utxo +} + +// ListUnspent returns the configured result. +func (w *failingWallet) ListUnspent(_ context.Context, + _, _ int32) ([]*wallet.Utxo, error) { + + return w.utxos, w.listErr +} + +// NewWalletPkScript returns the configured result. +func (w *failingWallet) NewWalletPkScript(_ context.Context) ([]byte, error) { + return w.changeScript, w.changeErr +} + +// FinalizePsbt returns the configured result. +func (w *failingWallet) FinalizePsbt(_ context.Context, + _ []byte) (*wire.MsgTx, error) { + + if w.finalizeErr != nil { + return nil, w.finalizeErr + } + + return wire.NewMsgTx(3), nil +} + +// LeaseOutput is a noop for the failing wallet test double. +func (w *failingWallet) LeaseOutput(_ context.Context, _ wallet.LockID, + _ wire.OutPoint, expiry time.Duration) (time.Time, error) { + + return time.Now().Add(expiry), nil +} + +// ReleaseOutput is a noop for the failing wallet test double. +func (w *failingWallet) ReleaseOutput(_ context.Context, _ wallet.LockID, + _ wire.OutPoint) error { + + return nil +} + +// rewritingWallet is a wallet test double that parses the PSBT it is +// given, attaches dummy finalized witnesses, and optionally hands the +// resulting transaction through a caller-supplied rewrite hook. It is +// used to exercise signCPFPChild's robustness to wallets that return +// finalized transactions whose input composition does not round-trip the +// requested PSBT (reordered inputs, added inputs, substituted outpoints). +type rewritingWallet struct { + utxos []*wallet.Utxo + changeScript []byte + + // rewrite, when non-nil, receives the default finalized tx and + // returns the tx that the wallet will hand back to the caller. The + // default finalized tx has every input's witness set to a dummy + // 64-byte value except for inputs whose PSBT FinalScriptWitness + // was pre-set (the anchor) which receive an empty witness. + rewrite func(*wire.MsgTx) *wire.MsgTx +} + +// ListUnspent returns the configured UTXOs. +func (w *rewritingWallet) ListUnspent(_ context.Context, + _, _ int32) ([]*wallet.Utxo, error) { + + return w.utxos, nil +} + +// NewWalletPkScript returns the configured change script. +func (w *rewritingWallet) NewWalletPkScript( + _ context.Context) ([]byte, error) { + + if len(w.changeScript) == 0 { + return p2trTestPkScript(), nil + } + + return w.changeScript, nil +} + +// FinalizePsbt parses the supplied PSBT, applies dummy witnesses, and +// then runs the configured rewrite hook (if any) before returning. +func (w *rewritingWallet) FinalizePsbt(_ context.Context, + packetBytes []byte) (*wire.MsgTx, error) { + + packet, err := psbt.NewFromRawBytes(bytes.NewReader(packetBytes), false) + if err != nil { + return nil, err + } + + tx := packet.UnsignedTx.Copy() + for i := range tx.TxIn { + if len(packet.Inputs[i].FinalScriptWitness) > 0 { + tx.TxIn[i].Witness = wire.TxWitness{} + continue + } + + tx.TxIn[i].Witness = wire.TxWitness{make([]byte, 64)} + } + + if w.rewrite != nil { + tx = w.rewrite(tx) + } + + return tx, nil +} + +// LeaseOutput is a noop for the rewriting wallet test double. +func (w *rewritingWallet) LeaseOutput(_ context.Context, _ wallet.LockID, + _ wire.OutPoint, expiry time.Duration) (time.Time, error) { + + return time.Now().Add(expiry), nil +} + +// ReleaseOutput is a noop for the rewriting wallet test double. +func (w *rewritingWallet) ReleaseOutput(_ context.Context, + _ wallet.LockID, _ wire.OutPoint) error { + + return nil +} + +// failingNotifyRef is a TellOnlyRef that always returns an error. +type failingNotifyRef struct{} + +// ID returns the fake subscriber ID. +func (f *failingNotifyRef) ID() string { + return "failing-notify" +} + +// Tell always returns an error. +func (f *failingNotifyRef) Tell(_ context.Context, _ Notification) error { + return fmt.Errorf("notify failed") +} + +// testMappedMsg is a small actor message used to cover MapNotification. +type testMappedMsg struct { + actor.BaseMessage + payload string +} + +// MessageType returns the stable message type identifier. +func (m testMappedMsg) MessageType() string { + return "testMappedMsg" +} + +// testUnknownMsg is a local message used to cover the actor's default Receive +// branch. +type testUnknownMsg struct { + actor.BaseMessage +} + +// MessageType returns the stable message type identifier. +func (m *testUnknownMsg) MessageType() string { + return "testUnknownMsg" +} + +// txConfirmMsgSealed seals testUnknownMsg into the package message surface for +// testing. +func (m *testUnknownMsg) txConfirmMsgSealed() {} + +// newTrackedTxForState creates a tracked tx handle backed by the supplied FSM +// state for white-box helper tests. +func newTrackedTxForState(t *testing.T, state trackedTxState) *trackedTx { + t.Helper() + + var data trackedTxData + switch s := state.(type) { + case *trackedTxStateNew: + data = s.trackedTxData + + case *trackedTxStateBroadcasting: + data = s.trackedTxData + + case *trackedTxStateAwaitingConfirmation: + data = s.trackedTxData + + case *trackedTxStateFeeBumping: + data = s.trackedTxData + + case *trackedTxStateConfirmed: + data = s.trackedTxData + + case *trackedTxStateFailed: + data = s.trackedTxData + + default: + t.Fatalf("unexpected tracked tx state %T", state) + } + + fsm := protofsm.NewStateMachine(protofsm.StateMachineCfg[ + trackedTxEvent, trackedTxOutboxEvent, *trackedTxEnvironment, + ]{ + InitialState: state, + Logger: btclog.Disabled, + ErrorReporter: &trackedTxErrorReporter{ + log: btclog.Disabled, + txid: data.Txid, + }, + Env: &trackedTxEnvironment{Txid: data.Txid}, + }) + fsm.Start(t.Context()) + t.Cleanup(fsm.Stop) + + return &trackedTx{ + data: data, + fsm: &fsm, + subscribers: make(map[string]actor.TellOnlyRef[Notification]), + } +} + +// TestMessageHelpers covers message helper methods and the notification mapper. +func TestMessageHelpers(t *testing.T) { + t.Run("state strings", func(t *testing.T) { + require.Equal(t, "new", TxStateNew.String()) + require.Equal(t, "broadcasting", TxStateBroadcasting.String()) + require.Equal( + t, "awaiting_confirmation", + TxStateAwaitingConfirmation.String(), + ) + require.Equal(t, "fee_bumping", TxStateFeeBumping.String()) + require.Equal(t, "confirmed", TxStateConfirmed.String()) + require.Equal(t, "failed", TxStateFailed.String()) + require.Contains(t, TxState(99).String(), "unknown") + }) + + t.Run("message types and sealed methods", func(t *testing.T) { + ensureReq := &EnsureConfirmedReq{} + ensureReq.txConfirmMsgSealed() + require.Equal(t, "EnsureConfirmedReq", ensureReq.MessageType()) + + ensureResp := &EnsureConfirmedResp{} + ensureResp.txConfirmRespSealed() + require.Equal( + t, "EnsureConfirmedResp", + ensureResp.MessageType(), + ) + + cancelReq := &CancelInterestReq{} + cancelReq.txConfirmMsgSealed() + require.Equal(t, "CancelInterestReq", cancelReq.MessageType()) + + cancelResp := &CancelInterestResp{} + cancelResp.txConfirmRespSealed() + require.Equal(t, "CancelInterestResp", cancelResp.MessageType()) + + confirmed := &TxConfirmed{} + confirmed.txConfirmNotificationSealed() + require.Equal(t, "TxConfirmed", confirmed.MessageType()) + + failed := &TxFailed{} + failed.txConfirmNotificationSealed() + require.Equal(t, "TxFailed", failed.MessageType()) + + confMsg := &confirmationObservedMsg{} + confMsg.txConfirmMsgSealed() + require.Equal( + t, "confirmationObservedMsg", + confMsg.MessageType(), + ) + + blockMsg := &blockEpochObservedMsg{} + blockMsg.txConfirmMsgSealed() + require.Equal( + t, "blockEpochObservedMsg", + blockMsg.MessageType(), + ) + }) + + t.Run("notification mapping", func(t *testing.T) { + target := actor.NewChannelTellOnlyRef[testMappedMsg]( + "mapped", 1, + ) + mapped := MapNotification( + target, + func(msg Notification) testMappedMsg { + return testMappedMsg{payload: msg.MessageType()} + }, + ) + + err := mapped.Tell(t.Context(), &TxConfirmed{}) + require.NoError(t, err) + + received, ok := target.AwaitMessage(testTimeout) + require.True(t, ok) + require.Equal(t, "TxConfirmed", received.payload) + }) +} + +// TestBroadcasterHelperFunctions covers the pure helper functions used by the +// generic broadcaster. +func TestBroadcasterHelperFunctions(t *testing.T) { + tx := makeTestTx(true) + + t.Run("estimate package fee", func(t *testing.T) { + fee, err := EstimatePackageFee(tx, 5) + require.NoError(t, err) + require.Positive(t, fee) + + _, err = EstimatePackageFee(nil, 5) + require.Error(t, err) + + _, err = EstimatePackageFee(tx, 0) + require.Error(t, err) + }) + + t.Run("build cpfp child", func(t *testing.T) { + feeInput := &FeeInput{ + Outpoint: wire.OutPoint{ + Hash: chainhash.Hash{3}, + Index: 2, + }, + Output: &wire.TxOut{ + Value: 10_000, + PkScript: []byte{txscript.OP_TRUE}, + }, + Confirmed: true, + } + + child, err := BuildCPFPChild( + tx.Version, + wire.OutPoint{Hash: tx.TxHash(), Index: 1}, + tx.TxOut[1], + feeInput, + []byte{txscript.OP_TRUE}, + 500, + ) + require.NoError(t, err) + require.Len(t, child.TxIn, 2) + require.Len(t, child.TxOut, 1) + + // The anchor input is anyone-can-spend with no timelock + // semantics, so its sequence keeps the sentinel value. + // The fee input signals BIP-125 RBF (MaxTxInSequenceNum - 2) + // as a belt-and-suspenders for any non-TRUC caller that + // ever slips past the Submit-time version gate. + require.Equal(t, + wire.MaxTxInSequenceNum, child.TxIn[0].Sequence) + require.Equal(t, + wire.MaxTxInSequenceNum-2, child.TxIn[1].Sequence) + + dustChild, err := BuildCPFPChild( + tx.Version, + wire.OutPoint{Hash: tx.TxHash(), Index: 1}, + tx.TxOut[1], + feeInput, + []byte{txscript.OP_TRUE}, + btcutil.Amount(feeInput.Output.Value), + ) + require.NoError(t, err) + require.Empty(t, dustChild.TxOut) + + _, err = BuildCPFPChild( + tx.Version, + wire.OutPoint{}, + tx.TxOut[1], + &FeeInput{Confirmed: false}, + nil, + 1, + ) + require.Error(t, err) + }) + + t.Run("select fee input", func(t *testing.T) { + feeInputs := []FeeInput{ + { + Outpoint: wire.OutPoint{ + Hash: chainhash.Hash{1}, + Index: 1, + }, + Output: &wire.TxOut{ + Value: 1000, + }, + Confirmed: true, + }, + { + Outpoint: wire.OutPoint{ + Hash: chainhash.Hash{2}, + Index: 2, + }, + Output: &wire.TxOut{ + Value: 5000, + }, + Confirmed: true, + }, + } + + selected, err := SelectFeeInput(feeInputs, 2000, nil) + require.NoError(t, err) + require.Equal(t, int64(5000), selected.Output.Value) + + excluded := map[wire.OutPoint]bool{ + feeInputs[0].Outpoint: true, + } + selected, err = SelectFeeInput( + feeInputs, 500, excluded, + ) + require.NoError(t, err) + require.Equal(t, feeInputs[1].Outpoint, selected.Outpoint) + + _, err = SelectFeeInput(feeInputs, 10_000, nil) + require.Error(t, err) + }) + + t.Run("ignorable errors", func(t *testing.T) { + require.True(t, IsIgnorableBroadcastError( + fmt.Errorf("already known"), + )) + require.False(t, IsIgnorableBroadcastError( + fmt.Errorf("fatal"), + )) + require.True(t, isPackageSubmissionUnsupported( + fmt.Errorf("package relay not supported"), + )) + require.False(t, isPackageSubmissionUnsupported( + fmt.Errorf("fatal"), + )) + }) +} + +// TestCPFPBroadcasterFallbackAndErrors covers the lower-level generic +// broadcaster's fallback and error branches. +func TestCPFPBroadcasterFallbackAndErrors(t *testing.T) { + t.Run("unsupported package falls back to "+ + "individual broadcast", func(t *testing.T) { + chain := newFakeChainSourceRef(100) + chain.packageErr = fmt.Errorf("package relay not supported") + broadcaster := NewCPFPBroadcaster(BroadcasterConfig{ + ChainSource: chain, + Wallet: &fakeWallet{ + utxos: []*wallet.Utxo{makeWalletUTXO()}, + }, + }) + + result, err := broadcaster.Submit(t.Context(), 100, + &BroadcastRequest{ + Tx: makeTestTx(true), + Label: "anchor", + }, + ) + require.NoError(t, err) + require.NotNil(t, result.ChildTxid) + require.Len(t, chain.broadcastCalls, 2) + }) + + t.Run("non-v3 parent rejected at Submit", func(t *testing.T) { + broadcaster := NewCPFPBroadcaster(BroadcasterConfig{ + ChainSource: newFakeChainSourceRef(100), + }) + + tx := makeTestTx(true) + tx.Version = 2 + + _, err := broadcaster.Submit(t.Context(), 100, + &BroadcastRequest{Tx: tx, Label: "not-truc"}, + ) + require.ErrorIs(t, err, ErrNonTRUCParent) + }) + + t.Run("submit validation and fee estimate errors", func(t *testing.T) { + broadcaster := NewCPFPBroadcaster(BroadcasterConfig{ + ChainSource: newFakeChainSourceRef(100), + }) + + _, err := broadcaster.Submit(t.Context(), 100, nil) + require.Error(t, err) + + badResp := &staticChainSourceRef{ + handler: func(_ context.Context, + msg chainsource.ChainSourceMsg, + ) (chainsource.ChainSourceResp, error) { + + resp := &chainsource.BestHeightResponse{} + + switch msg := msg.(type) { + case *chainsource.FeeEstimateRequest: + _ = msg + + return resp, nil + default: + return resp, nil + } + }, + } + broadcaster = NewCPFPBroadcaster(BroadcasterConfig{ + ChainSource: badResp, + }) + + _, err = broadcaster.EstimateFeeRate(t.Context()) + require.Error(t, err) + }) + + t.Run("wallet error branches", func(t *testing.T) { + tx := makeTestTx(true) + txid := tx.TxHash() + + chain := newFakeChainSourceRef(100) + broadcaster := NewCPFPBroadcaster(BroadcasterConfig{ + ChainSource: chain, + }) + _, err := broadcaster.selectFeeInput(t.Context(), txid, 100) + require.Error(t, err) + + broadcaster = NewCPFPBroadcaster(BroadcasterConfig{ + ChainSource: chain, + Wallet: &failingWallet{ + listErr: fmt.Errorf("list failed"), + }, + }) + _, err = broadcaster.selectFeeInput(t.Context(), txid, 100) + require.Error(t, err) + + broadcaster = NewCPFPBroadcaster(BroadcasterConfig{ + ChainSource: chain, + Wallet: &failingWallet{ + utxos: []*wallet.Utxo{makeWalletUTXO()}, + }, + }) + _, err = broadcaster.deriveChangePkScript(t.Context()) + require.Error(t, err) + + chain = newFakeChainSourceRef(100) + broadcaster = NewCPFPBroadcaster(BroadcasterConfig{ + ChainSource: chain, + // A valid change script is required now that the + // broadcaster derives it before fee selection so + // it can size the child correctly from real-world + // script classes. The ListUnspent failure below is + // what should surface as the CPFP-unavailable error. + Wallet: &failingWallet{ + changeScript: p2trTestPkScript(), + listErr: fmt.Errorf("list failed"), + }, + }) + result, err := broadcaster.broadcastWithCPFP( + t.Context(), 100, &BroadcastRequest{ + Tx: tx, + }, tx.TxHash(), 1, + ) + require.ErrorIs(t, err, ErrCPFPFeeInputUnavailable) + require.Nil(t, result) + require.Equal(t, 0, chain.broadcastCallCount()) + + chain = newFakeChainSourceRef(100) + broadcaster = NewCPFPBroadcaster(BroadcasterConfig{ + ChainSource: chain, + Wallet: &failingWallet{ + utxos: []*wallet.Utxo{makeWalletUTXO()}, + changeScript: p2trTestPkScript(), + finalizeErr: fmt.Errorf("finalize failed"), + }, + }) + result, err = broadcaster.broadcastWithCPFP( + t.Context(), 100, &BroadcastRequest{ + Tx: tx, + }, tx.TxHash(), 1, + ) + require.NoError(t, err) + require.NotNil(t, result) + require.Nil(t, result.ChildTxid) + require.Equal(t, 1, chain.broadcastCallCount()) + }) +} + +// TestFeeOutpointReleasedOnCPFPFallback verifies that when CPFP child +// setup fails partway through (e.g. PSBT finalize rejects the wallet +// input), the fee-input reservation registered on the parent is +// released so the same UTXO is available to the next retry or a +// concurrent parent. +func TestFeeOutpointReleasedOnCPFPFallback(t *testing.T) { + tx := makeTestTx(true) + txid := tx.TxHash() + utxo := makeWalletUTXO() + + chain := newFakeChainSourceRef(100) + broadcaster := NewCPFPBroadcaster(BroadcasterConfig{ + ChainSource: chain, + Wallet: &failingWallet{ + utxos: []*wallet.Utxo{utxo}, + changeScript: p2trTestPkScript(), + finalizeErr: fmt.Errorf("finalize failed"), + }, + }) + + result, err := broadcaster.broadcastWithCPFP( + t.Context(), 100, &BroadcastRequest{Tx: tx}, txid, 1, + ) + require.NoError(t, err) + require.NotNil(t, result) + require.Nil(t, result.ChildTxid) + + // The finalize failure triggered fallbackDirectBroadcast, which + // must release the tentatively-reserved fee outpoint so parent + // state contains no stale UTXOs that would starve future retries. + _, stillTracked := broadcaster.parentStates[txid] + require.False(t, stillTracked, + "parent state should be fully released after CPFP fallback") +} + +// TestFeeOutpointReleasedOnPreflightFailure verifies that when +// TestMempoolAccept preflight rejects the package, the +// tentatively-reserved fee outpoint is released so the caller's next +// attempt can re-select freely. +func TestFeeOutpointReleasedOnPreflightFailure(t *testing.T) { + tx := makeTestTx(true) + txid := tx.TxHash() + + chain := newFakeChainSourceRef(100) + chain.feeRate = 5 + chain.mempoolAcceptFn = func( + txs []*wire.MsgTx, + ) ([]chainsource.MempoolAcceptResult, error) { + + results := make([]chainsource.MempoolAcceptResult, len(txs)) + for i, tx := range txs { + results[i] = chainsource.MempoolAcceptResult{ + Txid: tx.TxHash(), + Accepted: false, + Reason: "preflight reject", + } + } + + return results, nil + } + + broadcaster := NewCPFPBroadcaster(BroadcasterConfig{ + ChainSource: chain, + Wallet: &fakeWallet{ + utxos: []*wallet.Utxo{makeWalletUTXO()}, + }, + PreSubmitTestMempoolAccept: true, + }) + + result, err := broadcaster.broadcastWithCPFP( + t.Context(), 100, &BroadcastRequest{Tx: tx}, txid, 1, + ) + require.Error(t, err) + require.Nil(t, result) + + _, stillTracked := broadcaster.parentStates[txid] + require.False(t, stillTracked, + "parent state should be released after preflight rejection") +} + +// TestWalletLeaseOutputLifecycle verifies the broadcaster leases a fee +// UTXO via Wallet.LeaseOutput on reservation and releases it via +// Wallet.ReleaseOutput on Evict. The in-memory reservation map remains +// the authoritative source of truth, but the wallet-level lease +// handshake must match so other subsystems sharing the same wallet +// cannot steal the UTXO while a CPFP child is in flight. +func TestWalletLeaseOutputLifecycle(t *testing.T) { + tx := makeTestTx(true) + txid := tx.TxHash() + + chain := newFakeChainSourceRef(100) + chain.feeRate = 5 + wlt := &fakeWallet{ + utxos: []*wallet.Utxo{makeWalletUTXO()}, + } + + b := NewCPFPBroadcaster(BroadcasterConfig{ + ChainSource: chain, + Wallet: wlt, + }) + + _, err := b.Submit(t.Context(), 100, &BroadcastRequest{ + Tx: tx, Label: "lease-lifecycle", + }) + require.NoError(t, err) + + // A successful CPFP submission must lease exactly the fee + // input's outpoint against the txconfirm LockID. + require.Len(t, wlt.leaseCalls, 1, + "exactly one LeaseOutput call per CPFP submission") + require.Equal(t, makeWalletUTXO().Outpoint, wlt.leaseCalls[0]) + require.Equal(t, txconfirmLockID, wlt.leaseLockID) + require.Equal(t, + DefaultFeeInputLeaseExpiry, wlt.leaseExpiryLast) + + // Eviction must drop the wallet lease so the UTXO becomes + // available to other subsystems immediately, not after the + // one-hour auto-expiry. + b.Evict(t.Context(), txid) + require.Len(t, wlt.releaseCalls, 1, + "Evict must call ReleaseOutput for every leased outpoint") + require.Equal(t, makeWalletUTXO().Outpoint, wlt.releaseCalls[0]) + require.Equal(t, txconfirmLockID, wlt.releaseLockID) +} + +// TestActorValidationAndCleanup covers actor validation, cleanup, and direct +// branch behavior that the higher-level flow tests do not hit. +func TestActorValidationAndCleanup(t *testing.T) { + t.Run("receive validation branches", func(t *testing.T) { + behavior := NewTxBroadcasterActor(Config{ + ChainSource: newFakeChainSourceRef(100), + }) + + _, err := behavior.handleEnsure(t.Context(), nil) + require.Error(t, err) + + _, err = behavior.handleEnsure( + t.Context(), &EnsureConfirmedReq{}, + ) + require.Error(t, err) + + _, err = behavior.handleCancel(t.Context(), nil) + require.Error(t, err) + + res := behavior.Receive(t.Context(), &testUnknownMsg{}) + _, err = res.Unpack() + require.Error(t, err) + }) + + t.Run("ensure best height unexpected response", func(t *testing.T) { + handler := func(_ context.Context, + msg chainsource.ChainSourceMsg, + ) (chainsource.ChainSourceResp, error) { + + if _, ok := msg.(*chainsource.BestHeightRequest); ok { + return &chainsource.BroadcastTxResponse{}, nil + } + + return &chainsource.SubscribeBlocksResponse{}, nil + } + behavior := NewTxBroadcasterActor(Config{ + ChainSource: &staticChainSourceRef{ + handler: handler, + }, + }) + + err := behavior.ensureBestHeight(t.Context()) + require.Error(t, err) + }) + + t.Run("ensure block subscription error", func(t *testing.T) { + handler := func(_ context.Context, + msg chainsource.ChainSourceMsg, + ) (chainsource.ChainSourceResp, error) { + + _, ok := msg.(*chainsource.SubscribeBlocksRequest) + if ok { + return nil, fmt.Errorf( + "subscribe failed", + ) + } + + return &chainsource.BestHeightResponse{}, nil + } + behavior := NewTxBroadcasterActor(Config{ + ChainSource: &staticChainSourceRef{ + handler: handler, + }, + }) + behavior.SetSelfRef(actor.NewChannelTellOnlyRef[Msg]("self", 1)) + + err := behavior.ensureBlockSubscription(t.Context()) + require.Error(t, err) + }) + + t.Run("should fee bump helper", func(t *testing.T) { + behavior := NewTxBroadcasterActor(Config{ + ChainSource: newFakeChainSourceRef(100), + }) + behavior.bestHeight = 100 + awaitState := &trackedTxStateAwaitingConfirmation{ + trackedTxData: trackedTxData{ + Txid: chainhash.Hash{7}, + }, + trackedTxProgress: trackedTxProgress{ + LastBroadcastHeight: 99, + }, + } + entry := newTrackedTxForState(t, awaitState) + require.False(t, behavior.shouldFeeBump(entry)) + + awaitState2 := &trackedTxStateAwaitingConfirmation{ + trackedTxData: trackedTxData{ + Txid: chainhash.Hash{7}, + }, + trackedTxProgress: trackedTxProgress{ + LastBroadcastHeight: 98, + }, + } + entry = newTrackedTxForState(t, awaitState2) + require.True(t, behavior.shouldFeeBump(entry)) + + entry = newTrackedTxForState(t, &trackedTxStateConfirmed{ + trackedTxData: trackedTxData{ + Txid: chainhash.Hash{7}, + }, + trackedTxProgress: trackedTxProgress{ + LastBroadcastHeight: 98, + }, + ConfirmHeight: 100, + }) + require.False(t, behavior.shouldFeeBump(entry)) + }) + + t.Run("notify error branches and cleanup", func(t *testing.T) { + chain := newFakeChainSourceRef(100) + behavior := NewTxBroadcasterActor(Config{ + ChainSource: chain, + }) + behavior.SetSelfRef(actor.NewChannelTellOnlyRef[Msg]("self", 1)) + behavior.blockSubscriptionActive = true + awaitConf := &trackedTxStateAwaitingConfirmation{ + trackedTxData: trackedTxData{ + Txid: chainhash.Hash{9}, + TargetConfs: 1, + }, + trackedTxProgress: trackedTxProgress{ + LastBroadcastHeight: 99, + }, + } + entry := newTrackedTxForState(t, awaitConf) + entry.subscribers["fail"] = &failingNotifyRef{} + behavior.tracked[entry.data.Txid] = entry + + behavior.notifyOneConfirmed( + t.Context(), &failingNotifyRef{}, + entry.data.Txid, 1, 1, + ) + behavior.notifyOneFailed( + t.Context(), &failingNotifyRef{}, + entry.data.Txid, "failed", + ) + + err := behavior.OnStop(t.Context()) + require.NoError(t, err) + require.Len(t, chain.unsubscribeBlocks, 1) + require.Len(t, chain.unregisterConfs, 1) + }) +} + +// TestApplyReplacementFloor exercises the pure fee-and-feerate comparator +// that CPFPBroadcaster.broadcastWithCPFP applies before every submission. +// These are white-box tests against the helper directly so the +// interaction between Rule 3 (absolute fee), Rule 4 (feerate), and the +// incrementalRelayFee term is pinned down independent of the broader +// broadcast flow. +func TestApplyReplacementFloor(t *testing.T) { + parent := makeTestTx(true) + txid := parent.TxHash() + + // Use a real P2TR pkScript for the child's fee input and change + // output so the vsize arithmetic matches the shape a modern wallet + // actually produces, not a hand-picked constant. + taprootScript := p2trTestPkScript() + childVSize := estimateChildVSize(taprootScript, taprootScript) + require.Greater(t, childVSize, int64(0)) + + parentVSize := (EstimateWeight(parent) + 3) / 4 + packageVSize := parentVSize + childVSize + + newBroadcaster := func(irf int64) *CPFPBroadcaster { + cfg := BroadcasterConfig{ + ChainSource: newFakeChainSourceRef(100), + + IncrementalRelayFeeSatPerVByte: irf, + } + + return NewCPFPBroadcaster(cfg) + } + + t.Run("no prior state is a pass-through", func(t *testing.T) { + b := newBroadcaster(1) + + feeRate, totalFee := b.applyReplacementFloor( + parent, txid, 7, + btcutil.Amount(7*packageVSize), childVSize, + ) + require.Equal(t, int64(7), feeRate) + require.Equal(t, btcutil.Amount(7*packageVSize), totalFee) + }) + + t.Run("flat estimator forces feerate +1", func(t *testing.T) { + b := newBroadcaster(1) + + prevFeeRate := int64(5) + prevFee := btcutil.Amount(prevFeeRate * packageVSize) + b.parentStates[txid] = &parentBumpState{ + LastFeeRate: prevFeeRate, + LastPackageFee: prevFee, + } + + feeRate, totalFee := b.applyReplacementFloor( + parent, txid, prevFeeRate, + btcutil.Amount(prevFeeRate*packageVSize), childVSize, + ) + + require.Equal(t, prevFeeRate+1, feeRate, + "flat estimator must be floored to prev + 1 sat/vB") + require.GreaterOrEqual(t, int64(totalFee), + int64(prevFee)+packageVSize, + "Rule 3 requires additional-fee >= irf * packageVSize") + }) + + t.Run("dip still clears prior feerate", func(t *testing.T) { + b := newBroadcaster(1) + + prevFeeRate := int64(20) + prevFee := btcutil.Amount(prevFeeRate * packageVSize) + b.parentStates[txid] = &parentBumpState{ + LastFeeRate: prevFeeRate, + LastPackageFee: prevFee, + } + + feeRate, totalFee := b.applyReplacementFloor( + parent, txid, 3, + btcutil.Amount(3*packageVSize), childVSize, + ) + + require.Equal(t, prevFeeRate+1, feeRate, + "dip below prior must be ratcheted to prev + 1") + require.GreaterOrEqual(t, int64(totalFee), int64(prevFee)+1, + "absolute replacement fee must strictly exceed prior") + }) + + t.Run("rule 3 bumps when feerate tick alone is insufficient", + func(t *testing.T) { + // Incremental relay fee set high so the Rule 3 + // threshold dominates. + irf := int64(5) + b := newBroadcaster(irf) + + prevFeeRate := int64(10) + prevFee := btcutil.Amount(prevFeeRate * packageVSize) + b.parentStates[txid] = &parentBumpState{ + LastFeeRate: prevFeeRate, + LastPackageFee: prevFee, + } + + // Raw feerate bump of +1 → naive new fee is + // (prevFeeRate+1) * packageVSize. But Rule 3 requires + // additional fee >= irf * packageVSize, which the +1 + // tick alone does not cover. + feeRate, totalFee := b.applyReplacementFloor( + parent, txid, prevFeeRate+1, + btcutil.Amount( + (prevFeeRate+1)*packageVSize, + ), + childVSize, + ) + + require.Equal(t, prevFeeRate+1, feeRate) + + required := int64(prevFee) + irf*packageVSize + require.GreaterOrEqual(t, int64(totalFee), required, + "Rule 3 must top up totalFee when feerate "+ + "bump alone is insufficient") + }) + + t.Run("custom incrementalRelayFee is honored", func(t *testing.T) { + irf := int64(3) + b := newBroadcaster(irf) + + prevFeeRate := int64(8) + prevFee := btcutil.Amount(prevFeeRate * packageVSize) + b.parentStates[txid] = &parentBumpState{ + LastFeeRate: prevFeeRate, + LastPackageFee: prevFee, + } + + _, totalFee := b.applyReplacementFloor( + parent, txid, prevFeeRate, // flat estimator + btcutil.Amount(prevFeeRate*packageVSize), childVSize, + ) + + minAdditional := irf * packageVSize + require.GreaterOrEqual(t, + int64(totalFee)-int64(prevFee), minAdditional, + "additional fee must be at least irf * packageVSize", + ) + }) + + t.Run("caller totalFee larger than naive is preserved", + func(t *testing.T) { + b := newBroadcaster(1) + + prevFeeRate := int64(5) + prevFee := btcutil.Amount(prevFeeRate * packageVSize) + b.parentStates[txid] = &parentBumpState{ + LastFeeRate: prevFeeRate, + LastPackageFee: prevFee, + } + + // Caller passed a fee larger than (prevFeeRate+1) * + // packageVSize; the floor must not shrink it. + large := btcutil.Amount( + (prevFeeRate + 1) * packageVSize * 2, + ) + + _, totalFee := b.applyReplacementFloor( + parent, txid, prevFeeRate, large, childVSize, + ) + require.Equal(t, large, totalFee, + "applyReplacementFloor must never shrink "+ + "a fee the caller already chose") + }) +} + +// TestPreflightTestMempoolAccept covers the opt-in +// PreSubmitTestMempoolAccept path. +// +// - The direct-broadcast path calls TestMempoolAccept with the single +// parent tx. +// - The CPFP path calls it with both parent and child as a package. +// - A backend rejection aborts submission with the backend's reason. +// - A backend "not supported" response is downgraded to a soft-miss +// and submission proceeds. +func TestPreflightTestMempoolAccept(t *testing.T) { + t.Run("package preflight precedes SubmitPackage", func(t *testing.T) { + chain := newFakeChainSourceRef(100) + chain.feeRate = 5 + chain.mempoolAcceptFn = func( + txs []*wire.MsgTx, + ) ([]chainsource.MempoolAcceptResult, error) { + + require.Len(t, txs, 2, + "CPFP path must preflight parent+child "+ + "together as a package") + + return []chainsource.MempoolAcceptResult{ + {Txid: txs[0].TxHash(), Accepted: true}, + {Txid: txs[1].TxHash(), Accepted: true}, + }, nil + } + wallet := &fakeWallet{ + utxos: []*wallet.Utxo{makeWalletUTXO()}, + } + b := NewCPFPBroadcaster(BroadcasterConfig{ + ChainSource: chain, + Wallet: wallet, + PreSubmitTestMempoolAccept: true, + }) + + _, err := b.Submit(t.Context(), 100, &BroadcastRequest{ + Tx: makeTestTx(true), Label: "anchor", + }) + require.NoError(t, err) + require.Len(t, chain.mempoolAcceptCalls, 1, + "exactly one preflight call per Submit") + require.Equal(t, 1, chain.packageCallCount()) + }) + + t.Run("direct-broadcast preflight is single-tx", func(t *testing.T) { + chain := newFakeChainSourceRef(100) + chain.feeRate = 5 + chain.mempoolAcceptFn = func( + txs []*wire.MsgTx, + ) ([]chainsource.MempoolAcceptResult, error) { + + require.Len(t, txs, 1, + "non-CPFP path must preflight only the tx") + + return []chainsource.MempoolAcceptResult{ + {Txid: txs[0].TxHash(), Accepted: true}, + }, nil + } + b := NewCPFPBroadcaster(BroadcasterConfig{ + ChainSource: chain, + PreSubmitTestMempoolAccept: true, + }) + + _, err := b.Submit(t.Context(), 100, &BroadcastRequest{ + Tx: makeTestTx(false), Label: "no-anchor", + }) + require.NoError(t, err) + require.Len(t, chain.mempoolAcceptCalls, 1) + require.Equal(t, 1, chain.broadcastCallCount()) + }) + + t.Run("backend rejection aborts with reason", func(t *testing.T) { + chain := newFakeChainSourceRef(100) + chain.mempoolAcceptFn = func( + txs []*wire.MsgTx, + ) ([]chainsource.MempoolAcceptResult, error) { + + return []chainsource.MempoolAcceptResult{ + { + Txid: txs[0].TxHash(), + Accepted: false, + Reason: "missing-inputs", + }, + }, nil + } + b := NewCPFPBroadcaster(BroadcasterConfig{ + ChainSource: chain, + PreSubmitTestMempoolAccept: true, + }) + + _, err := b.Submit(t.Context(), 100, &BroadcastRequest{ + Tx: makeTestTx(false), Label: "rejected", + }) + require.Error(t, err) + require.Contains(t, err.Error(), "missing-inputs") + require.Equal(t, 0, chain.broadcastCallCount(), + "backend rejection must abort before broadcast") + }) + + t.Run("unsupported backend is a soft-miss", func(t *testing.T) { + chain := newFakeChainSourceRef(100) + // No mempoolAcceptFn → fake returns "not supported". + b := NewCPFPBroadcaster(BroadcasterConfig{ + ChainSource: chain, + PreSubmitTestMempoolAccept: true, + }) + + _, err := b.Submit(t.Context(), 100, &BroadcastRequest{ + Tx: makeTestTx(false), Label: "unsupported-backend", + }) + require.NoError(t, err, + "an unsupported preflight must not block the submit") + require.Equal(t, 1, chain.broadcastCallCount()) + }) + + t.Run("preflight disabled by default", func(t *testing.T) { + chain := newFakeChainSourceRef(100) + chain.mempoolAcceptFn = func( + txs []*wire.MsgTx, + ) ([]chainsource.MempoolAcceptResult, error) { + + t.Fatal("preflight must not run when the flag is off") + return nil, nil + } + b := NewCPFPBroadcaster(BroadcasterConfig{ + ChainSource: chain, + }) + + _, err := b.Submit(t.Context(), 100, &BroadcastRequest{ + Tx: makeTestTx(false), Label: "no-preflight", + }) + require.NoError(t, err) + require.Empty(t, chain.mempoolAcceptCalls) + }) +} + +// TestUsedFeeOutpointsKeyedByParent verifies Phase 3 of the CPFP +// correctness fixes: UTXO reservations are scoped to the parent that +// consumed them and survive block boundaries until Evict, while a second +// parent is prevented from picking a UTXO another parent has in flight. +func TestUsedFeeOutpointsKeyedByParent(t *testing.T) { + t.Run("reservation survives a new block", func(t *testing.T) { + chain := newFakeChainSourceRef(100) + chain.feeRate = 5 + utxo := makeWalletUTXO() + b := NewCPFPBroadcaster(BroadcasterConfig{ + ChainSource: chain, + Wallet: &fakeWallet{utxos: []*wallet.Utxo{utxo}}, + }) + + parent := makeTestTx(true) + txid := parent.TxHash() + + _, err := b.Submit(t.Context(), 100, &BroadcastRequest{ + Tx: parent, Label: "initial", + }) + require.NoError(t, err) + require.Contains(t, + b.parentStates[txid].UsedFeeOutpoints, utxo.Outpoint, + "Submit must record the chosen fee outpoint against "+ + "the parent") + + // Advance to a higher block; under the previous + // per-block-clear behavior this would have erased the + // reservation. With per-parent keying it must persist. + _, err = b.Submit(t.Context(), 200, &BroadcastRequest{ + Tx: parent, Label: "same-parent-later-block", + }) + require.NoError(t, err) + require.Contains(t, + b.parentStates[txid].UsedFeeOutpoints, utxo.Outpoint, + "reservation must persist across block boundaries") + }) + + t.Run("second parent cannot reuse first parent's UTXO", + func(t *testing.T) { + chain := newFakeChainSourceRef(100) + chain.feeRate = 5 + utxo := makeWalletUTXO() + b := NewCPFPBroadcaster(BroadcasterConfig{ + ChainSource: chain, + Wallet: &fakeWallet{ + utxos: []*wallet.Utxo{utxo}, + }, + }) + + parentA := makeTestTx(true) + parentA.TxIn[0].PreviousOutPoint.Hash = + chainhash.Hash{0xaa} + _, err := b.Submit(t.Context(), 100, &BroadcastRequest{ + Tx: parentA, Label: "parent-a", + }) + require.NoError(t, err) + + // Parent B, a different txid, must not be able to + // claim the same fee UTXO while parent A is still + // tracked. + parentB := makeTestTx(true) + parentB.TxIn[0].PreviousOutPoint.Hash = + chainhash.Hash{0xbb} + require.NotEqual(t, parentA.TxHash(), parentB.TxHash()) + + _, err = b.Submit(t.Context(), 101, &BroadcastRequest{ + Tx: parentB, Label: "parent-b", + }) + require.ErrorIs(t, err, ErrCPFPFeeInputUnavailable, + "second parent must be blocked from reusing "+ + "the first parent's reserved fee UTXO") + }) + + t.Run("evict releases reservation for other parents", + func(t *testing.T) { + chain := newFakeChainSourceRef(100) + chain.feeRate = 5 + utxo := makeWalletUTXO() + b := NewCPFPBroadcaster(BroadcasterConfig{ + ChainSource: chain, + Wallet: &fakeWallet{ + utxos: []*wallet.Utxo{utxo}, + }, + }) + + parentA := makeTestTx(true) + parentA.TxIn[0].PreviousOutPoint.Hash = + chainhash.Hash{0xaa} + _, err := b.Submit(t.Context(), 100, &BroadcastRequest{ + Tx: parentA, Label: "parent-a", + }) + require.NoError(t, err) + + // Evict parent A; parent B should now be able to pick + // the same UTXO. + b.Evict(t.Context(), parentA.TxHash()) + + parentB := makeTestTx(true) + parentB.TxIn[0].PreviousOutPoint.Hash = + chainhash.Hash{0xbb} + _, err = b.Submit(t.Context(), 101, &BroadcastRequest{ + Tx: parentB, Label: "parent-b", + }) + require.NoError(t, err, + "Evict must free the fee UTXO for other "+ + "parents") + }) + + t.Run("same parent re-picking own UTXO is allowed", + func(t *testing.T) { + chain := newFakeChainSourceRef(100) + chain.feeRate = 5 + utxo := makeWalletUTXO() + b := NewCPFPBroadcaster(BroadcasterConfig{ + ChainSource: chain, + Wallet: &fakeWallet{ + utxos: []*wallet.Utxo{utxo}, + }, + }) + + parent := makeTestTx(true) + result1, err := b.Submit(t.Context(), 100, + &BroadcastRequest{Tx: parent, Label: "bump-1"}, + ) + require.NoError(t, err) + + // Second submission for the SAME parent with no + // other UTXOs available must succeed; per-parent + // re-picking is how TRUC package RBF triggers + // replacement via double-spending the fee input. + result2, err := b.Submit(t.Context(), 101, + &BroadcastRequest{Tx: parent, Label: "bump-2"}, + ) + require.NoError(t, err, + "a parent must be allowed to re-pick a UTXO "+ + "from its own reserved set") + require.Greater(t, result2.FeeRate, result1.FeeRate) + }) +} + +// TestCPFPBroadcasterFeeBumpReplacementFloor exercises the BIP-125 Rule 3 +// and Rule 4 enforcement applied on every Submit after the first one. +// +// We submit the same parent repeatedly with controlled fee estimator +// behaviour and verify: +// +// - A flat-fee estimator forces the replacement feerate up by at least +// 1 sat/vB so Rule 4 is satisfied. +// - A dipping estimator still lands a replacement strictly above the +// prior feerate. +// - The absolute package fee grows by at least +// IncrementalRelayFeeSatPerVByte * packageVSize on every bump so +// Rule 3 is satisfied. +// - Evict clears the per-parent bump history so a brand-new parent +// starts from the estimator again. +func TestCPFPBroadcasterFeeBumpReplacementFloor(t *testing.T) { + newBroadcaster := func(chain *fakeChainSourceRef) *CPFPBroadcaster { + largeUTXO := &wallet.Utxo{ + Outpoint: wire.OutPoint{ + Hash: chainhash.Hash{2}, + Index: 1, + }, + Amount: 5_000_000, + PkScript: p2trTestPkScript(), + } + + return NewCPFPBroadcaster(BroadcasterConfig{ + ChainSource: chain, + Wallet: &fakeWallet{ + utxos: []*wallet.Utxo{largeUTXO}, + }, + IncrementalRelayFeeSatPerVByte: 1, + }) + } + + parent := makeTestTx(true) + txid := parent.TxHash() + + t.Run("flat estimator still ratchets feerate", func(t *testing.T) { + chain := newFakeChainSourceRef(100) + chain.feeRate = 5 + b := newBroadcaster(chain) + + first, err := b.Submit(t.Context(), 100, &BroadcastRequest{ + Tx: parent, Label: "bump", + }) + require.NoError(t, err) + require.Equal(t, int64(5), first.FeeRate) + + second, err := b.Submit(t.Context(), 101, &BroadcastRequest{ + Tx: parent, Label: "bump", + }) + require.NoError(t, err) + require.Greater(t, second.FeeRate, first.FeeRate, + "replacement feerate must strictly exceed prior "+ + "feerate (BIP-125 Rule 4)") + + prev := b.parentStates[txid].LastPackageFee + require.Greater(t, int64(prev), int64(0)) + + third, err := b.Submit(t.Context(), 102, &BroadcastRequest{ + Tx: parent, Label: "bump", + }) + require.NoError(t, err) + require.Greater(t, third.FeeRate, second.FeeRate) + require.Greater(t, int64(b.parentStates[txid].LastPackageFee), + int64(prev), + "replacement absolute fee must strictly exceed prior "+ + "absolute fee (BIP-125 Rule 3)") + }) + + t.Run("estimator dip ratchets up", func(t *testing.T) { + chain := newFakeChainSourceRef(100) + chain.feeRate = 10 + b := newBroadcaster(chain) + + first, err := b.Submit(t.Context(), 100, &BroadcastRequest{ + Tx: parent, Label: "bump", + }) + require.NoError(t, err) + require.Equal(t, int64(10), first.FeeRate) + + chain.feeRate = 3 // estimator dips below prior feerate. + + second, err := b.Submit(t.Context(), 101, &BroadcastRequest{ + Tx: parent, Label: "bump", + }) + require.NoError(t, err) + require.Greater(t, second.FeeRate, first.FeeRate, + "replacement feerate must strictly exceed prior "+ + "feerate even when the estimator dips") + }) + + t.Run("evict clears per-parent bump history", func(t *testing.T) { + chain := newFakeChainSourceRef(100) + chain.feeRate = 5 + b := newBroadcaster(chain) + + _, err := b.Submit(t.Context(), 100, &BroadcastRequest{ + Tx: parent, Label: "bump", + }) + require.NoError(t, err) + require.NotNil(t, b.parentStates[txid]) + + b.Evict(t.Context(), txid) + require.Nil(t, b.parentStates[txid], + "Evict must release the per-parent bump state") + + // Follow-up submission starts from the raw estimator again. + next, err := b.Submit(t.Context(), 101, &BroadcastRequest{ + Tx: parent, Label: "bump-after-evict", + }) + require.NoError(t, err) + require.Equal(t, int64(5), next.FeeRate, + "after eviction, feerate should come straight from "+ + "the estimator") + }) +} + +// TestSignCPFPChildHandlesWalletInputRewrites exercises signCPFPChild with +// wallets that return finalized transactions whose input composition does +// not exactly round-trip the requested PSBT. The positional-indexing +// implementation this test guards against would panic on a length +// mismatch or silently miswire witnesses when inputs are reordered; the +// outpoint-matched implementation must return a clean error in the first +// case and succeed transparently in the second. +func TestSignCPFPChildHandlesWalletInputRewrites(t *testing.T) { + parent := makeTestTx(true) + anchorIdx := findAnchorOutput(parent) + require.GreaterOrEqual(t, anchorIdx, 0) + + anchorOutpoint := wire.OutPoint{ + Hash: parent.TxHash(), + Index: uint32(anchorIdx), + } + + t.Run("reordered inputs still succeed", func(t *testing.T) { + chain := newFakeChainSourceRef(100) + swap := &rewritingWallet{ + utxos: []*wallet.Utxo{makeWalletUTXO()}, + rewrite: func(tx *wire.MsgTx) *wire.MsgTx { + out := tx.Copy() + out.TxIn[0], out.TxIn[1] = + out.TxIn[1], out.TxIn[0] + + return out + }, + } + broadcaster := NewCPFPBroadcaster(BroadcasterConfig{ + ChainSource: chain, + Wallet: swap, + }) + + result, err := broadcaster.Submit(t.Context(), 100, + &BroadcastRequest{Tx: parent, Label: "anchor"}, + ) + require.NoError(t, err) + require.NotNil(t, result.ChildTxid) + }) + + t.Run("wallet adding extra input fails cleanly", func(t *testing.T) { + chain := newFakeChainSourceRef(100) + extra := &rewritingWallet{ + utxos: []*wallet.Utxo{makeWalletUTXO()}, + rewrite: func(tx *wire.MsgTx) *wire.MsgTx { + out := tx.Copy() + out.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: chainhash.Hash{99}, + Index: 0, + }, + Witness: wire.TxWitness{ + make([]byte, 64), + }, + }) + + return out + }, + } + broadcaster := NewCPFPBroadcaster(BroadcasterConfig{ + ChainSource: chain, + Wallet: extra, + }) + + require.NotPanics(t, func() { + _, err := broadcaster.Submit(t.Context(), 100, + &BroadcastRequest{Tx: parent, Label: "anchor"}, + ) + require.NoError(t, err) + }) + + // The sign error should have fallen back to direct parent + // broadcast rather than crashing or submitting a malformed + // package. + require.Equal(t, 1, chain.broadcastCallCount()) + require.Equal(t, 0, chain.packageCallCount()) + }) + + t.Run("substituted outpoint fails cleanly", func(t *testing.T) { + chain := newFakeChainSourceRef(100) + replacement := wire.OutPoint{ + Hash: chainhash.Hash{123}, + Index: 7, + } + rename := &rewritingWallet{ + utxos: []*wallet.Utxo{makeWalletUTXO()}, + rewrite: func(tx *wire.MsgTx) *wire.MsgTx { + out := tx.Copy() + for i := range out.TxIn { + prev := out.TxIn[i].PreviousOutPoint + if prev == anchorOutpoint { + continue + } + out.TxIn[i].PreviousOutPoint = + replacement + } + + return out + }, + } + broadcaster := NewCPFPBroadcaster(BroadcasterConfig{ + ChainSource: chain, + Wallet: rename, + }) + + require.NotPanics(t, func() { + _, err := broadcaster.Submit(t.Context(), 100, + &BroadcastRequest{Tx: parent, Label: "anchor"}, + ) + require.NoError(t, err) + }) + + // signCPFPChild's missing-outpoint guard forces the fallback + // to direct parent broadcast rather than submitting a + // malformed package. + require.Equal(t, 1, chain.broadcastCallCount()) + require.Equal(t, 0, chain.packageCallCount()) + }) +} diff --git a/txconfirm/doc.go b/txconfirm/doc.go new file mode 100644 index 000000000..74dd3efd6 --- /dev/null +++ b/txconfirm/doc.go @@ -0,0 +1,120 @@ +// Package txconfirm provides a generic shared actor for ensuring that +// transactions are confirmed on-chain. +// +// # Overview +// +// Any subsystem that needs "get this transaction confirmed and tell me when +// it happens" can use this package. txconfirm is intentionally +// subsystem-neutral — no unroll/, vtxo/, payment/, or round/ semantics leak +// in. Callers submit a signed transaction via EnsureConfirmedReq together +// with a subscriber that receives a terminal TxConfirmed or TxFailed +// notification, and cancel their interest with CancelInterestReq when they +// no longer care. +// +// The actor deduplicates by txid: two callers asking to confirm the same +// transaction share a single confirmation watch, a single broadcast +// attempt, and (when applicable) a single anchor-paying CPFP child. Each +// caller still gets its own terminal notification. +// +// # Architecture +// +// The package is split into two layers: +// +// - TxBroadcasterActor (actor.go) is the message-driven orchestrator. +// It holds a tracked-tx map keyed by txid, runs a protofsm lifecycle +// per txid, and handles the fan-out of chainsource callbacks +// (confirmation events, block epochs) back into per-txid state +// transitions. +// +// - CPFPBroadcaster (broadcaster.go) is an actor-free helper that +// handles the actual broadcast mechanics: direct submission for +// transactions without anchors, CPFP child construction and package +// submission for anchor parents, fee estimation, fee-input +// selection, and fee-bump replacement-floor enforcement. Callers can +// use CPFPBroadcaster standalone from outside the actor if they +// need the broadcast primitives without the tracking harness. +// +// # Lifecycle +// +// Each tracked txid transitions through a protofsm state machine: +// +// New → Broadcasting → AwaitingConfirmation → FeeBumping → … → Confirmed +// \→ Failed +// +// New is the initial state. Broadcasting and FeeBumping are transient +// states the FSM spends time in while submitting to the network. +// AwaitingConfirmation is the steady state between broadcast attempts +// waiting for the chain to confirm or for a fee-bump interval to +// elapse. Confirmed and Failed are terminal; upon entering either the +// actor notifies every subscriber in fan-out order and then evicts the +// tracked entry entirely (see the "Eviction" invariant below). +// +// # CPFP correctness +// +// For transactions containing an ephemeral anchor output (BIP 431), the +// CPFPBroadcaster attaches a fee-paying child that spends both the +// anchor and a confirmed wallet UTXO, then submits the parent + child +// as a TRUC-compliant package (BIP 331). Correctness of this flow rests +// on five invariants, each guarded by a dedicated code path: +// +// 1. Version gate. Submit rejects non-v3 parents so pattern-based +// anchor detection never misattaches a CPFP child to a coincidental +// anyone-can-spend output on a legacy parent. +// +// 2. Replacement floor. Every fee bump runs through +// applyReplacementFloor before selecting a fee input, which +// enforces BIP-125 Rule 4 (strictly higher feerate) and Rule 3 +// (strictly higher absolute fee, by at least +// IncrementalRelayFeeSatPerVByte * packageVSize) against the last +// successful submission for the same parent txid. Without this, a +// flat or dipping fee estimator would regenerate byte-identical or +// lower-fee packages that the mempool rejects. +// +// 3. Fee-input reservation. Each parent txid reserves the wallet +// UTXO(s) it has committed to across its submission history. +// Reservations survive block boundaries and are released only when +// the parent is evicted, preventing two concurrent parents from +// racing for the same UTXO. A parent IS allowed to re-pick UTXOs +// from its own reserved set, because TRUC package RBF relies on +// the new child double-spending the previous child's fee input. +// +// 4. RBF-signaling fee input. The CPFP child's fee input carries +// sequence MaxTxInSequenceNum - 2 (= 0xfffffffd) as a +// belt-and-suspenders so even a non-TRUC parent (if one ever +// slipped past the version gate) would produce a BIP-125-signaling +// child. +// +// 5. Optional preflight. When the caller enables +// PreSubmitTestMempoolAccept, every broadcast attempt is first +// validated against the backend's testmempoolaccept RPC. Rejections +// abort the submission with the backend's reject reason; backends +// that do not implement the RPC are downgraded to a soft-miss. +// +// # PSBT finalization +// +// signCPFPChild matches PSBT inputs by PreviousOutPoint rather than by +// positional index. Wallets that reorder finalized inputs (BIP 69) or +// add/remove inputs relative to the supplied PSBT return a clean error +// rather than silently mis-wiring witnesses or panicking on an +// out-of-bounds index. +// +// # Service-key round trip +// +// RegisterConfRequest and UnregisterConfRequest both carry PkScript so +// that chainsource's txid+script keyed service-actor lookup resolves +// symmetrically in both directions. Dropping PkScript on one side would +// leak one conf sub-actor per tracked tx. +// +// # Eviction +// +// Once a tracked txid reaches Confirmed or Failed and every subscriber +// has been notified, the actor's evictTerminal helper unregisters any +// remaining chainsource subscriptions, stops the per-txid FSM +// goroutine, releases the parent's fee-input reservations in the +// broadcaster, and drops the entry from the tracking map. Without this +// step a long-lived daemon would accumulate one FSM goroutine and one +// cached *wire.MsgTx per transaction it ever confirmed. A late caller +// that arrives after eviction re-registers with chainsource and — if +// the tx is already confirmed on-chain — receives an immediate +// TxConfirmed notification through the normal path. +package txconfirm diff --git a/txconfirm/fsm_test.go b/txconfirm/fsm_test.go new file mode 100644 index 000000000..c01ed88a4 --- /dev/null +++ b/txconfirm/fsm_test.go @@ -0,0 +1,202 @@ +package txconfirm + +import ( + "testing" + + "github.com/btcsuite/btclog/v2" + "github.com/stretchr/testify/require" +) + +// mustCurrentTrackedTxState returns the current tracked-tx FSM state. +func mustCurrentTrackedTxState(t *testing.T, + fsm *trackedTxStateMachine) trackedTxState { + + t.Helper() + + rawState, err := fsm.CurrentState() + require.NoError(t, err) + + state, ok := rawState.(trackedTxState) + require.True(t, ok) + + return state +} + +// TestTrackedTxFSMInitialBroadcastFlow verifies that the tracked-tx protofsm +// carries immutable data and broadcast progress through its normal lifecycle. +func TestTrackedTxFSMInitialBroadcastFlow(t *testing.T) { + tx := makeTestTx(true) + data := trackedTxData{ + Tx: tx, + Txid: tx.TxHash(), + Label: "test", + HeightHint: 91, + TargetConfs: 2, + } + + fsm := newTrackedTxStateMachine(btclog.Disabled, data) + fsm.Start(t.Context()) + t.Cleanup(fsm.Stop) + + _, err := fsm.AskEvent( + t.Context(), &trackedTxBroadcastStarted{}, + ).Await(t.Context()).Unpack() + require.NoError(t, err) + + broadcasting, ok := mustCurrentTrackedTxState( + t, fsm, + ).(*trackedTxStateBroadcasting) + require.True(t, ok) + require.Equal(t, data.Txid, broadcasting.Txid) + require.Equal(t, data.TargetConfs, broadcasting.TargetConfs) + + progress := trackedTxProgress{ + LastBroadcastHeight: 100, + CurrentFeeRate: 7, + ChildTxid: copyHash(&data.Txid), + } + _, err = fsm.AskEvent( + t.Context(), &trackedTxBroadcastAccepted{ + Progress: progress, + }, + ).Await(t.Context()).Unpack() + require.NoError(t, err) + + awaiting, ok := mustCurrentTrackedTxState( + t, fsm, + ).(*trackedTxStateAwaitingConfirmation) + require.True(t, ok) + require.Equal(t, progress.LastBroadcastHeight, + awaiting.LastBroadcastHeight) + require.Equal(t, progress.CurrentFeeRate, awaiting.CurrentFeeRate) + require.Equal(t, 0, awaiting.BumpCount) + require.Equal(t, progress.ChildTxid, awaiting.ChildTxid) + require.Equal(t, TxStateAwaitingConfirmation, + txStateFromTrackedState(awaiting)) + require.Equal(t, int32(100), trackedTxLastBroadcastHeight(awaiting)) + + _, err = fsm.AskEvent( + t.Context(), &trackedTxConfirmed{ + BlockHeight: 102, + }, + ).Await(t.Context()).Unpack() + require.NoError(t, err) + + confirmed, ok := mustCurrentTrackedTxState( + t, fsm, + ).(*trackedTxStateConfirmed) + require.True(t, ok) + require.Equal(t, int32(102), confirmed.ConfirmHeight) + require.Equal(t, progress.LastBroadcastHeight, + confirmed.LastBroadcastHeight) + height, ok := trackedTxConfirmHeight(confirmed) + require.True(t, ok) + require.Equal(t, int32(102), height) + require.Equal(t, TxStateConfirmed, txStateFromTrackedState(confirmed)) +} + +// TestTrackedTxFSMFeeBumpFlow verifies that fee-bump retries preserve prior +// progress and increment the bump counter on successful rebroadcast. +func TestTrackedTxFSMFeeBumpFlow(t *testing.T) { + tx := makeTestTx(true) + data := trackedTxData{ + Tx: tx, + Txid: tx.TxHash(), + TargetConfs: 1, + } + + fsm := newTrackedTxStateMachine(btclog.Disabled, data) + fsm.Start(t.Context()) + t.Cleanup(fsm.Stop) + + _, err := fsm.AskEvent( + t.Context(), &trackedTxBroadcastStarted{}, + ).Await(t.Context()).Unpack() + require.NoError(t, err) + + _, err = fsm.AskEvent( + t.Context(), &trackedTxBroadcastAccepted{ + Progress: trackedTxProgress{ + LastBroadcastHeight: 100, + CurrentFeeRate: 5, + }, + }, + ).Await(t.Context()).Unpack() + require.NoError(t, err) + + _, err = fsm.AskEvent( + t.Context(), &trackedTxFeeBumpStarted{}, + ).Await(t.Context()).Unpack() + require.NoError(t, err) + + feeBumping, ok := mustCurrentTrackedTxState( + t, fsm, + ).(*trackedTxStateFeeBumping) + require.True(t, ok) + require.Equal(t, int32(100), feeBumping.LastBroadcastHeight) + require.Equal(t, 0, feeBumping.BumpCount) + + _, err = fsm.AskEvent( + t.Context(), &trackedTxBroadcastAccepted{ + Progress: trackedTxProgress{ + LastBroadcastHeight: 103, + CurrentFeeRate: 11, + }, + }, + ).Await(t.Context()).Unpack() + require.NoError(t, err) + + awaiting, ok := mustCurrentTrackedTxState( + t, fsm, + ).(*trackedTxStateAwaitingConfirmation) + require.True(t, ok) + require.Equal(t, int32(103), awaiting.LastBroadcastHeight) + require.Equal(t, int64(11), awaiting.CurrentFeeRate) + require.Equal(t, 1, awaiting.BumpCount) +} + +// TestTrackedTxFSMFailureAndInvalidTransitions verifies terminal failure +// projection and unexpected-event error handling. +func TestTrackedTxFSMFailureAndInvalidTransitions(t *testing.T) { + tx := makeTestTx(false) + data := trackedTxData{ + Tx: tx, + Txid: tx.TxHash(), + TargetConfs: 1, + } + + fsm := newTrackedTxStateMachine(btclog.Disabled, data) + fsm.Start(t.Context()) + t.Cleanup(fsm.Stop) + + _, err := fsm.AskEvent( + t.Context(), &trackedTxFailed{ + Reason: "broadcast rejected", + }, + ).Await(t.Context()).Unpack() + require.NoError(t, err) + + failed, ok := mustCurrentTrackedTxState( + t, fsm, + ).(*trackedTxStateFailed) + require.True(t, ok) + reason, ok := trackedTxFailureReason(failed) + require.True(t, ok) + require.Equal(t, "broadcast rejected", reason) + require.Equal(t, TxStateFailed, txStateFromTrackedState(failed)) + require.Zero(t, trackedTxLastBroadcastHeight(failed)) + + _, err = failed.ProcessEvent( + t.Context(), &trackedTxBroadcastStarted{}, + &trackedTxEnvironment{Txid: data.Txid}, + ) + require.Error(t, err) + + newState := &trackedTxStateNew{trackedTxData: data} + _, err = newState.ProcessEvent( + t.Context(), &trackedTxConfirmed{ + BlockHeight: 1, + }, &trackedTxEnvironment{Txid: data.Txid}, + ) + require.Error(t, err) +} diff --git a/txconfirm/fsm_types.go b/txconfirm/fsm_types.go new file mode 100644 index 000000000..bce9d73a4 --- /dev/null +++ b/txconfirm/fsm_types.go @@ -0,0 +1,270 @@ +package txconfirm + +import ( + "fmt" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/darepo-client/baselib/protofsm" +) + +// trackedTxStateMachine is the protofsm instance used for one tracked txid. +type trackedTxStateMachine = protofsm.StateMachine[ + trackedTxEvent, trackedTxOutboxEvent, *trackedTxEnvironment, +] + +// trackedTxStateTransition is the tracked-tx protofsm transition type. +type trackedTxStateTransition = protofsm.StateTransition[ + trackedTxEvent, trackedTxOutboxEvent, *trackedTxEnvironment, +] + +// trackedTxState is the sealed protofsm state interface for one tracked txid. +type trackedTxState interface { + protofsm.State[ + trackedTxEvent, trackedTxOutboxEvent, *trackedTxEnvironment, + ] + trackedTxStateSealed() +} + +// trackedTxEvent is the sealed event surface accepted by the tracked-tx FSM. +type trackedTxEvent interface { + trackedTxEventSealed() +} + +// trackedTxOutboxEvent is the tracked-tx outbox event surface. +// +// The tracked-tx FSM is intentionally pure and does not currently emit outbox +// events. The sealed interface still exists so the package follows the same +// protofsm shape as the rest of the codebase. +type trackedTxOutboxEvent interface { + trackedTxOutboxEventSealed() +} + +// trackedTxEnvironment carries immutable execution context for one tracked-tx +// FSM instance. +type trackedTxEnvironment struct { + // Txid identifies the tracked transaction for logs and debugging. + Txid chainhash.Hash +} + +// trackedTxData is the immutable request data for one tracked txid. +type trackedTxData struct { + // Tx is the fully signed transaction that should be confirmed. + Tx *wire.MsgTx + + // Txid is the transaction hash used for deduplication. + Txid chainhash.Hash + + // ConfirmationPkScript is the output script used for the confirmation + // watch. + ConfirmationPkScript []byte + + // Label is the optional human-readable broadcast label. + Label string + + // HeightHint is the earliest height the transaction could confirm at. + HeightHint uint32 + + // TargetConfs is the required confirmation count. + TargetConfs uint32 +} + +// trackedTxProgress is the mutable per-broadcast progress carried by FSM +// states after the transaction has been submitted at least once. +type trackedTxProgress struct { + // LastBroadcastHeight is the chain height at which the last submission + // attempt completed. + LastBroadcastHeight int32 + + // CurrentFeeRate is the fee rate used by the latest submission attempt. + CurrentFeeRate int64 + + // BumpCount counts successful fee-bump rebroadcasts after the initial + // submission. + BumpCount int + + // ChildTxid is the latest CPFP child txid when an anchor package was + // built. + ChildTxid *chainhash.Hash +} + +// trackedTxBroadcastStarted records the start of the initial broadcast +// attempt. +type trackedTxBroadcastStarted struct{} + +// trackedTxEventSealed marks trackedTxBroadcastStarted as a tracked-tx event. +func (e *trackedTxBroadcastStarted) trackedTxEventSealed() {} + +// trackedTxBroadcastAccepted records that the current broadcast attempt +// completed successfully and the tx is now waiting for confirmation. +type trackedTxBroadcastAccepted struct { + // Progress captures the latest broadcast metadata. + Progress trackedTxProgress +} + +// trackedTxEventSealed marks trackedTxBroadcastAccepted as a tracked-tx event. +func (e *trackedTxBroadcastAccepted) trackedTxEventSealed() {} + +// trackedTxFeeBumpStarted records the start of a fee-bump rebroadcast +// attempt. +type trackedTxFeeBumpStarted struct{} + +// trackedTxEventSealed marks trackedTxFeeBumpStarted as a tracked-tx event. +func (e *trackedTxFeeBumpStarted) trackedTxEventSealed() {} + +// trackedTxConfirmed records terminal confirmation of the tracked txid. +type trackedTxConfirmed struct { + // BlockHeight is the block height where the tx confirmed. + BlockHeight int32 +} + +// trackedTxEventSealed marks trackedTxConfirmed as a tracked-tx event. +func (e *trackedTxConfirmed) trackedTxEventSealed() {} + +// trackedTxFailed records a terminal failure for the tracked txid. +type trackedTxFailed struct { + // Reason is the stable human-readable failure reason. + Reason string +} + +// trackedTxEventSealed marks trackedTxFailed as a tracked-tx event. +func (e *trackedTxFailed) trackedTxEventSealed() {} + +// trackedTxErrorReporter reports tracked-tx FSM errors through the package +// logger. +type trackedTxErrorReporter struct { + log btclog.Logger + txid chainhash.Hash +} + +// ReportError logs a tracked-tx FSM execution error. +func (r *trackedTxErrorReporter) ReportError(err error) { + r.log.Error("Tracked tx FSM error", btclog.Hex("txid", r.txid[:]), err) +} + +// newTrackedTxStateMachine creates a new protofsm state machine for one +// tracked txid. +func newTrackedTxStateMachine(log btclog.Logger, + data trackedTxData) *trackedTxStateMachine { + + cfg := protofsm.StateMachineCfg[ + trackedTxEvent, trackedTxOutboxEvent, *trackedTxEnvironment, + ]{ + Logger: log, + ErrorReporter: &trackedTxErrorReporter{ + log: log, + txid: data.Txid, + }, + InitialState: &trackedTxStateNew{ + trackedTxData: data, + }, + Env: &trackedTxEnvironment{ + Txid: data.Txid, + }, + } + + fsm := protofsm.NewStateMachine(cfg) + + return &fsm +} + +// txStateFromTrackedState projects an internal protofsm state into the public +// TxState status enum returned by the actor API. +func txStateFromTrackedState(state trackedTxState) TxState { + switch state.(type) { + case *trackedTxStateNew: + return TxStateNew + + case *trackedTxStateBroadcasting: + return TxStateBroadcasting + + case *trackedTxStateAwaitingConfirmation: + return TxStateAwaitingConfirmation + + case *trackedTxStateFeeBumping: + return TxStateFeeBumping + + case *trackedTxStateConfirmed: + return TxStateConfirmed + + case *trackedTxStateFailed: + return TxStateFailed + + default: + return TxStateFailed + } +} + +// currentTxState returns the tracked tx's projected public state. +func (t *trackedTx) currentTxState() (TxState, error) { + state, err := t.currentFSMState() + if err != nil { + return TxStateFailed, err + } + + return txStateFromTrackedState(state), nil +} + +// currentFSMState returns the current protofsm state for one tracked tx. +func (t *trackedTx) currentFSMState() (trackedTxState, error) { + if t.fsm == nil { + return nil, fmt.Errorf("tracked tx fsm not initialized") + } + + rawState, err := t.fsm.CurrentState() + if err != nil { + return nil, err + } + + state, ok := rawState.(trackedTxState) + if !ok { + return nil, fmt.Errorf( + "unexpected tracked tx state %T", rawState, + ) + } + + return state, nil +} + +// trackedTxLastBroadcastHeight returns the state's latest broadcast height. +func trackedTxLastBroadcastHeight(state trackedTxState) int32 { + switch s := state.(type) { + case *trackedTxStateAwaitingConfirmation: + return s.LastBroadcastHeight + + case *trackedTxStateFeeBumping: + return s.LastBroadcastHeight + + case *trackedTxStateConfirmed: + return s.LastBroadcastHeight + + case *trackedTxStateFailed: + return s.LastBroadcastHeight + + default: + return 0 + } +} + +// trackedTxConfirmHeight returns the state's confirmation height if the +// transaction has already confirmed. +func trackedTxConfirmHeight(state trackedTxState) (int32, bool) { + confirmed, ok := state.(*trackedTxStateConfirmed) + if !ok { + return 0, false + } + + return confirmed.ConfirmHeight, true +} + +// trackedTxFailureReason returns the state's terminal failure reason when +// available. +func trackedTxFailureReason(state trackedTxState) (string, bool) { + failed, ok := state.(*trackedTxStateFailed) + if !ok { + return "", false + } + + return failed.Reason, true +} diff --git a/txconfirm/messages.go b/txconfirm/messages.go new file mode 100644 index 000000000..ce7defa8b --- /dev/null +++ b/txconfirm/messages.go @@ -0,0 +1,250 @@ +package txconfirm + +import ( + "fmt" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/baselib/actor" +) + +// Msg is the sealed message surface accepted by the tx confirmation actor. +type Msg interface { + actor.Message + txConfirmMsgSealed() +} + +// Resp is the sealed response surface returned by the tx confirmation actor. +type Resp interface { + actor.Message + txConfirmRespSealed() +} + +// Notification is the sealed notification surface emitted to subscribers of a +// tracked transaction. +type Notification interface { + actor.Message + txConfirmNotificationSealed() +} + +// TxState identifies the lifecycle state of one tracked transaction. +type TxState int + +const ( + // TxStateNew is the initial state before any work starts. + TxStateNew TxState = iota + + // TxStateBroadcasting indicates the initial broadcast attempt is in + // progress. + TxStateBroadcasting + + // TxStateAwaitingConfirmation indicates the transaction has been + // submitted and is waiting for chain confirmation. + TxStateAwaitingConfirmation + + // TxStateFeeBumping indicates a replacement or rebroadcast attempt is + // in progress after the initial submission. + TxStateFeeBumping + + // TxStateConfirmed indicates the tracked transaction reached its target + // confirmation count. + TxStateConfirmed + + // TxStateFailed indicates the actor encountered a terminal + // failure while + // trying to confirm the transaction. + TxStateFailed +) + +// String returns a stable debug label for one transaction state. +func (s TxState) String() string { + switch s { + case TxStateNew: + return "new" + + case TxStateBroadcasting: + return "broadcasting" + + case TxStateAwaitingConfirmation: + return "awaiting_confirmation" + + case TxStateFeeBumping: + return "fee_bumping" + + case TxStateConfirmed: + return "confirmed" + + case TxStateFailed: + return "failed" + + default: + return fmt.Sprintf("unknown(%d)", s) + } +} + +// EnsureConfirmedReq asks the actor to ensure that a signed transaction +// reaches the requested confirmation target. +// +// Deduplication is keyed by txid. If the same txid is already being tracked, +// the actor attaches the supplied subscriber to the existing tracking state +// instead of starting a second confirmation workflow. +type EnsureConfirmedReq struct { + actor.BaseMessage + + // Tx is the fully signed transaction to confirm. + Tx *wire.MsgTx + + // ConfirmationPkScript is the script used for the confirmation watch. + // When empty, the actor falls back to the first transaction output + // script. + ConfirmationPkScript []byte + + // Label is an optional human-readable label for broadcast logging. + Label string + + // HeightHint is the earliest height the transaction could appear in. + HeightHint uint32 + + // TargetConfs is the required confirmation count. Zero defaults to one. + TargetConfs uint32 + + // Subscriber receives TxConfirmed or TxFailed notifications for this + // request. + Subscriber actor.TellOnlyRef[Notification] +} + +// MessageType returns the stable message type identifier. +func (m *EnsureConfirmedReq) MessageType() string { + return "EnsureConfirmedReq" +} + +// txConfirmMsgSealed seals EnsureConfirmedReq into the package message set. +func (m *EnsureConfirmedReq) txConfirmMsgSealed() {} + +// EnsureConfirmedResp acknowledges an EnsureConfirmedReq. +type EnsureConfirmedResp struct { + actor.BaseMessage + + // Txid is the deduplication key for the tracked transaction. + Txid chainhash.Hash + + // State is the actor's current state for this txid after processing the + // request. + State TxState + + // Created is true when the request created a new tracking entry and + // false when it attached to existing state. + Created bool +} + +// MessageType returns the stable message type identifier. +func (m *EnsureConfirmedResp) MessageType() string { + return "EnsureConfirmedResp" +} + +// txConfirmRespSealed seals EnsureConfirmedResp into the package response set. +func (m *EnsureConfirmedResp) txConfirmRespSealed() {} + +// CancelInterestReq asks the actor to remove one subscriber's interest in a +// tracked transaction. +type CancelInterestReq struct { + actor.BaseMessage + + // Txid identifies the tracked transaction. + Txid chainhash.Hash + + // SubscriberID is the ID of the subscriber to remove. Callers typically + // pass Subscriber.ID() from the original EnsureConfirmedReq. + SubscriberID string +} + +// MessageType returns the stable message type identifier. +func (m *CancelInterestReq) MessageType() string { + return "CancelInterestReq" +} + +// txConfirmMsgSealed seals CancelInterestReq into the package message set. +func (m *CancelInterestReq) txConfirmMsgSealed() {} + +// CancelInterestResp describes the result of removing subscriber interest from +// a tracked transaction. +type CancelInterestResp struct { + actor.BaseMessage + + // Txid identifies the tracked transaction. + Txid chainhash.Hash + + // Removed reports whether the subscriber was present and removed. + Removed bool + + // RemainingSubscribers is the number of subscribers still attached to + // the txid after processing the request. + RemainingSubscribers int + + // StoppedTracking is true when the actor dropped the tracked entry + // because no subscribers remained and the transaction was not yet in a + // terminal state. + StoppedTracking bool +} + +// MessageType returns the stable message type identifier. +func (m *CancelInterestResp) MessageType() string { + return "CancelInterestResp" +} + +// txConfirmRespSealed seals CancelInterestResp into the package response set. +func (m *CancelInterestResp) txConfirmRespSealed() {} + +// TxConfirmed notifies a subscriber that the tracked transaction reached its +// requested confirmation target. +type TxConfirmed struct { + actor.BaseMessage + + // Txid is the confirmed transaction hash. + Txid chainhash.Hash + + // BlockHeight is the block height where the transaction confirmed. + BlockHeight int32 + + // NumConfs is the confirmation count reported by the backend. + NumConfs uint32 +} + +// MessageType returns the stable message type identifier. +func (m *TxConfirmed) MessageType() string { + return "TxConfirmed" +} + +// txConfirmNotificationSealed seals TxConfirmed into the package notification +// set. +func (m *TxConfirmed) txConfirmNotificationSealed() {} + +// TxFailed notifies a subscriber that the actor encountered a terminal +// failure while trying to confirm the tracked transaction. +type TxFailed struct { + actor.BaseMessage + + // Txid identifies the failed transaction. + Txid chainhash.Hash + + // Reason is a stable human-readable failure reason. + Reason string +} + +// MessageType returns the stable message type identifier. +func (m *TxFailed) MessageType() string { + return "TxFailed" +} + +// txConfirmNotificationSealed seals TxFailed into the package notification +// set. +func (m *TxFailed) txConfirmNotificationSealed() {} + +// MapNotification adapts txconfirm notifications into a caller-specific actor +// message type. +func MapNotification[Out actor.Message]( + targetRef actor.TellOnlyRef[Out], mapFn func(Notification) Out, +) actor.TellOnlyRef[Notification] { + + return actor.NewMapInputRef(targetRef, mapFn) +} diff --git a/txconfirm/states.go b/txconfirm/states.go new file mode 100644 index 000000000..a1a58f04e --- /dev/null +++ b/txconfirm/states.go @@ -0,0 +1,285 @@ +package txconfirm + +import ( + "context" + "fmt" +) + +// trackedTxStateNew is the initial tracked-tx FSM state. +type trackedTxStateNew struct { + trackedTxData +} + +// String returns a human-readable representation of the initial state. +func (s *trackedTxStateNew) String() string { + return "New" +} + +// IsTerminal returns false because the initial state is not terminal. +func (s *trackedTxStateNew) IsTerminal() bool { + return false +} + +// trackedTxStateSealed marks trackedTxStateNew as a tracked-tx state. +func (s *trackedTxStateNew) trackedTxStateSealed() {} + +// ProcessEvent applies one event to the initial tracked-tx state. +func (s *trackedTxStateNew) ProcessEvent(_ context.Context, + event trackedTxEvent, _ *trackedTxEnvironment) ( + *trackedTxStateTransition, error) { + + switch event := event.(type) { + case *trackedTxBroadcastStarted: + return &trackedTxStateTransition{ + NextState: &trackedTxStateBroadcasting{ + trackedTxData: s.trackedTxData, + }, + }, nil + + case *trackedTxFailed: + return &trackedTxStateTransition{ + NextState: &trackedTxStateFailed{ + trackedTxData: s.trackedTxData, + Reason: event.Reason, + }, + }, nil + + default: + return nil, fmt.Errorf("unexpected event %T in %s", + event, s) + } +} + +// trackedTxStateBroadcasting indicates the initial broadcast attempt is in +// progress. +type trackedTxStateBroadcasting struct { + trackedTxData +} + +// String returns a human-readable representation of the broadcasting state. +func (s *trackedTxStateBroadcasting) String() string { + return "Broadcasting" +} + +// IsTerminal returns false because broadcasting is not terminal. +func (s *trackedTxStateBroadcasting) IsTerminal() bool { + return false +} + +// trackedTxStateSealed marks trackedTxStateBroadcasting as a tracked-tx state. +func (s *trackedTxStateBroadcasting) trackedTxStateSealed() {} + +// ProcessEvent applies one event to the broadcasting state. +func (s *trackedTxStateBroadcasting) ProcessEvent(_ context.Context, + event trackedTxEvent, _ *trackedTxEnvironment) ( + *trackedTxStateTransition, error) { + + switch e := event.(type) { + case *trackedTxBroadcastAccepted: + return &trackedTxStateTransition{ + NextState: &trackedTxStateAwaitingConfirmation{ + trackedTxData: s.trackedTxData, + trackedTxProgress: e.Progress, + }, + }, nil + + case *trackedTxConfirmed: + return &trackedTxStateTransition{ + NextState: &trackedTxStateConfirmed{ + trackedTxData: s.trackedTxData, + ConfirmHeight: e.BlockHeight, + }, + }, nil + + case *trackedTxFailed: + return &trackedTxStateTransition{ + NextState: &trackedTxStateFailed{ + trackedTxData: s.trackedTxData, + Reason: e.Reason, + }, + }, nil + + default: + return nil, fmt.Errorf("unexpected event %T in %s", + event, s) + } +} + +// trackedTxStateAwaitingConfirmation indicates the parent transaction is +// waiting for the target confirmation count. +type trackedTxStateAwaitingConfirmation struct { + trackedTxData + trackedTxProgress +} + +// String returns a human-readable representation of awaiting confirmation. +func (s *trackedTxStateAwaitingConfirmation) String() string { + return "AwaitingConfirmation" +} + +// IsTerminal returns false because awaiting confirmation is not terminal. +func (s *trackedTxStateAwaitingConfirmation) IsTerminal() bool { + return false +} + +// trackedTxStateSealed marks trackedTxStateAwaitingConfirmation as a tracked +// tx state. +func (s *trackedTxStateAwaitingConfirmation) trackedTxStateSealed() {} + +// ProcessEvent applies one event to the awaiting-confirmation state. +func (s *trackedTxStateAwaitingConfirmation) ProcessEvent(_ context.Context, + event trackedTxEvent, _ *trackedTxEnvironment) ( + *trackedTxStateTransition, error) { + + switch event := event.(type) { + case *trackedTxFeeBumpStarted: + return &trackedTxStateTransition{ + NextState: &trackedTxStateFeeBumping{ + trackedTxData: s.trackedTxData, + trackedTxProgress: s.trackedTxProgress, + }, + }, nil + + case *trackedTxConfirmed: + return &trackedTxStateTransition{ + NextState: &trackedTxStateConfirmed{ + trackedTxData: s.trackedTxData, + trackedTxProgress: s.trackedTxProgress, + ConfirmHeight: event.BlockHeight, + }, + }, nil + + case *trackedTxFailed: + return &trackedTxStateTransition{ + NextState: &trackedTxStateFailed{ + trackedTxData: s.trackedTxData, + trackedTxProgress: s.trackedTxProgress, + Reason: event.Reason, + }, + }, nil + + default: + return nil, fmt.Errorf("unexpected event %T in %s", + event, s) + } +} + +// trackedTxStateFeeBumping indicates the tracked txid is currently attempting +// a CPFP fee bump. +type trackedTxStateFeeBumping struct { + trackedTxData + trackedTxProgress +} + +// String returns a human-readable representation of the fee-bumping state. +func (s *trackedTxStateFeeBumping) String() string { + return "FeeBumping" +} + +// IsTerminal returns false because fee-bumping is not terminal. +func (s *trackedTxStateFeeBumping) IsTerminal() bool { + return false +} + +// trackedTxStateSealed marks trackedTxStateFeeBumping as a tracked-tx state. +func (s *trackedTxStateFeeBumping) trackedTxStateSealed() {} + +// ProcessEvent applies one event to the fee-bumping state. +func (s *trackedTxStateFeeBumping) ProcessEvent(_ context.Context, + event trackedTxEvent, _ *trackedTxEnvironment) ( + *trackedTxStateTransition, error) { + + switch e := event.(type) { + case *trackedTxBroadcastAccepted: + progress := e.Progress + progress.BumpCount = s.BumpCount + 1 + return &trackedTxStateTransition{ + NextState: &trackedTxStateAwaitingConfirmation{ + trackedTxData: s.trackedTxData, + trackedTxProgress: progress, + }, + }, nil + + case *trackedTxConfirmed: + return &trackedTxStateTransition{ + NextState: &trackedTxStateConfirmed{ + trackedTxData: s.trackedTxData, + trackedTxProgress: s.trackedTxProgress, + ConfirmHeight: e.BlockHeight, + }, + }, nil + + case *trackedTxFailed: + return &trackedTxStateTransition{ + NextState: &trackedTxStateFailed{ + trackedTxData: s.trackedTxData, + trackedTxProgress: s.trackedTxProgress, + Reason: e.Reason, + }, + }, nil + + default: + return nil, fmt.Errorf("unexpected event %T in %s", + event, s) + } +} + +// trackedTxStateConfirmed is the terminal confirmed state. +type trackedTxStateConfirmed struct { + trackedTxData + trackedTxProgress + + // ConfirmHeight is the block height where the tx confirmed. + ConfirmHeight int32 +} + +// String returns a human-readable representation of the confirmed state. +func (s *trackedTxStateConfirmed) String() string { + return "Confirmed" +} + +// IsTerminal returns true because confirmed is terminal. +func (s *trackedTxStateConfirmed) IsTerminal() bool { + return true +} + +// trackedTxStateSealed marks trackedTxStateConfirmed as a tracked-tx state. +func (s *trackedTxStateConfirmed) trackedTxStateSealed() {} + +// ProcessEvent rejects unexpected events in the terminal confirmed state. +func (s *trackedTxStateConfirmed) ProcessEvent(_ context.Context, + event trackedTxEvent, _ *trackedTxEnvironment) ( + *trackedTxStateTransition, error) { + + return nil, fmt.Errorf("unexpected event %T in %s", event, s) +} + +// trackedTxStateFailed is the terminal failure state. +type trackedTxStateFailed struct { + trackedTxData + trackedTxProgress + + // Reason is the stable human-readable failure reason. + Reason string +} + +// String returns a human-readable representation of the failed state. +func (s *trackedTxStateFailed) String() string { + return "Failed" +} + +// IsTerminal returns true because failed is terminal. +func (s *trackedTxStateFailed) IsTerminal() bool { + return true +} + +// trackedTxStateSealed marks trackedTxStateFailed as a tracked-tx state. +func (s *trackedTxStateFailed) trackedTxStateSealed() {} + +// ProcessEvent rejects unexpected events in the terminal failed state. +func (s *trackedTxStateFailed) ProcessEvent(_ context.Context, + event trackedTxEvent, _ *trackedTxEnvironment) ( + *trackedTxStateTransition, error) { + + return nil, fmt.Errorf("unexpected event %T in %s", event, s) +} diff --git a/wallet/interfaces.go b/wallet/interfaces.go index 8e7a11bb1..f4e3fd4e6 100644 --- a/wallet/interfaces.go +++ b/wallet/interfaces.go @@ -2,6 +2,7 @@ package wallet import ( "context" + "time" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" @@ -13,6 +14,37 @@ import ( "github.com/lightningnetwork/lnd/keychain" ) +// LockID is a 32-byte caller-scoped identifier assigned when leasing a +// wallet output, and re-supplied when releasing it. Each subsystem +// should derive its own LockID from a stable, human-readable prefix +// (for example the first 32 bytes of sha256("txconfirm")) so that two +// subsystems cannot accidentally release each other's leases and so +// the ID is stable across restarts. +type LockID [32]byte + +// OutputLeaser is implemented by wallet backends that let callers +// exclude specific UTXOs from the wallet's own coin-selection pool +// for a bounded duration. The two-method shape matches the canonical +// interface exposed by btcwallet and lndclient's WalletKit so a +// concrete backend can delegate directly without translating between +// type systems. +type OutputLeaser interface { + // LeaseOutput locks the named outpoint against the caller's + // LockID for at least the supplied expiry, returning the + // absolute time at which the lock will auto-release. The lease + // can be extended by calling LeaseOutput again with the same + // LockID before the previous lease expires. + LeaseOutput(ctx context.Context, id LockID, op wire.OutPoint, + expiry time.Duration) (time.Time, error) + + // ReleaseOutput drops the caller's lease on the named outpoint. + // The supplied LockID must match the one used at LeaseOutput + // time; a mismatch is an error to keep subsystems from + // interfering with each other's reservations. + ReleaseOutput(ctx context.Context, id LockID, + op wire.OutPoint) error +} + // VTXODescriptor contains the VTXO information needed by the wallet to build // intent packages for round registration. This is a wallet-level view that // avoids importing the vtxo package (which would cause an import cycle).