Skip to content

Commit f166ce8

Browse files
committed
loopout: cancel swap with server when off-chain fails
1 parent 6b732ba commit f166ce8

File tree

4 files changed

+264
-2
lines changed

4 files changed

+264
-2
lines changed

client_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ func TestFailOffchain(t *testing.T) {
100100
signalPrepaymentResult(
101101
errors.New(lndclient.PaymentResultUnknownPaymentHash),
102102
)
103+
<-ctx.serverMock.cancelSwap
103104
ctx.assertStatus(loopdb.StateFailOffchainPayments)
104105

105106
ctx.assertStoreFinished(loopdb.StateFailOffchainPayments)

loopout.go

Lines changed: 111 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"crypto/sha256"
77
"errors"
88
"fmt"
9+
"math"
910
"sync"
1011
"time"
1112

@@ -21,8 +22,17 @@ import (
2122
"github.com/lightningnetwork/lnd/channeldb"
2223
"github.com/lightningnetwork/lnd/lnrpc"
2324
"github.com/lightningnetwork/lnd/lntypes"
25+
"github.com/lightningnetwork/lnd/zpay32"
2426
)
2527

28+
// loopInternalHops indicate the number of hops that a loop out swap makes in
29+
// the server's off-chain infrastructure. We are ok reporting failure distances
30+
// from the server up until this point, because every swap takes these two
31+
// hops, so surfacing this information does not identify the client in any way.
32+
// After this point, the client does not report failure distances, so that
33+
// sender-privacy is preserved.
34+
const loopInternalHops = 2
35+
2636
var (
2737
// MinLoopOutPreimageRevealDelta configures the minimum number of
2838
// remaining blocks before htlc expiry required to reveal preimage.
@@ -759,7 +769,10 @@ func (s *loopOutSwap) waitForConfirmedHtlc(globalCtx context.Context) (
759769
s.log.Infof("Failed swap payment: %v",
760770
result.failure())
761771

762-
s.state = loopdb.StateFailOffchainPayments
772+
s.failOffChain(
773+
ctx, paymentTypeInvoice,
774+
result.status,
775+
)
763776
return nil, nil
764777
}
765778

@@ -778,7 +791,11 @@ func (s *loopOutSwap) waitForConfirmedHtlc(globalCtx context.Context) (
778791
s.log.Infof("Failed prepayment: %v",
779792
result.failure())
780793

781-
s.state = loopdb.StateFailOffchainPayments
794+
s.failOffChain(
795+
ctx, paymentTypeInvoice,
796+
result.status,
797+
)
798+
782799
return nil, nil
783800
}
784801

@@ -972,6 +989,98 @@ func (s *loopOutSwap) pushPreimage(ctx context.Context) {
972989
}
973990
}
974991

992+
// failOffChain updates a swap's state when it has failed due to a routing
993+
// failure and notifies the server of the failure.
994+
func (s *loopOutSwap) failOffChain(ctx context.Context, paymentType paymentType,
995+
status lndclient.PaymentStatus) {
996+
997+
// Set our state to failed off chain timeout.
998+
s.state = loopdb.StateFailOffchainPayments
999+
1000+
swapPayReq, err := zpay32.Decode(
1001+
s.LoopOutContract.SwapInvoice, s.swapConfig.lnd.ChainParams,
1002+
)
1003+
if err != nil {
1004+
s.log.Errorf("could not decode swap invoice: %v", err)
1005+
return
1006+
}
1007+
1008+
if swapPayReq.PaymentAddr == nil {
1009+
s.log.Errorf("expected payment address for invoice")
1010+
return
1011+
}
1012+
1013+
details := &outCancelDetails{
1014+
hash: s.hash,
1015+
paymentAddr: *swapPayReq.PaymentAddr,
1016+
metadata: routeCancelMetadata{
1017+
paymentType: paymentType,
1018+
failureReason: status.FailureReason,
1019+
},
1020+
}
1021+
1022+
for _, htlc := range status.Htlcs {
1023+
if htlc.Status != lnrpc.HTLCAttempt_FAILED {
1024+
continue
1025+
}
1026+
1027+
if htlc.Route == nil {
1028+
continue
1029+
}
1030+
1031+
if len(htlc.Route.Hops) == 0 {
1032+
continue
1033+
}
1034+
1035+
if htlc.Failure == nil {
1036+
continue
1037+
}
1038+
1039+
failureIdx := htlc.Failure.FailureSourceIndex
1040+
hops := uint32(len(htlc.Route.Hops))
1041+
1042+
// We really don't expect a failure index that is greater than
1043+
// our number of hops. This is because failure index is zero
1044+
// based, where a value of zero means that the payment failed
1045+
// at the client's node, and a value = len(hops) means that it
1046+
// failed at the last node in the route. We don't want to
1047+
// underflow so we check and log a warning if this happens.
1048+
if failureIdx > hops {
1049+
s.log.Warnf("Htlc attempt failure index > hops",
1050+
failureIdx, hops)
1051+
1052+
continue
1053+
}
1054+
1055+
// Add the number of hops from the server that we failed at
1056+
// to the set of attempts that we will report to the server.
1057+
distance := hops - failureIdx
1058+
1059+
// In the case that our swap failed in the network at large,
1060+
// rather than the loop server's internal infrastructure, we
1061+
// don't want to disclose and information about distance from
1062+
// the server, so we set maxUint32 to represent failure in
1063+
// "the network at large" rather than due to the server's
1064+
// liquidity.
1065+
if distance > loopInternalHops {
1066+
distance = math.MaxUint32
1067+
}
1068+
1069+
details.metadata.attempts = append(
1070+
details.metadata.attempts, distance,
1071+
)
1072+
}
1073+
1074+
s.log.Infof("Canceling swap: %v payment failed: %v, %v attempts",
1075+
paymentType, details.metadata.failureReason,
1076+
len(details.metadata.attempts))
1077+
1078+
// Report to server, it's not critical if this doesn't go through.
1079+
if err := s.cancelSwap(ctx, details); err != nil {
1080+
s.log.Warnf("Could not report failure: %v", err)
1081+
}
1082+
}
1083+
9751084
// sweep tries to sweep the given htlc to a destination address. It takes into
9761085
// account the max miner fee and marks the preimage as revealed when it
9771086
// published the tx. If the preimage has not yet been revealed, and the time

loopout_test.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package loop
33
import (
44
"context"
55
"errors"
6+
"math"
67
"reflect"
78
"testing"
89
"time"
@@ -16,6 +17,7 @@ import (
1617
"github.com/lightninglabs/loop/test"
1718
"github.com/lightningnetwork/lnd/lnrpc"
1819
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
20+
"github.com/lightningnetwork/lnd/zpay32"
1921
"github.com/stretchr/testify/require"
2022
)
2123

@@ -701,3 +703,140 @@ func TestExpiryBeforeReveal(t *testing.T) {
701703

702704
require.Nil(t, <-errChan)
703705
}
706+
707+
// TestFailedOffChainCancelation tests sending of a cancelation message to
708+
// the server when a swap fails due to off-chain routing.
709+
func TestFailedOffChainCancelation(t *testing.T) {
710+
defer test.Guard(t)()
711+
712+
lnd := test.NewMockLnd()
713+
ctx := test.NewContext(t, lnd)
714+
server := newServerMock(lnd)
715+
716+
testReq := *testRequest
717+
testReq.Expiry = lnd.Height + 20
718+
719+
cfg := newSwapConfig(
720+
&lnd.LndServices, newStoreMock(t), server,
721+
)
722+
723+
initResult, err := newLoopOutSwap(
724+
context.Background(), cfg, lnd.Height, &testReq,
725+
)
726+
require.NoError(t, err)
727+
swap := initResult.swap
728+
729+
// Set up the required dependencies to execute the swap.
730+
sweeper := &sweep.Sweeper{Lnd: &lnd.LndServices}
731+
blockEpochChan := make(chan interface{})
732+
statusChan := make(chan SwapInfo)
733+
expiryChan := make(chan time.Time)
734+
timerFactory := func(_ time.Duration) <-chan time.Time {
735+
return expiryChan
736+
}
737+
738+
errChan := make(chan error)
739+
go func() {
740+
cfg := &executeConfig{
741+
statusChan: statusChan,
742+
sweeper: sweeper,
743+
blockEpochChan: blockEpochChan,
744+
timerFactory: timerFactory,
745+
cancelSwap: server.CancelLoopOutSwap,
746+
}
747+
748+
err := swap.execute(context.Background(), cfg, ctx.Lnd.Height)
749+
errChan <- err
750+
}()
751+
752+
// The swap should be found in its initial state.
753+
cfg.store.(*storeMock).assertLoopOutStored()
754+
state := <-statusChan
755+
require.Equal(t, loopdb.StateInitiated, state.State)
756+
757+
// Assert that we register for htlc confirmation notifications.
758+
ctx.AssertRegisterConf(false, defaultConfirmations)
759+
760+
// We expect prepayment and invoice to be dispatched, order is unknown.
761+
pmt1 := <-ctx.Lnd.RouterSendPaymentChannel
762+
pmt2 := <-ctx.Lnd.RouterSendPaymentChannel
763+
764+
failUpdate := lndclient.PaymentStatus{
765+
State: lnrpc.Payment_FAILED,
766+
FailureReason: lnrpc.PaymentFailureReason_FAILURE_REASON_ERROR,
767+
Htlcs: []*lndclient.HtlcAttempt{
768+
{
769+
// Include a non-failed htlc to test that we
770+
// only report failed htlcs.
771+
Status: lnrpc.HTLCAttempt_IN_FLIGHT,
772+
},
773+
// Add one htlc that failed within the server's
774+
// infrastructure.
775+
{
776+
Status: lnrpc.HTLCAttempt_FAILED,
777+
Route: &lnrpc.Route{
778+
Hops: []*lnrpc.Hop{
779+
{}, {}, {},
780+
},
781+
},
782+
Failure: &lndclient.HtlcFailure{
783+
FailureSourceIndex: 1,
784+
},
785+
},
786+
// Add one htlc that failed in the network at wide.
787+
{
788+
Status: lnrpc.HTLCAttempt_FAILED,
789+
Route: &lnrpc.Route{
790+
Hops: []*lnrpc.Hop{
791+
{}, {}, {}, {}, {},
792+
},
793+
},
794+
Failure: &lndclient.HtlcFailure{
795+
FailureSourceIndex: 1,
796+
},
797+
},
798+
},
799+
}
800+
801+
successUpdate := lndclient.PaymentStatus{
802+
State: lnrpc.Payment_SUCCEEDED,
803+
}
804+
805+
// We want to fail our swap payment and succeed the prepush, so we send
806+
// a failure update to the payment that has the larger amount.
807+
if pmt1.Amount > pmt2.Amount {
808+
pmt1.TrackPaymentMessage.Updates <- failUpdate
809+
pmt2.TrackPaymentMessage.Updates <- successUpdate
810+
} else {
811+
pmt1.TrackPaymentMessage.Updates <- successUpdate
812+
pmt2.TrackPaymentMessage.Updates <- failUpdate
813+
}
814+
815+
invoice, err := zpay32.Decode(
816+
swap.LoopOutContract.SwapInvoice, lnd.ChainParams,
817+
)
818+
require.NoError(t, err)
819+
require.NotNil(t, invoice.PaymentAddr)
820+
821+
swapCancelation := &outCancelDetails{
822+
hash: swap.hash,
823+
paymentAddr: *invoice.PaymentAddr,
824+
metadata: routeCancelMetadata{
825+
paymentType: paymentTypeInvoice,
826+
failureReason: failUpdate.FailureReason,
827+
attempts: []uint32{
828+
2,
829+
math.MaxUint32,
830+
},
831+
},
832+
}
833+
server.assertSwapCanceled(t, swapCancelation)
834+
835+
// Finally, the swap should be recorded with failed off chain timeout.
836+
cfg.store.(*storeMock).assertLoopOutState(
837+
loopdb.StateFailOffchainPayments,
838+
)
839+
state = <-statusChan
840+
require.Equal(t, state.State, loopdb.StateFailOffchainPayments)
841+
require.NoError(t, <-errChan)
842+
}

server_mock_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package loop
33
import (
44
"context"
55
"errors"
6+
"testing"
67

78
"time"
89

@@ -15,6 +16,7 @@ import (
1516
"github.com/lightningnetwork/lnd/lnwire"
1617
"github.com/lightningnetwork/lnd/routing/route"
1718
"github.com/lightningnetwork/lnd/zpay32"
19+
"github.com/stretchr/testify/require"
1820
)
1921

2022
var (
@@ -124,10 +126,17 @@ func (s *serverMock) GetLoopOutQuote(ctx context.Context, amt btcutil.Amount,
124126
}
125127

126128
func getInvoice(hash lntypes.Hash, amt btcutil.Amount, memo string) (string, error) {
129+
// Set different payment addresses for swap invoices.
130+
payAddr := [32]byte{1, 2, 3}
131+
if memo == swapInvoiceDesc {
132+
payAddr = [32]byte{3, 2, 1}
133+
}
134+
127135
req, err := zpay32.NewInvoice(
128136
&chaincfg.TestNet3Params, hash, testTime,
129137
zpay32.Description(memo),
130138
zpay32.Amount(lnwire.MilliSatoshi(1000*amt)),
139+
zpay32.PaymentAddr(payAddr),
131140
)
132141
if err != nil {
133142
return "", err
@@ -190,6 +199,10 @@ func (s *serverMock) CancelLoopOutSwap(ctx context.Context,
190199
return nil
191200
}
192201

202+
func (s *serverMock) assertSwapCanceled(t *testing.T, details *outCancelDetails) {
203+
require.Equal(t, details, <-s.cancelSwap)
204+
}
205+
193206
func (s *serverMock) GetLoopInTerms(ctx context.Context) (
194207
*LoopInTerms, error) {
195208

0 commit comments

Comments
 (0)