From 0611832030e3d959f5818751a9b47c614c7c150d Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 1 Jul 2026 07:15:40 +0200 Subject: [PATCH] staticaddr/deposit: ignore expiry blocks in final states Return early when block notifications reach deposits that already moved into a terminal state. This prevents final deposits from retrying expiry handling after recovery or while their FSM is still draining block updates. --- staticaddr/deposit/fsm.go | 4 +++ staticaddr/deposit/fsm_test.go | 60 ++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/staticaddr/deposit/fsm.go b/staticaddr/deposit/fsm.go index 9e4faeee..01a7edf8 100644 --- a/staticaddr/deposit/fsm.go +++ b/staticaddr/deposit/fsm.go @@ -241,6 +241,10 @@ func NewFSM(ctx context.Context, deposit *Deposit, cfg *ManagerConfig, func (f *FSM) handleBlockNotification(ctx context.Context, currentHeight uint32) { + if f.deposit.IsInFinalState() { + return + } + // If the deposit is expired but not yet sufficiently confirmed, we // republish the expiry sweep transaction. if f.deposit.IsExpired(currentHeight, f.params.Expiry) { diff --git a/staticaddr/deposit/fsm_test.go b/staticaddr/deposit/fsm_test.go index 10399bb1..d7a70bb2 100644 --- a/staticaddr/deposit/fsm_test.go +++ b/staticaddr/deposit/fsm_test.go @@ -2,6 +2,7 @@ package deposit import ( "testing" + "time" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" @@ -11,6 +12,65 @@ import ( "github.com/stretchr/testify/require" ) +// TestHandleBlockNotificationIgnoresFinalStates verifies that a block-driven +// expiry notification cannot mutate deposits that already reached a final +// state but have not yet been removed from the manager's active set. +func TestHandleBlockNotificationIgnoresFinalStates(t *testing.T) { + t.Parallel() + + finalStates := []fsm.StateType{ + Expired, + Withdrawn, + LoopedIn, + HtlcTimeoutSwept, + ChannelPublished, + } + + for i, state := range finalStates { + t.Run(string(state), func(t *testing.T) { + t.Parallel() + + outpoint := wire.OutPoint{ + Hash: chainhash.Hash{byte(i + 1)}, + Index: uint32(i), + } + deposit := &Deposit{ + OutPoint: outpoint, + ConfirmationHeight: 1, + } + deposit.SetState(state) + + depositFSM := &FSM{ + cfg: &ManagerConfig{ + Store: new(mockStore), + }, + deposit: deposit, + params: &script.Parameters{Expiry: 1}, + quitChan: make(chan struct{}), + finalizedDepositChan: make(chan wire.OutPoint, 1), + } + depositFSM.StateMachine = fsm.NewStateMachineWithState( + depositFSM.DepositStatesV0(), state, + DefaultObserverSize, + ) + depositFSM.ActionEntryFunc = depositFSM.updateDeposit + + depositFSM.handleBlockNotification(t.Context(), 3) + + require.Never(t, func() bool { + return deposit.GetState() != state + }, 100*time.Millisecond, 10*time.Millisecond) + + select { + case finalized := <-depositFSM.finalizedDepositChan: + t.Fatalf("unexpected finalization for %v", finalized) + + default: + } + }) + } +} + // TestLoopingInTransitionsToSweepHtlcTimeout verifies that a deposit selected // by a loop-in can be moved into the timeout sweep state if the server confirms // the HTLC without paying the invoice.