loop: add manual static deposit recovery#1166
Conversation
Keep deposits locked when the server publishes the loop-in HTLC without paying the invoice. This lets the client sweep through the HTLC timeout path instead of making the same outputs available for another action.
Return early when block notifications reach deposits that already moved into a terminal state. This prevents final deposits from retrying expiry handling after recovery or while their FSM is still draining block updates.
Reject duplicate static-address deposit outpoints before creating withdrawal, loop-in, or channel-open requests. Use the shared outpoint duplicate helper so each flow reports the same input validation failure.
Extract the monitor invoice update semantics into a helper and cover the existing state mapping with a dedicated test.
Treat closed invoice update channels as terminal for the monitor loop. This avoids spinning when lnd closes the subscription after invoice cancellation or shutdown.
Add an explicit Stop method for deposit FSM block-notification loops. Call it when the manager removes a finalized active deposit so stale FSM goroutines stop consuming block updates.
Store an independent snapshot of the outpoints selected for a static loop-in. Recovered swaps remain tied to the original funding outputs even if deposit records later change confirmation or replacement metadata. Avoid decoding an empty database outpoint string as a synthetic outpoint.
Add a TxOutChecker interface for checking whether a selected deposit outpoint is still available before signing the HTLC transaction. Back the implementation with lnd wallet transaction data so known confirmed and mempool spends mark the outpoint unavailable.
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.
Recovered loop-ins carry two outpoint views. DepositOutpoints is the immutable swap input snapshot sent to the server and used to validate sweep requests. Deposits comes from the store's swap_hash/deposit-id join and reflects the current deposit rows. The active-deposit lookup takes a detour through the reconstructed deposit rows before asking the deposit manager for active deposits. That keeps recovery from depending on the historical input snapshot. A future replacement path can RBF a deposit from its original funding outpoint to a replacement outpoint while the swap still needs to retain the original input list. Looking up active deposits by DepositOutpoints would then fail recovery even though the store still maps the correct deposit IDs to the swap hash. Keep list responses on the store reconstruction too, so they do not re-resolve deposits through historical outpoints.
Before we send HTLC signatures to the server, the server cannot publish the HTLC transaction. After those signatures are handed over, the server can publish an HTLC that spends the selected deposits even if it never pays the swap invoice. Defend against stale local deposit state by checking the wallet's current txout view immediately before signing. A deposit can have been spent by a known withdrawal, channel open, timeout sweep, replacement, or another wallet transaction while the loop-in FSM is recovering or while earlier state still marked it as selected. Failing before signing leaves the server without spend authority over an unavailable input. Include mempool spends in the check so wallet-known unconfirmed spends are treated as unavailable too.
The txout checker needs a conservative answer when lnd's wallet history contains both the original deposit transaction and a later wallet-known spend of that outpoint. Scan the wallet transaction list once, remember a matching live txout, and keep scanning for matching previous outpoints. A known spend must win over the candidate funding output regardless of transaction ordering in the wallet response. This keeps the availability check cheap and preserves the rule that a deposit is unavailable if any wallet-known confirmed or mempool transaction already spends it.
Reject duplicate static-address deposit outpoints before creating withdrawal, loop-in, or channel-open requests. Use the shared outpoint duplicate helper so each flow reports the same input validation failure.
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.
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.
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.
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 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.
Add SendCoins-backed funding through loop static deposit, keeping loop static new focused on address creation. Wire the nested lnd SendCoins request through the NewStaticAddress RPC and return the SendCoins response. Validate cheap SendCoins errors before address creation and require execute permissions for the funding-capable RPC. Regenerate CLI docs and looprpc artifacts, and add validation coverage for the static address funding request.
Add recoverdeposit CLI/RPC support for verifying one static-address output on-chain, matching it to a derived static address, restoring the address/import, and directly creating or reactivating the deposit row.
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 introduces robust recovery mechanisms for static address deposits and L402 state, ensuring users can recover funds even after disaster recovery scenarios. It also enhances the static address deposit lifecycle with a new funding command, improved deposit reconciliation, and better risk management for swap payment deadlines. 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 local recovery for Loop's static-address and L402 state, adding backup and restore capabilities, new CLI commands, and support for multi-address branches. The code review identified several critical and high-severity issues, including self-import compile errors in the deposit package tests, potential nil pointer dereferences in swapclient_server.go, and opportunities to simplify imports by utilizing locally defined helper functions.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| "github.com/lightninglabs/loop/fsm" | ||
| "github.com/lightninglabs/loop/staticaddr/address" |
There was a problem hiding this comment.
This file is in package deposit. Importing "github.com/lightninglabs/loop/staticaddr/deposit" creates a self-import compile error in Go. Please remove this import and remove the deposit. prefix from all occurrences of deposit.Deposit and deposit.ErrDepositNotFound throughout this file.
| "github.com/lightninglabs/loop/fsm" | |
| "github.com/lightninglabs/loop/staticaddr/address" | |
| "github.com/lightninglabs/loop/staticaddr/address" |
| "github.com/lightninglabs/loop/staticaddr/address" | ||
| "github.com/lightninglabs/loop/staticaddr/script" |
There was a problem hiding this comment.
This file is in package deposit. Importing "github.com/lightninglabs/loop/staticaddr/deposit" creates a self-import compile error in Go. Please remove this import and remove the deposit. prefix from all occurrences of deposit.Deposit, deposit.Manager, deposit.NewManager, deposit.ManagerConfig, deposit.RecoveryRequest, and deposit.RecoveryResult throughout this file.
| "github.com/lightninglabs/loop/staticaddr/address" | |
| "github.com/lightninglabs/loop/staticaddr/script" | |
| "github.com/lightninglabs/loop/staticaddr/address" |
| params := s.staticAddressManager.GetParameters(u.PkScript) | ||
| if params == nil { | ||
| return nil, fmt.Errorf("missing static address "+ | ||
| "parameters for %v", u.OutPoint) | ||
| } |
There was a problem hiding this comment.
If s.staticAddressManager is nil, calling GetParameters will panic with a nil pointer dereference. Since other parts of this file explicitly check if s.staticAddressManager is nil, we should defensively check it here as well.
if s.staticAddressManager == nil {
return nil, fmt.Errorf("static address manager not configured")
}
params := s.staticAddressManager.GetParameters(u.PkScript)
if params == nil {
return nil, fmt.Errorf("missing static address " +
"parameters for %v", u.OutPoint)
}| if outpoint.HasDuplicates(outpoints) { | ||
| return nil, false | ||
| } |
There was a problem hiding this comment.
Instead of importing the external outpoint package and calling outpoint.HasDuplicates, use the locally defined and tested CheckDuplicates function from outpoint.go in the same package. This allows removing the external outpoint package import.
| if outpoint.HasDuplicates(outpoints) { | |
| return nil, false | |
| } | |
| if err := CheckDuplicates(outpoints); err != nil { | |
| return nil, false | |
| } |
| if outpoint.HasDuplicates(outpoints) { | ||
| return fmt.Errorf("duplicate deposit outpoint") | ||
| } |
There was a problem hiding this comment.
Instead of importing the external outpoint package and calling outpoint.HasDuplicates, use the locally defined and tested CheckDuplicates function from outpoint.go in the same package. This allows removing the external outpoint package import.
| if outpoint.HasDuplicates(outpoints) { | |
| return fmt.Errorf("duplicate deposit outpoint") | |
| } | |
| if err := CheckDuplicates(outpoints); err != nil { | |
| return err | |
| } |
| "github.com/lightninglabs/loop/staticaddr/address" | ||
| "github.com/lightninglabs/loop/staticaddr/outpoint" |
There was a problem hiding this comment.
| for _, d := range sourceDeposits { | ||
| if d.AddressParams == nil { | ||
| continue | ||
| } |
There was a problem hiding this comment.
If any element d in sourceDeposits is nil, accessing d.AddressParams will panic with a nil pointer dereference. Add a nil check for d to prevent this.
| for _, d := range sourceDeposits { | |
| if d.AddressParams == nil { | |
| continue | |
| } | |
| for _, d := range sourceDeposits { | |
| if d == nil || d.AddressParams == nil { | |
| continue | |
| } |
Problem
Static address deposits can be missed during disaster recovery when
lndandloopdare restored in stages.One concrete failure mode is:
lndandloopdcrash or are lost.lndis restored from seed.lndwallet does not yet know how to recover/import thestatic address taproot scripts by itself.
loopdis restored from backup and imports the static addressscripts into
lnd.lnd, but historical deposits may stillnot show up through wallet UTXO scanning.
missing, but there is no manual way to recover a specific confirmed deposit.
Solution
Add a manual
recoverdepositcommand for static address deposits.The command lets the user provide the known funding outpoint and output
script:
The recovery flow is:
Validate the request and require a taproot output script.
Use the chain notifier to confirm that the provided transaction
output exists on-chain at the requested outpoint.
Verify that the confirmed output's pkScript and amount match the
requested recovery target.
Match the output script to known or derivable static address
parameters.
Restore/import the matching static address scripts into lnd if
needed.
Create or reactivate the corresponding Loop deposit record.
Start the deposit FSM so the deposit can continue through the normal
static address lifecycle.
This allows recovery to proceed even when the deposit is not visible
through lnd wallet UTXO discovery, as long as the user can provide the funding
transaction details and Loop has been restored from backup.