Conversation
A MuSig2 Loop In reveals the client's internal HTLC key to the server once the swap is done, so that the server can sweep the HTLC through the cheaper key path. With that key the server can spend the HTLC on its own. The client revealed the key as soon as the swap invoice was finalized, which includes a canceled invoice. In the normal flow the client cancels the invoice only after its timeout refund confirmed. The HTLC is spent by then, so revealing the key could not be used to take it. But an invoice canceled while the HTLC is still unspent, for example by hand, gave the server the key to an HTLC that it never paid for. Only reveal the key once the invoice is known to be settled, and check that inside the key reveal itself. The next commit relies on this to cancel the invoice before the refund is published.
At the HTLC expiry the client published its timeout refund while the swap invoice was still open, and canceled the invoice only after the refund confirmed. A payment that arrived in between settled the invoice and gave the server the preimage, while the refund could still win the race for the HTLC. After a restart, the first refund attempt also ran before the client had looked at the invoice at all. Before any refund, cancel the swap invoice with lnd's CancelInvoice, which resolves the race with a settlement atomically: - If the cancellation succeeds, the invoice can no longer reveal the preimage and the refund is published. The same holds if lnd no longer knows the invoice: lnd only deletes canceled invoices, for example when it garbage collects them, so a deleted invoice can't be paid either. - If the invoice is already settled, the refund is blocked and the swap moves to InvoiceSettled. The paid amount is read from the invoice, because the swap can complete before the invoice update that reports it arrives. - Any other error leaves the outcome open, and the refund waits for the next block. After a successful cancellation the refund is not in a hurry: the server has neither the preimage nor, after the previous commit, the internal key, so only the client's timeout path can spend the HTLC. After a restart the invoice is simply canceled again. The cancellation after the refund confirmed also accepts a deleted invoice, and the swap then completes without waiting for an invoice update that a deleted invoice never sends.
starius
force-pushed
the
loopin-timeout-atomicity
branch
from
September 30, 2026 06:49
cb8e015 to
e5eee12
Compare
hieblmi
approved these changes
Oct 2, 2026
hieblmi
left a comment
Collaborator
There was a problem hiding this comment.
This looks good to me, I just have two minor suggestions.
| if s.ProtocolVersion < loopdb.ProtocolVersionMuSig2 { | ||
| return false | ||
| } | ||
| if !s.invoiceSettled { |
| if htlcSpend && invoiceFinalized { | ||
| // Check stop conditions. A canceled invoice is final even if | ||
| // lnd deleted it and never reports the cancellation. | ||
| if htlcSpend && (invoiceFinalized || s.invoiceCanceled) { |
Collaborator
There was a problem hiding this comment.
in the branch below
case invpkg.ContractCanceled:
invoiceFinalized = true
}
we also should set the s.InvoiceCancelled = true, so we save a cancel call in publishTimoutTx.
| // invoice itself. | ||
| invoice, lookupErr := s.lnd.Client.LookupInvoice(ctx, s.hash) | ||
| if lookupErr != nil { | ||
| s.log.Warnf("Unable to look up the paid amount of the "+ |
Collaborator
There was a problem hiding this comment.
I think we should return the error here and wait for the next block so that the server cost can be looked up.
Not that the invoice marked as settled, but we don't know the server cost:
invoice, lookupErr := s.lnd.Client.LookupInvoice(ctx, s.hash)
if lookupErr != nil {
s.log.Warnf(
"Unable to look up settled invoice, retrying at the "+
"next block: %v", lookupErr,
)
return false, nil
}
s.cost.Server = s.AmountRequested -
invoice.AmountPaid.ToSatoshis()
s.invoiceSettled = true
if s.state == loopdb.StateHtlcPublished {
s.setState(loopdb.StateInvoiceSettled)
return false, s.persistAndAnnounceState(ctx)
}
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A Loop In published its timeout refund at the HTLC expiry while the swap invoice was still open, and canceled the invoice only after the refund confirmed. A server payment arriving in between settled the invoice and gave the server the preimage, while the client's refund could still win the race for the HTLC.
Commits
loopin: reveal the internal HTLC key only for a settled invoice. A MuSig2 Loop In reveals its internal HTLC key to the server after the swap, so that the server can sweep through the key path. With the key the server can spend the HTLC on its own. The client revealed it whenever the invoice was finalized, which includes a canceled invoice. In the normal flow the invoice was only canceled after the refund confirmed, so the reveal came too late to matter. An invoice canceled while the HTLC is still unspent, for example by hand, handed over the key to an unpaid HTLC. The key is now only revealed for a settled invoice. The next commit relies on this.
loopin: cancel the invoice before publishing a timeout refund. Before any refund, the client calls
CancelInvoice, which resolves the race with a settlement atomically:After a restart the invoice is simply canceled again, so no new swap state is persisted. The cancellation after the refund also accepts NotFound, and the swap completes without waiting for an update that a deleted invoice never sends.
Tests
TestLoopInCanceledInvoiceKeepsHtlcKey: a canceled invoice never reveals the key. It fails before the first commit.TestLoopInTimeoutnow expects the cancellation before the refund.TestLoopInRefundGateSettledInvoice: a settled invoice blocks the refund, and the server cost is recorded without the invoice update.TestLoopInRefundGateUnresolvedCancellation: an unclear cancellation error defers the refund.TestLoopInRefundGateDeletedInvoice: NotFound before and after the refund. The swap refunds and completes without an invoice update.Pull Request Checklist
docs/release-notes/release-notes-next.md, or apply theno-changeloglabel (required by CI)