Skip to content

staticaddr/loopin: confirm only the htlc tx that spent deposits - #1293

Draft
claravanstaden wants to merge 2 commits into
lightninglabs:masterfrom
claravanstaden:static-loopin-decoy-htlc-conf
Draft

claravanstaden wants to merge 2 commits into
lightninglabs:masterfrom
claravanstaden:static-loopin-decoy-htlc-conf

Conversation

@claravanstaden

@claravanstaden claravanstaden commented Nov 12, 2026 •

Copy link
Copy Markdown
Collaborator

The static address loop-in client registers for the htlc confirmation by pkScript only, so lnd reports the first confirmed tx that pays to the htlc script. The server knows that script and can confirm a dust output to it from its own funds, then not pay the invoice. The client took that as the htlc confirmation and locked the deposits for a timeout sweep of an htlc output that doesn't exist, and the deposits had no way out of that state.

Before and after

Before

  1. The client signs the HTLC txs and watches the htlc script for a confirmation.
  2. The server pays a dust output to the htlc script from its own funds and doesn't pay the invoice.
  3. The client takes that as the htlc confirmation and locks the deposits for the timeout sweep.
  4. The sweep targets an htlc output that doesn't hold the deposits, and the deposits are stuck.

After

  1. The client signs the HTLC txs and watches the htlc script for a confirmation.
  2. The server pays a dust output to the htlc script and doesn't pay the invoice.
  3. The confirmed tx doesn't spend the swap's deposits, so the client ignores it and watches the spend of the first deposit instead.
  4. If the real htlc tx spends the deposits, it is confirmed by txid and swept after the timeout, also when its confirmation arrives after the htlc expiry. If it never does, the swap times out and releases the deposits.

Fix

A confirmation now only counts if its tx spends exactly the swap's deposits and pays to the htlc. A missing confirmation is rejected too. lnd reports a script match only once, so after a tx that doesn't qualify, the monitor watches the spend of the first deposit and registers the confirmation of the htlc tx that spent it by txid. The inputs are compared with the outpoints the htlc tx was signed for.

The htlc tx can spend the deposits before the monitor sees its confirmation, for example when the monitor starts after the htlc expiry. So once the spend watch reports the htlc tx, the htlc timeout waits for its confirmation, and before the timeout releases the deposits it asks lnd's wallet whether an htlc tx spent them. It asks again at every block, so the swap stops waiting if lnd no longer lists the tx. If the htlc tx never spends the deposits, the swap times out and releases them, as before.

A reorg drops the txid, so the next match goes through the same checks. The spend watch has its own reorg channel, because lnd before v0.21 sends no reorg for a confirmation it didn't report yet. After a spend reorg the monitor watches the deposit spend again, so it also finds another signed fee variant of the htlc tx.

Tests

  • TestMonitorInvoiceAndHtlcTxIgnoresDecoyHtlcConf: a dust decoy and a nil confirmation don't lock the deposits, the htlc tx found from the deposit spend is confirmed by txid and locks them, and a deposit spend by another tx doesn't. Fails on master.
  • TestIsHtlcTx: input set and htlc output checks, including a signed snapshot that differs from the deposit rows.
  • TestMonitorHtlcTimeoutWaitsForSpentDeposits: the htlc timeout waits when the spend watch or lnd's wallet reports the htlc tx, stops waiting when lnd no longer lists it, retries on a lookup error, and releases the deposits when nothing spent them.
  • TestMonitorDropsHtlcTxOnReorg: a spend reorg or confirmation reorg drops the htlc tx, another fee variant is found, and a replaced registration's reorg isn't handled twice.
  • TestMonitorDepositSpendWatchErrors and TestMonitorWatchesSignedDepositOutpoints: spend watch errors retry the monitor, and the watch uses the signed outpoints.
  • Existing monitor tests now deliver an htlc tx that spends the deposits instead of a nil confirmation.

Known limits

  • The reorg paths are tested by sending the reorg event by hand, since the mock notifier doesn't model reorgs. They are not tested against a real lnd.
  • With a reorg channel, lnd closes the spend stream once the spend is 144 blocks deep, which restarts the monitor once. Deposits are already locked or covered by the wallet lookup then.
  • The payment deadline still releases deposits that an unconfirmed htlc tx has spent, as on master. They are locked again when the htlc tx confirms.
  • Replaced chain subscriptions stay open until the monitor's context ends.

This fits with #1269, which makes the timeout sweep take the htlc tx from the chain. This PR doesn't persist the htlc outpoint.

@claravanstaden claravanstaden self-assigned this Nov 12, 2026
The loop-in monitor registers for the htlc confirmation by pkScript
only, so lnd reports the first confirmed tx that pays to the htlc
script. The server knows that script and can confirm a dust output to
it from its own funds, without spending the deposits and without
paying the invoice. The monitor took that as the htlc confirmation and
locked the deposits for a timeout sweep of an htlc output that doesn't
exist. The deposits had no way out of that state.

Now a confirmation only counts if its tx spends exactly the deposits
the htlc tx was signed for and pays to the htlc. lnd reports a script
match only once, so after a tx that doesn't qualify, the monitor
watches the spend of the first deposit and registers the confirmation
of the htlc tx that spent it by txid. A missing confirmation is
rejected the same way.

The htlc tx can spend the deposits before the monitor sees its
confirmation, for example if the monitor starts after the htlc expiry.
Releasing the deposits then would abandon the htlc. So once the spend
watch reports the htlc tx, the htlc timeout waits for its
confirmation. And before the timeout releases the deposits, it asks
lnd's wallet whether an htlc tx spent them. If none did, the swap
times out and the deposits are released, as before.

A reorg drops the txid and the note that the deposits are spent. The
next match then goes through the same checks, and the swap doesn't
wait forever for an htlc tx that is gone, because the timeout asks lnd
again. The spend watch reports its own reorgs, since lnd before v0.21
sends no reorg for a confirmation it didn't report yet. After a spend
reorg the monitor watches the deposit spend again, so it also finds
another signed fee variant of the htlc tx.
Note that a static address loop-in only takes a confirmation of the
htlc tx that spent its deposits.
@claravanstaden
claravanstaden force-pushed the static-loopin-decoy-htlc-conf branch from 50d9627 to 686ef37 Compare November 13, 2026 09:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant