accounts: optionally cap account balances at the node channel balance

Add an opt-in check that prevents the sum of all account balances from
exceeding the node's available local (outbound) channel balance. When
enabled via the new accounts.check-channel-balance config option, the
service rejects balance allocations that would over-provision the node:
creating an account, an administrative credit, or an administrative
balance increase now fails with ErrBalanceReservationExceeded if it
would push the total allocated balance above the node's local channel
balance.

Note that the total channel balance may still decrease below the already
allocated account balance. This can occur if the node operator decreases
the total channel balance through non-account related activity.

The check is a no-op by default to preserve the historical behaviour
where the operator manages over-provisioning themselves (accounts can
legitimately be created before channels are funded). Invoice-driven
credits are unaffected, as they are backed by real inbound payments.
This commit is contained in:
Viktor Torstensson 2026-08-03 14:21:03 -07:00
parent ae74392433
commit a493b6a3d6
No known key found for this signature in database
GPG key ID: 961CC8259AE675D4
5 changed files with 375 additions and 21 deletions

View file

@ -199,6 +199,14 @@ var (
ErrPaymentExceedsMaxSize = errors.New("payment amount exceeds the " +
"maximum allowed account payment size")
// ErrBalanceReservationExceeded is returned when the channel-balance
// check is enabled and an account balance allocation would push the sum
// of all account balances above the node's available local channel
// balance.
ErrBalanceReservationExceeded = errors.New("account balance " +
"allocation would exceed the node's available local channel " +
"balance")
// ErrNotSupportedWithAccounts is the error that is returned when an RPC
// is called that isn't supported to be handled by the account
// interceptor.

View file

@ -34,6 +34,22 @@ type Config struct {
// It defaults to 0, which disables the cap and preserves the historical
// behaviour of allowing payments up to the full account balance.
MaxPaymentSizeMsat uint64 `long:"max-payment-size-msat" description:"the maximum total amount in millisatoshis, including fees, that a single account payment may debit; 0 (the default value) disables the cap"`
// CheckChannelBalance, when set, makes the accounts service reject
// account balance allocations (new accounts, administrative credits and
// administrative balance increases) that would push the sum of all
// account balances above the node's available total local (outbound)
// channel balance.
//
// It defaults to false to preserve the historical behaviour, where the
// operator is trusted to manage over-provisioning themselves; accounts
// can legitimately be created before channels are opened or funded.
//
// NOTE: The total channel balance may still decrease below the already
// allocated account balance. This can occur if the node operator
// decreases the total channel balance through non-account related
// activity.
CheckChannelBalance bool `long:"check-channel-balance" description:"reject account balance allocations that would push the sum of all account balances above the node's available local channel balance. Note that the total channel balance can still decrease below the already allocated account balance. This can occur if the node operator decreases the total channel balance through non-account related activity."`
}
// trackedPayment is a struct that holds all information that identifies a
@ -66,7 +82,13 @@ type InterceptorService struct {
store Store
routerClient lndclient.RouterClient
routerClient lndclient.RouterClient
lightningClient lndclient.LightningClient
// checkChannelBalance, when set, makes the service reject account
// balance allocations that would exceed the node's available local
// channel balance. See Config.CheckChannelBalance.
checkChannelBalance bool
// maxPaymentSize, when greater than zero, is the maximum value that a
// single account payment may have. See Config.MaxPaymentSizeMsat.
@ -105,6 +127,15 @@ func WithMaxPaymentSize(maxPaymentSize lnwire.MilliSatoshi) ServiceOption {
}
}
// WithChannelBalanceCheck enables validation that the sum of all account
// balances never exceeds the node's available local channel balance when
// allocating account balances (new accounts, credits and balance increases).
func WithChannelBalanceCheck() ServiceOption {
return func(s *InterceptorService) {
s.checkChannelBalance = true
}
}
// NewService returns a service backed by the macaroon Bolt DB stored in the
// passed-in directory.
func NewService(store Store, errCallback func(error),
@ -137,6 +168,7 @@ func (s *InterceptorService) Start(ctx context.Context,
s.contextCancel = fn.Some(contextCancel)
s.routerClient = routerClient
s.lightningClient = lightningClient
s.checkers = NewAccountChecker(s, params, s.maxPaymentSize)
s.isEnabled = true
@ -325,9 +357,22 @@ func (s *InterceptorService) NewAccount(ctx context.Context,
expirationDate time.Time, label string) (*OffChainBalanceAccount,
error) {
availableChannelBalance, err := s.fetchChannelBalance(ctx)
if err != nil {
return nil, err
}
s.Lock()
defer s.Unlock()
// Make sure allocating this account's balance doesn't over-provision
// the node's available local channel balance (no-op unless enabled).
if err := s.checkChannelBalanceReservationUnsafe(
ctx, balance, availableChannelBalance,
); err != nil {
return nil, err
}
return s.store.NewAccount(ctx, balance, expirationDate, label)
}
@ -338,6 +383,29 @@ func (s *InterceptorService) UpdateAccount(ctx context.Context,
expirationDate int64, newLabel string) (*OffChainBalanceAccount,
error) {
// Convert the requested account balance to millisatoshis. An empty
// option signals that the stored balance should not be updated.
var (
balance fn.Option[int64]
availableChannelBalance lnwire.MilliSatoshi
err error
)
if accountBalance >= 0 {
// If the new account balance was set, parse it as
// millisatoshis. A value of -1 signals "don't update the
// balance".
balance = fn.Some(int64(
// Convert from satoshis to millisatoshis for storage.
lnwire.NewMSatFromSatoshis(accountBalance),
))
availableChannelBalance, err = s.fetchChannelBalance(ctx)
if err != nil {
return nil, err
}
}
s.Lock()
defer s.Unlock()
@ -361,14 +429,6 @@ func (s *InterceptorService) UpdateAccount(ctx context.Context,
expiry = fn.Some(time.Time{})
}
// If the new account balance was set, parse it as millisatoshis. A
// value of -1 signals "don't update the balance".
var balance fn.Option[int64]
if accountBalance >= 0 {
// Convert from satoshis to millisatoshis for storage.
balance = fn.Some(int64(accountBalance) * 1000)
}
// If a new label was provided, wrap it in an option. An empty
// string is treated as "no update requested" because protobuf
// cannot distinguish an absent field from the zero value "".
@ -377,10 +437,32 @@ func (s *InterceptorService) UpdateAccount(ctx context.Context,
label = fn.Some(newLabel)
}
// If the balance is being increased, make sure the increase doesn't
// over-provision the node's available local channel balance (no-op
// unless the `checkChannelBalance` option is enabled).
if balance.IsSome() && s.checkChannelBalance {
acct, err := s.store.Account(ctx, accountID)
if err != nil {
return nil, fmt.Errorf("fetching account: %w", err)
}
newBalanceMsat := balance.UnwrapOr(0)
requestedBalanceIncrease := newBalanceMsat - acct.CurrentBalance
if requestedBalanceIncrease > 0 {
err := s.checkChannelBalanceReservationUnsafe(
ctx,
lnwire.MilliSatoshi(requestedBalanceIncrease),
availableChannelBalance,
)
if err != nil {
return nil, err
}
}
}
// Create the actual account in the macaroon account store.
err := s.store.UpdateAccount(
ctx, accountID, balance, expiry, label,
)
err = s.store.UpdateAccount(ctx, accountID, balance, expiry, label)
if err != nil {
return nil, fmt.Errorf("unable to update account: %w", err)
}
@ -393,6 +475,11 @@ func (s *InterceptorService) CreditAccount(ctx context.Context,
accountID AccountID,
amount lnwire.MilliSatoshi) (*OffChainBalanceAccount, error) {
availableChannelBalance, err := s.fetchChannelBalance(ctx)
if err != nil {
return nil, err
}
s.Lock()
defer s.Unlock()
@ -404,8 +491,16 @@ func (s *InterceptorService) CreditAccount(ctx context.Context,
return nil, ErrAccountServiceDisabled
}
// Make sure crediting this amount doesn't over-provision the node's
// available local channel balance (no-op unless enabled).
if err := s.checkChannelBalanceReservationUnsafe(
ctx, amount, availableChannelBalance,
); err != nil {
return nil, err
}
// Credit the account in the DB.
err := s.store.CreditAccount(ctx, accountID, amount)
err = s.store.CreditAccount(ctx, accountID, amount)
if err != nil {
return nil, fmt.Errorf("unable to credit account: %w", err)
}
@ -502,13 +597,82 @@ func (s *InterceptorService) CheckBalance(ctx context.Context, id AccountID,
}
availableAmount := calcAvailableAccountBalance(account)
if availableAmount < int64(requiredBalance) {
// Ensure that the availableAmount is greater than the requiredBalance.
if availableAmount < 0 ||
lnwire.MilliSatoshi(availableAmount) < requiredBalance {
return ErrAccBalanceInsufficient
}
return nil
}
// fetchChannelBalance fetches the node's available local channel balance. It
// returns zero without querying the Lightning client if the
// `checkChannelBalance` option is not enabled.
//
// This network call must happen before acquiring the service lock so an
// unavailable Lightning client doesn't block unrelated account operations.
func (s *InterceptorService) fetchChannelBalance(ctx context.Context) (
lnwire.MilliSatoshi, error) {
if !s.checkChannelBalance {
return 0, nil
}
if s.lightningClient == nil {
return 0, errors.New("cannot check channel balance: " +
"lightning client is not available")
}
chanBalance, err := s.lightningClient.ChannelBalance(ctx)
if err != nil {
return 0, fmt.Errorf("error querying channel balance: %w", err)
}
return lnwire.NewMSatFromSatoshis(chanBalance.Balance), nil
}
// checkChannelBalanceReservationUnsafe ensures that increasing the total
// allocated account balance by the requested balance increase would not push
// the sum of all account balances above the node's available local (outbound)
// channel balance.
//
// The function is a no-op if the `checkChannelBalance` config flag isn't set.
//
// NOTE: the service lock MUST be held when calling this method.
func (s *InterceptorService) checkChannelBalanceReservationUnsafe(
ctx context.Context, requestedBalanceIncrease lnwire.MilliSatoshi,
availableChannelBalance lnwire.MilliSatoshi) error {
// If the `checkChannelBalance` config flag isn't set, the function is a
// no-op.
if !s.checkChannelBalance {
return nil
}
accounts, err := s.store.Accounts(ctx)
if err != nil {
return fmt.Errorf("error listing accounts: %w", err)
}
// Calculate the total account balance after applying the requested
// allocation, then ensure the node's local balance can cover it.
var allocated int64
for _, acct := range accounts {
allocated += acct.CurrentBalance
}
if allocated+int64(requestedBalanceIncrease) >
int64(availableChannelBalance) {
return ErrBalanceReservationExceeded
}
return nil
}
func calcAvailableAccountBalance(account *OffChainBalanceAccount) int64 {
var inFlightAmt int64
for _, payment := range account.Payments {
@ -688,15 +852,12 @@ func (s *InterceptorService) TrackPayment(ctx context.Context, id AccountID,
// option to ensure that we return an error if the payment has already
// succeeded. We can then match on the ErrAlreadySucceeded error and
// exit early if it is returned.
//
// Additionally, we ensure that the account's pending amount is
// preserved while the payment is in-flight.
opts := []UpsertPaymentOption{
WithErrIfAlreadySucceeded(),
}
// There is a case where the passed in fullAmt is zero but the pending
// amount is not. In that case, we should not overwrite the pending
// amount.
if fullAmt == 0 {
opts = append(opts, WithPendingAmount())
WithPendingAmount(),
}
known, err := s.store.UpsertAccountPayment(

View file

@ -6,6 +6,7 @@ import (
"testing"
"time"
"github.com/btcsuite/btcd/btcutil"
"github.com/lightninglabs/lndclient"
"github.com/lightningnetwork/lnd/clock"
invpkg "github.com/lightningnetwork/lnd/invoices"
@ -35,6 +36,29 @@ type mockLnd struct {
invoiceSubscriptionErr error
invoiceErrChan chan error
invoiceChan chan *lndclient.Invoice
// channelBalance is the local channel balance reported by the mocked
// ChannelBalance call.
channelBalance btcutil.Amount
channelBalanceCalled chan struct{}
channelBalanceRelease chan struct{}
}
// ChannelBalance returns the mocked local channel balance.
func (m *mockLnd) ChannelBalance(_ context.Context) (*lndclient.ChannelBalance,
error) {
if m.channelBalanceCalled != nil {
m.channelBalanceCalled <- struct{}{}
}
if m.channelBalanceRelease != nil {
<-m.channelBalanceRelease
}
return &lndclient.ChannelBalance{
Balance: m.channelBalance,
}, nil
}
func newMockLnd() *mockLnd {
@ -876,3 +900,146 @@ func TestAccountService(t *testing.T) {
func assertEventually(t *testing.T, predicate func() bool) {
require.Eventually(t, predicate, testTimeout, testInterval)
}
// TestChannelBalanceReservationCheck tests that, when the channel-balance check
// is enabled, the accounts service rejects balance allocations that would push
// the sum of all account balances above the node's available local channel
// balance (issue #495).
func TestChannelBalanceReservationCheck(t *testing.T) {
t.Parallel()
ctx := context.Background()
lndMock := newMockLnd()
// The node has a 10 sat (10_000 msat) local channel balance.
lndMock.channelBalance = 10
routerMock := newMockRouter()
errFunc := func(err error) {
lndMock.mainErrChan <- err
}
store := NewTestDB(t, clock.NewTestClock(time.Now()))
service, err := NewService(store, errFunc, WithChannelBalanceCheck())
require.NoError(t, err)
err = service.Start(ctx, lndMock, routerMock, chainParams)
require.NoError(t, err)
t.Cleanup(func() {
_ = service.Stop()
})
// Allocating an account within the node's local balance works.
acct, err := service.NewAccount(ctx, 6000, testExpiration, "a")
require.NoError(t, err)
// A second account that would push the total allocation over the
// available balance is rejected.
_, err = service.NewAccount(ctx, 5000, testExpiration, "b")
require.ErrorIs(t, err, ErrBalanceReservationExceeded)
// Crediting the existing account beyond the limit is rejected too.
_, err = service.CreditAccount(ctx, acct.ID, 5000)
require.ErrorIs(t, err, ErrBalanceReservationExceeded)
// But a credit that stays within the limit succeeds.
updated, err := service.CreditAccount(ctx, acct.ID, 3000)
require.NoError(t, err)
require.EqualValues(t, 9000, updated.CurrentBalance)
// Increasing the account balance directly is subject to the same check.
_, err = service.UpdateAccount(ctx, acct.ID, 11, -1, "")
require.ErrorIs(t, err, ErrBalanceReservationExceeded)
updated, err = service.UpdateAccount(ctx, acct.ID, 10, -1, "")
require.NoError(t, err)
require.EqualValues(t, 10_000, updated.CurrentBalance)
// With the check disabled (the default), over-provisioning is allowed.
plainService, err := NewService(store, errFunc)
require.NoError(t, err)
err = plainService.Start(ctx, lndMock, routerMock, chainParams)
require.NoError(t, err)
t.Cleanup(func() {
_ = plainService.Stop()
})
_, err = plainService.NewAccount(ctx, 999999, testExpiration, "c")
require.NoError(t, err)
}
// TestChannelBalanceReservationRequiresLnd tests that the optional channel
// balance check fails closed if the service has no Lightning client.
func TestChannelBalanceReservationRequiresLnd(t *testing.T) {
t.Parallel()
store := NewTestDB(t, clock.NewTestClock(time.Now()))
service, err := NewService(
store, func(error) {}, WithChannelBalanceCheck(),
)
require.NoError(t, err)
_, err = service.NewAccount(
context.Background(), 1000, testExpiration, "account",
)
require.ErrorContains(t, err, "lightning client is not available")
}
// TestChannelBalanceRequestDoesNotHoldLock tests that a slow ChannelBalance
// RPC does not block unrelated account operations.
func TestChannelBalanceRequestDoesNotHoldLock(t *testing.T) {
t.Parallel()
ctx := context.Background()
lndMock := newMockLnd()
lndMock.channelBalance = 10
lndMock.channelBalanceCalled = make(chan struct{}, 1)
lndMock.channelBalanceRelease = make(chan struct{})
store := NewTestDB(t, clock.NewTestClock(time.Now()))
service, err := NewService(
store, func(error) {}, WithChannelBalanceCheck(),
)
require.NoError(t, err)
require.NoError(t, service.Start(
ctx, lndMock, newMockRouter(), chainParams,
))
t.Cleanup(func() {
_ = service.Stop()
})
accountErr := make(chan error, 1)
go func() {
_, err := service.NewAccount(
ctx, 1000, testExpiration, "account",
)
accountErr <- err
}()
select {
case <-lndMock.channelBalanceCalled:
case <-time.After(testTimeout):
t.Fatal("ChannelBalance was not called")
}
type accountsResult struct {
accounts []*OffChainBalanceAccount
err error
}
resultChan := make(chan accountsResult, 1)
go func() {
accounts, err := service.Accounts(ctx)
resultChan <- accountsResult{accounts: accounts, err: err}
}()
select {
case result := <-resultChan:
require.NoError(t, result.err)
require.Empty(t, result.accounts)
case <-time.After(testTimeout):
close(lndMock.channelBalanceRelease)
t.Fatal("account read blocked by ChannelBalance")
}
close(lndMock.channelBalanceRelease)
require.NoError(t, <-accountErr)
}

View file

@ -73,6 +73,17 @@
per-account spending controls tracked in the issue (e.g. per-interval spend
limits).
* [Optionally cap total account balances at the node's channel
balance](https://github.com/lightninglabs/lightning-terminal/pull/1369):
Addresses
[#495](https://github.com/lightninglabs/lightning-terminal/issues/495).
Added an opt-in `accounts.check-channel-balance` config option. When enabled,
the accounts service rejects balance allocations (new accounts, administrative
credits and administrative balance increases) that would push the sum of all
account balances above the node's available local (outbound) channel balance,
helping operators avoid over-provisioning custodial accounts beyond what the
node can actually pay out. It defaults to off to preserve existing behaviour.
### Technical and Architectural Updates
* [Report litd's own version for `litd

View file

@ -793,6 +793,13 @@ func (g *LightningTerminal) start(ctx context.Context) error {
),
)
}
if g.cfg.Accounts.CheckChannelBalance {
accountsOpts = append(
accountsOpts, accounts.WithChannelBalanceCheck(),
)
}
g.accountService, err = accounts.NewService(
g.stores.accounts, accountServiceErrCallback, accountsOpts...,
)