Skip to content

staticaddr/loopin: sweep the htlc tx that spent the deposits - #1269

Merged
claravanstaden merged 4 commits into
lightninglabs:masterfrom
claravanstaden:static-htlc-variant-sweep
Nov 13, 2026
Merged

claravanstaden merged 4 commits into
lightninglabs:masterfrom
claravanstaden:static-htlc-variant-sweep

Conversation

@claravanstaden

@claravanstaden claravanstaden commented Oct 30, 2026 •

Copy link
Copy Markdown
Collaborator

The client co-signs three variants of the static address loop-in HTLC tx (standard, high and extremely high fee). The timeout sweep always rebuilt the standard fee variant, so if a backup variant confirmed, the sweep couldn't spend it. Only the standard fee rate is persisted, so rebuilding the confirmed variant after a restart isn't possible.

This PR takes the HTLC tx from the chain instead. It waits for the spend of the first deposit, checks that the spending tx spends exactly the swap's deposits and has one HTLC output, and sweeps that outpoint with its actual value. The lookup runs before the fee rate is estimated and is bounded by a 30 minute timeout (HtlcTxLookupTimeout in the loop-in config, not exposed by loopd). A second commit rejects sweeps whose output would be dust, since the backup variants leave a smaller HTLC output.

lnd reports missing inputs and mempool conflicts as an already spent output, which the sweep counted as published without recording a txid. That's how the sweep of the wrong variant failed without an error. Now it only counts as published if a confirmed tx spends the HTLC output and pays the sweep address, and that tx is the one monitored.

Recovering swaps that are already stuck from the old behavior isn't included. See the comment below; it can follow separately if needed.

An alternative to #1217 that doesn't depend on the multi-address stack. Thanks @hieblmi for the original approach.

Before and after

Before

  1. The server publishes the high fee HTLC variant and doesn't pay the invoice.
  2. After the HTLC expires, the sweep rebuilds the standard variant and spends its HTLC output, which isn't on chain.
  3. lnd reports the missing input as an already spent output, so the client counts the sweep as published.
  4. It waits for a confirmation that never comes, and the funds stay in the HTLC.

After

  1. The server publishes the high fee HTLC variant and doesn't pay the invoice.
  2. After the HTLC expires, the client looks up the tx that spent the first deposit (from lnd's spend notification, or lnd's wallet if the notification never comes) and gets the high fee variant.
  3. It checks that the tx spends exactly the swap's deposits and has one HTLC output, then sweeps that output with its real value.
  4. The sweep confirms and the funds come back.

Example

Two deposits, aaaa:0 (60,000 sat) and bbbb:1 (40,000 sat), at static address script S. HTLC script H. The client co-signs:

Variant Inputs Fee Output 0 pays H txid
standard aaaa:0, bbbb:1 1,000 99,000 1111
high aaaa:0, bbbb:1 5,000 95,000 2222
extreme aaaa:0, bbbb:1 20,000 80,000 3333

Only the fee differs, so the HTLC output value and the txid differ too. The server publishes 2222 and never pays the invoice.

  • Before: the sweep rebuilds the standard variant and spends 1111:0, signed over 99,000. lnd reports the missing input as an already spent output, so the client counts the sweep as published and waits for a confirmation that never comes.
  • Now: the client asks which tx spent aaaa:0 (script S, height hint the swap's initiation height) and gets 2222. htlcOutput checks that it spends exactly {aaaa:0, bbbb:1} and has one output to H, then the sweep spends 2222:0, signed over 95,000.

Testing

  • TestHtlcTimeoutSweepSpendsPublishedVariant: all three variants after a simulated restart
  • TestHtlcTimeoutSweepFeeRateAfterLookup: fee rate estimated after the lookup
  • TestFetchHtlcTxFailures: notifier error, spend without tx, lookup timeout, shutdown
  • TestHtlcOutput: input and output validation
  • TestCreateHtlcSweepTxRejectsDust: dust boundary
  • TestHtlcTimeoutSweepOutputAlreadySpent: HTLC output spent by an earlier sweep, by a tx paying elsewhere, and not spent
  • go test -race ./staticaddr/...
  • Integration test against the swap server, pinned to this PR: the high and extremely high fee htlc tx confirm instead of the standard one, and the client sweeps the htlc output that confirmed after it expires. Both cases pass with this PR (about 105 s each) and fail on Loop master, where the sweep never reaches the mempool and the swap stays stuck.
  • Each commit builds and passes the tests, and lint is clean.

Pull Request Checklist

  • Add an entry to docs/release-notes/release-notes-next.md, or apply the
    no-changelog label (required by CI)

@claravanstaden

Copy link
Copy Markdown
Collaborator Author

Open question before this is ready: do swaps that are already stuck in the timeout sweep need recovery at all? A swap only gets there if a backup fee variant of the HTLC tx confirmed and the swap invoice was never paid. We're checking whether that has actually happened. If it hasn't, I'll keep this PR to the sweep fix plus a dust check on the sweep output. I'd drop the recovery path for already stuck swaps, since review found it adds more state machine complexity than it's worth: an unpersisted sweep hash, a retry path without backoff, and no coverage for neutrino backends.

@claravanstaden
claravanstaden force-pushed the static-htlc-variant-sweep branch from fb585e6 to aa09dd4 Compare October 30, 2026 11:10
@claravanstaden
claravanstaden marked this pull request as ready for review October 30, 2026 11:10
@claravanstaden
claravanstaden force-pushed the static-htlc-variant-sweep branch from aa09dd4 to 4341f68 Compare October 30, 2026 12:31
@hieblmi

hieblmi commented Oct 30, 2026

Copy link
Copy Markdown
Collaborator

Open question before this is ready: do swaps that are already stuck in the timeout sweep need recovery at all? A swap only gets there if a backup fee variant of the HTLC tx confirmed and the swap invoice was never paid. We're checking whether that has actually happened. If it hasn't, I'll keep this PR to the sweep fix plus a dust check on the sweep output. I'd drop the recovery path for already stuck swaps, since review found it adds more state machine complexity than it's worth: an unpersisted sweep hash, a retry path without backoff, and no coverage for neutrino backends.

Good question. This hasn't happen yet since it requires manual trigger from the server. We hence can remove the recovery path.

@hieblmi hieblmi 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!

@claravanstaden claravanstaden self-assigned this Nov 5, 2026
@claravanstaden
claravanstaden force-pushed the static-htlc-variant-sweep branch 3 times, most recently from c110306 to 5311559 Compare November 9, 2026 09:06
Comment thread staticaddr/loopin/actions.go Outdated
}

