diff --git a/chainbackends/lndclient_adapters.go b/chainbackends/lndclient_adapters.go index 9521b7c96..98ad5bc30 100644 --- a/chainbackends/lndclient_adapters.go +++ b/chainbackends/lndclient_adapters.go @@ -2,6 +2,7 @@ package chainbackends import ( "context" + "errors" "fmt" "log/slog" "time" @@ -15,6 +16,8 @@ import ( "github.com/lightningnetwork/lnd/chainntnfs" fn "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) // lndRegistrationTimeout bounds how long a conf/spend registration call into @@ -27,6 +30,19 @@ import ( // genuinely wedged backend surfaces as an error rather than a silent hang. const lndRegistrationTimeout = 15 * time.Second +// isBlockEpochShutdownError reports whether an LND call only failed because +// the owning block-epoch subscription is shutting down. The context check is +// required so an independent Canceled status from a live backend remains an +// actionable warning. +func isBlockEpochShutdownError(ctx context.Context, err error) bool { + if ctx.Err() == nil { + return false + } + + return errors.Is(err, context.Canceled) || + status.Code(err) == codes.Canceled +} + // LndClientTxBroadcaster implements TxBroadcaster using // lndclient.WalletKitClient. type LndClientTxBroadcaster struct { @@ -507,6 +523,20 @@ func (n *LndClientChainNotifier) RegisterBlockEpochNtfn( ctx, int64(height), ) if err != nil { + if isBlockEpochShutdownError(ctx, err) { + log.DebugS(ctx, "Block hash "+ + "lookup cancelled during "+ + "subscription shutdown", + slog.Any("err", err), + slog.Int( + "height", + int(height), + ), + ) + + continue + } + log.WarnS( ctx, "Failed to get block hash", @@ -535,7 +565,18 @@ func (n *LndClientChainNotifier) RegisterBlockEpochNtfn( case err, ok := <-errChan: if ok && err != nil { - log.WarnS(ctx, "Block epoch error", err) + if isBlockEpochShutdownError(ctx, err) { + log.DebugS(ctx, "Block epoch "+ + "subscription cancelled", + slog.Any("err", err), + ) + } else { + log.WarnS( + ctx, + "Block epoch error", + err, + ) + } } return diff --git a/chainbackends/lndclient_adapters_test.go b/chainbackends/lndclient_adapters_test.go index 662578bfc..7963f1a0f 100644 --- a/chainbackends/lndclient_adapters_test.go +++ b/chainbackends/lndclient_adapters_test.go @@ -11,8 +11,65 @@ import ( "github.com/lightninglabs/lndclient" "github.com/lightningnetwork/lnd/chainntnfs" "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) +// TestIsBlockEpochShutdownError pins the classification boundary between an +// expected subscription shutdown and an actionable live-backend failure. +func TestIsBlockEpochShutdownError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cancelContext bool + err error + want bool + }{ + { + name: "live backend canceled status", + err: status.Error(codes.Canceled, "backend canceled"), + }, + { + name: "owning context canceled", + cancelContext: true, + err: context.Canceled, + want: true, + }, + { + name: "shutdown canceled status", + cancelContext: true, + err: status.Error( + codes.Canceled, "subscription closed", + ), + want: true, + }, + { + name: "unrelated shutdown error", + cancelContext: true, + err: errors.New("backend unavailable"), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + ctx := t.Context() + if test.cancelContext { + var cancel context.CancelFunc + ctx, cancel = context.WithCancel(ctx) + cancel() + } + + require.Equal( + t, test.want, + isBlockEpochShutdownError(ctx, test.err), + ) + }) + } +} + // stubLndClientNotifier exposes controllable lndclient notification streams. type stubLndClientNotifier struct { lndclient.ChainNotifierClient diff --git a/round/actor.go b/round/actor.go index de1bfa22a..1928aab6d 100644 --- a/round/actor.go +++ b/round/actor.go @@ -2333,8 +2333,10 @@ func (a *RoundClientActor) handleGetState(ctx context.Context, for keyStr, roundFSM := range a.rounds { roundState, err := fsmState(scanCtx, roundFSM.FSM) if err != nil { - a.log.WarnS(ctx, "Failed to get FSM state for round", - err, + a.log.DebugS( + ctx, + "Skipped round with unreadable FSM state", + slog.Any("err", err), slog.String("key", string(keyStr)), ) @@ -2923,12 +2925,13 @@ func (a *RoundClientActor) processOutbox(ctx context.Context, inputSigState.CommitmentTx, ) - // Index for confirmation routing and register. + // Index the transaction before the confirmation request + // emitted by the FSM can be delivered. The FSM outbox + // owns the steady-state registration; registering again + // here creates two notifier subscriptions for the same + // tx. A restarted actor still re-registers active + // rounds in Start. a.commitmentTxIndex[txid] = keyStr - a.registerCommitmentConfirmation( - ctx, txid, roundFSM.CommitmentTx, - inputSigState.VTXOTreePaths, - ) a.log.InfoS(ctx, "Round checkpoint processed", slog.String("round_id", m.RoundID.String()), diff --git a/round/actor_test.go b/round/actor_test.go index 28072c476..c3353dd7f 100644 --- a/round/actor_test.go +++ b/round/actor_test.go @@ -835,8 +835,20 @@ func TestActorProcessOutbox(t *testing.T) { // Set up a round in InputSigSentState, simulating the state // after the round has completed through partial sigs. commitmentTx := h.setupRoundInInputSigSentState(roundID) + txid := commitmentTx.UnsignedTx.TxHash() + pkScript := confirmationWatchScript( + commitmentTx.UnsignedTx, nil, + ) + targetConfs := h.actor.env.OperatorTerms.MinConfirmations outbox := []ClientOutMsg{ + &RegisterConfirmationRequest{ + CallerID: "commitment-" + txid.String(), + Txid: &txid, + PkScript: pkScript, + TargetConfs: targetConfs, + HeightHint: h.actor.env.StartHeight, + }, &RoundCheckpointedNotification{ RoundID: roundID, }, @@ -850,10 +862,18 @@ func TestActorProcessOutbox(t *testing.T) { keyStr := RoundKeyStr(roundID.KeyString()) require.Contains(t, h.actor.rounds, keyStr) - txid := commitmentTx.UnsignedTx.TxHash() indexedKeyStr, exists := h.actor.commitmentTxIndex[txid] require.True(t, exists) require.Equal(t, keyStr, indexedKeyStr) + + // The FSM outbox registration is sufficient. Processing the + // checkpoint notification must not create a second notifier for + // the same commitment transaction. + require.Len(t, h.chainSource.registrations, 1) + registration := h.chainSource.registrations[0] + require.True(t, registration.Txid.IsEqual(&txid)) + require.Equal(t, pkScript, registration.PkScript) + require.Equal(t, targetConfs, registration.TargetConfs) }) t.Run("new_boarding_creates_round", func(t *testing.T) { diff --git a/round/transitions.go b/round/transitions.go index 6cde9ad33..c79a6bcc7 100644 --- a/round/transitions.go +++ b/round/transitions.go @@ -3097,6 +3097,9 @@ func confirmationWatchScript(commitmentTx *wire.MsgTx, return commitmentTx.TxOut[idx].PkScript } +// forfeitCollectionOutbox orders the reconciliation timeout before every +// fallible external effect, then preserves the server submission sequence +// before registering the commitment confirmation watch. func (s *ForfeitSignaturesCollectingState) forfeitCollectionOutbox( env *ClientEnvironment, forfeitTxs map[wire.OutPoint]*types.ForfeitTxSig, @@ -3115,17 +3118,6 @@ func (s *ForfeitSignaturesCollectingState) forfeitCollectionOutbox( RoundKey: RoundKeyStr(s.RoundID.KeyString()), Phase: TimeoutPhaseForfeitCollection, }, - &SubmitVTXOForfeitSigsToServer{ - RoundID: s.RoundID, - ForfeitTxs: forfeitTxs, - }, - &RegisterConfirmationRequest{ - CallerID: callerID, - Txid: &txid, - PkScript: pkScript, - TargetConfs: env.OperatorTerms.MinConfirmations, - HeightHint: env.StartHeight, - }, } // The forfeit signatures leave the box on this transition, opening the @@ -3144,18 +3136,28 @@ func (s *ForfeitSignaturesCollectingState) forfeitCollectionOutbox( }) } - if len(boardingInputSigs) == 0 { - return outboxMsgs + // The timeout must be armed before either fallible external effect. + // processOutbox stops on the first error, so placing it after the + // signature submission or chain registration could strand the round in + // forfeit collection with no reconciliation path. + outboxMsgs = append(outboxMsgs, &SubmitVTXOForfeitSigsToServer{ + RoundID: s.RoundID, + ForfeitTxs: forfeitTxs, + }) + if len(boardingInputSigs) > 0 { + outboxMsgs = append(outboxMsgs, &SubmitForfeitSigRequest{ + RoundID: s.RoundID, + Signatures: boardingInputSigs, + }) } - return append( - outboxMsgs[:2], append([]ClientOutMsg{ - &SubmitForfeitSigRequest{ - RoundID: s.RoundID, - Signatures: boardingInputSigs, - }, - }, outboxMsgs[2:]...)..., - ) + return append(outboxMsgs, &RegisterConfirmationRequest{ + CallerID: callerID, + Txid: &txid, + PkScript: pkScript, + TargetConfs: env.OperatorTerms.MinConfirmations, + HeightHint: env.StartHeight, + }) } func (s *ForfeitSignaturesCollectingState) checkpointRound( diff --git a/round/vtxo_tree_binding_test.go b/round/vtxo_tree_binding_test.go index 41f8d9129..ec4aec4e8 100644 --- a/round/vtxo_tree_binding_test.go +++ b/round/vtxo_tree_binding_test.go @@ -2,8 +2,10 @@ package round import ( "testing" + "time" "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/psbt/v2" "github.com/btcsuite/btcd/txscript/v2" "github.com/btcsuite/btcd/wire/v2" "github.com/lightninglabs/wavelength/lib/tree" @@ -254,6 +256,50 @@ func TestConfirmationWatchScriptUsesBatchOutput(t *testing.T) { watch := confirmationWatchScript(tx, trees) require.Equal(t, batchScript, watch) require.NotEqual(t, fillerScript, watch) + + // The real FSM outbox must carry the same validated script and + // operator confirmation target. This is the sole steady-state watch; + // restart recovery independently rebuilds the same parameters. + packet, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t, err) + state := &ForfeitSignaturesCollectingState{ + RoundID: testRoundIDTr("nonzero-batch-output"), + CommitmentTx: packet, + VTXOTreePaths: trees, + Intents: Intents{ + Forfeits: []types.ForfeitRequest{ + {}, + }, + }, + } + const targetConfs = 6 + const startHeight = 123 + outbox := state.forfeitCollectionOutbox( + &ClientEnvironment{ + OperatorTerms: &types.OperatorTerms{ + MinConfirmations: targetConfs, + }, + StartHeight: startHeight, + StatusReconcileTimeout: time.Minute, + }, + nil, []*types.BoardingInputSignature{{}}, + ) + + // Keep the timeout ahead of every fallible effect while preserving the + // server submission order: VTXO forfeits, boarding signatures, and then + // chain registration. This must not depend on an insertion index whose + // meaning changes when another outbox message is added. + require.Len(t, outbox, 5) + require.IsType(t, &CancelTimeoutReq{}, outbox[0]) + require.IsType(t, &StartTimeoutReq{}, outbox[1]) + require.IsType(t, &SubmitVTXOForfeitSigsToServer{}, outbox[2]) + require.IsType(t, &SubmitForfeitSigRequest{}, outbox[3]) + registration, ok := outbox[4].(*RegisterConfirmationRequest) + require.True(t, ok) + require.Equal(t, batchScript, registration.PkScript) + require.Equal(t, uint32(targetConfs), registration.TargetConfs) + require.Equal(t, uint32(startHeight), registration.HeightHint) + require.True(t, registration.Txid.IsEqual(&txid)) } // TestCommitmentTxReceivedRejectsUnboundTree confirms the binding is wired into diff --git a/serverconn/ingress.go b/serverconn/ingress.go index a7211b5fa..e6a1eb000 100644 --- a/serverconn/ingress.go +++ b/serverconn/ingress.go @@ -62,6 +62,11 @@ func (a *ServerConnectionActor) ingressLoop(ctx context.Context, var failCount int + // pullFailCount scopes alert suppression to one remote pull outage. + // failCount is shared by every transport and checkpoint backoff, so it + // cannot identify whether a pull failure is the first in its episode. + var pullFailCount int + // When the delivery store supports transactions, each pulled batch is // dispatched and checkpointed in ONE write transaction below. The // ack watermark then rides along with the next dispatch checkpoint @@ -116,7 +121,7 @@ func (a *ServerConnectionActor) ingressLoop(ctx context.Context, // Step 2: Pull a batch of envelopes from the remote mailbox. envelopes, nextCursor, exit, retry := a.pullPhase( - ctx, &state, &ackDirty, &failCount, + ctx, &state, &ackDirty, &failCount, &pullFailCount, ) if exit { return @@ -392,15 +397,16 @@ func (a *ServerConnectionActor) ackPhase(ctx context.Context, state *AckState, // pullPhase pulls the next batch of envelopes from the remote mailbox and // absorbs the two outcomes that are not a batch to dispatch: a failed pull and -// an empty long-poll. It mutates state, ackDirty and failCount in place and -// returns (envelopes, nextCursor, exit, retry) on the same convention as -// ackPhase — exit is true when the loop must stop (local shutdown or a -// permanent version error), and retry is true when the caller should continue -// without dispatching. Both booleans are false only when envelopes holds a -// non-empty batch, and any backoff a retry needs has already been slept here. +// an empty long-poll. It mutates state, ackDirty, the shared backoff counter, +// and the pull-only failure counter in place. It returns (envelopes, +// nextCursor, exit, retry) on the same convention as ackPhase — exit is true +// when the loop must stop (local shutdown or a permanent version error), and +// retry is true when the caller should continue without dispatching. Both +// booleans are false only when envelopes holds a non-empty batch, and any +// backoff a retry needs has already been slept here. func (a *ServerConnectionActor) pullPhase(ctx context.Context, state *AckState, - ackDirty *bool, failCount *int) ([]*mailboxpb.Envelope, uint64, bool, - bool) { + ackDirty *bool, failCount, pullFailCount *int) ([]*mailboxpb.Envelope, + uint64, bool, bool) { envelopes, nextCursor, err := a.pullBatch(ctx, state.PullCursor) if err != nil { @@ -416,15 +422,28 @@ func (a *ServerConnectionActor) pullPhase(ctx context.Context, state *AckState, return nil, 0, true, false } - a.log.WarnS(ctx, "Pull failed, retrying", - err, - slog.Uint64("cursor", state.PullCursor), - ) + if *pullFailCount == 0 { + a.log.WarnS(ctx, "Pull failed, retrying", + err, + slog.Uint64("cursor", state.PullCursor), + ) + } else { + a.log.DebugS(ctx, "Pull retry failed", + slog.Any("err", err), + slog.Uint64("cursor", state.PullCursor), + slog.Int( + "consecutive_failures", + *pullFailCount+1, + ), + ) + } + *pullFailCount++ a.sleepBackoff(ctx, failCount) return nil, 0, false, true } + *pullFailCount = 0 // The pull returned, so the one goroutine that consumes the remote // mailbox is still running its loop. Stamping here rather than after diff --git a/unroll/AGENTS.md b/unroll/AGENTS.md index db008b220..fba247137 100644 --- a/unroll/AGENTS.md +++ b/unroll/AGENTS.md @@ -185,7 +185,13 @@ For field-level detail, use `go doc github.com/lightninglabs/wavelength/unroll.< fallback anchors to tip minus a lookback (never `CreatedHeight`), bounding the neutrino historical rescan instead of scanning to genesis (wavelength#884). The fallback path warns when the VTXO's age exceeds the - lookback (the floor may then miss an already-confirmed ancestor). + lookback (the floor may then miss an already-confirmed ancestor). One + process-local `proofNodeFloorAlertDeduper` is shared by every child of an + unroll registry, so targets with the same proof ancestor produce one warning + per proof transaction and process lifetime. That warning carries the first + affected target's outpoint, age, and creation height. A Debug record retains + the same evidence for every target. A restart creates a new deduper and + warns again if the condition persists. ## Relationships diff --git a/unroll/CLAUDE.md b/unroll/CLAUDE.md index db008b220..fba247137 100644 --- a/unroll/CLAUDE.md +++ b/unroll/CLAUDE.md @@ -185,7 +185,13 @@ For field-level detail, use `go doc github.com/lightninglabs/wavelength/unroll.< fallback anchors to tip minus a lookback (never `CreatedHeight`), bounding the neutrino historical rescan instead of scanning to genesis (wavelength#884). The fallback path warns when the VTXO's age exceeds the - lookback (the floor may then miss an already-confirmed ancestor). + lookback (the floor may then miss an already-confirmed ancestor). One + process-local `proofNodeFloorAlertDeduper` is shared by every child of an + unroll registry, so targets with the same proof ancestor produce one warning + per proof transaction and process lifetime. That warning carries the first + affected target's outpoint, age, and creation height. A Debug record retains + the same evidence for every target. A restart creates a new deduper and + warns again if the condition persists. ## Relationships diff --git a/unroll/actor.go b/unroll/actor.go index ac5345d0d..01142a0a7 100644 --- a/unroll/actor.go +++ b/unroll/actor.go @@ -75,13 +75,18 @@ type Config struct { // LedgerSink receives the confirmed on-chain exit fee once the // final sweep has confirmed. LedgerSink fn.Option[ledger.Sink] + + // proofNodeFloorAlerts is shared by every child of one registry so + // targets with the same proof ancestor produce one operator alert. + proofNodeFloorAlerts *proofNodeFloorAlertDeduper } // VTXOUnrollActor wraps one durable per-target unroll actor. type VTXOUnrollActor struct { - ref actor.ActorRef[Msg, Resp] - durable *actor.DurableActor[Msg, Resp] - stop func() + ref actor.ActorRef[Msg, Resp] + durable *actor.DurableActor[Msg, Resp] + stop func() + behavior *behavior } // Ref returns the public actor reference. @@ -111,6 +116,9 @@ func NewVTXOUnrollActor(cfg Config) (*VTXOUnrollActor, error) { if cfg.ActorID == "" { cfg.ActorID = actorIDForTarget(cfg.TargetOutpoint) } + if cfg.proofNodeFloorAlerts == nil { + cfg.proofNodeFloorAlerts = newProofNodeFloorAlertDeduper() + } behavior := &behavior{ cfg: cfg, @@ -134,9 +142,10 @@ func NewVTXOUnrollActor(cfg Config) (*VTXOUnrollActor, error) { durable.Start() return &VTXOUnrollActor{ - ref: durable.Ref(), - durable: durable, - stop: durable.Stop, + ref: durable.Ref(), + durable: durable, + stop: durable.Stop, + behavior: behavior, }, nil } @@ -865,9 +874,35 @@ func (b *behavior) proofNodeConfHeightHint(ctx context.Context, age := int64(currentHeight) - int64(b.desc.CreatedHeight) if age >= int64(proofNodeHeightHintLookback) { b.proofNodeFloorWarned = true - b.log.WarnS(ctx, "Proof-node confirmation floor may "+ - "exceed ancestor height; exit could stall", - nil, + firstAlert := b.cfg.proofNodeFloorAlerts == nil || + b.cfg.proofNodeFloorAlerts.first(txid) + if firstAlert { + b.log.WarnS(ctx, "Proof-node confirmation "+ + "floor may exceed ancestor height; "+ + "exits could stall", + nil, + slog.String( + "target_outpoint", + b.cfg.TargetOutpoint.String(), + ), + slog.String("proof_txid", txid.String()), + slog.Int64("vtxo_age_blocks", age), + slog.Uint64( + "lookback", uint64( + proofNodeHeightHintLookback, + ), + ), + slog.Uint64( + "height_hint", uint64(hint), + ), + slog.Int64( + "created_height", + int64(b.desc.CreatedHeight), + ), + ) + } + + b.log.DebugS(ctx, "Proof-node confirmation floor target", slog.String( "target_outpoint", b.cfg.TargetOutpoint.String(), diff --git a/unroll/actor_test.go b/unroll/actor_test.go index 72880ca46..e4ba8918b 100644 --- a/unroll/actor_test.go +++ b/unroll/actor_test.go @@ -773,6 +773,15 @@ type memCheckpointStore struct { checkpoints map[string]*actor.Checkpoint } +// ExecTx lets durable actor tests exercise the transaction-aware path. The +// in-memory methods take their own locks, so the callback can use this store +// directly without a separate transaction handle. +func (s *memCheckpointStore) ExecTx(ctx context.Context, _ bool, + fn actor.TxFunc) error { + + return fn(ctx, s) +} + // newMemCheckpointStore creates a new in-memory checkpoint store. func newMemCheckpointStore() *memCheckpointStore { return &memCheckpointStore{ diff --git a/unroll/proof_floor_alert.go b/unroll/proof_floor_alert.go new file mode 100644 index 000000000..9c9ad3392 --- /dev/null +++ b/unroll/proof_floor_alert.go @@ -0,0 +1,39 @@ +package unroll + +import ( + "sync" + + "github.com/btcsuite/btcd/chainhash/v2" +) + +// proofNodeFloorAlertDeduper limits the confirmation-floor warning to one +// alert per proof transaction and process lifetime. Multiple target VTXOs can +// share the same proof ancestor, so per-target warnings describe one recovery +// condition and create redundant alert instances. +type proofNodeFloorAlertDeduper struct { + mu sync.Mutex + seen map[chainhash.Hash]struct{} +} + +// newProofNodeFloorAlertDeduper creates an empty process-local alert set. +func newProofNodeFloorAlertDeduper() *proofNodeFloorAlertDeduper { + return &proofNodeFloorAlertDeduper{ + seen: make(map[chainhash.Hash]struct{}), + } +} + +// first reports whether txid has not produced a warning through this deduper. +// It records txid before returning so concurrent child actors cannot both +// claim the first alert. +func (d *proofNodeFloorAlertDeduper) first(txid chainhash.Hash) bool { + d.mu.Lock() + defer d.mu.Unlock() + + if _, ok := d.seen[txid]; ok { + return false + } + + d.seen[txid] = struct{}{} + + return true +} diff --git a/unroll/proof_floor_alert_test.go b/unroll/proof_floor_alert_test.go new file mode 100644 index 000000000..5dc7cd2ba --- /dev/null +++ b/unroll/proof_floor_alert_test.go @@ -0,0 +1,73 @@ +package unroll + +import ( + "sync" + "sync/atomic" + "testing" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/stretchr/testify/require" +) + +// TestProofNodeFloorAlertDeduper verifies that targets sharing one proof +// transaction produce one alert while an independent proof still alerts. +func TestProofNodeFloorAlertDeduper(t *testing.T) { + t.Parallel() + + deduper := newProofNodeFloorAlertDeduper() + firstProof := chainhash.Hash{1} + secondProof := chainhash.Hash{2} + + require.True(t, deduper.first(firstProof)) + require.False(t, deduper.first(firstProof)) + require.True(t, deduper.first(secondProof)) + + // Concurrent children must not both claim the first warning. + concurrent := newProofNodeFloorAlertDeduper() + var firstCount atomic.Int32 + var workers sync.WaitGroup + for range 32 { + workers.Add(1) + go func() { + defer workers.Done() + if concurrent.first(firstProof) { + firstCount.Add(1) + } + }() + } + workers.Wait() + require.Equal(t, int32(1), firstCount.Load()) + + // Exercise the production registry constructor and spawn seam. Two real + // children must receive the same registry-lifetime deduper. + registry := NewUnrollRegistryActor(RegistryConfig{ + DeliveryStore: newMemCheckpointStore(), + }) + t.Cleanup(registry.Stop) + require.NotNil(t, registry.behavior.proofNodeFloorAlerts) + + firstChild, err := registry.behavior.spawn( + t.Context(), wire.OutPoint{ + Index: 1, + }, + ) + require.NoError(t, err) + t.Cleanup(firstChild.Stop) + secondChild, err := registry.behavior.spawn( + t.Context(), wire.OutPoint{ + Index: 2, + }, + ) + require.NoError(t, err) + t.Cleanup(secondChild.Stop) + + require.Same( + t, registry.behavior.proofNodeFloorAlerts, + firstChild.behavior.cfg.proofNodeFloorAlerts, + ) + require.Same( + t, registry.behavior.proofNodeFloorAlerts, + secondChild.behavior.cfg.proofNodeFloorAlerts, + ) +} diff --git a/unroll/registry.go b/unroll/registry.go index be41ec765..891baccb9 100644 --- a/unroll/registry.go +++ b/unroll/registry.go @@ -231,11 +231,12 @@ func (a *UnrollRegistryActor) Stop() { // NewUnrollRegistryActor creates and starts the thin unroll registry actor. func NewUnrollRegistryActor(cfg RegistryConfig) *UnrollRegistryActor { behavior := ®istryBehavior{ - cfg: cfg, - log: cfg.Log.UnwrapOr(btclog.Disabled), - active: make(map[wire.OutPoint]*VTXOUnrollActor), - pending: make(map[wire.OutPoint]RegistryRecord), - persisting: make(map[wire.OutPoint]RegistryRecord), + cfg: cfg, + log: cfg.Log.UnwrapOr(btclog.Disabled), + active: make(map[wire.OutPoint]*VTXOUnrollActor), + pending: make(map[wire.OutPoint]RegistryRecord), + persisting: make(map[wire.OutPoint]RegistryRecord), + proofNodeFloorAlerts: newProofNodeFloorAlertDeduper(), } registry := actor.NewActor(actor.ActorConfig[RegistryMsg, RegistryResp]{ @@ -264,6 +265,8 @@ type registryBehavior struct { pending map[wire.OutPoint]RegistryRecord persisting map[wire.OutPoint]RegistryRecord + proofNodeFloorAlerts *proofNodeFloorAlertDeduper + spawnFunc func(context.Context, wire.OutPoint) (*VTXOUnrollActor, error) } @@ -1489,6 +1492,7 @@ func (r *registryBehavior) childConfig(target wire.OutPoint) Config { ExitSpendPolicyResolver: r.cfg.ExitSpendPolicyResolver, FraudCheckpointSafetyMargin: r.cfg.FraudCheckpointSafetyMargin, RegistryRef: r.selfRef, + proofNodeFloorAlerts: r.proofNodeFloorAlerts, } }