Merge pull request #1177 from chandachewe10/master
Some checks are pending
CI / RPC compilation check (push) Waiting to run
CI / SQL compilation check (push) Waiting to run
CI / go mod check (push) Waiting to run
CI / build and lint code (push) Waiting to run
CI / verify that auto-generated documentation is up-to-date (push) Waiting to run
CI / run unit-test sqlite3 race (push) Waiting to run
CI / run unit-test postgres race (push) Waiting to run
CI / run LiT itests (push) Waiting to run
CI / run LiT unit tests (push) Waiting to run

fix dead error variable and silent invoice cancel in setStateAbandoned
This commit is contained in:
Boris Nagaev 2026-07-21 01:12:14 -05:00 committed by GitHub
commit 359997ca7b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 170 additions and 7 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
}
}
@ -1174,13 +1195,17 @@ func (s *loopInSwap) setStateAbandoned(ctx context.Context) error {
return err
}
// If the invoice is already settled or canceled, this is a nop.
_ = s.lnd.Invoices.CancelInvoice(ctx, s.hash)
// Cancel the invoice so the server can no longer settle it. If the
// invoice is already settled we ignore the error, matching the
// 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 && !isInvoiceAlreadySettledError(err) {
s.log.Warnf("Failed to cancel invoice for abandoned swap: %v",
err)
}
return fmt.Errorf("swap hash "+
"abandoned by client, "+
"swap ID: %v, %v",
s.hash, err)
return fmt.Errorf("swap hash abandoned by client, swap ID: %v", s.hash)
}
// persistAndAnnounceState updates the swap state on disk and sends out an

View file

@ -20,6 +20,8 @@ import (
"github.com/lightningnetwork/lnd/routing/route"
"github.com/lightningnetwork/lnd/zpay32"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
var (
@ -44,6 +46,142 @@ type probeInvoicesMock struct {
cancelCtxErr chan error
}
// cancelErrorInvoicesMock is an InvoicesClient that returns a configured
// cancellation error.
type cancelErrorInvoicesMock struct {
lndclient.InvoicesClient
err error
}
// CancelInvoice returns the cancellation error configured on the mock.
func (c *cancelErrorInvoicesMock) CancelInvoice(context.Context,
lntypes.Hash) error {
return c.err
}
// TestIsInvoiceAlreadySettledError verifies the local and gRPC error forms
// recognized by the already-settled classifier.
func TestIsInvoiceAlreadySettledError(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
err error
expected bool
}{
{
name: "sentinel",
err: invpkg.ErrInvoiceAlreadySettled,
expected: true,
},
{
name: "wrapped sentinel",
err: fmt.Errorf(
"cancel invoice: %w",
invpkg.ErrInvoiceAlreadySettled,
),
expected: true,
},
{
name: "grpc representation",
err: status.Error(
codes.Unknown,
invpkg.ErrInvoiceAlreadySettled.Error(),
),
expected: true,
},
{
name: "different grpc status",
err: status.Error(
codes.FailedPrecondition,
invpkg.ErrInvoiceAlreadySettled.Error(),
),
},
{
name: "different error",
err: fmt.Errorf("cancel invoice failed"),
},
{
name: "nil",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
require.Equal(
t, testCase.expected,
isInvoiceAlreadySettledError(testCase.err),
)
})
}
}
// TestProcessHtlcSpendIgnoresGRPCAlreadySettled verifies that the timeout path
// completes normally when invoice cancellation reports an already-settled
// invoice.
func TestProcessHtlcSpendIgnoresGRPCAlreadySettled(t *testing.T) {
defer test.Guard(t)()
// Initialize a loop-in so the test uses the same contract and HTLC
// scripts as the production flow.
testCtx := newLoopInTestContext(t)
cfg := newSwapConfig(
&testCtx.lnd.LndServices, testCtx.store, testCtx.server, nil,
clock.NewTestClock(time.Unix(123, 0)),
)
initResult, err := newLoopInSwap(
context.Background(), cfg, 600, &testLoopInRequest,
)
require.NoError(t, err)
testCtx.store.AssertLoopInStored()
// Return the gRPC status observed when lnd transports its
// ErrInvoiceAlreadySettled sentinel across the RPC boundary.
cfg.lnd.Invoices = &cancelErrorInvoicesMock{
err: status.Error(
codes.Unknown,
invpkg.ErrInvoiceAlreadySettled.Error(),
),
}
// Confirmation processing normally selects the HTLC version that was
// found on chain. Select the initialized version explicitly because this
// test calls processHtlcSpend directly.
if initResult.swap.htlcP2TR != nil {
initResult.swap.htlc = initResult.swap.htlcP2TR
} else {
initResult.swap.htlc = initResult.swap.htlcP2WSH
}
require.NotNil(t, initResult.swap.htlc)
// Construct a timeout spend so processHtlcSpend takes the invoice
// cancellation branch.
timeoutWitness, err := initResult.swap.htlc.GenTimeoutWitness([]byte{1})
require.NoError(t, err)
timeoutTx := &wire.MsgTx{
TxIn: []*wire.TxIn{
{
Witness: timeoutWitness,
},
},
}
// The already-settled status must be suppressed while the swap still
// transitions to its terminal timeout state.
err = initResult.swap.processHtlcSpend(
context.Background(), &chainntnfs.SpendDetail{
SpendingTx: timeoutTx,
SpenderInputIndex: 0,
}, 100,
)
require.NoError(t, err)
require.Equal(t, loopdb.StateFailTimeout, initResult.swap.state)
}
// SubscribeSingleInvoice returns the mock's preconfigured channels.
func (p *probeInvoicesMock) SubscribeSingleInvoice(_ context.Context,
_ lntypes.Hash) (<-chan lndclient.InvoiceUpdate, <-chan error, error) {