mirror of
https://github.com/lightninglabs/lightning-terminal.git
synced 2026-08-13 12:33:36 +02:00
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:
parent
ae74392433
commit
a493b6a3d6
5 changed files with 375 additions and 21 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -67,6 +83,12 @@ type InterceptorService struct {
|
|||
store Store
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// Create the actual account in the macaroon account store.
|
||||
err := s.store.UpdateAccount(
|
||||
ctx, accountID, balance, expiry, label,
|
||||
// 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)
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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...,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue