diff --git a/chainbackends/lnd.go b/chainbackends/lnd.go index 43fab8519..61c9015ca 100644 --- a/chainbackends/lnd.go +++ b/chainbackends/lnd.go @@ -27,6 +27,20 @@ import ( // unexported there and so must be matched by value. const lndNeutrinoBroadcastMsg = "broadcast-unverified" +// reorgSignalBufferSize bounds the buffered reorg notifications forwarded +// from lnd's notifier to a chainsource registration. The forwarder sends +// to this channel with a blocking send, so a full buffer head-of-line +// blocks delivery of unrelated Confirmed / Done events on the same +// forwarder goroutine. A reorg burst (e.g. a multi-block reorg emitting +// one NegativeConf per disconnected block) can produce several signals +// back-to-back; sizing the buffer to absorb a typical reorg depth keeps +// the forwarder moving. The signal is coalescing — the consumer +// re-queries chain state on any reorg — so exact depth need not be +// preserved; the buffer only needs to be deep enough to avoid stalling, +// not to record every event. Eight comfortably covers realistic reorg +// depths while staying negligible in memory. +const reorgSignalBufferSize = 8 + // TxBroadcaster is a minimal interface for broadcasting transactions. This // allows LNDBackend to work with both lnwallet.WalletController (in-process // lnd) and lndclient wrappers (remote lnd via gRPC). @@ -330,37 +344,98 @@ func (b *LNDBackend) RegisterConf(ctx context.Context, txid *chainhash.Hash, // context is released. notifyCtx, cancel := context.WithCancel(context.Background()) - // Create a channel to convert lnd's TxConfirmation to our type. + // Create channels to convert lnd's confirmation lifecycle to our + // backend-agnostic types. NegativeConf carries a reorg depth that + // the lndclient gRPC transport cannot preserve, so we forward the + // forwarder's sequence number instead (see below). confChan := make(chan *chainsource.TxConfirmation, 1) + reorgChan := make(chan uint64, reorgSignalBufferSize) + doneChan := make(chan struct{}, 1) go func() { + // seq is a per-registration monotonic counter stamped onto + // every Confirmed and Reorged signal in the order this single + // forwarder goroutine observes them. Confirmed and Reorged + // leave on separate channels, so a select over both at any + // downstream hop (here, and again in the chainsource conf + // actor) cannot recover their order; the shared sequence lets + // the final consumer apply highest-seq-wins and discard a + // stale signal that lost a cross-channel race. This goroutine + // is the single authoritative ordering point — whatever order + // it reads lnd's channels in is the order the consumer honors. + var seq uint64 + // Defers run in LIFO order. event.Cancel() must run first so + // the upstream notifier stops writing to its internal + // channels before we cancel notifyCtx (which any in-flight + // downstream sends are still using) and finally close the + // outgoing chans. Reversing this order would race the + // upstream notifier against closed channels. defer close(confChan) + defer close(reorgChan) + defer close(doneChan) defer cancel() defer event.Cancel() - select { - case lndConf, ok := <-event.Confirmed: - if !ok { - return - } + for { + select { + case lndConf, ok := <-event.Confirmed: + if !ok { + return + } - conf := &chainsource.TxConfirmation{ - BlockHash: lndConf.BlockHash, - BlockHeight: lndConf.BlockHeight, - TxIndex: lndConf.TxIndex, - Tx: lndConf.Tx, - Block: lndConf.Block, - } + seq++ + conf := &chainsource.TxConfirmation{ + BlockHash: lndConf.BlockHash, + BlockHeight: lndConf.BlockHeight, + TxIndex: lndConf.TxIndex, + Tx: lndConf.Tx, + Block: lndConf.Block, + Seq: seq, + } + + select { + case confChan <- conf: + case <-notifyCtx.Done(): + return + } - confChan <- conf + case _, ok := <-event.NegativeConf: + if !ok { + event.NegativeConf = nil + continue + } - case <-notifyCtx.Done(): - return + seq++ + select { + case reorgChan <- seq: + case <-notifyCtx.Done(): + return + } + + case _, ok := <-event.Done: + if !ok { + event.Done = nil + continue + } + + select { + case doneChan <- struct{}{}: + case <-notifyCtx.Done(): + return + } + + return + + case <-notifyCtx.Done(): + return + } } }() return &chainsource.ConfRegistration{ Confirmed: confChan, + Reorged: reorgChan, + Done: doneChan, Cancel: func() { cancel() event.Cancel() @@ -393,39 +468,97 @@ func (b *LNDBackend) RegisterSpend(ctx context.Context, outpoint *wire.OutPoint, // Keep spend delivery alive independently of the actor request context. notifyCtx, cancel := context.WithCancel(context.Background()) - // Create a channel to convert lnd's SpendDetail to our type. + // Create channels to convert lnd's spend lifecycle to our + // backend-agnostic types. lnd's spend Reorg carries no payload, so + // we forward the forwarder's sequence number instead (see below). spendChan := make(chan *chainsource.SpendDetail, 1) + reorgChan := make(chan uint64, reorgSignalBufferSize) + doneChan := make(chan struct{}, 1) - // Start a goroutine to convert and forward the spend. + // Start a goroutine to convert and forward spend lifecycle events. go func() { + // seq is a per-registration monotonic counter stamped onto + // every Spend and Reorged signal in the order this single + // forwarder observes them. Spend and Reorged leave on separate + // channels, so a downstream select cannot recover their order; + // the shared sequence lets the final consumer apply + // highest-seq-wins and discard a stale signal that lost a + // cross-channel race. This goroutine is the single + // authoritative ordering point. + var seq uint64 + // Defer order is LIFO: event.Cancel() stops the upstream + // notifier first, then cancel() ends notifyCtx, and only + // then are the outgoing channels closed. This avoids a race + // where the upstream notifier writes to a freshly-closed + // downstream channel. defer close(spendChan) + defer close(reorgChan) + defer close(doneChan) defer cancel() defer event.Cancel() - select { - case lndSpend, ok := <-event.Spend: - if !ok { - return - } + for { + select { + case lndSpend, ok := <-event.Spend: + if !ok { + return + } - // Convert to our type. - spend := &chainsource.SpendDetail{ - SpentOutPoint: lndSpend.SpentOutPoint, - SpenderTxHash: lndSpend.SpenderTxHash, - SpendingTx: lndSpend.SpendingTx, - SpenderInputIndex: lndSpend.SpenderInputIndex, - SpendingHeight: lndSpend.SpendingHeight, - } + // Convert to our type. + seq++ + spend := &chainsource.SpendDetail{ + SpentOutPoint: lndSpend.SpentOutPoint, + SpenderTxHash: lndSpend.SpenderTxHash, + SpendingTx: lndSpend.SpendingTx, + SpenderInputIndex: lndSpend. + SpenderInputIndex, + SpendingHeight: lndSpend.SpendingHeight, + Seq: seq, + } + + select { + case spendChan <- spend: + case <-notifyCtx.Done(): + return + } + + case _, ok := <-event.Reorg: + if !ok { + event.Reorg = nil + continue + } + + seq++ + select { + case reorgChan <- seq: + case <-notifyCtx.Done(): + return + } + + case _, ok := <-event.Done: + if !ok { + event.Done = nil + continue + } + + select { + case doneChan <- struct{}{}: + case <-notifyCtx.Done(): + return + } - spendChan <- spend + return - case <-notifyCtx.Done(): - return + case <-notifyCtx.Done(): + return + } } }() return &chainsource.SpendRegistration{ - Spend: spendChan, + Spend: spendChan, + Reorged: reorgChan, + Done: doneChan, Cancel: func() { cancel() event.Cancel() diff --git a/chainbackends/lnd_reorg_test.go b/chainbackends/lnd_reorg_test.go new file mode 100644 index 000000000..622f8516d --- /dev/null +++ b/chainbackends/lnd_reorg_test.go @@ -0,0 +1,308 @@ +package chainbackends + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/darepo-client/chainsource" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/stretchr/testify/require" +) + +// reorgWaitTimeout is the per-step deadline used by the LNDBackend +// forwarder reorg tests. The forwarder is a tight goroutine, so the +// timeout exists only to make a hang surface as a fast failure on slow CI. +const reorgWaitTimeout = 2 * time.Second + +// TestRegisterConfForwardsReorgAndDone drives the full confirmation +// lifecycle through the chainntnfs notifier into the LNDBackend forwarder +// and asserts each event arrives on the matching chainsource registration +// channel. The lifecycle is: +// +// Confirmed -> NegativeConf -> Confirmed -> Done +// +// and the test additionally verifies the forwarder exits after Done by +// observing that the chainsource channels close. +func TestRegisterConfForwardsReorgAndDone(t *testing.T) { + t.Parallel() + + confChan := make(chan *chainntnfs.TxConfirmation, 2) + negChan := make(chan int32, 1) + doneChan := make(chan struct{}, 1) + notifier := &stubNotifier{ + confEvent: &chainntnfs.ConfirmationEvent{ + Confirmed: confChan, + NegativeConf: negChan, + Done: doneChan, + Cancel: func() {}, + }, + } + backend := NewLNDBackend( + notifier, &stubFeeEstimator{}, &stubBroadcaster{}, + ) + + reg, err := backend.RegisterConf( + t.Context(), &chainhash.Hash{0x42}, []byte{0x51}, 1, 100, false, + ) + require.NoError(t, err) + + // 1. First confirmation crosses the forwarder. + hash1 := chainhash.Hash{0xaa} + confChan <- &chainntnfs.TxConfirmation{ + BlockHash: &hash1, + BlockHeight: 123, + Tx: wire.NewMsgTx(2), + } + + conf1 := awaitConfForward(t, reg.Confirmed) + require.Equal(t, uint32(123), conf1.BlockHeight) + require.Equal(t, hash1, *conf1.BlockHash) + + // 2. Reorg ping is forwarded as a single struct{} on Reorged. The + // depth value carried on NegativeConf is intentionally dropped at + // this layer; consumers must not rely on it. + negChan <- 1 + + awaitSeq(t, reg.Reorged, "Reorged forward") + + // 3. Transaction re-confirms in a different block on the new tip. + hash2 := chainhash.Hash{0xbb} + confChan <- &chainntnfs.TxConfirmation{ + BlockHash: &hash2, + BlockHeight: 124, + Tx: wire.NewMsgTx(2), + } + + conf2 := awaitConfForward(t, reg.Confirmed) + require.Equal(t, uint32(124), conf2.BlockHeight) + require.Equal(t, hash2, *conf2.BlockHash) + + // 4. Done signal is forwarded; the forwarder then exits and the + // chainsource channels close. + doneChan <- struct{}{} + + awaitStruct(t, reg.Done, "Done forward") + + // All three forwarded channels must close once the forwarder exits. + requireConfClosedSoon(t, reg.Confirmed) + requireSeqClosedSoon(t, reg.Reorged) + requireStructClosedSoon(t, reg.Done) +} + +// TestRegisterSpendForwardsReorgAndDone is the spend-side equivalent of +// the confirmation lifecycle test above. +func TestRegisterSpendForwardsReorgAndDone(t *testing.T) { + t.Parallel() + + spendChan := make(chan *chainntnfs.SpendDetail, 2) + reorgChan := make(chan struct{}, 1) + doneChan := make(chan struct{}, 1) + notifier := &stubNotifier{ + spendEvent: &chainntnfs.SpendEvent{ + Spend: spendChan, + Reorg: reorgChan, + Done: doneChan, + Cancel: func() {}, + }, + } + backend := NewLNDBackend( + notifier, &stubFeeEstimator{}, &stubBroadcaster{}, + ) + + outpoint := &wire.OutPoint{Index: 1} + reg, err := backend.RegisterSpend( + t.Context(), outpoint, []byte{0x51}, 100, + ) + require.NoError(t, err) + + // 1. First spend. + hash1 := chainhash.Hash{0x10} + spendChan <- &chainntnfs.SpendDetail{ + SpentOutPoint: outpoint, + SpenderTxHash: &hash1, + SpendingTx: wire.NewMsgTx(2), + SpendingHeight: 150, + } + + spend1 := awaitSpendForward(t, reg.Spend) + require.Equal(t, int32(150), spend1.SpendingHeight) + require.Equal(t, hash1, *spend1.SpenderTxHash) + + // 2. Reorg evicts the spend. + reorgChan <- struct{}{} + + awaitSeq(t, reg.Reorged, "spend Reorged forward") + + // 3. A different spender wins the new chain. + hash2 := chainhash.Hash{0x20} + spendChan <- &chainntnfs.SpendDetail{ + SpentOutPoint: outpoint, + SpenderTxHash: &hash2, + SpendingTx: wire.NewMsgTx(2), + SpendingHeight: 151, + } + + spend2 := awaitSpendForward(t, reg.Spend) + require.Equal(t, int32(151), spend2.SpendingHeight) + require.Equal(t, hash2, *spend2.SpenderTxHash) + + // 4. Done. + doneChan <- struct{}{} + + awaitStruct(t, reg.Done, "spend Done forward") + + requireSpendClosedSoon(t, reg.Spend) + requireSeqClosedSoon(t, reg.Reorged) + requireStructClosedSoon(t, reg.Done) +} + +// awaitConfForward reads a single confirmation off the forwarded channel +// with a deadline, failing the test on timeout or unexpected close. +func awaitConfForward(t *testing.T, + ch <-chan *chainsource.TxConfirmation) *chainsource.TxConfirmation { + + t.Helper() + + select { + case conf, ok := <-ch: + if !ok { + t.Fatal("conf channel closed before delivery") + } + + return conf + + case <-time.After(reorgWaitTimeout): + t.Fatal("timeout waiting for conf forward") + + return nil + } +} + +// awaitSpendForward reads a single spend off the forwarded channel with a +// deadline, failing the test on timeout or unexpected close. +func awaitSpendForward(t *testing.T, + ch <-chan *chainsource.SpendDetail) *chainsource.SpendDetail { + + t.Helper() + + select { + case spend, ok := <-ch: + if !ok { + t.Fatal("spend channel closed before delivery") + } + + return spend + + case <-time.After(reorgWaitTimeout): + t.Fatal("timeout waiting for spend forward") + + return nil + } +} + +// awaitStruct reads a single struct{} off the forwarded channel with a +// deadline. Used for the Reorged and Done channels. +func awaitStruct(t *testing.T, ch <-chan struct{}, label string) { + t.Helper() + + select { + case _, ok := <-ch: + if !ok { + t.Fatalf("%s channel closed before delivery", label) + } + + case <-time.After(reorgWaitTimeout): + t.Fatalf("timeout waiting for %s", label) + } +} + +// requireConfClosedSoon asserts the confirmation channel closes within +// the reorg wait timeout. Used to verify the forwarder's defer-close +// chain runs after Done is delivered. +func requireConfClosedSoon(t *testing.T, + ch <-chan *chainsource.TxConfirmation) { + + t.Helper() + + require.Eventually(t, func() bool { + select { + case _, ok := <-ch: + return !ok + + default: + return false + } + }, reorgWaitTimeout, 10*time.Millisecond, + "conf channel did not close after Done") +} + +// requireSpendClosedSoon asserts the spend channel closes within the +// reorg wait timeout. +func requireSpendClosedSoon(t *testing.T, ch <-chan *chainsource.SpendDetail) { + t.Helper() + + require.Eventually(t, func() bool { + select { + case _, ok := <-ch: + return !ok + + default: + return false + } + }, reorgWaitTimeout, 10*time.Millisecond, + "spend channel did not close after Done") +} + +// requireStructClosedSoon asserts that a struct{} signal channel closes +// within the reorg wait timeout. +func requireStructClosedSoon(t *testing.T, ch <-chan struct{}) { + t.Helper() + + require.Eventually(t, func() bool { + select { + case _, ok := <-ch: + return !ok + + default: + return false + } + }, reorgWaitTimeout, 10*time.Millisecond, + "struct channel did not close after Done") +} + +// awaitSeq reads a single sequence number off the forwarded Reorged +// channel with a deadline. The reorg signal now carries the forwarder's +// monotonic sequence (see chainsource.TxConfirmation.Seq) rather than a +// bare struct{}. +func awaitSeq(t *testing.T, ch <-chan uint64, label string) { + t.Helper() + + select { + case _, ok := <-ch: + if !ok { + t.Fatalf("%s channel closed before delivery", label) + } + + case <-time.After(reorgWaitTimeout): + t.Fatalf("timeout waiting for %s", label) + } +} + +// requireSeqClosedSoon asserts that a sequence-carrying signal channel +// closes within the reorg wait timeout. +func requireSeqClosedSoon(t *testing.T, ch <-chan uint64) { + t.Helper() + + require.Eventually(t, func() bool { + select { + case _, ok := <-ch: + return !ok + + default: + return false + } + }, reorgWaitTimeout, 10*time.Millisecond, + "seq channel did not close after Done") +} diff --git a/chainbackends/lndclient_adapters.go b/chainbackends/lndclient_adapters.go index 326a93fb0..38cab8ee4 100644 --- a/chainbackends/lndclient_adapters.go +++ b/chainbackends/lndclient_adapters.go @@ -17,6 +17,16 @@ import ( "github.com/lightningnetwork/lnd/lnwallet/chainfee" ) +// lndRegistrationTimeout bounds how long a conf/spend registration call into +// lndclient may block before we give up and return an error. lnd can be slow +// to answer a RegisterConfirmationsNtfn / RegisterSpendNtfn while it is busy +// processing a fresh block, and a registration that hangs forever would pin +// the chainsource sub-actor's Receive call and back-pressure the factory +// actor onto every other in-flight registration. Fifteen seconds is well +// above lnd's normal under-load response time yet short enough that a +// genuinely wedged backend surfaces as an error rather than a silent hang. +const lndRegistrationTimeout = 15 * time.Second + // LndClientTxBroadcaster implements TxBroadcaster using // lndclient.WalletKitClient. type LndClientTxBroadcaster struct { @@ -131,7 +141,16 @@ func (n *LndClientChainNotifier) RegisterConfirmationsNtfn(txid *chainhash.Hash, opt(notifierOpts) } - var lndOpts []lndclient.NotifierOption + // Ask lndclient to keep the confirmation stream alive past the first + // Confirmed event and forward any subsequent reorg signal on a + // dedicated channel. Without WithReOrgChan, lndclient's receive + // loop tears the stream down after one delivery and any later + // reorg is silently dropped at the gRPC layer. + reorgPing := make(chan struct{}, 1) + + lndOpts := []lndclient.NotifierOption{ + lndclient.WithReOrgChan(reorgPing), + } if notifierOpts.IncludeBlock { lndOpts = append(lndOpts, lndclient.WithIncludeBlock()) } @@ -177,11 +196,11 @@ func (n *LndClientChainNotifier) RegisterConfirmationsNtfn(txid *chainhash.Hash, confChan = r.confChan errChan = r.errChan - case <-time.After(15 * time.Second): + case <-time.After(lndRegistrationTimeout): cancel() - return nil, fmt.Errorf("register confirmations timed out " + - "after 15s") + return nil, fmt.Errorf("register confirmations timed out "+ + "after %s", lndRegistrationTimeout) } go func() { @@ -199,26 +218,147 @@ func (n *LndClientChainNotifier) RegisterConfirmationsNtfn(txid *chainhash.Hash, } }() + // Forward the confirmation lifecycle through a SINGLE goroutine so the + // reorg ping and the (re-)confirmation reach the downstream chainntnfs + // channels in lndclient's emission order. lndclient drives both off one + // ordered gRPC receive loop and writes the reorg ping before the + // replacement Confirmed, but it splits them across two channels + // (confChan and reorgPing); if we forwarded each on its own goroutine, + // the downstream ConfActor's select could consume a re-Confirmed before + // the Reorged, reset confirmHeight to 0 with no further Confirmed + // coming, and strand the watch. Draining a pending reorg with priority + // on each iteration, and handing every event off with a blocking send, + // makes the ConfActor observe exactly the forwarder's (lndclient's) + // order. + // + // lndclient does not preserve the reorg depth across the gRPC boundary, + // so a sentinel value of 0 is forwarded on NegativeConf; callers over + // this transport must not rely on the integer value. + orderedConfirmed := make(chan *chainntnfs.TxConfirmation, 1) + negativeConf := make(chan int32, 1) + go func() { + defer close(orderedConfirmed) + defer close(negativeConf) + + forwardOrderedReorg( + ctx, reorgPing, confChan, orderedConfirmed, + negativeConf, + ) + }() + + // Done is allocated but never written to because lnd's internal + // "past reorg-safety depth" signal is not surfaced through the + // lndclient gRPC transport. Consumers needing such a gate must + // compute it themselves from block height. return &chainntnfs.ConfirmationEvent{ - Confirmed: confChan, - Cancel: cancel, - Done: make(chan struct{}, 1), + Confirmed: orderedConfirmed, + NegativeConf: negativeConf, + Cancel: cancel, + Done: make(chan struct{}, 1), }, nil } +// forwardOrderedReorg copies confirmations and reorg pings from lndclient's two +// source channels onto the downstream Confirmed / NegativeConf channels, +// forwarding each event in the order this single goroutine observes it. It does +// not bias either channel: the authoritative lifecycle ordering is +// re-established downstream by the per-registration sequence number the +// LNDBackend forwarder stamps (see chainsource.TxConfirmation.Seq), so the +// consumer applies highest-seq-wins regardless of how near-simultaneous events +// interleave here. lndclient's two-channel split makes a perfectly ordered +// merge impossible at this layer, so forwarding in natural arrival order keeps +// the stamped sequence faithful to what was actually observed rather than +// injecting an artificial reorg-first bias. lndclient does not preserve the +// reorg depth across the gRPC boundary, so a sentinel value of 0 is forwarded +// on NegativeConf; callers over this transport must not rely on the value. +func forwardOrderedReorg(ctx context.Context, reorgPing <-chan struct{}, + confChan <-chan *chainntnfs.TxConfirmation, + outConfirmed chan<- *chainntnfs.TxConfirmation, + outNegConf chan<- int32) { + + for confChan != nil || reorgPing != nil { + select { + case _, ok := <-reorgPing: + if !ok { + reorgPing = nil + + continue + } + + select { + case outNegConf <- 0: + case <-ctx.Done(): + return + } + + case c, ok := <-confChan: + if !ok { + confChan = nil + + continue + } + + select { + case outConfirmed <- c: + case <-ctx.Done(): + return + } + + case <-ctx.Done(): + return + } + } +} + // RegisterSpendNtfn registers for spend notifications using lndclient's // ChainNotifier. func (n *LndClientChainNotifier) RegisterSpendNtfn(outpoint *wire.OutPoint, pkScript []byte, heightHint uint32) (*chainntnfs.SpendEvent, error) { + // Ask lndclient to keep the spend stream alive past the first + // Spend event so it can forward reorg pings. Without WithReOrgChan + // the stream is torn down after the first delivery and any later + // spend reorg is silently dropped at the gRPC layer. + reorgPing := make(chan struct{}, 1) + ctx, cancel := context.WithCancel(context.Background()) - spendChan, errChan, err := n.cfg.LND.ChainNotifier.RegisterSpendNtfn( - ctx, outpoint, pkScript, int32(heightHint), - ) - if err != nil { + + // Run the registration in a goroutine with a timeout to prevent + // hanging when LND is slow under block load, mirroring the conf path. + type regResult struct { + spendChan chan *chainntnfs.SpendDetail + errChan chan error + err error + } + + resultCh := make(chan regResult, 1) + go func() { + sc, ec, err := n.cfg.LND.ChainNotifier.RegisterSpendNtfn( + ctx, outpoint, pkScript, int32(heightHint), + lndclient.WithReOrgChan(reorgPing), + ) + resultCh <- regResult{sc, ec, err} + }() + + var spendChan chan *chainntnfs.SpendDetail + var errChan chan error + + select { + case r := <-resultCh: + if r.err != nil { + cancel() + + return nil, fmt.Errorf("register spend: %w", r.err) + } + + spendChan = r.spendChan + errChan = r.errChan + + case <-time.After(lndRegistrationTimeout): cancel() - return nil, fmt.Errorf("register spend: %w", err) + return nil, fmt.Errorf("register spend timed out after %s", + lndRegistrationTimeout) } go func() { @@ -236,14 +376,80 @@ func (n *LndClientChainNotifier) RegisterSpendNtfn(outpoint *wire.OutPoint, } }() + // Forward the spend lifecycle through a SINGLE goroutine so the reorg + // ping and the (re-)spend reach the downstream chainntnfs channels in + // lndclient's emission order, for the same reason as the conf path: the + // two-channel split (spendChan and reorgPing) would otherwise let the + // downstream SpendActor's select consume a re-Spend before the Reorged + // and strand the watch. Draining a pending reorg with priority and + // using blocking hand-offs makes the SpendActor observe lndclient's + // order. + orderedSpend := make(chan *chainntnfs.SpendDetail, 1) + reorgChan := make(chan struct{}, 1) + go func() { + defer close(orderedSpend) + defer close(reorgChan) + + forwardOrderedSpendReorg( + ctx, reorgPing, spendChan, orderedSpend, reorgChan, + ) + }() + + // Done is allocated but never written to because lnd's "past + // reorg-safety depth" signal is not surfaced through the lndclient + // gRPC transport. return &chainntnfs.SpendEvent{ - Spend: spendChan, - Reorg: make(chan struct{}, 1), + Spend: orderedSpend, + Reorg: reorgChan, Done: make(chan struct{}, 1), Cancel: cancel, }, nil } +// forwardOrderedSpendReorg is the spend-path analogue of forwardOrderedReorg: +// it copies spends and reorg pings from lndclient's two source channels onto +// the downstream Spend / Reorg channels in the order this single goroutine +// observes them, without biasing either channel. The authoritative lifecycle +// ordering is re-established downstream by the per-registration sequence number +// the LNDBackend forwarder stamps (see chainsource.SpendDetail.Seq). +func forwardOrderedSpendReorg(ctx context.Context, reorgPing <-chan struct{}, + spendChan <-chan *chainntnfs.SpendDetail, + outSpend chan<- *chainntnfs.SpendDetail, outReorg chan<- struct{}) { + + for spendChan != nil || reorgPing != nil { + select { + case _, ok := <-reorgPing: + if !ok { + reorgPing = nil + + continue + } + + select { + case outReorg <- struct{}{}: + case <-ctx.Done(): + return + } + + case sp, ok := <-spendChan: + if !ok { + spendChan = nil + + continue + } + + select { + case outSpend <- sp: + case <-ctx.Done(): + return + } + + case <-ctx.Done(): + return + } + } +} + // RegisterBlockEpochNtfn registers for block epoch notifications using // lndclient's ChainNotifier. func (n *LndClientChainNotifier) RegisterBlockEpochNtfn( diff --git a/chainsource/AGENTS.md b/chainsource/AGENTS.md index bb2cd94a1..eac123686 100644 --- a/chainsource/AGENTS.md +++ b/chainsource/AGENTS.md @@ -14,7 +14,12 @@ communication alongside the raw registration API. - `ChainSourceActor` — Factory actor spawning sub-actors for each monitoring request. Registered under `ChainSourceKey`. - `ChainSourceConfig` — Config struct: `Backend ChainBackend`, `System - *actor.ActorSystem`, `Log fn.Option[btclog.Logger]`. + *actor.ActorSystem`, `Log fn.Option[btclog.Logger]`, `FinalityDepth uint32`. + `FinalityDepth` is forwarded to each spawned conf/spend sub-actor and + configures height-based synthesis of `Done` for backend transports that + cannot deliver one themselves (notably lndclient over gRPC). Zero disables + synthesis; `DefaultFinalityDepth` is six — the conventional Bitcoin + reorg-safety threshold. - `ChainSourceMsg` / `ChainSourceResp` — Sealed actor message interfaces for requests and responses sent to the `ChainSourceActor`. - `FeeEstimateRequest/Response`, `BestHeightRequest/Response`, @@ -25,19 +30,33 @@ communication alongside the raw registration API. - `RegisterConfRequest/Response`, `UnregisterConfRequest/Response` — Request types for conf-actor lifecycle. `RegisterConfRequest` carries an optional `NotifyActor fn.Option[actor.TellOnlyRef[ConfirmationEvent]]` for async-mode - notification without blocking on a Future. + notification without blocking on a Future, plus optional + `NotifyReorged fn.Option[actor.TellOnlyRef[ConfReorgedEvent]]` and + `NotifyDone fn.Option[actor.TellOnlyRef[ConfDoneEvent]]` refs for the + reorg-aware lifecycle. Reorg/Done refs require `NotifyActor` (a Future + can only complete once). - `SpendMsg` / `SpendResp` — Sealed interfaces for spend sub-actor messages. - `RegisterSpendRequest/Response`, `UnregisterSpendRequest/Response` — Spend - monitoring lifecycle. + monitoring lifecycle. `RegisterSpendRequest` carries the same + `NotifyReorged`/`NotifyDone` reorg-aware refs (typed + `SpendReorgedEvent`/`SpendDoneEvent`). - `EpochMsg` / `EpochResp` — Sealed interfaces for block-epoch sub-actor. - `SubscribeBlocksRequest/Response`, `UnsubscribeBlocksRequest/Response` — Block subscription lifecycle. - `ConfRegistration` / `SpendRegistration` / `BlockRegistration` — Structs with - buffered notification channels and a `Cancel()` function. -- `ConfirmationEvent`, `SpendEvent`, `BlockEpoch` — Notification payload types. -- `MapBlockEpoch`, `MapConfirmationEvent`, `MapSpendEvent` — Generic helpers - that wrap a target `TellOnlyRef[Out]` and a mapping function, producing a - `TellOnlyRef` of the source event type for actor-to-actor notification wiring. + buffered notification channels and a `Cancel()` function. `ConfRegistration` + and `SpendRegistration` carry optional `Reorged` and `Done` channels backends + use to surface the reversible lifecycle; `SpendDetail` carries + `SpendingBlockHash` so callers can match spends to specific blocks. +- `ConfirmationEvent`, `SpendEvent`, `BlockEpoch` — Positive-event payload + types. `ConfReorgedEvent`, `ConfDoneEvent`, `SpendReorgedEvent`, + `SpendDoneEvent` — Lifecycle payloads delivered to the matching + `NotifyReorged` / `NotifyDone` refs. +- `MapBlockEpoch`, `MapConfirmationEvent`, `MapSpendEvent`, + `MapConfReorgedEvent`, `MapConfDoneEvent`, `MapSpendReorgedEvent`, + `MapSpendDoneEvent` — Generic helpers that wrap a target `TellOnlyRef[Out]` + and a mapping function, producing a `TellOnlyRef` of the source event type + for actor-to-actor notification wiring. ## Relationships @@ -56,6 +75,23 @@ communication alongside the raw registration API. - Confirmation sub-actors support two notification modes: Future-based (blocking await) and actor-based (async `Tell` via `NotifyActor`). Callers use the actor mode when blocking inside a durable actor transaction is unsafe. +- Future-mode watches are single-shot: the sub-actor exits after the first + positive event. Actor-mode watches without `NotifyReorged`/`NotifyDone` + retain the same single-shot contract for backwards compatibility. Actor-mode + watches with at least one reorg/done ref are multi-shot: the sub-actor + continues running, forwarding the full + `Confirmed/Spend -> Reorged -> Confirmed/Spend -> Done` lifecycle to the + configured refs, and releases the backend registration on `Done`. +- Admission rejects a `NotifyReorged` or `NotifyDone` ref without + `NotifyActor` (a Future can only complete once; a missing ref would + silently drop every re-event after the first). +- When `ChainSourceConfig.FinalityDepth > 0`, reorg-aware sub-actors arm a + block-epoch subscription on the first positive event and synthesize a + `Done` once they observe a block at `eventHeight + FinalityDepth - 1`. A + reorg resets the depth counter so the next re-confirmation/re-spend on + the new tip restarts the window cleanly. This closes the lndclient gap + where `ConfirmationEvent.Done` / `SpendEvent.Done` are allocated but + never written across the gRPC transport. ## Deep Docs diff --git a/chainsource/CLAUDE.md b/chainsource/CLAUDE.md index bb2cd94a1..eac123686 100644 --- a/chainsource/CLAUDE.md +++ b/chainsource/CLAUDE.md @@ -14,7 +14,12 @@ communication alongside the raw registration API. - `ChainSourceActor` — Factory actor spawning sub-actors for each monitoring request. Registered under `ChainSourceKey`. - `ChainSourceConfig` — Config struct: `Backend ChainBackend`, `System - *actor.ActorSystem`, `Log fn.Option[btclog.Logger]`. + *actor.ActorSystem`, `Log fn.Option[btclog.Logger]`, `FinalityDepth uint32`. + `FinalityDepth` is forwarded to each spawned conf/spend sub-actor and + configures height-based synthesis of `Done` for backend transports that + cannot deliver one themselves (notably lndclient over gRPC). Zero disables + synthesis; `DefaultFinalityDepth` is six — the conventional Bitcoin + reorg-safety threshold. - `ChainSourceMsg` / `ChainSourceResp` — Sealed actor message interfaces for requests and responses sent to the `ChainSourceActor`. - `FeeEstimateRequest/Response`, `BestHeightRequest/Response`, @@ -25,19 +30,33 @@ communication alongside the raw registration API. - `RegisterConfRequest/Response`, `UnregisterConfRequest/Response` — Request types for conf-actor lifecycle. `RegisterConfRequest` carries an optional `NotifyActor fn.Option[actor.TellOnlyRef[ConfirmationEvent]]` for async-mode - notification without blocking on a Future. + notification without blocking on a Future, plus optional + `NotifyReorged fn.Option[actor.TellOnlyRef[ConfReorgedEvent]]` and + `NotifyDone fn.Option[actor.TellOnlyRef[ConfDoneEvent]]` refs for the + reorg-aware lifecycle. Reorg/Done refs require `NotifyActor` (a Future + can only complete once). - `SpendMsg` / `SpendResp` — Sealed interfaces for spend sub-actor messages. - `RegisterSpendRequest/Response`, `UnregisterSpendRequest/Response` — Spend - monitoring lifecycle. + monitoring lifecycle. `RegisterSpendRequest` carries the same + `NotifyReorged`/`NotifyDone` reorg-aware refs (typed + `SpendReorgedEvent`/`SpendDoneEvent`). - `EpochMsg` / `EpochResp` — Sealed interfaces for block-epoch sub-actor. - `SubscribeBlocksRequest/Response`, `UnsubscribeBlocksRequest/Response` — Block subscription lifecycle. - `ConfRegistration` / `SpendRegistration` / `BlockRegistration` — Structs with - buffered notification channels and a `Cancel()` function. -- `ConfirmationEvent`, `SpendEvent`, `BlockEpoch` — Notification payload types. -- `MapBlockEpoch`, `MapConfirmationEvent`, `MapSpendEvent` — Generic helpers - that wrap a target `TellOnlyRef[Out]` and a mapping function, producing a - `TellOnlyRef` of the source event type for actor-to-actor notification wiring. + buffered notification channels and a `Cancel()` function. `ConfRegistration` + and `SpendRegistration` carry optional `Reorged` and `Done` channels backends + use to surface the reversible lifecycle; `SpendDetail` carries + `SpendingBlockHash` so callers can match spends to specific blocks. +- `ConfirmationEvent`, `SpendEvent`, `BlockEpoch` — Positive-event payload + types. `ConfReorgedEvent`, `ConfDoneEvent`, `SpendReorgedEvent`, + `SpendDoneEvent` — Lifecycle payloads delivered to the matching + `NotifyReorged` / `NotifyDone` refs. +- `MapBlockEpoch`, `MapConfirmationEvent`, `MapSpendEvent`, + `MapConfReorgedEvent`, `MapConfDoneEvent`, `MapSpendReorgedEvent`, + `MapSpendDoneEvent` — Generic helpers that wrap a target `TellOnlyRef[Out]` + and a mapping function, producing a `TellOnlyRef` of the source event type + for actor-to-actor notification wiring. ## Relationships @@ -56,6 +75,23 @@ communication alongside the raw registration API. - Confirmation sub-actors support two notification modes: Future-based (blocking await) and actor-based (async `Tell` via `NotifyActor`). Callers use the actor mode when blocking inside a durable actor transaction is unsafe. +- Future-mode watches are single-shot: the sub-actor exits after the first + positive event. Actor-mode watches without `NotifyReorged`/`NotifyDone` + retain the same single-shot contract for backwards compatibility. Actor-mode + watches with at least one reorg/done ref are multi-shot: the sub-actor + continues running, forwarding the full + `Confirmed/Spend -> Reorged -> Confirmed/Spend -> Done` lifecycle to the + configured refs, and releases the backend registration on `Done`. +- Admission rejects a `NotifyReorged` or `NotifyDone` ref without + `NotifyActor` (a Future can only complete once; a missing ref would + silently drop every re-event after the first). +- When `ChainSourceConfig.FinalityDepth > 0`, reorg-aware sub-actors arm a + block-epoch subscription on the first positive event and synthesize a + `Done` once they observe a block at `eventHeight + FinalityDepth - 1`. A + reorg resets the depth counter so the next re-confirmation/re-spend on + the new tip restarts the window cleanly. This closes the lndclient gap + where `ConfirmationEvent.Done` / `SpendEvent.Done` are allocated but + never written across the gRPC transport. ## Deep Docs diff --git a/chainsource/backend.go b/chainsource/backend.go index 3ef13b54e..7a6acfcaa 100644 --- a/chainsource/backend.go +++ b/chainsource/backend.go @@ -137,12 +137,41 @@ type ChainBackend interface { // ConfRegistration encapsulates the channels and control functions for a // confirmation registration. This mirrors lnd's chainntnfs.ConfirmationEvent // structure but provides a backend-agnostic interface. +// +// The registration is reorg-aware: after a confirmation has been delivered +// on Confirmed, a subsequent reorg that buries the original block can cause +// a fresh send on Reorged. Once the transaction re-confirms on the new +// canonical chain another event arrives on Confirmed. The lifecycle is +// therefore Confirmed -> Reorged -> Confirmed -> ... terminated by either a +// send on Done (the confirmation is past the backend's reorg-safety depth) +// or a caller-driven Cancel. Backends that cannot observe reorgs leave +// Reorged and Done as never-firing channels; callers must not assume either +// will ever fire. type ConfRegistration struct { - // Confirmed is a channel that fires once when the transaction reaches - // the target number of confirmations. The channel is buffered and will - // only send a single event. + // Confirmed fires every time the transaction reaches the target + // number of confirmations on the canonical chain. After a Reorged + // event it may fire again when the transaction re-confirms. The + // channel is buffered. Confirmed <-chan *TxConfirmation + // Reorged fires when a previously delivered confirmation is reorged + // out of the canonical chain. The payload is the backend forwarder's + // monotonic sequence number (see TxConfirmation.Seq) rather than + // reorg depth or block identity, which the lndclient gRPC transport + // cannot preserve; the sequence lets the consumer order this signal + // against confirmations sharing the same sequence space even though + // they arrive on a different channel. Backends that cannot observe + // reorgs leave this channel nil-equivalent (allocated but never + // written to); reading from it is always safe. + Reorged <-chan uint64 + + // Done fires once when the confirmation watch is past the backend's + // reorg-safety depth and will receive no further events. Callers + // must still invoke Cancel to release client-side resources. + // Backends that cannot synthesize a safety-depth signal leave this + // channel nil-equivalent (allocated but never written to). + Done <-chan struct{} + // Cancel is a function that can be called to cancel this registration // and clean up resources. After calling Cancel, no more events will be // sent on any channels. @@ -168,17 +197,54 @@ type TxConfirmation struct { // when the confirmation was registered with IncludeBlock=true. This // matches lnd's chainntnfs behavior. Block *wire.MsgBlock + + // Seq is a per-registration monotonic sequence number stamped by the + // backend forwarder in the order it observed lifecycle events. + // Confirmed and Reorged share one sequence space so the consumer can + // order them even though they arrive on separate channels (a select + // over two ready channels picks at random and cannot recover order). + // The consumer applies an event only when its Seq exceeds the highest + // Seq seen so far, discarding a stale event that lost a cross-channel + // race. Zero means the backend does not stamp sequences (it never + // reorgs, so ordering is moot); such events are always applied. + Seq uint64 } // SpendRegistration encapsulates the channels and control functions for a // spend registration. This mirrors lnd's chainntnfs.SpendEvent structure. +// +// The registration is reorg-aware: after a spend has been delivered on +// Spend, a subsequent reorg that buries the spending block can cause a +// fresh send on Reorged. If the outpoint is then re-spent on the new +// canonical chain (by the same or a different transaction) another event +// arrives on Spend. The lifecycle is therefore Spend -> Reorged -> Spend +// -> ... terminated by either a send on Done (the spend is past the +// backend's reorg-safety depth) or a caller-driven Cancel. Backends that +// cannot observe reorgs leave Reorged and Done as never-firing channels. type SpendRegistration struct { - // Spend is a channel that fires when the monitored outpoint is spent. - // The spending transaction must have at least one confirmation. The - // channel is buffered and will send an event for each spend (though - // typically only one unless reorgs occur). + // Spend fires every time the monitored outpoint is spent on the + // canonical chain. After a Reorged event it may fire again when a + // new spender confirms. The channel is buffered. Spend <-chan *SpendDetail + // Reorged fires when a previously delivered spend is reorged out of + // the canonical chain. The payload is the backend forwarder's + // monotonic sequence number (see SpendDetail.Seq) rather than reorg + // depth or block identity, which the lndclient gRPC transport cannot + // preserve; the sequence lets the consumer order this signal against + // spends sharing the same sequence space even though they arrive on a + // different channel. Backends that cannot observe spend reorgs leave + // this channel nil-equivalent (allocated but never written to); + // reading from it is always safe. + Reorged <-chan uint64 + + // Done fires once when the spend watch is past the backend's + // reorg-safety depth and will receive no further events. Callers + // must still invoke Cancel to release client-side resources. + // Backends that cannot synthesize a safety-depth signal leave this + // channel nil-equivalent (allocated but never written to). + Done <-chan struct{} + // Cancel is a function that can be called to cancel this registration // and clean up resources. Cancel func() @@ -203,6 +269,17 @@ type SpendDetail struct { // SpendingHeight is the block height where the spending transaction // was confirmed. SpendingHeight int32 + + // Seq is a per-registration monotonic sequence number stamped by the + // backend forwarder in the order it observed lifecycle events. Spend + // and Reorged share one sequence space so the consumer can order them + // even though they arrive on separate channels (a select over two + // ready channels picks at random and cannot recover order). The + // consumer applies an event only when its Seq exceeds the highest Seq + // seen so far, discarding a stale event that lost a cross-channel + // race. Zero means the backend does not stamp sequences (it never + // reorgs, so ordering is moot); such events are always applied. + Seq uint64 } // BlockRegistration encapsulates the channels and control functions for a diff --git a/chainsource/chainsource.go b/chainsource/chainsource.go index 44c6e7ce6..42f0cdde4 100644 --- a/chainsource/chainsource.go +++ b/chainsource/chainsource.go @@ -18,6 +18,16 @@ const ( // allows for buffering up to 10 blocks in transit, which should cover // normal block arrival patterns without blocking the backend. epochChannelSize = 10 + + // DefaultFinalityDepth is the conventional Bitcoin reorg-safety + // depth. After this many inclusive confirmations the ConfActor + // and SpendActor synthesize their Done events when the backend + // transport (notably lndclient over gRPC) does not surface one + // of its own. Six is the value the wider Lightning stack treats + // as final for the purposes of channel funding / settlement, so + // using it here keeps the unroll subsystem's finality threshold + // aligned with the rest of the daemon's chain assumptions. + DefaultFinalityDepth uint32 = 6 ) // ChainSourceConfig holds configuration for ChainSourceActor. @@ -32,6 +42,14 @@ type ChainSourceConfig struct { // falls back to extracting a logger from context via LoggerFromContext, // or uses btclog.Disabled if no logger is found. Log fn.Option[btclog.Logger] + + // FinalityDepth is forwarded to each spawned sub-actor as the + // number of confirmations past the first observed positive event + // that the actor uses to synthesize a Done signal when the backend + // transport (notably lndclient over gRPC) cannot deliver one. Zero + // disables height-based finality synthesis. See + // ConfActorConfig.FinalityDepth / SpendActorConfig.FinalityDepth. + FinalityDepth uint32 } // WithLogger returns a new config with the given logger set. @@ -318,8 +336,9 @@ func (a *ChainSourceActor) handleRegisterConf(ctx context.Context, ) confCfg := ConfActorConfig{ - Backend: a.cfg.Backend, - Log: fn.Some(a.logger(ctx)), + Backend: a.cfg.Backend, + Log: fn.Some(a.logger(ctx)), + FinalityDepth: a.cfg.FinalityDepth, } confActor := NewConfActor(confCfg) actorRef := serviceKey.Spawn(a.cfg.System, actorID, confActor) @@ -353,8 +372,9 @@ func (a *ChainSourceActor) handleRegisterSpend(ctx context.Context, ) spendCfg := SpendActorConfig{ - Backend: a.cfg.Backend, - Log: fn.Some(a.logger(ctx)), + Backend: a.cfg.Backend, + Log: fn.Some(a.logger(ctx)), + FinalityDepth: a.cfg.FinalityDepth, } spendActor := NewSpendActor(spendCfg) actorRef := serviceKey.Spawn(a.cfg.System, actorID, spendActor) diff --git a/chainsource/chainsource_test.go b/chainsource/chainsource_test.go index 822f4cb92..92b0d89d5 100644 --- a/chainsource/chainsource_test.go +++ b/chainsource/chainsource_test.go @@ -3,6 +3,7 @@ package chainsource import ( "context" "errors" + "sync/atomic" "testing" "time" @@ -17,12 +18,23 @@ import ( // mockBackend implements ChainBackend for testing. type mockBackend struct { - confChan chan *TxConfirmation - spendChan chan *SpendDetail - epochChan chan *BlockEpoch + confChan chan *TxConfirmation + confReorgedChan chan uint64 + confDoneChan chan struct{} + spendChan chan *SpendDetail + spendReorgedChan chan uint64 + spendDoneChan chan struct{} + epochChan chan *BlockEpoch epochCancel chan struct{} + // confCancelled / spendCancelled count Cancel invocations on the + // most recently issued registration. The reorg-aware lifecycle + // tests rely on these to assert that the actor released the + // registration after a Done event. + confCancelled atomic.Int32 + spendCancelled atomic.Int32 + feeRate btcutil.Amount bestHeight int32 bestHash chainhash.Hash @@ -42,12 +54,16 @@ type reconnectBlockBackend struct { // newMockBackend creates a new mock backend for testing. func newMockBackend() *mockBackend { return &mockBackend{ - confChan: make(chan *TxConfirmation, 1), - spendChan: make(chan *SpendDetail, 1), - epochChan: make(chan *BlockEpoch, 10), - epochCancel: make(chan struct{}, 10), - feeRate: 1000, - bestHeight: 100, + confChan: make(chan *TxConfirmation, 1), + confReorgedChan: make(chan uint64, 1), + confDoneChan: make(chan struct{}, 1), + spendChan: make(chan *SpendDetail, 1), + spendReorgedChan: make(chan uint64, 1), + spendDoneChan: make(chan struct{}, 1), + epochChan: make(chan *BlockEpoch, 10), + epochCancel: make(chan struct{}, 10), + feeRate: 1000, + bestHeight: 100, } } @@ -208,7 +224,11 @@ func (m *mockBackend) RegisterConf(ctx context.Context, txid *chainhash.Hash, return &ConfRegistration{ Confirmed: m.confChan, - Cancel: func() {}, + Reorged: m.confReorgedChan, + Done: m.confDoneChan, + Cancel: func() { + m.confCancelled.Add(1) + }, }, nil } @@ -217,8 +237,12 @@ func (m *mockBackend) RegisterSpend(ctx context.Context, *SpendRegistration, error) { return &SpendRegistration{ - Spend: m.spendChan, - Cancel: func() {}, + Spend: m.spendChan, + Reorged: m.spendReorgedChan, + Done: m.spendDoneChan, + Cancel: func() { + m.spendCancelled.Add(1) + }, }, nil } diff --git a/chainsource/conf_actor.go b/chainsource/conf_actor.go index d5d1ecfaa..c8d0b187a 100644 --- a/chainsource/conf_actor.go +++ b/chainsource/conf_actor.go @@ -22,6 +22,20 @@ type ConfActorConfig struct { // falls back to extracting a logger from context via LoggerFromContext, // or uses btclog.Disabled if no logger is found. Log fn.Option[btclog.Logger] + + // FinalityDepth is the number of confirmations past the first + // observed Confirmed event that the actor uses to synthesize a Done + // signal when the backend cannot deliver one. Zero disables + // height-based finality synthesis entirely; in that case the actor + // only fires ConfDoneEvent when the backend's own Done channel + // fires (e.g. an in-process lnd notifier). Non-zero values close + // the lndclient transport gap, where the gRPC layer does not + // surface lnd's internal "past reorg-safety depth" signal. + // + // The depth is counted inclusively (a tx confirmed at height H is + // at depth 1; height-based finality fires once the actor observes + // a block at H + FinalityDepth - 1). The conventional choice is 6. + FinalityDepth uint32 } // WithLogger returns a new config with the given logger set. @@ -67,9 +81,28 @@ type ConfActor struct { // mode. notifyActor fn.Option[actor.TellOnlyRef[ConfirmationEvent]] + // notifyReorged receives negative confirmation events in Actor mode. + notifyReorged fn.Option[actor.TellOnlyRef[ConfReorgedEvent]] + + // notifyDone receives finality events in Actor mode. + notifyDone fn.Option[actor.TellOnlyRef[ConfDoneEvent]] + // registration is the backend registration for this watch. registration *ConfRegistration + // blockReg is the block-epoch subscription used by height-based + // finality synthesis. Allocated lazily after the first Confirmed + // event when FinalityDepth > 0 and the registration is reorg-aware, + // torn down when the actor exits. + blockReg *BlockRegistration + + // confirmHeight records the block height the most recent + // Confirmed event arrived at. Used by the height-based finality + // synthesizer to compute current depth. Zero means there is no + // active confirmation to count from (either we have not yet seen + // one, or the last one was reorged out). + confirmHeight int32 + // ctx is the actor's internal context for cancellation, created from // context.Background() to ensure it outlives any request context. //nolint:containedctx @@ -143,6 +176,13 @@ func (a *ConfActor) handleRegisterConf(actorCtx context.Context, "than zero"), ) } + if req.NotifyActor.IsNone() && + (req.NotifyReorged.IsSome() || req.NotifyDone.IsSome()) { + return fn.Err[ConfResp]( + fmt.Errorf("confirmation reorg/done notifications " + + "require actor-mode NotifyActor"), + ) + } a.txid = req.Txid a.pkScript = req.PkScript @@ -150,6 +190,8 @@ func (a *ConfActor) handleRegisterConf(actorCtx context.Context, a.heightHint = req.HeightHint a.includeBlock = req.IncludeBlock a.notifyActor = req.NotifyActor + a.notifyReorged = req.NotifyReorged + a.notifyDone = req.NotifyDone // We're either in future or iterator mode, set the promise // accordingly. @@ -197,8 +239,8 @@ func (a *ConfActor) handleRegisterConf(actorCtx context.Context, } // monitorConfirmation runs in a background goroutine and waits for the target -// confirmation count to be reached. When reached, it delivers the event and -// exits. +// confirmation count to be reached. Legacy watches exit after confirmation, +// while reorg-aware actor-mode watches remain alive for reorg and done events. func (a *ConfActor) monitorConfirmation() { defer a.wg.Done() defer a.cancel() @@ -215,50 +257,279 @@ func (a *ConfActor) monitorConfirmation() { if a.registration != nil { a.registration.Cancel() } + if a.blockReg != nil { + a.blockReg.Cancel() + } }() - select { - case confDetails, ok := <-a.registration.Confirmed: - if !ok || confDetails == nil { - log.WarnS(a.ctx, "Confirmation subscription closed", - fmt.Errorf("channel closed or nil details"), + var lastEvent *ConfirmationEvent + reorgAware := a.notifyReorged.IsSome() || a.notifyDone.IsSome() + + // lastSeq is the highest backend forwarder sequence applied so far. + // Confirmed and Reorged signals arrive on separate channels and a + // select cannot order two ready channels, so we order them by the + // shared sequence instead: an event whose Seq does not exceed lastSeq + // lost a cross-channel race to a newer signal and is discarded. This + // makes the actor's view correct regardless of delivery interleaving + // — both reorg-then-reconfirm and confirm-then-reorg resolve to the + // highest-sequence outcome. Seq 0 means the backend does not stamp + // sequences (it never reorgs); those events are always applied. + var lastSeq uint64 + + // blockEpochs is rebound when height-based finality synthesis + // arms a block subscription. Until then a nil channel keeps the + // select arm parked. + var blockEpochs <-chan *BlockEpoch + + // blockRegCh hands a finality block subscription from the off-loop + // arming goroutine back to this loop; arming guards against launching + // more than one armer at a time. See armFinalityAsync for why arming + // runs off the select loop. + blockRegCh := make(chan *BlockRegistration) + var arming bool + + for { + select { + case confDetails, ok := <-a.registration.Confirmed: + if !ok || confDetails == nil { + log.WarnS( + a.ctx, + "Confirmation subscription closed", + fmt.Errorf("channel closed or nil "+ + "details"), + ) + a.failConfirmation( + fmt.Errorf("confirmation " + + "subscription closed"), + ) + + return + } + + // Discard a confirmation that lost a cross-channel race + // to a newer reorg: an event whose sequence does not + // exceed the highest applied is stale. + if confDetails.Seq != 0 && confDetails.Seq <= lastSeq { + continue + } + if confDetails.Seq > lastSeq { + lastSeq = confDetails.Seq + } + + log.InfoS(a.ctx, "Received confirmation from backend", + "block_height", confDetails.BlockHeight, + "block_hash", confDetails.BlockHash, ) - a.failConfirmation( - fmt.Errorf("confirmation subscription closed"), + + event, err := buildConfirmationEvent(confDetails, a) + if err != nil { + log.WarnS( + a.ctx, + "Failed to build confirmation event", + err, + ) + a.failConfirmation(err) + + return + } + + log.InfoS(a.ctx, "Delivering confirmation event", + "txid", event.Txid, + "block_height", event.BlockHeight, ) + a.deliverConfirmation(event) + lastEvent = &event + a.confirmHeight = event.BlockHeight + if !reorgAware || a.promise.IsSome() { + return + } + + // Arm height-based finality synthesis on the first + // confirmation if requested, off the select loop so + // the bounded RegisterBlocks retries cannot stall + // delivery of Reorged/Done/ctx.Done on this watch. A + // nil blockEpochs channel keeps the synthesis arm + // parked until the registration is handed back on + // blockRegCh. + if a.cfg.FinalityDepth > 0 && a.blockReg == nil && + !arming { + + arming = true + a.armFinalityAsync(blockRegCh, log) + } + + case reg := <-blockRegCh: + // Finality arming completed. Clear the flag so a later + // confirmation can retry if this attempt failed + // (reg == nil); otherwise wire up the epoch channel. + arming = false + if reg != nil { + a.blockReg = reg + blockEpochs = reg.Epochs + } + + case seq, ok := <-a.registration.Reorged: + if !ok { + a.registration.Reorged = nil + continue + } + + // Discard a stale reorg that lost a cross-channel race + // to a newer confirmation. + if seq != 0 && seq <= lastSeq { + continue + } + if seq > lastSeq { + lastSeq = seq + } + + a.deliverConfReorged(lastEvent) + + // The previous confirmation is no longer on the + // canonical chain. Clear the cached event so a later + // Done cannot report the reorged-out txid, and reset + // the depth counter so the next re-confirmation starts + // a fresh window. + lastEvent = nil + a.confirmHeight = 0 + + case _, ok := <-a.registration.Done: + if !ok { + a.registration.Done = nil + continue + } + + a.deliverConfDone(lastEvent) return - } - - log.InfoS(a.ctx, "Received confirmation from backend", - "block_height", confDetails.BlockHeight, - "block_hash", confDetails.BlockHash, - ) - event, err := buildConfirmationEvent(confDetails, a) - if err != nil { - log.WarnS(a.ctx, "Failed to build confirmation event", - err, + case epoch, ok := <-blockEpochs: + if !ok || epoch == nil { + blockEpochs = nil + continue + } + + // Coalesce any epochs already queued behind this one + // and evaluate finality against the most recent + // height only. With rapid-fire blocks (or a backend + // that re-delivers historical epochs) the channel can + // hold several epochs at once; processing them one per + // loop iteration would re-check the same monotonic + // Done condition repeatedly and risk synthesizing + // against a stale height. If the channel closed during + // the drain, park it so we stop selecting on it. + var closed bool + epoch, closed = drainToLatestEpoch(blockEpochs, epoch) + if closed { + blockEpochs = nil + } + + // The confirmHeight==0 guard is load-bearing: a reorg + // resets confirmHeight to 0 (see the Reorged case + // above), and a fresh epoch on the new tip would + // otherwise produce a negative depth (epoch.Height - 0 + // wraps to a large value); the depth comparison is + // meaningful only after a confirmation arms + // confirmHeight. FinalityDepth==0 disables synthesis + // entirely. + if a.confirmHeight == 0 || + a.cfg.FinalityDepth == 0 { + + continue + } + + depth := epoch.Height - a.confirmHeight + 1 + if depth < int32(a.cfg.FinalityDepth) { + continue + } + + log.InfoS(a.ctx, "Synthesizing confirmation done "+ + "from height-based safety depth", + "confirm_height", a.confirmHeight, + "current_height", epoch.Height, + "finality_depth", int(a.cfg.FinalityDepth), ) - a.failConfirmation(err) + + // Finality is terminal for the whole watch: deliver + // Done (a no-op when only NotifyReorged was set) and + // stop. A reorg-only watch therefore intentionally + // stops receiving reorg events once the confirmation is + // buried FinalityDepth deep — past that depth a reorg + // is beyond the safety threshold the watch was created + // to cover, so there is nothing left to observe. + a.deliverConfDone(lastEvent) return - } - log.InfoS(a.ctx, "Delivering confirmation event", - "txid", event.Txid, - "block_height", event.BlockHeight, - ) - a.deliverConfirmation(event) + case <-a.ctx.Done(): + log.InfoS(a.ctx, "ConfActor context cancelled") + a.failConfirmation(a.ctx.Err()) + + return + } + } +} - case <-a.ctx.Done(): - log.InfoS(a.ctx, "ConfActor context cancelled") - a.failConfirmation(a.ctx.Err()) +// drainToLatestEpoch non-blockingly consumes any block epochs already +// queued on ch and returns the most recent non-nil epoch, starting from +// cur. With rapid-fire blocks (or a backend that re-delivers historical +// epochs) several epochs can sit in the channel at once; height-based +// finality synthesis depends only on the highest observed height, so +// collapsing the backlog to the newest epoch avoids re-evaluating the +// same monotonic Done condition once per stale epoch. The returned bool +// reports whether the channel was observed closed during the drain so the +// caller can park its receive on a nil channel. +func drainToLatestEpoch(ch <-chan *BlockEpoch, + cur *BlockEpoch) (*BlockEpoch, bool) { + + latest := cur + for { + select { + case e, ok := <-ch: + if !ok { + return latest, true + } + if e != nil { + latest = e + } + + default: + return latest, false + } } } // deliverConfirmation sends the confirmation event to the subscriber and // completes the promise (Future mode) or sends to the actor (Actor mode). +// armFinalityAsync registers a block-epoch subscription for height-based +// finality synthesis off the actor's select loop. registerBlocksForFinality +// retries with a bounded backoff that can run for tens of seconds; doing it +// inline would block delivery of Reorged/Done/ctx.Done on this watch for the +// whole window. The registration (or nil on failure) is handed back on regCh, +// or cancelled if the actor exits before the loop reads it. The goroutine is +// tracked by the actor's wait group so Stop drains it. +func (a *ConfActor) armFinalityAsync(regCh chan<- *BlockRegistration, + log btclog.Logger) { + + a.wg.Go(func() { + reg, err := registerBlocksForFinality(a.ctx, a.cfg.Backend, log) + if err != nil { + log.WarnS(a.ctx, "Giving up on height-based finality "+ + "synthesis; conf sub-actor will rely on "+ + "backend Done", err) + reg = nil + } + + select { + case regCh <- reg: + case <-a.ctx.Done(): + if reg != nil { + reg.Cancel() + } + } + }) +} + func (a *ConfActor) deliverConfirmation(event ConfirmationEvent) { a.promise.WhenSome(func(p actor.Promise[ConfirmationEvent]) { p.Complete(fn.Ok(event)) @@ -272,6 +543,53 @@ func (a *ConfActor) deliverConfirmation(event ConfirmationEvent) { }) } +// deliverConfReorged sends a reorg event to actor-mode subscribers. The +// correlation Txid is the registration's configured txid when set, since +// that is the identifier the caller asked us to watch; pkScript-only +// watches fall back to the txid carried on the most recent positive +// ConfirmationEvent. +func (a *ConfActor) deliverConfReorged(lastEvent *ConfirmationEvent) { + var event ConfReorgedEvent + switch { + case a.txid != nil: + event.Txid = *a.txid + + case lastEvent != nil: + event.Txid = lastEvent.Txid + } + + a.notifyReorged.WhenSome(func(ref actor.TellOnlyRef[ConfReorgedEvent]) { + log := a.logger(a.ctx) + if err := ref.Tell(a.ctx, event); err != nil { + log.WarnS(a.ctx, "Failed to deliver confirmation reorg", + err, + ) + } + }) +} + +// deliverConfDone sends a confirmation finality event to actor-mode +// subscribers. Txid follows the same precedence as deliverConfReorged. +func (a *ConfActor) deliverConfDone(lastEvent *ConfirmationEvent) { + var event ConfDoneEvent + switch { + case a.txid != nil: + event.Txid = *a.txid + + case lastEvent != nil: + event.Txid = lastEvent.Txid + } + + a.notifyDone.WhenSome(func(ref actor.TellOnlyRef[ConfDoneEvent]) { + log := a.logger(a.ctx) + if err := ref.Tell(a.ctx, event); err != nil { + log.WarnS(a.ctx, "Failed to deliver confirmation done", + err, + ) + } + }) +} + // failConfirmation completes the promise with an error (Future mode) or does // nothing (Actor mode - errors are not delivered in async mode). func (a *ConfActor) failConfirmation(err error) { diff --git a/chainsource/epoch_drain_test.go b/chainsource/epoch_drain_test.go new file mode 100644 index 000000000..9a439f84c --- /dev/null +++ b/chainsource/epoch_drain_test.go @@ -0,0 +1,76 @@ +package chainsource + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestDrainToLatestEpochCoalesces verifies that a backlog of queued block +// epochs collapses to the most recent one, so finality synthesis evaluates +// the highest observed height rather than re-checking once per stale epoch. +func TestDrainToLatestEpochCoalesces(t *testing.T) { + t.Parallel() + + ch := make(chan *BlockEpoch, 8) + cur := &BlockEpoch{Height: 100} + + // Queue several newer epochs behind the one already dequeued. + for h := int32(101); h <= 105; h++ { + ch <- &BlockEpoch{Height: h} + } + + got, closed := drainToLatestEpoch(ch, cur) + require.False(t, closed) + require.NotNil(t, got) + require.Equal(t, int32(105), got.Height) + + // The channel must be fully drained afterwards. + require.Len(t, ch, 0) +} + +// TestDrainToLatestEpochEmpty verifies that with nothing queued the helper +// returns the current epoch unchanged and reports the channel open. +func TestDrainToLatestEpochEmpty(t *testing.T) { + t.Parallel() + + ch := make(chan *BlockEpoch, 4) + cur := &BlockEpoch{Height: 42} + + got, closed := drainToLatestEpoch(ch, cur) + require.False(t, closed) + require.Same(t, cur, got) +} + +// TestDrainToLatestEpochSkipsNil verifies that nil epochs in the backlog are +// ignored: the most recent non-nil epoch wins. +func TestDrainToLatestEpochSkipsNil(t *testing.T) { + t.Parallel() + + ch := make(chan *BlockEpoch, 4) + cur := &BlockEpoch{Height: 10} + ch <- &BlockEpoch{Height: 11} + ch <- nil + + got, closed := drainToLatestEpoch(ch, cur) + require.False(t, closed) + require.NotNil(t, got) + require.Equal(t, int32(11), got.Height) +} + +// TestDrainToLatestEpochClosed verifies that a closed channel is reported so +// the caller can park its receive, while still returning the latest epoch +// observed before the close. +func TestDrainToLatestEpochClosed(t *testing.T) { + t.Parallel() + + ch := make(chan *BlockEpoch, 4) + cur := &BlockEpoch{Height: 7} + ch <- &BlockEpoch{Height: 8} + close(ch) + + got, closed := drainToLatestEpoch(ch, cur) + require.True(t, closed) + require.NotNil(t, got) + require.Equal(t, int32(8), got.Height) +} diff --git a/chainsource/finality.go b/chainsource/finality.go new file mode 100644 index 000000000..2b8561b1f --- /dev/null +++ b/chainsource/finality.go @@ -0,0 +1,79 @@ +package chainsource + +import ( + "context" + "time" + + "github.com/btcsuite/btclog/v2" +) + +// finalityBlockSubscriptionBackoffs is the retry schedule conf/spend +// actors use when arming the block-epoch subscription that drives +// height-based finality synthesis. Three attempts at 100ms, 500ms, 2s +// spread the retries across roughly 2.6 seconds — short enough that a +// transient backend hiccup is absorbed inside the monitoring +// goroutine, long enough that we are not spinning on a backend that +// is genuinely down. Exhaustion is non-fatal: the sub-actor logs and +// falls back to whatever Done the backend itself delivers. +var finalityBlockSubscriptionBackoffs = []time.Duration{ + 100 * time.Millisecond, + 500 * time.Millisecond, + 2 * time.Second, +} + +// registerBlocksForFinality registers a block-epoch subscription used +// to synthesize a Done signal at FinalityDepth past an observed +// confirmation or spend. The call is retried with a short bounded +// backoff because finality synthesis is the only Done source for +// backends that do not write the upstream Done channel (notably +// lndclient over gRPC); a one-shot RegisterBlocks attempt that +// briefly hiccups would leak the per-watch sub-actor indefinitely. +// +// The retries run in a dedicated arming goroutine (not the sub-actor's +// select loop), so brief blocking here is safe: more confirmation/spend +// events on this specific watch are not expected during the retry window +// (we already consumed the one that triggered the arm), and ctx +// cancellation breaks out promptly. +// +// The passed ctx MUST be the sub-actor's long-lived context, and it is +// handed to RegisterBlocks unwrapped: for in-process backends the +// block-epoch forwarder goroutine is tied to the ctx it receives, so +// bounding each attempt with a cancellable child ctx (and cancelling it +// once the call returns) would tear the subscription down the instant it +// was armed — starving finality synthesis of the very epochs it needs. +// A hung RegisterBlocks can therefore stall this arming goroutine, but +// that is contained: it is off the select loop (fix moved arming there +// precisely so a slow backend cannot wedge Confirmed/Reorged/Done +// delivery), and a genuinely wedged backend is a lost watch regardless. +// +// Returns the registration on success, or a non-nil error after +// retries are exhausted. Callers should log the error at warn level +// and continue without height-based synthesis; the backend's own Done +// channel remains the only finality path in that case. +func registerBlocksForFinality(ctx context.Context, backend ChainBackend, + log btclog.Logger) (*BlockRegistration, error) { + + var lastErr error + for attempt, backoff := range finalityBlockSubscriptionBackoffs { + reg, err := backend.RegisterBlocks(ctx) + if err == nil { + return reg, nil + } + lastErr = err + + log.WarnS(ctx, "RegisterBlocks for finality synthesis failed; "+ + "retrying", err, + "attempt", attempt+1, + "max_attempts", len(finalityBlockSubscriptionBackoffs), + "backoff", backoff, + ) + + select { + case <-time.After(backoff): + case <-ctx.Done(): + return nil, ctx.Err() + } + } + + return nil, lastErr +} diff --git a/chainsource/messages.go b/chainsource/messages.go index cd5eefba3..98e15ea91 100644 --- a/chainsource/messages.go +++ b/chainsource/messages.go @@ -261,6 +261,15 @@ type RegisterConfRequest struct { // events will be sent to this actor asynchronously. If None, a Future // is returned in the response for blocking await. NotifyActor fn.Option[actor.TellOnlyRef[ConfirmationEvent]] + + // NotifyReorged is an optional actor reference for negative + // confirmation events. It is only used in async actor mode. + NotifyReorged fn.Option[actor.TellOnlyRef[ConfReorgedEvent]] + + // NotifyDone is an optional actor reference notified when the + // confirmation watch is beyond the backend's reorg tracking horizon. + // It is only used in async actor mode. + NotifyDone fn.Option[actor.TellOnlyRef[ConfDoneEvent]] } // MessageType returns the message type identifier for logging and debugging. @@ -332,6 +341,48 @@ func (m ConfirmationEvent) MessageType() string { return "ConfirmationEvent" } +// ConfReorgedEvent is sent when a previously reported confirmation is +// reorged out of the canonical chain. After receiving this event a consumer +// should consider the prior confirmation no longer valid; if the transaction +// re-confirms on the new canonical chain a fresh ConfirmationEvent will +// follow on the same registration. +// +// No block hash, height, or reorg depth is carried because the lndclient +// gRPC transport does not preserve that information and we do not want to +// expose fields that are always zero in production. Consumers that need to +// invalidate cached block metadata should match on Txid and use the data +// from the most recent ConfirmationEvent they observed on this watch. +type ConfReorgedEvent struct { + actor.BaseMessage + + // Txid is the transaction ID whose confirmation was reorged. This + // matches the Txid carried on the originating ConfirmationEvent. + Txid chainhash.Hash +} + +// MessageType returns the message type identifier for logging and debugging. +func (m ConfReorgedEvent) MessageType() string { + return "ConfReorgedEvent" +} + +// ConfDoneEvent is sent when a confirmation watch has matured past the +// backend's reorg-safety depth and will receive no further events. +// Consumers may use this signal to drop any reorg-recovery bookkeeping they +// were holding for the registration. Not all backends synthesize this +// event; consumers must treat its absence as a normal operating condition +// rather than an error. +type ConfDoneEvent struct { + actor.BaseMessage + + // Txid is the transaction ID whose registration matured. + Txid chainhash.Hash +} + +// MessageType returns the message type identifier for logging and debugging. +func (m ConfDoneEvent) MessageType() string { + return "ConfDoneEvent" +} + // UnregisterConfRequest requests cancellation of a confirmation subscription. // The ChainSource actor uses the fields to construct the service key and // cancel the dedicated actor. @@ -417,6 +468,15 @@ type RegisterSpendRequest struct { // will be sent to this actor asynchronously. If None, a Future is // returned for blocking await. NotifyActor fn.Option[actor.TellOnlyRef[SpendEvent]] + + // NotifyReorged is an optional actor reference for spend reorg events. + // It is only used in async actor mode. + NotifyReorged fn.Option[actor.TellOnlyRef[SpendReorgedEvent]] + + // NotifyDone is an optional actor reference notified when the spend + // watch is beyond the backend's reorg tracking horizon. It is only used + // in async actor mode. + NotifyDone fn.Option[actor.TellOnlyRef[SpendDoneEvent]] } // MessageType returns the message type identifier for logging and debugging. @@ -481,6 +541,45 @@ func (m SpendEvent) MessageType() string { return "SpendEvent" } +// SpendReorgedEvent is sent when a previously reported spend is reorged out +// of the canonical chain. After receiving this event a consumer should +// consider the prior spend no longer valid; if the outpoint is re-spent on +// the new canonical chain a fresh SpendEvent will follow on the same +// registration. +// +// No spending txid, height, or block hash is carried because the lndclient +// gRPC transport does not preserve that information and we do not want to +// expose fields that are always zero in production. Consumers that need to +// invalidate cached spending metadata should match on Outpoint and use the +// data from the most recent SpendEvent they observed on this watch. +type SpendReorgedEvent struct { + actor.BaseMessage + + // Outpoint is the output whose spend was reorged. + Outpoint wire.OutPoint +} + +// MessageType returns the message type identifier for logging and debugging. +func (m SpendReorgedEvent) MessageType() string { + return "SpendReorgedEvent" +} + +// SpendDoneEvent is sent when a spend watch has matured past the backend's +// reorg-safety depth and will receive no further events. Not all backends +// synthesize this event; consumers must treat its absence as a normal +// operating condition rather than an error. +type SpendDoneEvent struct { + actor.BaseMessage + + // Outpoint is the output whose spend registration matured. + Outpoint wire.OutPoint +} + +// MessageType returns the message type identifier for logging and debugging. +func (m SpendDoneEvent) MessageType() string { + return "SpendDoneEvent" +} + // UnregisterSpendRequest requests cancellation of a spend subscription. // The ChainSource actor uses the fields to construct the service key and // cancel the dedicated actor. diff --git a/chainsource/reorg_test.go b/chainsource/reorg_test.go new file mode 100644 index 000000000..d045b8d49 --- /dev/null +++ b/chainsource/reorg_test.go @@ -0,0 +1,735 @@ +package chainsource + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" +) + +// awaitTimeout is the per-step wait used by the reorg lifecycle tests. It +// is generous enough to absorb scheduling jitter on overloaded CI machines +// but still short enough that a hung actor surfaces as a fast failure. +const awaitTimeout = 5 * time.Second + +// TestConfActorReorgAwareForwardsFullLifecycle drives the full +// Confirmed -> Reorged -> Confirmed -> Done sequence through a reorg-aware +// ConfActor and asserts each event is forwarded to the correct notify ref +// in order, and that the actor releases the backend registration after +// Done. +func TestConfActorReorgAwareForwardsFullLifecycle(t *testing.T) { + t.Parallel() + + backend := newMockBackend() + ctx := t.Context() + confActor := NewConfActor(ConfActorConfig{Backend: backend}) + defer confActor.Stop() + + txHash := chainhash.Hash{0x01} + confNotifier := actor.NewChannelTellOnlyRef[ConfirmationEvent]( + "conf-notify", 10, + ) + reorgNotifier := actor.NewChannelTellOnlyRef[ConfReorgedEvent]( + "conf-reorged", 10, + ) + doneNotifier := actor.NewChannelTellOnlyRef[ConfDoneEvent]( + "conf-done", 10, + ) + + var confRef actor.TellOnlyRef[ConfirmationEvent] = confNotifier + var reorgRef actor.TellOnlyRef[ConfReorgedEvent] = reorgNotifier + var doneRef actor.TellOnlyRef[ConfDoneEvent] = doneNotifier + + result := confActor.Receive(ctx, &RegisterConfRequest{ + CallerID: "test-conf-reorg-lifecycle", + Txid: &txHash, + PkScript: []byte{0x00, 0x14}, + TargetConfs: 1, + NotifyActor: fn.Some(confRef), + NotifyReorged: fn.Some(reorgRef), + NotifyDone: fn.Some(doneRef), + }) + require.True(t, result.IsOk()) + resp, err := result.Unpack() + require.NoError(t, err) + confResp, ok := resp.(*RegisterConfResponse) + require.True(t, ok) + + // Reorg-aware mode is actor-only, so the response must not carry a + // Future. + require.Nil(t, confResp.Future) + + // 1. First confirmation on the canonical chain. + blockHash1 := chainhash.Hash{0xaa} + backend.confChan <- &TxConfirmation{ + BlockHash: &blockHash1, + BlockHeight: 100, + Tx: wire.NewMsgTx(2), + } + + event1, ok := confNotifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for first ConfirmationEvent") + require.Equal(t, int32(100), event1.BlockHeight) + require.Equal(t, blockHash1, event1.BlockHash) + + // 2. Reorg evicts that confirmation. + backend.confReorgedChan <- uint64(0) + + reorgEvt, ok := reorgNotifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for ConfReorgedEvent") + require.Equal(t, txHash, reorgEvt.Txid) + + // 3. Transaction re-confirms in a different block on the new tip. + blockHash2 := chainhash.Hash{0xbb} + backend.confChan <- &TxConfirmation{ + BlockHash: &blockHash2, + BlockHeight: 101, + Tx: wire.NewMsgTx(2), + } + + event2, ok := confNotifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for re-ConfirmationEvent") + require.Equal(t, int32(101), event2.BlockHeight) + require.Equal(t, blockHash2, event2.BlockHash) + + // 4. Registration matures past reorg safety; backend fires Done. + backend.confDoneChan <- struct{}{} + + doneEvt, ok := doneNotifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for ConfDoneEvent") + require.Equal(t, txHash, doneEvt.Txid) + + // After Done the actor must release the registration. + require.Eventually(t, func() bool { + return backend.confCancelled.Load() >= 1 + }, awaitTimeout, 10*time.Millisecond, + "registration Cancel was never invoked after Done") +} + +// TestConfActorSynthesizesDoneFromFinalityDepth verifies that a +// reorg-aware ConfActor with a non-zero FinalityDepth fires +// ConfDoneEvent on its own once enough blocks have been observed past +// the first Confirmed event, even when the backend's Done channel +// never fires. This closes the lndclient gRPC gap, where lnd's +// internal "past reorg-safety depth" signal does not survive the +// transport. +func TestConfActorSynthesizesDoneFromFinalityDepth(t *testing.T) { + t.Parallel() + + backend := newMockBackend() + ctx := t.Context() + const finalityDepth = 6 + confActor := NewConfActor(ConfActorConfig{ + Backend: backend, + FinalityDepth: finalityDepth, + }) + defer confActor.Stop() + + txHash := chainhash.Hash{0x01} + confNotifier := actor.NewChannelTellOnlyRef[ConfirmationEvent]( + "conf-notify", 10, + ) + reorgNotifier := actor.NewChannelTellOnlyRef[ConfReorgedEvent]( + "conf-reorged", 10, + ) + doneNotifier := actor.NewChannelTellOnlyRef[ConfDoneEvent]( + "conf-done", 10, + ) + + var confRef actor.TellOnlyRef[ConfirmationEvent] = confNotifier + var reorgRef actor.TellOnlyRef[ConfReorgedEvent] = reorgNotifier + var doneRef actor.TellOnlyRef[ConfDoneEvent] = doneNotifier + + result := confActor.Receive(ctx, &RegisterConfRequest{ + CallerID: "test-conf-finality-depth", + Txid: &txHash, + PkScript: []byte{0x00, 0x14}, + TargetConfs: 1, + NotifyActor: fn.Some(confRef), + NotifyReorged: fn.Some(reorgRef), + NotifyDone: fn.Some(doneRef), + }) + require.True(t, result.IsOk()) + + // 1. First confirmation at height 100. This arms the height-based + // finality synthesizer. + blockHash1 := chainhash.Hash{0xaa} + backend.confChan <- &TxConfirmation{ + BlockHash: &blockHash1, + BlockHeight: 100, + Tx: wire.NewMsgTx(2), + } + _, ok := confNotifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for ConfirmationEvent") + + // 2. Push blocks up to height 104. Inclusive depth at that point + // is 5 (heights 100..104), one short of the finality threshold, + // so ConfDoneEvent MUST NOT have fired yet. + for height := int32(101); height <= + int32(100+finalityDepth-2); height++ { + + backend.epochChan <- &BlockEpoch{Height: height} + } + _, ok = doneNotifier.AwaitMessage(50 * time.Millisecond) + require.False(t, ok, "ConfDoneEvent fired before finality depth") + + // 3. One more block brings the inclusive depth to exactly + // FinalityDepth (heights 100..105). Done must fire now. + backend.epochChan <- &BlockEpoch{ + Height: 100 + int32(finalityDepth) - 1, + } + doneEvt, ok := doneNotifier.AwaitMessage(awaitTimeout) + require.True( + t, ok, + "ConfDoneEvent never fired despite reaching finality depth", + ) + require.Equal(t, txHash, doneEvt.Txid) + + // 4. After Done the actor exits and releases the registration. + require.Eventually(t, func() bool { + return backend.confCancelled.Load() >= 1 + }, awaitTimeout, 10*time.Millisecond, + "registration Cancel was never invoked after synthesized "+ + "Done") +} + +// TestConfActorDiscardsStaleReorgBySeq verifies that a reorg signal which +// lost a cross-channel race to a newer re-confirmation is discarded by +// sequence number. The re-confirmation (seq 3) is observed before the +// older reorg (seq 2); because Confirmed and Reorged arrive on separate +// channels the actor cannot order them by arrival, so it must order them +// by Seq and ignore the stale reorg. The proof is that height-based +// finality still fires against the re-confirmation height — if the stale +// reorg had been applied it would have reset confirmHeight to 0 and Done +// would never synthesize. +func TestConfActorDiscardsStaleReorgBySeq(t *testing.T) { + t.Parallel() + + backend := newMockBackend() + ctx := t.Context() + const finalityDepth = 6 + confActor := NewConfActor(ConfActorConfig{ + Backend: backend, + FinalityDepth: finalityDepth, + }) + defer confActor.Stop() + + txHash := chainhash.Hash{0x04} + confNotifier := actor.NewChannelTellOnlyRef[ConfirmationEvent]( + "conf-notify", 10, + ) + reorgNotifier := actor.NewChannelTellOnlyRef[ConfReorgedEvent]( + "conf-reorged", 10, + ) + doneNotifier := actor.NewChannelTellOnlyRef[ConfDoneEvent]( + "conf-done", 10, + ) + + var confRef actor.TellOnlyRef[ConfirmationEvent] = confNotifier + var reorgRef actor.TellOnlyRef[ConfReorgedEvent] = reorgNotifier + var doneRef actor.TellOnlyRef[ConfDoneEvent] = doneNotifier + + result := confActor.Receive(ctx, &RegisterConfRequest{ + CallerID: "test-conf-stale-reorg-seq", + Txid: &txHash, + PkScript: []byte{0x00, 0x14}, + TargetConfs: 1, + NotifyActor: fn.Some(confRef), + NotifyReorged: fn.Some(reorgRef), + NotifyDone: fn.Some(doneRef), + }) + require.True(t, result.IsOk()) + + // 1. First confirmation at height 100, seq 1. + blockHash1 := chainhash.Hash{0xaa} + backend.confChan <- &TxConfirmation{ + BlockHash: &blockHash1, + BlockHeight: 100, + Tx: wire.NewMsgTx(2), + Seq: 1, + } + _, ok := confNotifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for first ConfirmationEvent") + + // 2. The re-confirmation (seq 3) is delivered before the older reorg + // (seq 2) — the cross-channel race. It arms finality at height 101. + blockHash2 := chainhash.Hash{0xbb} + backend.confChan <- &TxConfirmation{ + BlockHash: &blockHash2, + BlockHeight: 101, + Tx: wire.NewMsgTx(2), + Seq: 3, + } + _, ok = confNotifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for re-ConfirmationEvent") + + // 3. The stale reorg (seq 2 <= 3) arrives late and must be discarded: + // no ConfReorgedEvent is delivered and confirmHeight is untouched. + backend.confReorgedChan <- uint64(2) + _, ok = reorgNotifier.AwaitMessage(50 * time.Millisecond) + require.False(t, ok, "stale reorg (seq 2) was not discarded") + + // 4. Drive blocks to the finality depth past height 101. Done must + // fire, proving confirmHeight survived the stale reorg. + for height := int32(102); height <= + int32(101+finalityDepth)-1; height++ { + + backend.epochChan <- &BlockEpoch{Height: height} + } + doneEvt, ok := doneNotifier.AwaitMessage(awaitTimeout) + require.True( + t, ok, "ConfDoneEvent never fired; stale reorg wrongly "+ + "reset confirmHeight", + ) + require.Equal(t, txHash, doneEvt.Txid) +} + +// TestConfActorDiscardsStaleConfirmBySeq verifies the opposite race: a +// re-confirmation that lost a cross-channel race to a newer reorg is +// discarded by sequence number. The reorg (seq 3) is observed before the +// stale confirmation (seq 2); the actor must ignore the confirmation and +// leave the watch unconfirmed, so height-based finality must NOT fire. +// Without sequence ordering the stale confirmation would set a non-zero +// confirmHeight and synthesize a false Done for a tx that is gone. +func TestConfActorDiscardsStaleConfirmBySeq(t *testing.T) { + t.Parallel() + + backend := newMockBackend() + ctx := t.Context() + const finalityDepth = 6 + confActor := NewConfActor(ConfActorConfig{ + Backend: backend, + FinalityDepth: finalityDepth, + }) + defer confActor.Stop() + + txHash := chainhash.Hash{0x05} + confNotifier := actor.NewChannelTellOnlyRef[ConfirmationEvent]( + "conf-notify", 10, + ) + reorgNotifier := actor.NewChannelTellOnlyRef[ConfReorgedEvent]( + "conf-reorged", 10, + ) + doneNotifier := actor.NewChannelTellOnlyRef[ConfDoneEvent]( + "conf-done", 10, + ) + + var confRef actor.TellOnlyRef[ConfirmationEvent] = confNotifier + var reorgRef actor.TellOnlyRef[ConfReorgedEvent] = reorgNotifier + var doneRef actor.TellOnlyRef[ConfDoneEvent] = doneNotifier + + result := confActor.Receive(ctx, &RegisterConfRequest{ + CallerID: "test-conf-stale-confirm-seq", + Txid: &txHash, + PkScript: []byte{0x00, 0x14}, + TargetConfs: 1, + NotifyActor: fn.Some(confRef), + NotifyReorged: fn.Some(reorgRef), + NotifyDone: fn.Some(doneRef), + }) + require.True(t, result.IsOk()) + + // 1. First confirmation at height 100, seq 1. + blockHash1 := chainhash.Hash{0xaa} + backend.confChan <- &TxConfirmation{ + BlockHash: &blockHash1, + BlockHeight: 100, + Tx: wire.NewMsgTx(2), + Seq: 1, + } + _, ok := confNotifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for first ConfirmationEvent") + + // 2. The newer reorg (seq 3) is observed first and resets the watch. + backend.confReorgedChan <- uint64(3) + _, ok = reorgNotifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for ConfReorgedEvent") + + // 3. The stale re-confirmation (seq 2 <= 3) arrives late and must be + // discarded: no ConfirmationEvent is delivered and the watch stays + // unconfirmed. + blockHash2 := chainhash.Hash{0xbb} + backend.confChan <- &TxConfirmation{ + BlockHash: &blockHash2, + BlockHeight: 101, + Tx: wire.NewMsgTx(2), + Seq: 2, + } + _, ok = confNotifier.AwaitMessage(50 * time.Millisecond) + require.False(t, ok, "stale confirmation (seq 2) was not discarded") + + // 4. Drive many blocks well past any finality window. Done must NOT + // fire because confirmHeight was never re-armed. + for height := int32(101); height <= int32(120); height++ { + backend.epochChan <- &BlockEpoch{Height: height} + } + _, ok = doneNotifier.AwaitMessage(100 * time.Millisecond) + require.False( + t, ok, "ConfDoneEvent fired for a reorged-out tx; stale "+ + "confirmation was wrongly applied", + ) +} + +// TestConfActorReorgAwareRejectsWithoutNotifyActor verifies that opting in +// to reorg-aware mode without an actor-mode confirmation ref is rejected at +// admission. Allowing it would silently drop every re-confirmation after +// the first, since a Future can only complete once. +func TestConfActorReorgAwareRejectsWithoutNotifyActor(t *testing.T) { + t.Parallel() + + backend := newMockBackend() + ctx := t.Context() + confActor := NewConfActor(ConfActorConfig{Backend: backend}) + defer confActor.Stop() + + txHash := chainhash.Hash{0x02} + reorgNotifier := actor.NewChannelTellOnlyRef[ConfReorgedEvent]( + "conf-reorged", 1, + ) + var reorgRef actor.TellOnlyRef[ConfReorgedEvent] = reorgNotifier + + result := confActor.Receive(ctx, &RegisterConfRequest{ + CallerID: "test-conf-reorg-no-notify", + Txid: &txHash, + PkScript: []byte{0x00, 0x14}, + TargetConfs: 1, + NotifyReorged: fn.Some(reorgRef), + }) + require.True(t, result.IsErr()) + _, err := result.Unpack() + require.ErrorContains( + t, err, + "reorg/done notifications require actor-mode NotifyActor", + ) +} + +// TestConfActorLegacyExitsAfterFirstConfirm ensures legacy Actor-mode +// subscribers (no NotifyReorged / NotifyDone) keep their historical +// single-shot contract even when the backend would later emit Reorged or +// Done. The actor must cancel the registration after the first +// confirmation. +func TestConfActorLegacyExitsAfterFirstConfirm(t *testing.T) { + t.Parallel() + + backend := newMockBackend() + ctx := t.Context() + confActor := NewConfActor(ConfActorConfig{Backend: backend}) + defer confActor.Stop() + + txHash := chainhash.Hash{0x03} + notifier := actor.NewChannelTellOnlyRef[ConfirmationEvent]( + "conf-notify", 10, + ) + var confRef actor.TellOnlyRef[ConfirmationEvent] = notifier + + result := confActor.Receive(ctx, &RegisterConfRequest{ + CallerID: "test-conf-legacy-single-shot", + Txid: &txHash, + PkScript: []byte{0x00, 0x14}, + TargetConfs: 1, + NotifyActor: fn.Some(confRef), + }) + require.True(t, result.IsOk()) + + // First confirmation goes through. + blockHash := chainhash.Hash{0x10} + backend.confChan <- &TxConfirmation{ + BlockHash: &blockHash, + BlockHeight: 200, + Tx: wire.NewMsgTx(2), + } + + event, ok := notifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for first ConfirmationEvent") + require.Equal(t, int32(200), event.BlockHeight) + + // Actor must have exited after the first confirmation, releasing the + // registration. + require.Eventually(t, func() bool { + return backend.confCancelled.Load() >= 1 + }, awaitTimeout, 10*time.Millisecond, + "legacy ConfActor did not cancel registration after first "+ + "confirmation") + + // No further events should reach the notifier. + _, ok = notifier.AwaitMessage(50 * time.Millisecond) + require.False( + t, ok, "legacy ConfActor delivered an unexpected second event", + ) +} + +// TestSpendActorReorgAwareForwardsFullLifecycle drives the spend lifecycle +// (Spend -> Reorged -> Spend -> Done) through a reorg-aware SpendActor and +// asserts every event is forwarded to the correct notify ref in order. +func TestSpendActorReorgAwareForwardsFullLifecycle(t *testing.T) { + t.Parallel() + + backend := newMockBackend() + ctx := t.Context() + spendActor := NewSpendActor(SpendActorConfig{Backend: backend}) + defer spendActor.Stop() + + outpoint := wire.OutPoint{Hash: chainhash.Hash{0x11}, Index: 0} + + spendNotifier := actor.NewChannelTellOnlyRef[SpendEvent]( + "spend-notify", 10, + ) + reorgNotifier := actor.NewChannelTellOnlyRef[SpendReorgedEvent]( + "spend-reorged", 10, + ) + doneNotifier := actor.NewChannelTellOnlyRef[SpendDoneEvent]( + "spend-done", 10, + ) + + var spendRef actor.TellOnlyRef[SpendEvent] = spendNotifier + var reorgRef actor.TellOnlyRef[SpendReorgedEvent] = reorgNotifier + var doneRef actor.TellOnlyRef[SpendDoneEvent] = doneNotifier + + result := spendActor.Receive(ctx, &RegisterSpendRequest{ + CallerID: "test-spend-reorg-lifecycle", + Outpoint: &outpoint, + PkScript: []byte{0x00, 0x14}, + NotifyActor: fn.Some(spendRef), + NotifyReorged: fn.Some(reorgRef), + NotifyDone: fn.Some(doneRef), + }) + require.True(t, result.IsOk()) + resp, err := result.Unpack() + require.NoError(t, err) + spendResp, ok := resp.(*RegisterSpendResponse) + require.True(t, ok) + require.Nil(t, spendResp.Future) + + // 1. First spend confirms. + spendingTx1 := wire.NewMsgTx(2) + hash1 := spendingTx1.TxHash() + backend.spendChan <- &SpendDetail{ + SpentOutPoint: &outpoint, + SpenderTxHash: &hash1, + SpendingTx: spendingTx1, + SpendingHeight: 150, + } + + spend1, ok := spendNotifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for first SpendEvent") + require.Equal(t, outpoint, spend1.Outpoint) + require.Equal(t, int32(150), spend1.SpendingHeight) + require.Equal(t, hash1, spend1.SpendingTxid) + + // 2. Reorg evicts that spend. + backend.spendReorgedChan <- uint64(0) + + reorgEvt, ok := reorgNotifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for SpendReorgedEvent") + require.Equal(t, outpoint, reorgEvt.Outpoint) + + // 3. A different spender wins the new chain. + spendingTx2 := wire.NewMsgTx(2) + spendingTx2.AddTxIn(&wire.TxIn{Sequence: 1}) + hash2 := spendingTx2.TxHash() + require.NotEqual(t, hash1, hash2) + backend.spendChan <- &SpendDetail{ + SpentOutPoint: &outpoint, + SpenderTxHash: &hash2, + SpendingTx: spendingTx2, + SpendingHeight: 151, + } + + spend2, ok := spendNotifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for re-SpendEvent") + require.Equal(t, outpoint, spend2.Outpoint) + require.Equal(t, int32(151), spend2.SpendingHeight) + require.Equal(t, hash2, spend2.SpendingTxid) + + // 4. Registration matures past reorg safety. + backend.spendDoneChan <- struct{}{} + + doneEvt, ok := doneNotifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for SpendDoneEvent") + require.Equal(t, outpoint, doneEvt.Outpoint) + + require.Eventually(t, func() bool { + return backend.spendCancelled.Load() >= 1 + }, awaitTimeout, 10*time.Millisecond, + "registration Cancel was never invoked after Done") +} + +// TestSpendActorSynthesizesDoneFromFinalityDepth mirrors the conf-side +// height-based finality test for the spend watch. A reorg-aware +// SpendActor with non-zero FinalityDepth fires SpendDoneEvent on its +// own once enough blocks have been observed past the first Spend, +// closing the same lndclient gRPC gap that ConfActor closes for +// confirmations. +func TestSpendActorSynthesizesDoneFromFinalityDepth(t *testing.T) { + t.Parallel() + + backend := newMockBackend() + ctx := t.Context() + const finalityDepth = 6 + spendActor := NewSpendActor(SpendActorConfig{ + Backend: backend, + FinalityDepth: finalityDepth, + }) + defer spendActor.Stop() + + outpoint := wire.OutPoint{Hash: chainhash.Hash{0x11}, Index: 0} + + spendNotifier := actor.NewChannelTellOnlyRef[SpendEvent]( + "spend-notify", 10, + ) + reorgNotifier := actor.NewChannelTellOnlyRef[SpendReorgedEvent]( + "spend-reorged", 10, + ) + doneNotifier := actor.NewChannelTellOnlyRef[SpendDoneEvent]( + "spend-done", 10, + ) + + var spendRef actor.TellOnlyRef[SpendEvent] = spendNotifier + var reorgRef actor.TellOnlyRef[SpendReorgedEvent] = reorgNotifier + var doneRef actor.TellOnlyRef[SpendDoneEvent] = doneNotifier + + result := spendActor.Receive(ctx, &RegisterSpendRequest{ + CallerID: "test-spend-finality-depth", + Outpoint: &outpoint, + PkScript: []byte{0x00, 0x14}, + NotifyActor: fn.Some(spendRef), + NotifyReorged: fn.Some(reorgRef), + NotifyDone: fn.Some(doneRef), + }) + require.True(t, result.IsOk()) + + // 1. First spend confirms at height 150. This arms the + // height-based finality synthesizer. + spendingTx := wire.NewMsgTx(2) + hash := spendingTx.TxHash() + backend.spendChan <- &SpendDetail{ + SpentOutPoint: &outpoint, + SpenderTxHash: &hash, + SpendingTx: spendingTx, + SpendingHeight: 150, + } + _, ok := spendNotifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for SpendEvent") + + // 2. Push blocks up to height 154. Inclusive depth at that point + // is 5 (heights 150..154), one short of the finality threshold. + // SpendDoneEvent MUST NOT have fired yet. + for height := int32(151); height <= + int32(150+finalityDepth-2); height++ { + + backend.epochChan <- &BlockEpoch{Height: height} + } + _, ok = doneNotifier.AwaitMessage(50 * time.Millisecond) + require.False(t, ok, "SpendDoneEvent fired before finality depth") + + // 3. One more block brings the inclusive depth to exactly + // FinalityDepth (heights 150..155). Done must fire now. + backend.epochChan <- &BlockEpoch{ + Height: 150 + int32(finalityDepth) - 1, + } + doneEvt, ok := doneNotifier.AwaitMessage(awaitTimeout) + require.True( + t, ok, + "SpendDoneEvent never fired despite reaching finality depth", + ) + require.Equal(t, outpoint, doneEvt.Outpoint) + + require.Eventually(t, func() bool { + return backend.spendCancelled.Load() >= 1 + }, awaitTimeout, 10*time.Millisecond, + "registration Cancel was never invoked after synthesized "+ + "Done") +} + +// TestSpendActorReorgAwareRejectsWithoutNotifyActor mirrors the conf-side +// admission check: opting in to reorg-aware spend forwarding without a +// NotifyActor must be rejected. +func TestSpendActorReorgAwareRejectsWithoutNotifyActor(t *testing.T) { + t.Parallel() + + backend := newMockBackend() + ctx := t.Context() + spendActor := NewSpendActor(SpendActorConfig{Backend: backend}) + defer spendActor.Stop() + + outpoint := wire.OutPoint{Hash: chainhash.Hash{0x21}, Index: 0} + reorgNotifier := actor.NewChannelTellOnlyRef[SpendReorgedEvent]( + "spend-reorged", 1, + ) + var reorgRef actor.TellOnlyRef[SpendReorgedEvent] = reorgNotifier + + result := spendActor.Receive(ctx, &RegisterSpendRequest{ + CallerID: "test-spend-reorg-no-notify", + Outpoint: &outpoint, + PkScript: []byte{0x00, 0x14}, + NotifyReorged: fn.Some(reorgRef), + }) + require.True(t, result.IsErr()) + _, err := result.Unpack() + require.ErrorContains( + t, err, + "reorg/done notifications require actor-mode NotifyActor", + ) +} + +// TestSpendActorLegacyExitsAfterFirstSpend exercises the legacy Actor-mode +// path: a watch that did not opt into the reorg lifecycle +// (NotifyReorged/NotifyDone both unset) is single-shot, exiting and +// releasing its backend registration after the first spend. This mirrors +// ConfActor's legacy contract and the documented invariant in CLAUDE.md; +// without the reorgAware gate such a watch would run forever and, with +// FinalityDepth > 0, arm a block subscription it never requested. +func TestSpendActorLegacyExitsAfterFirstSpend(t *testing.T) { + t.Parallel() + + backend := newMockBackend() + ctx := t.Context() + spendActor := NewSpendActor(SpendActorConfig{Backend: backend}) + defer spendActor.Stop() + + outpoint := wire.OutPoint{Hash: chainhash.Hash{0x31}, Index: 0} + notifier := actor.NewChannelTellOnlyRef[SpendEvent]( + "spend-notify", 10, + ) + var spendRef actor.TellOnlyRef[SpendEvent] = notifier + + result := spendActor.Receive(ctx, &RegisterSpendRequest{ + CallerID: "test-spend-legacy-single-shot", + Outpoint: &outpoint, + PkScript: []byte{0x00, 0x14}, + NotifyActor: fn.Some(spendRef), + }) + require.True(t, result.IsOk()) + + // First spend goes through. + spendingTx := wire.NewMsgTx(2) + hash := spendingTx.TxHash() + backend.spendChan <- &SpendDetail{ + SpentOutPoint: &outpoint, + SpenderTxHash: &hash, + SpendingTx: spendingTx, + SpendingHeight: 300, + } + + first, ok := notifier.AwaitMessage(awaitTimeout) + require.True(t, ok) + require.Equal(t, int32(300), first.SpendingHeight) + + // The actor must have exited after the first spend, releasing the + // registration. + require.Eventually(t, func() bool { + return backend.spendCancelled.Load() >= 1 + }, awaitTimeout, 10*time.Millisecond, + "legacy SpendActor did not cancel registration after first "+ + "spend") + + // No further events should reach the notifier. + _, ok = notifier.AwaitMessage(50 * time.Millisecond) + require.False( + t, ok, "legacy SpendActor delivered an unexpected second event", + ) +} diff --git a/chainsource/spend_actor.go b/chainsource/spend_actor.go index 287d1b3e3..888cd2258 100644 --- a/chainsource/spend_actor.go +++ b/chainsource/spend_actor.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "sync" + "time" "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" @@ -23,6 +24,15 @@ type SpendActorConfig struct { // falls back to extracting a logger from context via LoggerFromContext, // or uses btclog.Disabled if no logger is found. Log fn.Option[btclog.Logger] + + // FinalityDepth is the number of confirmations past the first + // observed Spend event that the actor uses to synthesize a Done + // signal when the backend cannot deliver one. See the matching + // field on ConfActorConfig for the rationale; the same constraint + // applies on the spend watch — lnd's chainntnfs.SpendEvent.Done + // does not survive the lndclient gRPC transport, so consumers + // that gate eviction on Done would otherwise leak per-spend state. + FinalityDepth uint32 } // WithLogger returns a new config with the given logger set. @@ -61,9 +71,26 @@ type SpendActor struct { // mode. notifyActor fn.Option[actor.TellOnlyRef[SpendEvent]] + // notifyReorged receives spend reorg events in Actor mode. + notifyReorged fn.Option[actor.TellOnlyRef[SpendReorgedEvent]] + + // notifyDone receives spend finality events in Actor mode. + notifyDone fn.Option[actor.TellOnlyRef[SpendDoneEvent]] + // registration is the backend registration for this watch. registration *SpendRegistration + // blockReg is the block-epoch subscription used by height-based + // finality synthesis. Allocated lazily after the first Spend + // event when FinalityDepth > 0, torn down when the actor exits. + blockReg *BlockRegistration + + // spendHeight records the block height the most recent Spend + // event arrived at. Zero means there is no active spend to count + // from (either we have not yet seen one, or the last one was + // reorged out). + spendHeight int32 + // ctx is the actor's internal context for cancellation, created from // context.Background() to ensure it outlives any request context. //nolint:containedctx @@ -132,12 +159,21 @@ func (a *SpendActor) handleRegisterSpend(actorCtx context.Context, "provided"), ) } + if req.NotifyActor.IsNone() && + (req.NotifyReorged.IsSome() || req.NotifyDone.IsSome()) { + return fn.Err[SpendResp]( + fmt.Errorf("spend reorg/done notifications require " + + "actor-mode NotifyActor"), + ) + } // Configure the actor with request parameters. a.outpoint = req.Outpoint a.pkScript = req.PkScript a.heightHint = req.HeightHint a.notifyActor = req.NotifyActor + a.notifyReorged = req.NotifyReorged + a.notifyDone = req.NotifyDone // Create promise for Future mode. var promise fn.Option[actor.Promise[SpendEvent]] @@ -152,10 +188,17 @@ func (a *SpendActor) handleRegisterSpend(actorCtx context.Context, // Register with the backend to receive spend notifications. We do this // before starting the goroutine so we can return an error to the - // caller if registration fails. + // caller if registration fails. The bounded timeout mirrors + // ConfActor.handleRegisterConf: a backend (LND) that is slow under + // heavy block processing load must not pin the parent Receive call + // indefinitely, since that would back-pressure the chainsource + // factory actor onto every other in-flight registration. + regCtx, regCancel := context.WithTimeout(a.ctx, 10*time.Second) + defer regCancel() + //nolint:contextcheck // actor root context owns registration lifetime registration, err := a.cfg.Backend.RegisterSpend( - a.ctx, a.outpoint, a.pkScript, a.heightHint, + regCtx, a.outpoint, a.pkScript, a.heightHint, ) if err != nil { return fn.Err[SpendResp]( @@ -191,10 +234,47 @@ func (a *SpendActor) monitorSpend() { if a.registration != nil { a.registration.Cancel() } + if a.blockReg != nil { + a.blockReg.Cancel() + } }() + log := a.logger(a.ctx) + // Monitor for spends indefinitely until cancelled or shutdown. // This allows us to catch re-org events where a spend is replaced. + var lastEvent *SpendEvent + + // reorgAware reports whether the caller opted into the multi-shot + // reorg lifecycle (at least one of NotifyReorged/NotifyDone). When + // false the watch is single-shot for backwards compatibility: the + // actor exits after the first spend, mirroring ConfActor. Without + // this gate a plain actor-mode spend watch would run forever and, with + // FinalityDepth > 0, arm a block subscription it never asked for. + reorgAware := a.notifyReorged.IsSome() || a.notifyDone.IsSome() + + // lastSeq is the highest backend forwarder sequence applied so far. + // Spend and Reorged signals arrive on separate channels and a select + // cannot order two ready channels, so we order them by the shared + // sequence instead: an event whose Seq does not exceed lastSeq lost a + // cross-channel race to a newer signal and is discarded. This makes + // the actor's view correct regardless of delivery interleaving. Seq 0 + // means the backend does not stamp sequences (it never reorgs); those + // events are always applied. + var lastSeq uint64 + + // blockEpochs is rebound when height-based finality synthesis + // arms a block subscription. Until then a nil channel keeps the + // select arm parked. + var blockEpochs <-chan *BlockEpoch + + // blockRegCh hands a finality block subscription from the off-loop + // arming goroutine back to this loop; arming guards against launching + // more than one armer at a time. See armFinalityAsync for why arming + // runs off the select loop. + blockRegCh := make(chan *BlockRegistration) + var arming bool + for { select { case spend, ok := <-a.registration.Spend: @@ -206,6 +286,16 @@ func (a *SpendActor) monitorSpend() { return } + // Discard a spend that lost a cross-channel race to a + // newer reorg: an event whose sequence does not exceed + // the highest applied is stale. + if spend.Seq != 0 && spend.Seq <= lastSeq { + continue + } + if spend.Seq > lastSeq { + lastSeq = spend.Seq + } + event, err := buildSpendEvent(spend, a) if err != nil { a.failSpend(err) @@ -215,13 +305,123 @@ func (a *SpendActor) monitorSpend() { // Deliver the event. a.deliverSpend(event) - - // In Future mode, exit after first event. In Actor - // mode, continue monitoring for re-org events. - if a.promise.IsSome() { + lastEvent = &event + a.spendHeight = event.SpendingHeight + + // Exit after the first event in Future mode, and in + // actor mode that did not opt into the reorg lifecycle + // (single-shot backwards-compatible contract). Only a + // reorg-aware actor watch keeps monitoring. + if !reorgAware || a.promise.IsSome() { return } + // Arm height-based finality synthesis on the first + // spend if requested, off the select loop so the + // bounded RegisterBlocks retries cannot stall delivery + // of Reorged/Done/ctx.Done on this watch. A nil + // blockEpochs channel keeps the synthesis arm parked + // until the registration is handed back on blockRegCh. + if a.cfg.FinalityDepth > 0 && a.blockReg == nil && + !arming { + + arming = true + a.armFinalityAsync(blockRegCh, log) + } + + case reg := <-blockRegCh: + // Finality arming completed. Clear the flag so a later + // spend can retry if this attempt failed (reg == nil); + // otherwise wire up the epoch channel. + arming = false + if reg != nil { + a.blockReg = reg + blockEpochs = reg.Epochs + } + + case seq, ok := <-a.registration.Reorged: + if !ok { + a.registration.Reorged = nil + continue + } + + // Discard a stale reorg that lost a cross-channel race + // to a newer spend. + if seq != 0 && seq <= lastSeq { + continue + } + if seq > lastSeq { + lastSeq = seq + } + + a.deliverSpendReorged(lastEvent) + + // The previous spend is no longer on the canonical + // chain. Clear the cached event so a later Done cannot + // report the reorged-out outpoint, and reset the depth + // counter so the next re-spend starts a fresh window. + lastEvent = nil + a.spendHeight = 0 + + case _, ok := <-a.registration.Done: + if !ok { + a.registration.Done = nil + continue + } + + a.deliverSpendDone(lastEvent) + + return + + case epoch, ok := <-blockEpochs: + if !ok || epoch == nil { + blockEpochs = nil + continue + } + + // Coalesce any epochs already queued behind this one + // and evaluate finality against the most recent height + // only. With rapid-fire blocks the channel can hold + // several epochs at once; processing them one per loop + // iteration would re-check the same monotonic Done + // condition repeatedly and risk synthesizing against a + // stale height. If the channel closed during the drain, + // park it so we stop selecting on it. + var closed bool + epoch, closed = drainToLatestEpoch(blockEpochs, epoch) + if closed { + blockEpochs = nil + } + + // The spendHeight==0 guard is load-bearing: a reorg + // resets spendHeight to 0 (the Reorged arm above), so + // a fresh epoch arriving before the re-spend would + // otherwise compute depth against a zero base and + // could synthesize Done prematurely. While + // spendHeight==0 there is no active spend to count + // from, so the depth comparison is meaningless. + // FinalityDepth==0 disables synthesis entirely. + if a.spendHeight == 0 || + a.cfg.FinalityDepth == 0 { + + continue + } + + depth := epoch.Height - a.spendHeight + 1 + if depth < int32(a.cfg.FinalityDepth) { + continue + } + + log.InfoS(a.ctx, "Synthesizing spend done from "+ + "height-based safety depth", + "spend_height", a.spendHeight, + "current_height", epoch.Height, + "finality_depth", int(a.cfg.FinalityDepth), + ) + a.deliverSpendDone(lastEvent) + + return + case <-a.ctx.Done(): // Actor was cancelled. a.failSpend(a.ctx.Err()) @@ -231,6 +431,35 @@ func (a *SpendActor) monitorSpend() { } } +// armFinalityAsync registers a block-epoch subscription for height-based +// finality synthesis off the actor's select loop. registerBlocksForFinality +// retries with a bounded backoff that can run for tens of seconds; doing it +// inline would block delivery of Reorged/Done/ctx.Done on this watch for the +// whole window. The registration (or nil on failure) is handed back on regCh, +// or cancelled if the actor exits before the loop reads it. The goroutine is +// tracked by the actor's wait group so Stop drains it. +func (a *SpendActor) armFinalityAsync(regCh chan<- *BlockRegistration, + log btclog.Logger) { + + a.wg.Go(func() { + reg, err := registerBlocksForFinality(a.ctx, a.cfg.Backend, log) + if err != nil { + log.WarnS(a.ctx, "Giving up on height-based finality "+ + "synthesis; spend sub-actor will rely on "+ + "backend Done", err) + reg = nil + } + + select { + case regCh <- reg: + case <-a.ctx.Done(): + if reg != nil { + reg.Cancel() + } + } + }) +} + // deliverSpend delivers a spend event to the subscriber. In Future mode, it // completes the promise. In Actor mode, it sends to the registered actor. func (a *SpendActor) deliverSpend(event SpendEvent) { @@ -249,6 +478,55 @@ func (a *SpendActor) deliverSpend(event SpendEvent) { }) } +// deliverSpendReorged delivers a spend reorg event to actor-mode +// subscribers. The correlation Outpoint is the registration's configured +// outpoint when set, since that is the identifier the caller asked us to +// watch; pkScript-only watches fall back to the outpoint carried on the +// most recent positive SpendEvent. +func (a *SpendActor) deliverSpendReorged(lastEvent *SpendEvent) { + var event SpendReorgedEvent + switch { + case a.outpoint != nil: + event.Outpoint = *a.outpoint + + case lastEvent != nil: + event.Outpoint = lastEvent.Outpoint + } + + a.notifyReorged.WhenSome( + func(ref actor.TellOnlyRef[SpendReorgedEvent]) { + log := a.logger(a.ctx) + if err := ref.Tell(a.ctx, event); err != nil { + log.WarnS( + a.ctx, + "Failed to deliver spend reorg", + err, + ) + } + }, + ) +} + +// deliverSpendDone delivers a spend finality event to actor-mode subscribers. +// Outpoint follows the same precedence as deliverSpendReorged. +func (a *SpendActor) deliverSpendDone(lastEvent *SpendEvent) { + var event SpendDoneEvent + switch { + case a.outpoint != nil: + event.Outpoint = *a.outpoint + + case lastEvent != nil: + event.Outpoint = lastEvent.Outpoint + } + + a.notifyDone.WhenSome(func(ref actor.TellOnlyRef[SpendDoneEvent]) { + log := a.logger(a.ctx) + if err := ref.Tell(a.ctx, event); err != nil { + log.WarnS(a.ctx, "Failed to deliver spend done", err) + } + }) +} + // failSpend completes the promise with an error (Future mode) or does nothing // (Actor mode - errors are not delivered in async mode). func (a *SpendActor) failSpend(err error) { diff --git a/chainsource/transform.go b/chainsource/transform.go index 16f9f164a..9e392ae62 100644 --- a/chainsource/transform.go +++ b/chainsource/transform.go @@ -33,6 +33,25 @@ func MapConfirmationEvent[Out actor.Message]( return actor.NewMapInputRef(targetRef, mapFn) } +// MapConfReorgedEvent creates a transformed TellOnlyRef that accepts +// ConfReorgedEvent and transforms it to the caller's desired output message +// type. +func MapConfReorgedEvent[Out actor.Message]( + targetRef actor.TellOnlyRef[Out], mapFn func(ConfReorgedEvent) Out, +) actor.TellOnlyRef[ConfReorgedEvent] { + + return actor.NewMapInputRef(targetRef, mapFn) +} + +// MapConfDoneEvent creates a transformed TellOnlyRef that accepts +// ConfDoneEvent and transforms it to the caller's desired output message type. +func MapConfDoneEvent[Out actor.Message]( + targetRef actor.TellOnlyRef[Out], mapFn func(ConfDoneEvent) Out, +) actor.TellOnlyRef[ConfDoneEvent] { + + return actor.NewMapInputRef(targetRef, mapFn) +} + // MapSpendEvent creates a transformed TellOnlyRef that accepts SpendEvent and // transforms it to the caller's desired output message type. This is a // convenience wrapper for the common pattern of adapting chainsource spend @@ -64,6 +83,25 @@ func MapSpendEvent[Out actor.Message]( return actor.NewMapInputRef(targetRef, mapFn) } +// MapSpendReorgedEvent creates a transformed TellOnlyRef that accepts +// SpendReorgedEvent and transforms it to the caller's desired output message +// type. +func MapSpendReorgedEvent[Out actor.Message]( + targetRef actor.TellOnlyRef[Out], mapFn func(SpendReorgedEvent) Out, +) actor.TellOnlyRef[SpendReorgedEvent] { + + return actor.NewMapInputRef(targetRef, mapFn) +} + +// MapSpendDoneEvent creates a transformed TellOnlyRef that accepts +// SpendDoneEvent and transforms it to the caller's desired output message type. +func MapSpendDoneEvent[Out actor.Message]( + targetRef actor.TellOnlyRef[Out], mapFn func(SpendDoneEvent) Out, +) actor.TellOnlyRef[SpendDoneEvent] { + + return actor.NewMapInputRef(targetRef, mapFn) +} + // MapBlockEpoch creates a transformed TellOnlyRef that accepts BlockEpoch // and transforms it to the caller's desired output message type. This is a // convenience wrapper for the common pattern of adapting chainsource block diff --git a/darepod/server.go b/darepod/server.go index b2e19a9d4..c7f5a7e2b 100644 --- a/darepod/server.go +++ b/darepod/server.go @@ -2085,6 +2085,13 @@ func (s *Server) registerChainSourceActor( chainsource.ChainSourceConfig{ Backend: s.chainBackend, System: s.actorSystem, + // Enable height-based Done synthesis at the default + // safety depth. Without this, conf/spend sub-actors + // driven through lndclient (whose Done channel is + // allocated-but-never-written) would never receive a + // finality signal, and reorg-aware consumers like + // txconfirm would leak watch state forever. + FinalityDepth: chainsource.DefaultFinalityDepth, }, ) diff --git a/harness/harness.go b/harness/harness.go index a5c9a3cd5..12c5f4258 100644 --- a/harness/harness.go +++ b/harness/harness.go @@ -9,6 +9,7 @@ import ( "bytes" "context" crand "crypto/rand" + "encoding/hex" "encoding/json" "flag" "fmt" @@ -26,8 +27,13 @@ import ( "testing" "time" + btcaddr "github.com/btcsuite/btcd/address/v2" "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chaincfg/v2" + "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/rpcclient" + "github.com/btcsuite/btcd/txscript/v2" + "github.com/btcsuite/btcd/wire/v2" "github.com/lightninglabs/darepo-client/chain" "github.com/lightninglabs/lndclient" "github.com/lightninglabs/taproot-assets/taprpc" @@ -1773,6 +1779,169 @@ func (h *Harness) BlockHeader(hash string) BlockHeader { return hdr } +// GetRawTransaction fetches the raw transaction body for txid from +// bitcoind and deserializes it into a wire.MsgTx. The transaction must +// exist either in the current chain or in bitcoind's mempool; +// `getrawtransaction` returns it via the wallet's lookup path in either +// case for a faucet-sent tx, so callers do not need to mine first. +func (h *Harness) GetRawTransaction(txid string) *wire.MsgTx { + h.T.Helper() + + res, err := h.bitcoinRPCCall("getrawtransaction", txid) + require.NoError(h.T, err, "getrawtransaction rpc failed") + + var hexStr string + err = json.Unmarshal(res, &hexStr) + require.NoError(h.T, err, "getrawtransaction unmarshal failed") + + rawBytes, err := hex.DecodeString(hexStr) + require.NoError(h.T, err, "decode raw tx hex failed") + + tx := wire.NewMsgTx(2) + err = tx.Deserialize(bytes.NewReader(rawBytes)) + require.NoError(h.T, err, "deserialize raw tx failed") + + return tx +} + +// SignedV3Tx builds a TRUC (v3) transaction that sends `amount` to +// `destPkScript`, uses one of bitcoind's wallet UTXOs as input, and +// returns it fully signed but NOT broadcast. The change output goes +// back to a fresh address from bitcoind's wallet. +// +// This helper exists for reorg systests that need to feed a v3 tx into +// txconfirm: bitcoind's `sendtoaddress` faucet path produces v2 txs, +// and txconfirm's CPFP broadcaster enforces v3/TRUC at the version +// gate. Building the v3 tx ourselves and letting txconfirm broadcast +// it is the cleanest way to exercise the full tracked-tx lifecycle on +// a freshly-mined chain entry. +// +// Fee rate is fixed at 5 sat/vB — well above any regtest min-relay-fee +// floor and small enough that the leftover change is reusable. +func (h *Harness) SignedV3Tx(destPkScript []byte, + amount btcutil.Amount) *wire.MsgTx { + + h.T.Helper() + + h.bitcoindEnsureWallet() + + // Find a confirmed wallet UTXO with enough value to cover the + // destination + change + a generous fee. + utxoTxid, utxoVout, utxoValueBTC := h.bitcoindFirstSpendableUTXO() + utxoValue := btcutil.Amount(utxoValueBTC * btcutil.SatoshiPerBitcoin) + + feeSat := btcutil.Amount(2_000) // ~5 sat/vB on a ~400 vB tx. + require.Greater( + h.T, utxoValue, amount+feeSat, + "selected UTXO not large enough for destination + fee", + ) + changeSat := utxoValue - amount - feeSat + + // Fresh change address. + changeAddrRes, err := h.bitcoinRPCCall("getnewaddress") + require.NoError(h.T, err, "getnewaddress for change failed") + var changeAddrStr string + require.NoError( + h.T, json.Unmarshal(changeAddrRes, &changeAddrStr), + "getnewaddress unmarshal failed", + ) + changeAddr, err := btcaddr.DecodeAddress( + changeAddrStr, &chaincfg.RegressionNetParams, + ) + require.NoError(h.T, err, "decode change address failed") + changePkScript, err := txscript.PayToAddrScript(changeAddr) + require.NoError(h.T, err, "derive change pkScript failed") + + // Build the unsigned v3 tx. + tx := wire.NewMsgTx(3) + prevHash, err := chainhash.NewHashFromStr(utxoTxid) + require.NoError(h.T, err, "parse selected UTXO txid") + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: *prevHash, Index: utxoVout, + }, + Sequence: wire.MaxTxInSequenceNum - 1, + }) + tx.AddTxOut(&wire.TxOut{ + Value: int64(amount), + PkScript: destPkScript, + }) + tx.AddTxOut(&wire.TxOut{ + Value: int64(changeSat), + PkScript: changePkScript, + }) + + // Hex-encode and ask bitcoind to sign. + var unsignedBuf bytes.Buffer + require.NoError( + h.T, tx.Serialize(&unsignedBuf), + "serialize unsigned v3 tx", + ) + unsignedHex := hex.EncodeToString(unsignedBuf.Bytes()) + + signRes, err := h.bitcoinRPCCall( + "signrawtransactionwithwallet", unsignedHex, + ) + require.NoError(h.T, err, "signrawtransactionwithwallet failed") + + var signResult struct { + Hex string `json:"hex"` + Complete bool `json:"complete"` + } + require.NoError( + h.T, json.Unmarshal(signRes, &signResult), + "signrawtransactionwithwallet unmarshal failed", + ) + require.True( + h.T, signResult.Complete, "tx signing incomplete: %s", + signResult.Hex, + ) + + signedBytes, err := hex.DecodeString(signResult.Hex) + require.NoError(h.T, err, "decode signed v3 tx hex failed") + + signed := wire.NewMsgTx(3) + require.NoError( + h.T, + signed.Deserialize( + bytes.NewReader(signedBytes), + ), + "deserialize signed v3 tx failed", + ) + require.Equal( + h.T, int32(3), signed.Version, + "signing must preserve v3 version", + ) + + return signed +} + +// bitcoindFirstSpendableUTXO picks the first confirmed wallet UTXO via +// `listunspent` and returns its txid, vout, and amount in BTC. +func (h *Harness) bitcoindFirstSpendableUTXO() (string, uint32, float64) { + h.T.Helper() + + // Restrict to confirmed and spendable; bitcoind defaults are + // already minconf=1, maxconf=9999999. + res, err := h.bitcoinRPCCall("listunspent") + require.NoError(h.T, err, "listunspent rpc failed") + + var utxos []struct { + Txid string `json:"txid"` + Vout uint32 `json:"vout"` + Amount float64 `json:"amount"` + } + require.NoError( + h.T, json.Unmarshal(res, &utxos), + "listunspent unmarshal failed", + ) + require.NotEmpty(h.T, utxos, "no spendable wallet UTXOs") + + first := utxos[0] + + return first.Txid, first.Vout, first.Amount +} + // Faucet funds a test address by sending the specified amount from bitcoind's // default wallet, creating unconfirmed UTXOs for tests to spend. This mimics // external funding without requiring manual transaction construction. diff --git a/systest/reorg_test.go b/systest/reorg_test.go new file mode 100644 index 000000000..bdb19a64b --- /dev/null +++ b/systest/reorg_test.go @@ -0,0 +1,244 @@ +//go:build systest + +package systest + +import ( + "crypto/sha256" + "testing" + "time" + + btcaddr "github.com/btcsuite/btcd/address/v2" + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chaincfg/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/txscript/v2" + "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/chainsource" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" +) + +// reorgSystestEventTimeout is the per-step deadline used by the +// chainsource reorg systests. The chain-notification pipeline runs +// over gRPC to a real lnd instance, so the timeout is generous enough +// to absorb container startup variance and notifier wake-up latency. +const reorgSystestEventTimeout = 30 * time.Second + +// TestChainSourceConfReorgRoundTrip drives a real bitcoind reorg +// through the full chainsource pipeline: +// +// lnd chainntnfs (in-process) +// -> lndclient gRPC (WithReOrgChan) +// -> chainbackends.LndClientChainNotifier (bridge) +// -> chainbackends.LNDBackend (multi-shot forwarder) +// -> chainsource.ConfActor (reorg-aware mode) +// -> test actor refs +// +// The flow is: +// +// 1. Register a reorg-aware confirmation watch on a synthetic P2WPKH +// pkScript whose txid we know once we faucet to it. +// 2. Faucet + mine one block. Assert ConfirmationEvent arrives with +// the expected (txid, blockHeight, blockHash). +// 3. Drive a 1-block reorg via the harness helper, which invalidates +// the confirmation block and mines a strictly longer (2-block) +// replacement branch. Bitcoind preserves the original tx in its +// mempool, so the transaction re-confirms in the new chain at a +// different block hash (and potentially the same height). +// 4. Assert ConfReorgedEvent arrives. +// 5. Assert a fresh ConfirmationEvent arrives with the new chain's +// block hash (NOT the original one), demonstrating that lnd's +// chainntnfs dispatched a re-confirmation after the reorg and +// that every layer above it propagated the multi-shot signal +// correctly. +// +// This is the systest-level oracle for "the reorg-aware pipeline +// actually works over the real gRPC transport". The unit tests in +// chainsource/reorg_test.go and chainbackends/lnd_reorg_test.go prove +// the same lifecycle against mocks, but they cannot prove that +// lndclient.WithReOrgChan fires when wired to real lnd. Only this +// test does. +func TestChainSourceConfReorgRoundTrip(t *testing.T) { + ParallelN(t) + + h := NewSysTestHarness(t) + ctx := h.Context() + + // Spawn a real chainsource actor over the harness's LND. + chainSource := h.NewChainSourceActor() + + // Build a synthetic P2WPKH address from a deterministic per-test + // pubkey hash. The address does not need a controllable key (we + // never spend it in this test); we just need a known pkScript so + // we can register a confirmation watch. + pubKeyHash := sha256.Sum256([]byte(t.Name())) + addr, err := btcaddr.NewAddressWitnessPubKeyHash( + pubKeyHash[:20], &chaincfg.RegressionNetParams, + ) + require.NoError(t, err, "build synthetic P2WPKH address") + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err, "derive pkScript for synthetic address") + + // Wire test refs for each event variant. The Reorged ref also + // has to be set for NotifyDone-or-NotifyReorged admission to flip + // the sub-actor into multi-shot mode; we leave Done unwired + // because the lndclient transport does not synthesize a Done + // signal so it would never fire over a real lnd run anyway. + confRef := actor.NewChannelTellOnlyRef[chainsource.ConfirmationEvent]( + "systest-conf", 8, + ) + reorgRef := actor.NewChannelTellOnlyRef[chainsource.ConfReorgedEvent]( + "systest-conf-reorged", 8, + ) + + // Register the watch BEFORE the tx hits the mempool so we + // exercise the live-detection path rather than the + // historical-backfill path. + heightHint := h.Harness.BlockCount() + amount := btcutil.Amount(btcutil.SatoshiPerBitcoin / 100) + + // We need the txid up front, which means faucet first, then + // register, then mine. The watch is on the txid + pkScript pair + // so the ordering between mempool entry and registration is OK; + // what must NOT happen is that we mine the block before + // registering, because lnd's notifier would then dispatch + // historical confirmation state and our test would be racing two + // delivery paths. + txidStr := h.Harness.Faucet(addr.String(), amount) + txid, err := chainhash.NewHashFromStr(txidStr) + require.NoError(t, err, "parse faucet txid") + + confNotify := actor.TellOnlyRef[chainsource.ConfirmationEvent]( + confRef, + ) + reorgNotify := actor.TellOnlyRef[chainsource.ConfReorgedEvent]( + reorgRef, + ) + + regResp := chainSource.Ask(ctx, &chainsource.RegisterConfRequest{ + CallerID: "test-reorg-conf-" + txidStr, + Txid: txid, + PkScript: pkScript, + TargetConfs: 1, + HeightHint: heightHint, + NotifyActor: fn.Some(confNotify), + NotifyReorged: fn.Some(reorgNotify), + }).Await(ctx) + require.True(t, regResp.IsOk(), "register reorg-aware conf watch") + resp, err := regResp.Unpack() + require.NoError(t, err) + _, ok := resp.(*chainsource.RegisterConfResponse) + require.True(t, ok, "unexpected register response type") + + // 1. Mine the block that confirms the faucet tx. + originalBlocks := h.Harness.Generate(1) + require.Len(t, originalBlocks, 1) + originalBlock := originalBlocks[0] + + originalHash, err := chainhash.NewHashFromStr(originalBlock.Hash) + require.NoError(t, err, "parse original block hash") + + // 2. Assert the first ConfirmationEvent. + firstConf := awaitConfEvent(t, confRef) + require.Equal(t, *txid, firstConf.Txid, "first conf txid mismatch") + require.Equal( + t, int32(originalBlock.Height), firstConf.BlockHeight, + "first conf block height should match the mined block", + ) + require.Equal( + t, *originalHash, firstConf.BlockHash, + "first conf block hash should match the mined block", + ) + t.Logf( + "first ConfirmationEvent: txid=%s height=%d hash=%s", + firstConf.Txid, firstConf.BlockHeight, firstConf.BlockHash, + ) + + // 3. Drive a reorg: invalidate the conf block, mine a strictly + // longer replacement branch. The harness Reorg helper waits for + // lnd's chain sync to catch up before returning. + reorg := h.Harness.Reorg(1, 2) + require.Equal( + t, originalBlock.Hash, reorg.Disconnected[0].Hash, + "the reorg should have disconnected the conf block", + ) + require.Len(t, reorg.Connected, 2) + t.Logf( + "reorg: disconnected=%d connected=%d fork=%d", + len(reorg.Disconnected), len(reorg.Connected), + reorg.ForkPoint.Height, + ) + + // 4. Assert the ConfReorgedEvent. Lnd's chainntnfs notifier + // dispatches NegativeConf for the disconnected confirmation + // asynchronously after processing the disconnect; the gRPC + // transport adds a further hop, so this can take longer than + // the initial confirmation event. + reorgEvt := awaitReorgEvent(t, reorgRef) + require.Equal(t, *txid, reorgEvt.Txid, "reorg event txid mismatch") + t.Logf("ConfReorgedEvent: txid=%s", reorgEvt.Txid) + + // 5. Assert a fresh ConfirmationEvent for the same tx, now in + // the replacement chain. Bitcoind keeps the tx in mempool across + // the invalidate, so generatetoaddress picks it back up on the + // first new block. The new block hash MUST differ from the + // original; the height may or may not match depending on where + // the tx landed in the replacement branch. + secondConf := awaitConfEvent(t, confRef) + require.Equal(t, *txid, secondConf.Txid, "re-conf txid mismatch") + require.NotEqual( + t, firstConf.BlockHash, secondConf.BlockHash, + "re-confirmation must arrive in a new block", + ) + t.Logf( + "second ConfirmationEvent: txid=%s height=%d hash=%s", + secondConf.Txid, secondConf.BlockHeight, secondConf.BlockHash, + ) + + // Sanity: the new block hash must be one of the harness-reported + // connected blocks. chainhash.Hash.String() renders the + // canonical big-endian hex form that bitcoind's RPCs emit, so + // the strings can be compared directly. + connectedHashes := make(map[string]struct{}, len(reorg.Connected)) + for _, blk := range reorg.Connected { + connectedHashes[blk.Hash] = struct{}{} + } + require.Contains( + t, connectedHashes, secondConf.BlockHash.String(), + "re-confirmation block must belong to the replacement branch", + ) +} + +// awaitConfEvent reads a single ConfirmationEvent from the test ref +// with a generous deadline, failing the test on timeout. +func awaitConfEvent(t *testing.T, + ref *actor.ChannelTellOnlyRef[chainsource.ConfirmationEvent], +) chainsource.ConfirmationEvent { + + t.Helper() + + evt, ok := ref.AwaitMessage(reorgSystestEventTimeout) + require.True( + t, ok, "timeout waiting for ConfirmationEvent (%s)", + reorgSystestEventTimeout, + ) + + return evt +} + +// awaitReorgEvent reads a single ConfReorgedEvent from the test ref +// with a generous deadline, failing the test on timeout. +func awaitReorgEvent(t *testing.T, + ref *actor.ChannelTellOnlyRef[chainsource.ConfReorgedEvent], +) chainsource.ConfReorgedEvent { + + t.Helper() + + evt, ok := ref.AwaitMessage(reorgSystestEventTimeout) + require.True( + t, ok, "timeout waiting for ConfReorgedEvent (%s)", + reorgSystestEventTimeout, + ) + + return evt +} diff --git a/systest/systest.go b/systest/systest.go index bfdc46a03..c2f264876 100644 --- a/systest/systest.go +++ b/systest/systest.go @@ -212,6 +212,13 @@ func (h *SysTestHarness) NewChainSourceActor() actor.ActorRef[ chainsource.ChainSourceConfig{ Backend: backend, System: h.actorSystem, + // Mirror the production wiring so systests that + // register reorg-aware conf/spend watches do not + // leak per-watch sub-actors past test teardown: + // the lndclient backend never writes the upstream + // Done channel, so without height-based synthesis + // reorg-aware watches would stay open forever. + FinalityDepth: chainsource.DefaultFinalityDepth, }.WithLogger( h.SubLogger(chainsource.Subsystem), ), diff --git a/systest/txconfirm_reorg_test.go b/systest/txconfirm_reorg_test.go new file mode 100644 index 000000000..e52bbae20 --- /dev/null +++ b/systest/txconfirm_reorg_test.go @@ -0,0 +1,221 @@ +//go:build systest + +package systest + +import ( + "context" + "crypto/sha256" + "testing" + "time" + + btcaddr "github.com/btcsuite/btcd/address/v2" + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chaincfg/v2" + "github.com/btcsuite/btcd/txscript/v2" + "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/txconfirm" + "github.com/stretchr/testify/require" +) + +// txConfirmSystestEventTimeout is the per-step deadline used by the +// txconfirm reorg systest. Generous because the chain-notification +// pipeline runs over gRPC to a real lnd instance plus this test waits +// for txconfirm's tracked-tx FSM to forward each event. +const txConfirmSystestEventTimeout = 30 * time.Second + +// recordingNotificationRef captures every Notification delivered to a +// txconfirm subscriber so the test can assert on the order and shape +// of the lifecycle without racing concurrent delivery. +type recordingNotificationRef struct { + id string + msgs chan txconfirm.Notification +} + +func newRecordingNotificationRef(id string) *recordingNotificationRef { + return &recordingNotificationRef{ + id: id, + msgs: make(chan txconfirm.Notification, 16), + } +} + +// ID returns the subscriber identifier. +func (r *recordingNotificationRef) ID() string { + return r.id +} + +// Tell records the inbound notification on the channel for the test to +// consume. +func (r *recordingNotificationRef) Tell(_ context.Context, + msg txconfirm.Notification) error { + + r.msgs <- msg + + return nil +} + +// await pulls the next notification or fails the test on timeout. +func (r *recordingNotificationRef) await(t *testing.T) txconfirm.Notification { + t.Helper() + + select { + case msg := <-r.msgs: + return msg + + case <-time.After(txConfirmSystestEventTimeout): + t.Fatalf("timeout waiting for txconfirm notification (%s)", + txConfirmSystestEventTimeout) + + return nil + } +} + +// TestTxConfirmReorgRoundTrip drives a real bitcoind reorg through +// the full txconfirm pipeline: +// +// lnd chainntnfs (in-process) +// -> lndclient gRPC (WithReOrgChan) +// -> chainbackends.LndClientChainNotifier (bridge) +// -> chainbackends.LNDBackend (multi-shot forwarder) +// -> chainsource.ConfActor (reorg-aware mode + finality synth) +// -> txconfirm.TxBroadcasterActor (tracked-tx FSM) +// -> recording subscriber +// +// This is the systest-level oracle for the layer that unroll consumes. +// The chainsource-level systest (TestChainSourceConfReorgRoundTrip) +// proves the chain-event plumbing; this one proves the tracked-tx FSM +// transitions Confirmed -> AwaitingConfirmation -> Confirmed correctly +// on real reorgs and that subscribers see TxConfirmed -> TxReorged -> +// TxConfirmed in order, with TxFinalized arriving once the +// height-based safety depth is reached. +// +// Full daemon-level end-to-end coverage (the VTXOUnrollActor walking +// a real proof through a real wallet under a real reorg) belongs in +// itest; the unroll FSM's reducer behavior on these events is already +// covered by unit tests in unroll/reorg_safety_test.go. +func TestTxConfirmReorgRoundTrip(t *testing.T) { + ParallelN(t) + + h := NewSysTestHarness(t) + ctx := h.Context() + + chainSource := h.NewChainSourceActor() + + // Spawn a txconfirm actor over the real chainsource. Wallet is + // nil because the faucet tx is non-anchor and we never trigger + // CPFP fee-input selection in this test. + txconfBehavior := txconfirm.NewTxBroadcasterActor(txconfirm.Config{ + ChainSource: chainSource, + }) + txconfInstance := actor.NewActor(actor.ActorConfig[ + txconfirm.Msg, txconfirm.Resp, + ]{ + ID: "txconfirm-systest", + Behavior: txconfBehavior, + MailboxSize: 64, + }) + txconfBehavior.SetSelfRef(txconfInstance.TellRef()) + txconfInstance.Start() + t.Cleanup(txconfInstance.Stop) + + // Synthetic watched address: deterministic P2WPKH derived from + // the test name. We faucet to it so we have a known txid and + // pkScript for txconfirm to track. The address is never spent. + pubKeyHash := sha256.Sum256([]byte(t.Name())) + addr, err := btcaddr.NewAddressWitnessPubKeyHash( + pubKeyHash[:20], &chaincfg.RegressionNetParams, + ) + require.NoError(t, err, "build synthetic P2WPKH address") + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err, "derive pkScript for synthetic address") + + heightHint := h.Harness.BlockCount() + + // txconfirm's CPFP broadcaster enforces v3/TRUC at the version + // gate, and the standard bitcoind faucet returns v2 txs. Build a + // v3 tx that pays our synthetic address from bitcoind's wallet, + // sign it, but do NOT broadcast — txconfirm will handle the + // first broadcast on EnsureConfirmedReq. + signedTx := h.Harness.SignedV3Tx( + pkScript, btcutil.Amount(btcutil.SatoshiPerBitcoin/100), + ) + txidVal := signedTx.TxHash() + txid := &txidVal + t.Logf("constructed v3 tx: txid=%s", txid) + + subscriber := newRecordingNotificationRef("txconfirm-sub") + var subRef actor.TellOnlyRef[txconfirm.Notification] = subscriber + + // Register the EnsureConfirmedReq BEFORE mining so we exercise + // the live-detection path and the tracked-tx FSM walks the full + // Broadcasting -> AwaitingConfirmation -> Confirmed transitions. + ensureResp, err := txconfInstance.Ref().Ask( + ctx, &txconfirm.EnsureConfirmedReq{ + Tx: signedTx, + ConfirmationPkScript: pkScript, + Label: "systest-reorg", + HeightHint: heightHint, + TargetConfs: 1, + Subscriber: subRef, + }, + ).Await(ctx).Unpack() + require.NoError(t, err, "EnsureConfirmedReq failed") + require.IsType(t, &txconfirm.EnsureConfirmedResp{}, ensureResp) + + // 1. Mine the block that confirms the faucet tx. + originalBlocks := h.Harness.Generate(1) + require.Len(t, originalBlocks, 1) + originalBlock := originalBlocks[0] + + // 2. Expect TxConfirmed. + first := subscriber.await(t) + firstConfirmed, ok := first.(*txconfirm.TxConfirmed) + require.True( + t, ok, "first notification must be TxConfirmed, got %T", first, + ) + require.Equal(t, *txid, firstConfirmed.Txid) + require.Equal( + t, int32(originalBlock.Height), firstConfirmed.BlockHeight, + "first conf block height should match the mined block", + ) + t.Logf( + "first TxConfirmed: txid=%s height=%d", firstConfirmed.Txid, + firstConfirmed.BlockHeight, + ) + + // 3. Reorg the conf block out, mine a strictly longer + // replacement branch, wait for lnd to chain-sync. + reorg := h.Harness.Reorg(1, 2) + require.Equal( + t, originalBlock.Hash, reorg.Disconnected[0].Hash, + "the reorg should have disconnected the conf block", + ) + require.Len(t, reorg.Connected, 2) + t.Logf( + "reorg: disconnected=%d connected=%d fork=%d", + len(reorg.Disconnected), len(reorg.Connected), + reorg.ForkPoint.Height, + ) + + // 4. Expect TxReorged. + second := subscriber.await(t) + reorgedMsg, ok := second.(*txconfirm.TxReorged) + require.True( + t, ok, "second notification must be TxReorged, got %T", second, + ) + require.Equal(t, *txid, reorgedMsg.Txid) + t.Logf("TxReorged: txid=%s", reorgedMsg.Txid) + + // 5. Expect a fresh TxConfirmed on the replacement chain. The + // faucet tx stays in mempool across the invalidate so it + // re-confirms in the first new block. + third := subscriber.await(t) + secondConfirmed, ok := third.(*txconfirm.TxConfirmed) + require.True( + t, ok, "third notification must be TxConfirmed, got %T", third, + ) + require.Equal(t, *txid, secondConfirmed.Txid) + t.Logf( + "second TxConfirmed: txid=%s height=%d", secondConfirmed.Txid, + secondConfirmed.BlockHeight, + ) +} diff --git a/txconfirm/AGENTS.md b/txconfirm/AGENTS.md index 6af44f7e9..93204e3c2 100644 --- a/txconfirm/AGENTS.md +++ b/txconfirm/AGENTS.md @@ -2,54 +2,70 @@ ## 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. +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 reorg-aware lifecycle of `TxConfirmed`, +optional `TxReorged` (if the confirming block is reorged out), back to +`TxConfirmed` on re-mining, and a terminal `TxFinalized` once the +confirmation matures past the backend's reorg-safety depth. Failures land +on a terminal `TxFailed`. 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 lifecycle notifications. ## Key Types -For field-level detail, use `go doc github.com/lightninglabs/darepo-client/txconfirm.`. - -- `TxBroadcasterActor` (`actor.go`) — message-driven orchestrator. Holds +- `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) into per-txid - transitions. -- `CPFPBroadcaster` (`broadcaster.go`) — actor-free helper for broadcast - mechanics: direct submission for txs without anchors, CPFP child - construction for anchor parents, fee estimation, script-aware child - vsize estimation, fee-input selection / reservation, BIP-125 Rule 3/4 - floor enforcement, and optional `TestMempoolAccept` preflight. Usable - standalone for callers needing only the broadcast primitives. **Not - safe for concurrent use** — `TxBroadcasterActor` serializes access. - Constructed via `NewCPFPBroadcaster(BroadcasterConfig)`. -- `BroadcasterConfig` — `ChainSource`, `Wallet`, optional `Log`, - `MaxFeeRateSatPerVByte` (default 100), - `IncrementalRelayFeeSatPerVByte` (default 1; should match the node's - `-incrementalrelayfee`), `PreSubmitTestMempoolAccept` (opt-in). -- `BroadcastRequest` / `BroadcastResult` — `CPFPBroadcaster.Submit` - I/O. Request carries the signed parent `Tx` and a `Label`. -- Exported helpers usable standalone: `BuildCPFPChild`, - `EstimatePackageFee`, `EstimateWeight`, `SelectFeeInput`. -- `Wallet` — interface required by the broadcaster: `ListUnspent`, + 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. Not safe for concurrent use; the outer `TxBroadcasterActor` + serializes access. Constructed via `NewCPFPBroadcaster(BroadcasterConfig)`. +- `BroadcasterConfig` — Configuration for `CPFPBroadcaster`: `ChainSource`, + `Wallet`, optional `Log`, `MaxFeeRateSatPerVByte` (default 100), + `IncrementalRelayFeeSatPerVByte` (default 1, should match the node's + `-incrementalrelayfee`), and `PreSubmitTestMempoolAccept` (opt-in + `testmempoolaccept` preflight before every broadcast). +- `BroadcastRequest` / `BroadcastResult` — Input/output for + `CPFPBroadcaster.Submit`. Request carries the fully signed parent `Tx` + and a `Label` for logging. +- `BuildCPFPChild` — Exported helper that constructs the CPFP fee-paying + child for a given v3 parent anchor outpoint, fee input, change script, + and fee amount. Useful for callers that need the broadcast primitive + without the full actor harness. +- `EstimatePackageFee`, `EstimateWeight`, `SelectFeeInput` — Exported + helpers for fee estimation and fee-input selection, usable standalone. +- `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 `TargetConfs`, `ConfirmationPkScript`, - and a subscriber. -- `CancelInterestReq` / `CancelInterestResp` — drop a subscriber; the - last subscriber's cancel tears down tracking. -- `TxConfirmed` / `TxFailed` — terminal `Notification` types delivered - to each subscriber. -- `TxState` — `New`, `Broadcasting`, `AwaitingConfirmation`, - `FeeBumping`, `Confirmed`, `Failed`. `Broadcasting` covers BOTH the - initial attempt and the "reached no mempool, retrying" case; - `AwaitingConfirmation` is reported only once the tx (or a redundant - parent) is actually in a mempool. +- `EnsureConfirmedReq` / `EnsureConfirmedResp` — Public Ask API: register + interest in a txid with a `TargetConfs`, `ConfirmationPkScript`, and a + subscriber that receives the lifecycle notifications. +- `CancelInterestReq` / `CancelInterestResp` — Public Ask API: drop a + subscriber; the last subscriber's cancel also tears down tracking. +- `TxConfirmed` / `TxReorged` / `TxFinalized` / `TxFailed` — `Notification` + types delivered to each subscriber. `TxConfirmed` and `TxReorged` are + reversible (the entry can move between Confirmed and AwaitingConfirmation + any number of times before finality); only `TxFinalized` and `TxFailed` + are terminal. Reversible deliveries are fire-and-forget (per-subscriber + goroutine, bounded by `reversibleNotifyTimeout`) so a slow durable + subscriber cannot pin the actor loop; dropped reversible events are + recoverable from the next lifecycle transition. Terminal deliveries + retain the subscriber on failure and retry on later actor ticks. +- `TxState` (`New`, `Broadcasting`, `AwaitingConfirmation`, `FeeBumping`, + `Confirmed`, `Finalized`, `Failed`) — Public view of the per-txid + protofsm state. Only `Finalized` and `Failed` are terminal; `Confirmed` + is reorg-reversible. `Broadcasting` covers BOTH the initial attempt and + the "reached no mempool, retrying" case; `AwaitingConfirmation` is + reported only once the tx (or a redundant parent) is actually in a + mempool. - Sentinels: `ErrNonTRUCParent`, `ErrCPFPFeeInputUnavailable`, `ErrEnsureParamsMismatch`, `ErrFeeInputProducesDust`. - `Config.BroadcastFailureAlertThreshold` — consecutive no-mempool @@ -58,24 +74,36 @@ For field-level detail, use `go doc github.com/lightninglabs/darepo-client/txcon ## Relationships -- **Depends on**: `baselib/actor`, `baselib/protofsm`, `chainsource` - (confirmation watches, block epochs, broadcast, package submission, - fee estimation, preflight), `wallet` (`Utxo`, `OutputLeaser`, - `LockID`), `lib/tx/arktx` (`TxVersion` constant, `IsAnchorOutput`). -- **Depended on by**: `unroll`, `btcwbackend` (fee-input selection - helper), `darepod`, `db`. -- **Sends → `chainsource`** (Ask): `BestHeightRequest`, - `SubscribeBlocksRequest`, `RegisterConfRequest`, - `UnregisterConfRequest`, `BroadcastTxRequest`, - `SubmitPackageRequest`, `TestMempoolAcceptRequest`, - `FeeEstimateRequest`. -- **Sends → `Wallet`** (direct): `ListUnspent`, `NewWalletPkScript`, - `FinalizePsbt`, `LeaseOutput`, `ReleaseOutput`. -- **Sends → caller subscriber** (Tell): `TxConfirmed`, `TxFailed`. -- **Receives ← `chainsource`** (mapped Tell refs): `BlockEpoch` → - `blockEpochObservedMsg`; `ConfirmationEvent` → - `confirmationObservedMsg`. -- **Receives ← API**: `EnsureConfirmedReq`, `CancelInterestReq`. +- **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**: `unroll` (plugs `TxBroadcasterActor` and + `CPFPBroadcaster` into boarding sweep / unilateral exit flows), + `btcwbackend` (fee-input selection helper), `darepod` (wiring), + `db` (schema references). +- **Sends**: + - → `chainsource` (Ask): `BestHeightRequest`, `SubscribeBlocksRequest`, + `RegisterConfRequest` (reorg-aware mode, with `NotifyReorged` and + `NotifyDone` mapped refs), `UnregisterConfRequest`, + `BroadcastTxRequest`, `SubmitPackageRequest`, + `TestMempoolAcceptRequest`, `FeeEstimateRequest`. + - → `Wallet` (direct call): `ListUnspent`, `NewWalletPkScript`, + `FinalizePsbt`, `LeaseOutput`, `ReleaseOutput`. + - → Caller-supplied subscriber (Tell): `TxConfirmed`, `TxReorged`, + `TxFinalized`, `TxFailed`. +- **Receives**: + - ← `chainsource` (via mapped Tell refs): `BlockEpoch` (re-wrapped as + `blockEpochObservedMsg`), `ConfirmationEvent` (re-wrapped as + `confirmationObservedMsg`), `ConfReorgedEvent` (re-wrapped as + `confirmationReorgedMsg`), `ConfDoneEvent` (re-wrapped as + `confirmationDoneMsg`). + - ← API: `EnsureConfirmedReq`, `CancelInterestReq`. ## Invariants @@ -107,22 +135,24 @@ For field-level detail, use `go doc github.com/lightninglabs/darepo-client/txcon parent txid. - **Per-parent fee-input reservation**: each parent txid reserves the wallet UTXO(s) it has committed to. Reservations survive block - boundaries and release only on eviction (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 — 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. + 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, …). Unknown script classes - fall back to P2WKH (which over-estimates for P2TR, safe for Rule 4). + 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`) belt-and-suspenders; the anchor input keeps the + 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 @@ -130,22 +160,40 @@ For field-level detail, use `go doc github.com/lightninglabs/darepo-client/txcon 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. -- **Terminal eviction**: on Confirmed or Failed, the actor delivers - terminal notifications first. If a subscriber is slow or transiently - fails, the tracked entry is retained without a conf watch and - retried on later actor ticks. Once every subscriber is notified or - cancelled, the actor 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 - after eviction re-register from scratch and receive an immediate - `TxConfirmed` via the normal path if the tx is already on chain. + txid+script keyed service-actor lookup resolves symmetrically; one + conf sub-actor per tracked tx. +- **Reversible notifications are fire-and-forget**: `notifyConfirmed` + and `notifyReorged` dispatch each subscriber on its own goroutine + with a bounded `reversibleNotifyTimeout`. The actor does NOT wait + for delivery to complete and does NOT retry on failure, because the + next lifecycle event (re-`TxConfirmed` after a `TxReorged`, + `TxFinalized`, or eventual `TxFailed`) supersedes any dropped + reversible delivery. The same fire-and-forget path is used when + `attachExistingSubscriber` replays the cached confirmation to a + late-arriving subscriber. +- **Terminal eviction**: on `Finalized` or `Failed`, the actor first + delivers terminal notifications synchronously (via the goroutine + + timeout + idempotent-retry pattern in `notifyOneTerminal`). If a + subscriber is slow or transiently fails, the tracked entry is retained + without a conf watch and retried on later actor ticks. Once every + subscriber has been notified or cancelled, the actor 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. +- **Backend Done in non-Confirmed states is dropped**: `confirmationDoneMsg` + for an entry that is not in `TxStateConfirmed` (e.g. mid-reorg + AwaitingConfirmation) is logged at warn and dropped rather than advancing + to Finalized, because finalization from a non-confirmed state is + semantically incorrect. The realistic backends (chainntnfs, lndclient) + do not fire Done during reorg gaps; if this warn ever fires in + production, the right follow-up is to re-register the conf watch from + txconfirm rather than relax the guard. ## Deep Docs -- [`doc.go`](doc.go) — Package-level overview covering architecture, - lifecycle, CPFP correctness invariants, PSBT finalization, - service-key round trip, and eviction. +- [`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 index 6af44f7e9..93204e3c2 100644 --- a/txconfirm/CLAUDE.md +++ b/txconfirm/CLAUDE.md @@ -2,54 +2,70 @@ ## 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. +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 reorg-aware lifecycle of `TxConfirmed`, +optional `TxReorged` (if the confirming block is reorged out), back to +`TxConfirmed` on re-mining, and a terminal `TxFinalized` once the +confirmation matures past the backend's reorg-safety depth. Failures land +on a terminal `TxFailed`. 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 lifecycle notifications. ## Key Types -For field-level detail, use `go doc github.com/lightninglabs/darepo-client/txconfirm.`. - -- `TxBroadcasterActor` (`actor.go`) — message-driven orchestrator. Holds +- `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) into per-txid - transitions. -- `CPFPBroadcaster` (`broadcaster.go`) — actor-free helper for broadcast - mechanics: direct submission for txs without anchors, CPFP child - construction for anchor parents, fee estimation, script-aware child - vsize estimation, fee-input selection / reservation, BIP-125 Rule 3/4 - floor enforcement, and optional `TestMempoolAccept` preflight. Usable - standalone for callers needing only the broadcast primitives. **Not - safe for concurrent use** — `TxBroadcasterActor` serializes access. - Constructed via `NewCPFPBroadcaster(BroadcasterConfig)`. -- `BroadcasterConfig` — `ChainSource`, `Wallet`, optional `Log`, - `MaxFeeRateSatPerVByte` (default 100), - `IncrementalRelayFeeSatPerVByte` (default 1; should match the node's - `-incrementalrelayfee`), `PreSubmitTestMempoolAccept` (opt-in). -- `BroadcastRequest` / `BroadcastResult` — `CPFPBroadcaster.Submit` - I/O. Request carries the signed parent `Tx` and a `Label`. -- Exported helpers usable standalone: `BuildCPFPChild`, - `EstimatePackageFee`, `EstimateWeight`, `SelectFeeInput`. -- `Wallet` — interface required by the broadcaster: `ListUnspent`, + 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. Not safe for concurrent use; the outer `TxBroadcasterActor` + serializes access. Constructed via `NewCPFPBroadcaster(BroadcasterConfig)`. +- `BroadcasterConfig` — Configuration for `CPFPBroadcaster`: `ChainSource`, + `Wallet`, optional `Log`, `MaxFeeRateSatPerVByte` (default 100), + `IncrementalRelayFeeSatPerVByte` (default 1, should match the node's + `-incrementalrelayfee`), and `PreSubmitTestMempoolAccept` (opt-in + `testmempoolaccept` preflight before every broadcast). +- `BroadcastRequest` / `BroadcastResult` — Input/output for + `CPFPBroadcaster.Submit`. Request carries the fully signed parent `Tx` + and a `Label` for logging. +- `BuildCPFPChild` — Exported helper that constructs the CPFP fee-paying + child for a given v3 parent anchor outpoint, fee input, change script, + and fee amount. Useful for callers that need the broadcast primitive + without the full actor harness. +- `EstimatePackageFee`, `EstimateWeight`, `SelectFeeInput` — Exported + helpers for fee estimation and fee-input selection, usable standalone. +- `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 `TargetConfs`, `ConfirmationPkScript`, - and a subscriber. -- `CancelInterestReq` / `CancelInterestResp` — drop a subscriber; the - last subscriber's cancel tears down tracking. -- `TxConfirmed` / `TxFailed` — terminal `Notification` types delivered - to each subscriber. -- `TxState` — `New`, `Broadcasting`, `AwaitingConfirmation`, - `FeeBumping`, `Confirmed`, `Failed`. `Broadcasting` covers BOTH the - initial attempt and the "reached no mempool, retrying" case; - `AwaitingConfirmation` is reported only once the tx (or a redundant - parent) is actually in a mempool. +- `EnsureConfirmedReq` / `EnsureConfirmedResp` — Public Ask API: register + interest in a txid with a `TargetConfs`, `ConfirmationPkScript`, and a + subscriber that receives the lifecycle notifications. +- `CancelInterestReq` / `CancelInterestResp` — Public Ask API: drop a + subscriber; the last subscriber's cancel also tears down tracking. +- `TxConfirmed` / `TxReorged` / `TxFinalized` / `TxFailed` — `Notification` + types delivered to each subscriber. `TxConfirmed` and `TxReorged` are + reversible (the entry can move between Confirmed and AwaitingConfirmation + any number of times before finality); only `TxFinalized` and `TxFailed` + are terminal. Reversible deliveries are fire-and-forget (per-subscriber + goroutine, bounded by `reversibleNotifyTimeout`) so a slow durable + subscriber cannot pin the actor loop; dropped reversible events are + recoverable from the next lifecycle transition. Terminal deliveries + retain the subscriber on failure and retry on later actor ticks. +- `TxState` (`New`, `Broadcasting`, `AwaitingConfirmation`, `FeeBumping`, + `Confirmed`, `Finalized`, `Failed`) — Public view of the per-txid + protofsm state. Only `Finalized` and `Failed` are terminal; `Confirmed` + is reorg-reversible. `Broadcasting` covers BOTH the initial attempt and + the "reached no mempool, retrying" case; `AwaitingConfirmation` is + reported only once the tx (or a redundant parent) is actually in a + mempool. - Sentinels: `ErrNonTRUCParent`, `ErrCPFPFeeInputUnavailable`, `ErrEnsureParamsMismatch`, `ErrFeeInputProducesDust`. - `Config.BroadcastFailureAlertThreshold` — consecutive no-mempool @@ -58,24 +74,36 @@ For field-level detail, use `go doc github.com/lightninglabs/darepo-client/txcon ## Relationships -- **Depends on**: `baselib/actor`, `baselib/protofsm`, `chainsource` - (confirmation watches, block epochs, broadcast, package submission, - fee estimation, preflight), `wallet` (`Utxo`, `OutputLeaser`, - `LockID`), `lib/tx/arktx` (`TxVersion` constant, `IsAnchorOutput`). -- **Depended on by**: `unroll`, `btcwbackend` (fee-input selection - helper), `darepod`, `db`. -- **Sends → `chainsource`** (Ask): `BestHeightRequest`, - `SubscribeBlocksRequest`, `RegisterConfRequest`, - `UnregisterConfRequest`, `BroadcastTxRequest`, - `SubmitPackageRequest`, `TestMempoolAcceptRequest`, - `FeeEstimateRequest`. -- **Sends → `Wallet`** (direct): `ListUnspent`, `NewWalletPkScript`, - `FinalizePsbt`, `LeaseOutput`, `ReleaseOutput`. -- **Sends → caller subscriber** (Tell): `TxConfirmed`, `TxFailed`. -- **Receives ← `chainsource`** (mapped Tell refs): `BlockEpoch` → - `blockEpochObservedMsg`; `ConfirmationEvent` → - `confirmationObservedMsg`. -- **Receives ← API**: `EnsureConfirmedReq`, `CancelInterestReq`. +- **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**: `unroll` (plugs `TxBroadcasterActor` and + `CPFPBroadcaster` into boarding sweep / unilateral exit flows), + `btcwbackend` (fee-input selection helper), `darepod` (wiring), + `db` (schema references). +- **Sends**: + - → `chainsource` (Ask): `BestHeightRequest`, `SubscribeBlocksRequest`, + `RegisterConfRequest` (reorg-aware mode, with `NotifyReorged` and + `NotifyDone` mapped refs), `UnregisterConfRequest`, + `BroadcastTxRequest`, `SubmitPackageRequest`, + `TestMempoolAcceptRequest`, `FeeEstimateRequest`. + - → `Wallet` (direct call): `ListUnspent`, `NewWalletPkScript`, + `FinalizePsbt`, `LeaseOutput`, `ReleaseOutput`. + - → Caller-supplied subscriber (Tell): `TxConfirmed`, `TxReorged`, + `TxFinalized`, `TxFailed`. +- **Receives**: + - ← `chainsource` (via mapped Tell refs): `BlockEpoch` (re-wrapped as + `blockEpochObservedMsg`), `ConfirmationEvent` (re-wrapped as + `confirmationObservedMsg`), `ConfReorgedEvent` (re-wrapped as + `confirmationReorgedMsg`), `ConfDoneEvent` (re-wrapped as + `confirmationDoneMsg`). + - ← API: `EnsureConfirmedReq`, `CancelInterestReq`. ## Invariants @@ -107,22 +135,24 @@ For field-level detail, use `go doc github.com/lightninglabs/darepo-client/txcon parent txid. - **Per-parent fee-input reservation**: each parent txid reserves the wallet UTXO(s) it has committed to. Reservations survive block - boundaries and release only on eviction (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 — 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. + 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, …). Unknown script classes - fall back to P2WKH (which over-estimates for P2TR, safe for Rule 4). + 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`) belt-and-suspenders; the anchor input keeps the + 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 @@ -130,22 +160,40 @@ For field-level detail, use `go doc github.com/lightninglabs/darepo-client/txcon 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. -- **Terminal eviction**: on Confirmed or Failed, the actor delivers - terminal notifications first. If a subscriber is slow or transiently - fails, the tracked entry is retained without a conf watch and - retried on later actor ticks. Once every subscriber is notified or - cancelled, the actor 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 - after eviction re-register from scratch and receive an immediate - `TxConfirmed` via the normal path if the tx is already on chain. + txid+script keyed service-actor lookup resolves symmetrically; one + conf sub-actor per tracked tx. +- **Reversible notifications are fire-and-forget**: `notifyConfirmed` + and `notifyReorged` dispatch each subscriber on its own goroutine + with a bounded `reversibleNotifyTimeout`. The actor does NOT wait + for delivery to complete and does NOT retry on failure, because the + next lifecycle event (re-`TxConfirmed` after a `TxReorged`, + `TxFinalized`, or eventual `TxFailed`) supersedes any dropped + reversible delivery. The same fire-and-forget path is used when + `attachExistingSubscriber` replays the cached confirmation to a + late-arriving subscriber. +- **Terminal eviction**: on `Finalized` or `Failed`, the actor first + delivers terminal notifications synchronously (via the goroutine + + timeout + idempotent-retry pattern in `notifyOneTerminal`). If a + subscriber is slow or transiently fails, the tracked entry is retained + without a conf watch and retried on later actor ticks. Once every + subscriber has been notified or cancelled, the actor 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. +- **Backend Done in non-Confirmed states is dropped**: `confirmationDoneMsg` + for an entry that is not in `TxStateConfirmed` (e.g. mid-reorg + AwaitingConfirmation) is logged at warn and dropped rather than advancing + to Finalized, because finalization from a non-confirmed state is + semantically incorrect. The realistic backends (chainntnfs, lndclient) + do not fire Done during reorg gaps; if this warn ever fires in + production, the right follow-up is to re-register the conf watch from + txconfirm rather than relax the guard. ## Deep Docs -- [`doc.go`](doc.go) — Package-level overview covering architecture, - lifecycle, CPFP correctness invariants, PSBT finalization, - service-key round trip, and eviction. +- [`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 index a2d88a491..0fff91500 100644 --- a/txconfirm/actor.go +++ b/txconfirm/actor.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "log/slog" + "sync/atomic" "time" "github.com/btcsuite/btcd/chainhash/v2" @@ -52,6 +53,14 @@ var ( // waiting longer only risks blocking unrelated confirmation work behind // a durable subscriber's DB writer. terminalNotifyTimeout = time.Second + + // reversibleNotifyTimeout bounds how long a fire-and-forget reversible + // notification goroutine (TxConfirmed, TxReorged) is willing to wait + // on a slow subscriber's mailbox before logging the drop and returning. + // Reversible deliveries are best-effort: the next state transition on + // the same tracked tx supersedes the missed event, so we trade a stale + // notification for keeping txconfirm's actor loop unblocked. + reversibleNotifyTimeout = time.Second ) // ErrEnsureParamsMismatch is returned by EnsureConfirmedReq when a second @@ -185,6 +194,24 @@ type TxBroadcasterActor struct { blockSubscriptionActive bool } +// trackedSubscriber is one attached subscriber to a tracked tx. Every +// subscriber receives the full reorg-aware lifecycle (TxConfirmed, +// TxReorged, re-TxConfirmed, TxFinalized, TxFailed) and remains +// attached until TxFinalized or TxFailed is acknowledged. +// +// pendingConfirmed reports whether the INITIAL TxConfirmed delivery +// is still owed to this subscriber. It is true at admission, flipped +// false the first time notifyOneConfirmed lands successfully, and +// drives retryConfirmedRedelivery's per-tick retry loop so the +// at-least-once initial-TxConfirmed contract holds even when the +// subscriber's mailbox is briefly slow. Re-confirmations after a +// reorg are delivered best-effort because the eventual TxFinalized +// reliably carries the final height/numConfs. +type trackedSubscriber struct { + Ref actor.TellOnlyRef[Notification] + pendingConfirmed bool +} + // trackedTx stores the actor-owned handle for one tracked txid. // // The struct is the actor's single source of truth about a tracked @@ -195,7 +222,7 @@ type trackedTx struct { data trackedTxData fsm *trackedTxStateMachine - subscribers map[string]actor.TellOnlyRef[Notification] + subscribers map[string]trackedSubscriber // escalateLog rate-limits the operator-facing escalation that fires // once a tx has failed to reach any mempool repeatedly. It @@ -219,6 +246,17 @@ type trackedTx struct { // subsequent interval-paced bumps fall back to the estimator. Zero // means "no override pending". pendingTargetFeeRate int64 + + // sealed is set true the moment terminal delivery (TxFinalized / + // TxFailed) begins for this entry. Reversible deliveries + // (TxReorged / re-TxConfirmed) run on detached fire-and-forget + // goroutines, so a reversible spawned just before finality could + // otherwise Tell its subscriber after the terminal notification and + // resurrect reorg-recovery bookkeeping the consumer already dropped. + // Each reversible goroutine checks this flag immediately before its + // Tell and skips if the entry has sealed. It is atomic because it is + // read off the actor goroutine; only the actor goroutine writes it. + sealed atomic.Bool } // confirmationObservedMsg routes a chainsource confirmation callback back into @@ -239,14 +277,53 @@ func (m *confirmationObservedMsg) MessageType() string { // set. func (m *confirmationObservedMsg) txConfirmMsgSealed() {} -// terminalNotifyResultMsg returns the result of a terminal notification that -// outlived the actor-path wait budget. +// confirmationReorgedMsg routes a chainsource ConfReorgedEvent back into +// the actor mailbox. +type confirmationReorgedMsg struct { + actor.BaseMessage + txid chainhash.Hash +} + +// MessageType returns the stable message type identifier. +func (m *confirmationReorgedMsg) MessageType() string { + return "confirmationReorgedMsg" +} + +// txConfirmMsgSealed seals confirmationReorgedMsg into the package message +// set. +func (m *confirmationReorgedMsg) txConfirmMsgSealed() {} + +// confirmationDoneMsg routes a chainsource ConfDoneEvent back into the +// actor mailbox. +type confirmationDoneMsg struct { + actor.BaseMessage + txid chainhash.Hash +} + +// MessageType returns the stable message type identifier. +func (m *confirmationDoneMsg) MessageType() string { + return "confirmationDoneMsg" +} + +// txConfirmMsgSealed seals confirmationDoneMsg into the package message +// set. +func (m *confirmationDoneMsg) txConfirmMsgSealed() {} + +// terminalNotifyResultMsg returns the result of a terminal-shape +// notification that outlived the actor-path wait budget. kind carries the +// original delivery kind ("confirmed" / "finalized" / "failed") so +// handleTerminalNotifyResult can distinguish a mid-lifecycle +// initial-TxConfirmed redelivery (where the subscriber must stay attached +// to receive later TxReorged / TxFinalized) from a truly terminal +// notification (where the subscriber should be removed and the entry can +// evict once empty). type terminalNotifyResultMsg struct { actor.BaseMessage txid chainhash.Hash subscriberID string inflightKey string + kind string err error } @@ -354,6 +431,22 @@ func (a *TxBroadcasterActor) Receive(ctx context.Context, State: TxStateConfirmed, }) + case *confirmationReorgedMsg: + a.handleConfirmationReorged(ctx, req) + + return fn.Ok[Resp](&EnsureConfirmedResp{ + Txid: req.txid, + State: TxStateAwaitingConfirmation, + }) + + case *confirmationDoneMsg: + a.handleConfirmationDone(ctx, req) + + return fn.Ok[Resp](&EnsureConfirmedResp{ + Txid: req.txid, + State: TxStateFinalized, + }) + case *blockEpochObservedMsg: a.handleBlockObserved(ctx, req) @@ -401,7 +494,23 @@ func (a *TxBroadcasterActor) OnStop(ctx context.Context) error { continue } - if state == TxStateConfirmed || state == TxStateFailed { + if isTerminalTxState(state) { + // A terminal entry can still hold a registered conf + // watch: a Failed entry never received Done, and a + // Finalized entry whose Done-driven release raced + // OnStop may not have torn it down yet. Release it so + // the chainsource sub-actor does not leak for the + // daemon's lifetime. confWatchRegistered guards the + // common case where Done already released the watch. + if entry.confWatchRegistered { + if err := a.unregisterConfWatch( + ctx, entry, + ); err != nil && firstErr == nil { + + firstErr = err + } + } + if entry.fsm != nil { entry.fsm.Stop() } @@ -476,7 +585,10 @@ func (a *TxBroadcasterActor) handleEnsure(ctx context.Context, } return a.attachExistingSubscriber( - ctx, existing, req.Subscriber, + ctx, existing, trackedSubscriber{ + Ref: req.Subscriber, + pendingConfirmed: true, + }, ), nil } @@ -700,7 +812,7 @@ func (a *TxBroadcasterActor) handleCancel(ctx context.Context, return nil, err } - if state == TxStateConfirmed || state == TxStateFailed { + if isTerminalTxState(state) { a.evictTerminal(ctx, entry) return resp, nil @@ -920,8 +1032,13 @@ func (a *TxBroadcasterActor) handleBumpNow(ctx context.Context, }, nil } -// handleConfirmationObserved marks a tracked txid as confirmed and fans the -// result out to all subscribers. +// handleConfirmationObserved advances a tracked txid into the reversible +// Confirmed state and fans TxConfirmed out to all subscribers. The +// confirmation watch is intentionally kept alive: a subsequent reorg +// would arrive on the same registration, and the entry must stay +// observable so the chainsource sub-actor's reorg/done channels reach +// this layer. The terminal Finalized state is reached separately when +// the backend emits a Done signal. func (a *TxBroadcasterActor) handleConfirmationObserved(ctx context.Context, msg *confirmationObservedMsg) { @@ -942,7 +1059,9 @@ func (a *TxBroadcasterActor) handleConfirmationObserved(ctx context.Context, return } - if state == TxStateConfirmed || state == TxStateFailed { + // Terminal entries (Failed / Finalized) are sticky; if delivery to + // any subscriber was deferred, retry it and evict on success. + if isTerminalTxState(state) { if a.retryTerminalNotifications(ctx, entry) { a.evictTerminal(ctx, entry) } @@ -950,6 +1069,17 @@ func (a *TxBroadcasterActor) handleConfirmationObserved(ctx context.Context, return } + // An already-Confirmed entry receiving another Confirmed event + // without an intervening Reorged is unexpected on a well-behaved + // backend (the chainsource sub-actor only re-fires Confirmed after + // a reorg), but we tolerate it by re-delivering TxConfirmed rather + // than failing the FSM. + if state == TxStateConfirmed { + a.notifyConfirmed(ctx, entry, msg.blockHeight, msg.numConfs) + + return + } + if err := a.advanceTrackedTxFSM(ctx, entry, &trackedTxConfirmed{ BlockHeight: msg.blockHeight, }); err != nil { @@ -960,13 +1090,153 @@ func (a *TxBroadcasterActor) handleConfirmationObserved(ctx context.Context, return } + a.notifyConfirmed(ctx, entry, msg.blockHeight, msg.numConfs) +} + +// handleConfirmationReorged moves a Confirmed tracked txid back into +// AwaitingConfirmation and fans TxReorged out to all subscribers. The +// confirmation watch stays alive on the chainsource side, so a +// subsequent re-confirmation arrives on the same registration and drives +// another handleConfirmationObserved. +func (a *TxBroadcasterActor) handleConfirmationReorged(ctx context.Context, + msg *confirmationReorgedMsg) { + + 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 + } + + // Only Confirmed entries can be reorged. A reorg ping in any other + // state is benign and dropped: it may be a late notification for an + // entry the actor has already evicted or terminally failed. + if state != TxStateConfirmed { + return + } + + if err := a.advanceTrackedTxFSM( + ctx, entry, &trackedTxReorged{}, + ); err != nil { + + a.log.WarnS(ctx, "Failed to apply reorg to tracked tx", + err, "txid", entry.data.Txid) + + return + } + + a.notifyReorged(ctx, entry) +} + +// handleConfirmationDone moves a Confirmed tracked txid into the terminal +// Finalized state, fans TxFinalized out to all subscribers, releases the +// confirmation watch, and evicts the entry. +func (a *TxBroadcasterActor) handleConfirmationDone(ctx context.Context, + msg *confirmationDoneMsg) { + + 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 + } + + // Only Confirmed entries can be finalized. A Done ping for an entry + // that is not Confirmed has three possible origins: + // + // - Idempotent re-delivery for a tx that is already Finalized: a + // benign no-op. + // + // - Late arrival while the entry is in AwaitingConfirmation + // after a reorg: structurally anomalous for the current + // backends — chainntnfs (in-process lnd) only writes Done + // after the tx has matured past the safety depth, and the + // lndclient adapter never writes the channel at all and + // relies on height-based synthesis (which is gated on + // confirmHeight, reset to 0 on reorg). A future backend that + // DID fire Done during the reorg gap would land here, the + // chainsource sub-actor would already have exited (Done is + // one-shot), and this txid would stop receiving new events. + // The watch is unrecoverable without a fresh RegisterConf + // from this layer. + // + // We log + drop rather than failing the entry: the realistic + // backends do not produce this state, and failing on a hypothetical + // edge case would break callers that rely on the FSM staying live + // for re-confirmation after a reorg. If the log starts firing in + // the wild, the right follow-up is to re-register the conf watch + // from here (see registerConfWatch) rather than relax the guard. + if state != TxStateConfirmed { + a.log.WarnS(ctx, "Dropping confirmation Done for non-Confirmed "+ + "entry; chainsource watch is gone, entry will not "+ + "receive further reorg/finality events", + fmt.Errorf("state %s", state), + "txid", entry.data.Txid) + + return + } + + // Snapshot the confirmation height BEFORE advancing the FSM so we + // can attach it to the outgoing TxFinalized event; the new state + // preserves it but reading from the FSM state directly avoids a + // second state-lookup roundtrip. + fsmState, err := entry.currentFSMState() + if err != nil { + a.log.WarnS(ctx, "Failed to read tracked tx FSM state", + err, "txid", entry.data.Txid) + + return + } + confirmHeight, _ := trackedTxConfirmHeight(fsmState) + + if err := a.advanceTrackedTxFSM( + ctx, entry, &trackedTxFinalized{}, + ); err != nil { + + a.log.WarnS(ctx, "Failed to finalize tracked tx", + err, "txid", entry.data.Txid) + + return + } + + // Only release the conf watch and evict once every subscriber has + // acknowledged the terminal TxFinalized notification. Failed + // deliveries leave the entry in place so retryTerminalNotifications + // can resend on a later actor tick. + if !a.notifyFinalized(ctx, entry, confirmHeight) { + return + } + if err := a.unregisterConfWatch(ctx, entry); err != nil { a.log.WarnS(ctx, "Failed to unregister confirmation watch", err, "txid", entry.data.Txid) } - if a.notifyConfirmed(ctx, entry, msg.blockHeight, msg.numConfs) { - a.evictTerminal(ctx, entry) + a.evictTerminal(ctx, entry) +} + +// isTerminalTxState reports whether a public TxState value represents a +// terminal lifecycle stage that the actor will not advance further on +// its own. +func isTerminalTxState(state TxState) bool { + switch state { + case TxStateFinalized, TxStateFailed: + return true + + default: + return false } } @@ -1003,7 +1273,7 @@ func (a *TxBroadcasterActor) handleBlockObserved(ctx context.Context, continue } - if state == TxStateConfirmed || state == TxStateFailed { + if isTerminalTxState(state) { if a.retryTerminalNotifications(ctx, entry) { a.evictTerminal(ctx, entry) } @@ -1057,13 +1327,13 @@ func (a *TxBroadcasterActor) handleBlockObserved(ctx context.Context, // txid or immediately replays a terminal result. func (a *TxBroadcasterActor) attachExistingSubscriber( ctx context.Context, entry *trackedTx, - subscriber actor.TellOnlyRef[Notification], + subscriber trackedSubscriber, ) *EnsureConfirmedResp { state, err := entry.currentFSMState() if err != nil { a.notifyOneFailed( - ctx, subscriber, entry.data.Txid, + ctx, subscriber.Ref, entry.data.Txid, fmt.Sprintf("tracked tx state: %v", err), ) @@ -1073,27 +1343,57 @@ func (a *TxBroadcasterActor) attachExistingSubscriber( } } + subID := subscriber.Ref.ID() switch state := state.(type) { case *trackedTxStateConfirmed: confirmHeight, _ := trackedTxConfirmHeight(state) - if !a.notifyOneConfirmed( - ctx, subscriber, entry.data.Txid, confirmHeight, - entry.data.TargetConfs, + txConfirmed := &TxConfirmed{ + Txid: entry.data.Txid, + BlockHeight: confirmHeight, + NumConfs: entry.data.TargetConfs, + } + + // Reliable replay of the at-least-once TxConfirmed contract. + // The subscriber is retained in the map regardless of + // delivery outcome so it also receives any later TxReorged + // or TxFinalized on this entry, and on timeout the per-tick + // retryTerminalNotifications path re-attempts delivery via + // the still-true pendingConfirmed flag until it lands. + if a.notifyOneConfirmed( + ctx, subscriber.Ref, entry.data.Txid, txConfirmed, + ) { + + subscriber.pendingConfirmed = false + } + entry.subscribers[subID] = subscriber + + case *trackedTxStateFinalized: + // Finalized is terminal. Deliver TxFinalized reliably and + // retain on timeout so the per-tick retry path can finish + // the handoff. Note that a late-attaching subscriber has + // not yet received TxConfirmed; TxFinalized carries the + // authoritative confirmation height so consumers that need + // it (sweep-finality gates etc.) can recover without an + // out-of-band lookup. + if !a.notifyOneFinalized( + ctx, subscriber.Ref, entry.data.Txid, + state.ConfirmHeight, entry.data.TargetConfs, ) { - entry.subscribers[subscriber.ID()] = subscriber + entry.subscribers[subID] = subscriber } case *trackedTxStateFailed: reason, _ := trackedTxFailureReason(state) - if !a.notifyOneFailed(ctx, subscriber, entry.data.Txid, - reason) { + if !a.notifyOneFailed( + ctx, subscriber.Ref, entry.data.Txid, reason, + ) { - entry.subscribers[subscriber.ID()] = subscriber + entry.subscribers[subID] = subscriber } default: - entry.subscribers[subscriber.ID()] = subscriber + entry.subscribers[subID] = subscriber } return a.ensureResp(entry, false) @@ -1150,8 +1450,11 @@ func (a *TxBroadcasterActor) newTrackedTx(ctx context.Context, return &trackedTx{ data: data, fsm: fsm, - subscribers: map[string]actor.TellOnlyRef[Notification]{ - req.Subscriber.ID(): req.Subscriber, + subscribers: map[string]trackedSubscriber{ + req.Subscriber.ID(): { + Ref: req.Subscriber, + pendingConfirmed: true, + }, }, escalateLog: rate.Sometimes{ First: 1, @@ -1289,6 +1592,9 @@ func (a *TxBroadcasterActor) ensureBlockSubscription( } // registerConfWatch registers a confirmation watch for one tracked txid. +// The watch is registered in reorg-aware mode so the tracked entry can +// observe a confirmation being reorged out and a confirmation maturing +// past the backend's reorg-safety depth. func (a *TxBroadcasterActor) registerConfWatch(ctx context.Context, entry *trackedTx) error { @@ -1303,6 +1609,18 @@ func (a *TxBroadcasterActor) registerConfWatch(ctx context.Context, } }, ) + reorgRef := chainsource.MapConfReorgedEvent( + a.selfRef, + func(event chainsource.ConfReorgedEvent) Msg { + return &confirmationReorgedMsg{txid: event.Txid} + }, + ) + doneRef := chainsource.MapConfDoneEvent( + a.selfRef, + func(event chainsource.ConfDoneEvent) Msg { + return &confirmationDoneMsg{txid: event.Txid} + }, + ) _, err := a.cfg.ChainSource.Ask( ctx, &chainsource.RegisterConfRequest{ @@ -1311,9 +1629,11 @@ func (a *TxBroadcasterActor) registerConfWatch(ctx context.Context, PkScript: append( []byte(nil), entry.data.ConfirmationPkScript..., ), - TargetConfs: entry.data.TargetConfs, - HeightHint: entry.data.HeightHint, - NotifyActor: fn.Some(notifyRef), + TargetConfs: entry.data.TargetConfs, + HeightHint: entry.data.HeightHint, + NotifyActor: fn.Some(notifyRef), + NotifyReorged: fn.Some(reorgRef), + NotifyDone: fn.Some(doneRef), }, ).Await(ctx).Unpack() if err != nil { @@ -1570,11 +1890,18 @@ func (a *TxBroadcasterActor) retryTerminalNotifications(ctx context.Context, switch state := state.(type) { case *trackedTxStateConfirmed: - confirmHeight, _ := trackedTxConfirmHeight(state) + // Retry deferred TxConfirmed deliveries to legacy + // subscribers whose first attempt timed out. Reorg-aware + // subscribers receive TxConfirmed fire-and-forget and have + // no retry tracking; they are skipped here. Returning false + // keeps the entry alive — the caller never evicts on + // Confirmed, only on terminal states. + a.retryConfirmedRedelivery(ctx, entry, state.ConfirmHeight) - return a.notifyConfirmed( - ctx, entry, confirmHeight, entry.data.TargetConfs, - ) + return false + + case *trackedTxStateFinalized: + return a.notifyFinalized(ctx, entry, state.ConfirmHeight) case *trackedTxStateFailed: reason, _ := trackedTxFailureReason(state) @@ -1586,8 +1913,57 @@ func (a *TxBroadcasterActor) retryTerminalNotifications(ctx context.Context, } } -// handleTerminalNotifyResult applies the result of a terminal subscriber -// notification that continued after txconfirm returned to its actor mailbox. +// retryConfirmedRedelivery retries TxConfirmed delivery to every +// subscriber whose initial delivery timed out. The at-least-once +// initial-TxConfirmed contract means a missed delivery must be +// landed before the lifecycle moves on, so this helper runs every +// actor tick from retryTerminalNotifications until every +// pendingConfirmed subscriber has been notified. On successful +// delivery the subscriber's pendingConfirmed flag is cleared and +// the subscriber stays in the map so it continues to receive any +// later TxReorged / TxFinalized on this entry — eviction is +// reserved for the truly terminal Finalized / Failed states. +func (a *TxBroadcasterActor) retryConfirmedRedelivery(ctx context.Context, + entry *trackedTx, confirmHeight int32) { + + for id, subscriber := range entry.subscribers { + if !subscriber.pendingConfirmed { + continue + } + + txConfirmed := &TxConfirmed{ + Txid: entry.data.Txid, + BlockHeight: confirmHeight, + NumConfs: entry.data.TargetConfs, + } + if a.notifyOneConfirmed( + ctx, subscriber.Ref, entry.data.Txid, txConfirmed, + ) { + + subscriber.pendingConfirmed = false + entry.subscribers[id] = subscriber + } + } +} + +// handleTerminalNotifyResult applies the result of a terminal-shape +// subscriber notification that continued after txconfirm returned to its +// actor mailbox. +// +// The kind field distinguishes mid-lifecycle from truly terminal +// deliveries: +// +// - kind == "confirmed": the late-landing delivery is the INITIAL +// TxConfirmed for this subscriber. Clear pendingConfirmed and KEEP +// the subscriber attached to the entry so later TxReorged / +// TxFinalized still reach it. Removing the subscriber here would +// silently drop the entire reorg-aware tail of its lifecycle — +// exactly the kind of failure the unified reliable-delivery path is +// supposed to prevent. +// +// - kind == "finalized" or "failed": the lifecycle is genuinely over +// for this subscriber. Remove it, and evict the entry once every +// subscriber has been notified. func (a *TxBroadcasterActor) handleTerminalNotifyResult(ctx context.Context, msg *terminalNotifyResultMsg) { @@ -1596,7 +1972,8 @@ func (a *TxBroadcasterActor) handleTerminalNotifyResult(ctx context.Context, if msg.err != nil { a.log.WarnS(ctx, "Terminal notification failed after "+ "actor-path timeout", msg.err, "txid", msg.txid, - "subscriber_id", msg.subscriberID) + "subscriber_id", msg.subscriberID, + "notification_kind", msg.kind) return } @@ -1606,6 +1983,40 @@ func (a *TxBroadcasterActor) handleTerminalNotifyResult(ctx context.Context, return } + if msg.kind == "confirmed" { + subscriber, ok := entry.subscribers[msg.subscriberID] + if !ok { + return + } + subscriber.pendingConfirmed = false + entry.subscribers[msg.subscriberID] = subscriber + + // If the tx reorged out of its confirmation while this + // subscriber's initial TxConfirmed was still parked on the + // async notify path, notifyReorged skipped it: a TxReorged must + // never precede the TxConfirmed it reverses. Now that the + // initial delivery has completed and pendingConfirmed is + // cleared, deliver the catch-up TxReorged so the subscriber is + // not left believing a reorged-out tx is confirmed. A tx that + // reorged and then re-confirmed during the window is back in + // Confirmed, so the delivered TxConfirmed is already accurate + // and no catch-up is owed; terminal states own their own + // notifications. + state, err := entry.currentTxState() + if err == nil && state != TxStateConfirmed && + !isTerminalTxState(state) { + + a.notifyReversibleAsync( + ctx, &entry.sealed, subscriber.Ref, + entry.data.Txid, "TxReorged", &TxReorged{ + Txid: entry.data.Txid, + }, + ) + } + + return + } + delete(entry.subscribers, msg.subscriberID) if len(entry.subscribers) != 0 { return @@ -1619,21 +2030,205 @@ func (a *TxBroadcasterActor) handleTerminalNotifyResult(ctx context.Context, return } - if state == TxStateConfirmed || state == TxStateFailed { + if isTerminalTxState(state) { a.evictTerminal(ctx, entry) } } -// notifyConfirmed fans a confirmation result out to all current subscribers. -// It returns true only after every subscriber accepted the terminal -// notification. Failed deliveries are left in the subscriber map so a later -// actor tick can retry instead of permanently losing the confirmation. +// notifyConfirmed fans a TxConfirmed notification out to every current +// subscriber. The initial TxConfirmed (subscriber.pendingConfirmed == +// true) goes through the reliable terminal-delivery path: on success +// pendingConfirmed flips false and the subscriber is retained so it +// keeps receiving the reorg-aware lifecycle (TxReorged / TxFinalized); +// on timeout pendingConfirmed stays true and retryConfirmedRedelivery +// re-attempts on the next actor tick, so the at-least-once initial- +// TxConfirmed guarantee holds even for slow durable subscribers. +// +// Re-confirmations (pendingConfirmed == false, i.e. this subscriber +// has already received the initial TxConfirmed and the chain reorged +// then re-confirmed) are best-effort. The subscriber already knows +// the tx confirmed at least once; the eventual TxFinalized carries +// the authoritative height / numConfs so a missed re-confirmation is +// not load-bearing. +// +// Subscribers are never deleted from the map by this function — that +// happens only on TxFinalized / TxFailed acknowledgment, which is +// when the reorg-aware lifecycle reaches a truly terminal state. func (a *TxBroadcasterActor) notifyConfirmed(ctx context.Context, - entry *trackedTx, blockHeight int32, numConfs uint32) bool { + entry *trackedTx, blockHeight int32, numConfs uint32) { + + for id, subscriber := range entry.subscribers { + txConfirmed := &TxConfirmed{ + Txid: entry.data.Txid, + BlockHeight: blockHeight, + NumConfs: numConfs, + } + + if !subscriber.pendingConfirmed { + a.notifyReversibleAsync( + ctx, &entry.sealed, subscriber.Ref, + entry.data.Txid, "TxConfirmed", txConfirmed, + ) + + continue + } + + if a.notifyOneConfirmed( + ctx, subscriber.Ref, entry.data.Txid, txConfirmed, + ) { + + subscriber.pendingConfirmed = false + entry.subscribers[id] = subscriber + } + } +} + +// notifyOneConfirmed delivers one TxConfirmed notification to a legacy +// (non-opt-in) subscriber via the reliable terminal-delivery path so a +// slow durable subscriber cannot block the actor while still +// preserving the pre-reorg-aware contract of guaranteed-at-least-once +// TxConfirmed delivery. +func (a *TxBroadcasterActor) notifyOneConfirmed(ctx context.Context, + subscriber actor.TellOnlyRef[Notification], txid chainhash.Hash, + notification *TxConfirmed) bool { + + return a.notifyOneTerminal( + ctx, subscriber, txid, "confirmed", + func(notifyCtx context.Context) error { + return subscriber.Tell(notifyCtx, notification) + }, + ) +} + +// notifyReorged fans a TxReorged notification out to every retained +// subscriber whose initial TxConfirmed has already landed. Delivery is +// fire-and-forget: a slow subscriber that misses the reorg is recovered +// by the next lifecycle event (re-TxConfirmed, TxFinalized, or TxFailed) +// since the actor stays attached until one of those terminal events lands. +// +// Subscribers still owed their initial TxConfirmed (pendingConfirmed) are +// skipped: that delivery is deferred to the reliable retry path while a +// reorg's TxReorged goes out fire-and-forget, so notifying them here would +// race the two and could surface TxReorged before — or without — the +// initial TxConfirmed, violating the at-least-once contract and leaving a +// consumer believing a reorged-out tx is confirmed. They observe the live +// state on their eventual initial delivery instead. +func (a *TxBroadcasterActor) notifyReorged(ctx context.Context, + entry *trackedTx) { + + for _, subscriber := range entry.subscribers { + if subscriber.pendingConfirmed { + continue + } + + a.notifyReversibleAsync( + ctx, &entry.sealed, subscriber.Ref, entry.data.Txid, + "TxReorged", + &TxReorged{ + Txid: entry.data.Txid, + }, + ) + } +} + +// notifyReversibleAsync delivers one reversible (non-terminal) +// Notification to a subscriber on a fresh goroutine so txconfirm's +// actor loop does not block on a slow durable subscriber's mailbox. +// Failures are logged but not retried: the next event in the tracked +// tx's lifecycle (re-TxConfirmed after a TxReorged, TxFinalized, or +// eventual TxFailed) carries the live state and supersedes any +// dropped reversible delivery. +// +// The delivery context is detached from the actor transaction (so the +// txconfirm goroutine's DB tx is not held open behind a subscriber +// roundtrip) and from ctx cancellation (so a per-message context +// expiring mid-flight does not race the actor releasing its mailbox). +// Each goroutine is bounded by reversibleNotifyTimeout so a stuck +// subscriber does not leak unbounded goroutines on every block. +func (a *TxBroadcasterActor) notifyReversibleAsync(ctx context.Context, + sealed *atomic.Bool, subscriber actor.TellOnlyRef[Notification], + txid chainhash.Hash, kind string, notification Notification) { + + notifyCtx := actor.WithoutTx(context.WithoutCancel(ctx)) + notifyCtx, cancel := context.WithTimeout( + notifyCtx, reversibleNotifyTimeout, + ) + + subscriberID := subscriber.ID() + go func() { + defer cancel() + + // Drop the reversible delivery if the entry has sealed (a + // terminal TxFinalized/TxFailed has begun). Checked here, just + // before the Tell, so a reversible spawned before finality + // cannot trail the terminal notification into the subscriber's + // mailbox and resurrect bookkeeping it already released. + if sealed != nil && sealed.Load() { + return + } + + if err := subscriber.Tell(notifyCtx, notification); err != nil { + a.log.WarnS(notifyCtx, + "Failed to deliver reversible notification", + err, "txid", txid, + "subscriber_id", subscriberID, + "notification_kind", kind) + } + }() +} + +// notifyFinalized fans a TxFinalized notification out to every +// retained subscriber. The map may still contain subscribers whose +// initial reliable TxConfirmed delivery timed out +// (pendingConfirmed=true) — those still need TxConfirmed before they +// see TxFinalized, otherwise the at-least-once TxConfirmed contract +// is violated. For each pending subscriber we make one TxConfirmed +// attempt, flip pendingConfirmed on success, and either way leave +// them attached for the next finalize retry (the per-tick retry path +// will keep cycling through this function until both deliveries +// land). +// +// On successful TxFinalized delivery the subscriber is removed and +// the caller may evict the tracked entry once every subscriber has +// acknowledged. Failed deliveries are left in the subscriber map for +// retry from the next actor tick. +// +// confirmHeight is the height observed at finalization time, replayed +// onto TxFinalized so consumers that dropped the fire-and-forget +// re-TxConfirmed can recover the authoritative confirmation height +// without an out-of-band lookup. +func (a *TxBroadcasterActor) notifyFinalized(ctx context.Context, + entry *trackedTx, confirmHeight int32) bool { + + // Seal the entry so any in-flight reversible delivery skips its Tell + // rather than trailing this terminal notification into a subscriber's + // mailbox. Idempotent across the per-tick finalize retries. + entry.sealed.Store(true) for id, subscriber := range entry.subscribers { - ok := a.notifyOneConfirmed( - ctx, subscriber, entry.data.Txid, blockHeight, numConfs, + if subscriber.pendingConfirmed { + if a.notifyOneConfirmed( + ctx, subscriber.Ref, entry.data.Txid, + &TxConfirmed{ + Txid: entry.data.Txid, + BlockHeight: confirmHeight, + NumConfs: entry.data.TargetConfs, + }, + ) { + + subscriber.pendingConfirmed = false + entry.subscribers[id] = subscriber + } else { + // Hold until the next tick — we must not + // deliver TxFinalized before the documented + // initial TxConfirmed has landed. + continue + } + } + + ok := a.notifyOneFinalized( + ctx, subscriber.Ref, entry.data.Txid, confirmHeight, + entry.data.TargetConfs, ) if !ok { continue @@ -1652,9 +2247,14 @@ func (a *TxBroadcasterActor) notifyConfirmed(ctx context.Context, func (a *TxBroadcasterActor) notifyFailed(ctx context.Context, entry *trackedTx, reason string) bool { + // Seal the entry so any in-flight reversible delivery skips its Tell + // rather than trailing this terminal failure into a subscriber's + // mailbox. Idempotent across the per-tick retries. + entry.sealed.Store(true) + for id, subscriber := range entry.subscribers { ok := a.notifyOneFailed( - ctx, subscriber, entry.data.Txid, reason, + ctx, subscriber.Ref, entry.data.Txid, reason, ) if !ok { continue @@ -1666,15 +2266,20 @@ func (a *TxBroadcasterActor) notifyFailed(ctx context.Context, entry *trackedTx, return len(entry.subscribers) == 0 } -// notifyOneConfirmed delivers one confirmation notification. -func (a *TxBroadcasterActor) notifyOneConfirmed(ctx context.Context, +// notifyOneFinalized delivers one TxFinalized notification through the +// terminal-delivery path so a slow subscriber cannot block the actor +// loop. blockHeight and numConfs are replayed onto the notification +// from the last observed confirmation so opt-in subscribers that +// dropped the (fire-and-forget) TxConfirmed event can still recover +// the authoritative confirmation height. +func (a *TxBroadcasterActor) notifyOneFinalized(ctx context.Context, subscriber actor.TellOnlyRef[Notification], txid chainhash.Hash, blockHeight int32, numConfs uint32) bool { return a.notifyOneTerminal( - ctx, subscriber, txid, "confirmed", + ctx, subscriber, txid, "finalized", func(notifyCtx context.Context) error { - return subscriber.Tell(notifyCtx, &TxConfirmed{ + return subscriber.Tell(notifyCtx, &TxFinalized{ Txid: txid, BlockHeight: blockHeight, NumConfs: numConfs, @@ -1735,7 +2340,7 @@ func (a *TxBroadcasterActor) notifyOneTerminal(ctx context.Context, a.terminalNotifyInflight[inflightKey] = struct{}{} //nolint:contextcheck // async result outlives ctx a.completeTerminalNotifyAsync( - inflightKey, txid, subscriberID, errChan, cancel, + inflightKey, txid, subscriberID, kind, errChan, cancel, ) a.log.DebugS(ctx, "Terminal tx notification deferred", @@ -1748,10 +2353,13 @@ func (a *TxBroadcasterActor) notifyOneTerminal(ctx context.Context, } } -// completeTerminalNotifyAsync reports a timed-out terminal delivery back to the -// txconfirm actor once the underlying Tell returns. +// completeTerminalNotifyAsync reports a timed-out terminal-shape delivery +// back to the txconfirm actor once the underlying Tell returns. kind +// records the notification variant ("confirmed" / "finalized" / "failed") +// so the actor-side handler can apply the correct post-delivery state +// transition. func (a *TxBroadcasterActor) completeTerminalNotifyAsync(inflightKey string, - txid chainhash.Hash, subscriberID string, errChan <-chan error, + txid chainhash.Hash, subscriberID, kind string, errChan <-chan error, cancel context.CancelFunc) { if a.selfRef == nil { @@ -1768,6 +2376,7 @@ func (a *TxBroadcasterActor) completeTerminalNotifyAsync(inflightKey string, txid: txid, subscriberID: subscriberID, inflightKey: inflightKey, + kind: kind, err: err, } bgCtx := context.Background() diff --git a/txconfirm/actor_test.go b/txconfirm/actor_test.go index 688dcb64f..eebe1d0b4 100644 --- a/txconfirm/actor_test.go +++ b/txconfirm/actor_test.go @@ -29,6 +29,11 @@ import ( // testTimeout is the default timeout used by txconfirm actor tests. const testTimeout = time.Second +// confReorgedRef / confDoneRef are the reorg / done notification ref +// types used in the fake chain-source ref. +type confReorgedRef = actor.TellOnlyRef[chainsource.ConfReorgedEvent] +type confDoneRef = actor.TellOnlyRef[chainsource.ConfDoneEvent] + // confNotifyRef is the confirmation-event notification ref type used in // the fake chainsource test double. type confNotifyRef = actor.TellOnlyRef[chainsource.ConfirmationEvent] @@ -54,6 +59,8 @@ type fakeChainSourceRef struct { blockNotify actor.TellOnlyRef[chainsource.BlockEpoch] confNotify map[chainhash.Hash]confNotifyRef + confReorged map[chainhash.Hash]confReorgedRef + confDone map[chainhash.Hash]confDoneRef confConfs map[chainhash.Hash]uint32 alreadyConfirmed map[chainhash.Hash]chainsource.ConfirmationEvent @@ -148,6 +155,93 @@ func (b *blockingNotifyRef) attemptsCount() int { return b.attempts } +// deferringNotifyRef blocks Tell until released, then completes +// successfully (returns nil regardless of ctx state). Used to drive the +// async-completion path of notifyOneTerminal: Tell exceeds the actor-path +// budget, so the caller observes a timeout and parks the result on the +// completeTerminalNotifyAsync goroutine; the test then releases the Tell +// to simulate the underlying mailbox eventually accepting the +// notification, producing a terminalNotifyResultMsg{err: nil} that the +// actor mailbox processes via handleTerminalNotifyResult. +type deferringNotifyRef struct { + id string + + started chan struct{} + release chan struct{} + once sync.Once + + mu sync.Mutex + attempts int + msgs []Notification +} + +// newDeferringNotifyRef creates a subscriber that blocks on the first Tell +// until release is closed. +func newDeferringNotifyRef(id string) *deferringNotifyRef { + return &deferringNotifyRef{ + id: id, + started: make(chan struct{}), + release: make(chan struct{}), + } +} + +// ID returns the fake subscriber ID. +func (d *deferringNotifyRef) ID() string { + return d.id +} + +// Tell blocks until release is closed, records the notification, and +// returns nil. ctx cancellation is intentionally ignored so the test can +// drive the "Tell eventually succeeded" path even after the actor-side +// notifyCtx timed out. +func (d *deferringNotifyRef) Tell(_ context.Context, n Notification) error { + d.mu.Lock() + d.attempts++ + d.mu.Unlock() + + d.once.Do(func() { + close(d.started) + }) + + <-d.release + + d.mu.Lock() + d.msgs = append(d.msgs, n) + d.mu.Unlock() + + return nil +} + +// waitStarted blocks until the first Tell has begun, so the test can +// release after the actor-side timeout has fired. +func (d *deferringNotifyRef) waitStarted(t *testing.T) { + t.Helper() + + select { + case <-d.started: + case <-time.After(testTimeout): + t.Fatal("deferringNotifyRef Tell never started") + } +} + +// releaseTell unblocks every parked Tell call so the deferred deliveries +// complete and the test observes the async-completion path. +func (d *deferringNotifyRef) releaseTell() { + close(d.release) +} + +// snapshotMessages returns a defensive copy of every notification that +// has landed in the subscriber's mailbox so far. +func (d *deferringNotifyRef) snapshotMessages() []Notification { + d.mu.Lock() + defer d.mu.Unlock() + + out := make([]Notification, len(d.msgs)) + copy(out, d.msgs) + + return out +} + // ID returns the fake subscriber ID. func (r *contextInspectNotifyRef) ID() string { return r.id @@ -232,10 +326,12 @@ func (r *retryNotifyRef) awaitMessage(timeout time.Duration) (Notification, // 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), + bestHeight: bestHeight, + feeRate: 5, + confNotify: make(map[chainhash.Hash]confNotifyRef), + confReorged: make(map[chainhash.Hash]confReorgedRef), + confDone: make(map[chainhash.Hash]confDoneRef), + confConfs: make(map[chainhash.Hash]uint32), alreadyConfirmed: make( map[chainhash.Hash]chainsource.ConfirmationEvent, ), @@ -344,6 +440,12 @@ func (f *fakeChainSourceRef) handleAsk(_ context.Context, if req.Txid != nil && req.NotifyActor.IsSome() { f.confNotify[*req.Txid] = req.NotifyActor.UnwrapOr(nil) f.confConfs[*req.Txid] = req.TargetConfs + req.NotifyReorged.WhenSome(func(r confReorgedRef) { + f.confReorged[*req.Txid] = r + }) + req.NotifyDone.WhenSome(func(r confDoneRef) { + f.confDone[*req.Txid] = r + }) if event, ok := f.alreadyConfirmed[*req.Txid]; ok { notifyRef := req.NotifyActor.UnwrapOr(nil) //nolint:contextcheck // fake backend @@ -358,6 +460,8 @@ func (f *fakeChainSourceRef) handleAsk(_ context.Context, if req.Txid != nil { delete(f.confNotify, *req.Txid) delete(f.confConfs, *req.Txid) + delete(f.confReorged, *req.Txid) + delete(f.confDone, *req.Txid) } return &chainsource.UnregisterConfResponse{}, nil @@ -417,6 +521,34 @@ func (f *fakeChainSourceRef) emitConfirmation(t *testing.T, txid chainhash.Hash, require.NoError(t, err) } +// emitConfReorged delivers a reorg event for one tracked txid. +func (f *fakeChainSourceRef) emitConfReorged(t *testing.T, + txid chainhash.Hash) { + + t.Helper() + + f.mu.Lock() + ref := f.confReorged[txid] + f.mu.Unlock() + + require.NotNil(t, ref) + err := ref.Tell(t.Context(), chainsource.ConfReorgedEvent{Txid: txid}) + require.NoError(t, err) +} + +// emitConfDone delivers a finality event for one tracked txid. +func (f *fakeChainSourceRef) emitConfDone(t *testing.T, txid chainhash.Hash) { + t.Helper() + + f.mu.Lock() + ref := f.confDone[txid] + f.mu.Unlock() + + require.NotNil(t, ref) + err := ref.Tell(t.Context(), chainsource.ConfDoneEvent{Txid: txid}) + 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() @@ -815,15 +947,27 @@ func TestEnsureConfirmedDedupesTwoSubscribers(t *testing.T) { require.IsType(t, &TxConfirmed{}, confirmedA) require.IsType(t, &TxConfirmed{}, confirmedB) + + // TxConfirmed alone does not release the chainsource conf watch in + // the reorg-aware model: the watch stays alive until the backend + // finalizes the confirmation. Driving Done evicts the tracked + // entry and unregisters. + chain.emitConfDone(t, tx.TxHash()) + mustAwaitNotification(t, subA) + mustAwaitNotification(t, subB) mustEventually(t, func() bool { return chain.unregisterConfCount() == 1 }) } -// TestConfirmationDeliveryRetriesAfterTellFailure verifies that a transient -// subscriber delivery failure does not permanently drop a terminal -// confirmation notification. -func TestConfirmationDeliveryRetriesAfterTellFailure(t *testing.T) { +// TestLifecycleDeliveryRetriesAfterTellFailure verifies that a transient +// subscriber delivery failure does not permanently drop a lifecycle +// notification. Both the initial TxConfirmed and the terminal +// TxFinalized are reliable: if the first Tell attempt fails, the +// per-tick retry path keeps re-attempting until delivery lands, and +// the entry only evicts after every retained subscriber has +// acknowledged both events. +func TestLifecycleDeliveryRetriesAfterTellFailure(t *testing.T) { chain := newFakeChainSourceRef(100) ref, _ := newTestActor(t, Config{ ChainSource: chain, @@ -831,6 +975,9 @@ func TestConfirmationDeliveryRetriesAfterTellFailure(t *testing.T) { tx := makeTestTx(false) txid := tx.TxHash() + // Fail the first Tell attempt: TxConfirmed delivery has to be + // retried before pendingConfirmed clears, after which TxFinalized + // can be attempted. sub := newRetryNotifyRef("sub-retry", 1) resp := mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ @@ -844,31 +991,40 @@ func TestConfirmationDeliveryRetriesAfterTellFailure(t *testing.T) { return sub.attemptsCount() == 1 }) + // First TxConfirmed attempt failed; the entry stays alive with + // pendingConfirmed=true and no notification has reached the + // subscriber mailbox yet. msg, ok := sub.awaitMessage(100 * time.Millisecond) - require.False(t, ok, "unexpected notification: %v", msg) - mustEventually(t, func() bool { - return chain.unregisterConfCount() == 1 - }) - - chain.emitBlock(t, 102) - msg, ok = sub.awaitMessage(testTimeout) - require.True(t, ok, "expected retried notification") - - confirmed, ok := msg.(*TxConfirmed) - require.True(t, ok) + require.False(t, ok, "unexpected early notification: %v", msg) + require.Equal(t, 0, chain.unregisterConfCount()) + + // Finalization fires. notifyFinalized retries TxConfirmed first + // (the at-least-once initial-confirmation contract must land + // before TxFinalized is allowed through). The second Tell + // succeeds, so TxConfirmed lands and TxFinalized follows in the + // same notifyFinalized pass. + chain.emitConfDone(t, txid) + + first, ok := sub.awaitMessage(testTimeout) + require.True(t, ok, "expected retried TxConfirmed") + confirmed, ok := first.(*TxConfirmed) + require.True( + t, ok, "first notification must be TxConfirmed, got %T", first, + ) require.Equal(t, txid, confirmed.Txid) - require.Equal(t, int32(101), confirmed.BlockHeight) - require.Equal(t, uint32(1), confirmed.NumConfs) - require.Equal(t, 2, sub.attemptsCount()) - freshSub := actor.NewChannelTellOnlyRef[Notification]("sub-fresh", 4) - replayResp := mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ - Tx: tx, - Subscriber: freshSub, + second, ok := sub.awaitMessage(testTimeout) + require.True(t, ok, "expected TxFinalized") + finalized, ok := second.(*TxFinalized) + require.True( + t, ok, "second notification must be TxFinalized, got %T", + second, + ) + require.Equal(t, txid, finalized.Txid) + + mustEventually(t, func() bool { + return chain.unregisterConfCount() == 1 }) - require.True(t, replayResp.Created) - require.Equal(t, 2, chain.registerConfCount()) - require.Equal(t, 2, chain.broadcastCallCount()) } // TestTerminalNotificationsDoNotInheritCallerContext verifies that terminal @@ -884,17 +1040,17 @@ func TestTerminalNotificationsDoNotInheritCallerContext(t *testing.T) { cancel() txid := chainhash.Hash{1} - confirmedSub := &contextInspectNotifyRef{id: "confirmed-sub"} - ok := behavior.notifyOneConfirmed( - ctx, confirmedSub, txid, 101, 1, + finalizedSub := &contextInspectNotifyRef{id: "finalized-sub"} + ok := behavior.notifyOneFinalized( + ctx, finalizedSub, txid, 101, 1, ) require.True(t, ok) - hasTx, ctxErr, msgs := confirmedSub.snapshot() + hasTx, ctxErr, msgs := finalizedSub.snapshot() require.False(t, hasTx) require.NoError(t, ctxErr) require.Len(t, msgs, 1) - require.IsType(t, &TxConfirmed{}, msgs[0]) + require.IsType(t, &TxFinalized{}, msgs[0]) failedSub := &contextInspectNotifyRef{id: "failed-sub"} ok = behavior.notifyOneFailed(ctx, failedSub, txid, "boom") @@ -937,14 +1093,14 @@ func TestTerminalNotificationTimeoutDoesNotBlockActor(t *testing.T) { }() start := time.Now() - ok := behavior.notifyOneConfirmed( + ok := behavior.notifyOneFinalized( context.Background(), sub, txid, 101, 1, ) require.False(t, ok) require.Less(t, time.Since(start), testTimeout) require.True(t, <-started) - key := terminalNotifyKey(txid, sub.ID(), "confirmed") + key := terminalNotifyKey(txid, sub.ID(), "finalized") _, inflight := behavior.terminalNotifyInflight[key] require.True(t, inflight) require.Equal(t, 1, sub.attemptsCount()) @@ -1051,6 +1207,9 @@ func TestEnsureConfirmedAlreadyConfirmedUsesSuccessPath(t *testing.T) { subA := actor.NewChannelTellOnlyRef[Notification]("sub-a", 4) subB := actor.NewChannelTellOnlyRef[Notification]("sub-b", 4) + // Opt in to reorg-aware notifications so the tracked entry stays + // alive past TxConfirmed and the second EnsureConfirmedReq can + // attach to the existing tracking state. resp := mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ Tx: tx, Subscriber: subA, @@ -1062,21 +1221,21 @@ func TestEnsureConfirmedAlreadyConfirmedUsesSuccessPath(t *testing.T) { 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. + // TxConfirmed is no longer terminal: the tracked entry remains alive + // (still subscribed to chainsource reorg/done) until a Done event + // fires. A subsequent EnsureConfirmedReq therefore attaches to the + // existing tracking state and replays TxConfirmed without + // re-broadcasting or re-registering with chainsource. replayResp := mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ Tx: tx, Subscriber: subB, }) - require.True(t, replayResp.Created) + require.False(t, replayResp.Created) replayed := mustAwaitNotification(t, subB) require.IsType(t, &TxConfirmed{}, replayed) - require.Equal(t, 2, chain.broadcastCallCount()) - require.Equal(t, 2, chain.registerConfCount()) + require.Equal(t, 1, chain.broadcastCallCount()) + require.Equal(t, 1, chain.registerConfCount()) } // TestEnsureConfirmedBroadcastFailureNotifiesFailure verifies that terminal @@ -1462,6 +1621,11 @@ func TestUnregisterConfMatchesRegisterServiceKey(t *testing.T) { confirmed := mustAwaitNotification(t, sub) require.IsType(t, &TxConfirmed{}, confirmed) + // The conf watch is only released once chainsource fires Done. + chain.emitConfDone(t, tx.TxHash()) + finalized := mustAwaitNotification(t, sub) + require.IsType(t, &TxFinalized{}, finalized) + mustEventually(t, func() bool { return chain.unregisterConfCount() == 1 }) @@ -1531,7 +1695,17 @@ func TestTerminalEntriesEvictedAfterConfirmation(t *testing.T) { require.IsType(t, &TxConfirmed{}, confirmed) } - // Every confirmation should have produced exactly one unregister. + // Finalization drives terminal eviction: TxConfirmed alone is now + // reversible and the tracked entry stays alive until the backend + // fires Done. + for i := 0; i < numTxs; i++ { + chain.emitConfDone(t, txids[i]) + + finalized := mustAwaitNotification(t, subs[i]) + require.IsType(t, &TxFinalized{}, finalized) + } + + // Every finalized entry should have produced exactly one unregister. mustEventually(t, func() bool { return chain.unregisterConfCount() == numTxs }) @@ -1907,3 +2081,81 @@ func TestEnsureConfirmedFailsPermanentBroadcastError(t *testing.T) { failed := mustAwaitNotification(t, sub) require.IsType(t, &TxFailed{}, failed) } + +// TestInitialConfirmedAsyncDeliveryRetainsSubscriber regression-tests an +// initial TxConfirmed delivery whose Tell exceeds terminalNotifyTimeout +// before landing successfully via the async-completion goroutine. The +// subscriber must remain attached to the entry afterwards so the +// reorg-aware tail of its lifecycle (TxReorged / TxFinalized) still +// reaches it; the previous handleTerminalNotifyResult deleted the +// subscriber on any successful async terminal completion, silently +// dropping every subsequent reorg event for slow mailboxes. +func TestInitialConfirmedAsyncDeliveryRetainsSubscriber(t *testing.T) { + // Shorten the actor-path budget so the test reliably exercises the + // async-completion code path within a normal test runtime. + oldTimeout := terminalNotifyTimeout + terminalNotifyTimeout = 20 * time.Millisecond + t.Cleanup(func() { + terminalNotifyTimeout = oldTimeout + }) + + chain := newFakeChainSourceRef(100) + ref, _ := newTestActor(t, Config{ + ChainSource: chain, + }) + + tx := makeTestTx(false) + txid := tx.TxHash() + sub := newDeferringNotifyRef("sub-async-confirmed") + + resp := mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ + Tx: tx, + Subscriber: sub, + }) + require.True(t, resp.Created) + + // Fire the confirmation. notifyOneConfirmed -> notifyOneTerminal + // will park on sub.Tell, blow through terminalNotifyTimeout, and + // hand the in-flight Tell off to completeTerminalNotifyAsync. + chain.emitConfirmation(t, txid, 101) + sub.waitStarted(t) + + // Wait long enough that the actor-side notifyCtx is guaranteed to + // have timed out and the async-completion goroutine is in flight. + // Without this delay the Tell could land synchronously and the + // async path the regression targets would not be exercised. + time.Sleep(10 * terminalNotifyTimeout) + + // Release the parked Tell so the async goroutine reports back to + // the txconfirm actor with a successful terminalNotifyResultMsg. + sub.releaseTell() + + // Wait for TxConfirmed to actually land in the subscriber mailbox. + require.Eventually(t, func() bool { + for _, m := range sub.snapshotMessages() { + if _, ok := m.(*TxConfirmed); ok { + return true + } + } + + return false + }, testTimeout, 10*time.Millisecond, "TxConfirmed never delivered") + + // Drive a reorg. If the subscriber was wrongly evicted on the + // async-confirmed completion, notifyReorged has nobody to fan to + // and the TxReorged below never arrives. + chain.emitConfReorged(t, txid) + + require.Eventually(t, func() bool { + for _, m := range sub.snapshotMessages() { + if _, ok := m.(*TxReorged); ok { + return true + } + } + + return false + }, testTimeout, 10*time.Millisecond, + "TxReorged never reached subscriber whose initial TxConfirmed "+ + "completed via the async path — handleTerminalNotify"+ + "Result probably evicted it on completion") +} diff --git a/txconfirm/broadcaster_test.go b/txconfirm/broadcaster_test.go index cbf638e3e..716348281 100644 --- a/txconfirm/broadcaster_test.go +++ b/txconfirm/broadcaster_test.go @@ -361,7 +361,7 @@ func newTrackedTxForState(t *testing.T, state trackedTxState) *trackedTx { return &trackedTx{ data: data, fsm: &fsm, - subscribers: make(map[string]actor.TellOnlyRef[Notification]), + subscribers: make(map[string]trackedSubscriber), } } @@ -1619,11 +1619,13 @@ func TestActorValidationAndCleanup(t *testing.T) { }, } entry := newTrackedTxForState(t, awaitConf) - entry.subscribers["fail"] = &failingNotifyRef{} + entry.subscribers["fail"] = trackedSubscriber{ + Ref: &failingNotifyRef{}, + } behavior.tracked[entry.data.Txid] = entry - behavior.notifyOneConfirmed( - t.Context(), &failingNotifyRef{}, entry.data.Txid, 1, 1, + behavior.notifyOneFinalized( + t.Context(), &failingNotifyRef{}, entry.data.Txid, 0, 0, ) behavior.notifyOneFailed( t.Context(), &failingNotifyRef{}, entry.data.Txid, diff --git a/txconfirm/fsm_types.go b/txconfirm/fsm_types.go index 387962b43..3cde4011f 100644 --- a/txconfirm/fsm_types.go +++ b/txconfirm/fsm_types.go @@ -168,6 +168,21 @@ type trackedTxFailed struct { // trackedTxEventSealed marks trackedTxFailed as a tracked-tx event. func (e *trackedTxFailed) trackedTxEventSealed() {} +// trackedTxReorged records that a previously delivered confirmation was +// reorged out of the canonical chain. Only valid from +// trackedTxStateConfirmed. +type trackedTxReorged struct{} + +// trackedTxEventSealed marks trackedTxReorged as a tracked-tx event. +func (e *trackedTxReorged) trackedTxEventSealed() {} + +// trackedTxFinalized records that a confirmation is past the backend's +// reorg-safety depth. Only valid from trackedTxStateConfirmed. +type trackedTxFinalized struct{} + +// trackedTxEventSealed marks trackedTxFinalized as a tracked-tx event. +func (e *trackedTxFinalized) trackedTxEventSealed() {} + // trackedTxErrorReporter reports tracked-tx FSM errors through the package // logger. type trackedTxErrorReporter struct { @@ -225,6 +240,9 @@ func txStateFromTrackedState(state trackedTxState) TxState { case *trackedTxStateConfirmed: return TxStateConfirmed + case *trackedTxStateFinalized: + return TxStateFinalized + case *trackedTxStateFailed: return TxStateFailed @@ -279,6 +297,9 @@ func trackedTxLastBroadcastHeight(state trackedTxState) fn.Option[int32] { case *trackedTxStateConfirmed: return s.LastBroadcastHeight + case *trackedTxStateFinalized: + return s.LastBroadcastHeight + case *trackedTxStateFailed: return s.LastBroadcastHeight @@ -302,12 +323,16 @@ func trackedTxBroadcastFailures(state trackedTxState) int { // 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 { + switch s := state.(type) { + case *trackedTxStateConfirmed: + return s.ConfirmHeight, true + + case *trackedTxStateFinalized: + return s.ConfirmHeight, true + + default: return 0, false } - - return confirmed.ConfirmHeight, true } // trackedTxFailureReason returns the state's terminal failure reason when diff --git a/txconfirm/funded_anchor_test.go b/txconfirm/funded_anchor_test.go index 53c07bb46..f0f51e992 100644 --- a/txconfirm/funded_anchor_test.go +++ b/txconfirm/funded_anchor_test.go @@ -575,9 +575,12 @@ func TestBumpNowParentFeeSufficient(t *testing.T) { } // TestBumpNowUntrackedAndTerminal covers the remaining no-op guards: an -// untracked txid, and a transaction that confirmed and was evicted from -// tracking (a confirmed entry whose terminal notification is delivered is -// dropped from the map, so a late bump lands in the untracked branch). +// untracked txid, and a transaction that has reached its confirmation target. +// Under the reorg-aware lifecycle a confirmed entry is NOT evicted at its +// confirmation — it stays tracked in the reversible Confirmed state until the +// backend signals finality (Done), so it can observe a later reorg — so a bump +// of a confirmed-but-not-final tx lands in the "already confirmed" no-op +// branch rather than the untracked branch. func TestBumpNowUntrackedAndTerminal(t *testing.T) { t.Parallel() @@ -608,9 +611,10 @@ func TestBumpNowUntrackedAndTerminal(t *testing.T) { Subscriber: sub, }) - // Drain the confirmation callback the fake chain delivered to the - // self ref so the entry reaches its terminal state and, with its - // notification delivered, is evicted from tracking. + // Drain the confirmation callback the fake chain delivered to the self + // ref so the entry advances into the reversible Confirmed state and its + // TxConfirmed notification is delivered. It is NOT evicted: the entry + // stays tracked until finality so a reorg can still be observed. selfRef, ok := behavior.selfRef.(*actor.ChannelTellOnlyRef[Msg]) require.True(t, ok) for { @@ -622,11 +626,13 @@ func TestBumpNowUntrackedAndTerminal(t *testing.T) { } require.NotNil(t, mustAwaitNotification(t, sub)) - // A bump after eviction reports the untracked no-op: there is no - // entry left to bump, which is the correct answer for a confirmed tx. + // A bump of the confirmed (but not-yet-final) entry is the confirmed + // no-op: it is still tracked, so the handler reports "already + // confirmed" rather than the untracked branch. Either way a confirmed + // tx cannot be fee-bumped. bumpResp = mustReceiveBump(t, behavior, &BumpNowReq{ Txid: tx.TxHash(), }) require.False(t, bumpResp.Bumped) - require.Contains(t, bumpResp.Reason, "not tracked") + require.Contains(t, bumpResp.Reason, "already confirmed") } diff --git a/txconfirm/messages.go b/txconfirm/messages.go index 2208788f2..e595e6401 100644 --- a/txconfirm/messages.go +++ b/txconfirm/messages.go @@ -58,9 +58,15 @@ const ( TxStateFeeBumping // TxStateConfirmed indicates the tracked transaction reached its target - // confirmation count. + // confirmation count on the canonical chain. This state is reversible: + // a reorg moves the tracked entry back to TxStateAwaitingConfirmation, + // and finality moves it to TxStateFinalized. TxStateConfirmed + // TxStateFinalized indicates the tracked transaction confirmed and is + // past the backend's reorg-safety depth. Terminal. + TxStateFinalized + // TxStateFailed indicates the actor encountered a terminal // failure while // trying to confirm the transaction. @@ -85,6 +91,9 @@ func (s TxState) String() string { case TxStateConfirmed: return "confirmed" + case TxStateFinalized: + return "finalized" + case TxStateFailed: return "failed" @@ -126,8 +135,17 @@ type EnsureConfirmedReq struct { // parents and for callers that do not fee-bump. ParentFee btcutil.Amount - // Subscriber receives TxConfirmed or TxFailed notifications for this - // request. + // Subscriber receives the full reorg-aware notification lifecycle + // for this request: TxConfirmed when the tx reaches the + // confirmation target, TxReorged if a previously delivered + // TxConfirmed is reorged out, re-TxConfirmed on the new canonical + // chain, TxFinalized once the confirmation is past the backend's + // reorg-safety depth, and TxFailed on terminal failure. + // TxConfirmed and TxFailed deliveries are reliable (retry on + // timeout from the per-tick retry path); TxReorged is best-effort + // because the next lifecycle event (re-TxConfirmed / TxFinalized / + // TxFailed) re-establishes state. TxFinalized is reliable so the + // caller can free reorg-recovery bookkeeping deterministically. Subscriber actor.TellOnlyRef[Notification] } @@ -306,6 +324,65 @@ func (m *TxConfirmed) MessageType() string { // set. func (m *TxConfirmed) txConfirmNotificationSealed() {} +// TxReorged notifies a subscriber that a previously delivered TxConfirmed +// has been reorged out of the canonical chain. After receiving this event +// a consumer should consider the prior confirmation no longer valid; if +// the transaction re-confirms on the new canonical chain a fresh +// TxConfirmed will follow on the same subscription. +type TxReorged struct { + actor.BaseMessage + + // Txid identifies the reorged transaction. + Txid chainhash.Hash +} + +// MessageType returns the stable message type identifier. +func (m *TxReorged) MessageType() string { + return "TxReorged" +} + +// txConfirmNotificationSealed seals TxReorged into the package notification +// set. +func (m *TxReorged) txConfirmNotificationSealed() {} + +// TxFinalized notifies a subscriber that the tracked transaction is past +// the backend's reorg-safety depth and will receive no further events. +// Subscribers may use this signal to drop any reorg-recovery bookkeeping +// they were holding for the registration. Not all backends synthesize +// this event (the lndclient transport does not), so consumers must treat +// its absence as a normal operating condition rather than an error. +// +// BlockHeight and NumConfs replay the authoritative confirmation +// numbers carried by the last TxConfirmed before finalization. Because +// reversible TxConfirmed deliveries are fire-and-forget for opt-in +// subscribers and may be dropped on a momentarily-full mailbox, the +// finalization event must carry enough information for height-dependent +// consumers to recover without out-of-band lookups. +type TxFinalized struct { + actor.BaseMessage + + // Txid identifies the finalized transaction. + Txid chainhash.Hash + + // BlockHeight is the height of the block carrying the latest + // observed confirmation (post-any-reorg) at finalization time. + BlockHeight int32 + + // NumConfs is the confirmation count that triggered the + // finalization event — typically the EnsureConfirmedReq's + // TargetConfs. + NumConfs uint32 +} + +// MessageType returns the stable message type identifier. +func (m *TxFinalized) MessageType() string { + return "TxFinalized" +} + +// txConfirmNotificationSealed seals TxFinalized into the package +// notification set. +func (m *TxFinalized) txConfirmNotificationSealed() {} + // TxFailed notifies a subscriber that the actor encountered a terminal // failure while trying to confirm the tracked transaction. type TxFailed struct { diff --git a/txconfirm/reorg_test.go b/txconfirm/reorg_test.go new file mode 100644 index 000000000..dcc49e76e --- /dev/null +++ b/txconfirm/reorg_test.go @@ -0,0 +1,147 @@ +package txconfirm + +import ( + "testing" + + "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/stretchr/testify/require" +) + +// TestEnsureConfirmedReorgLifecycle drives the full reorg-aware lifecycle +// (Confirmed -> Reorged -> Confirmed -> Finalized) through the +// TxBroadcasterActor and asserts that: +// +// - Each chainsource event reaches the subscriber as a matching public +// notification, in order. +// - The conf watch is held open across the reorg-out / re-confirm +// bounce (no unregister fires before Done). +// - Finalization releases the conf watch and evicts the tracked entry. +func TestEnsureConfirmedReorgLifecycle(t *testing.T) { + chain := newFakeChainSourceRef(100) + ref, _ := newTestActor(t, Config{ + ChainSource: chain, + }) + + tx := makeTestTx(false) + txid := tx.TxHash() + sub := actor.NewChannelTellOnlyRef[Notification]("sub", 16) + + resp := mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ + Tx: tx, + Subscriber: sub, + }) + require.True(t, resp.Created) + require.Equal(t, TxStateAwaitingConfirmation, resp.State) + require.Equal(t, 1, chain.registerConfCount()) + + // 1. First confirmation on the canonical chain. + chain.emitConfirmation(t, txid, 101) + first := mustAwaitNotification(t, sub) + confirmed, ok := first.(*TxConfirmed) + require.True(t, ok, "first event must be TxConfirmed") + require.Equal(t, txid, confirmed.Txid) + require.Equal(t, int32(101), confirmed.BlockHeight) + + // The conf watch must NOT have been released yet: the entry is + // still in the reversible Confirmed state. + require.Equal(t, 0, chain.unregisterConfCount()) + + // 2. Reorg evicts that confirmation. + chain.emitConfReorged(t, txid) + second := mustAwaitNotification(t, sub) + reorged, ok := second.(*TxReorged) + require.True(t, ok, "second event must be TxReorged") + require.Equal(t, txid, reorged.Txid) + require.Equal(t, 0, chain.unregisterConfCount()) + + // 3. Transaction re-confirms on the new tip. + chain.emitConfirmation(t, txid, 102) + third := mustAwaitNotification(t, sub) + reConfirmed, ok := third.(*TxConfirmed) + require.True(t, ok, "third event must be TxConfirmed") + require.Equal(t, int32(102), reConfirmed.BlockHeight) + require.Equal(t, 0, chain.unregisterConfCount()) + + // 4. Finality. After TxFinalized the entry evicts. + chain.emitConfDone(t, txid) + fourth := mustAwaitNotification(t, sub) + finalized, ok := fourth.(*TxFinalized) + require.True(t, ok, "fourth event must be TxFinalized") + require.Equal(t, txid, finalized.Txid) + + mustEventually(t, func() bool { + return chain.unregisterConfCount() == 1 + }) + + // Cancel for the now-evicted entry must observe an empty map: Removed + // is false because there is nothing to remove. + cancelResp := mustCancel(t, ref.Ref(), &CancelInterestReq{ + Txid: txid, + SubscriberID: sub.ID(), + }) + require.False(t, cancelResp.Removed) +} + +// TestEnsureConfirmedDoneDuringReorgGapDropped pins the documented +// edge-case semantics: if the chainsource backend fires Done while the +// tracked entry is in AwaitingConfirmation (post-reorg, pre-re-confirm), +// txconfirm drops the Done rather than advancing to Finalized. The +// realistic backends (chainntnfs, lndclient) do not fire Done during a +// reorg gap, but this test pins the guard so a future backend change +// cannot silently land the entry in Finalized off a non-Confirmed +// state, and so an accidental relaxation of the guard fails loudly. +func TestEnsureConfirmedDoneDuringReorgGapDropped(t *testing.T) { + chain := newFakeChainSourceRef(100) + ref, _ := newTestActor(t, Config{ + ChainSource: chain, + }) + + tx := makeTestTx(false) + txid := tx.TxHash() + sub := actor.NewChannelTellOnlyRef[Notification]("sub", 16) + + mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ + Tx: tx, + Subscriber: sub, + }) + + // Confirm, then reorg out — entry is now in AwaitingConfirmation. + chain.emitConfirmation(t, txid, 101) + first := mustAwaitNotification(t, sub) + _, ok := first.(*TxConfirmed) + require.True(t, ok, "first event must be TxConfirmed") + + chain.emitConfReorged(t, txid) + second := mustAwaitNotification(t, sub) + _, ok = second.(*TxReorged) + require.True(t, ok, "second event must be TxReorged") + + // Fire Done out-of-band, before any re-confirmation. txconfirm + // must NOT advance the entry to Finalized, must NOT deliver + // TxFinalized to the subscriber, and must NOT unregister the + // conf watch. + chain.emitConfDone(t, txid) + + // Re-issuing EnsureConfirmedReq for the same (txid, params) is a + // no-op attach that flushes the mailbox: by the time the response + // returns, the queued confirmationDoneMsg has been processed (or + // in this case, logged and dropped). The reported state must + // remain AwaitingConfirmation. + probe := mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ + Tx: tx, + Subscriber: sub, + }) + require.False(t, probe.Created) + require.Equal( + t, TxStateAwaitingConfirmation, probe.State, + "Done during reorg gap must not promote entry to Finalized", + ) + + // No TxFinalized notification should have been delivered. + mustHaveNoNotification(t, sub) + + require.Equal( + t, 0, chain.unregisterConfCount(), + "dropped Done must not release the conf watch", + ) +} diff --git a/txconfirm/states.go b/txconfirm/states.go index 3555c5ce7..1b173fdad 100644 --- a/txconfirm/states.go +++ b/txconfirm/states.go @@ -249,7 +249,10 @@ func (s *trackedTxStateFeeBumping) ProcessEvent(_ context.Context, } } -// trackedTxStateConfirmed is the terminal confirmed state. +// trackedTxStateConfirmed is the reorg-reversible confirmed state. A +// transaction in this state has reached its target confirmation count on +// the canonical chain; a subsequent reorg moves it back to +// AwaitingConfirmation, and finality moves it to Finalized. type trackedTxStateConfirmed struct { trackedTxData trackedTxProgress @@ -263,19 +266,74 @@ func (s *trackedTxStateConfirmed) String() string { return "Confirmed" } -// IsTerminal returns true because confirmed is terminal. +// IsTerminal returns false because the confirmation is reversible until +// the backend reports finality via trackedTxFinalized. func (s *trackedTxStateConfirmed) IsTerminal() bool { - return true + return false } // trackedTxStateSealed marks trackedTxStateConfirmed as a tracked-tx state. func (s *trackedTxStateConfirmed) trackedTxStateSealed() {} -// ProcessEvent rejects unexpected events in the terminal confirmed state. +// ProcessEvent applies one event to the confirmed state. A reorg moves +// the FSM back to AwaitingConfirmation; finality moves it to the terminal +// Finalized state. func (s *trackedTxStateConfirmed) ProcessEvent(_ context.Context, event trackedTxEvent, _ *trackedTxEnvironment) ( *trackedTxStateTransition, error) { + switch event.(type) { + case *trackedTxReorged: + return &trackedTxStateTransition{ + NextState: &trackedTxStateAwaitingConfirmation{ + trackedTxData: s.trackedTxData, + trackedTxProgress: s.trackedTxProgress, + }, + }, nil + + case *trackedTxFinalized: + return &trackedTxStateTransition{ + NextState: &trackedTxStateFinalized{ + trackedTxData: s.trackedTxData, + trackedTxProgress: s.trackedTxProgress, + ConfirmHeight: s.ConfirmHeight, + }, + }, nil + + default: + return nil, fmt.Errorf("unexpected event %T in %s", event, s) + } +} + +// trackedTxStateFinalized is the terminal "confirmed past reorg safety +// depth" state. No further events are accepted. +type trackedTxStateFinalized struct { + trackedTxData + trackedTxProgress + + // ConfirmHeight is the block height where the tx confirmed before + // being finalized. + ConfirmHeight int32 +} + +// String returns a human-readable representation of the finalized state. +func (s *trackedTxStateFinalized) String() string { + return "Finalized" +} + +// IsTerminal returns true because finalized is terminal. +func (s *trackedTxStateFinalized) IsTerminal() bool { + return true +} + +// trackedTxStateSealed marks trackedTxStateFinalized as a tracked-tx state. +func (s *trackedTxStateFinalized) trackedTxStateSealed() {} + +// ProcessEvent rejects unexpected events in the terminal finalized state. +func (s *trackedTxStateFinalized) ProcessEvent(_ context.Context, + event trackedTxEvent, _ *trackedTxEnvironment) ( + *trackedTxStateTransition, error) { + return nil, fmt.Errorf("unexpected event %T in %s", event, s) } diff --git a/wallet/boarding_sweep_actor.go b/wallet/boarding_sweep_actor.go index 255e54fab..2620ee413 100644 --- a/wallet/boarding_sweep_actor.go +++ b/wallet/boarding_sweep_actor.go @@ -233,29 +233,154 @@ func (m BoardingSweepSpendNotification) MessageType() string { func (m BoardingSweepSpendNotification) walletMsgSealed() {} -// BoardingSweepTxNotification is a Tell carrying a txconfirm terminal -// notification (confirmation or failure) for a tracked sweep tx, -// re-wrapped from txconfirm.TxConfirmed / txconfirm.TxFailed via +// BoardingSweepTxStatus identifies which point in the txconfirm +// reorg-aware lifecycle drove this notification. Splitting the status +// out (instead of a single `Confirmed bool`) makes it impossible for +// the handler to confuse a reorg-out with a terminal failure: txconfirm +// fan-outs a TxReorged whenever a previously delivered TxConfirmed is +// rolled back on chain, and we must NOT treat that as a sweep failure. +type BoardingSweepTxStatus int + +const ( + // BoardingSweepTxStatusUnknown is the zero value; receiving it + // indicates a programmer error (MapNotification missed a kind). + BoardingSweepTxStatusUnknown BoardingSweepTxStatus = iota + + // BoardingSweepTxStatusConfirmed reports that the sweep + // transaction has been observed on the canonical chain at + // BlockHeight with NumConfs confirmations. The observation is + // provisional until BoardingSweepTxStatusFinalized arrives — a + // reorg can roll it back via BoardingSweepTxStatusReorged. + BoardingSweepTxStatusConfirmed + + // BoardingSweepTxStatusReorged reports that a previously + // delivered TxConfirmed for this sweep was reorged out. The + // handler must NOT call MarkBoardingSweepFailed; instead it + // leaves the spend watches and pending state armed so the next + // TxConfirmed on the new canonical chain (or an eventual + // TxFailed) drives the terminal decision. + BoardingSweepTxStatusReorged + + // BoardingSweepTxStatusFinalized reports that the sweep + // confirmation is past the chainsource backend's reorg-safety + // depth and is no longer reversible. The handler can release + // any reorg-recovery bookkeeping for this sweep. + BoardingSweepTxStatusFinalized + + // BoardingSweepTxStatusFailed reports a terminal failure from + // txconfirm (broadcast rejected, retry budget exhausted, etc.). + // The handler marks the sweep failed in the store and cleans up + // pending state. + BoardingSweepTxStatusFailed +) + +// classifyTxconfirmNotificationForBoardingSweep maps one event from +// the txconfirm reorg-aware lifecycle into the wallet-domain +// BoardingSweepTxNotification shape consumed by +// handleSweepTxNotification. Pulled out of submitSweepConfirmer so the +// systest (TestBoardingSweepReorgRoundTrip) can reuse the exact same +// classifier the production wiring uses; if the production wiring +// drifts from the systest classifier, the test stops validating +// production behavior. +func classifyTxconfirmNotificationForBoardingSweep( + n txconfirm.Notification) BoardingSweepTxNotification { + + switch ev := n.(type) { + case *txconfirm.TxConfirmed: + return BoardingSweepTxNotification{ + Status: BoardingSweepTxStatusConfirmed, + + Txid: ev.Txid, + BlockHeight: ev.BlockHeight, + NumConfs: ev.NumConfs, + } + + case *txconfirm.TxReorged: + // Reorg-out: do NOT mark the sweep as failed in the + // handler. Leave spend watches and pending state armed so + // the next TxConfirmed on the new canonical chain (or an + // eventual TxFailed) drives the terminal decision. + return BoardingSweepTxNotification{ + Status: BoardingSweepTxStatusReorged, + Txid: ev.Txid, + } + + case *txconfirm.TxFinalized: + // Reorg-safety horizon reached. The sweep observation is + // no longer reversible; the handler may release reorg- + // recovery bookkeeping. Audit/ledger emission already + // fired on the original Confirmed (idempotent on replay). + return BoardingSweepTxNotification{ + Status: BoardingSweepTxStatusFinalized, + + Txid: ev.Txid, + BlockHeight: ev.BlockHeight, + NumConfs: ev.NumConfs, + } + + case *txconfirm.TxFailed: + return BoardingSweepTxNotification{ + Status: BoardingSweepTxStatusFailed, + Txid: ev.Txid, + Reason: ev.Reason, + } + } + + return BoardingSweepTxNotification{} +} + +// NewBoardingSweepTxconfirmSubscriber wires the production boarding- +// sweep classifier onto a txconfirm.MapNotification anchored at +// selfRef. The returned ref can be set as the Subscriber field on a +// txconfirm.EnsureConfirmedReq; every TxConfirmed / TxReorged / +// TxFinalized / TxFailed delivered for that sweep will be classified +// into a BoardingSweepTxNotification and Tell'd to selfRef as a +// WalletMsg, which the wallet actor's Receive arm dispatches to +// handleSweepTxNotification. +// +// Exported so the systest can construct the exact same subscriber +// chain that submitSweepConfirmer uses in production. +func NewBoardingSweepTxconfirmSubscriber( + selfRef actor.TellOnlyRef[WalletMsg], +) actor.TellOnlyRef[txconfirm.Notification] { + + walletNotif := actor.NewMapInputRef[ + BoardingSweepTxNotification, WalletMsg, + ]( + selfRef, + func(n BoardingSweepTxNotification) WalletMsg { + return n + }, + ) + + return txconfirm.MapNotification( + walletNotif, classifyTxconfirmNotificationForBoardingSweep, + ) +} + +// BoardingSweepTxNotification is a Tell carrying one event from the +// txconfirm reorg-aware lifecycle for a tracked sweep tx, re-wrapped +// from txconfirm.{TxConfirmed,TxReorged,TxFinalized,TxFailed} via // txconfirm.MapNotification. type BoardingSweepTxNotification struct { actor.BaseMessage - // Confirmed is true when the underlying txconfirm.TxConfirmed event - // fired; false when it was txconfirm.TxFailed. - Confirmed bool + // Status identifies which lifecycle event this notification + // carries. See BoardingSweepTxStatus. + Status BoardingSweepTxStatus // Txid identifies the tracked sweep transaction. Txid chainhash.Hash // BlockHeight is the height at which the sweep confirmed when - // Confirmed=true; zero otherwise. + // Status=Confirmed or Finalized; zero otherwise. BlockHeight int32 - // NumConfs is the confirmation count when Confirmed=true; zero - // otherwise. + // NumConfs is the confirmation count when Status=Confirmed or + // Finalized; zero otherwise. NumConfs uint32 - // Reason is the human-readable failure reason when Confirmed=false. + // Reason is the human-readable failure reason when Status=Failed. Reason string } @@ -430,6 +555,16 @@ func (a *Ark) loadSweepCandidates(ctx context.Context, return intents, nil } +// isTerminalSuccessSweepStatus reports whether a persisted boarding-sweep +// status represents a resolved sweep whose accounting is already booked and +// must not be rolled back to failed: confirmed (our sweep landed) or +// external_resolved (the input was spent by another path). A spurious +// TxFailed for such a sweep is ignored. +func isTerminalSuccessSweepStatus(status string) bool { + return status == BoardingSweepStatusConfirmed || + status == BoardingSweepStatusExternalResolved +} + // defaultBoardingSweepStatuses are the boarding-intent statuses considered // candidates for an aggregate timeout sweep when no outpoint set is // supplied. @@ -868,37 +1003,11 @@ func (a *Ark) cancelSweepSpendWatches(ctx context.Context, func (a *Ark) submitSweepConfirmer(ctx context.Context, tx *wire.MsgTx, pkScript []byte, heightHint uint32) error { - walletNotif := actor.NewMapInputRef[ - BoardingSweepTxNotification, WalletMsg, - ]( - a.selfRef, - func(n BoardingSweepTxNotification) WalletMsg { - return n - }, - ) - - subscriber := txconfirm.MapNotification(walletNotif, - func(n txconfirm.Notification) BoardingSweepTxNotification { - switch ev := n.(type) { - case *txconfirm.TxConfirmed: - return BoardingSweepTxNotification{ - Confirmed: true, - Txid: ev.Txid, - BlockHeight: ev.BlockHeight, - NumConfs: ev.NumConfs, - } - - case *txconfirm.TxFailed: - return BoardingSweepTxNotification{ - Confirmed: false, - Txid: ev.Txid, - Reason: ev.Reason, - } - } + if a.actorSystem == nil { + return fmt.Errorf("actor system unavailable") + } - return BoardingSweepTxNotification{} - }, - ) + subscriber := NewBoardingSweepTxconfirmSubscriber(a.selfRef) return a.submitSweepToConfirm( ctx, tx, pkScript, heightHint, boardingSweepBroadcastLabel, @@ -1061,8 +1170,8 @@ func (a *Ark) handleSweepTxNotification(ctx context.Context, return fn.Ok[WalletResp](&BoardingSweepNotificationAck{}) } - switch { - case notif.Confirmed: + switch notif.Status { + case BoardingSweepTxStatusConfirmed: a.logger(ctx).DebugS( ctx, "Boarding sweep confirmation observed by broadcaster", @@ -1074,7 +1183,74 @@ func (a *Ark) handleSweepTxNotification(ctx context.Context, a.emitSweepConfirmedLedger(ctx, notif) - default: + case BoardingSweepTxStatusReorged: + // A previously delivered TxConfirmed for this sweep was + // reorged out. Do NOT mark the sweep as failed and do NOT + // tear down the pending entry: txconfirm keeps the watch + // alive, so a subsequent TxConfirmed on the new canonical + // chain will re-run reconcileSweepInputsOnConfirm with the + // new height. MarkBoardingSweepInputSpent is idempotent on + // (outpoint, txid), and the ledger emissions use txid-keyed + // idempotency, so the second confirmation will not produce + // duplicate audit/balance entries. + // + // Best-effort backstop only: the per-input chainsource + // spend watch fires handleSweepSpendNotification if some + // other party spends an input. That spend-watch path is + // not itself reorg-symmetric — MapSpendEvent collapses the + // chainsource SpendEvent lifecycle to a single + // BoardingSweepSpendNotification without surfacing + // Reorged / Done — so a reorged-out external spender will + // leave the input row marked external_spent until manual + // reconciliation. Closing that gap is tracked separately. + a.logger(ctx).WarnS( + ctx, + "Boarding sweep confirmation reorged out; waiting "+ + "for re-confirmation or external spend", + nil, + slog.String("txid", notif.Txid.String()), + ) + + case BoardingSweepTxStatusFinalized: + // Confirmation is past the backend's reorg-safety depth. + // No further reversible events will fire for this sweep — + // reorg-recovery bookkeeping can drop. Audit/ledger + // emission already ran on Confirmed and is idempotent on + // replay, so we do not re-emit here. + a.logger(ctx).DebugS( + ctx, + "Boarding sweep confirmation finalized", + nil, + slog.String("txid", notif.Txid.String()), + slog.Int("block_height", int(notif.BlockHeight)), + ) + + case BoardingSweepTxStatusFailed: + // Defensive guard against a failure arriving for a sweep that + // already confirmed. txconfirm's contract is that TxFailed + // never follows a real TxConfirmed, but the confirmed sweep's + // ledger legs (fee + per-input + destination) are irreversible + // and txid-keyed, so rolling the intents back to failed here + // would diverge the store from the ledger. If the persisted + // record shows the sweep already reached a terminal-success + // status, ignore the failure rather than undo a booked sweep. + if rec, ok := a.lookupSweepRecord(ctx, notif.Txid); ok && + isTerminalSuccessSweepStatus(rec.Status) { + + a.logger(ctx).WarnS( + ctx, + "Ignoring boarding sweep failure for an "+ + "already-resolved sweep", + errors.New(notif.Reason), + slog.String("txid", notif.Txid.String()), + slog.String("status", rec.Status), + ) + + return fn.Ok[WalletResp]( + &BoardingSweepNotificationAck{}, + ) + } + a.logger(ctx).WarnS( ctx, "Boarding sweep broadcaster reported failure", @@ -1104,6 +1280,14 @@ func (a *Ark) handleSweepTxNotification(ctx context.Context, if pending != nil { a.cancelSweepSpendWatches(ctx, pending) } + + default: + a.logger(ctx).WarnS( + ctx, + "Boarding sweep tx notification with unknown status", + fmt.Errorf("status=%d", notif.Status), + slog.String("txid", notif.Txid.String()), + ) } return fn.Ok[WalletResp](&BoardingSweepNotificationAck{}) @@ -1116,6 +1300,18 @@ func (a *Ark) handleSweepTxNotification(ctx context.Context, // the spending txid is the sweep's own txid. MarkBoardingSweepInputSpent // is idempotent, so inputs already resolved via the spend-notification path // are left untouched. +// +// Under the reorg-aware lifecycle a second TxConfirmed can land after a +// TxReorged / re-confirmation cycle. The store's status guard rejects +// the redundant transition with sql.ErrNoRows; that signals "input row +// already advanced past pending/published" and is treated as a benign +// no-op, mirroring how handleSweepSpendNotification classifies the +// same error. Other errors still log at warn because they indicate a +// real persistence problem. Note: the input row's confirmed_height +// stays pinned to the FIRST Confirmed observation — a deliberate +// first-observation-wins audit policy that survives reorg-reconfirm +// at a different height; the reorg-safety horizon is reported +// separately via the Finalized lifecycle event. func (a *Ark) reconcileSweepInputsOnConfirm(ctx context.Context, notif BoardingSweepTxNotification) { @@ -1128,7 +1324,17 @@ func (a *Ark) reconcileSweepInputsOnConfirm(ctx context.Context, _, err := a.sweepStore.MarkBoardingSweepInputSpent( ctx, op, notif.Txid, notif.BlockHeight, ) - if err != nil { + switch { + case err == nil: + // Success. + + case errors.Is(err, sql.ErrNoRows): + // Row already past pending/published — likely + // resolved via handleSweepSpendNotification or a + // previous Confirmed in a reorg-reconfirm cycle. + // Idempotent no-op. + + default: a.logger(ctx).WarnS( ctx, "Failed to mark sweep input spent on confirm", diff --git a/wallet/boarding_sweep_actor_test.go b/wallet/boarding_sweep_actor_test.go index a1030f27f..a8d103eae 100644 --- a/wallet/boarding_sweep_actor_test.go +++ b/wallet/boarding_sweep_actor_test.go @@ -599,7 +599,7 @@ func TestSweepTxNotificationConfirmedEmitsLedger(t *testing.T) { result := a.handleSweepTxNotification( t.Context(), BoardingSweepTxNotification{ - Confirmed: true, + Status: BoardingSweepTxStatusConfirmed, Txid: swept, BlockHeight: 800_650, NumConfs: 1, @@ -719,7 +719,7 @@ func TestSweepTxNotificationConfirmedExternalDestSkipsCreated(t *testing.T) { result := a.handleSweepTxNotification( t.Context(), BoardingSweepTxNotification{ - Confirmed: true, + Status: BoardingSweepTxStatusConfirmed, Txid: swept, BlockHeight: 800_700, }, @@ -854,12 +854,13 @@ func TestSweepLedgerClearingNetsToZero(t *testing.T) { ), ) + notif := BoardingSweepTxNotification{ + Status: BoardingSweepTxStatusConfirmed, + Txid: swept, + BlockHeight: 800_800, + } result := a.handleSweepTxNotification( - t.Context(), BoardingSweepTxNotification{ - Confirmed: true, - Txid: swept, - BlockHeight: 800_800, - }, + t.Context(), notif, ) require.True(t, result.IsOk()) @@ -934,7 +935,7 @@ func TestSweepTxNotificationMissingTxSkipsLegs(t *testing.T) { result := a.handleSweepTxNotification( t.Context(), BoardingSweepTxNotification{ - Confirmed: true, + Status: BoardingSweepTxStatusConfirmed, Txid: swept, BlockHeight: 800_900, }, @@ -962,6 +963,12 @@ func TestSweepTxNotificationFailedMarksFailed(t *testing.T) { mock.Anything, ).Return(nil) + // The Failed arm looks the sweep up first to ignore failures for an + // already-resolved sweep; absent record means the failure proceeds. + store.On( + "GetBoardingSweep", mock.Anything, failedTxid, + ).Return(nil, nil) + a := newSweepTestArk(t, store, nil, 0, 0) a.pendingSweeps[failedTxid] = &pendingSweepState{ txid: failedTxid, @@ -970,9 +977,9 @@ func TestSweepTxNotificationFailedMarksFailed(t *testing.T) { result := a.handleSweepTxNotification( t.Context(), BoardingSweepTxNotification{ - Confirmed: false, - Txid: failedTxid, - Reason: "test failure", + Status: BoardingSweepTxStatusFailed, + Txid: failedTxid, + Reason: "test failure", }, ) require.True(t, result.IsOk()) @@ -980,3 +987,245 @@ func TestSweepTxNotificationFailedMarksFailed(t *testing.T) { store.AssertExpectations(t) } + +// TestSweepTxNotificationReorgedDoesNotMarkFailed verifies that a +// TxReorged event from txconfirm — which arrives whenever a previously +// observed confirmation is rolled back on chain — must NOT be treated +// as a sweep failure. The handler should leave pendingSweeps and the +// persistent sweep record intact so that the next TxConfirmed on the +// new canonical chain (or, in the worst case, a chainsource spend +// notification for some other spender of the inputs) drives the +// terminal decision. +func TestSweepTxNotificationReorgedDoesNotMarkFailed(t *testing.T) { + t.Parallel() + + reorgedTxid := chainhash.Hash{0xa1} + store := &MockBoardingSweepStore{} + // CRITICAL: MarkBoardingSweepFailed must NOT be called on reorg. + // testify/mock will fail the test if any unexpected method is + // invoked, so we simply do not register MarkBoardingSweepFailed + // here and rely on AssertExpectations to verify the negative. + + a := newSweepTestArk(t, store, nil, 0, 0) + pending := &pendingSweepState{ + txid: reorgedTxid, + inputs: map[wire.OutPoint]string{}, + } + a.pendingSweeps[reorgedTxid] = pending + + result := a.handleSweepTxNotification( + t.Context(), BoardingSweepTxNotification{ + Status: BoardingSweepTxStatusReorged, + Txid: reorgedTxid, + }, + ) + require.True(t, result.IsOk()) + + // Pending state must remain intact: a re-confirmation on the new + // canonical chain has to find the same entry to drive its + // reconcileSweepInputsOnConfirm pass. + require.Same( + t, pending, a.pendingSweeps[reorgedTxid], + "reorg must not evict the pending sweep entry", + ) + + // Mock did not register MarkBoardingSweepFailed; AssertExpectations + // passes vacuously, but any call would have failed the mock. + store.AssertExpectations(t) +} + +// TestSweepTxNotificationFinalizedIsBenign verifies that a TxFinalized +// event (the chainsource reorg-safety horizon is reached) does not +// mark the sweep failed, does not re-emit ledger entries, and does +// not tear down pending state. Pending state remains because the +// terminal release path is the same as the legacy confirmation path — +// post-finalization cleanup is a no-op since reconcileSweepInputsOnConfirm +// already ran on the original TxConfirmed. +func TestSweepTxNotificationFinalizedIsBenign(t *testing.T) { + t.Parallel() + + finalizedTxid := chainhash.Hash{0xa2} + store := &MockBoardingSweepStore{} + // No expectations registered — finalized must not call any + // store mutation method. + + a := newSweepTestArk(t, store, nil, 0, 0) + pending := &pendingSweepState{ + txid: finalizedTxid, + inputs: map[wire.OutPoint]string{}, + } + a.pendingSweeps[finalizedTxid] = pending + + result := a.handleSweepTxNotification( + t.Context(), BoardingSweepTxNotification{ + Status: BoardingSweepTxStatusFinalized, + Txid: finalizedTxid, + BlockHeight: 800_750, + NumConfs: 6, + }, + ) + require.True(t, result.IsOk()) + + // Pending state should still be present; finalized is informational. + require.Same(t, pending, a.pendingSweeps[finalizedTxid]) + + store.AssertExpectations(t) +} + +// TestSweepTxNotificationReorgedAfterPendingCleared verifies the +// Reorged handler arm survives a missing pendingSweeps entry (which +// can happen when every per-input spend notification has already +// resolved and the entry was cleaned up by handleSweepSpendNotification +// before the tx-level reorg notification arrives). +func TestSweepTxNotificationReorgedAfterPendingCleared(t *testing.T) { + t.Parallel() + + reorgedTxid := chainhash.Hash{0xa3} + store := &MockBoardingSweepStore{} + // No store expectations — reorg with no pending entry must touch + // nothing. + + a := newSweepTestArk(t, store, nil, 0, 0) + // Note: pendingSweeps is intentionally empty. + + result := a.handleSweepTxNotification( + t.Context(), BoardingSweepTxNotification{ + Status: BoardingSweepTxStatusReorged, + Txid: reorgedTxid, + }, + ) + require.True(t, result.IsOk()) + require.Empty(t, a.pendingSweeps) + + store.AssertExpectations(t) +} + +// TestSweepTxNotificationFailedAfterReorgedStillTerminates verifies +// the Reorged arm does NOT suppress a subsequent terminal Failed +// notification: a sequence of Reorged then Failed must still call +// MarkBoardingSweepFailed and drop the pending entry, otherwise a +// reorg followed by a hard broadcast failure would be silently +// stranded. +func TestSweepTxNotificationFailedAfterReorgedStillTerminates(t *testing.T) { + t.Parallel() + + txid := chainhash.Hash{0xa4} + store := &MockBoardingSweepStore{} + store.On( + "MarkBoardingSweepFailed", mock.Anything, txid, + mock.Anything, + ).Return(nil) + + // The Failed arm now looks the sweep up first to ignore a spurious + // failure for an already-resolved sweep. Here the record is absent + // (not terminal-success), so the failure path proceeds as before. + store.On( + "GetBoardingSweep", mock.Anything, txid, + ).Return(nil, nil) + + a := newSweepTestArk(t, store, nil, 0, 0) + pending := &pendingSweepState{ + txid: txid, + inputs: map[wire.OutPoint]string{}, + } + a.pendingSweeps[txid] = pending + + // Step 1: Reorged — pending should remain, store should not be + // touched. + reorgResult := a.handleSweepTxNotification( + t.Context(), BoardingSweepTxNotification{ + Status: BoardingSweepTxStatusReorged, + Txid: txid, + }, + ) + require.True(t, reorgResult.IsOk()) + require.Same(t, pending, a.pendingSweeps[txid]) + + // Step 2: Failed — terminal path must still fire even though we + // passed through Reorged first. + failResult := a.handleSweepTxNotification( + t.Context(), BoardingSweepTxNotification{ + Status: BoardingSweepTxStatusFailed, + Txid: txid, + Reason: "post-reorg broadcast failure", + }, + ) + require.True(t, failResult.IsOk()) + require.Empty( + t, a.pendingSweeps, + "Failed after Reorged must still tear down pendingSweeps", + ) + + store.AssertExpectations(t) +} + +// TestSweepTxNotificationFailedIgnoredForConfirmedSweep verifies the +// defensive guard on the Failed arm: a spurious Failed notification for a +// sweep whose persisted record already shows a terminal-success status +// (confirmed) must NOT roll the sweep back to failed, because the +// confirmed sweep's ledger legs are irreversible and txid-keyed. +func TestSweepTxNotificationFailedIgnoredForConfirmedSweep(t *testing.T) { + t.Parallel() + + txid := chainhash.Hash{0xa6} + store := &MockBoardingSweepStore{} + + // The record is already confirmed, so the guard must short-circuit + // before MarkBoardingSweepFailed; no expectation is set for it, so a + // call would fail the test. + store.On( + "GetBoardingSweep", mock.Anything, txid, + ).Return(&BoardingSweepRecord{ + Status: BoardingSweepStatusConfirmed, + }, nil) + + a := newSweepTestArk(t, store, nil, 0, 0) + pending := &pendingSweepState{ + txid: txid, + inputs: map[wire.OutPoint]string{}, + } + a.pendingSweeps[txid] = pending + + failResult := a.handleSweepTxNotification( + t.Context(), BoardingSweepTxNotification{ + Status: BoardingSweepTxStatusFailed, + Txid: txid, + Reason: "spurious failure after confirmation", + }, + ) + require.True(t, failResult.IsOk()) + + store.AssertNotCalled(t, "MarkBoardingSweepFailed") + store.AssertExpectations(t) +} + +// TestSweepTxNotificationUnknownStatusIsBenign verifies the default +// arm of handleSweepTxNotification handles an unrecognised status +// without touching the store or pendingSweeps. This guards against a +// future txconfirm lifecycle event being added without a matching +// MapNotification arm. +func TestSweepTxNotificationUnknownStatusIsBenign(t *testing.T) { + t.Parallel() + + txid := chainhash.Hash{0xa5} + store := &MockBoardingSweepStore{} + // No expectations — unknown must touch nothing. + + a := newSweepTestArk(t, store, nil, 0, 0) + pending := &pendingSweepState{ + txid: txid, + inputs: map[wire.OutPoint]string{}, + } + a.pendingSweeps[txid] = pending + + result := a.handleSweepTxNotification( + t.Context(), BoardingSweepTxNotification{ + Status: BoardingSweepTxStatusUnknown, + Txid: txid, + }, + ) + require.True(t, result.IsOk()) + require.Same(t, pending, a.pendingSweeps[txid]) + + store.AssertExpectations(t) +}