mirror of
https://github.com/lightninglabs/lightning-terminal.git
synced 2026-08-13 12:33:36 +02:00
Merge pull request #1369 from ViktorT-11/2026-08-configurable-spending-controls
Some checks failed
CI / frontend tests on macOS-latest (push) Has been cancelled
CI / frontend tests on ubuntu-latest (push) Has been cancelled
CI / frontend tests on windows-latest (push) Has been cancelled
CI / backend build on macOS-latest (push) Has been cancelled
CI / backend build on ubuntu-latest (push) Has been cancelled
CI / backend build on windows-latest (push) Has been cancelled
CI / cross compilation (push) Has been cancelled
CI / cross compilation-1 (push) Has been cancelled
CI / cross compilation-2 (push) Has been cancelled
CI / RPC proto compilation check (push) Has been cancelled
CI / check commits (push) Has been cancelled
CI / Sqlc check (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / run unit tests (push) Has been cancelled
CI / run unit tests-1 (push) Has been cancelled
CI / run unit tests-2 (push) Has been cancelled
CI / run unit tests-3 (push) Has been cancelled
CI / build itest binaries (push) Has been cancelled
CI / check release notes updated (push) Has been cancelled
CI / integration test (push) Has been cancelled
CI / integration test-1 (push) Has been cancelled
CI / integration test-2 (push) Has been cancelled
Some checks failed
CI / frontend tests on macOS-latest (push) Has been cancelled
CI / frontend tests on ubuntu-latest (push) Has been cancelled
CI / frontend tests on windows-latest (push) Has been cancelled
CI / backend build on macOS-latest (push) Has been cancelled
CI / backend build on ubuntu-latest (push) Has been cancelled
CI / backend build on windows-latest (push) Has been cancelled
CI / cross compilation (push) Has been cancelled
CI / cross compilation-1 (push) Has been cancelled
CI / cross compilation-2 (push) Has been cancelled
CI / RPC proto compilation check (push) Has been cancelled
CI / check commits (push) Has been cancelled
CI / Sqlc check (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / run unit tests (push) Has been cancelled
CI / run unit tests-1 (push) Has been cancelled
CI / run unit tests-2 (push) Has been cancelled
CI / run unit tests-3 (push) Has been cancelled
CI / build itest binaries (push) Has been cancelled
CI / check release notes updated (push) Has been cancelled
CI / integration test (push) Has been cancelled
CI / integration test-1 (push) Has been cancelled
CI / integration test-2 (push) Has been cancelled
accounts: add configurable account spending controls
This commit is contained in:
commit
23433a05ed
8 changed files with 623 additions and 40 deletions
|
|
@ -3,6 +3,7 @@ package accounts
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/bits"
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
"github.com/btcsuite/btcd/chaincfg"
|
||||
|
|
@ -79,8 +80,10 @@ type AccountChecker struct {
|
|||
|
||||
// NewAccountChecker creates a new account checker that can keep track of all
|
||||
// account related requests, including invoices, payments and account balances.
|
||||
func NewAccountChecker(service Service,
|
||||
chainParams *chaincfg.Params) *AccountChecker {
|
||||
// If maxPaymentSize is greater than zero, payments whose amount exceeds it are
|
||||
// rejected.
|
||||
func NewAccountChecker(service Service, chainParams *chaincfg.Params,
|
||||
maxPaymentSize lnwire.MilliSatoshi) *AccountChecker {
|
||||
|
||||
// nolint:ll
|
||||
checkers := CheckerMap{
|
||||
|
|
@ -162,9 +165,9 @@ func NewAccountChecker(service Service,
|
|||
}
|
||||
|
||||
return checkSend(
|
||||
ctx, chainParams, service, r.Amt,
|
||||
r.AmtMsat, r.PaymentRequest,
|
||||
r.PaymentHash,
|
||||
ctx, chainParams, service,
|
||||
maxPaymentSize, r.Amt, r.AmtMsat,
|
||||
r.PaymentRequest, r.PaymentHash,
|
||||
&lnrpc.FeeLimit{
|
||||
Limit: &lnrpc.FeeLimit_FixedMsat{
|
||||
FixedMsat: feeLimitMsat,
|
||||
|
|
@ -196,7 +199,8 @@ func NewAccountChecker(service Service,
|
|||
r *routerrpc.SendToRouteRequest) error {
|
||||
|
||||
return checkSendToRoute(
|
||||
ctx, service, r.PaymentHash, r.Route,
|
||||
ctx, service, maxPaymentSize,
|
||||
r.PaymentHash, r.Route,
|
||||
)
|
||||
},
|
||||
sendToRouteHTLCResponseHandler(service),
|
||||
|
|
@ -465,11 +469,22 @@ func filterPayments(ctx context.Context,
|
|||
return filteredPayments, nil
|
||||
}
|
||||
|
||||
// checkMaxPaymentSize returns an error if a maximum account payment size is
|
||||
// configured (non-zero) and the given total payment amount exceeds it.
|
||||
func checkMaxPaymentSize(maxPaymentSize, amt lnwire.MilliSatoshi) error {
|
||||
if maxPaymentSize != 0 && amt > maxPaymentSize {
|
||||
return fmt.Errorf("%w: amount %v exceeds the maximum of %v",
|
||||
ErrPaymentExceedsMaxSize, amt, maxPaymentSize)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkSend checks if a payment can be initiated by making sure the account in
|
||||
// the context has enough balance to pay for it.
|
||||
func checkSend(ctx context.Context, chainParams *chaincfg.Params,
|
||||
service Service, amt, amtMsat int64, invoice string,
|
||||
paymentHash []byte, feeLimit *lnrpc.FeeLimit) error {
|
||||
service Service, maxPaymentSize lnwire.MilliSatoshi, amt, amtMsat int64,
|
||||
invoice string, paymentHash []byte, feeLimit *lnrpc.FeeLimit) error {
|
||||
|
||||
log, acct, reqID, err := requestScopedValuesFromCtx(ctx)
|
||||
if err != nil {
|
||||
|
|
@ -540,7 +555,19 @@ func checkSend(ctx context.Context, chainParams *chaincfg.Params,
|
|||
limit = &lnrpc.FeeLimit{}
|
||||
}
|
||||
fee := lnrpc.CalculateFeeLimit(limit, sendAmt)
|
||||
sendAmt += fee
|
||||
|
||||
// Add the fee explicitly and reject amounts that cannot be represented.
|
||||
total, carry := bits.Add64(uint64(sendAmt), uint64(fee), 0)
|
||||
if carry != 0 {
|
||||
return ErrAccBalanceInsufficient
|
||||
}
|
||||
sendAmt = lnwire.MilliSatoshi(total)
|
||||
|
||||
// Enforce the configured maximum payment size on the full amount that
|
||||
// may be debited from the account.
|
||||
if err := checkMaxPaymentSize(maxPaymentSize, sendAmt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = service.CheckBalance(ctx, acct.ID, sendAmt)
|
||||
if err != nil {
|
||||
|
|
@ -607,7 +634,8 @@ func checkSendResponse(ctx context.Context, service Service,
|
|||
|
||||
// checkSendToRoute checks if a payment can be sent to the route by making sure
|
||||
// the account in the context has enough balance to pay for it.
|
||||
func checkSendToRoute(ctx context.Context, service Service, paymentHash []byte,
|
||||
func checkSendToRoute(ctx context.Context, service Service,
|
||||
maxPaymentSize lnwire.MilliSatoshi, paymentHash []byte,
|
||||
route *lnrpc.Route) error {
|
||||
|
||||
log, acct, reqID, err := requestScopedValuesFromCtx(ctx)
|
||||
|
|
@ -640,7 +668,19 @@ func checkSendToRoute(ctx context.Context, service Service, paymentHash []byte,
|
|||
if lnwire.MilliSatoshi(route.TotalFeesMsat) > fee {
|
||||
fee = lnwire.MilliSatoshi(route.TotalFeesMsat)
|
||||
}
|
||||
sendAmt += fee
|
||||
|
||||
// Add the fee explicitly and reject amounts that cannot be represented.
|
||||
total, carry := bits.Add64(uint64(sendAmt), uint64(fee), 0)
|
||||
if carry != 0 {
|
||||
return ErrAccBalanceInsufficient
|
||||
}
|
||||
sendAmt = lnwire.MilliSatoshi(total)
|
||||
|
||||
// Enforce the configured maximum payment size on the full amount that
|
||||
// may be debited from the account.
|
||||
if err := checkMaxPaymentSize(maxPaymentSize, sendAmt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = service.CheckBalance(ctx, acct.ID, sendAmt)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ var _ Service = (*mockService)(nil)
|
|||
func TestAccountChecker(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
checker := NewAccountChecker(nil, nil)
|
||||
checker := NewAccountChecker(nil, nil, 0)
|
||||
for checkerName := range checker.checkers {
|
||||
t.Logf("Checker registered: %v", checkerName)
|
||||
}
|
||||
|
|
@ -442,7 +442,7 @@ func TestAccountCheckers(t *testing.T) {
|
|||
tt.Parallel()
|
||||
|
||||
service := newMockService()
|
||||
checkers := NewAccountChecker(service, chainParams)
|
||||
checkers := NewAccountChecker(service, chainParams, 0)
|
||||
acct := &OffChainBalanceAccount{
|
||||
ID: testID,
|
||||
Type: TypeInitialBalance,
|
||||
|
|
@ -923,3 +923,109 @@ func assertMessagesEqual(t *testing.T, expected, actual proto.Message) {
|
|||
|
||||
require.Equal(t, string(expectedJSON), string(actualJSON))
|
||||
}
|
||||
|
||||
// TestSendPaymentV2MaxPaymentSize tests that, when a maximum account payment
|
||||
// size is configured, the SendPaymentV2 checker rejects payments whose total
|
||||
// amount, including the fee limit, exceeds the cap (issue #583).
|
||||
func TestSendPaymentV2MaxPaymentSize(t *testing.T) {
|
||||
var (
|
||||
uri = "/routerrpc.Router/SendPaymentV2"
|
||||
ctx = context.Background()
|
||||
requestID uint64
|
||||
)
|
||||
|
||||
nextRequestID := func() uint64 {
|
||||
requestID++
|
||||
|
||||
return requestID
|
||||
}
|
||||
|
||||
lndMock := newMockLnd()
|
||||
routerMock := newMockRouter()
|
||||
errFunc := func(err error) {
|
||||
lndMock.mainErrChan <- err
|
||||
}
|
||||
clk := clock.NewTestClock(time.Now())
|
||||
store := NewTestDB(t, clk)
|
||||
service, err := NewService(store, errFunc, WithMaxPaymentSize(2000))
|
||||
require.NoError(t, err)
|
||||
|
||||
err = service.Start(ctx, lndMock, routerMock, chainParams)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = service.Stop()
|
||||
})
|
||||
|
||||
acct, err := service.NewAccount(
|
||||
ctx, 1_000_000, clk.Now().Add(time.Hour), "max",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctxWithAcct := AddAccountToContext(ctx, acct)
|
||||
|
||||
// A payment whose total amount is exactly at the cap is allowed.
|
||||
ctx1 := AddRequestIDToContext(ctxWithAcct, nextRequestID())
|
||||
err = service.checkers.checkIncomingRequest(
|
||||
ctx1, uri, &routerrpc.SendPaymentRequest{
|
||||
AmtMsat: 2000,
|
||||
PaymentHash: testHash[:],
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
// A payment whose amount exceeds the cap is rejected.
|
||||
ctx2 := AddRequestIDToContext(ctxWithAcct, nextRequestID())
|
||||
err = service.checkers.checkIncomingRequest(
|
||||
ctx2, uri, &routerrpc.SendPaymentRequest{
|
||||
AmtMsat: 2001,
|
||||
PaymentHash: testHash2[:],
|
||||
},
|
||||
)
|
||||
require.ErrorIs(t, err, ErrPaymentExceedsMaxSize)
|
||||
|
||||
// A fee limit that brings an otherwise valid payment over the cap is
|
||||
// rejected as well.
|
||||
ctx3 := AddRequestIDToContext(ctxWithAcct, nextRequestID())
|
||||
err = service.checkers.checkIncomingRequest(
|
||||
ctx3, uri, &routerrpc.SendPaymentRequest{
|
||||
AmtMsat: 1900,
|
||||
FeeLimitMsat: 101,
|
||||
PaymentHash: testHash3[:],
|
||||
},
|
||||
)
|
||||
require.ErrorIs(t, err, ErrPaymentExceedsMaxSize)
|
||||
}
|
||||
|
||||
func TestSendToRouteV2MaxPaymentSize(t *testing.T) {
|
||||
const uri = "/routerrpc.Router/SendToRouteV2"
|
||||
|
||||
ctx := context.Background()
|
||||
lndMock := newMockLnd()
|
||||
routerMock := newMockRouter()
|
||||
service, err := NewService(
|
||||
NewTestDB(t, clock.NewTestClock(time.Now())),
|
||||
func(err error) { lndMock.mainErrChan <- err },
|
||||
WithMaxPaymentSize(2000),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
err = service.Start(ctx, lndMock, routerMock, chainParams)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = service.Stop() })
|
||||
|
||||
acct, err := service.NewAccount(
|
||||
ctx, 1_000_000, time.Now().Add(time.Hour), "max",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx = AddRequestIDToContext(AddAccountToContext(ctx, acct), 1)
|
||||
err = service.checkers.checkIncomingRequest(
|
||||
ctx, uri, &routerrpc.SendToRouteRequest{
|
||||
PaymentHash: testHash[:],
|
||||
Route: &lnrpc.Route{
|
||||
TotalAmtMsat: 1900,
|
||||
TotalFeesMsat: 101,
|
||||
},
|
||||
},
|
||||
)
|
||||
require.ErrorIs(t, err, ErrPaymentExceedsMaxSize)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -194,6 +194,19 @@ var (
|
|||
// account
|
||||
ErrAccBalanceInsufficient = errors.New("account balance insufficient")
|
||||
|
||||
// ErrPaymentExceedsMaxSize is returned when a maximum account payment
|
||||
// size is configured and a payment's total amount exceeds it.
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -19,9 +19,37 @@ import (
|
|||
)
|
||||
|
||||
// Config holds the configuration options for the accounts service.
|
||||
//
|
||||
//nolint:ll
|
||||
type Config struct {
|
||||
// Disable will disable the accounts service if set.
|
||||
Disable bool `long:"disable" description:"disable the accounts service"`
|
||||
|
||||
// MaxPaymentSizeMsat, when greater than zero, is the maximum value (in
|
||||
// millisatoshis) that a single account payment may have. Payments whose
|
||||
// amount exceeds this cap are rejected by the account interceptor. This
|
||||
// provides a guard rail against a compromised or misbehaving account
|
||||
// macaroon draining its balance in a single large payment.
|
||||
//
|
||||
// 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
|
||||
|
|
@ -54,7 +82,17 @@ 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.
|
||||
maxPaymentSize lnwire.MilliSatoshi
|
||||
|
||||
mainCtx context.Context
|
||||
contextCancel fn.Option[context.CancelFunc]
|
||||
|
|
@ -77,12 +115,33 @@ type InterceptorService struct {
|
|||
isEnabled bool
|
||||
}
|
||||
|
||||
// ServiceOption is a functional option that can be used to modify the behaviour
|
||||
// of the InterceptorService.
|
||||
type ServiceOption func(*InterceptorService)
|
||||
|
||||
// WithMaxPaymentSize sets the maximum value that a single account payment may
|
||||
// have. A value of zero (the default) disables the cap.
|
||||
func WithMaxPaymentSize(maxPaymentSize lnwire.MilliSatoshi) ServiceOption {
|
||||
return func(s *InterceptorService) {
|
||||
s.maxPaymentSize = maxPaymentSize
|
||||
}
|
||||
}
|
||||
|
||||
// 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)) (*InterceptorService,
|
||||
error) {
|
||||
func NewService(store Store, errCallback func(error),
|
||||
opts ...ServiceOption) (*InterceptorService, error) {
|
||||
|
||||
return &InterceptorService{
|
||||
s := &InterceptorService{
|
||||
store: store,
|
||||
invoiceToAccount: make(map[lntypes.Hash]AccountID),
|
||||
pendingPayments: make(map[lntypes.Hash]*trackedPayment),
|
||||
|
|
@ -90,7 +149,13 @@ func NewService(store Store, errCallback func(error)) (*InterceptorService,
|
|||
mainErrCallback: errCallback,
|
||||
quit: make(chan struct{}),
|
||||
isEnabled: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Start starts the account service and its interceptor capability.
|
||||
|
|
@ -103,7 +168,8 @@ func (s *InterceptorService) Start(ctx context.Context,
|
|||
s.contextCancel = fn.Some(contextCancel)
|
||||
|
||||
s.routerClient = routerClient
|
||||
s.checkers = NewAccountChecker(s, params)
|
||||
s.lightningClient = lightningClient
|
||||
s.checkers = NewAccountChecker(s, params, s.maxPaymentSize)
|
||||
|
||||
s.isEnabled = true
|
||||
|
||||
|
|
@ -291,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)
|
||||
}
|
||||
|
||||
|
|
@ -304,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()
|
||||
|
||||
|
|
@ -327,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 "".
|
||||
|
|
@ -343,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)
|
||||
}
|
||||
|
|
@ -359,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()
|
||||
|
||||
|
|
@ -370,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)
|
||||
}
|
||||
|
|
@ -468,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 {
|
||||
|
|
@ -654,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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,25 @@ enforces the following rules on the RPC interface:
|
|||
mapped invoice is paid, the amount is credited to that account's virtual
|
||||
balance.
|
||||
|
||||
## Operator controls
|
||||
|
||||
LiT provides optional account safeguards through its configuration:
|
||||
|
||||
* `accounts.max-payment-size-msat` limits the total amount a single account
|
||||
payment may debit, including fees. For `SendPaymentV2`, the configured fee
|
||||
limit is included; for `SendToRouteV2`, the route's stated fee is included.
|
||||
The default value of `0` disables this limit.
|
||||
* `accounts.check-channel-balance` rejects new accounts, administrative
|
||||
credits and administrative balance increases that would make the sum of all
|
||||
account balances exceed the node's current local channel balance. It is
|
||||
disabled by default. When enabled, allocations fail if LiT cannot query the
|
||||
Lightning client for the channel balance.
|
||||
|
||||
Note: The channel-balance check only applies when a balance is allocated. It
|
||||
cannot prevent the node's local balance from subsequently falling below the
|
||||
allocated account balance if the operator sends non-account Lightning
|
||||
payments or other non-account activity.
|
||||
|
||||
## Use cases
|
||||
|
||||
The following (definitely non-exhaustive) list of use cases is made possible by
|
||||
|
|
|
|||
|
|
@ -59,6 +59,31 @@
|
|||
sub-servers on startup. If the macaroon already exists but has different
|
||||
permissions, it will be automatically regenerated.
|
||||
|
||||
* [Add a configurable maximum account payment
|
||||
size](https://github.com/lightninglabs/lightning-terminal/pull/1369):
|
||||
Addresses [
|
||||
#583](https://github.com/lightninglabs/lightning-terminal/issues/583).
|
||||
Added an `accounts.max-payment-size-msat` config option. When set to a
|
||||
non-zero value, the account interceptor rejects any single account payment
|
||||
(`SendPaymentV2`/`SendToRouteV2`) whose total amount, including fees,
|
||||
exceeds the configured cap,
|
||||
providing a guard rail against a compromised or misbehaving account macaroon
|
||||
draining its balance in one large payment. It defaults to 0 (no cap),
|
||||
preserving existing behaviour. This is a first step towards the finer-grained
|
||||
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
|
||||
|
|
|
|||
20
terminal.go
20
terminal.go
|
|
@ -58,6 +58,7 @@ import (
|
|||
"github.com/lightningnetwork/lnd/lnwallet"
|
||||
"github.com/lightningnetwork/lnd/lnwallet/btcwallet"
|
||||
"github.com/lightningnetwork/lnd/lnwallet/chancloser"
|
||||
"github.com/lightningnetwork/lnd/lnwire"
|
||||
"github.com/lightningnetwork/lnd/macaroons"
|
||||
"github.com/lightningnetwork/lnd/msgmux"
|
||||
"github.com/lightningnetwork/lnd/rpcperms"
|
||||
|
|
@ -782,8 +783,25 @@ func (g *LightningTerminal) start(ctx context.Context) error {
|
|||
return fmt.Errorf("could not start firewall DB: %v", err)
|
||||
}
|
||||
|
||||
var accountsOpts []accounts.ServiceOption
|
||||
if g.cfg.Accounts.MaxPaymentSizeMsat > 0 {
|
||||
accountsOpts = append(
|
||||
accountsOpts, accounts.WithMaxPaymentSize(
|
||||
lnwire.MilliSatoshi(
|
||||
g.cfg.Accounts.MaxPaymentSizeMsat,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if g.cfg.Accounts.CheckChannelBalance {
|
||||
accountsOpts = append(
|
||||
accountsOpts, accounts.WithChannelBalanceCheck(),
|
||||
)
|
||||
}
|
||||
|
||||
g.accountService, err = accounts.NewService(
|
||||
g.stores.accounts, accountServiceErrCallback,
|
||||
g.stores.accounts, accountServiceErrCallback, accountsOpts...,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating account service: %v", err)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue