Repository navigation
staticaddr/loopin: sweep the htlc tx that spent the deposits - #1269
claravanstaden merged 4 commits into
Conversation
|
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. |
fb585e6 to
aa09dd4
Compare
aa09dd4 to
4341f68
Compare
Good question. This hasn't happen yet since it requires manual trigger from the server. We hence can remove the recovery path. |
c110306 to
5311559
Compare
| } | ||
|
|
||
| // paysTo returns whether the tx has an output to the given pkScript. | ||
| func paysTo(tx *wire.MsgTx, pkScript []byte) bool { |
There was a problem hiding this comment.
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.
de70b91 to
74ed2b6
Compare
|
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. |
c4b0398 to
5defcd0
Compare
|
@hieblmi thanks, good catch. I changed it to use lnd's |
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.
5defcd0 to
3d4e29a
Compare
hieblmi
left a comment
There was a problem hiding this comment.
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.
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 (
HtlcTxLookupTimeoutin 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
After
Example
Two deposits,
aaaa:0(60,000 sat) andbbbb:1(40,000 sat), at static address scriptS. HTLC scriptH. The client co-signs:H111122223333Only the fee differs, so the HTLC output value and the txid differ too. The server publishes
2222and never pays the invoice.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.aaaa:0(scriptS, height hint the swap's initiation height) and gets2222.htlcOutputchecks that it spends exactly {aaaa:0, bbbb:1} and has one output toH, then the sweep spends2222:0, signed over 95,000.Testing
TestHtlcTimeoutSweepSpendsPublishedVariant: all three variants after a simulated restartTestHtlcTimeoutSweepFeeRateAfterLookup: fee rate estimated after the lookupTestFetchHtlcTxFailures: notifier error, spend without tx, lookup timeout, shutdownTestHtlcOutput: input and output validationTestCreateHtlcSweepTxRejectsDust: dust boundaryTestHtlcTimeoutSweepOutputAlreadySpent: HTLC output spent by an earlier sweep, by a tx paying elsewhere, and not spentgo test -race ./staticaddr/...Pull Request Checklist
docs/release-notes/release-notes-next.md, or apply theno-changeloglabel (required by CI)