mirror of
https://github.com/lightninglabs/lightning-terminal.git
synced 2026-08-13 12:33:36 +02:00
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.
488 lines
17 KiB
Go
488 lines
17 KiB
Go
package accounts
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/lightningnetwork/lnd/fn"
|
|
"github.com/lightningnetwork/lnd/lnrpc"
|
|
"github.com/lightningnetwork/lnd/lntypes"
|
|
"github.com/lightningnetwork/lnd/lnwire"
|
|
"gopkg.in/macaroon-bakery.v2/bakery"
|
|
)
|
|
|
|
const (
|
|
// AccountIDLen is the length of the ID that is generated as a unique
|
|
// identifier of an account. It is 8 bytes long so guessing is
|
|
// improbable, but it's still not mistaken for a SHA256 hash.
|
|
AccountIDLen = 8
|
|
)
|
|
|
|
// AccountType is an enum-like type which denotes the possible account types
|
|
// that can be referenced in macaroons to keep track of user's balances.
|
|
type AccountType uint8
|
|
|
|
const (
|
|
// TypeInitialBalance represents an account that has an initial balance
|
|
// that is used up when it is spent and is not replenished
|
|
// automatically.
|
|
TypeInitialBalance AccountType = 0
|
|
|
|
// TODO(guggero): Add support for auto-replenishing (e.g. monthly
|
|
// allowance) or spend-only (no invoice creation) accounts.
|
|
)
|
|
|
|
// AccountID represents an account's unique ID.
|
|
type AccountID [AccountIDLen]byte
|
|
|
|
// ParseAccountID attempts to parse a string as an account ID.
|
|
func ParseAccountID(idStr string) (*AccountID, error) {
|
|
if len(idStr) != hex.EncodedLen(AccountIDLen) {
|
|
return nil, fmt.Errorf("invalid account ID length")
|
|
}
|
|
|
|
idBytes, err := hex.DecodeString(idStr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("error decoding account ID: %w", err)
|
|
}
|
|
|
|
var id AccountID
|
|
copy(id[:], idBytes)
|
|
|
|
return &id, nil
|
|
}
|
|
|
|
// ToInt64 converts an AccountID to its int64 representation.
|
|
func (a AccountID) ToInt64() (int64, error) {
|
|
var value int64
|
|
buf := bytes.NewReader(a[:])
|
|
if err := binary.Read(buf, byteOrder, &value); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return value, nil
|
|
}
|
|
|
|
// AccountIDFromInt64 converts an int64 to an AccountID.
|
|
func AccountIDFromInt64(value int64) (AccountID, error) {
|
|
var (
|
|
a = AccountID{}
|
|
buf = new(bytes.Buffer)
|
|
)
|
|
if err := binary.Write(buf, byteOrder, value); err != nil {
|
|
return a, err
|
|
}
|
|
copy(a[:], buf.Bytes())
|
|
|
|
return a, nil
|
|
}
|
|
|
|
// String returns the string representation of the AccountID.
|
|
func (a AccountID) String() string {
|
|
return hex.EncodeToString(a[:])
|
|
}
|
|
|
|
// PaymentEntry is the data we track per payment that is associated with an
|
|
// account. This basically includes all information required to make sure
|
|
// in-flight payments don't exceed the total available account balance.
|
|
type PaymentEntry struct {
|
|
// Status is the RPC status of the payment as reported by lnd.
|
|
Status lnrpc.Payment_PaymentStatus
|
|
|
|
// FullAmount is the total amount of the payment which includes the
|
|
// payment amount and the estimated routing fee. The routing fee is
|
|
// set to the fee limit set when sending the payment and updated to the
|
|
// actual routing fee when the payment settles.
|
|
FullAmount lnwire.MilliSatoshi
|
|
}
|
|
|
|
// AccountInvoices is the set of invoices that are associated with an account.
|
|
type AccountInvoices map[lntypes.Hash]struct{}
|
|
|
|
// AccountPayments is the set of payments that are associated with an account.
|
|
type AccountPayments map[lntypes.Hash]*PaymentEntry
|
|
|
|
// AccountPaymentEntry wraps a payment hash with its entry details.
|
|
type AccountPaymentEntry struct {
|
|
Hash lntypes.Hash
|
|
*PaymentEntry
|
|
}
|
|
|
|
// OffChainBalanceAccount holds all information that is needed to keep track of
|
|
// a user's off-chain account balance. This balance can only be spent by paying
|
|
// invoices.
|
|
type OffChainBalanceAccount struct {
|
|
// ID is the randomly generated account identifier.
|
|
ID AccountID
|
|
|
|
// Type is the account type.
|
|
Type AccountType
|
|
|
|
// InitialBalance stores the initial balance in millisatoshis and is
|
|
// never updated.
|
|
InitialBalance lnwire.MilliSatoshi
|
|
|
|
// CurrentBalance is the currently available balance of the account
|
|
// in millisatoshis that is updated every time an invoice is paid. This
|
|
// value can be negative (for example if the fees for a payment are
|
|
// larger than the estimate made when checking the balance and the
|
|
// account is close to zero value).
|
|
CurrentBalance int64
|
|
|
|
// LastUpdate keeps track of the last time the balance of the account
|
|
// was updated.
|
|
LastUpdate time.Time
|
|
|
|
// ExpirationDate is a specific date in the future after which the
|
|
// account is marked as expired. Can be set to zero for accounts that
|
|
// never expire.
|
|
ExpirationDate time.Time
|
|
|
|
// Invoices is a list of all invoices that are associated with the
|
|
// account.
|
|
Invoices AccountInvoices
|
|
|
|
// Payments is a list of all payments that are associated with the
|
|
// account and the last status we were aware of.
|
|
Payments AccountPayments
|
|
|
|
// Label is an optional label that can be set for the account. If it is
|
|
// not empty then it must be unique.
|
|
Label string
|
|
}
|
|
|
|
// HasExpired returns true if the account has an expiration date set and that
|
|
// date is in the past.
|
|
func (a *OffChainBalanceAccount) HasExpired() bool {
|
|
if a.ExpirationDate.IsZero() {
|
|
return false
|
|
}
|
|
|
|
return a.ExpirationDate.Before(time.Now())
|
|
}
|
|
|
|
// CurrentBalanceSats returns the current account balance in satoshis.
|
|
func (a *OffChainBalanceAccount) CurrentBalanceSats() int64 {
|
|
return a.CurrentBalance / 1000
|
|
}
|
|
|
|
var (
|
|
// ErrAccountBucketNotFound specifies that there is no bucket for the
|
|
// accounts in the DB yet which can/should only happen if the account
|
|
// store has been corrupted or was initialized incorrectly.
|
|
ErrAccountBucketNotFound = errors.New("account bucket not found")
|
|
|
|
// ErrAccNotFound is returned if an account could not be found in the
|
|
// local bolt DB.
|
|
ErrAccNotFound = errors.New("account not found")
|
|
|
|
// ErrNoInvoiceIndexKnown is the error that is returned by the store if
|
|
// it does not yet have any invoice indexes stored.
|
|
ErrNoInvoiceIndexKnown = errors.New("no invoice index known")
|
|
|
|
// ErrAccExpired is returned if an account has an expiration date set
|
|
// and that date is in the past.
|
|
ErrAccExpired = errors.New("account has expired")
|
|
|
|
// ErrAccBalanceInsufficient is returned if the amount required to
|
|
// perform a certain action is larger than the current balance of the
|
|
// 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.
|
|
ErrNotSupportedWithAccounts = errors.New("this RPC call is not " +
|
|
"supported with restricted account macaroons")
|
|
|
|
// ErrAccountServiceDisabled is the error that is returned when the
|
|
// account service has been disabled due to an error being thrown
|
|
// in the service that cannot be recovered from.
|
|
ErrAccountServiceDisabled = errors.New("the account service has been " +
|
|
"stopped")
|
|
|
|
// MacaroonPermissions are the permissions required for an account
|
|
// macaroon.
|
|
MacaroonPermissions = []bakery.Op{{
|
|
Entity: "info",
|
|
Action: "read",
|
|
}, {
|
|
Entity: "offchain",
|
|
Action: "read",
|
|
}, {
|
|
Entity: "offchain",
|
|
Action: "write",
|
|
}, {
|
|
Entity: "onchain",
|
|
Action: "read",
|
|
}, {
|
|
Entity: "invoices",
|
|
Action: "read",
|
|
}, {
|
|
Entity: "invoices",
|
|
Action: "write",
|
|
}, {
|
|
Entity: "peers",
|
|
Action: "read",
|
|
}}
|
|
)
|
|
|
|
// Store is the main account store interface.
|
|
type Store interface {
|
|
// NewAccount creates a new OffChainBalanceAccount with the given
|
|
// balance and a randomly chosen ID.
|
|
NewAccount(ctx context.Context, balance lnwire.MilliSatoshi,
|
|
expirationDate time.Time, label string) (
|
|
*OffChainBalanceAccount, error)
|
|
|
|
// Account retrieves an account from the Store and un-marshals it. If
|
|
// the account cannot be found, then ErrAccNotFound is returned.
|
|
Account(ctx context.Context, id AccountID) (*OffChainBalanceAccount,
|
|
error)
|
|
|
|
// Accounts retrieves all accounts from the store and un-marshals them.
|
|
Accounts(ctx context.Context) ([]*OffChainBalanceAccount, error)
|
|
|
|
// UpdateAccount updates the balance, expiry and/or label of an account.
|
|
UpdateAccount(ctx context.Context, id AccountID,
|
|
newBalance fn.Option[int64],
|
|
newExpiry fn.Option[time.Time],
|
|
newLabel fn.Option[string]) error
|
|
|
|
// AddAccountInvoice adds an invoice hash to an account.
|
|
AddAccountInvoice(ctx context.Context, id AccountID,
|
|
hash lntypes.Hash) error
|
|
|
|
// CreditAccount increases the balance of the account with the
|
|
// given ID by the given amount.
|
|
CreditAccount(ctx context.Context, id AccountID,
|
|
amount lnwire.MilliSatoshi) error
|
|
|
|
// DebitAccount decreases the balance of the account with the
|
|
// given ID by the given amount.
|
|
DebitAccount(ctx context.Context, id AccountID,
|
|
amount lnwire.MilliSatoshi) error
|
|
|
|
// UpsertAccountPayment updates or inserts a payment entry for the given
|
|
// account. Various functional options can be passed to modify the
|
|
// behavior of the method. The returned boolean is true if the payment
|
|
// was already known before the update. This is to be treated as a
|
|
// best-effort indication if an error is also returned since the method
|
|
// may error before the boolean can be set correctly.
|
|
UpsertAccountPayment(_ context.Context, id AccountID,
|
|
paymentHash lntypes.Hash, fullAmount lnwire.MilliSatoshi,
|
|
status lnrpc.Payment_PaymentStatus,
|
|
options ...UpsertPaymentOption) (bool, error)
|
|
|
|
// DeleteAccountPayment removes a payment entry from the account with
|
|
// the given ID. It will return the ErrPaymentNotAssociated error if the
|
|
// payment is not associated with the account.
|
|
DeleteAccountPayment(_ context.Context, id AccountID,
|
|
hash lntypes.Hash) error
|
|
|
|
// ListAccountPayments returns a paginated list of payments
|
|
// associated with the given account, sorted in ascending
|
|
// lexicographical order of their payment hash.
|
|
ListAccountPayments(ctx context.Context, id AccountID, offset,
|
|
limit int32) ([]*AccountPaymentEntry, error)
|
|
|
|
// CountAccountPayments returns the total number of payments associated
|
|
// with the given account.
|
|
CountAccountPayments(ctx context.Context, id AccountID) (uint64, error)
|
|
|
|
// RemoveAccount finds an account by its ID and removes it from the¨
|
|
// store.
|
|
RemoveAccount(ctx context.Context, id AccountID) error
|
|
|
|
// LastIndexes returns the last invoice add and settle index or
|
|
// ErrNoInvoiceIndexKnown if no indexes are known yet.
|
|
LastIndexes(ctx context.Context) (uint64, uint64, error)
|
|
|
|
// StoreLastIndexes stores the last invoice add and settle index.
|
|
StoreLastIndexes(ctx context.Context, addIndex,
|
|
settleIndex uint64) error
|
|
|
|
// Close closes the underlying store.
|
|
Close() error
|
|
}
|
|
|
|
// Service is the main account service interface.
|
|
type Service interface {
|
|
// CheckBalance ensures an account is valid and has a balance equal to
|
|
// or larger than the amount that is required.
|
|
CheckBalance(ctx context.Context, id AccountID,
|
|
requiredBalance lnwire.MilliSatoshi) error
|
|
|
|
// AssociateInvoice associates a generated invoice with the given
|
|
// account, making it possible for the account to be credited in case
|
|
// the invoice is paid.
|
|
AssociateInvoice(ctx context.Context, id AccountID,
|
|
hash lntypes.Hash) error
|
|
|
|
// TrackPayment adds a new payment to be tracked to the service. If the
|
|
// payment is eventually settled, its amount needs to be debited from
|
|
// the given account.
|
|
TrackPayment(ctx context.Context, id AccountID, hash lntypes.Hash,
|
|
fullAmt lnwire.MilliSatoshi) error
|
|
|
|
// RemovePayment removes a failed payment from the service because it no
|
|
// longer needs to be tracked. The payment is certain to never succeed,
|
|
// so we never need to debit the amount from the account.
|
|
RemovePayment(ctx context.Context, hash lntypes.Hash) error
|
|
|
|
// AssociatePayment associates a payment (hash) with the given account,
|
|
// ensuring that the payment will be tracked for a user when LiT is
|
|
// restarted.
|
|
AssociatePayment(ctx context.Context, id AccountID,
|
|
paymentHash lntypes.Hash, fullAmt lnwire.MilliSatoshi) error
|
|
|
|
// PaymentErrored removes a pending payment from the accounts
|
|
// registered payment list. This should only ever be called if we are
|
|
// sure that the payment request errored out.
|
|
PaymentErrored(ctx context.Context, id AccountID,
|
|
hash lntypes.Hash) error
|
|
|
|
// CreditAccount increases the balance of an existing account in the
|
|
// database.
|
|
CreditAccount(ctx context.Context, accountID AccountID,
|
|
amount lnwire.MilliSatoshi) (*OffChainBalanceAccount, error)
|
|
|
|
// DebitAccount decreases the balance of an existing account in the
|
|
// database.
|
|
DebitAccount(ctx context.Context, accountID AccountID,
|
|
amount lnwire.MilliSatoshi) (*OffChainBalanceAccount, error)
|
|
|
|
RequestValuesStore
|
|
}
|
|
|
|
// RequestValues holds various values associated with a specific request that
|
|
// we may want access to when handling the response. At the moment this only
|
|
// stores payment related data.
|
|
type RequestValues struct {
|
|
// PaymentHash is the hash of the payment that this request is
|
|
// associated with.
|
|
PaymentHash lntypes.Hash
|
|
|
|
// PaymentAmount is the value of the payment being made.
|
|
PaymentAmount lnwire.MilliSatoshi
|
|
}
|
|
|
|
// RequestValuesStore is a store that can be used to keep track of the mapping
|
|
// between a request ID and various values associated with that request which
|
|
// we may want access to when handling the request response.
|
|
type RequestValuesStore interface {
|
|
// RegisterValues stores values for the given request ID.
|
|
RegisterValues(reqID uint64, values *RequestValues) error
|
|
|
|
// GetValues returns the corresponding request values for the given
|
|
// request ID if they exist.
|
|
GetValues(reqID uint64) (*RequestValues, bool)
|
|
|
|
// DeleteValues deletes any values stored for the given request ID.
|
|
DeleteValues(reqID uint64)
|
|
}
|
|
|
|
// UpsertPaymentOption is a functional option that can be passed to the
|
|
// UpsertAccountPayment method to modify its behavior.
|
|
type UpsertPaymentOption func(*upsertAcctPaymentOption)
|
|
|
|
// upsertAcctPaymentOption is a struct that holds optional parameters for the
|
|
// UpsertAccountPayment method.
|
|
type upsertAcctPaymentOption struct {
|
|
debitAccount bool
|
|
errIfAlreadyPending bool
|
|
usePendingAmount bool
|
|
errIfAlreadySucceeded bool
|
|
errIfUnknown bool
|
|
}
|
|
|
|
// newUpsertPaymentOption creates a new upsertAcctPaymentOption with default
|
|
// values.
|
|
func newUpsertPaymentOption() *upsertAcctPaymentOption {
|
|
return &upsertAcctPaymentOption{
|
|
debitAccount: false,
|
|
errIfAlreadyPending: false,
|
|
usePendingAmount: false,
|
|
errIfAlreadySucceeded: false,
|
|
errIfUnknown: false,
|
|
}
|
|
}
|
|
|
|
// WithDebitAccount is a functional option that can be passed to the
|
|
// UpsertAccountPayment method to indicate that the account balance should be
|
|
// debited by the full amount of the payment.
|
|
func WithDebitAccount() UpsertPaymentOption {
|
|
return func(o *upsertAcctPaymentOption) {
|
|
o.debitAccount = true
|
|
}
|
|
}
|
|
|
|
// WithErrIfAlreadyPending is a functional option that can be passed to the
|
|
// UpsertAccountPayment method to indicate that an error should be returned if
|
|
// the payment is already pending or succeeded.
|
|
func WithErrIfAlreadyPending() UpsertPaymentOption {
|
|
return func(o *upsertAcctPaymentOption) {
|
|
o.errIfAlreadyPending = true
|
|
}
|
|
}
|
|
|
|
// WithErrIfAlreadySucceeded is a functional option that can be passed to the
|
|
// UpsertAccountPayment method to indicate that the ErrAlreadySucceeded error
|
|
// should be returned if the payment is already in a succeeded state.
|
|
func WithErrIfAlreadySucceeded() UpsertPaymentOption {
|
|
return func(o *upsertAcctPaymentOption) {
|
|
o.errIfAlreadySucceeded = true
|
|
}
|
|
}
|
|
|
|
// WithPendingAmount is a functional option that can be passed to the
|
|
// UpsertAccountPayment method to indicate that if the payment already exists,
|
|
// then the known payment amount should be used instead of the new value passed
|
|
// to the method.
|
|
func WithPendingAmount() UpsertPaymentOption {
|
|
return func(o *upsertAcctPaymentOption) {
|
|
o.usePendingAmount = true
|
|
}
|
|
}
|
|
|
|
// WithErrIfUnknown is a functional option that can be passed to the
|
|
// UpsertAccountPayment method to indicate that the ErrPaymentNotAssociated
|
|
// error should be returned if the payment is not associated with the account.
|
|
func WithErrIfUnknown() UpsertPaymentOption {
|
|
return func(o *upsertAcctPaymentOption) {
|
|
o.errIfUnknown = true
|
|
}
|
|
}
|
|
|
|
// First, ensure that if a label is set, it can't be
|
|
// mistaken for a hex encoded account ID.
|
|
func checkLabel(label string) error {
|
|
if len(label) == hex.EncodedLen(AccountIDLen) {
|
|
_, err := hex.DecodeString(label)
|
|
if err == nil {
|
|
return fmt.Errorf("the label '%s' "+
|
|
"is not allowed as it "+
|
|
"can be mistaken for an account ID", label)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|