Skip to content

staticaddr: dyn-conf-tracker#1141

Merged
hieblmi merged 21 commits into
masterfrom
dyn-conf-tracker-staging
Jul 16, 2026
Merged

staticaddr: dyn-conf-tracker#1141
hieblmi merged 21 commits into
masterfrom
dyn-conf-tracker-staging

Conversation

@hieblmi

@hieblmi hieblmi commented May 15, 2026

Copy link
Copy Markdown
Collaborator

This PR surfaces static-address deposits as soon as they appear in the wallet, including mempool deposits, instead of waiting for the old confirmation readiness
threshold.

It also updates the static-address flows around those low-confirmation deposits:

  • Track, reconcile, and list unconfirmed static-address deposits.
  • Preserve logical deposit identity across RBF/replacement while hiding replaced deposits from normal listings.
  • Allow static loop-ins to select low-confirmation deposits, while keeping withdrawals and channel opens restricted to confirmed inputs.
  • Add CLI/RPC warnings and metadata so users can see when low-confirmation deposits may delay swap execution.
  • Harden static loop-in lifecycle handling around orphan invoices, replaced inputs, failed swaps, and server confirmation-risk decisions.
  • Persist confirmation-risk decisions so restart recovery preserves accepted/rejected risk state and payment deadline timing.
  • Prevent notification-manager stalls by queueing blocking notification fan-out paths.
  • Add regression coverage for deposit tracking, selection, replacement, recovery, notification delivery, and risk-decision persistence.

@gemini-code-assist

Copy link
Copy Markdown

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly improves the reliability and responsiveness of static-address deposits. By allowing immediate visibility of mempool deposits while enforcing strict confirmation requirements for high-risk operations like channel opens and withdrawals, the system balances user experience with security. The changes also include robust handling of deposit replacement, durable persistence of risk decisions for recovery, and comprehensive regression testing to ensure stable behavior across the deposit lifecycle.

Highlights

  • Immediate Deposit Visibility: Surfaces static-address deposits as soon as they appear in the wallet, including those currently in the mempool, removing the need to wait for the old confirmation threshold.
  • Enhanced Deposit Lifecycle: Introduces robust tracking and reconciliation for unconfirmed deposits, including logic to handle RBF (Replace-by-Fee) scenarios by marking replaced outpoints as 'Replaced' to keep the database clean.
  • Confirmation Enforcement: Restricts sensitive operations such as withdrawals and channel opens to confirmed inputs, ensuring that only finalized funds are used for these actions.
  • Risk Decision Persistence: Persists confirmation-risk decisions to the database, allowing for consistent swap execution and payment deadline timing even after service restarts.
  • User Feedback: Adds CLI and RPC warnings to inform users when low-confirmation deposits might delay swap execution.
New Features

🧠 You can now enable Memory (public preview) to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for unconfirmed deposits in static address loop-ins, enabling faster swap initiation while managing confirmation risk. It includes client-side warnings for low-confirmation outputs, server-side persistence of risk decisions, and logic to handle deposits that vanish from the wallet view due to mempool replacement or reorgs. The notification manager was also enhanced to support asynchronous risk acceptance/rejection events. Feedback was provided regarding the duplication of coin selection logic between the CLI warning path and the loop-in manager, recommending a refactor into a shared utility for better maintainability.

Comment thread cmd/loop/staticaddr.go
@hieblmi hieblmi self-assigned this May 15, 2026
@hieblmi hieblmi force-pushed the dyn-conf-tracker-staging branch 4 times, most recently from 7bee626 to ec42d48 Compare May 22, 2026 09:26
@hieblmi hieblmi force-pushed the dyn-conf-tracker-staging branch 2 times, most recently from f8f474e to d424169 Compare June 1, 2026 08:49
@hieblmi hieblmi requested a review from starius June 2, 2026 12:33
@hieblmi hieblmi force-pushed the dyn-conf-tracker-staging branch from d424169 to d630c2e Compare June 5, 2026 08:31
@hieblmi hieblmi force-pushed the dyn-conf-tracker-staging branch 4 times, most recently from 49241e1 to f6caba3 Compare June 19, 2026 13:57
@hieblmi hieblmi force-pushed the dyn-conf-tracker-staging branch 4 times, most recently from 8304783 to da5253e Compare June 22, 2026 15:02
@hieblmi

