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
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:
|
a56abf3 to
f447cae
Compare
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.
5b29bb1 to
f338c9e
Compare
|
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
left a comment
There was a problem hiding this comment.
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:
| 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 |
There was a problem hiding this comment.
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/loopinCurrent 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
There was a problem hiding this comment.
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" | ||
| } | ||
| } | ||
| }, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Added two recorded CLI scenarios:
- Explicit --utxo selection showing a five-confirmation warning.
- Automatic selection showing an unconfirmed-deposit warning.
f338c9e to
0a347bf
Compare
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.
0a347bf to
43b16ff
Compare
|
Commit "staticaddr/loopin: retry failed deposit state transitions" has long lines in description. Strange that CI didn't catch this. |
c16c153 to
6dd1aef
Compare
6dd1aef to
43b16ff
Compare
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: