diff --git a/README.md b/README.md index c54a947..52780c4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # LndHub.go Wrapper for Lightning Network Daemon (lnd). It provides separate accounts with minimum trust for end users. +Live deployment at [ln.getalby.com](https://ln.getalby.com). + ### [LndHub](https://github.com/BlueWallet/LndHub) compatible API implemented in Go using relational database backends * Using a relational database (PostgreSQL and SQLite) @@ -13,7 +15,6 @@ Wrapper for Lightning Network Daemon (lnd). It provides separate accounts with m ## Known Issues * Currently no fee handling (users are currently not charged for lightning transaction fees) -* No handling of in-transit payments ## Configuration diff --git a/common/globals.go b/common/globals.go index 1b4f6d8..b53b18b 100644 --- a/common/globals.go +++ b/common/globals.go @@ -9,6 +9,7 @@ const ( InvoiceStateSettled = "settled" InvoiceStateInitialized = "initialized" InvoiceStateOpen = "open" + InvoiceStateError = "error" AccountTypeIncoming = "incoming" AccountTypeCurrent = "current" diff --git a/controllers/checkpayment.ctrl.go b/controllers/checkpayment.ctrl.go index d2068a1..c280058 100644 --- a/controllers/checkpayment.ctrl.go +++ b/controllers/checkpayment.ctrl.go @@ -13,6 +13,10 @@ type CheckPaymentController struct { svc *service.LndhubService } +type CheckPaymentResponseBody struct { + IsPaid bool `json:"paid"` +} + func NewCheckPaymentController(svc *service.LndhubService) *CheckPaymentController { return &CheckPaymentController{svc: svc} } @@ -30,9 +34,7 @@ func (controller *CheckPaymentController) CheckPayment(c echo.Context) error { return c.JSON(http.StatusBadRequest, responses.BadArgumentsError) } - var responseBody struct { - IsPaid bool `json:"paid"` - } + responseBody := &CheckPaymentResponseBody{} responseBody.IsPaid = !invoice.SettledAt.IsZero() return c.JSON(http.StatusOK, &responseBody) } diff --git a/db/models/invoice.go b/db/models/invoice.go index ebb0aa5..a117adf 100644 --- a/db/models/invoice.go +++ b/db/models/invoice.go @@ -22,6 +22,7 @@ type Invoice struct { Preimage string `json:"preimage" bun:",nullzero"` Internal bool `json:"internal" bun:",nullzero"` State string `json:"state" bun:",default:'initialized'"` + ErrorMessage string `json:"error_message" bun:",nullzero"` AddIndex uint64 `json:"add_index" bun:",nullzero"` CreatedAt time.Time `bun:",nullzero,notnull,default:current_timestamp"` ExpiresAt bun.NullTime `bun:",nullzero"` diff --git a/integration_tests/auth_test.go b/integration_tests/auth_test.go index e453928..bd92753 100644 --- a/integration_tests/auth_test.go +++ b/integration_tests/auth_test.go @@ -27,7 +27,7 @@ type UserAuthTestSuite struct { } func (suite *UserAuthTestSuite) SetupSuite() { - svc, err := LndHubTestServiceInit() + svc, err := LndHubTestServiceInit(nil) if err != nil { log.Fatalf("Error initializing test service: %v", err) } diff --git a/integration_tests/checkpayment_test.go b/integration_tests/checkpayment_test.go new file mode 100644 index 0000000..fade5e7 --- /dev/null +++ b/integration_tests/checkpayment_test.go @@ -0,0 +1,132 @@ +package integration_tests + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/getAlby/lndhub.go/controllers" + "github.com/getAlby/lndhub.go/lib" + "github.com/getAlby/lndhub.go/lib/responses" + "github.com/getAlby/lndhub.go/lib/service" + "github.com/getAlby/lndhub.go/lib/tokens" + "github.com/getAlby/lndhub.go/lnd" + "github.com/go-playground/validator/v10" + "github.com/labstack/echo/v4" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type CheckPaymentTestSuite struct { + TestSuite + fundingClient *lnd.LNDWrapper + service *service.LndhubService + userLogin controllers.CreateUserResponseBody + userToken string + invoiceUpdateSubCancelFn context.CancelFunc +} + +func (suite *CheckPaymentTestSuite) SetupSuite() { + lndClient, err := lnd.NewLNDclient(lnd.LNDoptions{ + Address: lnd2RegtestAddress, + MacaroonHex: lnd2RegtestMacaroonHex, + }) + if err != nil { + log.Fatalf("Error setting up funding client: %v", err) + } + suite.fundingClient = lndClient + + svc, err := LndHubTestServiceInit(nil) + if err != nil { + log.Fatalf("Error initializing test service: %v", err) + } + users, userTokens, err := createUsers(svc, 1) + if err != nil { + log.Fatalf("Error creating test users: %v", err) + } + // Subscribe to LND invoice updates in the background + // store cancel func to be called in tear down suite + ctx, cancel := context.WithCancel(context.Background()) + suite.invoiceUpdateSubCancelFn = cancel + go svc.InvoiceUpdateSubscription(ctx) + suite.service = svc + e := echo.New() + + e.HTTPErrorHandler = responses.HTTPErrorHandler + e.Validator = &lib.CustomValidator{Validator: validator.New()} + suite.echo = e + assert.Equal(suite.T(), 1, len(users)) + assert.Equal(suite.T(), 1, len(userTokens)) + suite.userLogin = users[0] + suite.userToken = userTokens[0] + suite.echo.Use(tokens.Middleware([]byte(suite.service.Config.JWTSecret))) + suite.echo.POST("/addinvoice", controllers.NewAddInvoiceController(suite.service).AddInvoice) + suite.echo.POST("/payinvoice", controllers.NewPayInvoiceController(suite.service).PayInvoice) + suite.echo.GET("/checkpayment/:payment_hash", controllers.NewCheckPaymentController(suite.service).CheckPayment) +} + +func (suite *CheckPaymentTestSuite) TestCheckPaymentNotFound() { + dummyRHash := "12345" + req := httptest.NewRequest(http.MethodGet, "/checkpayment/"+dummyRHash, nil) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", suite.userToken)) + rec := httptest.NewRecorder() + suite.echo.ServeHTTP(rec, req) + errorResponse := &responses.ErrorResponse{} + assert.Equal(suite.T(), http.StatusBadRequest, rec.Code) + assert.NoError(suite.T(), json.NewDecoder(rec.Body).Decode(errorResponse)) +} + +func (suite *CheckPaymentTestSuite) TestCheckPaymentProperIsPaidResponse() { + // create incoming invoice and fund account + invoice := suite.createAddInvoiceReq(1000, "integration test check payments for user", suite.userToken) + sendPaymentRequest := lnrpc.SendRequest{ + PaymentRequest: invoice.PayReq, + FeeLimit: nil, + } + _, err := suite.fundingClient.SendPaymentSync(context.Background(), &sendPaymentRequest) + assert.NoError(suite.T(), err) + + // wait a bit for the callback event to hit + time.Sleep(100 * time.Millisecond) + // create invoice + invoice = suite.createAddInvoiceReq(500, "integration test check payments for user", suite.userToken) + // pay invoice, this will create outgoing invoice and settle it + + // check payment not paid + req := httptest.NewRequest(http.MethodGet, "/checkpayment/"+invoice.RHash, nil) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", suite.userToken)) + rec := httptest.NewRecorder() + suite.echo.ServeHTTP(rec, req) + checkPaymentResponse := &controllers.CheckPaymentResponseBody{} + assert.Equal(suite.T(), http.StatusOK, rec.Code) + assert.NoError(suite.T(), json.NewDecoder(rec.Body).Decode(checkPaymentResponse)) + assert.False(suite.T(), checkPaymentResponse.IsPaid) + + // pay external from user + payResponse := suite.createPayInvoiceReq(invoice.PaymentRequest, suite.userToken) + assert.NotEmpty(suite.T(), payResponse.PaymentPreimage) + + // check payment is paid + req = httptest.NewRequest(http.MethodGet, "/checkpayment/"+invoice.RHash, nil) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", suite.userToken)) + rec = httptest.NewRecorder() + suite.echo.ServeHTTP(rec, req) + checkPaymentResponse = &controllers.CheckPaymentResponseBody{} + assert.Equal(suite.T(), http.StatusOK, rec.Code) + assert.NoError(suite.T(), json.NewDecoder(rec.Body).Decode(checkPaymentResponse)) + assert.True(suite.T(), checkPaymentResponse.IsPaid) +} + +func (suite *CheckPaymentTestSuite) TearDownSuite() { + suite.invoiceUpdateSubCancelFn() +} + +func TestCheckPaymentSuite(t *testing.T) { + suite.Run(t, new(CheckPaymentTestSuite)) +} diff --git a/integration_tests/create_test.go b/integration_tests/create_test.go index 275e537..fdaa3dd 100644 --- a/integration_tests/create_test.go +++ b/integration_tests/create_test.go @@ -25,7 +25,7 @@ type CreateUserTestSuite struct { } func (suite *CreateUserTestSuite) SetupSuite() { - svc, err := LndHubTestServiceInit() + svc, err := LndHubTestServiceInit(nil) if err != nil { log.Fatalf("Error initializing test service: %v", err) } @@ -36,6 +36,15 @@ func (suite *CreateUserTestSuite) TearDownSuite() { } +func (suite *CreateUserTestSuite) TearDownTest() { + err := clearTable(suite.Service, "users") + if err != nil { + fmt.Printf("Tear down test error %v\n", err.Error()) + return + } + fmt.Println("Tear down test success") +} + func (suite *CreateUserTestSuite) TestCreate() { e := echo.New() e.HTTPErrorHandler = responses.HTTPErrorHandler diff --git a/integration_tests/getinfo_test.go b/integration_tests/getinfo_test.go new file mode 100644 index 0000000..0bb9300 --- /dev/null +++ b/integration_tests/getinfo_test.go @@ -0,0 +1,83 @@ +package integration_tests + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "net/http/httptest" + "testing" + + "github.com/getAlby/lndhub.go/controllers" + "github.com/getAlby/lndhub.go/lib" + "github.com/getAlby/lndhub.go/lib/responses" + "github.com/getAlby/lndhub.go/lib/service" + "github.com/getAlby/lndhub.go/lib/tokens" + "github.com/go-playground/validator/v10" + "github.com/labstack/echo/v4" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type GetInfoTestSuite struct { + TestSuite + service *service.LndhubService + userLogin controllers.CreateUserResponseBody + userToken string +} + +func (suite *GetInfoTestSuite) SetupSuite() { + svc, err := LndHubTestServiceInit(nil) + if err != nil { + log.Fatalf("Error initializing test service: %v", err) + } + users, userTokens, err := createUsers(svc, 1) + if err != nil { + log.Fatalf("Error creating test users: %v", err) + } + + suite.service = svc + e := echo.New() + + e.HTTPErrorHandler = responses.HTTPErrorHandler + e.Validator = &lib.CustomValidator{Validator: validator.New()} + suite.echo = e + assert.Equal(suite.T(), 1, len(users)) + assert.Equal(suite.T(), 1, len(userTokens)) + suite.userLogin = users[0] + suite.userToken = userTokens[0] + suite.echo.Use(tokens.Middleware([]byte(suite.service.Config.JWTSecret))) + suite.echo.GET("/getinfo", controllers.NewGetInfoController(svc).GetInfo) +} + +func (suite *GetInfoTestSuite) TestGetInfoWithDefaultAlias() { + req := httptest.NewRequest(http.MethodGet, "/getinfo", nil) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", suite.userToken)) + rec := httptest.NewRecorder() + suite.echo.ServeHTTP(rec, req) + getInfoResponse := &lnrpc.GetInfoResponse{} + assert.Equal(suite.T(), http.StatusOK, rec.Code) + assert.NoError(suite.T(), json.NewDecoder(rec.Body).Decode(getInfoResponse)) + assert.NotNil(suite.T(), getInfoResponse) + assert.Equal(suite.T(), "alby-simnet-lnd1", getInfoResponse.Alias) +} + +func (suite *GetInfoTestSuite) TestGetInfoWithGivenAlias() { + suite.service.Config.CustomName = "test-alias" + req := httptest.NewRequest(http.MethodGet, "/getinfo", nil) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", suite.userToken)) + rec := httptest.NewRecorder() + suite.echo.ServeHTTP(rec, req) + getInfoResponse := &lnrpc.GetInfoResponse{} + assert.Equal(suite.T(), http.StatusOK, rec.Code) + assert.NoError(suite.T(), json.NewDecoder(rec.Body).Decode(getInfoResponse)) + assert.NotNil(suite.T(), getInfoResponse) + assert.Equal(suite.T(), suite.service.Config.CustomName, getInfoResponse.Alias) +} + +func (suite *GetInfoTestSuite) TearDownSuite() {} + +func TestGetInfoSuite(t *testing.T) { + suite.Run(t, new(GetInfoTestSuite)) +} diff --git a/integration_tests/gettxs_test.go b/integration_tests/gettxs_test.go index f490244..72b71cf 100644 --- a/integration_tests/gettxs_test.go +++ b/integration_tests/gettxs_test.go @@ -42,7 +42,7 @@ func (suite *GetTxTestSuite) SetupSuite() { } suite.fundingClient = lndClient - svc, err := LndHubTestServiceInit() + svc, err := LndHubTestServiceInit(nil) if err != nil { log.Fatalf("Error initializing test service: %v", err) } diff --git a/integration_tests/incoming_payment_test.go b/integration_tests/incoming_payment_test.go index 5762c92..0683126 100644 --- a/integration_tests/incoming_payment_test.go +++ b/integration_tests/incoming_payment_test.go @@ -24,17 +24,13 @@ import ( "github.com/stretchr/testify/suite" ) -const ( - lnd2RegtestAddress = "rpc.lnd2.regtest.getalby.com:443" - lnd2RegtestMacaroonHex = "0201036C6E6402F801030A101782922F4358E80655920FC7A7C3E9291201301A160A0761646472657373120472656164120577726974651A130A04696E666F120472656164120577726974651A170A08696E766F69636573120472656164120577726974651A210A086D616361726F6F6E120867656E6572617465120472656164120577726974651A160A076D657373616765120472656164120577726974651A170A086F6666636861696E120472656164120577726974651A160A076F6E636861696E120472656164120577726974651A140A057065657273120472656164120577726974651A180A067369676E6572120867656E657261746512047265616400000620628FFB2938C8540DD3AA5E578D9B43456835FAA176E175FFD4F9FBAE540E3BE9" -) - type IncomingPaymentTestSuite struct { TestSuite - fundingClient *lnd.LNDWrapper - service *service.LndhubService - userLogin controllers.CreateUserResponseBody - userToken string + fundingClient *lnd.LNDWrapper + service *service.LndhubService + userLogin controllers.CreateUserResponseBody + userToken string + invoiceUpdateSubCancelFn context.CancelFunc } func (suite *IncomingPaymentTestSuite) SetupSuite() { @@ -47,7 +43,7 @@ func (suite *IncomingPaymentTestSuite) SetupSuite() { } suite.fundingClient = lndClient - svc, err := LndHubTestServiceInit() + svc, err := LndHubTestServiceInit(nil) if err != nil { log.Fatalf("Error initializing test service: %v", err) } @@ -56,7 +52,10 @@ func (suite *IncomingPaymentTestSuite) SetupSuite() { log.Fatalf("Error creating test users: %v", err) } // Subscribe to LND invoice updates in the background - go svc.InvoiceUpdateSubscription(context.Background()) + // store cancel func to be called in tear down suite + ctx, cancel := context.WithCancel(context.Background()) + suite.invoiceUpdateSubCancelFn = cancel + go svc.InvoiceUpdateSubscription(ctx) suite.service = svc e := echo.New() @@ -70,7 +69,7 @@ func (suite *IncomingPaymentTestSuite) SetupSuite() { } func (suite *IncomingPaymentTestSuite) TearDownSuite() { - + suite.invoiceUpdateSubCancelFn() } func (suite *IncomingPaymentTestSuite) TestIncomingPayment() { diff --git a/integration_tests/internal_payment_test.go b/integration_tests/internal_payment_test.go index 4105504..fc3ff08 100644 --- a/integration_tests/internal_payment_test.go +++ b/integration_tests/internal_payment_test.go @@ -2,10 +2,12 @@ package integration_tests import ( "context" + "fmt" "log" "testing" "time" + "github.com/getAlby/lndhub.go/common" "github.com/getAlby/lndhub.go/controllers" "github.com/getAlby/lndhub.go/lib" "github.com/getAlby/lndhub.go/lib/responses" @@ -21,12 +23,13 @@ import ( type PaymentTestSuite struct { TestSuite - fundingClient *lnd.LNDWrapper - service *service.LndhubService - aliceLogin controllers.CreateUserResponseBody - aliceToken string - bobLogin controllers.CreateUserResponseBody - bobToken string + fundingClient *lnd.LNDWrapper + service *service.LndhubService + aliceLogin controllers.CreateUserResponseBody + aliceToken string + bobLogin controllers.CreateUserResponseBody + bobToken string + invoiceUpdateSubCancelFn context.CancelFunc } func (suite *PaymentTestSuite) SetupSuite() { @@ -39,7 +42,7 @@ func (suite *PaymentTestSuite) SetupSuite() { } suite.fundingClient = lndClient - svc, err := LndHubTestServiceInit() + svc, err := LndHubTestServiceInit(nil) if err != nil { log.Fatalf("Error initializing test service: %v", err) } @@ -48,7 +51,10 @@ func (suite *PaymentTestSuite) SetupSuite() { log.Fatalf("Error creating test users: %v", err) } // Subscribe to LND invoice updates in the background - go svc.InvoiceUpdateSubscription(context.Background()) + // store cancel func to be called in tear down suite + ctx, cancel := context.WithCancel(context.Background()) + suite.invoiceUpdateSubCancelFn = cancel + go svc.InvoiceUpdateSubscription(ctx) suite.service = svc e := echo.New() @@ -68,7 +74,12 @@ func (suite *PaymentTestSuite) SetupSuite() { } func (suite *PaymentTestSuite) TearDownSuite() { + suite.invoiceUpdateSubCancelFn() +} +func (suite *PaymentTestSuite) TearDownTest() { + clearTable(suite.service, "transaction_entries") + clearTable(suite.service, "invoices") } func (suite *PaymentTestSuite) TestInternalPayment() { @@ -99,6 +110,59 @@ func (suite *PaymentTestSuite) TestInternalPayment() { assert.Equal(suite.T(), responses.NotEnoughBalanceError.Code, errorResp.Code) } +func (suite *PaymentTestSuite) TestInternalPaymentFail() { + aliceFundingSats := 1000 + bobSatRequested := 500 + //fund alice account + invoiceResponse := suite.createAddInvoiceReq(aliceFundingSats, "integration test internal payment alice", suite.aliceToken) + sendPaymentRequest := lnrpc.SendRequest{ + PaymentRequest: invoiceResponse.PayReq, + FeeLimit: nil, + } + _, err := suite.fundingClient.SendPaymentSync(context.Background(), &sendPaymentRequest) + assert.NoError(suite.T(), err) + + //wait a bit for the callback event to hit + time.Sleep(100 * time.Millisecond) + + //create invoice for bob + bobInvoice := suite.createAddInvoiceReq(bobSatRequested, "integration test internal payment bob", suite.bobToken) + //pay bob from alice + payResponse := suite.createPayInvoiceReq(bobInvoice.PayReq, suite.aliceToken) + assert.NotEmpty(suite.T(), payResponse.PaymentPreimage) + //try to pay same invoice again for make it fail + _ = suite.createPayInvoiceReqError(bobInvoice.PayReq, suite.aliceToken) + + userId := getUserIdFromToken(suite.aliceToken) + invoices, err := suite.service.InvoicesFor(context.Background(), userId, common.InvoiceTypeOutgoing) + if err != nil { + fmt.Printf("Error when getting invoices %v\n", err.Error()) + } + + // check if first one is settled, but second one error (they are ordered desc by id) + assert.Equal(suite.T(), 2, len(invoices)) + assert.Equal(suite.T(), common.InvoiceStateError, invoices[0].State) + assert.Equal(suite.T(), common.InvoiceStateSettled, invoices[1].State) + transactonEntries, err := suite.service.TransactionEntriesFor(context.Background(), userId) + if err != nil { + fmt.Printf("Error when getting transaction entries %v\n", err.Error()) + } + + aliceBalance, err := suite.service.CurrentUserBalance(context.Background(), userId) + if err != nil { + fmt.Printf("Error when getting balance %v\n", err.Error()) + } + + // check if there are 4 transaction entries, with reversed credit and debit account ids for last 2 + assert.Equal(suite.T(), 4, len(transactonEntries)) + assert.Equal(suite.T(), transactonEntries[2].CreditAccountID, transactonEntries[3].DebitAccountID) + assert.Equal(suite.T(), transactonEntries[2].DebitAccountID, transactonEntries[3].CreditAccountID) + assert.Equal(suite.T(), transactonEntries[2].Amount, int64(bobSatRequested)) + assert.Equal(suite.T(), transactonEntries[3].Amount, int64(bobSatRequested)) + // assert that balance was reduced only once + assert.Equal(suite.T(), int64(aliceFundingSats)-int64(bobSatRequested), int64(aliceBalance)) +} + func TestInternalPaymentTestSuite(t *testing.T) { suite.Run(t, new(PaymentTestSuite)) } diff --git a/integration_tests/lnd-mock.go b/integration_tests/lnd-mock.go new file mode 100644 index 0000000..ee2cb24 --- /dev/null +++ b/integration_tests/lnd-mock.go @@ -0,0 +1,59 @@ +package integration_tests + +import ( + "context" + "errors" + + "github.com/getAlby/lndhub.go/lnd" + "github.com/lightningnetwork/lnd/lnrpc" + "google.golang.org/grpc" +) + +const SendPaymentMockError = "mocked send payment error" + +type LNDMockWrapper struct { + *lnd.LNDWrapper +} + +func NewLNDMockWrapper(lndOptions lnd.LNDoptions) (result *LNDMockWrapper, err error) { + lnd, err := lnd.NewLNDclient(lndOptions) + if err != nil { + return nil, err + } + + return &LNDMockWrapper{ + LNDWrapper: lnd, + }, nil +} + +func (wrapper *LNDMockWrapper) SendPaymentSync(ctx context.Context, req *lnrpc.SendRequest, options ...grpc.CallOption) (*lnrpc.SendResponse, error) { + return nil, errors.New(SendPaymentMockError) +} + +// mock where send payment sync failure is controlled by channel +// even though send payment method is still sync, suffix "Async" here is used to show intention of using this mock +var errorMessageChannel = make(chan string, 1) + +type LNDMockWrapperAsync struct { + *lnd.LNDWrapper +} + +func NewLNDMockWrapperAsync(lndOptions lnd.LNDoptions) (result *LNDMockWrapperAsync, err error) { + lnd, err := lnd.NewLNDclient(lndOptions) + if err != nil { + return nil, err + } + + return &LNDMockWrapperAsync{ + LNDWrapper: lnd, + }, nil +} + +func (wrapper *LNDMockWrapperAsync) SendPaymentSync(ctx context.Context, req *lnrpc.SendRequest, options ...grpc.CallOption) (*lnrpc.SendResponse, error) { + errorMessage := <-errorMessageChannel + return nil, errors.New(errorMessage) +} + +func (wrapper *LNDMockWrapperAsync) FailPayment(message string) { + errorMessageChannel <- message +} diff --git a/integration_tests/outgoing_payment_test.go b/integration_tests/outgoing_payment_test.go index 6f24cb6..1a8ec20 100644 --- a/integration_tests/outgoing_payment_test.go +++ b/integration_tests/outgoing_payment_test.go @@ -2,6 +2,7 @@ package integration_tests import ( "context" + "fmt" "time" "github.com/lightningnetwork/lnd/lnrpc" @@ -33,4 +34,19 @@ func (suite *PaymentTestSuite) TestOutGoingPayment() { //pay external from alice payResponse := suite.createPayInvoiceReq(invoice.PaymentRequest, suite.aliceToken) assert.NotEmpty(suite.T(), payResponse.PaymentPreimage) + + // check that balance was reduced + userId := getUserIdFromToken(suite.aliceToken) + aliceBalance, err := suite.service.CurrentUserBalance(context.Background(), userId) + if err != nil { + fmt.Printf("Error when getting balance %v\n", err.Error()) + } + assert.Equal(suite.T(), int64(aliceFundingSats)-int64(externalSatRequested), aliceBalance) + + // check that no additional transaction entry was created + transactonEntries, err := suite.service.TransactionEntriesFor(context.Background(), userId) + if err != nil { + fmt.Printf("Error when getting transaction entries %v\n", err.Error()) + } + assert.Equal(suite.T(), 2, len(transactonEntries)) } diff --git a/integration_tests/payment_failure_async_test.go b/integration_tests/payment_failure_async_test.go new file mode 100644 index 0000000..9a23675 --- /dev/null +++ b/integration_tests/payment_failure_async_test.go @@ -0,0 +1,157 @@ +package integration_tests + +import ( + "context" + "fmt" + "log" + "testing" + "time" + + "github.com/getAlby/lndhub.go/common" + "github.com/getAlby/lndhub.go/controllers" + "github.com/getAlby/lndhub.go/lib" + "github.com/getAlby/lndhub.go/lib/responses" + "github.com/getAlby/lndhub.go/lib/service" + "github.com/getAlby/lndhub.go/lib/tokens" + "github.com/getAlby/lndhub.go/lnd" + "github.com/go-playground/validator/v10" + "github.com/labstack/echo/v4" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type PaymentTestAsyncErrorsSuite struct { + TestSuite + fundingClient *lnd.LNDWrapper + service *service.LndhubService + userLogin controllers.CreateUserResponseBody + userToken string + invoiceUpdateSubCancelFn context.CancelFunc + serviceClient *LNDMockWrapperAsync +} + +func (suite *PaymentTestAsyncErrorsSuite) SetupSuite() { + // use real client for funding only + fundingClient, err := lnd.NewLNDclient(lnd.LNDoptions{ + Address: lnd2RegtestAddress, + MacaroonHex: lnd2RegtestMacaroonHex, + }) + if err != nil { + log.Fatalf("Error setting up funding client: %v", err) + } + + // inject fake lnd client with failing send payment sync into service + lndClient, err := NewLNDMockWrapperAsync(lnd.LNDoptions{ + Address: lnd1RegtestAddress, + MacaroonHex: lnd1RegtestMacaroonHex, + }) + suite.serviceClient = lndClient + if err != nil { + log.Fatalf("Error setting up test client: %v", err) + } + suite.fundingClient = fundingClient + + svc, err := LndHubTestServiceInit(lndClient) + if err != nil { + log.Fatalf("Error initializing test service: %v", err) + } + users, userTokens, err := createUsers(svc, 1) + if err != nil { + log.Fatalf("Error creating test users: %v", err) + } + // Subscribe to LND invoice updates in the background + // store cancel func to be called in tear down suite + ctx, cancel := context.WithCancel(context.Background()) + suite.invoiceUpdateSubCancelFn = cancel + go svc.InvoiceUpdateSubscription(ctx) + suite.service = svc + e := echo.New() + + e.HTTPErrorHandler = responses.HTTPErrorHandler + e.Validator = &lib.CustomValidator{Validator: validator.New()} + suite.echo = e + assert.Equal(suite.T(), 1, len(users)) + assert.Equal(suite.T(), 1, len(userTokens)) + suite.userLogin = users[0] + suite.userToken = userTokens[0] + suite.echo.Use(tokens.Middleware([]byte(suite.service.Config.JWTSecret))) + suite.echo.GET("/balance", controllers.NewBalanceController(suite.service).Balance) + suite.echo.POST("/addinvoice", controllers.NewAddInvoiceController(suite.service).AddInvoice) + suite.echo.POST("/payinvoice", controllers.NewPayInvoiceController(suite.service).PayInvoice) +} + +func (suite *PaymentTestAsyncErrorsSuite) TestExternalAsyncFailingInvoice() { + userFundingSats := 1000 + externalSatRequested := 500 + // fund user account + invoiceResponse := suite.createAddInvoiceReq(userFundingSats, "integration test external payment user", suite.userToken) + sendPaymentRequest := lnrpc.SendRequest{ + PaymentRequest: invoiceResponse.PayReq, + FeeLimit: nil, + } + _, err := suite.fundingClient.SendPaymentSync(context.Background(), &sendPaymentRequest) + assert.NoError(suite.T(), err) + + // wait a bit for the callback event to hit + time.Sleep(100 * time.Millisecond) + + // create external invoice + externalInvoice := lnrpc.Invoice{ + Memo: "integration tests: external pay from user", + Value: int64(externalSatRequested), + } + invoice, err := suite.fundingClient.AddInvoice(context.Background(), &externalInvoice) + assert.NoError(suite.T(), err) + // pay external from user, req will be canceled after 2 sec + go suite.createPayInvoiceReqWithCancel(invoice.PaymentRequest, suite.userToken) + + // wait for request to fail + time.Sleep(5 * time.Second) + + // check to see that balance was reduced + userId := getUserIdFromToken(suite.userToken) + userBalance, err := suite.service.CurrentUserBalance(context.Background(), userId) + if err != nil { + fmt.Printf("Error when getting balance %v\n", err.Error()) + } + assert.Equal(suite.T(), int64(userFundingSats-externalSatRequested), userBalance) + + // fail payment and wait a bit + suite.serviceClient.FailPayment(SendPaymentMockError) + time.Sleep(2 * time.Second) + + // check that balance was reverted and invoice is in error state + userBalance, err = suite.service.CurrentUserBalance(context.Background(), userId) + if err != nil { + fmt.Printf("Error when getting balance %v\n", err.Error()) + } + assert.Equal(suite.T(), int64(userFundingSats), userBalance) + + invoices, err := suite.service.InvoicesFor(context.Background(), userId, common.InvoiceTypeOutgoing) + if err != nil { + fmt.Printf("Error when getting invoices %v\n", err.Error()) + } + assert.Equal(suite.T(), 1, len(invoices)) + assert.Equal(suite.T(), common.InvoiceStateError, invoices[0].State) + assert.Equal(suite.T(), SendPaymentMockError, invoices[0].ErrorMessage) + + transactonEntries, err := suite.service.TransactionEntriesFor(context.Background(), userId) + if err != nil { + fmt.Printf("Error when getting transaction entries %v\n", err.Error()) + } + // check if there are 3 transaction entries, with reversed credit and debit account ids + assert.Equal(suite.T(), 3, len(transactonEntries)) + assert.Equal(suite.T(), transactonEntries[1].CreditAccountID, transactonEntries[2].DebitAccountID) + assert.Equal(suite.T(), transactonEntries[1].DebitAccountID, transactonEntries[2].CreditAccountID) + assert.Equal(suite.T(), transactonEntries[1].Amount, int64(externalSatRequested)) + assert.Equal(suite.T(), transactonEntries[2].Amount, int64(externalSatRequested)) +} + +func (suite *PaymentTestAsyncErrorsSuite) TearDownSuite() { + suite.invoiceUpdateSubCancelFn() +} + +func TestPaymentTestErrorsAsyncSuite(t *testing.T) { + suite.Run(t, new(PaymentTestAsyncErrorsSuite)) +} diff --git a/integration_tests/payment_failure_test.go b/integration_tests/payment_failure_test.go new file mode 100644 index 0000000..23ed8b5 --- /dev/null +++ b/integration_tests/payment_failure_test.go @@ -0,0 +1,143 @@ +package integration_tests + +import ( + "context" + "fmt" + "log" + "testing" + "time" + + "github.com/getAlby/lndhub.go/common" + "github.com/getAlby/lndhub.go/controllers" + "github.com/getAlby/lndhub.go/lib" + "github.com/getAlby/lndhub.go/lib/responses" + "github.com/getAlby/lndhub.go/lib/service" + "github.com/getAlby/lndhub.go/lib/tokens" + "github.com/getAlby/lndhub.go/lnd" + "github.com/go-playground/validator/v10" + "github.com/labstack/echo/v4" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type PaymentTestErrorsSuite struct { + TestSuite + fundingClient *lnd.LNDWrapper + service *service.LndhubService + userLogin controllers.CreateUserResponseBody + userToken string + invoiceUpdateSubCancelFn context.CancelFunc +} + +func (suite *PaymentTestErrorsSuite) SetupSuite() { + // use real client for funding only + fundingClient, err := lnd.NewLNDclient(lnd.LNDoptions{ + Address: lnd2RegtestAddress, + MacaroonHex: lnd2RegtestMacaroonHex, + }) + if err != nil { + log.Fatalf("Error setting up funding client: %v", err) + } + + // inject fake lnd client with failing send payment sync into service + lndClient, err := NewLNDMockWrapper(lnd.LNDoptions{ + Address: lnd1RegtestAddress, + MacaroonHex: lnd1RegtestMacaroonHex, + }) + if err != nil { + log.Fatalf("Error setting up test client: %v", err) + } + suite.fundingClient = fundingClient + + svc, err := LndHubTestServiceInit(lndClient) + if err != nil { + log.Fatalf("Error initializing test service: %v", err) + } + users, userTokens, err := createUsers(svc, 1) + if err != nil { + log.Fatalf("Error creating test users: %v", err) + } + // Subscribe to LND invoice updates in the background + // store cancel func to be called in tear down suite + ctx, cancel := context.WithCancel(context.Background()) + suite.invoiceUpdateSubCancelFn = cancel + go svc.InvoiceUpdateSubscription(ctx) + suite.service = svc + e := echo.New() + + e.HTTPErrorHandler = responses.HTTPErrorHandler + e.Validator = &lib.CustomValidator{Validator: validator.New()} + suite.echo = e + assert.Equal(suite.T(), 1, len(users)) + assert.Equal(suite.T(), 1, len(userTokens)) + suite.userLogin = users[0] + suite.userToken = userTokens[0] + suite.echo.Use(tokens.Middleware([]byte(suite.service.Config.JWTSecret))) + suite.echo.GET("/balance", controllers.NewBalanceController(suite.service).Balance) + suite.echo.POST("/addinvoice", controllers.NewAddInvoiceController(suite.service).AddInvoice) + suite.echo.POST("/payinvoice", controllers.NewPayInvoiceController(suite.service).PayInvoice) +} + +func (suite *PaymentTestErrorsSuite) TestExternalFailingInvoice() { + userFundingSats := 1000 + externalSatRequested := 500 + //fund user account + invoiceResponse := suite.createAddInvoiceReq(userFundingSats, "integration test external payment user", suite.userToken) + sendPaymentRequest := lnrpc.SendRequest{ + PaymentRequest: invoiceResponse.PayReq, + FeeLimit: nil, + } + _, err := suite.fundingClient.SendPaymentSync(context.Background(), &sendPaymentRequest) + assert.NoError(suite.T(), err) + + //wait a bit for the callback event to hit + time.Sleep(100 * time.Millisecond) + + //create external invoice + externalInvoice := lnrpc.Invoice{ + Memo: "integration tests: external pay from user", + Value: int64(externalSatRequested), + } + invoice, err := suite.fundingClient.AddInvoice(context.Background(), &externalInvoice) + assert.NoError(suite.T(), err) + //pay external from user, mock will fail immediately + _ = suite.createPayInvoiceReqError(invoice.PaymentRequest, suite.userToken) + + userId := getUserIdFromToken(suite.userToken) + + invoices, err := suite.service.InvoicesFor(context.Background(), userId, common.InvoiceTypeOutgoing) + if err != nil { + fmt.Printf("Error when getting invoices %v\n", err.Error()) + } + assert.Equal(suite.T(), 1, len(invoices)) + assert.Equal(suite.T(), common.InvoiceStateError, invoices[0].State) + assert.Equal(suite.T(), SendPaymentMockError, invoices[0].ErrorMessage) + + transactonEntries, err := suite.service.TransactionEntriesFor(context.Background(), userId) + if err != nil { + fmt.Printf("Error when getting transaction entries %v\n", err.Error()) + } + + userBalance, err := suite.service.CurrentUserBalance(context.Background(), userId) + if err != nil { + fmt.Printf("Error when getting balance %v\n", err.Error()) + } + + // check if there are 3 transaction entries, with reversed credit and debit account ids + assert.Equal(suite.T(), 3, len(transactonEntries)) + assert.Equal(suite.T(), transactonEntries[1].CreditAccountID, transactonEntries[2].DebitAccountID) + assert.Equal(suite.T(), transactonEntries[1].DebitAccountID, transactonEntries[2].CreditAccountID) + assert.Equal(suite.T(), transactonEntries[1].Amount, int64(externalSatRequested)) + assert.Equal(suite.T(), transactonEntries[2].Amount, int64(externalSatRequested)) + // assert that balance is the same + assert.Equal(suite.T(), int64(userFundingSats), userBalance) +} + +func (suite *PaymentTestErrorsSuite) TearDownSuite() { + suite.invoiceUpdateSubCancelFn() +} + +func TestPaymentTestErrorsSuite(t *testing.T) { + suite.Run(t, new(PaymentTestErrorsSuite)) +} diff --git a/integration_tests/util.go b/integration_tests/util.go index 38f5cd8..5f0a443 100644 --- a/integration_tests/util.go +++ b/integration_tests/util.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "time" "github.com/getAlby/lndhub.go/controllers" "github.com/getAlby/lndhub.go/db" @@ -15,6 +16,7 @@ import ( "github.com/getAlby/lndhub.go/lib/responses" "github.com/getAlby/lndhub.go/lib/service" "github.com/getAlby/lndhub.go/lnd" + "github.com/golang-jwt/jwt" "github.com/labstack/echo/v4" "github.com/lightningnetwork/lnd/lnrpc" "github.com/stretchr/testify/assert" @@ -22,7 +24,14 @@ import ( "github.com/uptrace/bun/migrate" ) -func LndHubTestServiceInit() (svc *service.LndhubService, err error) { +const ( + lnd1RegtestAddress = "rpc.lnd1.regtest.getalby.com:443" + lnd1RegtestMacaroonHex = "0201036c6e6402f801030a10e2133a1cac2c5b4d56e44e32dc64c8551201301a160a0761646472657373120472656164120577726974651a130a04696e666f120472656164120577726974651a170a08696e766f69636573120472656164120577726974651a210a086d616361726f6f6e120867656e6572617465120472656164120577726974651a160a076d657373616765120472656164120577726974651a170a086f6666636861696e120472656164120577726974651a160a076f6e636861696e120472656164120577726974651a140a057065657273120472656164120577726974651a180a067369676e6572120867656e657261746512047265616400000620c4f9783e0873fa50a2091806f5ebb919c5dc432e33800b401463ada6485df0ed" + lnd2RegtestAddress = "rpc.lnd2.regtest.getalby.com:443" + lnd2RegtestMacaroonHex = "0201036C6E6402F801030A101782922F4358E80655920FC7A7C3E9291201301A160A0761646472657373120472656164120577726974651A130A04696E666F120472656164120577726974651A170A08696E766F69636573120472656164120577726974651A210A086D616361726F6F6E120867656E6572617465120472656164120577726974651A160A076D657373616765120472656164120577726974651A170A086F6666636861696E120472656164120577726974651A160A076F6E636861696E120472656164120577726974651A140A057065657273120472656164120577726974651A180A067369676E6572120867656E657261746512047265616400000620628FFB2938C8540DD3AA5E578D9B43456835FAA176E175FFD4F9FBAE540E3BE9" +) + +func LndHubTestServiceInit(lndClientMock lnd.LightningClientWrapper) (svc *service.LndhubService, err error) { // change this if you want to run tests using sqlite // dbUri := "file:data_test.db" //make sure the datbase is empty every time you run the test suite @@ -31,8 +40,8 @@ func LndHubTestServiceInit() (svc *service.LndhubService, err error) { DatabaseUri: dbUri, JWTSecret: []byte("SECRET"), JWTExpiry: 3600, - LNDAddress: "rpc.lnd1.regtest.getalby.com:443", - LNDMacaroonHex: "0201036c6e6402f801030a10e2133a1cac2c5b4d56e44e32dc64c8551201301a160a0761646472657373120472656164120577726974651a130a04696e666f120472656164120577726974651a170a08696e766f69636573120472656164120577726974651a210a086d616361726f6f6e120867656e6572617465120472656164120577726974651a160a076d657373616765120472656164120577726974651a170a086f6666636861696e120472656164120577726974651a160a076f6e636861696e120472656164120577726974651a140a057065657273120472656164120577726974651a180a067369676e6572120867656e657261746512047265616400000620c4f9783e0873fa50a2091806f5ebb919c5dc432e33800b401463ada6485df0ed", + LNDAddress: lnd1RegtestAddress, + LNDMacaroonHex: lnd1RegtestMacaroonHex, } dbConn, err := db.Open(c.DatabaseUri) if err != nil { @@ -50,12 +59,17 @@ func LndHubTestServiceInit() (svc *service.LndhubService, err error) { return nil, fmt.Errorf("failed to migrate: %w", err) } - lndClient, err := lnd.NewLNDclient(lnd.LNDoptions{ - Address: c.LNDAddress, - MacaroonHex: c.LNDMacaroonHex, - }) - if err != nil { - return nil, fmt.Errorf("failed to initialize lnd service client: %w", err) + var lndClient lnd.LightningClientWrapper + if lndClientMock == nil { + lndClient, err = lnd.NewLNDclient(lnd.LNDoptions{ + Address: c.LNDAddress, + MacaroonHex: c.LNDMacaroonHex, + }) + if err != nil { + return nil, fmt.Errorf("failed to initialize lnd service client: %w", err) + } + } else { + lndClient = lndClientMock } logger := lib.Logger(c.LogFilePath) @@ -74,6 +88,24 @@ func LndHubTestServiceInit() (svc *service.LndhubService, err error) { return svc, nil } +func clearTable(svc *service.LndhubService, tableName string) error { + dbConn, err := db.Open(svc.Config.DatabaseUri) + if err != nil { + return fmt.Errorf("failed to connect to database: %w", err) + } + + _, err = dbConn.Exec(fmt.Sprintf("DELETE FROM %s", tableName)) + return err +} + +// unsafe parse jwt method to pull out userId claim +// should be used only in integration_tests package +func getUserIdFromToken(token string) int64 { + parsedToken, _, _ := new(jwt.Parser).ParseUnverified(token, jwt.MapClaims{}) + claims, _ := parsedToken.Claims.(jwt.MapClaims) + return int64(claims["id"].(float64)) +} + func createUsers(svc *service.LndhubService, usersToCreate int) (logins []controllers.CreateUserResponseBody, tokens []string, err error) { logins = []controllers.CreateUserResponseBody{} tokens = []string{} @@ -150,3 +182,17 @@ func (suite *TestSuite) createPayInvoiceReqError(payReq string, token string) *r assert.NoError(suite.T(), json.NewDecoder(rec.Body).Decode(errorResponse)) return errorResponse } + +func (suite *TestSuite) createPayInvoiceReqWithCancel(payReq string, token string) { + rec := httptest.NewRecorder() + var buf bytes.Buffer + assert.NoError(suite.T(), json.NewEncoder(&buf).Encode(&controllers.PayInvoiceRequestBody{ + Invoice: payReq, + })) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + req := httptest.NewRequest(http.MethodPost, "/payinvoice", &buf).WithContext(ctx) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) + suite.echo.ServeHTTP(rec, req) +} diff --git a/lib/service/invoices.go b/lib/service/invoices.go index c69a55d..9b43d93 100644 --- a/lib/service/invoices.go +++ b/lib/service/invoices.go @@ -2,7 +2,6 @@ package service import ( "context" - "database/sql" "encoding/hex" "errors" "math/rand" @@ -10,6 +9,7 @@ import ( "github.com/getAlby/lndhub.go/common" "github.com/getAlby/lndhub.go/db/models" + "github.com/getsentry/sentry-go" "github.com/labstack/gommon/random" "github.com/lightningnetwork/lnd/lnrpc" "github.com/uptrace/bun" @@ -42,9 +42,8 @@ func (svc *LndhubService) FindInvoiceByPaymentHash(ctx context.Context, userId i return &invoice, nil } -func (svc *LndhubService) SendInternalPayment(ctx context.Context, tx *bun.Tx, invoice *models.Invoice) (SendPaymentResponse, error) { +func (svc *LndhubService) SendInternalPayment(ctx context.Context, invoice *models.Invoice) (SendPaymentResponse, error) { sendPaymentResponse := SendPaymentResponse{} - //SendInternalPayment() // find invoice var incomingInvoice models.Invoice err := svc.DB.NewSelect().Model(&incomingInvoice).Where("type = ? AND payment_request = ? AND state = ? ", common.InvoiceTypeIncoming, invoice.PaymentRequest, common.InvoiceStateOpen).Limit(1).Scan(ctx) @@ -70,7 +69,7 @@ func (svc *LndhubService) SendInternalPayment(ctx context.Context, tx *bun.Tx, i DebitAccountID: recipientDebitAccount.ID, Amount: invoice.Amount, } - _, err = tx.NewInsert().Model(&recipientEntry).Exec(ctx) + _, err = svc.DB.NewInsert().Model(&recipientEntry).Exec(ctx) if err != nil { return sendPaymentResponse, err } @@ -89,7 +88,7 @@ func (svc *LndhubService) SendInternalPayment(ctx context.Context, tx *bun.Tx, i incomingInvoice.Internal = true // mark incoming invoice as internal, just for documentation/debugging incomingInvoice.State = common.InvoiceStateSettled incomingInvoice.SettledAt = schema.NullTime{Time: time.Now()} - _, err = tx.NewUpdate().Model(&incomingInvoice).WherePK().Exec(ctx) + _, err = svc.DB.NewUpdate().Model(&incomingInvoice).WherePK().Exec(ctx) if err != nil { // could not save the invoice of the recipient return sendPaymentResponse, err @@ -98,7 +97,7 @@ func (svc *LndhubService) SendInternalPayment(ctx context.Context, tx *bun.Tx, i return sendPaymentResponse, nil } -func (svc *LndhubService) SendPaymentSync(ctx context.Context, tx *bun.Tx, invoice *models.Invoice) (SendPaymentResponse, error) { +func (svc *LndhubService) SendPaymentSync(ctx context.Context, invoice *models.Invoice) (SendPaymentResponse, error) { sendPaymentResponse := SendPaymentResponse{} // TODO: set dynamic fee limit feeLimit := lnrpc.FeeLimit{ @@ -123,7 +122,7 @@ func (svc *LndhubService) SendPaymentSync(ctx context.Context, tx *bun.Tx, invoi return sendPaymentResponse, err } - // If there was a payment error we rollback and return an error + // If there was a payment error we return an error if sendPaymentResult.GetPaymentError() != "" || sendPaymentResult.GetPaymentPreimage() == nil { return sendPaymentResponse, errors.New(sendPaymentResult.GetPaymentError()) } @@ -144,10 +143,12 @@ func (svc *LndhubService) PayInvoice(ctx context.Context, invoice *models.Invoic // Get the user's current and outgoing account for the transaction entry debitAccount, err := svc.AccountFor(ctx, common.AccountTypeCurrent, userId) if err != nil { + svc.Logger.Errorf("Could not find current account user_id:%v", invoice.UserID) return nil, err } creditAccount, err := svc.AccountFor(ctx, common.AccountTypeOutgoing, userId) if err != nil { + svc.Logger.Errorf("Could not find outgoing account user_id:%v", invoice.UserID) return nil, err } @@ -159,35 +160,28 @@ func (svc *LndhubService) PayInvoice(ctx context.Context, invoice *models.Invoic Amount: invoice.Amount, } - // Start a DB transaction - // We rollback anything on error (only the invoice that was passed in to the PayInvoice calls stays in the DB) - tx, err := svc.DB.BeginTx(ctx, &sql.TxOptions{}) - if err != nil { - return nil, err - } - // The DB constraints make sure the user actually has enough balance for the transaction // If the user does not have enough balance this call fails - _, err = tx.NewInsert().Model(&entry).Exec(ctx) + _, err = svc.DB.NewInsert().Model(&entry).Exec(ctx) if err != nil { - tx.Rollback() + svc.Logger.Errorf("Could not insert transaction entry user_id:%v invoice_id:%v", invoice.UserID, invoice.ID) return nil, err } - // TODO: maybe save errors on the invoice? - var paymentResponse SendPaymentResponse // Check the destination pubkey if it is an internal invoice and going to our node + // Here we start using context.Background because we want to complete these calls + // regardless of if the request's context is canceled or not. if svc.IdentityPubkey == invoice.DestinationPubkeyHex { - paymentResponse, err = svc.SendInternalPayment(ctx, &tx, invoice) + paymentResponse, err = svc.SendInternalPayment(context.Background(), invoice) if err != nil { - tx.Rollback() + svc.HandleFailedPayment(context.Background(), invoice, entry, err) return nil, err } } else { - paymentResponse, err = svc.SendPaymentSync(ctx, &tx, invoice) + paymentResponse, err = svc.SendPaymentSync(context.Background(), invoice) if err != nil { - tx.Rollback() + svc.HandleFailedPayment(context.Background(), invoice, entry, err) return nil, err } } @@ -196,23 +190,49 @@ func (svc *LndhubService) PayInvoice(ctx context.Context, invoice *models.Invoic // The payment was successful. invoice.Preimage = paymentResponse.PaymentPreimageStr + err = svc.HandleSuccessfulPayment(context.Background(), invoice) + return &paymentResponse, err +} + +func (svc *LndhubService) HandleFailedPayment(ctx context.Context, invoice *models.Invoice, entryToRevert models.TransactionEntry, failedPaymentError error) error { + // add transaction entry with reverted credit/debit account id + entry := models.TransactionEntry{ + UserID: invoice.UserID, + InvoiceID: invoice.ID, + CreditAccountID: entryToRevert.DebitAccountID, + DebitAccountID: entryToRevert.CreditAccountID, + Amount: invoice.Amount, + } + _, err := svc.DB.NewInsert().Model(&entry).Exec(ctx) + if err != nil { + sentry.CaptureException(err) + svc.Logger.Errorf("Could not insert transaction entry user_id:%v invoice_id:%v", invoice.UserID, invoice.ID) + return err + } + + invoice.State = common.InvoiceStateError + if failedPaymentError != nil { + invoice.ErrorMessage = failedPaymentError.Error() + } + + _, err = svc.DB.NewUpdate().Model(invoice).WherePK().Exec(ctx) + if err != nil { + sentry.CaptureException(err) + svc.Logger.Errorf("Could not update failed payment invoice user_id:%v invoice_id:%v", invoice.UserID, invoice.ID) + } + return err +} + +func (svc *LndhubService) HandleSuccessfulPayment(ctx context.Context, invoice *models.Invoice) error { invoice.State = common.InvoiceStateSettled invoice.SettledAt = schema.NullTime{Time: time.Now()} - _, err = tx.NewUpdate().Model(invoice).WherePK().Exec(ctx) + _, err := svc.DB.NewUpdate().Model(invoice).WherePK().Exec(ctx) if err != nil { - tx.Rollback() - return nil, err + sentry.CaptureException(err) + svc.Logger.Errorf("Could not update sucessful payment invoice user_id:%v invoice_id:%v", invoice.UserID, invoice.ID) } - - // Commit the DB transaction. Done, everything worked - err = tx.Commit() - - if err != nil { - return nil, err - } - - return &paymentResponse, err + return err } func (svc *LndhubService) AddOutgoingInvoice(ctx context.Context, userID int64, paymentRequest string, decodedInvoice *lnrpc.PayReq) (*models.Invoice, error) { @@ -227,7 +247,7 @@ func (svc *LndhubService) AddOutgoingInvoice(ctx context.Context, userID int64, DestinationPubkeyHex: decodedInvoice.Destination, DescriptionHash: decodedInvoice.DescriptionHash, Memo: decodedInvoice.Description, - ExpiresAt: bun.NullTime{Time: time.Unix(decodedInvoice.Timestamp, 0).Add(time.Duration(decodedInvoice.Expiry))}, + ExpiresAt: bun.NullTime{Time: time.Unix(decodedInvoice.Timestamp, 0).Add(time.Duration(decodedInvoice.Expiry) * time.Second)}, } // Save invoice diff --git a/lib/service/user.go b/lib/service/user.go index ad9c471..766a3bb 100644 --- a/lib/service/user.go +++ b/lib/service/user.go @@ -82,6 +82,12 @@ func (svc *LndhubService) AccountFor(ctx context.Context, accountType string, us return account, err } +func (svc *LndhubService) TransactionEntriesFor(ctx context.Context, userId int64) ([]models.TransactionEntry, error) { + transactionEntries := []models.TransactionEntry{} + err := svc.DB.NewSelect().Model(&transactionEntries).Where("user_id = ?", userId).Scan(ctx) + return transactionEntries, err +} + func (svc *LndhubService) InvoicesFor(ctx context.Context, userId int64, invoiceType string) ([]models.Invoice, error) { var invoices []models.Invoice diff --git a/lnd/lnd.go b/lnd/lnd.go index 30b03e0..995ee59 100644 --- a/lnd/lnd.go +++ b/lnd/lnd.go @@ -16,10 +16,6 @@ import ( "gopkg.in/macaroon.v2" ) -const ( - MSAT_PER_SAT = 1000 -) - // LNDoptions are the options for the connection to the lnd node. type LNDoptions struct { Address string