hieblmi commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator Author

@claude please review

@claude

claude Bot commented Jun 23, 2026

Copy link
Copy Markdown

Code Review — PR #1141: staticaddr: dyn-conf-tracker

This PR is a substantial addition: it surfaces mempool deposits immediately (vs. waiting for MinConfs), adds RBF/replacement tracking, persist-and-replay for the server's confirmation-risk decision, notification manager queueing, and regression coverage across all the new flows. The overall design is sound and the test coverage is good. A few correctness issues and edge cases worth addressing before merge:


Bug (High) — Payment deadline reset to full duration on legacy MinConfs fallback

File: staticaddr/loopin/actions.go

The new startPaymentDeadline helper has a branching contract on its startedAt parameter: if non-zero it subtracts elapsed time; if zero it uses the full PaymentTimeoutDuration(). The risk-accepted notification path correctly passes the persisted ConfirmationRiskDecisionTime, so the remaining window is computed accurately. But the legacy MinConfs fallback call passes time.Time{}:

// Legacy path
startPaymentDeadline(
    "legacy confirmation fallback",
    time.Time{},  // <-- zero time → full timeout granted from NOW
)

This means a swap initiated 55 seconds ago with a 60-second payment window grants the server a fresh 60-second deadline from the moment deposits confirm, rather than the remaining 5 seconds. The server could use this discrepancy to delay payment well beyond the negotiated window. The fix is to pass f.loopIn.InitiationTime (or whichever timestamp records when the swap was started) so startPaymentDeadline can compute the true remaining budget.


Bug (Medium) — unlockDeposits failure at deadline fires silently and is unretried until htlc timeout

File: staticaddr/loopin/actions.go

When deadlineChan fires:

case <-deadlineChan:
    deadlineChan = nil   // won't fire again

    cancelInvoice()

    err = f.unlockDeposits(ctx)
    if err != nil {
        f.Errorf("unable to unlock deposits after payment deadline: %v", err)
        continue   // depositsUnlocked stays false
    }
    depositsUnlocked = true

After this continue, deadlineChan is nil and the only retry path is the !depositsUnlocked guard in the htlc-timeout branch — which only fires when the htlc actually times out on-chain. If the htlc is never published (or both unlock attempts fail), the deposits remain stuck in LoopingIn until daemon restart. Consider logging with a stronger warning and, at minimum, scheduling a bounded retry rather than relying solely on the htlc-timeout coincidence.


Bug (Medium) — TransitionDeposits "deposit in final state" error silently swallowed in background paths

Files: staticaddr/withdraw/manager.go, staticaddr/openchannel/manager.go

The PR adds a new final-state guard in TransitionDeposits that returns an error when a deposit is already in a final state (including the new Replaced state). The user-facing gRPC handlers propagate this correctly. However, several background goroutines only log.Errorf it:

  • handleConfirmedWithdrawal — withdrawal confirmed but TransitionDeposits fails: deposits silently remain in Withdrawing state.
  • openChannelPsbt on confirmation — deposits stay stuck in OpeningChannel instead of advancing to ChannelPublished.
  • OpenChannel rollback path — rollback TransitionDeposits failure is only logged; deposits remain stuck in OpeningChannel rather than being returned to Deposited.

In all these cases the error is caused by a deposit being concurrently Replaced (a legitimate race this PR introduces). The user has no visibility that their deposits are stuck. Consider incrementing a metric, surfacing the state via ListStaticAddressDeposits, or adding a recovery path in reconcileDeposits.


