From 5e2034ed5b762c04e75609f1858771ab8d980cfd Mon Sep 17 00:00:00 2001 From: Oliver Gugger Date: Wed, 28 Apr 2021 09:30:17 +0200 Subject: [PATCH] lsat: remove pending token if payment failed --- lsat/client_interceptor.go | 37 ++++++++++++++++++++ lsat/client_interceptor_test.go | 60 +++++++++++++++++++++++++++++++++ lsat/store.go | 14 ++++++++ 3 files changed, 111 insertions(+) diff --git a/lsat/client_interceptor.go b/lsat/client_interceptor.go index 691ca07..93c331b 100644 --- a/lsat/client_interceptor.go +++ b/lsat/client_interceptor.go @@ -3,6 +3,7 @@ package lsat import ( "context" "encoding/base64" + "errors" "fmt" "regexp" "sync" @@ -58,6 +59,12 @@ var ( authHeaderRegex = regexp.MustCompile( "LSAT macaroon=\"(.*?)\", invoice=\"(.*?)\"", ) + + // errPaymentFailedTerminally is signaled by the payment tracking method + // to indicate a payment failed for good and will never change to a + // success state. + errPaymentFailedTerminally = errors.New("payment is in terminal " + + "failure state") ) // ClientInterceptor is a gRPC client interceptor that can handle LSAT @@ -249,6 +256,29 @@ func (i *ClientInterceptor) handlePayment(iCtx *interceptContext) error { log.Infof("Payment of LSAT token is required, resuming/" + "tracking previous payment from pending LSAT token") err := i.trackPayment(iCtx.mainCtx, iCtx.token) + + // If the payment failed for good, it will never come back to a + // success state. We need to remove the pending token and try + // again. + if err == errPaymentFailedTerminally { + iCtx.token = nil + if err := i.store.RemovePendingToken(); err != nil { + return fmt.Errorf("error removing pending "+ + "token, cannot retry payment: %v", err) + } + + // Let's try again by paying for the new token. + log.Infof("Retrying payment of LSAT token invoice") + var err error + iCtx.token, err = i.payLsatToken( + iCtx.mainCtx, iCtx.metadata, + ) + if err != nil { + return err + } + + break + } if err != nil { return err } @@ -408,6 +438,13 @@ func (i *ClientInterceptor) trackPayment(ctx context.Context, token *Token) erro // time to complete. case lnrpc.Payment_IN_FLIGHT: + // The payment is in a terminal failed state, it will + // never recover. There is no use keeping the pending + // token around. So we signal the caller to remove it + // and try again. + case lnrpc.Payment_FAILED: + return errPaymentFailedTerminally + // Any other state means either error or timeout. default: return fmt.Errorf("payment tracking failed "+ diff --git a/lsat/client_interceptor_test.go b/lsat/client_interceptor_test.go index cf6b719..624ed97 100644 --- a/lsat/client_interceptor_test.go +++ b/lsat/client_interceptor_test.go @@ -25,6 +25,7 @@ type interceptTestCase struct { interceptor *ClientInterceptor resetCb func() expectLndCall bool + expectSecondLndCall bool sendPaymentCb func(*testing.T, test.PaymentChannelMessage) trackPaymentCb func(*testing.T, test.TrackPaymentMessage) expectToken bool @@ -54,6 +55,11 @@ func (s *mockStore) StoreToken(token *Token) error { return nil } +func (s *mockStore) RemovePendingToken() error { + s.token = nil + return nil +} + var ( lnd = test.NewMockLnd() store = &mockStore{} @@ -158,6 +164,46 @@ var ( expectBackendCalls: 2, expectMacaroonCall1: false, expectMacaroonCall2: true, + }, { + name: "auth required, has pending but expired token", + initialPreimage: &zeroPreimage, + interceptor: interceptor, + resetCb: func() { + resetBackend( + status.New(GRPCErrCode, GRPCErrMessage).Err(), + makeAuthHeader(testMacBytes), + ) + }, + expectLndCall: true, + expectSecondLndCall: true, + sendPaymentCb: func(t *testing.T, + msg test.PaymentChannelMessage) { + + require.Len(t, callMD, 0) + + // The next call to the "backend" shouldn't return an + // error. + resetBackend(nil, "") + msg.Done <- lndclient.PaymentResult{ + Preimage: paidPreimage, + PaidAmt: 123, + PaidFee: 345, + } + }, + trackPaymentCb: func(t *testing.T, + msg test.TrackPaymentMessage) { + + // The next call to the "backend" shouldn't return an + // error. + resetBackend(nil, "") + msg.Updates <- lndclient.PaymentStatus{ + State: lnrpc.Payment_FAILED, + } + }, + expectToken: true, + expectBackendCalls: 2, + expectMacaroonCall1: false, + expectMacaroonCall2: true, }, { name: "auth required, no token yet, cost limit", initialPreimage: nil, @@ -317,6 +363,18 @@ func testInterceptor(t *testing.T, tc interceptTestCase, t.Fatalf("[%s]: no payment request received", tc.name) } } + if tc.expectSecondLndCall { + select { + case payment := <-lnd.SendPaymentChannel: + tc.sendPaymentCb(t, payment) + + case track := <-lnd.TrackPaymentChannel: + tc.trackPaymentCb(t, track) + + case <-time.After(testTimeout): + t.Fatalf("[%s]: no payment request received", tc.name) + } + } backendWg.Wait() overallWg.Wait() @@ -334,6 +392,8 @@ func testInterceptor(t *testing.T, tc interceptTestCase, if tc.expectToken { require.NoError(t, err) require.Equal(t, paidPreimage, storeToken.Preimage) + } else { + require.Equal(t, ErrNoToken, err) } if tc.expectMacaroonCall2 { require.Len(t, callMD, 1) diff --git a/lsat/store.go b/lsat/store.go index 3122879..11c028d 100644 --- a/lsat/store.go +++ b/lsat/store.go @@ -42,6 +42,10 @@ type Store interface { // StoreToken saves a token to the store. Old tokens should be kept for // accounting purposes but marked as invalid somehow. StoreToken(*Token) error + + // RemovePendingToken removes a pending token from the store or returns + // ErrNoToken if there is no pending token. + RemovePendingToken() error } // FileStore is an implementation of the Store interface that files to save the @@ -190,6 +194,16 @@ func (f *FileStore) StoreToken(newToken *Token) error { } } +// RemovePendingToken removes a pending token from the store or returns +// ErrNoToken if there is no pending token. +func (f *FileStore) RemovePendingToken() error { + if !fileExists(f.fileNamePending) { + return ErrNoToken + } + + return os.Remove(f.fileNamePending) +} + // readTokenFile reads a single token from a file and returns it deserialized. func readTokenFile(tokenFile string) (*Token, error) { bytes, err := ioutil.ReadFile(tokenFile)