Skip to content

staticaddr: dyn-conf-tracker#1141

Open
hieblmi wants to merge 32 commits into
masterfrom
dyn-conf-tracker-staging
Open

staticaddr: dyn-conf-tracker#1141
hieblmi wants to merge 32 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 from f6caba3 to 0669702 Compare June 19, 2026 14:53
@lightninglabs-deploy

Copy link
Copy Markdown

@hieblmi, remember to re-request review from reviewers when ready

@hieblmi hieblmi force-pushed the dyn-conf-tracker-staging branch 3 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 2 times, most recently from fc506ac to a56abf3 Compare June 25, 2026 08:47
@hieblmi hieblmi force-pushed the dyn-conf-tracker-staging branch from 24375c8 to 46fbeb4 Compare July 1, 2026 13:52
hieblmi added 9 commits July 2, 2026 10:13
Document the lock-order invariant between Manager.mu and individual
deposit locks.

Later changes need both locks in the same path, so make the rule
explicit before the locking surface grows.
Move active-deposit block notification fan-out into a helper.

This keeps the event loop small and gives later startup replay logic
a single path for notifying recovered deposit FSMs.
Guard reconcileDeposits with a dedicated mutex.

Polling and block-driven reconciliation can overlap, so serialize the
path before it updates confirmation data and active FSM state.
Reject nil deposits and final-state deposits before sending FSM
events.

This keeps callers from transitioning stale or completed deposits and
uses the no-lock state helper while deposits are already locked.
Add a duration helper that falls back to the default payment timeout.

Recovered legacy swaps can have a zero persisted timeout, so later
deadline logic can use this without treating zero as immediate expiry.
A block notification can queue OnExpiry before a deposit reaches a final
state. If the final transition wins that race first, the stale expiry event
must not overwrite the terminal outcome.

Keep LoopedIn and Withdrawn as self-loops on OnExpiry, matching the other
final states. Add a focused FSM test that sends OnExpiry directly to each
final state and verifies the state is preserved.
Document deposit lock ownership for mutable confirmation state and
route production reads through deposit accessors.

Keep store persistence on no-lock helpers while callers hold the
deposit lock, preserving the existing transition behavior without
leaving direct field reads in user-facing paths.
A shutdown while publishing or monitoring the HTLC timeout sweep should not
transition the loop-in to Failed.

Return NoOp on context cancellation in those actions so the persisted
state remains a recovery point. Add focused tests for shutdown during
publication retry and confirmation monitoring.
After the client gives the server HTLC signatures, shutdown must not drive the monitor state through the generic error path. That path cancels the invoice and attempts to unlock deposits even though the server can still publish the HTLC.

Return NoOp for monitor-state cancellation races and cover shutdown with a regression test that asserts no invoice cancellation or deposit unlock occurs.
@hieblmi hieblmi force-pushed the dyn-conf-tracker-staging branch 2 times, most recently from 86d1de1 to 7849084 Compare July 2, 2026 09:20
hieblmi added 13 commits July 2, 2026 12:32
Allow golangci-lint more time for the larger static-address test
suite.

The dynamic-confirmation stack adds several focused tests, and the
default timeout is tight on slower CI workers.
Retain static-address deposits as soon as lnd reports the UTXO, even when the output is still unconfirmed. Store the first confirmation height once the output confirms.

Replay the startup block to recovered deposit FSMs so expiry handling can run immediately after restart. Derive confirmation heights from a stable wallet view because lnd reports confirmation counts.
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.

Keep withdrawal and channel-open flows on confirmed inputs by rejecting
unconfirmed selected deposits in those paths.
Treat unconfirmed static-address deposits as swappable because their CSV
timeout has not started yet. Keep confirmed deposits ahead of
unconfirmed ones during automatic selection, then sort by value and
remaining lifetime within each confirmation group.

Share the expiry calculation with the dynamic-programming selector so
unconfirmed deposits do not look like the earliest-expiring candidates.
Treat lnd wallet view as the source of spendable static-address
outputs while keeping historical deposit records in the DB. Reconcile
active FSMs against the current wallet view and reactivate known
deposits when their outpoints are visible again.

Refresh deposits before selection, withdrawal, loop-in, and channel-open
paths, and filter list and summary responses through the live active set
so stale Deposited records 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.

Wire the lnd-backed checker through loopd and make invoice-monitoring
handle closed subscription channels without spinning.
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.
@hieblmi hieblmi force-pushed the dyn-conf-tracker-staging branch from 7849084 to 9c23aab Compare July 2, 2026 10:47
hieblmi added 3 commits July 2, 2026 13:07
Store server confirmation-risk decisions with static loop-in swaps and
recover accepted payment-deadline timers after restart. Wire
notification persistence through loopd so recovered swaps do not lose
pending risk state.

Deduplicate notification fanout cache entries by swap hash.
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.
Refresh static loop-in replay sessions for the low-confirmation warning
and payment-timeout prompts. Add replay coverage for the warning prompt
and update fee and payment-timeout variants for the new interaction
sequence.
@hieblmi hieblmi force-pushed the dyn-conf-tracker-staging branch from 9c23aab to 5193b2b Compare July 2, 2026 11:08
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