Skip to content

Commit 340dc5d

Browse files
committed
accounts: add new UpsertAccountPayment Store method
And use it in the InterceptorService's paymentUpdate method.
1 parent b6ff059 commit 340dc5d

File tree

4 files changed

+232
-53
lines changed

4 files changed

+232
-53
lines changed

accounts/interface.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,14 @@ type Store interface {
235235
IncreaseAccountBalance(ctx context.Context, id AccountID,
236236
amount lnwire.MilliSatoshi) error
237237

238+
// UpsertAccountPayment updates or inserts a payment entry for the given
239+
// account. Various functional options can be passed to modify the
240+
// behavior of the method.
241+
UpsertAccountPayment(_ context.Context, id AccountID,
242+
paymentHash lntypes.Hash, fullAmount lnwire.MilliSatoshi,
243+
status lnrpc.Payment_PaymentStatus,
244+
options ...UpsertPaymentOption) error
245+
238246
// RemoveAccount finds an account by its ID and removes it from the¨
239247
// store.
240248
RemoveAccount(ctx context.Context, id AccountID) error
@@ -316,3 +324,41 @@ type RequestValuesStore interface {
316324
// DeleteValues deletes any values stored for the given request ID.
317325
DeleteValues(reqID uint64)
318326
}
327+
328+
// UpsertPaymentOption is a functional option that can be passed to the
329+
// UpsertAccountPayment method to modify its behavior.
330+
type UpsertPaymentOption func(*upsertAcctPaymentOption)
331+
332+
// upsertAcctPaymentOption is a struct that holds optional parameters for the
333+
// UpsertAccountPayment method.
334+
type upsertAcctPaymentOption struct {
335+
debitAccount bool
336+
errIfAlreadyPending bool
337+
}
338+
339+
// newUpsertPaymentOption creates a new upsertAcctPaymentOption with default
340+
// values.
341+
func newUpsertPaymentOption() *upsertAcctPaymentOption {
342+
return &upsertAcctPaymentOption{
343+
debitAccount: false,
344+
errIfAlreadyPending: false,
345+
}
346+
}
347+
348+
// WithDebitAccount is a functional option that can be passed to the
349+
// UpsertAccountPayment method to indicate that the account balance should be
350+
// debited by the full amount of the payment.
351+
func WithDebitAccount() UpsertPaymentOption {
352+
return func(o *upsertAcctPaymentOption) {
353+
o.debitAccount = true
354+
}
355+
}
356+
357+
// WithErrIfAlreadyPending is a functional option that can be passed to the
358+
// UpsertAccountPayment method to indicate that an error should be returned if
359+
// the payment is already pending or succeeded.
360+
func WithErrIfAlreadyPending() UpsertPaymentOption {
361+
return func(o *upsertAcctPaymentOption) {
362+
o.errIfAlreadyPending = true
363+
}
364+
}

accounts/service.go

Lines changed: 22 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -498,44 +498,22 @@ func (s *InterceptorService) AssociatePayment(ctx context.Context, id AccountID,
498498
s.Lock()
499499
defer s.Unlock()
500500

501-
account, err := s.store.Account(ctx, id)
502-
if err != nil {
503-
return err
504-
}
505-
506-
// Check if this payment is associated with the account already.
507-
_, ok := account.Payments[paymentHash]
508-
if ok {
509-
// We do not allow another payment to the same hash if the
510-
// payment is already in-flight or succeeded. This mitigates a
511-
// user being able to launch a second RPC-erring payment with
512-
// the same hash that would remove the payment from being
513-
// tracked. Note that this prevents launching multipart
514-
// payments, but allows retrying a payment if it has failed.
515-
if account.Payments[paymentHash].Status !=
516-
lnrpc.Payment_FAILED {
517-
518-
return fmt.Errorf("payment with hash %s is already in "+
519-
"flight or succeeded (status %v)", paymentHash,
520-
account.Payments[paymentHash].Status)
521-
}
522-
523-
// Otherwise, we fall through to correctly update the payment
524-
// amount, in case we have a zero-amount invoice that is
525-
// retried.
526-
}
527-
528-
// Associate the payment with the account and store it.
529-
account.Payments[paymentHash] = &PaymentEntry{
530-
Status: lnrpc.Payment_UNKNOWN,
531-
FullAmount: fullAmt,
532-
}
533-
534-
if err := s.store.UpdateAccount(ctx, account); err != nil {
535-
return fmt.Errorf("error updating account: %w", err)
536-
}
537-
538-
return nil
501+
// We add the WithErrIfAlreadyPending option to ensure that if the
502+
// payment is already associated with the account, then we return
503+
// an error if the payment is already in-flight or succeeded. This
504+
// prevents a user from being able to launch a second RPC-erring payment
505+
// with the same hash that would remove the payment from being tracked.
506+
//
507+
// NOTE: this prevents launching multipart payments, but allows
508+
// retrying a payment if it has failed.
509+
//
510+
// If the payment is already associated with the account but not in
511+
// flight, we update the payment amount in case we have a zero-amount
512+
// invoice that is retried.
513+
return s.store.UpsertAccountPayment(
514+
ctx, id, paymentHash, fullAmt, lnrpc.Payment_UNKNOWN,
515+
WithErrIfAlreadyPending(),
516+
)
539517
}
540518

541519
// invoiceUpdate credits the account an invoice was registered with, in case the
@@ -832,23 +810,14 @@ func (s *InterceptorService) paymentUpdate(ctx context.Context,
832810

833811
// The payment went through! We now need to debit the full amount from
834812
// the account.
835-
account, err := s.store.Account(ctx, pendingPayment.accountID)
836-
if err != nil {
837-
err = s.disableAndErrorfUnsafe("error fetching account: %w",
838-
err)
839-
840-
return terminalState, err
841-
}
842-
843813
fullAmount := status.Value + status.Fee
844814

845-
// Update the account and store it in the database.
846-
account.CurrentBalance -= int64(fullAmount)
847-
account.Payments[hash] = &PaymentEntry{
848-
Status: lnrpc.Payment_SUCCEEDED,
849-
FullAmount: fullAmount,
850-
}
851-
if err := s.store.UpdateAccount(ctx, account); err != nil {
815+
// Update the persisted account.
816+
err := s.store.UpsertAccountPayment(
817+
ctx, pendingPayment.accountID, hash, fullAmount,
818+
lnrpc.Payment_SUCCEEDED, WithDebitAccount(),
819+
)
820+
if err != nil {
852821
err = s.disableAndErrorfUnsafe("error updating account: %w",
853822
err)
854823

accounts/store_kvdb.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/btcsuite/btcwallet/walletdb"
1515
"github.com/lightningnetwork/lnd/fn"
1616
"github.com/lightningnetwork/lnd/kvdb"
17+
"github.com/lightningnetwork/lnd/lnrpc"
1718
"github.com/lightningnetwork/lnd/lntypes"
1819
"github.com/lightningnetwork/lnd/lnwire"
1920
"go.etcd.io/bbolt"
@@ -255,6 +256,52 @@ func (s *BoltStore) IncreaseAccountBalance(_ context.Context, id AccountID,
255256
return s.updateAccount(id, update)
256257
}
257258

259+
// UpsertAccountPayment updates or inserts a payment entry for the given
260+
// account. Various functional options can be passed to modify the behavior of
261+
// the method.
262+
//
263+
// NOTE: This is part of the Store interface.
264+
func (s *BoltStore) UpsertAccountPayment(_ context.Context, id AccountID,
265+
paymentHash lntypes.Hash, fullAmount lnwire.MilliSatoshi,
266+
status lnrpc.Payment_PaymentStatus,
267+
options ...UpsertPaymentOption) error {
268+
269+
opts := newUpsertPaymentOption()
270+
for _, o := range options {
271+
o(opts)
272+
}
273+
274+
update := func(account *OffChainBalanceAccount) error {
275+
entry, ok := account.Payments[paymentHash]
276+
if ok {
277+
// If the errIfAlreadyPending option is set, we return
278+
// an error if the payment is already in-flight or
279+
// succeeded.
280+
if opts.errIfAlreadyPending &&
281+
entry.Status != lnrpc.Payment_FAILED {
282+
283+
return fmt.Errorf("payment with hash %s is "+
284+
"already in flight or succeeded "+
285+
"(status %v)", paymentHash,
286+
account.Payments[paymentHash].Status)
287+
}
288+
}
289+
290+
account.Payments[paymentHash] = &PaymentEntry{
291+
Status: status,
292+
FullAmount: fullAmount,
293+
}
294+
295+
if opts.debitAccount {
296+
account.CurrentBalance -= int64(fullAmount)
297+
}
298+
299+
return nil
300+
}
301+
302+
return s.updateAccount(id, update)
303+
}
304+
258305
func (s *BoltStore) updateAccount(id AccountID,
259306
updateFn func(*OffChainBalanceAccount) error) error {
260307

accounts/store_test.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,123 @@ func TestAccountUpdateMethods(t *testing.T) {
257257

258258
assertBalance(223)
259259
})
260+
261+
t.Run("UpsertAccountPayment", func(t *testing.T) {
262+
store := NewTestDB(t)
263+
264+
acct, err := store.NewAccount(ctx, 1000, time.Time{}, "foo")
265+
require.NoError(t, err)
266+
267+
assertBalanceAndPayments := func(balance int64,
268+
payments AccountPayments) {
269+
270+
dbAcct, err := store.Account(ctx, acct.ID)
271+
require.NoError(t, err)
272+
require.EqualValues(t, balance, dbAcct.CurrentBalance)
273+
274+
require.Len(t, dbAcct.Payments, len(payments))
275+
for hash, payment := range payments {
276+
dbPayment, ok := dbAcct.Payments[hash]
277+
require.True(t, ok)
278+
require.Equal(t, payment, dbPayment)
279+
}
280+
}
281+
282+
// The account initially has a balance of 1000 and no payments.
283+
assertBalanceAndPayments(1000, nil)
284+
285+
// Assert that calling the method for a non-existent account
286+
// errors out.
287+
err = store.UpsertAccountPayment(
288+
ctx, AccountID{}, lntypes.Hash{}, 0,
289+
lnrpc.Payment_UNKNOWN,
290+
)
291+
require.ErrorIs(t, err, ErrAccNotFound)
292+
293+
// Add a payment to the account but don't update the balance.
294+
// We do add a WithErrIfAlreadyPending option here just to show
295+
// that no error is returned since the payment does not exist
296+
// yet.
297+
hash1 := lntypes.Hash{1, 2, 3, 4}
298+
err = store.UpsertAccountPayment(
299+
ctx, acct.ID, hash1, 600, lnrpc.Payment_UNKNOWN,
300+
WithErrIfAlreadyPending(),
301+
)
302+
require.NoError(t, err)
303+
304+
assertBalanceAndPayments(1000, AccountPayments{
305+
hash1: &PaymentEntry{
306+
Status: lnrpc.Payment_UNKNOWN,
307+
FullAmount: 600,
308+
},
309+
})
310+
311+
// Add a second payment to the account and again don't update
312+
// the balance.
313+
hash2 := lntypes.Hash{5, 6, 7, 8}
314+
err = store.UpsertAccountPayment(
315+
ctx, acct.ID, hash2, 100, lnrpc.Payment_UNKNOWN,
316+
)
317+
require.NoError(t, err)
318+
319+
assertBalanceAndPayments(1000, AccountPayments{
320+
hash1: &PaymentEntry{
321+
Status: lnrpc.Payment_UNKNOWN,
322+
FullAmount: 600,
323+
},
324+
hash2: &PaymentEntry{
325+
Status: lnrpc.Payment_UNKNOWN,
326+
FullAmount: 100,
327+
},
328+
})
329+
330+
// Now, update the first payment to have a new status and this
331+
// time, debit the account.
332+
err = store.UpsertAccountPayment(
333+
ctx, acct.ID, hash1, 600, lnrpc.Payment_SUCCEEDED,
334+
WithDebitAccount(),
335+
)
336+
require.NoError(t, err)
337+
338+
// The account should now have a balance of 400 and the first
339+
// payment should have a status of succeeded.
340+
assertBalanceAndPayments(400, AccountPayments{
341+
hash1: &PaymentEntry{
342+
Status: lnrpc.Payment_SUCCEEDED,
343+
FullAmount: 600,
344+
},
345+
hash2: &PaymentEntry{
346+
Status: lnrpc.Payment_UNKNOWN,
347+
FullAmount: 100,
348+
},
349+
})
350+
351+
// Calling the same method again with the same payment hash
352+
// should have no effect by default.
353+
err = store.UpsertAccountPayment(
354+
ctx, acct.ID, hash1, 600, lnrpc.Payment_SUCCEEDED,
355+
)
356+
require.NoError(t, err)
357+
358+
assertBalanceAndPayments(400, AccountPayments{
359+
hash1: &PaymentEntry{
360+
Status: lnrpc.Payment_SUCCEEDED,
361+
FullAmount: 600,
362+
},
363+
hash2: &PaymentEntry{
364+
Status: lnrpc.Payment_UNKNOWN,
365+
FullAmount: 100,
366+
},
367+
})
368+
369+
// But, if we use the WithErrIfAlreadyPending option, we should
370+
// get an error since the payment already exists.
371+
err = store.UpsertAccountPayment(
372+
ctx, acct.ID, hash1, 600, lnrpc.Payment_SUCCEEDED,
373+
WithErrIfAlreadyPending(),
374+
)
375+
require.ErrorContains(t, err, "is already in flight")
376+
})
260377
}
261378

262379
// TestLastInvoiceIndexes makes sure the last known invoice indexes can be

0 commit comments

Comments
 (0)