Maintainability — autoSelectedWarningOutpoints duplicates server-side SelectDeposits logic

File: cmd/loop/staticaddr.go

The PR itself acknowledges this with a // KEEP IN SYNC with staticaddr/loopin.SelectDeopits comment. The duplication is structurally necessary (CLI uses RPC wire types; server uses internal types), so this isn't a blocking issue — but the "KEEP IN SYNC" pattern is fragile. If the server-side tie-break criteria change (e.g. adding a third criterion), the warning could silently diverge, showing spurious or missing confirmations warnings. Consider at least a unit test that runs both selection functions against the same simulated deposit set and asserts they agree on which deposits are selected, or document the exact shared invariants the comment refers to.


Good work on the test coverage — the reconciliation, replacement, recovery, and risk-decision-persistence tests are exactly what's needed here. The depositBlocksUntilExpiry helper correctly guards the unconfirmed case (returns int64(expiry) when confirmationHeight <= 0), which avoids the large-negative-number issue the old inline formula would have produced for mempool deposits.

@hieblmi hieblmi force-pushed the dyn-conf-tracker-staging branch from da5253e to 80a5b44 Compare June 23, 2026 12:40
@hieblmi

hieblmi commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator Author

@claude please re-review

@claude

claude Bot commented Jun 23, 2026

Copy link
Copy Markdown

Code Review — PR #1141: staticaddr: dyn-conf-tracker

This PR surfaces static-address deposits from the mempool (without waiting for 6 confirmations), adds RBF/replacement tracking, introduces confirmation-risk notifications from the server, hardens the loop-in lifecycle, and persists risk decisions across restarts. It's a substantial, well-structured change. The findings below are ranked most-severe first.


Findings

1. deadlineChan case omits invoiceCanceledForNonPayment = true — deposit re-use window after HTLC confirms
staticaddr/loopin/actions.go — new MonitorInvoiceAndHtlcTxAction event loop

When the payment deadline fires, cancelInvoice() is called but invoiceCanceledForNonPayment is never set to true. That flag drives the htlcConfChan case:

case <-htlcConfChan:
    htlcConfirmed = true
    if invoiceCanceledForNonPayment {          // ← always false after deadlineChan fires
        transitionDepositsToHtlcTimeout(...)  // ← skipped
    }

The consequence: if the server publishes the HTLC on-chain after the deadline has already expired, deposits are left in Deposited state (they were unlocked by unlockDepositsAfterInvoiceCancel) until the HTLC-timeout block fires. In that window the deposit manager may select them for a new loop-in. When the original FSM's blockChan htlc-timeout path eventually fires and calls transitionDepositsToHtlcTimeout, the deposits may now be in LoopingIn state for the new swap — OnSweepingHtlcTimeout is not a valid transition from LoopingIn, producing an FSM error that leaves the original HTLC un-swept.

Fix: add invoiceCanceledForNonPayment = true after cancelInvoice() inside the deadlineChan case.


2. Legacy MinConfs fallback grants the server the full payment timeout instead of the remaining time
staticaddr/loopin/actions.goblockChan case

startPaymentDeadline(
    "legacy confirmation fallback",
    time.Time{},        // ← zero value
)

Inside startPaymentDeadline:

timeout := f.loopIn.PaymentTimeoutDuration()
if !startedAt.IsZero() {
    timeout -= time.Since(startedAt)  // ← never executed for zero value
    ...
}

When deposits confirm 30 minutes after swap initiation with a 60-minute PaymentTimeoutSeconds, the server receives 60 minutes from the legacy-MinConfs block instead of the ~30 remaining minutes the contract specifies. The old code used RemainingPaymentTimeSeconds() which measured from InitiationTime. Fix: pass f.loopIn.InitiationTime as startedAt for the legacy path so elapsed time is always deducted.


