Skip to content

Commit

Permalink
Merge pull request #117 from getAlby/fee-handling
Browse files Browse the repository at this point in the history
Fee handling
  • Loading branch information
kiwiidb authored Mar 8, 2022
2 parents f86543e + a3c6f29 commit e834d41
Show file tree
Hide file tree
Showing 8 changed files with 213 additions and 17 deletions.
1 change: 1 addition & 0 deletions .env_example
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ JWT_REFRESH_EXPIRY=604800
LND_ADDRESS=
LND_MACAROON_HEX=
LND_CERT_HEX=
FIXED_FEE=10
17 changes: 14 additions & 3 deletions db/migrations/20220120000700_add_constraints.up.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions db/migrations/20220301100000_invoice_add_fee.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
alter table invoices ADD COLUMN fee bigint;
1 change: 1 addition & 0 deletions db/models/invoice.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
42 changes: 34 additions & 8 deletions integration_tests/internal_payment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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{
Expand All @@ -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{
Expand Down Expand Up @@ -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) {
Expand Down
116 changes: 114 additions & 2 deletions integration_tests/outgoing_payment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@ import (
"fmt"
"time"

"github.com/getAlby/lndhub.go/common"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/stretchr/testify/assert"
)

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{
Expand Down Expand Up @@ -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)
}
6 changes: 5 additions & 1 deletion integration_tests/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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,
Expand Down
46 changes: 43 additions & 3 deletions lib/service/invoices.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"math/rand"
"time"

Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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()}

Expand All @@ -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) {
Expand Down

0 comments on commit e834d41

Please sign in to comment.