Skip to content

loopin: cancel the swap invoice before the timeout refund - #1241

Open
starius wants to merge 2 commits into
lightninglabs:masterfrom
starius:loopin-timeout-atomicity
Open

starius wants to merge 2 commits into
lightninglabs:masterfrom
starius:loopin-timeout-atomicity

Conversation

@starius

@starius starius commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator

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:

    • If the cancellation succeeds, the refund is published. So it is if lnd no longer knows the invoice (NotFound): lnd only deletes canceled invoices, for example through its garbage collection, so a deleted invoice can't be paid either.
    • If the invoice is already settled, the refund is blocked, the swap moves to InvoiceSettled, and the paid amount is read from the invoice. The swap can complete before the invoice update that reports the amount.
    • Any other error defers the refund to the next block.

    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.
  • TestLoopInTimeout now 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

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

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
starius force-pushed the loopin-timeout-atomicity branch from cb8e015 to e5eee12 Compare September 30, 2026 06:49
@starius
starius requested a review from hieblmi September 30, 2026 06:49

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

This looks good to me, I just have two minor suggestions.

Comment thread loopin.go
if s.ProtocolVersion < loopdb.ProtocolVersionMuSig2 {
return false
}
if !s.invoiceSettled {

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.

👍

Comment thread loopin.go
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) {

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.

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.

Comment thread loopin.go
// 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 "+

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.

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)
}

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.

3 participants