loopin: classify already-settled invoice RPC errors

lnd returns ErrInvoiceAlreadySettled from its RPC handler as an
uncoded gRPC error. On the client it is reconstructed as an Unknown
status, so comparing it directly with the Go sentinel never matches.

This made the abandon path log a warning for an expected condition.
The older timeout path had the same comparison and could return the
RPC error instead of finishing normal timeout processing.

Add a shared classifier that accepts the local sentinel and the exact
gRPC status representation, and use it in both cancellation paths.
Add coverage for the classifier and timeout handling.
This commit is contained in:
Boris Nagaev 2026-07-17 15:36:02 -05:00
parent 89f2dcff8b
commit 8716a51722
No known key found for this signature in database
2 changed files with 161 additions and 2 deletions

View file

@ -27,6 +27,8 @@ import (
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/routing/route"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
var (
@ -58,6 +60,25 @@ var (
ErrSwapFinalized = errors.New("swap is in a final state")
)
// isInvoiceAlreadySettledError reports whether err indicates that an invoice
// cancellation failed because the invoice was already settled. If lnd returns
// the sentinel from an RPC handler, gRPC transports it as an Unknown status
// with the sentinel's error text.
func isInvoiceAlreadySettledError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, invpkg.ErrInvoiceAlreadySettled) {
return true
}
rpcStatus, ok := status.FromError(err)
return ok &&
rpcStatus.Code() == codes.Unknown &&
rpcStatus.Message() == invpkg.ErrInvoiceAlreadySettled.Error()
}
// loopInSwap contains all the in-memory state related to a pending loop in
// swap.
type loopInSwap struct {
@ -1087,7 +1108,7 @@ func (s *loopInSwap) processHtlcSpend(ctx context.Context,
// already settled. This means that the server didn't succeed in
// sweeping the htlc after paying the invoice.
err := s.lnd.Invoices.CancelInvoice(ctx, s.hash)
if err != nil && err != invpkg.ErrInvoiceAlreadySettled {
if err != nil && !isInvoiceAlreadySettledError(err) {
return err
}
}
@ -1179,7 +1200,7 @@ func (s *loopInSwap) setStateAbandoned(ctx context.Context) error {
// behaviour of the timeout path. Any other unexpected error is logged
// but does not prevent the abandon from completing.
err = s.lnd.Invoices.CancelInvoice(ctx, s.hash)
if err != nil && err != invpkg.ErrInvoiceAlreadySettled {
if err != nil && !isInvoiceAlreadySettledError(err) {
s.log.Warnf("Failed to cancel invoice for abandoned swap: %v",
err)
}