3. AfterFunc in SubscribeStaticLoopInRiskAccepted/Rejected can evict a notification the FSM never consumed
notifications/manager.goSubscribeStaticLoopInRiskAccepted / SubscribeStaticLoopInRiskRejected

context.AfterFunc(ctx, func() {
    m.removeSubscriber(NotificationTypeStaticLoopInRiskAccepted, sub)
    m.Lock()
    delete(m.staticLoopInRiskAccepted, swapHash)   // ← unconditional
    m.Unlock()
    close(notifChan)
})

Problematic sequence:

  1. FSM subscribes for swap H (no notification yet in cache).
  2. FSM's notificationCtx is cancelled (transient error before the for/select reads from riskAcceptedChan).
  3. Server sends risk-accepted — handleStaticLoopInRiskDecision persists to DB (best-effort), caches in m.staticLoopInRiskAccepted[H], and calls dropNotification. Because sub.subCtx.Done() is already closed, dropNotification's select takes the ctx.Done() branch — notification is NOT delivered to notifChan.
  4. AfterFunc fires → deletes m.staticLoopInRiskAccepted[H], the only in-memory copy.
  5. If the DB persist also failed, an FSM restart finds neither a cache entry nor a persisted decision. The payment deadline never starts; the swap is stuck until the legacy MinConfs fallback fires (which itself has the zero-time bug from finding Update README.md #2).

Consider not deleting the cache entry in AfterFunc, or only deleting it after verifying the notification was actually delivered to the subscriber.


4. ListUnspentDeposits becomes blind to freshly-arrived mempool deposits
loopd/swapclient_server.goListUnspentDeposits

Old code added any confirmed UTXO that had no DB record to isUnspent via the confirmedToCheck map, guaranteeing immediate visibility. New code:

ignoreUnknownOutpoints := true   // was false
deposits, err := s.depositManager.DepositsForOutpoints(...)
for _, d := range deposits {
    if d == nil { continue }      // unknown outpoints silently skipped
    if d.IsInState(deposit.Deposited) {
        isUnspent[d.OutPoint] = struct{}{}
    }
}

A deposit that arrived between two reconcile ticks (up to PollInterval = 10 s) has no DB record yet and returns nil from DepositsForOutpoints, so it is silently excluded from the response. This contradicts the PR's stated goal of surfacing deposits "as soon as they appear in the wallet." Consider keeping the confirmedToCheck fallback, or running a reconcile synchronously before returning results.


5. autoSelectedWarningOutpoints re-implements SelectDeposits with a // KEEP IN SYNC comment
cmd/loop/staticaddr.goautoSelectedWarningOutpoints

The function manually reproduces the sort/filter logic from staticaddr/loopin.SelectDeposits, acknowledged with a comment. Future changes to SelectDeposits (e.g. a new deposit state, a tie-breaking rule) will silently diverge the CLI warning, causing it to warn about different deposits than those the server will actually wait on. The existing TestWarningDepositSelectionMatchesLoopInSelection helps, but only if that test is kept in sync too. Consider factoring the pure selection logic into a shared helper that both the CLI and the FSM import.


6. selectedDepositConfirmationHeights allocates and acquires locks on every block after the deadline has started
staticaddr/loopin/actions.goblockChan case

After deadlineStarted = true, legacyMinConfsReached will immediately return true on every subsequent block, but selectedDepositConfirmationHeights still runs: it allocates two maps and acquires each deposit's lock on every block for the lifetime of the swap. Gate the block-driven check on !deadlineStarted:

if !deadlineStarted && legacyMinConfsReached(...) {
    startPaymentDeadline(...)
}

7. riskDecisionTime closure issues two sequential DB reads in the non-persisted path
staticaddr/loopin/actions.goriskDecisionTime closure

When the decision has not yet been persisted, the function calls GetLoopInByHashRecordStaticAddressRiskDecisionGetLoopInByHash again only to read back ConfirmationRiskDecisionTime. That timestamp is exactly what was passed to RecordStaticAddressRiskDecision. The second read can be eliminated by having RecordStaticAddressRiskDecision return (or accept-and-echo) the stored timestamp, or by capturing time.Now() before the write and accepting the minor precision difference.


8. handleStaticLoopInRiskDecision does an O(N) linear scan over all risk subscribers
notifications/manager.gohandleStaticLoopInRiskDecision

for _, sub := range m.subscribers[notifType] {
    if !hasSwapHash || sub.swapHash == nil ||
        *sub.swapHash != swapHash {
        continue
    }
    notifySubscriber(sub)
}

Risk notifications are swap-scoped and there is at most one subscriber per in-flight swap. With N concurrent loop-ins, every incoming risk notification scans all N entries. The existing staticLoopInRiskAccepted / staticLoopInRiskRejected maps already key by hash; a parallel map[lntypes.Hash]subscriber for the per-swap subscription would provide O(1) dispatch.


Overall the approach is sound and the test coverage (reconcile, replacement, notification delivery, risk-decision persistence) is thorough. The two highest-priority fixes are the missing invoiceCanceledForNonPayment = true (finding #1) and the zero startedAt in the legacy fallback (finding #2), both of which affect swap-payment timing correctness.

@hieblmi

hieblmi commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator Author

On the two priority items:

  1. invoiceCanceledForNonPayment: this is already covered on the current branch via the shared cancellation paths. cancelInvoice() and handleRiskRejected() both set the invoice state to canceled and set invoiceCanceledForNonPayment = true, so a later HTLC confirmation moves the deposits back to SweepHtlcTimeout instead of leaving them reusable.

  2. Legacy fallback startedAt: using time.Time{} is intentional. The legacy MinConfs path is the compatibility substitute for a risk-accepted notification; before that point the client is still waiting for the deposits to become acceptable. Charging the payment timeout from swap initiation would make slow-confirming deposits consume the server payment window before the server is expected to pay, which regresses the dyn-conf behavior.

@hieblmi hieblmi force-pushed the dyn-conf-tracker-staging branch 3 times, most recently from a56abf3 to f447cae Compare June 25, 2026 08:47
hieblmi added 16 commits July 12, 2026 09:14
Build list and summary responses from tracked deposit records instead
of raw wallet UTXOs so RPC clients see the manager availability state.
Split unconfirmed value from confirmed deposited value in summaries,
and reject manual loop-in quotes for selected deposits that are not
currently Deposited.
Deposited can now include mempool outputs for static loop-ins, but
withdrawals and static channel opens still require confirmed funding
inputs. Filter automatic channel-open selection to confirmed deposits
and reject explicit unconfirmed selections, including withdraw-all
requests that would otherwise silently include mempool deposits.
Static address deposits with no confirmation height have not started
their CSV timeout yet, so keep them eligible for loop-in selection
instead of treating them as already near expiry. Prefer confirmed
deposits before unconfirmed ones during automatic selection, and share
the remaining-lifetime calculation used by the selector.
Use the shared deposit-expiry helper when building autoloop DP
candidates so unconfirmed deposits do not look like the
earliest-expiring options.

This keeps the no-change selector's expiry tie-break aligned with the
generic loop-in deposit selection rules.
Refresh the active static-address deposit set against lnd's wallet view
before quote, loop-in, withdrawal, channel-open, and autoloop selection
paths. This prevents stale persisted Deposited records from being
selected after replacement, reorg, or an external spend.
Use the deposit manager's visible-deposit view for normal list and
summary RPCs so historical Deposited rows whose outpoints vanished from
lnd's wallet view are not exposed as available funds.
Check the originally selected deposit outpoints before signing a static
loop-in HTLC transaction. If any selected outpoint is no longer
available, cancel the swap invoice and fail the signing action instead
of producing signatures for stale inputs.
Add cached per-swap notification fanout for static loop-in
confirmation-risk acceptance and rejection notifications so loop-in
FSMs can subscribe by swap hash and receive decisions that arrived
before subscription.
Subscribe to static loop-in confirmation-risk notifications before
starting the payment deadline. Start that deadline only after server
acceptance or the legacy confirmation fallback, and cancel the swap
invoice when the server rejects the risk wait. Refresh selected
deposits before the legacy fallback so recovered monitors use current
confirmation heights.
Add schema, sqlc queries, store fields, and SqlStore support for
recording server confirmation-risk decisions with static loop-in swaps.
Store the decision timestamp so payment-deadline recovery can
reconstruct elapsed time after restart.
Persist static loop-in confirmation-risk decisions before fanout when a
persistence callback is configured. Keep unpersisted decisions cached
for replay so notification delivery is not lost if the swap row is not
available yet.
Create the static loop-in SQL store before the notification manager and
pass a persistence callback so server confirmation-risk decisions are
durably recorded before fanout.
Track whether the invoice was canceled for non-payment while monitoring
the HTLC. If the HTLC never confirms before timeout, unlock the
deposits; if it did confirm, transition them to the HTLC-timeout sweep
state without issuing duplicate transitions.
Record replayed server risk decisions through the loop-in store,
recover accepted payment-deadline timers using the persisted decision
time, and handle persisted rejections on restart. This lets recovered
static loop-ins keep pending confirmation-risk state instead of
restarting payment timing from scratch.
@hieblmi hieblmi force-pushed the dyn-conf-tracker-staging branch from 5b29bb1 to f338c9e Compare July 12, 2026 07:42
@hieblmi

hieblmi commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator Author

Also addressed the earlier history-cleanup request: the static loop-in replay fixture update is now squashed into the cmd/loop low-confirmation warning commit.

@starius starius left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Great job! 🚀

I propose to fix the AI finding in transitionDepositsToHtlcTimeout and to add CLI sessions covering new logic in cmd/loop/staticaddr.go.

Follow-ups for the future, not blockers:

  • time.Now() cleanup PR thread
  • armed HTLC recovery issue #1171
  • immutable selected-deposit invariant issue #1172
  • dedup deposit selection logic in CLI and SelectDeposits

Comment thread staticaddr/loopin/actions.go Outdated
Comment on lines +991 to +1001
err = f.cfg.DepositManager.TransitionDeposits(
ctx, f.loopIn.Deposits,
deposit.OnSweepingHtlcTimeout,
deposit.SweepHtlcTimeout,
)
if err != nil {
f.Errorf("unable to transition deposits to the htlc "+
"timeout sweeping state after %s: %v",
reason, err)

return

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Monitor can advance after the deposit transition it depends on failed.

Problem: transitionDepositsToHtlcTimeout logs and returns on DepositManager.TransitionDeposits failure, but the caller does not know it failed; at HTLC timeout the monitor still returns OnSweepHtlcTimeout, moving the loop-in FSM to SweepHtlcTimeout while selected deposits may still be LoopingIn/Deposited/partially transitioned. The same pattern exists on the no-HTLC path: unlockDeposits failure is logged but the monitor can still return OnSwapTimedOut and mark the swap failed while deposits remain locked or only partially unlocked.

Impact: a transient DB/observer/context error in TransitionDeposits can make the swap FSM and deposit FSM disagree about who owns the next recovery action. In the confirmed-HTLC case the next MonitorHtlcTimeoutSweepAction later expects deposits to be in SweepHtlcTimeout; if they were not moved there, timeout-sweep finalization can fail or require manual recovery. This is not #1171 armed-HTLC monitoring and not #1172 selected-deposit identity; it is current local error handling after a transition attempt fails.

Fix direction: make the transition/unlock helper return an error/bool and do not emit OnSweepHtlcTimeout/OnSwapTimedOut unless all selected deposits reached the required state. Retry the monitor with OnRecover or a bounded retry timer, while still returning fsm.NoOp on shutdown; do not hold extra deposit/manager locks around the retry.

Reproduction: reproduced with a focused action-level test that injects a deposit transition failure; current PR returns OnSweepHtlcTimeout anyway.

Failing regression test for GH comment
// TestMonitorInvoiceAndHtlcTxDoesNotAdvanceWhenTimeoutDepositTransitionFails
// reproduces the local state skew where the loop-in FSM advances to the HTLC
// timeout sweep path even though the deposit manager failed to move the selected
// deposits into SweepHtlcTimeout.
func TestMonitorInvoiceAndHtlcTxDoesNotAdvanceWhenTimeoutDepositTransitionFails(
	t *testing.T) {

	ctx, cancel := context.WithTimeout(t.Context(), testTimeout)
	defer cancel()

	mockLnd := test.NewMockLnd()

	clientKey, err := btcec.NewPrivateKey()
	require.NoError(t, err)
	serverKey, err := btcec.NewPrivateKey()
	require.NoError(t, err)

	swapHash := lntypes.Hash{20, 21, 22}
	depositOutpoint := wire.OutPoint{
		Hash:  chainhash.Hash{20},
		Index: 0,
	}

	loopIn := &StaticAddressLoopIn{
		SwapHash:              swapHash,
		HtlcCltvExpiry:        mockLnd.Height,
		InitiationHeight:      uint32(mockLnd.Height),
		InitiationTime:        time.Now(),
		ProtocolVersion:       version.ProtocolVersion_V0,
		ClientPubkey:          clientKey.PubKey(),
		ServerPubkey:          serverKey.PubKey(),
		PaymentTimeoutSeconds: 3_600,
		DepositOutpoints: []string{
			depositOutpoint.String(),
		},
		Deposits: []*deposit.Deposit{{
			OutPoint: depositOutpoint,
		}},
	}
	loopIn.SetState(MonitorInvoiceAndHtlcTx)

	mockLnd.SetInvoice(&lndclient.Invoice{
		Hash:  swapHash,
		State: invoices.ContractCanceled,
	})

	depositMgr := &recordingDepositManager{
		err:            errors.New("transition failed"),
		transitionChan: make(chan depositTransition, 2),
	}
	cfg := &Config{
		AddressManager: &mockAddressManager{
			params: &script.Parameters{
				ClientPubkey:    clientKey.PubKey(),
				ServerPubkey:    serverKey.PubKey(),
				ProtocolVersion: version.ProtocolVersion_V0,
			},
		},
		ChainNotifier:  mockLnd.ChainNotifier,
		DepositManager: depositMgr,
		InvoicesClient: mockLnd.LndServices.Invoices,
		LndClient:      mockLnd.Client,
		ChainParams:    mockLnd.ChainParams,
	}

	f, err := NewFSM(ctx, loopIn, cfg, false)
	require.NoError(t, err)

	resultChan := make(chan fsm.EventType, 1)
	go func() {
		resultChan <- f.MonitorInvoiceAndHtlcTxAction(ctx, nil)
	}()

	select {
	case <-mockLnd.SingleInvoiceSubcribeChannel:
	case <-ctx.Done():
		t.Fatalf("invoice subscription not registered: %v", ctx.Err())
	}

	var confRegistration *test.ConfRegistration
	select {
	case confRegistration = <-mockLnd.RegisterConfChannel:
	case <-ctx.Done():
		t.Fatalf("htlc conf registration not received: %v", ctx.Err())
	}

	confRegistration.ConfChan <- nil

	select {
	case transition := <-depositMgr.transitionChan:
		require.Equal(t, deposit.OnSweepingHtlcTimeout, transition.event)
		require.Equal(t, deposit.SweepHtlcTimeout, transition.state)

	case <-ctx.Done():
		t.Fatalf("deposit timeout transition was not attempted: %v", ctx.Err())
	}

	require.NoError(t, mockLnd.NotifyHeight(mockLnd.Height+1))

	select {
	case event := <-resultChan:
		require.NotEqual(t, OnSweepHtlcTimeout, event,
			"monitor advanced even though deposits were not locked for timeout sweeping")

	case <-ctx.Done():
		t.Fatalf("monitor action did not exit: %v", ctx.Err())
	}
}

Command:

go test -run TestMonitorInvoiceAndHtlcTxDoesNotAdvanceWhenTimeoutDepositTransitionFails -count=1 -parallel=1 ./staticaddr/loopin

Current failure:

--- FAIL: TestMonitorInvoiceAndHtlcTxDoesNotAdvanceWhenTimeoutDepositTransitionFails (0.00s)
    actions_test.go:3379:
        	Error Trace:	staticaddr/loopin/actions_test.go:3379
        	Error:      	Should not be: "OnSweepHtlcTimeout"
        	Test:       	TestMonitorInvoiceAndHtlcTxDoesNotAdvanceWhenTimeoutDepositTransitionFails
        	Messages:   	monitor advanced even though deposits were not locked for timeout sweeping
FAIL
FAIL	github.com/lightninglabs/loop/staticaddr/loopin	0.033s
FAIL

@hieblmi hieblmi Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c718e9a.

transitionDepositsToHtlcTimeout now returns an error, and every caller retries the monitor instead of emitting OnSweepHtlcTimeout unless all selected deposits reached SweepHtlcTimeout. A retry after a partial multi-deposit transition filters out deposits that already reached the target state and only transitions the remaining deposits, then rechecks the complete selected set before advancing.

The no-HTLC path now handles unlock failures the same way, so OnSwapTimedOut is not emitted while deposits remain locked. Shutdown cancellation is also checked before treating an all-target transition as success; retryMonitor returns fsm.NoOp immediately for a canceled context.

Added regression coverage for a failed timeout transition, a partial multi-deposit transition followed by recovery, and a failed timeout unlock. The focused race tests and the full staticaddr/loopin and staticaddr/deposit test suites pass.

"value_channels_opened": "0"
}
}
},

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I propose to record and add more CLI sessions to cover the new logic in CLI. File cmd/loop/staticaddr.go had many changes - cover them with a couple of scenarios, please.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added two recorded CLI scenarios:

  • Explicit --utxo selection showing a five-confirmation warning.
  • Automatic selection showing an unconfirmed-deposit warning.