// paysTo returns whether the tx has an output to the given pkScript.
func paysTo(tx *wire.MsgTx, pkScript []byte) bool {

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.

Nonblocking improvement: could we reuse input.FindScriptOutputIndex at the call site instead of adding paysTo? It already checks for an output with the given pkScript and returns (bool, uint32), so we can ignore the index. input is already imported, and this would let us remove this helper and the new bytes import.

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.

Done! :)

@claravanstaden
claravanstaden force-pushed the static-htlc-variant-sweep branch 3 times, most recently from de70b91 to 74ed2b6 Compare November 11, 2026 14:40
@hieblmi

hieblmi commented Nov 11, 2026

Copy link
Copy Markdown
Collaborator

One recovery edge case: if lnd’s historical spend scan fails (e.g. a transient getblock error), it leaves the scan marked pending. Canceling the subscription and re-registering after our timeout does not restart that scan, so refund sweeping can stay stuck until lnd restarts.

A minimal mitigation would be to fall back to ListTransactions when waitForSpend times out: accept only a confirmed transaction spending the exact requested outpoint, then retain the existing HTLC/output validation. If no match is found, keep the current retry behavior. This avoids DB/FSM changes, though it cannot guarantee recovery when the wallet does not know the transaction; the underlying scan-retry bug still needs an lnd fix.

@claravanstaden
claravanstaden force-pushed the static-htlc-variant-sweep branch 2 times, most recently from c4b0398 to 5defcd0 Compare November 12, 2026 06:52
@claravanstaden

Copy link
Copy Markdown
Collaborator Author

@hieblmi thanks, good catch. I changed it to use lnd's ListTransaction in case the chain notifier lookup fails.

The client co-signs a standard, a high and an extremely high fee
variant of the static loop-in htlc tx, and the server may publish any
of them. The timeout sweep always rebuilt the standard variant, so
after a backup variant confirmed it spent an output that doesn't exist.
Only the standard fee rate is persisted, so the variant can't be
rebuilt.

Take the htlc tx from the chain instead: wait for the spend of the
first deposit, accept the tx only if it spends exactly the swap's
deposits and pays the htlc script once, and sweep that output with its
actual value. Inputs are matched as a set, since recovery can reorder
deposits. The lookup runs before the fee estimate and is bounded by the
new HtlcTxLookupTimeout (30 minutes by default).

lnd reports missing inputs and mempool conflicts as an already spent
output, which the sweep counted as published without a txid. Now it
only does so if a confirmed tx spends the htlc output and pays the
sweep address, and it monitors that tx.
The timeout sweep now spends whichever htlc tx variant confirmed. The
backup variants leave a smaller htlc output, so a high sweep fee rate
can leave a dust or negative sweep output. Such a sweep was signed and
only rejected by the backend with an unclear error on every retry.

Check the sweep output against the dust limit before signing and
return an error that names the htlc output, its value and the fee.
The timeout sweep of a static address loop-in could not sweep a
confirmed backup fee variant of the htlc tx. Note the fix and the new
dust check so that users with affected swaps know to upgrade.
If lnd's historical spend scan fails, lnd logs the error and keeps the
scan marked as pending. Registering for the spend again doesn't start a
new scan, so the timeout sweep never learns about the htlc tx, or about
an earlier sweep of ours, until lnd restarts. See
lightningnetwork/lnd#11234.

When the spend lookup times out, look for a confirmed wallet tx that
spends the exact outpoint, starting at the htlc tx search height. The
deposits are wallet outputs and our sweeps pay to the wallet, so lnd
lists those spends. The tx found goes through the same checks as one
from the notifier. If the wallet has no match, the sweep keeps retrying
as before.
@claravanstaden
claravanstaden force-pushed the static-htlc-variant-sweep branch from 5defcd0 to 3d4e29a Compare November 13, 2026 09:56

@hieblmi hieblmi 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.

Re-reviewed at 3d4e29a. The wallet fallback addresses the historical spend-scan issue: it accepts only a confirmed transaction spending the exact outpoint, retains HTLC input/output validation before signing, and preserves retry behavior when no wallet spend is found. Also rechecked the fee variants, dust guard, and already-spent sweep handling. No remaining blockers found. Validation: go test -race ./staticaddr/loopin passed locally; current CI checks are green.

@claravanstaden
claravanstaden merged commit 07328fb into lightninglabs:master Nov 13, 2026
12 checks passed
@claravanstaden
claravanstaden deleted the static-htlc-variant-sweep branch November 13, 2026 13:41
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.

2 participants