staticaddr: dyn-conf-tracker#1141
Conversation
Summary of ChangesHello, 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
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 AssistThe 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
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 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
|
There was a problem hiding this comment.
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.
7bee626 to
ec42d48
Compare
f8f474e to
d424169
Compare
d424169 to
d630c2e
Compare
49241e1 to
f6caba3
Compare
f6caba3 to
0669702
Compare
|
@hieblmi, remember to re-request review from reviewers when ready |
8304783 to
da5253e
Compare
|
@claude please review |
Code Review — PR #1141: staticaddr: dyn-conf-trackerThis 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 fallbackFile: The new // 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 Bug (Medium) —
|
da5253e to
80a5b44
Compare
|
@claude please re-review |
Code Review — PR #1141: staticaddr: dyn-conf-trackerThis 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. Findings1. When the payment deadline fires, 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 Fix: add 2. Legacy MinConfs fallback grants the server the full payment timeout instead of the remaining time startPaymentDeadline(
"legacy confirmation fallback",
time.Time{}, // ← zero value
)Inside 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 3. context.AfterFunc(ctx, func() {
m.removeSubscriber(NotificationTypeStaticLoopInRiskAccepted, sub)
m.Lock()
delete(m.staticLoopInRiskAccepted, swapHash) // ← unconditional
m.Unlock()
close(notifChan)
})Problematic sequence:
Consider not deleting the cache entry in AfterFunc, or only deleting it after verifying the notification was actually delivered to the subscriber. 4. Old code added any confirmed UTXO that had no DB record to 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 5. The function manually reproduces the sort/filter logic from 6. After if !deadlineStarted && legacyMinConfsReached(...) {
startPaymentDeadline(...)
}7. When the decision has not yet been persisted, the function calls 8. 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 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 |
|
On the two priority items:
|
fc506ac to
a56abf3
Compare
5228535 to
d7349a1
Compare
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.
083ee57 to
919eff0
Compare
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.
Store an independent snapshot of the deposit outpoints selected for a static loop-in. This keeps recovered swaps tied to the original funding outputs even if the deposit records later reflect replacement or confirmation changes. Avoid recovering an empty database outpoint string as a synthetic outpoint entry, and cover recovery of the stored snapshot separately from the current deposit metadata.
Add a TxOutChecker interface for testing whether an original deposit outpoint is still available before a static loop-in signs its HTLC transaction. Implement the checker using lnd's wallet transaction view so wallet-known spends, including mempool spends when requested, cause the outpoint to be reported unavailable.
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.
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.
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.
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.
Allow the native lint target more time for the larger static-address test suite.
919eff0 to
e2c6f04
Compare
|
@claude please review the latest set of changes. The |
|
test comment |
|
Finding 1/5: Notification cache deleted on subscriber cancellation File: notifications/manager.go (SubscribeStaticLoopInRiskAccepted / SubscribeStaticLoopInRiskRejected) The context.AfterFunc callback unconditionally deletes the in-memory cache entry on subscriber context cancellation. The live-delivery path in handleStaticLoopInRiskDecision sets the cache via cacheDecision and sends to the subscriber via dropNotification, but never clears the cache after delivery. After a live delivery the cache still holds the notification AND it is in the buffered channel. If the FSM context is cancelled (daemon shutdown, restart) before it drains the channel, AfterFunc fires, deletes the cache, and closes the channel. On restart: if DB persistence succeeded (common path), recovery works fine via the stored ConfirmationRiskDecision field. If DB persistence failed (the swap row did not exist yet when the notification arrived — a real race the code comments explicitly acknowledge), there is no recovery path: no DB record, no in-memory cache, no channel. Fix: skip the cache deletion in AfterFunc when the subscribe-time cache-hit path already moved the notification to the channel buffer (and cleared the cache there); and consider not deleting the cache on the live-delivery AfterFunc path so restarting subscribers can replay. |
|
Finding 2/5: ListUnspentDeposits misses EnsureDepositsFresh call unlike its sibling endpoints File: loopd/swapclient_server.go (ListUnspentDeposits) WithdrawDeposits and GetLoopInQuote both call s.depositManager.EnsureDepositsFresh(ctx) before querying deposits. ListUnspentDeposits — the primary endpoint clients use to pick deposits for a loop-in — makes no such call. The PR also changes ignoreUnknownOutpoints from false to true and removes the fallback that previously added confirmed-but-unknown UTXOs directly to the unspent set. Combined: a deposit that confirmed (or entered the mempool) since the last reconciliation tick may be invisible to ListUnspentDeposits for up to the poll interval. A user who sends a deposit and immediately calls this RPC to select it would see an empty list. Fix: add a single EnsureDepositsFresh call at the top of ListUnspentDeposits to restore the guaranteed-fresh view. |
|
Finding 3/5: GetStaticAddressSummary unconfirmed-value field blind to pre-tracker mempool UTXOs File: loopd/swapclient_server.go (GetStaticAddressSummary) The old code queried the live wallet directly (ListUnspent(ctx, 0, MinConfs-1)) for unconfirmed value. The new code derives valueUnconfirmed from the deposit manager tracked state (deposits with ConfirmationHeight <= 0 in state Deposited). A mempool UTXO not yet picked up by reconcileDeposits reports valueUnconfirmed=0 even though the funds are visible in the wallet. This is a user-visible regression: a user who sends to the static address and immediately calls GetStaticAddressSummary will see zero unconfirmed funds for up to the poll interval. Fix: call EnsureDepositsFresh before computing the summary, or keep a live wallet fallback for the unconfirmed-value calculation. |
|
Finding 4/5: listUnspentWithBestHeight relist path returns a stale height File: staticaddr/deposit/manager.go (listUnspentWithBestHeight) On the relist path (when a block arrives mid-listing), afterHeight is captured from the second GetBestBlock call and then a third ListUnspent is issued: _, afterHeight, err := m.cfg.ChainKit.GetBestBlock(ctx) If another block arrives between that GetBestBlock and the third ListUnspent, the returned UTXOs reflect height afterHeight+1 but afterHeight is returned as the stable anchor. confirmationHeightForUtxo(afterHeight, utxo) then computes a confirmation height one block too high. The error is transient and corrects on the next updateDepositConfirmations call, but there is no test for this race path. Fix: issue a final GetBestBlock after the third ListUnspent and use that as the returned height. |
|
Finding 5/5: CLI deposit-selection warning logic coupled to server path via comment only File: cmd/loop/staticaddr.go (autoSelectedWarningOutpoints / filterSwappableWarningDeposits) The comment '// KEEP IN SYNC with staticaddr/loopin.SelectDeposits' documents that the CLI warning duplicates the server-side sort order and swappability check. TestWarningDepositSelectionMatchesLoopInSelection validates current alignment, which is good. However, a future change to SelectDeposits (sort order, minimum-blocks threshold, etc.) will not automatically break any CLI test, so the warning could silently diverge. Suggestion: extract the common selection logic into a shared tested helper in the loopin package that both paths call directly. Minor nits:
Overall the PR is well-engineered with strong test coverage across the new reconciliation, RBF/reorg handling, and risk-decision persistence paths. The findings above are mostly about freshness guarantees on the RPC layer (2, 3) and a subtle notification lifecycle issue (1) that the DB recovery path largely masks in the common case. |
|
|
||
| // Check the spent status of the deposits by looking at their states. | ||
| ignoreUnknownOutpoints := false | ||
| ignoreUnknownOutpoints := true |
There was a problem hiding this comment.
A brand-new mempool/confirmed deposit visible to lnd but not yet reconciled is silently omitted for up to PollInterval. Since this endpoint already hits lnd and is used by clients to pick deposits, I’d add EnsureDepositsFresh(ctx) before ListUnspentRaw or before DepositsForOutpoints.
| *looprpc.StaticAddressSummaryResponse, error) { | ||
|
|
||
| allDeposits, err := s.depositManager.GetAllDeposits(ctx) | ||
| allDeposits, err := s.depositManager.GetVisibleDeposits(ctx) |
There was a problem hiding this comment.
Here it might also return stale information. Should we call EnsureDepositsFresh here?
| } | ||
|
|
||
| allDeposits, err := s.depositManager.GetAllDeposits(ctx) | ||
| allDeposits, err := s.depositManager.GetVisibleDeposits(ctx) |
There was a problem hiding this comment.
Here it might also return stale information. Should we call EnsureDepositsFresh here?
| deposit.Lock() | ||
| if deposit.ConfirmationHeight == confirmationHeight { | ||
| deposit.Unlock() | ||
| continue | ||
| } | ||
|
|
There was a problem hiding this comment.
Should ConfirmationHeight now be treated as mutex-protected deposit state?
This PR makes ConfirmationHeight mutable during reconciliation, but several callers still read it directly from *deposit.Deposit values, for example in staticaddr/loopin, staticaddr/openchannel, and loopd/swapclient_server.go. It is not obvious where the boundary is between deposit-manager-owned mutable state and fields that outside packages may read directly.
Could we either:
- guard all
ConfirmationHeightreads/writes with deposit methods, or - return an immutable read-only snapshot type for list/summary/selection paths?
I think the snapshot approach would make the boundary clearer: FSM/transition paths can keep using live *Deposit pointers, while read-only consumers get a copy like DepositSnapshot/DepositView that does not expose the mutex-backed mutable struct.
Either way, it would help to document on Deposit and its mutex which fields are protected by the lock. Since ConfirmationHeight is now updated after creation, direct field access outside the deposit package is easy to misuse.
| } | ||
| m.Unlock() | ||
|
|
||
| context.AfterFunc(ctx, func() { |
There was a problem hiding this comment.
AI found a bug here. Here is the patch: https://gist.github.com/starius/316e644ea82b9a76625ade9512345016
Bug: if the Loop server sent a confrisk accept/reject before the swap FSM was persisted, PersistStaticLoopInRiskDecision could fail. The manager still cached the decision for replay, but if the current subscriber context was canceled before the FSM consumed the buffered notification, the cleanup path deleted the cached decision. After restart/resubscribe, the decision was gone, so the swap could miss the server’s risk response and stall.
Fix: cached risk decisions now track whether they were successfully persisted. The manager only deletes cached decisions on subscriber replay/cancel when persistence succeeded. If persistence failed, the cache entry is retained as the only recovery path until a later subscriber/FSM can receive and persist/consume it.
How this can happen in real loopd:
loopdreceives a static loop-in risk accepted/rejected notification from the Loop server.- The notification manager tries to persist it through
RecordStaticAddressRiskDecision. - Persistence can fail in real code, for example if the swap row is not visible/stored yet (
ErrLoopInNotFound), or because of a transient DB/context error. - The manager still forwards/caches the notification for the FSM.
- If the FSM action context is then canceled before it reads the buffered notification, old code deleted the cached notification even though it had not been persisted.
- On the next same-process FSM subscription, there was no durable decision and no cached decision, so the client behaved as if the Loop server never sent it.
Real cancellation cases include daemon shutdown, monitor action restart/recovery, or an error path while MonitorInvoiceAndHtlcTxAction is active.
Important nuance: this fix does not make an unpersisted decision survive a hard process death. It fixes the in-process loss where the notification manager already has the decision, persistence failed, and the current subscriber goes away before consuming it.
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: