diff --git a/client_test.go b/client_test.go index aca47fe..06995b2 100644 --- a/client_test.go +++ b/client_test.go @@ -100,6 +100,7 @@ func TestFailOffchain(t *testing.T) { signalPrepaymentResult( errors.New(lndclient.PaymentResultUnknownPaymentHash), ) + <-ctx.serverMock.cancelSwap ctx.assertStatus(loopdb.StateFailOffchainPayments) ctx.assertStoreFinished(loopdb.StateFailOffchainPayments) diff --git a/loopout.go b/loopout.go index 8a20349..9c8ce0b 100644 --- a/loopout.go +++ b/loopout.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "errors" "fmt" + "math" "sync" "time" @@ -21,8 +22,17 @@ import ( "github.com/lightningnetwork/lnd/channeldb" "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/zpay32" ) +// loopInternalHops indicate the number of hops that a loop out swap makes in +// the server's off-chain infrastructure. We are ok reporting failure distances +// from the server up until this point, because every swap takes these two +// hops, so surfacing this information does not identify the client in any way. +// After this point, the client does not report failure distances, so that +// sender-privacy is preserved. +const loopInternalHops = 2 + var ( // MinLoopOutPreimageRevealDelta configures the minimum number of // remaining blocks before htlc expiry required to reveal preimage. @@ -759,7 +769,10 @@ func (s *loopOutSwap) waitForConfirmedHtlc(globalCtx context.Context) ( s.log.Infof("Failed swap payment: %v", result.failure()) - s.state = loopdb.StateFailOffchainPayments + s.failOffChain( + ctx, paymentTypeInvoice, + result.status, + ) return nil, nil } @@ -778,7 +791,11 @@ func (s *loopOutSwap) waitForConfirmedHtlc(globalCtx context.Context) ( s.log.Infof("Failed prepayment: %v", result.failure()) - s.state = loopdb.StateFailOffchainPayments + s.failOffChain( + ctx, paymentTypeInvoice, + result.status, + ) + return nil, nil } @@ -972,6 +989,98 @@ func (s *loopOutSwap) pushPreimage(ctx context.Context) { } } +// failOffChain updates a swap's state when it has failed due to a routing +// failure and notifies the server of the failure. +func (s *loopOutSwap) failOffChain(ctx context.Context, paymentType paymentType, + status lndclient.PaymentStatus) { + + // Set our state to failed off chain timeout. + s.state = loopdb.StateFailOffchainPayments + + swapPayReq, err := zpay32.Decode( + s.LoopOutContract.SwapInvoice, s.swapConfig.lnd.ChainParams, + ) + if err != nil { + s.log.Errorf("could not decode swap invoice: %v", err) + return + } + + if swapPayReq.PaymentAddr == nil { + s.log.Errorf("expected payment address for invoice") + return + } + + details := &outCancelDetails{ + hash: s.hash, + paymentAddr: *swapPayReq.PaymentAddr, + metadata: routeCancelMetadata{ + paymentType: paymentType, + failureReason: status.FailureReason, + }, + } + + for _, htlc := range status.Htlcs { + if htlc.Status != lnrpc.HTLCAttempt_FAILED { + continue + } + + if htlc.Route == nil { + continue + } + + if len(htlc.Route.Hops) == 0 { + continue + } + + if htlc.Failure == nil { + continue + } + + failureIdx := htlc.Failure.FailureSourceIndex + hops := uint32(len(htlc.Route.Hops)) + + // We really don't expect a failure index that is greater than + // our number of hops. This is because failure index is zero + // based, where a value of zero means that the payment failed + // at the client's node, and a value = len(hops) means that it + // failed at the last node in the route. We don't want to + // underflow so we check and log a warning if this happens. + if failureIdx > hops { + s.log.Warnf("Htlc attempt failure index > hops", + failureIdx, hops) + + continue + } + + // Add the number of hops from the server that we failed at + // to the set of attempts that we will report to the server. + distance := hops - failureIdx + + // In the case that our swap failed in the network at large, + // rather than the loop server's internal infrastructure, we + // don't want to disclose and information about distance from + // the server, so we set maxUint32 to represent failure in + // "the network at large" rather than due to the server's + // liquidity. + if distance > loopInternalHops { + distance = math.MaxUint32 + } + + details.metadata.attempts = append( + details.metadata.attempts, distance, + ) + } + + s.log.Infof("Canceling swap: %v payment failed: %v, %v attempts", + paymentType, details.metadata.failureReason, + len(details.metadata.attempts)) + + // Report to server, it's not critical if this doesn't go through. + if err := s.cancelSwap(ctx, details); err != nil { + s.log.Warnf("Could not report failure: %v", err) + } +} + // sweep tries to sweep the given htlc to a destination address. It takes into // account the max miner fee and marks the preimage as revealed when it // published the tx. If the preimage has not yet been revealed, and the time diff --git a/loopout_test.go b/loopout_test.go index c4eeca9..ea146d0 100644 --- a/loopout_test.go +++ b/loopout_test.go @@ -3,6 +3,7 @@ package loop import ( "context" "errors" + "math" "reflect" "testing" "time" @@ -16,6 +17,7 @@ import ( "github.com/lightninglabs/loop/test" "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/lightningnetwork/lnd/zpay32" "github.com/stretchr/testify/require" ) @@ -701,3 +703,140 @@ func TestExpiryBeforeReveal(t *testing.T) { require.Nil(t, <-errChan) } + +// TestFailedOffChainCancelation tests sending of a cancelation message to +// the server when a swap fails due to off-chain routing. +func TestFailedOffChainCancelation(t *testing.T) { + defer test.Guard(t)() + + lnd := test.NewMockLnd() + ctx := test.NewContext(t, lnd) + server := newServerMock(lnd) + + testReq := *testRequest + testReq.Expiry = lnd.Height + 20 + + cfg := newSwapConfig( + &lnd.LndServices, newStoreMock(t), server, + ) + + initResult, err := newLoopOutSwap( + context.Background(), cfg, lnd.Height, &testReq, + ) + require.NoError(t, err) + swap := initResult.swap + + // Set up the required dependencies to execute the swap. + sweeper := &sweep.Sweeper{Lnd: &lnd.LndServices} + blockEpochChan := make(chan interface{}) + statusChan := make(chan SwapInfo) + expiryChan := make(chan time.Time) + timerFactory := func(_ time.Duration) <-chan time.Time { + return expiryChan + } + + errChan := make(chan error) + go func() { + cfg := &executeConfig{ + statusChan: statusChan, + sweeper: sweeper, + blockEpochChan: blockEpochChan, + timerFactory: timerFactory, + cancelSwap: server.CancelLoopOutSwap, + } + + err := swap.execute(context.Background(), cfg, ctx.Lnd.Height) + errChan <- err + }() + + // The swap should be found in its initial state. + cfg.store.(*storeMock).assertLoopOutStored() + state := <-statusChan + require.Equal(t, loopdb.StateInitiated, state.State) + + // Assert that we register for htlc confirmation notifications. + ctx.AssertRegisterConf(false, defaultConfirmations) + + // We expect prepayment and invoice to be dispatched, order is unknown. + pmt1 := <-ctx.Lnd.RouterSendPaymentChannel + pmt2 := <-ctx.Lnd.RouterSendPaymentChannel + + failUpdate := lndclient.PaymentStatus{ + State: lnrpc.Payment_FAILED, + FailureReason: lnrpc.PaymentFailureReason_FAILURE_REASON_ERROR, + Htlcs: []*lndclient.HtlcAttempt{ + { + // Include a non-failed htlc to test that we + // only report failed htlcs. + Status: lnrpc.HTLCAttempt_IN_FLIGHT, + }, + // Add one htlc that failed within the server's + // infrastructure. + { + Status: lnrpc.HTLCAttempt_FAILED, + Route: &lnrpc.Route{ + Hops: []*lnrpc.Hop{ + {}, {}, {}, + }, + }, + Failure: &lndclient.HtlcFailure{ + FailureSourceIndex: 1, + }, + }, + // Add one htlc that failed in the network at wide. + { + Status: lnrpc.HTLCAttempt_FAILED, + Route: &lnrpc.Route{ + Hops: []*lnrpc.Hop{ + {}, {}, {}, {}, {}, + }, + }, + Failure: &lndclient.HtlcFailure{ + FailureSourceIndex: 1, + }, + }, + }, + } + + successUpdate := lndclient.PaymentStatus{ + State: lnrpc.Payment_SUCCEEDED, + } + + // We want to fail our swap payment and succeed the prepush, so we send + // a failure update to the payment that has the larger amount. + if pmt1.Amount > pmt2.Amount { + pmt1.TrackPaymentMessage.Updates <- failUpdate + pmt2.TrackPaymentMessage.Updates <- successUpdate + } else { + pmt1.TrackPaymentMessage.Updates <- successUpdate + pmt2.TrackPaymentMessage.Updates <- failUpdate + } + + invoice, err := zpay32.Decode( + swap.LoopOutContract.SwapInvoice, lnd.ChainParams, + ) + require.NoError(t, err) + require.NotNil(t, invoice.PaymentAddr) + + swapCancelation := &outCancelDetails{ + hash: swap.hash, + paymentAddr: *invoice.PaymentAddr, + metadata: routeCancelMetadata{ + paymentType: paymentTypeInvoice, + failureReason: failUpdate.FailureReason, + attempts: []uint32{ + 2, + math.MaxUint32, + }, + }, + } + server.assertSwapCanceled(t, swapCancelation) + + // Finally, the swap should be recorded with failed off chain timeout. + cfg.store.(*storeMock).assertLoopOutState( + loopdb.StateFailOffchainPayments, + ) + state = <-statusChan + require.Equal(t, state.State, loopdb.StateFailOffchainPayments) + require.NoError(t, <-errChan) +} diff --git a/server_mock_test.go b/server_mock_test.go index 9e53a7f..b687e36 100644 --- a/server_mock_test.go +++ b/server_mock_test.go @@ -3,6 +3,7 @@ package loop import ( "context" "errors" + "testing" "time" @@ -15,6 +16,7 @@ import ( "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/routing/route" "github.com/lightningnetwork/lnd/zpay32" + "github.com/stretchr/testify/require" ) var ( @@ -124,10 +126,17 @@ func (s *serverMock) GetLoopOutQuote(ctx context.Context, amt btcutil.Amount, } func getInvoice(hash lntypes.Hash, amt btcutil.Amount, memo string) (string, error) { + // Set different payment addresses for swap invoices. + payAddr := [32]byte{1, 2, 3} + if memo == swapInvoiceDesc { + payAddr = [32]byte{3, 2, 1} + } + req, err := zpay32.NewInvoice( &chaincfg.TestNet3Params, hash, testTime, zpay32.Description(memo), zpay32.Amount(lnwire.MilliSatoshi(1000*amt)), + zpay32.PaymentAddr(payAddr), ) if err != nil { return "", err @@ -190,6 +199,10 @@ func (s *serverMock) CancelLoopOutSwap(ctx context.Context, return nil } +func (s *serverMock) assertSwapCanceled(t *testing.T, details *outCancelDetails) { + require.Equal(t, details, <-s.cancelSwap) +} + func (s *serverMock) GetLoopInTerms(ctx context.Context) ( *LoopInTerms, error) {