From fcecc22003adca12f62f6ece2107237733d59d24 Mon Sep 17 00:00:00 2001 From: Michael Bumann Date: Fri, 29 Dec 2023 02:12:56 +0100 Subject: [PATCH 01/22] Add support for service fees This introduces a new transaction type "service_fee" Each outgoing payment is charged a service fee of x/1000. The service fee entries is added with the routing fee reserve entry. For failed payments the service fee is reversed. --- db/models/invoice.go | 2 ++ db/models/transactionentry.go | 3 ++ lib/service/checkpayments.go | 8 +++++ lib/service/config.go | 1 + lib/service/invoices.go | 61 ++++++++++++++++++++++++++++++----- lib/service/user.go | 7 ++++ 6 files changed, 74 insertions(+), 8 deletions(-) diff --git a/db/models/invoice.go b/db/models/invoice.go index ba03e5fc..53f3ff74 100644 --- a/db/models/invoice.go +++ b/db/models/invoice.go @@ -15,6 +15,8 @@ type Invoice struct { User *User `json:"-" bun:"rel:belongs-to,join:user_id=id"` Amount int64 `json:"amount" validate:"gte=0"` Fee int64 `json:"fee" bun:",nullzero"` + ServiceFee int64 `json:"service_fee" bun:",nullzero"` + RoutingFee int64 `json:"routing_fee" bun:",nullzero"` Memo string `json:"memo" bun:",nullzero"` DescriptionHash string `json:"description_hash,omitempty" bun:",nullzero"` PaymentRequest string `json:"payment_request" bun:",nullzero"` diff --git a/db/models/transactionentry.go b/db/models/transactionentry.go index 2abbe522..fae20568 100644 --- a/db/models/transactionentry.go +++ b/db/models/transactionentry.go @@ -9,6 +9,8 @@ const ( EntryTypeOutgoing = "outgoing" EntryTypeFee = "fee" EntryTypeFeeReserve = "fee_reserve" + EntryTypeServiceFee = "service_fee" + EntryTypeServiceFeeReversal = "service_fee_reversal" EntryTypeFeeReserveReversal = "fee_reserve_reversal" EntryTypeOutgoingReversal = "outgoing_reversal" ) @@ -24,6 +26,7 @@ type TransactionEntry struct { Parent *TransactionEntry `bun:"rel:belongs-to"` CreditAccountID int64 `bun:",notnull"` FeeReserve *TransactionEntry `bun:"rel:belongs-to"` + ServiceFee *TransactionEntry `bun:"rel:belongs-to"` CreditAccount *Account `bun:"rel:belongs-to,join:credit_account_id=id"` DebitAccountID int64 `bun:",notnull"` DebitAccount *Account `bun:"rel:belongs-to,join:debit_account_id=id"` diff --git a/lib/service/checkpayments.go b/lib/service/checkpayments.go index c7cb264c..38f80368 100644 --- a/lib/service/checkpayments.go +++ b/lib/service/checkpayments.go @@ -54,6 +54,7 @@ func (svc *LndhubService) CheckPendingOutgoingPayments(ctx context.Context, pend func (svc *LndhubService) GetTransactionEntryByInvoiceId(ctx context.Context, id int64) (models.TransactionEntry, error) { entry := models.TransactionEntry{} feeReserveEntry := models.TransactionEntry{} + serviceFeeEntry := models.TransactionEntry{} err := svc.DB.NewSelect().Model(&entry).Where("invoice_id = ? and entry_type = ?", id, models.EntryTypeOutgoing).Limit(1).Scan(ctx) if err != nil { @@ -74,6 +75,13 @@ func (svc *LndhubService) GetTransactionEntryByInvoiceId(ctx context.Context, id return entry, err } entry.FeeReserve = &feeReserveEntry + + err = svc.DB.NewSelect().Model(&serviceFeeEntry).Where("invoice_id = ? and entry_type = ?", id, models.EntryTypeServiceFee).Limit(1).Scan(ctx) + if err != nil { + return entry, err + } + entry.ServiceFee = &serviceFeeEntry + return entry, err } diff --git a/lib/service/config.go b/lib/service/config.go index 3f8f271f..f6974ba4 100644 --- a/lib/service/config.go +++ b/lib/service/config.go @@ -31,6 +31,7 @@ type Config struct { PrometheusPort int `envconfig:"PROMETHEUS_PORT" default:"9092"` WebhookUrl string `envconfig:"WEBHOOK_URL"` FeeReserve bool `envconfig:"FEE_RESERVE" default:"false"` + ServiceFee int `envconfig:"SERVICE_FEE" default:"1"` AllowAccountCreation bool `envconfig:"ALLOW_ACCOUNT_CREATION" default:"true"` MinPasswordEntropy int `envconfig:"MIN_PASSWORD_ENTROPY" default:"0"` MaxReceiveAmount int64 `envconfig:"MAX_RECEIVE_AMOUNT" default:"0"` diff --git a/lib/service/invoices.go b/lib/service/invoices.go index 0b2ba4b1..02e75f7a 100644 --- a/lib/service/invoices.go +++ b/lib/service/invoices.go @@ -235,7 +235,11 @@ func (svc *LndhubService) PayInvoice(ctx context.Context, invoice *models.Invoic // The payment was successful. // These changes to the invoice are persisted in the `HandleSuccessfulPayment` function invoice.Preimage = paymentResponse.PaymentPreimageStr - invoice.Fee = paymentResponse.PaymentRoute.TotalFees + invoice.RoutingFee = paymentResponse.PaymentRoute.TotalFees + if entry.ServiceFee != nil { + invoice.ServiceFee = entry.ServiceFee.Amount + } + invoice.Fee = invoice.RoutingFee + invoice.ServiceFee invoice.RHash = paymentResponse.PaymentHashStr err = svc.HandleSuccessfulPayment(context.Background(), invoice, entry) return &paymentResponse, err @@ -258,6 +262,13 @@ func (svc *LndhubService) HandleFailedPayment(ctx context.Context, invoice *mode svc.Logger.Errorf("Could not revert fee reserve entry entry user_id:%v invoice_id:%v error %s", invoice.UserID, invoice.ID, err.Error()) return err } + //revert the service fee if necessary + err = svc.RevertServiceFee(ctx, &entryToRevert, invoice, tx) + if err != nil { + sentry.CaptureException(err) + svc.Logger.Errorf("Could not revert service fee entry entry user_id:%v invoice_id:%v error %s", invoice.UserID, invoice.ID, err.Error()) + return err + } //revert the payment if necessary entry := models.TransactionEntry{ @@ -318,8 +329,10 @@ func (svc *LndhubService) InsertTransactionEntry(ctx context.Context, invoice *m return entry, err } - //if external payment: add fee reserve to entry + // add fee entries (fee reserve and service fee) feeLimit := svc.CalcFeeLimit(invoice.DestinationPubkeyHex, invoice.Amount) + serviceFee := svc.CalcServiceFee(invoice.Amount) + if feeLimit != 0 { feeReserveEntry := models.TransactionEntry{ UserID: invoice.UserID, @@ -335,6 +348,21 @@ func (svc *LndhubService) InsertTransactionEntry(ctx context.Context, invoice *m } entry.FeeReserve = &feeReserveEntry } + if serviceFee != 0 { + serviceFeeEntry := models.TransactionEntry{ + UserID: invoice.UserID, + InvoiceID: invoice.ID, + CreditAccountID: feeAccount.ID, + DebitAccountID: debitAccount.ID, + Amount: serviceFee, + EntryType: models.EntryTypeServiceFee, + } + _, err = tx.NewInsert().Model(&serviceFeeEntry).Exec(ctx) + if err != nil { + return entry, err + } + entry.ServiceFee = &serviceFeeEntry + } err = tx.Commit() if err != nil { return entry, err @@ -359,22 +387,39 @@ func (svc *LndhubService) RevertFeeReserve(ctx context.Context, entry *models.Tr return nil } -func (svc *LndhubService) AddFeeEntry(ctx context.Context, entry *models.TransactionEntry, invoice *models.Invoice, tx bun.Tx) (err error) { +func (svc *LndhubService) RevertServiceFee(ctx context.Context, entry *models.TransactionEntry, invoice *models.Invoice, tx bun.Tx) (err error) { + if entry.ServiceFee != nil { + entryToRevert := entry.ServiceFee + serviceFeeRevert := models.TransactionEntry{ + UserID: entryToRevert.UserID, + InvoiceID: invoice.ID, + CreditAccountID: entryToRevert.DebitAccountID, + DebitAccountID: entryToRevert.CreditAccountID, + Amount: entryToRevert.Amount, + EntryType: models.EntryTypeServiceFeeReversal, + } + _, err = tx.NewInsert().Model(&serviceFeeRevert).Exec(ctx) + return err + } + return nil +} + +func (svc *LndhubService) AddRoutingFeeEntry(ctx context.Context, entry *models.TransactionEntry, invoice *models.Invoice, tx bun.Tx) (err error) { if entry.FeeReserve != nil { // add transaction entry for fee // if there was no fee reserve then this is an internal payment // and no fee entry is needed // if there is a fee reserve then we must use the same account id's - entry := models.TransactionEntry{ + lnFeeEntry := models.TransactionEntry{ UserID: invoice.UserID, InvoiceID: invoice.ID, CreditAccountID: entry.FeeReserve.CreditAccountID, DebitAccountID: entry.FeeReserve.DebitAccountID, - Amount: int64(invoice.Fee), + Amount: int64(invoice.RoutingFee), ParentID: entry.ID, EntryType: models.EntryTypeFee, } - _, err = tx.NewInsert().Model(&entry).Exec(ctx) + _, err = tx.NewInsert().Model(&lnFeeEntry).Exec(ctx) return err } return nil @@ -408,7 +453,7 @@ func (svc *LndhubService) HandleSuccessfulPayment(ctx context.Context, invoice * } //add the real fee entry - err = svc.AddFeeEntry(ctx, &parentEntry, invoice, tx) + err = svc.AddRoutingFeeEntry(ctx, &parentEntry, invoice, tx) if err != nil { tx.Rollback() sentry.CaptureException(err) @@ -464,7 +509,7 @@ func (svc *LndhubService) AddOutgoingInvoice(ctx context.Context, userID int64, } pHash := sha256.New() pHash.Write(preImage) - + invoice.RHash = hex.EncodeToString(pHash.Sum(nil)) invoice.Preimage = hex.EncodeToString(preImage) } diff --git a/lib/service/user.go b/lib/service/user.go index 8ef3c1cc..2ebeab45 100644 --- a/lib/service/user.go +++ b/lib/service/user.go @@ -170,6 +170,9 @@ func (svc *LndhubService) CheckOutgoingPaymentAllowed(c echo.Context, lnpayReq * if svc.Config.FeeReserve { minimumBalance += svc.CalcFeeLimit(lnpayReq.PayReq.Destination, lnpayReq.PayReq.NumSatoshis) } + if svc.Config.ServiceFee != 0 { + minimumBalance += svc.CalcServiceFee(lnpayReq.PayReq.NumSatoshis) + } if currentBalance < minimumBalance { return &responses.NotEnoughBalanceError, nil } @@ -225,6 +228,10 @@ func (svc *LndhubService) CheckIncomingPaymentAllowed(c echo.Context, amount, us return nil, nil } +func (svc *LndhubService) CalcServiceFee(amount int64) int64 { + serviceFee := int64(math.Ceil(float64(amount) * float64(svc.Config.ServiceFee) / 1000.0)) + return serviceFee +} func (svc *LndhubService) CalcFeeLimit(destination string, amount int64) int64 { if svc.LndClient.IsIdentityPubkey(destination) { From ebd944f10279093b1a74b8ba00419a0e9fedcc30 Mon Sep 17 00:00:00 2001 From: Michael Bumann Date: Fri, 29 Dec 2023 09:28:45 +0100 Subject: [PATCH 02/22] Add service_fee migration --- db/migrations/20231229091800_add_service_fee.up.sql | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 db/migrations/20231229091800_add_service_fee.up.sql diff --git a/db/migrations/20231229091800_add_service_fee.up.sql b/db/migrations/20231229091800_add_service_fee.up.sql new file mode 100644 index 00000000..010c9590 --- /dev/null +++ b/db/migrations/20231229091800_add_service_fee.up.sql @@ -0,0 +1,7 @@ +alter table invoices ADD COLUMN service_fee bigint default 0; +alter table invoices ADD COLUMN routing_fee bigint default 0; + +-- maybe manually migrate existing data? +-- alter table invoices ALTER COLUMN fee SET DEFAULT 0; +-- update invoices set fee = 0 where fee IS NULL; +-- update invoices set routing_fee = fee where routing_fee=0; From 6e2d89693d24249b86acc317672af02e263231c3 Mon Sep 17 00:00:00 2001 From: Michael Bumann Date: Fri, 29 Dec 2023 09:38:06 +0100 Subject: [PATCH 03/22] No service fee by default --- lib/service/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/service/config.go b/lib/service/config.go index f6974ba4..691cb25f 100644 --- a/lib/service/config.go +++ b/lib/service/config.go @@ -31,7 +31,7 @@ type Config struct { PrometheusPort int `envconfig:"PROMETHEUS_PORT" default:"9092"` WebhookUrl string `envconfig:"WEBHOOK_URL"` FeeReserve bool `envconfig:"FEE_RESERVE" default:"false"` - ServiceFee int `envconfig:"SERVICE_FEE" default:"1"` + ServiceFee int `envconfig:"SERVICE_FEE" default:"0"` AllowAccountCreation bool `envconfig:"ALLOW_ACCOUNT_CREATION" default:"true"` MinPasswordEntropy int `envconfig:"MIN_PASSWORD_ENTROPY" default:"0"` MaxReceiveAmount int64 `envconfig:"MAX_RECEIVE_AMOUNT" default:"0"` From 2234b33ad1cc07dbb074fe7b297656f6d0a415df Mon Sep 17 00:00:00 2001 From: Michael Bumann Date: Fri, 29 Dec 2023 10:02:20 +0100 Subject: [PATCH 04/22] Fee reserve and service fee is optional ignore NoRows errors --- lib/service/checkpayments.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/lib/service/checkpayments.go b/lib/service/checkpayments.go index 38f80368..fc06a662 100644 --- a/lib/service/checkpayments.go +++ b/lib/service/checkpayments.go @@ -71,16 +71,22 @@ func (svc *LndhubService) GetTransactionEntryByInvoiceId(ctx context.Context, id return entry, err } err = svc.DB.NewSelect().Model(&feeReserveEntry).Where("invoice_id = ? and entry_type = ?", id, models.EntryTypeFeeReserve).Limit(1).Scan(ctx) - if err != nil { + // The fee reserve transaction entry is optional thus we ignore NoRow errors. + if err != nil && err != sql.ErrNoRows { return entry, err } - entry.FeeReserve = &feeReserveEntry + if err != nil { + entry.FeeReserve = &feeReserveEntry + } err = svc.DB.NewSelect().Model(&serviceFeeEntry).Where("invoice_id = ? and entry_type = ?", id, models.EntryTypeServiceFee).Limit(1).Scan(ctx) - if err != nil { + // The service fee transaction entry is optional thus we ignore NoRow errors. + if err != nil && err != sql.ErrNoRows { return entry, err } - entry.ServiceFee = &serviceFeeEntry + if err != nil { + entry.ServiceFee = &serviceFeeEntry + } return entry, err } From bfa9c9b907cb82c290c1dd33db853ea75767fd3a Mon Sep 17 00:00:00 2001 From: Michael Bumann Date: Fri, 29 Dec 2023 10:02:31 +0100 Subject: [PATCH 05/22] Update Makefile --- Makefile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 0ca7fd22..76d7d2e0 100644 --- a/Makefile +++ b/Makefile @@ -2,4 +2,7 @@ cp .env_example .env build: - CGO_ENABLED=0 go build -o lndhub + CGO_ENABLED=0 go build -o lndhub ./cmd/server + +test: + go test -p 1 -v -covermode=atomic -coverprofile=coverage.out -cover -coverpkg=./... ./... From f13491add4c4860e3e533a180519f2151dd05e1b Mon Sep 17 00:00:00 2001 From: Michael Bumann Date: Fri, 29 Dec 2023 13:49:15 +0100 Subject: [PATCH 06/22] Unify setting the fee on the invoice --- db/models/invoice.go | 8 ++++++++ lib/service/checkpayments.go | 2 +- lib/service/invoices.go | 6 +----- rabbitmq/rabbitmq.go | 2 +- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/db/models/invoice.go b/db/models/invoice.go index 53f3ff74..d7d92c34 100644 --- a/db/models/invoice.go +++ b/db/models/invoice.go @@ -35,6 +35,14 @@ type Invoice struct { SettledAt bun.NullTime `json:"settled_at"` } +func (i *Invoice) SetFee(txEntry TransactionEntry, routingFee int64) { + i.RoutingFee = routingFee + i.Fee = routingFee + if txEntry.ServiceFee != nil { + i.Fee += txEntry.ServiceFee.Amount + } +} + func (i *Invoice) BeforeAppendModel(ctx context.Context, query bun.Query) error { switch query.(type) { case *bun.UpdateQuery: diff --git a/lib/service/checkpayments.go b/lib/service/checkpayments.go index fc06a662..5153b036 100644 --- a/lib/service/checkpayments.go +++ b/lib/service/checkpayments.go @@ -137,7 +137,7 @@ func (svc *LndhubService) TrackOutgoingPaymentstatus(ctx context.Context, invoic return } if payment.Status == lnrpc.Payment_SUCCEEDED { - invoice.Fee = payment.FeeSat + invoice.SetFee(entry, payment.FeeSat) invoice.Preimage = payment.PaymentPreimage svc.Logger.Infof("Completed payment detected: hash %s", payment.PaymentHash) err = svc.HandleSuccessfulPayment(ctx, invoice, entry) diff --git a/lib/service/invoices.go b/lib/service/invoices.go index 02e75f7a..6576b8a3 100644 --- a/lib/service/invoices.go +++ b/lib/service/invoices.go @@ -235,11 +235,7 @@ func (svc *LndhubService) PayInvoice(ctx context.Context, invoice *models.Invoic // The payment was successful. // These changes to the invoice are persisted in the `HandleSuccessfulPayment` function invoice.Preimage = paymentResponse.PaymentPreimageStr - invoice.RoutingFee = paymentResponse.PaymentRoute.TotalFees - if entry.ServiceFee != nil { - invoice.ServiceFee = entry.ServiceFee.Amount - } - invoice.Fee = invoice.RoutingFee + invoice.ServiceFee + invoice.SetFee(entry, paymentResponse.PaymentRoute.TotalFees) invoice.RHash = paymentResponse.PaymentHashStr err = svc.HandleSuccessfulPayment(context.Background(), invoice, entry) return &paymentResponse, err diff --git a/rabbitmq/rabbitmq.go b/rabbitmq/rabbitmq.go index 9308f41c..9ee45cdc 100644 --- a/rabbitmq/rabbitmq.go +++ b/rabbitmq/rabbitmq.go @@ -214,7 +214,7 @@ func (client *DefaultClient) FinalizeInitializedPayments(ctx context.Context, sv switch payment.Status { case lnrpc.Payment_SUCCEEDED: - invoice.Fee = payment.FeeSat + invoice.SetFee(t, payment.FeeSat) invoice.Preimage = payment.PaymentPreimage if err = svc.HandleSuccessfulPayment(ctx, &invoice, t); err != nil { From 49f775eddfc02a30b8daa8aebf6366752e00feae Mon Sep 17 00:00:00 2001 From: Michael Bumann Date: Fri, 29 Dec 2023 14:53:36 +0100 Subject: [PATCH 07/22] Proper error check --- lib/service/checkpayments.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/service/checkpayments.go b/lib/service/checkpayments.go index 5153b036..5ae8afcd 100644 --- a/lib/service/checkpayments.go +++ b/lib/service/checkpayments.go @@ -72,7 +72,7 @@ func (svc *LndhubService) GetTransactionEntryByInvoiceId(ctx context.Context, id } err = svc.DB.NewSelect().Model(&feeReserveEntry).Where("invoice_id = ? and entry_type = ?", id, models.EntryTypeFeeReserve).Limit(1).Scan(ctx) // The fee reserve transaction entry is optional thus we ignore NoRow errors. - if err != nil && err != sql.ErrNoRows { + if err != nil && !errors.Is(err, sql.ErrNoRows) { return entry, err } if err != nil { @@ -81,7 +81,7 @@ func (svc *LndhubService) GetTransactionEntryByInvoiceId(ctx context.Context, id err = svc.DB.NewSelect().Model(&serviceFeeEntry).Where("invoice_id = ? and entry_type = ?", id, models.EntryTypeServiceFee).Limit(1).Scan(ctx) // The service fee transaction entry is optional thus we ignore NoRow errors. - if err != nil && err != sql.ErrNoRows { + if err != nil && !errors.Is(err, sql.ErrNoRows) { return entry, err } if err != nil { From b3fca799e2378a4a306e3359dd03b9a76eba91ff Mon Sep 17 00:00:00 2001 From: Michael Bumann Date: Fri, 29 Dec 2023 15:47:52 +0100 Subject: [PATCH 08/22] ups --- lib/service/checkpayments.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/service/checkpayments.go b/lib/service/checkpayments.go index 5ae8afcd..6f8595a5 100644 --- a/lib/service/checkpayments.go +++ b/lib/service/checkpayments.go @@ -75,7 +75,7 @@ func (svc *LndhubService) GetTransactionEntryByInvoiceId(ctx context.Context, id if err != nil && !errors.Is(err, sql.ErrNoRows) { return entry, err } - if err != nil { + if err == nil { entry.FeeReserve = &feeReserveEntry } @@ -84,11 +84,11 @@ func (svc *LndhubService) GetTransactionEntryByInvoiceId(ctx context.Context, id if err != nil && !errors.Is(err, sql.ErrNoRows) { return entry, err } - if err != nil { + if err == nil { entry.ServiceFee = &serviceFeeEntry } - return entry, err + return entry, nil } // Should be called in a goroutine as the tracking can potentially take a long time From d1761e323521ed69bed7447eb71aec4ed396a655 Mon Sep 17 00:00:00 2001 From: Michael Bumann Date: Fri, 29 Dec 2023 19:02:37 +0100 Subject: [PATCH 09/22] Add service fee tests to outgoing payment tests --- integration_tests/keysend_test.go | 5 +- integration_tests/outgoing_payment_test.go | 68 +++++++++---- integration_tests/payment_failure_test.go | 107 ++++++++++++++++++--- 3 files changed, 144 insertions(+), 36 deletions(-) diff --git a/integration_tests/keysend_test.go b/integration_tests/keysend_test.go index 7ce79557..58da0f85 100644 --- a/integration_tests/keysend_test.go +++ b/integration_tests/keysend_test.go @@ -78,8 +78,10 @@ func (suite *KeySendTestSuite) TearDownSuite() { } func (suite *KeySendTestSuite) TestKeysendPayment() { + suite.service.Config.ServiceFee = 1 aliceFundingSats := 1000 externalSatRequested := 500 + expectedServiceFee := 1 //fund alice account invoiceResponse := suite.createAddInvoiceReq(aliceFundingSats, "integration test external payment alice", suite.aliceToken) err := suite.mlnd.mockPaidInvoice(invoiceResponse, 0, false, nil) @@ -95,7 +97,8 @@ func (suite *KeySendTestSuite) TestKeysendPayment() { if err != nil { fmt.Printf("Error when getting balance %v\n", err.Error()) } - assert.Equal(suite.T(), int64(aliceFundingSats)-int64(externalSatRequested+int(suite.mlnd.fee)), aliceBalance) + assert.Equal(suite.T(), int64(aliceFundingSats)-int64(externalSatRequested+int(suite.mlnd.fee)+expectedServiceFee), aliceBalance) + suite.service.Config.ServiceFee = 0 } func (suite *KeySendTestSuite) TestKeysendPaymentNonExistentDestination() { diff --git a/integration_tests/outgoing_payment_test.go b/integration_tests/outgoing_payment_test.go index f1fd8a60..1592758a 100644 --- a/integration_tests/outgoing_payment_test.go +++ b/integration_tests/outgoing_payment_test.go @@ -14,8 +14,10 @@ import ( ) func (suite *PaymentTestSuite) TestOutGoingPayment() { + suite.service.Config.ServiceFee = 1 aliceFundingSats := 1000 externalSatRequested := 500 + expectedServiceFee := 1 // 1 sat + 1 ppm suite.mlnd.fee = 1 //fund alice account @@ -45,10 +47,10 @@ 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+int(suite.mlnd.fee)), aliceBalance) + assert.Equal(suite.T(), int64(aliceFundingSats)-int64(externalSatRequested+int(suite.mlnd.fee)+expectedServiceFee), aliceBalance) // check that no additional transaction entry was created - transactonEntries, err := suite.service.TransactionEntriesFor(context.Background(), userId) + transactionEntries, err := suite.service.TransactionEntriesFor(context.Background(), userId) if err != nil { fmt.Printf("Error when getting transaction entries %v\n", err.Error()) } @@ -63,27 +65,54 @@ func (suite *PaymentTestSuite) TestOutGoingPayment() { assert.Equal(suite.T(), 1, len(outgoingInvoices)) assert.Equal(suite.T(), 1, len(incomingInvoices)) - assert.Equal(suite.T(), 5, len(transactonEntries)) + // check if there are 6 transaction entries: + // - [0] incoming + // - [1] outgoing + // - [2] fee_reserve + // - [3] service_fee + // - [4] fee_reserve_reversal + // - [5] fee + // + assert.Equal(suite.T(), 6, len(transactionEntries)) + + // the incoming funding + assert.Equal(suite.T(), int64(aliceFundingSats), transactionEntries[0].Amount) + assert.Equal(suite.T(), currentAccount.ID, transactionEntries[0].CreditAccountID) + assert.Equal(suite.T(), incomingAccount.ID, transactionEntries[0].DebitAccountID) + assert.Equal(suite.T(), int64(0), transactionEntries[0].ParentID) + assert.Equal(suite.T(), incomingInvoices[0].ID, transactionEntries[0].InvoiceID) - 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) + // the outgoing payment + assert.Equal(suite.T(), int64(externalSatRequested), transactionEntries[1].Amount) + assert.Equal(suite.T(), outgoingAccount.ID, transactionEntries[1].CreditAccountID) + assert.Equal(suite.T(), currentAccount.ID, transactionEntries[1].DebitAccountID) + assert.Equal(suite.T(), int64(0), transactionEntries[1].ParentID) + assert.Equal(suite.T(), outgoingInvoices[0].ID, transactionEntries[1].InvoiceID) + + // fee + assert.Equal(suite.T(), int64(suite.mlnd.fee), transactionEntries[5].Amount) + assert.Equal(suite.T(), feeAccount.ID, transactionEntries[5].CreditAccountID) + assert.Equal(suite.T(), currentAccount.ID, transactionEntries[5].DebitAccountID) + assert.Equal(suite.T(), outgoingInvoices[0].ID, transactionEntries[5].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) + // fee reserve + fee reserve reversal + assert.Equal(suite.T(), transactionEntries[4].Amount, transactionEntries[2].Amount) // the amount of the fee_reserve and the fee_reserve_reversal must be equal + assert.Equal(suite.T(), feeAccount.ID, transactionEntries[2].CreditAccountID) + assert.Equal(suite.T(), currentAccount.ID, transactionEntries[2].DebitAccountID) + assert.Equal(suite.T(), currentAccount.ID, transactionEntries[4].CreditAccountID) + assert.Equal(suite.T(), feeAccount.ID, transactionEntries[4].DebitAccountID) + assert.Equal(suite.T(), outgoingInvoices[0].ID, transactionEntries[2].InvoiceID) + assert.Equal(suite.T(), outgoingInvoices[0].ID, transactionEntries[4].InvoiceID) - assert.Equal(suite.T(), int64(suite.mlnd.fee), transactonEntries[4].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) + // service fee + assert.Equal(suite.T(), int64(expectedServiceFee), transactionEntries[3].Amount) + assert.Equal(suite.T(), feeAccount.ID, transactionEntries[3].CreditAccountID) + assert.Equal(suite.T(), currentAccount.ID, transactionEntries[3].DebitAccountID) + assert.Equal(suite.T(), outgoingInvoices[0].ID, transactionEntries[3].InvoiceID) // make sure fee entry parent id is previous entry - assert.Equal(suite.T(), transactonEntries[1].ID, transactonEntries[4].ParentID) + assert.Equal(suite.T(), transactionEntries[1].ID, transactionEntries[5].ParentID) + assert.Equal(suite.T(), transactionEntries[1].ID, transactionEntries[3].ParentID) //fetch transactions, make sure the fee is there // check invoices again @@ -94,7 +123,8 @@ func (suite *PaymentTestSuite) TestOutGoingPayment() { responseBody := &[]ExpectedOutgoingInvoice{} assert.Equal(suite.T(), http.StatusOK, rec.Code) assert.NoError(suite.T(), json.NewDecoder(rec.Body).Decode(&responseBody)) - assert.Equal(suite.T(), int64(suite.mlnd.fee), (*responseBody)[0].Fee) + assert.Equal(suite.T(), int64(suite.mlnd.fee)+int64(expectedServiceFee), (*responseBody)[0].Fee) + suite.service.Config.ServiceFee = 0 // reset ServiceFee config (we don't expect the service fee everywhere) } func (suite *PaymentTestSuite) TestOutGoingPaymentWithNegativeBalance() { diff --git a/integration_tests/payment_failure_test.go b/integration_tests/payment_failure_test.go index b73ec94c..6ab1ade3 100644 --- a/integration_tests/payment_failure_test.go +++ b/integration_tests/payment_failure_test.go @@ -78,8 +78,10 @@ func (suite *PaymentTestErrorsSuite) SetupSuite() { } func (suite *PaymentTestErrorsSuite) TestExternalFailingInvoice() { + suite.service.Config.ServiceFee = 1 userFundingSats := 1000 externalSatRequested := 500 + expectedServiceFee := 1 //fund user account invoiceResponse := suite.createAddInvoiceReq(userFundingSats, "integration test external payment user", suite.userToken) err := suite.mlnd.mockPaidInvoice(invoiceResponse, 0, false, nil) @@ -130,13 +132,23 @@ func (suite *PaymentTestErrorsSuite) TestExternalFailingInvoice() { userId := getUserIdFromToken(suite.userToken) - invoices, err := invoicesFor(suite.service, userId, common.InvoiceTypeOutgoing) + // 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, err := invoicesFor(suite.service, 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) + incomingInvoices, err := invoicesFor(suite.service, userId, common.InvoiceTypeIncoming) + if err != nil { + fmt.Printf("Error when getting invoices %v\n", err.Error()) + } + assert.Equal(suite.T(), 1, len(outgoingInvoices)) + assert.Equal(suite.T(), common.InvoiceStateError, outgoingInvoices[0].State) + assert.Equal(suite.T(), SendPaymentMockError, outgoingInvoices[0].ErrorMessage) transactionEntries, err := suite.service.TransactionEntriesFor(context.Background(), userId) if err != nil { @@ -148,21 +160,84 @@ func (suite *PaymentTestErrorsSuite) TestExternalFailingInvoice() { fmt.Printf("Error when getting balance %v\n", err.Error()) } - // check if there are 5 transaction entries: - // - the incoming payment - // - the outgoing payment - // - the fee reserve + the fee reserve reversal - // - the outgoing payment reversal - // with reversed credit and debit account ids for payment 2/5 & payment 3/4 - assert.Equal(suite.T(), 5, len(transactionEntries)) - assert.Equal(suite.T(), transactionEntries[1].CreditAccountID, transactionEntries[4].DebitAccountID) - assert.Equal(suite.T(), transactionEntries[1].DebitAccountID, transactionEntries[4].CreditAccountID) - assert.Equal(suite.T(), transactionEntries[2].CreditAccountID, transactionEntries[3].DebitAccountID) - assert.Equal(suite.T(), transactionEntries[2].DebitAccountID, transactionEntries[3].CreditAccountID) + // check if there are 7 transaction entries: + // - [0] incoming + // - [1] outgoing + // - [2] fee_reserve + // - [3] service_fee + // - [4] fee_reserve_reversal + // - [5] service_fee_reversal + // - [6] outgoing_reversal + // + + fmt.Printf("%v", transactionEntries[0]) + fmt.Println("") + fmt.Printf("%v", transactionEntries[1]) + fmt.Println("") + fmt.Printf("%v", transactionEntries[2]) + fmt.Println("") + fmt.Printf("%v", transactionEntries[3]) + fmt.Println("") + fmt.Printf("%v", transactionEntries[4]) + fmt.Println("") + fmt.Printf("%v", transactionEntries[5]) + fmt.Println("") + fmt.Printf("%v", transactionEntries[6]) + fmt.Println("") + + assert.Equal(suite.T(), 7, len(transactionEntries)) + + // the incoming funding + assert.Equal(suite.T(), int64(userFundingSats), transactionEntries[0].Amount) + assert.Equal(suite.T(), currentAccount.ID, transactionEntries[0].CreditAccountID) + assert.Equal(suite.T(), incomingAccount.ID, transactionEntries[0].DebitAccountID) + assert.Equal(suite.T(), int64(0), transactionEntries[0].ParentID) + assert.Equal(suite.T(), incomingInvoices[0].ID, transactionEntries[0].InvoiceID) + + // the outgoing payment + assert.Equal(suite.T(), int64(externalSatRequested), transactionEntries[1].Amount) + assert.Equal(suite.T(), outgoingAccount.ID, transactionEntries[1].CreditAccountID) + assert.Equal(suite.T(), currentAccount.ID, transactionEntries[1].DebitAccountID) + assert.Equal(suite.T(), int64(0), transactionEntries[1].ParentID) + assert.Equal(suite.T(), outgoingInvoices[0].ID, transactionEntries[1].InvoiceID) + + // fee reserve + fee reserve reversal + assert.Equal(suite.T(), transactionEntries[4].Amount, transactionEntries[2].Amount) // the amount of the fee_reserve and the fee_reserve_reversal must be equal + assert.Equal(suite.T(), feeAccount.ID, transactionEntries[2].CreditAccountID) + assert.Equal(suite.T(), currentAccount.ID, transactionEntries[2].DebitAccountID) + assert.Equal(suite.T(), currentAccount.ID, transactionEntries[4].CreditAccountID) + assert.Equal(suite.T(), feeAccount.ID, transactionEntries[4].DebitAccountID) + assert.Equal(suite.T(), outgoingInvoices[0].ID, transactionEntries[2].InvoiceID) + assert.Equal(suite.T(), outgoingInvoices[0].ID, transactionEntries[4].InvoiceID) + + // service fee + assert.Equal(suite.T(), int64(expectedServiceFee), transactionEntries[3].Amount) + assert.Equal(suite.T(), feeAccount.ID, transactionEntries[3].CreditAccountID) + assert.Equal(suite.T(), currentAccount.ID, transactionEntries[3].DebitAccountID) + assert.Equal(suite.T(), outgoingInvoices[0].ID, transactionEntries[3].InvoiceID) + // service fee reversal + assert.Equal(suite.T(), int64(expectedServiceFee), transactionEntries[5].Amount) + assert.Equal(suite.T(), currentAccount.ID, transactionEntries[5].CreditAccountID) + assert.Equal(suite.T(), feeAccount.ID, transactionEntries[5].DebitAccountID) + assert.Equal(suite.T(), outgoingInvoices[0].ID, transactionEntries[5].InvoiceID) + + // the outgoing payment reversal + assert.Equal(suite.T(), int64(externalSatRequested), transactionEntries[6].Amount) + assert.Equal(suite.T(), currentAccount.ID, transactionEntries[6].CreditAccountID) + assert.Equal(suite.T(), outgoingAccount.ID, transactionEntries[6].DebitAccountID) + assert.Equal(suite.T(), int64(0), transactionEntries[1].ParentID) + assert.Equal(suite.T(), outgoingInvoices[0].ID, transactionEntries[6].InvoiceID) + + // outgoing debit account must be the outgoing reversal credit account + assert.Equal(suite.T(), transactionEntries[1].CreditAccountID, transactionEntries[6].DebitAccountID) + assert.Equal(suite.T(), transactionEntries[1].DebitAccountID, transactionEntries[6].CreditAccountID) + // outgoing amounts and reversal amounts assert.Equal(suite.T(), transactionEntries[1].Amount, int64(externalSatRequested)) - assert.Equal(suite.T(), transactionEntries[4].Amount, int64(externalSatRequested)) + assert.Equal(suite.T(), transactionEntries[6].Amount, int64(externalSatRequested)) // assert that balance is the same assert.Equal(suite.T(), int64(userFundingSats), userBalance) + + suite.service.Config.ServiceFee = 0 // reset service fee - we don't expect this everywhere } func (suite *PaymentTestErrorsSuite) TearDownSuite() { From ceee545a03aa0a9e3273464898827478893af1dc Mon Sep 17 00:00:00 2001 From: Michael Bumann Date: Fri, 29 Dec 2023 19:02:56 +0100 Subject: [PATCH 10/22] Save parant id to fee tx entries --- lib/service/invoices.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/service/invoices.go b/lib/service/invoices.go index 6576b8a3..0553dd94 100644 --- a/lib/service/invoices.go +++ b/lib/service/invoices.go @@ -337,6 +337,7 @@ func (svc *LndhubService) InsertTransactionEntry(ctx context.Context, invoice *m DebitAccountID: debitAccount.ID, Amount: feeLimit, EntryType: models.EntryTypeFeeReserve, + ParentID: entry.ID, } _, err = tx.NewInsert().Model(&feeReserveEntry).Exec(ctx) if err != nil { @@ -352,6 +353,7 @@ func (svc *LndhubService) InsertTransactionEntry(ctx context.Context, invoice *m DebitAccountID: debitAccount.ID, Amount: serviceFee, EntryType: models.EntryTypeServiceFee, + ParentID: entry.ID, } _, err = tx.NewInsert().Model(&serviceFeeEntry).Exec(ctx) if err != nil { From caeba3691b1bb6735b5f4736598cb44f9ab75393 Mon Sep 17 00:00:00 2001 From: Michael Bumann Date: Fri, 29 Dec 2023 19:03:08 +0100 Subject: [PATCH 11/22] Optionally load test DB from env variable --- integration_tests/util.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/integration_tests/util.go b/integration_tests/util.go index c353abe1..4d4fb483 100644 --- a/integration_tests/util.go +++ b/integration_tests/util.go @@ -47,7 +47,10 @@ const ( ) func LndHubTestServiceInit(lndClientMock lnd.LightningClientWrapper) (svc *service.LndhubService, err error) { - dbUri := "postgresql://user:password@localhost/lndhub?sslmode=disable" + dbUri, ok := os.LookupEnv("DATABASE_URI") + if !ok { + dbUri = "postgresql://user:password@localhost/lndhub?sslmode=disable" + } c := &service.Config{ DatabaseUri: dbUri, DatabaseMaxConns: 1, From 77d391c348941b8fc9b76e7e66a5526407dd051c Mon Sep 17 00:00:00 2001 From: Michael Bumann Date: Fri, 29 Dec 2023 19:26:39 +0100 Subject: [PATCH 12/22] Add config for free transactions payment amounts up to NO_SERVICE_FEE_UP_TO_AMOUNT don't get a service fee charged --- lib/service/config.go | 1 + lib/service/invoices_test.go | 39 ++++++++++++++++++++++++++++++++++++ lib/service/user.go | 6 ++++++ 3 files changed, 46 insertions(+) diff --git a/lib/service/config.go b/lib/service/config.go index 691cb25f..de83de47 100644 --- a/lib/service/config.go +++ b/lib/service/config.go @@ -32,6 +32,7 @@ type Config struct { WebhookUrl string `envconfig:"WEBHOOK_URL"` FeeReserve bool `envconfig:"FEE_RESERVE" default:"false"` ServiceFee int `envconfig:"SERVICE_FEE" default:"0"` + NoServiceFeeUpToAmount int `envconfig:"NO_SERVICE_FEE_UP_TO_AMOUNT" default:"0"` AllowAccountCreation bool `envconfig:"ALLOW_ACCOUNT_CREATION" default:"true"` MinPasswordEntropy int `envconfig:"MIN_PASSWORD_ENTROPY" default:"0"` MaxReceiveAmount int64 `envconfig:"MAX_RECEIVE_AMOUNT" default:"0"` diff --git a/lib/service/invoices_test.go b/lib/service/invoices_test.go index 5ee15360..3a303e06 100644 --- a/lib/service/invoices_test.go +++ b/lib/service/invoices_test.go @@ -56,3 +56,42 @@ func TestCalcFeeWithMaxGlobalFee(t *testing.T) { expectedFee := svc.Config.MaxFeeAmount assert.Equal(t, expectedFee, feeLimit) } + +func TestCalcServiceFee(t *testing.T) { + var serviceFee int64 + + svc.Config.ServiceFee = 0 + serviceFee = svc.CalcServiceFee(10000) + assert.Equal(t, int64(0), serviceFee) + + svc.Config.ServiceFee = 5 + serviceFee = svc.CalcServiceFee(1000) + assert.Equal(t, int64(5), serviceFee) + + serviceFee = svc.CalcServiceFee(100) + assert.Equal(t, int64(1), serviceFee) + + serviceFee = svc.CalcServiceFee(212121) + assert.Equal(t, int64(1061), serviceFee) + + svc.Config.ServiceFee = 1 + serviceFee = svc.CalcServiceFee(1000) + assert.Equal(t, int64(1), serviceFee) + + serviceFee = svc.CalcServiceFee(100) + assert.Equal(t, int64(1), serviceFee) + + serviceFee = svc.CalcServiceFee(212121) + assert.Equal(t, int64(213), serviceFee) +} + +func TestCalcServiceFeeWithFreeAmounts(t *testing.T) { + var serviceFee int64 + svc.Config.ServiceFee = 5 + svc.Config.NoServiceFeeUpToAmount = 2121 + serviceFee = svc.CalcServiceFee(2100) + assert.Equal(t, int64(0), serviceFee) + + serviceFee = svc.CalcServiceFee(2122) + assert.Equal(t, int64(11), serviceFee) +} diff --git a/lib/service/user.go b/lib/service/user.go index 2ebeab45..d642a9bf 100644 --- a/lib/service/user.go +++ b/lib/service/user.go @@ -229,6 +229,12 @@ func (svc *LndhubService) CheckIncomingPaymentAllowed(c echo.Context, amount, us return nil, nil } func (svc *LndhubService) CalcServiceFee(amount int64) int64 { + if svc.Config.ServiceFee == 0 { + return 0 + } + if svc.Config.NoServiceFeeUpToAmount != 0 && amount < int64(svc.Config.NoServiceFeeUpToAmount) { + return 0 + } serviceFee := int64(math.Ceil(float64(amount) * float64(svc.Config.ServiceFee) / 1000.0)) return serviceFee } From bbf9e74b2b81b027100f7812ed4a2f6d147ff38f Mon Sep 17 00:00:00 2001 From: Michael Bumann Date: Fri, 29 Dec 2023 19:26:48 +0100 Subject: [PATCH 13/22] Update readme --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 664c46a6..6dcb441b 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,8 @@ vim .env # edit your config + `MAX_ACCOUNT_BALANCE`: (default: 0 = no limit) Set maximum balance (in satoshi) for each account + `MAX_SEND_VOLUME`: (default: 0 = no limit) Set maximum volume (in satoshi) for sending for each account + `MAX_RECEIVE_VOLUME`: (default: 0 = no limit) Set maximum volume (in satoshi) for receiving for each account ++ `SERVICE_FEE`: (default: 0 = no service fee) Set the service fee for each outgoing transaction in 1/1000 (e.g. 1 means a fee of 1sat for 1000sats - rounded up to the next bigger integer) ++ `NO_SERVICE_FEE_UP_TO_AMOUNT` (default: 0 = no free transcation) the amount in sats up to which no service fee should be charged ### Macaroon From 65c38b35c1914316768392b28c46002905a4ddb2 Mon Sep 17 00:00:00 2001 From: Michael Bumann Date: Fri, 29 Dec 2023 19:29:14 +0100 Subject: [PATCH 14/22] cleanup --- integration_tests/payment_failure_test.go | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/integration_tests/payment_failure_test.go b/integration_tests/payment_failure_test.go index 6ab1ade3..5d8e984b 100644 --- a/integration_tests/payment_failure_test.go +++ b/integration_tests/payment_failure_test.go @@ -170,21 +170,6 @@ func (suite *PaymentTestErrorsSuite) TestExternalFailingInvoice() { // - [6] outgoing_reversal // - fmt.Printf("%v", transactionEntries[0]) - fmt.Println("") - fmt.Printf("%v", transactionEntries[1]) - fmt.Println("") - fmt.Printf("%v", transactionEntries[2]) - fmt.Println("") - fmt.Printf("%v", transactionEntries[3]) - fmt.Println("") - fmt.Printf("%v", transactionEntries[4]) - fmt.Println("") - fmt.Printf("%v", transactionEntries[5]) - fmt.Println("") - fmt.Printf("%v", transactionEntries[6]) - fmt.Println("") - assert.Equal(suite.T(), 7, len(transactionEntries)) // the incoming funding From 55f1792b888703fd44eb324fc1a407b6f43c7cfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= Date: Fri, 29 Dec 2023 23:46:28 +0100 Subject: [PATCH 15/22] fix: only charge service fees for amounts > free limit --- README.md | 2 +- lib/service/invoices_test.go | 4 ++++ lib/service/user.go | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6dcb441b..a1da72c7 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ vim .env # edit your config + `MAX_SEND_VOLUME`: (default: 0 = no limit) Set maximum volume (in satoshi) for sending for each account + `MAX_RECEIVE_VOLUME`: (default: 0 = no limit) Set maximum volume (in satoshi) for receiving for each account + `SERVICE_FEE`: (default: 0 = no service fee) Set the service fee for each outgoing transaction in 1/1000 (e.g. 1 means a fee of 1sat for 1000sats - rounded up to the next bigger integer) -+ `NO_SERVICE_FEE_UP_TO_AMOUNT` (default: 0 = no free transcation) the amount in sats up to which no service fee should be charged ++ `NO_SERVICE_FEE_UP_TO_AMOUNT` (default: 0 = no free transactions) the amount in sats up to which no service fee should be charged ### Macaroon diff --git a/lib/service/invoices_test.go b/lib/service/invoices_test.go index 3a303e06..a4082c7a 100644 --- a/lib/service/invoices_test.go +++ b/lib/service/invoices_test.go @@ -89,9 +89,13 @@ func TestCalcServiceFeeWithFreeAmounts(t *testing.T) { var serviceFee int64 svc.Config.ServiceFee = 5 svc.Config.NoServiceFeeUpToAmount = 2121 + serviceFee = svc.CalcServiceFee(2100) assert.Equal(t, int64(0), serviceFee) + serviceFee = svc.CalcServiceFee(2121) + assert.Equal(t, int64(0), serviceFee) + serviceFee = svc.CalcServiceFee(2122) assert.Equal(t, int64(11), serviceFee) } diff --git a/lib/service/user.go b/lib/service/user.go index d642a9bf..df90fdf0 100644 --- a/lib/service/user.go +++ b/lib/service/user.go @@ -232,7 +232,7 @@ func (svc *LndhubService) CalcServiceFee(amount int64) int64 { if svc.Config.ServiceFee == 0 { return 0 } - if svc.Config.NoServiceFeeUpToAmount != 0 && amount < int64(svc.Config.NoServiceFeeUpToAmount) { + if svc.Config.NoServiceFeeUpToAmount != 0 && amount <= int64(svc.Config.NoServiceFeeUpToAmount) { return 0 } serviceFee := int64(math.Ceil(float64(amount) * float64(svc.Config.ServiceFee) / 1000.0)) From 7d335d5e24a62a92930cddd8f96d88215350c88f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= Date: Fri, 29 Dec 2023 23:47:03 +0100 Subject: [PATCH 16/22] fix: format --- integration_tests/outgoing_payment_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration_tests/outgoing_payment_test.go b/integration_tests/outgoing_payment_test.go index 1592758a..efa7d8b4 100644 --- a/integration_tests/outgoing_payment_test.go +++ b/integration_tests/outgoing_payment_test.go @@ -114,7 +114,7 @@ func (suite *PaymentTestSuite) TestOutGoingPayment() { assert.Equal(suite.T(), transactionEntries[1].ID, transactionEntries[5].ParentID) assert.Equal(suite.T(), transactionEntries[1].ID, transactionEntries[3].ParentID) - //fetch transactions, make sure the fee is there + // fetch transactions, make sure the fee is there // check invoices again req := httptest.NewRequest(http.MethodGet, "/gettxs", nil) req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", suite.aliceToken)) From 6ac8effb3e65d1081c74d8f341f96e2cd57b9140 Mon Sep 17 00:00:00 2001 From: Michael Bumann Date: Sat, 30 Dec 2023 00:29:17 +0100 Subject: [PATCH 17/22] Save service fee on invoice --- db/models/invoice.go | 1 + lib/service/invoices_test.go | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/db/models/invoice.go b/db/models/invoice.go index d7d92c34..b0809a9c 100644 --- a/db/models/invoice.go +++ b/db/models/invoice.go @@ -39,6 +39,7 @@ func (i *Invoice) SetFee(txEntry TransactionEntry, routingFee int64) { i.RoutingFee = routingFee i.Fee = routingFee if txEntry.ServiceFee != nil { + i.ServiceFee = txEntry.ServiceFee.Amount i.Fee += txEntry.ServiceFee.Amount } } diff --git a/lib/service/invoices_test.go b/lib/service/invoices_test.go index a4082c7a..d18866ec 100644 --- a/lib/service/invoices_test.go +++ b/lib/service/invoices_test.go @@ -99,3 +99,17 @@ func TestCalcServiceFeeWithFreeAmounts(t *testing.T) { serviceFee = svc.CalcServiceFee(2122) assert.Equal(t, int64(11), serviceFee) } + +func TestSetFeeOnInvoice(t *testing.T) { + invoice := &models.Invoice{ + Amount: 500, + } + entry := &models.TransactionEntry{} + entry.ServiceFee = &models.TransactionEntry{ + Amount: 42, + } + invoice.SetFee(*entry, 21) + assert.Equal(t, int64(21), invoice.RoutingFee) + assert.Equal(t, int64(42), invoice.ServiceFee) + assert.Equal(t, int64(63), invoice.Fee) +} From 168d49a14e1d3d463643dfdd61277b52ef68d984 Mon Sep 17 00:00:00 2001 From: Michael Bumann Date: Sat, 30 Dec 2023 00:30:40 +0100 Subject: [PATCH 18/22] naming --- lib/service/invoices.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/service/invoices.go b/lib/service/invoices.go index 0553dd94..03294650 100644 --- a/lib/service/invoices.go +++ b/lib/service/invoices.go @@ -408,7 +408,7 @@ func (svc *LndhubService) AddRoutingFeeEntry(ctx context.Context, entry *models. // if there was no fee reserve then this is an internal payment // and no fee entry is needed // if there is a fee reserve then we must use the same account id's - lnFeeEntry := models.TransactionEntry{ + routingFeeEntry := models.TransactionEntry{ UserID: invoice.UserID, InvoiceID: invoice.ID, CreditAccountID: entry.FeeReserve.CreditAccountID, @@ -417,7 +417,7 @@ func (svc *LndhubService) AddRoutingFeeEntry(ctx context.Context, entry *models. ParentID: entry.ID, EntryType: models.EntryTypeFee, } - _, err = tx.NewInsert().Model(&lnFeeEntry).Exec(ctx) + _, err = tx.NewInsert().Model(&routingFeeEntry).Exec(ctx) return err } return nil From b133f47e1b098f12e15f78094510665afaad356b Mon Sep 17 00:00:00 2001 From: Michael Bumann Date: Sat, 30 Dec 2023 17:39:37 +0100 Subject: [PATCH 19/22] Also save the payment hash where we save the preimage we already do exactly that when in the normal PayInvoice flow. Normally the RHash is already set here, but I guess it does not hurt to have it consistent everywhere. --- lib/service/checkpayments.go | 1 + rabbitmq/rabbitmq.go | 1 + 2 files changed, 2 insertions(+) diff --git a/lib/service/checkpayments.go b/lib/service/checkpayments.go index 6f8595a5..b6653625 100644 --- a/lib/service/checkpayments.go +++ b/lib/service/checkpayments.go @@ -139,6 +139,7 @@ func (svc *LndhubService) TrackOutgoingPaymentstatus(ctx context.Context, invoic if payment.Status == lnrpc.Payment_SUCCEEDED { invoice.SetFee(entry, payment.FeeSat) invoice.Preimage = payment.PaymentPreimage + invoice.RHash = payment.PaymentHash svc.Logger.Infof("Completed payment detected: hash %s", payment.PaymentHash) err = svc.HandleSuccessfulPayment(ctx, invoice, entry) if err != nil { diff --git a/rabbitmq/rabbitmq.go b/rabbitmq/rabbitmq.go index 9ee45cdc..677f9d43 100644 --- a/rabbitmq/rabbitmq.go +++ b/rabbitmq/rabbitmq.go @@ -216,6 +216,7 @@ func (client *DefaultClient) FinalizeInitializedPayments(ctx context.Context, sv case lnrpc.Payment_SUCCEEDED: invoice.SetFee(t, payment.FeeSat) invoice.Preimage = payment.PaymentPreimage + invoice.RHash = payment.PaymentHash if err = svc.HandleSuccessfulPayment(ctx, &invoice, t); err != nil { captureErr(client.logger, err, log.JSON{ From 9ffd589fdb39c1192e6891cb6111ed97bc7043c6 Mon Sep 17 00:00:00 2001 From: Michael Bumann Date: Sat, 30 Dec 2023 17:46:07 +0100 Subject: [PATCH 20/22] Use the invoice routing fee field to check if a routing fee must be charged It feels a bit indirect to check if we have set a FeeReserve. We save the routing fee on the invoice thus we should be able to use that information --- lib/service/invoices.go | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/service/invoices.go b/lib/service/invoices.go index 03294650..172e6e79 100644 --- a/lib/service/invoices.go +++ b/lib/service/invoices.go @@ -403,11 +403,7 @@ func (svc *LndhubService) RevertServiceFee(ctx context.Context, entry *models.Tr } func (svc *LndhubService) AddRoutingFeeEntry(ctx context.Context, entry *models.TransactionEntry, invoice *models.Invoice, tx bun.Tx) (err error) { - if entry.FeeReserve != nil { - // add transaction entry for fee - // if there was no fee reserve then this is an internal payment - // and no fee entry is needed - // if there is a fee reserve then we must use the same account id's + if invoice.RoutingFee != 0 { routingFeeEntry := models.TransactionEntry{ UserID: invoice.UserID, InvoiceID: invoice.ID, From c049d75ef25e4a2a8f9a068a4c921ae509820694 Mon Sep 17 00:00:00 2001 From: Michael Bumann Date: Tue, 9 Jan 2024 22:56:22 +0100 Subject: [PATCH 21/22] remove nullzero from fee columns we want those to be 0 by default --- db/models/invoice.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/db/models/invoice.go b/db/models/invoice.go index b0809a9c..0430480e 100644 --- a/db/models/invoice.go +++ b/db/models/invoice.go @@ -14,9 +14,9 @@ type Invoice struct { UserID int64 `json:"user_id" validate:"required"` User *User `json:"-" bun:"rel:belongs-to,join:user_id=id"` Amount int64 `json:"amount" validate:"gte=0"` - Fee int64 `json:"fee" bun:",nullzero"` - ServiceFee int64 `json:"service_fee" bun:",nullzero"` - RoutingFee int64 `json:"routing_fee" bun:",nullzero"` + Fee int64 `json:"fee"` + ServiceFee int64 `json:"service_fee"` + RoutingFee int64 `json:"routing_fee"` Memo string `json:"memo" bun:",nullzero"` DescriptionHash string `json:"description_hash,omitempty" bun:",nullzero"` PaymentRequest string `json:"payment_request" bun:",nullzero"` From 33c10b2a30611548d12bfc0988bcce1e7643c113 Mon Sep 17 00:00:00 2001 From: Michael Bumann Date: Tue, 9 Jan 2024 23:11:22 +0100 Subject: [PATCH 22/22] add columns only if not existent --- db/migrations/20231229091800_add_service_fee.up.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/db/migrations/20231229091800_add_service_fee.up.sql b/db/migrations/20231229091800_add_service_fee.up.sql index 010c9590..6bd7cb6a 100644 --- a/db/migrations/20231229091800_add_service_fee.up.sql +++ b/db/migrations/20231229091800_add_service_fee.up.sql @@ -1,5 +1,5 @@ -alter table invoices ADD COLUMN service_fee bigint default 0; -alter table invoices ADD COLUMN routing_fee bigint default 0; +alter table invoices ADD COLUMN IF NOT EXISTS service_fee bigint default 0; +alter table invoices ADD COLUMN IF NOT EXISTS routing_fee bigint default 0; -- maybe manually migrate existing data? -- alter table invoices ALTER COLUMN fee SET DEFAULT 0;