@hieblmi hieblmi force-pushed the dyn-conf-tracker-staging branch from f338c9e to 0a347bf Compare July 15, 2026 14:47
hieblmi added 2 commits July 15, 2026 16:52
Keep the loop-in monitor in its recoverable state when a required deposit transition or unlock fails. Only advance after every selected deposit reaches the expected state, and retry only deposits that remain pending after a partial transition.

Preserve shutdown semantics when observer cancellation races with a completed deposit update, and add regression coverage for transition, partial-transition, and unlock failures.
Warn before dispatching a static loop-in that selects deposits below
the conservative six-confirmation threshold. Mirror automatic coin
selection before prompting so the warning reflects both manual and
auto-selected deposits.

Cover manual and auto-selected warning paths in CLI tests.
@hieblmi hieblmi force-pushed the dyn-conf-tracker-staging branch from 0a347bf to 43b16ff Compare July 15, 2026 14:52
@starius

starius commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Commit "staticaddr/loopin: retry failed deposit state transitions" has long lines in description. Strange that CI didn't catch this.

@starius starius force-pushed the dyn-conf-tracker-staging branch from c16c153 to 6dd1aef Compare July 16, 2026 04:13
@hieblmi hieblmi force-pushed the dyn-conf-tracker-staging branch from 6dd1aef to 43b16ff Compare July 16, 2026 08:19
@hieblmi hieblmi merged commit 39c97e6 into master Jul 16, 2026
28 of 29 checks passed
@hieblmi hieblmi deleted the dyn-conf-tracker-staging branch July 16, 2026 13:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants