mirror of
https://github.com/lightninglabs/lightning-terminal.git
synced 2026-08-13 12:33:36 +02:00
Introduce the ListAccountPayments and CountAccountPayments methods to the accounts Store interface. ListAccountPayments enables retrieval of a paginated list of payment entries associated with a given account ID, supporting offset and limit. CountAccountPayments returns the total number of payments associated with the account.
982 lines
28 KiB
Go
982 lines
28 KiB
Go
package accounts
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/lightningnetwork/lnd/clock"
|
|
"github.com/lightningnetwork/lnd/fn"
|
|
"github.com/lightningnetwork/lnd/lnrpc"
|
|
"github.com/lightningnetwork/lnd/lntypes"
|
|
"github.com/lightningnetwork/lnd/lnwire"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// TestAccountStore tests that accounts can be stored and retrieved correctly.
|
|
func TestAccountStore(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
|
|
clock := clock.NewTestClock(time.Now())
|
|
store := NewTestDB(t, clock)
|
|
|
|
// Create an account that does not expire.
|
|
acct1, err := store.NewAccount(ctx, 0, time.Time{}, "foo")
|
|
require.NoError(t, err)
|
|
require.False(t, acct1.HasExpired())
|
|
|
|
dbAccount, err := store.Account(ctx, acct1.ID)
|
|
require.NoError(t, err)
|
|
|
|
assertEqualAccounts(t, acct1, dbAccount)
|
|
|
|
// Make sure we cannot create a second account with the same label.
|
|
_, err = store.NewAccount(ctx, 123, time.Time{}, "foo")
|
|
require.ErrorIs(t, err, ErrLabelAlreadyExists)
|
|
|
|
// Make sure we cannot set a label that looks like an account ID.
|
|
_, err = store.NewAccount(ctx, 123, time.Time{}, "0011223344556677")
|
|
require.ErrorContains(t, err, "is not allowed as it can be mistaken")
|
|
|
|
// Make sure we can create an account with an empty label.
|
|
acctEmpty, err := store.NewAccount(ctx, 0, time.Time{}, "")
|
|
require.NoError(t, err)
|
|
require.Empty(t, acctEmpty.Label)
|
|
|
|
now := clock.Now()
|
|
|
|
// Update all values of the account that we can modify.
|
|
//
|
|
// Update the balance and expiry.
|
|
err = store.UpdateAccount(
|
|
ctx, acct1.ID, fn.Some(int64(-500)), fn.Some(now),
|
|
fn.None[string](),
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
// Add 2 payments.
|
|
_, err = store.UpsertAccountPayment(
|
|
ctx, acct1.ID, lntypes.Hash{12, 34, 56, 78}, 123456,
|
|
lnrpc.Payment_FAILED,
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
_, err = store.UpsertAccountPayment(
|
|
ctx, acct1.ID, lntypes.Hash{34, 56, 78, 90}, 789456123789,
|
|
lnrpc.Payment_SUCCEEDED,
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
// Add 2 invoices.
|
|
err = store.AddAccountInvoice(
|
|
ctx, acct1.ID, lntypes.Hash{12, 34, 56, 78},
|
|
)
|
|
require.NoError(t, err)
|
|
err = store.AddAccountInvoice(
|
|
ctx, acct1.ID, lntypes.Hash{34, 56, 78, 90},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
// Adjust the account balance by first crediting 10000, and then
|
|
// debiting 5000.
|
|
err = store.CreditAccount(ctx, acct1.ID, lnwire.MilliSatoshi(10000))
|
|
require.NoError(t, err)
|
|
|
|
err = store.DebitAccount(ctx, acct1.ID, lnwire.MilliSatoshi(5000))
|
|
require.NoError(t, err)
|
|
|
|
// Update the in-memory account so that we can compare it with the
|
|
// account we get from the store.
|
|
acct1.CurrentBalance = -500
|
|
acct1.ExpirationDate = clock.Now()
|
|
acct1.Payments[lntypes.Hash{12, 34, 56, 78}] = &PaymentEntry{
|
|
Status: lnrpc.Payment_FAILED,
|
|
FullAmount: 123456,
|
|
}
|
|
acct1.Payments[lntypes.Hash{34, 56, 78, 90}] = &PaymentEntry{
|
|
Status: lnrpc.Payment_SUCCEEDED,
|
|
FullAmount: 789456123789,
|
|
}
|
|
acct1.Invoices[lntypes.Hash{12, 34, 56, 78}] = struct{}{}
|
|
acct1.Invoices[lntypes.Hash{34, 56, 78, 90}] = struct{}{}
|
|
acct1.CurrentBalance += 10000
|
|
acct1.CurrentBalance -= 5000
|
|
|
|
dbAccount, err = store.Account(ctx, acct1.ID)
|
|
require.NoError(t, err)
|
|
assertEqualAccounts(t, acct1, dbAccount)
|
|
|
|
// Test that adjusting the balance to exactly 0 should work, while
|
|
// adjusting the balance to below 0 should fail.
|
|
err = store.DebitAccount(
|
|
ctx, acct1.ID, lnwire.MilliSatoshi(acct1.CurrentBalance),
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
acct1.CurrentBalance = 0
|
|
|
|
dbAccount, err = store.Account(ctx, acct1.ID)
|
|
require.NoError(t, err)
|
|
assertEqualAccounts(t, acct1, dbAccount)
|
|
|
|
// Adjusting the value to below 0 should fail.
|
|
err = store.DebitAccount(ctx, acct1.ID, lnwire.MilliSatoshi(1))
|
|
require.ErrorContains(t, err, "balance would be below 0")
|
|
|
|
// Sleep just a tiny bit to make sure we are never too quick to measure
|
|
// the expiry, even though the time is nanosecond scale and writing to
|
|
// the store and reading again should take at least a couple of
|
|
// microseconds.
|
|
time.Sleep(5 * time.Millisecond)
|
|
require.True(t, acct1.HasExpired())
|
|
|
|
// Test listing and deleting accounts.
|
|
accounts, err := store.Accounts(ctx)
|
|
require.NoError(t, err)
|
|
require.Len(t, accounts, 2)
|
|
|
|
err = store.RemoveAccount(ctx, acct1.ID)
|
|
require.NoError(t, err)
|
|
|
|
accounts, err = store.Accounts(ctx)
|
|
require.NoError(t, err)
|
|
require.Len(t, accounts, 1)
|
|
|
|
_, err = store.Account(ctx, acct1.ID)
|
|
require.ErrorIs(t, err, ErrAccNotFound)
|
|
}
|
|
|
|
// TestAccountStoreAccountsGrouping verifies that Accounts groups invoices and
|
|
// payments by account ID and does not leak linked data across accounts.
|
|
func TestAccountStoreAccountsGrouping(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
store := NewTestDB(t, clock.NewTestClock(time.Now()))
|
|
|
|
accountA, err := store.NewAccount(ctx, 1_000, time.Time{}, "group-a")
|
|
require.NoError(t, err)
|
|
|
|
accountB, err := store.NewAccount(ctx, 2_000, time.Time{}, "group-b")
|
|
require.NoError(t, err)
|
|
|
|
invoiceA := lntypes.Hash{0x01, 0x02, 0x03, 0x04}
|
|
invoiceB := lntypes.Hash{0x0a, 0x0b, 0x0c, 0x0d}
|
|
err = store.AddAccountInvoice(ctx, accountA.ID, invoiceA)
|
|
require.NoError(t, err)
|
|
err = store.AddAccountInvoice(ctx, accountB.ID, invoiceB)
|
|
require.NoError(t, err)
|
|
|
|
paymentA := lntypes.Hash{0x11, 0x12, 0x13, 0x14}
|
|
paymentB := lntypes.Hash{0x1a, 0x1b, 0x1c, 0x1d}
|
|
_, err = store.UpsertAccountPayment(
|
|
ctx, accountA.ID, paymentA, lnwire.MilliSatoshi(1234),
|
|
lnrpc.Payment_IN_FLIGHT,
|
|
)
|
|
require.NoError(t, err)
|
|
_, err = store.UpsertAccountPayment(
|
|
ctx, accountB.ID, paymentB, lnwire.MilliSatoshi(5678),
|
|
lnrpc.Payment_SUCCEEDED,
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
accounts, err := store.Accounts(ctx)
|
|
require.NoError(t, err)
|
|
require.Len(t, accounts, 2)
|
|
|
|
accountsByID := make(
|
|
map[AccountID]*OffChainBalanceAccount, len(accounts),
|
|
)
|
|
for _, account := range accounts {
|
|
accountsByID[account.ID] = account
|
|
}
|
|
|
|
accountAFromList, ok := accountsByID[accountA.ID]
|
|
require.True(t, ok)
|
|
|
|
accountBFromList, ok := accountsByID[accountB.ID]
|
|
require.True(t, ok)
|
|
|
|
require.Contains(t, accountAFromList.Invoices, invoiceA)
|
|
require.NotContains(t, accountAFromList.Invoices, invoiceB)
|
|
require.Contains(t, accountAFromList.Payments, paymentA)
|
|
require.NotContains(t, accountAFromList.Payments, paymentB)
|
|
require.Equal(
|
|
t, lnrpc.Payment_IN_FLIGHT,
|
|
accountAFromList.Payments[paymentA].Status,
|
|
)
|
|
|
|
require.Contains(t, accountBFromList.Invoices, invoiceB)
|
|
require.NotContains(t, accountBFromList.Invoices, invoiceA)
|
|
require.Contains(t, accountBFromList.Payments, paymentB)
|
|
require.NotContains(t, accountBFromList.Payments, paymentA)
|
|
require.Equal(
|
|
t, lnrpc.Payment_SUCCEEDED,
|
|
accountBFromList.Payments[paymentB].Status,
|
|
)
|
|
}
|
|
|
|
// assertEqualAccounts asserts that two accounts are equal. This helper function
|
|
// is needed because an account contains two time.Time values that cannot be
|
|
// compared using reflect.DeepEqual().
|
|
func assertEqualAccounts(t *testing.T, expected,
|
|
actual *OffChainBalanceAccount) {
|
|
|
|
expectedExpiry := expected.ExpirationDate
|
|
actualExpiry := actual.ExpirationDate
|
|
expectedUpdate := expected.LastUpdate
|
|
actualUpdate := actual.LastUpdate
|
|
|
|
expected.ExpirationDate = time.Time{}
|
|
expected.LastUpdate = time.Time{}
|
|
actual.ExpirationDate = time.Time{}
|
|
actual.LastUpdate = time.Time{}
|
|
|
|
require.Equal(t, expected, actual)
|
|
require.Equal(t, expectedExpiry.Unix(), actualExpiry.Unix())
|
|
require.Equal(t, expectedUpdate.Unix(), actualUpdate.Unix())
|
|
|
|
// Restore the old values to not influence the tests.
|
|
expected.ExpirationDate = expectedExpiry
|
|
expected.LastUpdate = expectedUpdate
|
|
actual.ExpirationDate = actualExpiry
|
|
actual.LastUpdate = actualUpdate
|
|
}
|
|
|
|
// TestAccountReadConcurrentWrite verifies that Account() returns correct data
|
|
// when the database is under concurrent write pressure. This is a regression
|
|
// test for a use-after-free on bbolt's mmap: bucket.Get() returns a slice
|
|
// pointing into the mmap'd file, and if that slice escapes the read
|
|
// transaction, a concurrent write can trigger a remap that invalidates the
|
|
// pointer — causing a segfault or silent data corruption.
|
|
func TestAccountReadConcurrentWrite(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
|
|
clk := clock.NewTestClock(time.Now())
|
|
store := NewTestDB(t, clk)
|
|
|
|
// Create the account we'll keep reading throughout the test.
|
|
targetAcct, err := store.NewAccount(
|
|
ctx, 42_000, time.Time{}, "target",
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
// Also store initial indexes so LastIndexes reads have data.
|
|
err = store.StoreLastIndexes(ctx, 100, 200)
|
|
require.NoError(t, err)
|
|
|
|
// Number of concurrent writers. Each creates accounts in a tight
|
|
// loop, growing the database and forcing bbolt to remap.
|
|
const (
|
|
numWriters = 4
|
|
numWrites = 50
|
|
numReaders = 4
|
|
numReads = 200
|
|
)
|
|
|
|
var wg sync.WaitGroup
|
|
|
|
// Spawn writers that grow the database.
|
|
for w := 0; w < numWriters; w++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for i := 0; i < numWrites; i++ {
|
|
_, wErr := store.NewAccount(
|
|
ctx,
|
|
lnwire.MilliSatoshi(1_000_000+i),
|
|
time.Time{},
|
|
"",
|
|
)
|
|
require.NoError(t, wErr)
|
|
|
|
// Also update indexes to exercise
|
|
// LastIndexes' code path.
|
|
wErr = store.StoreLastIndexes(
|
|
ctx,
|
|
uint64(100+i),
|
|
uint64(200+i),
|
|
)
|
|
require.NoError(t, wErr)
|
|
}
|
|
}()
|
|
}
|
|
|
|
// Spawn readers that continuously read the target account and
|
|
// the last indexes while writers are growing the DB.
|
|
for r := 0; r < numReaders; r++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for i := 0; i < numReads; i++ {
|
|
// Read single account (the crash site).
|
|
acct, rErr := store.Account(
|
|
ctx, targetAcct.ID,
|
|
)
|
|
require.NoError(t, rErr)
|
|
|
|
// Verify the data is not corrupted.
|
|
require.Equal(t,
|
|
lnwire.MilliSatoshi(42_000),
|
|
acct.InitialBalance,
|
|
"corrupted balance",
|
|
)
|
|
require.Equal(t,
|
|
"target", acct.Label,
|
|
"corrupted label",
|
|
)
|
|
|
|
// Read last indexes.
|
|
add, settle, rErr := store.LastIndexes(ctx)
|
|
require.NoError(t, rErr)
|
|
require.GreaterOrEqual(t, add,
|
|
uint64(100),
|
|
"corrupted add index",
|
|
)
|
|
require.GreaterOrEqual(t, settle,
|
|
uint64(200),
|
|
"corrupted settle index",
|
|
)
|
|
}
|
|
}()
|
|
}
|
|
|
|
wg.Wait()
|
|
}
|
|
|
|
// TestAccountUpdateMethods tests that all the Store methods that update an
|
|
// account work correctly.
|
|
func TestAccountUpdateMethods(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
|
|
t.Run("UpdateAccount", func(t *testing.T) {
|
|
clock := clock.NewTestClock(time.Now())
|
|
store := NewTestDB(t, clock)
|
|
|
|
// Ensure that the function errors out if we try update an
|
|
// account that does not exist.
|
|
err := store.UpdateAccount(
|
|
ctx, AccountID{}, fn.None[int64](),
|
|
fn.None[time.Time](), fn.None[string](),
|
|
)
|
|
require.ErrorIs(t, err, ErrAccNotFound)
|
|
|
|
acct, err := store.NewAccount(ctx, 0, time.Time{}, "foo")
|
|
require.NoError(t, err)
|
|
|
|
assertBalanceAndExpiry := func(balance int64,
|
|
expiry time.Time) {
|
|
|
|
dbAcct, err := store.Account(ctx, acct.ID)
|
|
require.NoError(t, err)
|
|
require.EqualValues(t, balance, dbAcct.CurrentBalance)
|
|
require.WithinDuration(
|
|
t, expiry, dbAcct.ExpirationDate, time.Second,
|
|
)
|
|
}
|
|
|
|
// Get the account from the store and check to see what its
|
|
// initial balance and expiry fields are set to.
|
|
assertBalanceAndExpiry(0, time.Time{})
|
|
|
|
// Now, update just the balance of the account.
|
|
newBalance := int64(123)
|
|
err = store.UpdateAccount(
|
|
ctx, acct.ID, fn.Some(newBalance), fn.None[time.Time](),
|
|
fn.None[string](),
|
|
)
|
|
require.NoError(t, err)
|
|
assertBalanceAndExpiry(newBalance, time.Time{})
|
|
|
|
// Now update just the expiry of the account.
|
|
newExpiry := clock.Now().Add(time.Hour)
|
|
err = store.UpdateAccount(
|
|
ctx, acct.ID, fn.None[int64](), fn.Some(newExpiry),
|
|
fn.None[string](),
|
|
)
|
|
require.NoError(t, err)
|
|
assertBalanceAndExpiry(newBalance, newExpiry)
|
|
|
|
// Update both the balance and expiry of the account.
|
|
newBalance = 456
|
|
newExpiry = clock.Now().Add(2 * time.Hour)
|
|
err = store.UpdateAccount(
|
|
ctx, acct.ID, fn.Some(newBalance), fn.Some(newExpiry),
|
|
fn.None[string](),
|
|
)
|
|
require.NoError(t, err)
|
|
assertBalanceAndExpiry(newBalance, newExpiry)
|
|
|
|
// Finally, test an update that has no net changes to the
|
|
// balance or expiry.
|
|
err = store.UpdateAccount(
|
|
ctx, acct.ID, fn.None[int64](), fn.None[time.Time](),
|
|
fn.None[string](),
|
|
)
|
|
require.NoError(t, err)
|
|
assertBalanceAndExpiry(newBalance, newExpiry)
|
|
|
|
// Test renaming the account.
|
|
newLabel := "bar"
|
|
err = store.UpdateAccount(
|
|
ctx, acct.ID, fn.None[int64](), fn.None[time.Time](),
|
|
fn.Some(newLabel),
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
dbAcct, err := store.Account(ctx, acct.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, newLabel, dbAcct.Label)
|
|
|
|
// Test updating an account with its existing label doesn't fail
|
|
err = store.UpdateAccount(
|
|
ctx, acct.ID, fn.None[int64](), fn.None[time.Time](),
|
|
fn.Some(newLabel),
|
|
)
|
|
require.NoError(t, err)
|
|
require.Equal(t, newLabel, dbAcct.Label)
|
|
|
|
// Try to rename to an existing label.
|
|
_, err = store.NewAccount(ctx, 0, time.Time{}, "existing")
|
|
require.NoError(t, err)
|
|
|
|
err = store.UpdateAccount(
|
|
ctx, acct.ID, fn.None[int64](), fn.None[time.Time](),
|
|
fn.Some("existing"),
|
|
)
|
|
require.ErrorIs(t, err, ErrLabelAlreadyExists)
|
|
|
|
// Test that passing an empty Some("") label works and clears
|
|
// the label.
|
|
err = store.UpdateAccount(
|
|
ctx, acct.ID, fn.None[int64](), fn.None[time.Time](),
|
|
fn.Some(""),
|
|
)
|
|
require.NoError(t, err)
|
|
dbAcct, err = store.Account(ctx, acct.ID)
|
|
require.NoError(t, err)
|
|
require.Empty(t, dbAcct.Label)
|
|
|
|
// Test that passing a None label doesn't change anything.
|
|
err = store.UpdateAccount(
|
|
ctx, acct.ID, fn.None[int64](), fn.None[time.Time](),
|
|
fn.Some("new-label"),
|
|
)
|
|
require.NoError(t, err)
|
|
err = store.UpdateAccount(
|
|
ctx, acct.ID, fn.None[int64](), fn.None[time.Time](),
|
|
fn.None[string](),
|
|
)
|
|
require.NoError(t, err)
|
|
dbAcct, err = store.Account(ctx, acct.ID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "new-label", dbAcct.Label)
|
|
|
|
// Test that we cannot update to a label that looks like an
|
|
// account ID.
|
|
err = store.UpdateAccount(
|
|
ctx, acct.ID, fn.None[int64](), fn.None[time.Time](),
|
|
fn.Some("0011223344556677"),
|
|
)
|
|
require.ErrorContains(t, err, "is not allowed"+
|
|
" as it can be mistaken")
|
|
|
|
// Test that a hex string with a different length is allowed.
|
|
err = store.UpdateAccount(
|
|
ctx, acct.ID, fn.None[int64](), fn.None[time.Time](),
|
|
fn.Some("00112233445566"),
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
err = store.UpdateAccount(
|
|
ctx, acct.ID, fn.None[int64](), fn.None[time.Time](),
|
|
fn.Some("001122334455667788"),
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
// Test that a non-hex string with the same length as an account
|
|
// ID is allowed.
|
|
err = store.UpdateAccount(
|
|
ctx, acct.ID, fn.None[int64](), fn.None[time.Time](),
|
|
fn.Some("G011223344556677"),
|
|
)
|
|
require.NoError(t, err)
|
|
})
|
|
|
|
t.Run("AddAccountInvoice", func(t *testing.T) {
|
|
store := NewTestDB(t, clock.NewTestClock(time.Now()))
|
|
|
|
acct, err := store.NewAccount(ctx, 0, time.Time{}, "foo")
|
|
require.NoError(t, err)
|
|
|
|
assertInvoices := func(invoices ...lntypes.Hash) {
|
|
dbAcct, err := store.Account(ctx, acct.ID)
|
|
require.NoError(t, err)
|
|
|
|
// First make sure the number of invoices match before
|
|
// de-duping the hashes.
|
|
require.Len(t, dbAcct.Invoices, len(invoices))
|
|
|
|
dbInvs := make([]lntypes.Hash, 0, len(dbAcct.Invoices))
|
|
for hash := range dbAcct.Invoices {
|
|
dbInvs = append(dbInvs, hash)
|
|
}
|
|
|
|
require.ElementsMatch(t, invoices, dbInvs)
|
|
}
|
|
|
|
// The account initially has no invoices.
|
|
assertInvoices()
|
|
|
|
// Adding an invoice to an account that doesnt exist yet should
|
|
// error out.
|
|
err = store.AddAccountInvoice(ctx, AccountID{}, lntypes.Hash{})
|
|
require.ErrorIs(t, err, ErrAccNotFound)
|
|
|
|
// Add an invoice to the account.
|
|
hash1 := lntypes.Hash{1, 2, 3, 4}
|
|
err = store.AddAccountInvoice(ctx, acct.ID, hash1)
|
|
require.NoError(t, err)
|
|
|
|
assertInvoices(hash1)
|
|
|
|
// Assert that adding the same invoice again does not change the
|
|
// state.
|
|
err = store.AddAccountInvoice(ctx, acct.ID, hash1)
|
|
require.NoError(t, err)
|
|
|
|
assertInvoices(hash1)
|
|
|
|
// Now add a second invoice.
|
|
hash2 := lntypes.Hash{5, 6, 7, 8}
|
|
err = store.AddAccountInvoice(ctx, acct.ID, hash2)
|
|
require.NoError(t, err)
|
|
|
|
assertInvoices(hash1, hash2)
|
|
})
|
|
|
|
t.Run("CreditAccount", func(t *testing.T) {
|
|
store := NewTestDB(t, clock.NewTestClock(time.Now()))
|
|
|
|
// Increasing the balance of an account that doesn't exist
|
|
// should error out.
|
|
err := store.CreditAccount(ctx, AccountID{}, 100)
|
|
require.ErrorIs(t, err, ErrAccNotFound)
|
|
|
|
acct, err := store.NewAccount(ctx, 123, time.Time{}, "foo")
|
|
require.NoError(t, err)
|
|
|
|
assertBalance := func(balance int64) {
|
|
dbAcct, err := store.Account(ctx, acct.ID)
|
|
require.NoError(t, err)
|
|
require.EqualValues(t, balance, dbAcct.CurrentBalance)
|
|
}
|
|
|
|
// The account initially has a balance of 123.
|
|
assertBalance(123)
|
|
|
|
// Increase the balance by 100 and assert that the new balance
|
|
// is 223.
|
|
err = store.CreditAccount(ctx, acct.ID, 100)
|
|
require.NoError(t, err)
|
|
|
|
assertBalance(223)
|
|
})
|
|
|
|
t.Run("Upsert and Delete AccountPayment", func(t *testing.T) {
|
|
store := NewTestDB(t, clock.NewTestClock(time.Now()))
|
|
|
|
acct, err := store.NewAccount(ctx, 1000, time.Time{}, "foo")
|
|
require.NoError(t, err)
|
|
|
|
assertBalanceAndPayments := func(balance int64,
|
|
payments AccountPayments) {
|
|
|
|
dbAcct, err := store.Account(ctx, acct.ID)
|
|
require.NoError(t, err)
|
|
require.EqualValues(t, balance, dbAcct.CurrentBalance)
|
|
|
|
require.Len(t, dbAcct.Payments, len(payments))
|
|
for hash, payment := range payments {
|
|
dbPayment, ok := dbAcct.Payments[hash]
|
|
require.True(t, ok)
|
|
require.Equal(t, payment, dbPayment)
|
|
}
|
|
}
|
|
|
|
// The account initially has a balance of 1000 and no payments.
|
|
assertBalanceAndPayments(1000, nil)
|
|
|
|
// Assert that calling the method for a non-existent account
|
|
// errors out.
|
|
_, err = store.UpsertAccountPayment(
|
|
ctx, AccountID{}, lntypes.Hash{}, 0,
|
|
lnrpc.Payment_UNKNOWN,
|
|
)
|
|
require.ErrorIs(t, err, ErrAccNotFound)
|
|
|
|
// Add a payment to the account but don't update the balance.
|
|
// We do add a WithErrIfAlreadyPending and
|
|
// WithErrIfAlreadySucceeded option. here just to show that no
|
|
// error is returned since the payment does not exist yet.
|
|
hash1 := lntypes.Hash{1, 2, 3, 4}
|
|
known, err := store.UpsertAccountPayment(
|
|
ctx, acct.ID, hash1, 600, lnrpc.Payment_UNKNOWN,
|
|
WithErrIfAlreadyPending(),
|
|
WithErrIfAlreadySucceeded(),
|
|
)
|
|
require.NoError(t, err)
|
|
require.False(t, known)
|
|
|
|
assertBalanceAndPayments(1000, AccountPayments{
|
|
hash1: &PaymentEntry{
|
|
Status: lnrpc.Payment_UNKNOWN,
|
|
FullAmount: 600,
|
|
},
|
|
})
|
|
|
|
// Add a second payment to the account and again don't update
|
|
// the balance.
|
|
hash2 := lntypes.Hash{5, 6, 7, 8}
|
|
known, err = store.UpsertAccountPayment(
|
|
ctx, acct.ID, hash2, 100, lnrpc.Payment_UNKNOWN,
|
|
)
|
|
require.NoError(t, err)
|
|
require.False(t, known)
|
|
|
|
assertBalanceAndPayments(1000, AccountPayments{
|
|
hash1: &PaymentEntry{
|
|
Status: lnrpc.Payment_UNKNOWN,
|
|
FullAmount: 600,
|
|
},
|
|
hash2: &PaymentEntry{
|
|
Status: lnrpc.Payment_UNKNOWN,
|
|
FullAmount: 100,
|
|
},
|
|
})
|
|
|
|
// Now, update the first payment to have a new status and this
|
|
// time, debit the account.
|
|
known, err = store.UpsertAccountPayment(
|
|
ctx, acct.ID, hash1, 600, lnrpc.Payment_SUCCEEDED,
|
|
WithDebitAccount(),
|
|
)
|
|
require.NoError(t, err)
|
|
require.True(t, known)
|
|
|
|
// The account should now have a balance of 400 and the first
|
|
// payment should have a status of succeeded.
|
|
assertBalanceAndPayments(400, AccountPayments{
|
|
hash1: &PaymentEntry{
|
|
Status: lnrpc.Payment_SUCCEEDED,
|
|
FullAmount: 600,
|
|
},
|
|
hash2: &PaymentEntry{
|
|
Status: lnrpc.Payment_UNKNOWN,
|
|
FullAmount: 100,
|
|
},
|
|
})
|
|
|
|
// Calling the same method again with the same payment hash
|
|
// should have no effect by default.
|
|
known, err = store.UpsertAccountPayment(
|
|
ctx, acct.ID, hash1, 600, lnrpc.Payment_SUCCEEDED,
|
|
)
|
|
require.NoError(t, err)
|
|
require.True(t, known)
|
|
|
|
assertBalanceAndPayments(400, AccountPayments{
|
|
hash1: &PaymentEntry{
|
|
Status: lnrpc.Payment_SUCCEEDED,
|
|
FullAmount: 600,
|
|
},
|
|
hash2: &PaymentEntry{
|
|
Status: lnrpc.Payment_UNKNOWN,
|
|
FullAmount: 100,
|
|
},
|
|
})
|
|
|
|
// But, if we use the WithErrIfAlreadyPending option, we should
|
|
// get an error since the payment already exists.
|
|
known, err = store.UpsertAccountPayment(
|
|
ctx, acct.ID, hash1, 600, lnrpc.Payment_SUCCEEDED,
|
|
WithErrIfAlreadyPending(),
|
|
)
|
|
require.ErrorContains(t, err, "is already in flight")
|
|
require.True(t, known)
|
|
|
|
// Do the above call again but this time, use the
|
|
// WithErrIfAlreadySucceeded option. This should return the
|
|
// ErrAlreadySucceeded error since the payment has already
|
|
// succeeded.
|
|
known, err = store.UpsertAccountPayment(
|
|
ctx, acct.ID, hash1, 600, lnrpc.Payment_SUCCEEDED,
|
|
WithErrIfAlreadySucceeded(),
|
|
)
|
|
require.ErrorIs(t, err, ErrAlreadySucceeded)
|
|
require.True(t, known)
|
|
|
|
// We now call the method again for hash 2 and update its status
|
|
// to SUCCEEDED. This time, we will use the WithPendingAmount
|
|
// option which means that whatever `fullAmount` is passed in
|
|
// should be ignored and the pending amount should be used
|
|
// instead.
|
|
known, err = store.UpsertAccountPayment(
|
|
ctx, acct.ID, hash2, 0, lnrpc.Payment_SUCCEEDED,
|
|
WithPendingAmount(),
|
|
)
|
|
require.NoError(t, err)
|
|
require.True(t, known)
|
|
|
|
assertBalanceAndPayments(400, AccountPayments{
|
|
hash1: &PaymentEntry{
|
|
Status: lnrpc.Payment_SUCCEEDED,
|
|
FullAmount: 600,
|
|
},
|
|
hash2: &PaymentEntry{
|
|
Status: lnrpc.Payment_SUCCEEDED,
|
|
FullAmount: 100,
|
|
},
|
|
})
|
|
|
|
// Delete the first payment and make sure it is removed from the
|
|
// account.
|
|
err = store.DeleteAccountPayment(ctx, acct.ID, hash1)
|
|
require.NoError(t, err)
|
|
|
|
assertBalanceAndPayments(400, AccountPayments{
|
|
hash2: &PaymentEntry{
|
|
Status: lnrpc.Payment_SUCCEEDED,
|
|
FullAmount: 100,
|
|
},
|
|
})
|
|
|
|
// Test that deleting a payment that does not exist returns an
|
|
// error.
|
|
err = store.DeleteAccountPayment(ctx, acct.ID, hash1)
|
|
require.ErrorIs(t, err, ErrPaymentNotAssociated)
|
|
|
|
// Try once more to insert a payment that is currently unknown
|
|
// but this time add the WithErrIfUnknown option. This should
|
|
// return the ErrPaymentNotAssociated error.
|
|
_, err = store.UpsertAccountPayment(
|
|
ctx, acct.ID, hash1, 600, lnrpc.Payment_SUCCEEDED,
|
|
WithErrIfUnknown(),
|
|
)
|
|
require.ErrorIs(t, err, ErrPaymentNotAssociated)
|
|
|
|
// Show that using the two options WithErrIfUnknown and
|
|
// WithPendingAmount together will return the
|
|
// ErrPaymentNotAssociated and will not successfully update
|
|
// the status. We call this for hash1 since it is no longer
|
|
// known. We do this to simulate the behaviour of
|
|
// removePayment.
|
|
_, err = store.UpsertAccountPayment(
|
|
ctx, acct.ID, hash1, 0, lnrpc.Payment_SUCCEEDED,
|
|
WithErrIfUnknown(),
|
|
WithPendingAmount(),
|
|
)
|
|
require.ErrorIs(t, err, ErrPaymentNotAssociated)
|
|
|
|
assertBalanceAndPayments(400, AccountPayments{
|
|
hash2: &PaymentEntry{
|
|
Status: lnrpc.Payment_SUCCEEDED,
|
|
FullAmount: 100,
|
|
},
|
|
})
|
|
|
|
// Now insert hash 1 again.
|
|
_, err = store.UpsertAccountPayment(
|
|
ctx, acct.ID, hash1, 600, lnrpc.Payment_IN_FLIGHT,
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
assertBalanceAndPayments(400, AccountPayments{
|
|
hash1: &PaymentEntry{
|
|
Status: lnrpc.Payment_IN_FLIGHT,
|
|
FullAmount: 600,
|
|
},
|
|
hash2: &PaymentEntry{
|
|
Status: lnrpc.Payment_SUCCEEDED,
|
|
FullAmount: 100,
|
|
},
|
|
})
|
|
|
|
// Once again call UpsertAccountPayment with both the
|
|
// WithErrIfUnknown and WithPendingAmount options. This time
|
|
// it should succeed since the payment is now known and so the
|
|
// status should be updated.
|
|
_, err = store.UpsertAccountPayment(
|
|
ctx, acct.ID, hash1, 0, lnrpc.Payment_SUCCEEDED,
|
|
WithErrIfUnknown(),
|
|
WithPendingAmount(),
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
assertBalanceAndPayments(400, AccountPayments{
|
|
hash1: &PaymentEntry{
|
|
Status: lnrpc.Payment_SUCCEEDED,
|
|
FullAmount: 600,
|
|
},
|
|
hash2: &PaymentEntry{
|
|
Status: lnrpc.Payment_SUCCEEDED,
|
|
FullAmount: 100,
|
|
},
|
|
})
|
|
})
|
|
}
|
|
|
|
// TestLastInvoiceIndexes makes sure the last known invoice indexes can be
|
|
// stored and retrieved correctly.
|
|
func TestLastInvoiceIndexes(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
|
|
store := NewTestDB(t, clock.NewTestClock(time.Now()))
|
|
|
|
_, _, err := store.LastIndexes(ctx)
|
|
require.ErrorIs(t, err, ErrNoInvoiceIndexKnown)
|
|
|
|
require.NoError(t, store.StoreLastIndexes(ctx, 7, 99))
|
|
|
|
add, settle, err := store.LastIndexes(ctx)
|
|
require.NoError(t, err)
|
|
require.EqualValues(t, 7, add)
|
|
require.EqualValues(t, 99, settle)
|
|
}
|
|
|
|
// TestCheckLabel ensures that only labels that could be mistaken for a hex
|
|
// encoded account ID are rejected, while all other labels (including the empty
|
|
// label) are accepted.
|
|
func TestCheckLabel(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
label string
|
|
expectErr bool
|
|
}{{
|
|
name: "empty label is allowed",
|
|
label: "",
|
|
}, {
|
|
name: "plain text label is allowed",
|
|
label: "my account",
|
|
}, {
|
|
name: "short hex label is allowed",
|
|
label: "00112233",
|
|
}, {
|
|
name: "non-hex label with account ID length is allowed",
|
|
// 16 characters long, matching an encoded account ID, but not
|
|
// valid hex.
|
|
label: "zzzzzzzzzzzzzzzz",
|
|
}, {
|
|
name: "lowercase hex label with account ID length is rejected",
|
|
// 16 characters of valid hex, exactly the length of an encoded
|
|
// account ID.
|
|
label: "0011223344556677",
|
|
expectErr: true,
|
|
}, {
|
|
name: "uppercase hex label with account ID length is rejected",
|
|
// hex.DecodeString also accepts uppercase digits, so this must
|
|
// be rejected as well.
|
|
label: "00112233445566AA",
|
|
expectErr: true,
|
|
}}
|
|
|
|
for _, tc := range tests {
|
|
tc := tc
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
err := checkLabel(tc.label)
|
|
if tc.expectErr {
|
|
require.ErrorContains(
|
|
t, err, "is not allowed as it can be "+
|
|
"mistaken",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestListAccountPayments tests listing and counting payment entries associated
|
|
// with a given account.
|
|
func TestListAccountPayments(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
|
|
store := NewTestDB(t, clock.NewTestClock(time.Now()))
|
|
|
|
// Listing payments for non-existent account should fail.
|
|
_, err := store.ListAccountPayments(
|
|
ctx, AccountID{}, 0, 0,
|
|
)
|
|
require.ErrorIs(t, err, ErrAccNotFound)
|
|
|
|
acct, err := store.NewAccount(
|
|
ctx, 10000, time.Time{}, "payment-list",
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
// Initially, there should be no payments.
|
|
payments, err := store.ListAccountPayments(
|
|
ctx, acct.ID, 0, 0,
|
|
)
|
|
require.NoError(t, err)
|
|
require.Empty(t, payments)
|
|
|
|
count, err := store.CountAccountPayments(ctx, acct.ID)
|
|
require.NoError(t, err)
|
|
require.Zero(t, count)
|
|
|
|
// Add 3 payments.
|
|
hash1 := lntypes.Hash{1}
|
|
hash2 := lntypes.Hash{2}
|
|
hash3 := lntypes.Hash{3}
|
|
|
|
_, err = store.UpsertAccountPayment(
|
|
ctx, acct.ID, hash1, 100, lnrpc.Payment_IN_FLIGHT,
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
_, err = store.UpsertAccountPayment(
|
|
ctx, acct.ID, hash2, 200, lnrpc.Payment_SUCCEEDED,
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
_, err = store.UpsertAccountPayment(
|
|
ctx, acct.ID, hash3, 300, lnrpc.Payment_FAILED,
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
// Test counting all payments.
|
|
count, err = store.CountAccountPayments(ctx, acct.ID)
|
|
require.NoError(t, err)
|
|
require.EqualValues(t, 3, count)
|
|
|
|
// List all payments in default order (ascending by hash).
|
|
payments, err = store.ListAccountPayments(
|
|
ctx, acct.ID, 0, 3,
|
|
)
|
|
require.NoError(t, err)
|
|
require.Len(t, payments, 3)
|
|
require.Equal(t, hash1, payments[0].Hash)
|
|
require.Equal(t, hash2, payments[1].Hash)
|
|
require.Equal(t, hash3, payments[2].Hash)
|
|
|
|
// Test offset and limit.
|
|
payments, err = store.ListAccountPayments(
|
|
ctx, acct.ID, 1, 1,
|
|
)
|
|
require.NoError(t, err)
|
|
require.Len(t, payments, 1)
|
|
require.Equal(t, hash2, payments[0].Hash)
|
|
}
|