diff --git a/.env_example b/.env_example index f0bd7dd8..a7ede046 100644 --- a/.env_example +++ b/.env_example @@ -7,3 +7,4 @@ JWT_REFRESH_EXPIRY=604800 LND_ADDRESS= LND_MACAROON_HEX= LND_CERT_HEX= +FIXED_FEE=10 \ No newline at end of file diff --git a/db/migrations/20220120000700_add_constraints.up.go b/db/migrations/20220120000700_add_constraints.up.go index cdbd4385..6d09e1fc 100644 --- a/db/migrations/20220120000700_add_constraints.up.go +++ b/db/migrations/20220120000700_add_constraints.up.go @@ -34,6 +34,7 @@ func init() { DECLARE sum BIGINT; debit_account_type VARCHAR; + credit_account_type VARCHAR; BEGIN -- LOCK the account if the transaction is not from an incoming account @@ -48,8 +49,18 @@ func init() { -- This can happen when two transactions try to access the same account FOR UPDATE NOWAIT; - -- If it is an incoming account return; otherwise check the balance - IF debit_account_type IS NULL + -- check if credit_account type is fees, if it's fees we don't check for negative balance constraint + SELECT INTO credit_account_type type + FROM accounts + WHERE id = NEW.credit_account_id AND type <> 'fees' + -- IMPORTANT: lock rows but do not wait for another lock to be released. + -- Waiting would result in a deadlock because two parallel transactions could try to lock the same rows + -- NOWAIT reports an error rather than waiting for the lock to be released + -- This can happen when two transactions try to access the same account + FOR UPDATE NOWAIT; + + -- If it is an debit incoming account or fees credit account return; otherwise check the balance + IF debit_account_type IS NULL OR credit_account_type IS NULL THEN RETURN NEW; END IF; @@ -60,7 +71,7 @@ func init() { WHERE account_ledgers.account_id = NEW.debit_account_id; -- IF the account would go negative raise an exception - IF sum < 0 AND debit_account_type != 'incoming' + IF sum < 0 THEN RAISE EXCEPTION 'invalid balance [user_id:%] [debit_account_id:%] balance [%]', NEW.user_id, diff --git a/db/migrations/20220301100000_invoice_add_fee.up.sql b/db/migrations/20220301100000_invoice_add_fee.up.sql new file mode 100644 index 00000000..375ca3bb --- /dev/null +++ b/db/migrations/20220301100000_invoice_add_fee.up.sql @@ -0,0 +1 @@ +alter table invoices ADD COLUMN fee bigint; \ No newline at end of file diff --git a/db/models/invoice.go b/db/models/invoice.go index 06792405..17d6ae5e 100644 --- a/db/models/invoice.go +++ b/db/models/invoice.go @@ -14,6 +14,7 @@ type Invoice struct { UserID int64 `json:"user_id" validate:"required"` User *User `bun:"rel:belongs-to,join:user_id=id"` Amount int64 `json:"amount" validate:"gte=0"` + Fee int64 `json:"fee" bun:",nullzero"` Memo string `json:"memo" bun:",nullzero"` DescriptionHash string `json:"description_hash" bun:",nullzero"` PaymentRequest string `json:"payment_request" bun:",nullzero"` diff --git a/integration_tests/internal_payment_test.go b/integration_tests/internal_payment_test.go index fc3ff083..30c4543d 100644 --- a/integration_tests/internal_payment_test.go +++ b/integration_tests/internal_payment_test.go @@ -34,8 +34,8 @@ type PaymentTestSuite struct { func (suite *PaymentTestSuite) SetupSuite() { lndClient, err := lnd.NewLNDclient(lnd.LNDoptions{ - Address: lnd2RegtestAddress, - MacaroonHex: lnd2RegtestMacaroonHex, + Address: lnd3RegtestAddress, + MacaroonHex: lnd3RegtestMacaroonHex, }) if err != nil { log.Fatalf("Error setting up funding client: %v", err) @@ -85,6 +85,8 @@ func (suite *PaymentTestSuite) TearDownTest() { func (suite *PaymentTestSuite) TestInternalPayment() { aliceFundingSats := 1000 bobSatRequested := 500 + // currently fee is 0 for internal payments + fee := 0 //fund alice account invoiceResponse := suite.createAddInvoiceReq(aliceFundingSats, "integration test internal payment alice", suite.aliceToken) sendPaymentRequest := lnrpc.SendRequest{ @@ -102,17 +104,38 @@ func (suite *PaymentTestSuite) TestInternalPayment() { //pay bob from alice payResponse := suite.createPayInvoiceReq(bobInvoice.PayReq, suite.aliceToken) assert.NotEmpty(suite.T(), payResponse.PaymentPreimage) + + aliceId := getUserIdFromToken(suite.aliceToken) + bobId := getUserIdFromToken(suite.bobToken) + //try to pay Bob more than we currently have //create invoice for bob tooMuch := suite.createAddInvoiceReq(10000, "integration test internal payment bob", suite.bobToken) //pay bob from alice errorResp := suite.createPayInvoiceReqError(tooMuch.PayReq, suite.aliceToken) assert.Equal(suite.T(), responses.NotEnoughBalanceError.Code, errorResp.Code) + + transactonEntriesAlice, _ := suite.service.TransactionEntriesFor(context.Background(), aliceId) + aliceBalance, _ := suite.service.CurrentUserBalance(context.Background(), aliceId) + assert.Equal(suite.T(), 3, len(transactonEntriesAlice)) + assert.Equal(suite.T(), int64(aliceFundingSats), transactonEntriesAlice[0].Amount) + assert.Equal(suite.T(), int64(bobSatRequested), transactonEntriesAlice[1].Amount) + assert.Equal(suite.T(), int64(fee), transactonEntriesAlice[2].Amount) + assert.Equal(suite.T(), transactonEntriesAlice[1].ID, transactonEntriesAlice[2].ParentID) + assert.Equal(suite.T(), int64(aliceFundingSats-bobSatRequested-fee), aliceBalance) + + bobBalance, _ := suite.service.CurrentUserBalance(context.Background(), bobId) + transactionEntriesBob, _ := suite.service.TransactionEntriesFor(context.Background(), bobId) + assert.Equal(suite.T(), 1, len(transactionEntriesBob)) + assert.Equal(suite.T(), int64(bobSatRequested), transactionEntriesBob[0].Amount) + assert.Equal(suite.T(), int64(bobSatRequested), bobBalance) } func (suite *PaymentTestSuite) TestInternalPaymentFail() { aliceFundingSats := 1000 bobSatRequested := 500 + // currently fee is 0 for internal payments + fee := 0 //fund alice account invoiceResponse := suite.createAddInvoiceReq(aliceFundingSats, "integration test internal payment alice", suite.aliceToken) sendPaymentRequest := lnrpc.SendRequest{ @@ -153,14 +176,17 @@ func (suite *PaymentTestSuite) TestInternalPaymentFail() { 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)) + // check if there are 5 transaction entries, with reversed credit and debit account ids for last 2 + assert.Equal(suite.T(), 5, len(transactonEntries)) + assert.Equal(suite.T(), int64(aliceFundingSats), transactonEntries[0].Amount) + assert.Equal(suite.T(), int64(bobSatRequested), transactonEntries[1].Amount) + assert.Equal(suite.T(), int64(fee), transactonEntries[2].Amount) + assert.Equal(suite.T(), transactonEntries[3].CreditAccountID, transactonEntries[4].DebitAccountID) + assert.Equal(suite.T(), transactonEntries[3].DebitAccountID, transactonEntries[4].CreditAccountID) assert.Equal(suite.T(), transactonEntries[3].Amount, int64(bobSatRequested)) + assert.Equal(suite.T(), transactonEntries[4].Amount, int64(bobSatRequested)) // assert that balance was reduced only once - assert.Equal(suite.T(), int64(aliceFundingSats)-int64(bobSatRequested), int64(aliceBalance)) + assert.Equal(suite.T(), int64(aliceFundingSats)-int64(bobSatRequested+fee), int64(aliceBalance)) } func TestInternalPaymentTestSuite(t *testing.T) { diff --git a/integration_tests/outgoing_payment_test.go b/integration_tests/outgoing_payment_test.go index 1a8ec202..a09be125 100644 --- a/integration_tests/outgoing_payment_test.go +++ b/integration_tests/outgoing_payment_test.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + "github.com/getAlby/lndhub.go/common" "github.com/lightningnetwork/lnd/lnrpc" "github.com/stretchr/testify/assert" ) @@ -12,6 +13,8 @@ import ( func (suite *PaymentTestSuite) TestOutGoingPayment() { aliceFundingSats := 1000 externalSatRequested := 500 + // 1 sat + 1 ppm + fee := 1 //fund alice account invoiceResponse := suite.createAddInvoiceReq(aliceFundingSats, "integration test external payment alice", suite.aliceToken) sendPaymentRequest := lnrpc.SendRequest{ @@ -41,12 +44,121 @@ func (suite *PaymentTestSuite) TestOutGoingPayment() { if err != nil { fmt.Printf("Error when getting balance %v\n", err.Error()) } - assert.Equal(suite.T(), int64(aliceFundingSats)-int64(externalSatRequested), aliceBalance) + assert.Equal(suite.T(), int64(aliceFundingSats)-int64(externalSatRequested+fee), 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)) + // verify transaction entries data + feeAccount, _ := suite.service.AccountFor(context.Background(), common.AccountTypeFees, userId) + incomingAccount, _ := suite.service.AccountFor(context.Background(), common.AccountTypeIncoming, userId) + outgoingAccount, _ := suite.service.AccountFor(context.Background(), common.AccountTypeOutgoing, userId) + currentAccount, _ := suite.service.AccountFor(context.Background(), common.AccountTypeCurrent, userId) + + outgoingInvoices, _ := suite.service.InvoicesFor(context.Background(), userId, common.InvoiceTypeOutgoing) + incomingInvoices, _ := suite.service.InvoicesFor(context.Background(), userId, common.InvoiceTypeIncoming) + assert.Equal(suite.T(), 1, len(outgoingInvoices)) + assert.Equal(suite.T(), 1, len(incomingInvoices)) + + assert.Equal(suite.T(), 3, len(transactonEntries)) + + assert.Equal(suite.T(), int64(aliceFundingSats), transactonEntries[0].Amount) + assert.Equal(suite.T(), currentAccount.ID, transactonEntries[0].CreditAccountID) + assert.Equal(suite.T(), incomingAccount.ID, transactonEntries[0].DebitAccountID) + assert.Equal(suite.T(), int64(0), transactonEntries[0].ParentID) + assert.Equal(suite.T(), incomingInvoices[0].ID, transactonEntries[0].InvoiceID) + + assert.Equal(suite.T(), int64(externalSatRequested), transactonEntries[1].Amount) + assert.Equal(suite.T(), outgoingAccount.ID, transactonEntries[1].CreditAccountID) + assert.Equal(suite.T(), currentAccount.ID, transactonEntries[1].DebitAccountID) + assert.Equal(suite.T(), int64(0), transactonEntries[1].ParentID) + assert.Equal(suite.T(), outgoingInvoices[0].ID, transactonEntries[1].InvoiceID) + + assert.Equal(suite.T(), int64(fee), transactonEntries[2].Amount) + assert.Equal(suite.T(), feeAccount.ID, transactonEntries[2].CreditAccountID) + assert.Equal(suite.T(), currentAccount.ID, transactonEntries[2].DebitAccountID) + assert.Equal(suite.T(), outgoingInvoices[0].ID, transactonEntries[2].InvoiceID) + + // make sure fee entry parent id is previous entry + assert.Equal(suite.T(), transactonEntries[1].ID, transactonEntries[2].ParentID) +} + +func (suite *PaymentTestSuite) TestOutGoingPaymentWithNegativeBalance() { + // this will cause balance to go to -1 + aliceFundingSats := 1000 + externalSatRequested := 1000 + // 1 sat + 1 ppm + fee := 1 + //fund alice account + invoiceResponse := suite.createAddInvoiceReq(aliceFundingSats, "integration test external 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 external invoice + externalInvoice := lnrpc.Invoice{ + Memo: "integration tests: external pay from alice", + Value: int64(externalSatRequested), + } + invoice, err := suite.fundingClient.AddInvoice(context.Background(), &externalInvoice) + assert.NoError(suite.T(), err) + //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+fee), aliceBalance) + assert.Equal(suite.T(), int64(-1), 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()) + } + // verify transaction entries data + feeAccount, _ := suite.service.AccountFor(context.Background(), common.AccountTypeFees, userId) + incomingAccount, _ := suite.service.AccountFor(context.Background(), common.AccountTypeIncoming, userId) + outgoingAccount, _ := suite.service.AccountFor(context.Background(), common.AccountTypeOutgoing, userId) + currentAccount, _ := suite.service.AccountFor(context.Background(), common.AccountTypeCurrent, userId) + + outgoingInvoices, _ := suite.service.InvoicesFor(context.Background(), userId, common.InvoiceTypeOutgoing) + incomingInvoices, _ := suite.service.InvoicesFor(context.Background(), userId, common.InvoiceTypeIncoming) + assert.Equal(suite.T(), 1, len(outgoingInvoices)) + assert.Equal(suite.T(), 1, len(incomingInvoices)) + + assert.Equal(suite.T(), 3, len(transactonEntries)) + + assert.Equal(suite.T(), int64(aliceFundingSats), transactonEntries[0].Amount) + assert.Equal(suite.T(), currentAccount.ID, transactonEntries[0].CreditAccountID) + assert.Equal(suite.T(), incomingAccount.ID, transactonEntries[0].DebitAccountID) + assert.Equal(suite.T(), int64(0), transactonEntries[0].ParentID) + assert.Equal(suite.T(), incomingInvoices[0].ID, transactonEntries[0].InvoiceID) + + assert.Equal(suite.T(), int64(externalSatRequested), transactonEntries[1].Amount) + assert.Equal(suite.T(), outgoingAccount.ID, transactonEntries[1].CreditAccountID) + assert.Equal(suite.T(), currentAccount.ID, transactonEntries[1].DebitAccountID) + assert.Equal(suite.T(), int64(0), transactonEntries[1].ParentID) + assert.Equal(suite.T(), outgoingInvoices[0].ID, transactonEntries[1].InvoiceID) + + assert.Equal(suite.T(), int64(fee), transactonEntries[2].Amount) + assert.Equal(suite.T(), feeAccount.ID, transactonEntries[2].CreditAccountID) + assert.Equal(suite.T(), currentAccount.ID, transactonEntries[2].DebitAccountID) + assert.Equal(suite.T(), outgoingInvoices[0].ID, transactonEntries[2].InvoiceID) + + // make sure fee entry parent id is previous entry + assert.Equal(suite.T(), transactonEntries[1].ID, transactonEntries[2].ParentID) } diff --git a/integration_tests/util.go b/integration_tests/util.go index 6361391f..90306940 100644 --- a/integration_tests/util.go +++ b/integration_tests/util.go @@ -29,6 +29,10 @@ const ( lnd1RegtestMacaroonHex = "0201036c6e6402f801030a10e2133a1cac2c5b4d56e44e32dc64c8551201301a160a0761646472657373120472656164120577726974651a130a04696e666f120472656164120577726974651a170a08696e766f69636573120472656164120577726974651a210a086d616361726f6f6e120867656e6572617465120472656164120577726974651a160a076d657373616765120472656164120577726974651a170a086f6666636861696e120472656164120577726974651a160a076f6e636861696e120472656164120577726974651a140a057065657273120472656164120577726974651a180a067369676e6572120867656e657261746512047265616400000620c4f9783e0873fa50a2091806f5ebb919c5dc432e33800b401463ada6485df0ed" lnd2RegtestAddress = "rpc.lnd2.regtest.getalby.com:443" lnd2RegtestMacaroonHex = "0201036C6E6402F801030A101782922F4358E80655920FC7A7C3E9291201301A160A0761646472657373120472656164120577726974651A130A04696E666F120472656164120577726974651A170A08696E766F69636573120472656164120577726974651A210A086D616361726F6F6E120867656E6572617465120472656164120577726974651A160A076D657373616765120472656164120577726974651A170A086F6666636861696E120472656164120577726974651A160A076F6E636861696E120472656164120577726974651A140A057065657273120472656164120577726974651A180A067369676E6572120867656E657261746512047265616400000620628FFB2938C8540DD3AA5E578D9B43456835FAA176E175FFD4F9FBAE540E3BE9" + // Use lnd3 for a funding client when testing out fee handling for payments done by lnd-1, since lnd3 doesn't have a direct channel to lnd1. + // This will cause payment to be routed through lnd2, which will charge a fee (lnd default fee 1 sat base + 1 ppm). + lnd3RegtestAddress = "rpc.lnd3.regtest.getalby.com:443" + lnd3RegtestMacaroonHex = "0201036c6e6402f801030a102a5aa69a5efdf4b4a55a5304b164641f1201301a160a0761646472657373120472656164120577726974651a130a04696e666f120472656164120577726974651a170a08696e766f69636573120472656164120577726974651a210a086d616361726f6f6e120867656e6572617465120472656164120577726974651a160a076d657373616765120472656164120577726974651a170a086f6666636861696e120472656164120577726974651a160a076f6e636861696e120472656164120577726974651a140a057065657273120472656164120577726974651a180a067369676e6572120867656e657261746512047265616400000620defbb5a809262297fd661a9ab6d3deb4b7acca4f1309c79addb952f0dc2d8c82" simnetLnd1PubKey = "0242898f86064c2fd72de22059c947a83ba23e9d97aedeae7b6dba647123f1d71b" simnetLnd2PubKey = "025c1d5d1b4c983cc6350fc2d756fbb59b4dc365e45e87f8e3afe07e24013e8220" simnetLnd3PubKey = "03c7092d076f799ab18806743634b4c9bb34e351bdebc91d5b35963f3dc63ec5aa" @@ -37,7 +41,7 @@ const ( 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 + // make sure the datbase is empty every time you run the test suite dbUri := "postgresql://user:password@localhost/lndhub?sslmode=disable" c := &service.Config{ DatabaseUri: dbUri, diff --git a/lib/service/invoices.go b/lib/service/invoices.go index 087d3874..90702a8f 100644 --- a/lib/service/invoices.go +++ b/lib/service/invoices.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/hex" "errors" + "fmt" "math/rand" "time" @@ -218,8 +219,10 @@ func (svc *LndhubService) PayInvoice(ctx context.Context, invoice *models.Invoic paymentResponse.TransactionEntry = &entry // The payment was successful. + // These changes to the invoice are persisted in the `HandleSuccessfulPayment` function invoice.Preimage = paymentResponse.PaymentPreimageStr - err = svc.HandleSuccessfulPayment(context.Background(), invoice) + invoice.Fee = paymentResponse.PaymentRoute.TotalFees + err = svc.HandleSuccessfulPayment(context.Background(), invoice, entry) return &paymentResponse, err } @@ -252,7 +255,7 @@ func (svc *LndhubService) HandleFailedPayment(ctx context.Context, invoice *mode return err } -func (svc *LndhubService) HandleSuccessfulPayment(ctx context.Context, invoice *models.Invoice) error { +func (svc *LndhubService) HandleSuccessfulPayment(ctx context.Context, invoice *models.Invoice, parentEntry models.TransactionEntry) error { invoice.State = common.InvoiceStateSettled invoice.SettledAt = schema.NullTime{Time: time.Now()} @@ -261,7 +264,44 @@ func (svc *LndhubService) HandleSuccessfulPayment(ctx context.Context, invoice * sentry.CaptureException(err) svc.Logger.Errorf("Could not update sucessful payment invoice user_id:%v invoice_id:%v", invoice.UserID, invoice.ID) } - return err + + // Get the user's fee account for the transaction entry, current account is already there in parent entry + feeAccount, err := svc.AccountFor(ctx, common.AccountTypeFees, invoice.UserID) + if err != nil { + svc.Logger.Errorf("Could not find fees account user_id:%v", invoice.UserID) + return err + } + + // add transaction entry for fee + entry := models.TransactionEntry{ + UserID: invoice.UserID, + InvoiceID: invoice.ID, + CreditAccountID: feeAccount.ID, + DebitAccountID: parentEntry.DebitAccountID, + Amount: int64(invoice.Fee), + ParentID: parentEntry.ID, + } + _, err = svc.DB.NewInsert().Model(&entry).Exec(ctx) + if err != nil { + sentry.CaptureException(err) + svc.Logger.Errorf("Could not insert fee transaction entry user_id:%v invoice_id:%v", invoice.UserID, invoice.ID) + return err + } + + userBalance, err := svc.CurrentUserBalance(ctx, entry.UserID) + if err != nil { + sentry.CaptureException(err) + svc.Logger.Errorf("Could not fetch user balance user_id:%v invoice_id:%v", invoice.UserID, invoice.ID) + return err + } + + if userBalance < 0 { + amountMsg := fmt.Sprintf("User balance is negative transaction_entry_id:%v user_id:%v amount:%v", entry.ID, entry.UserID, userBalance) + svc.Logger.Info(amountMsg) + sentry.CaptureMessage(amountMsg) + } + + return nil } func (svc *LndhubService) AddOutgoingInvoice(ctx context.Context, userID int64, paymentRequest string, lnPayReq *lnd.LNPayReq) (*models.Invoice, error) {