mirror of
https://github.com/lightninglabs/lightning-terminal.git
synced 2026-08-13 12:33:36 +02:00
Add an opt-in cap on the total amount a single account payment may debit, including routing fees, enforced at the account interceptor for both SendPaymentV2 and SendToRouteV2. When the new accounts.max-payment-size-msat config option is set to a non-zero value, payments whose amount plus fees exceeds it are rejected with ErrPaymentExceedsMaxSize before the balance check and before any funds are reserved. For SendPaymentV2, the configured fee limit is included in the capped amount. For SendToRouteV2, the route's stated fee is included. This gives operators a guard rail against a compromised or misbehaving account macaroon draining its balance in a single large payment. The cap defaults to 0 (disabled), preserving existing behaviour, and is a first step towards the finer-grained per-account spending controls requested in the issue.
1031 lines
28 KiB
Go
1031 lines
28 KiB
Go
package accounts
|
|
|
|
import (
|
|
"context"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/btcsuite/btcd/chaincfg"
|
|
"github.com/lightningnetwork/lnd/clock"
|
|
"github.com/lightningnetwork/lnd/lnrpc"
|
|
"github.com/lightningnetwork/lnd/lnrpc/routerrpc"
|
|
"github.com/lightningnetwork/lnd/lntypes"
|
|
"github.com/lightningnetwork/lnd/lnwire"
|
|
"github.com/stretchr/testify/require"
|
|
"google.golang.org/protobuf/encoding/protojson"
|
|
"google.golang.org/protobuf/proto"
|
|
)
|
|
|
|
var (
|
|
chainParams = &chaincfg.RegressionNetParams
|
|
|
|
marshalOptions = &protojson.MarshalOptions{
|
|
UseProtoNames: true,
|
|
EmitUnpopulated: true,
|
|
}
|
|
|
|
testID = AccountID{77, 88, 99}
|
|
testHash = lntypes.Hash{
|
|
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
|
|
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
|
|
}
|
|
testHash2 = lntypes.Hash{
|
|
2, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
|
|
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
|
|
}
|
|
testHash3 = lntypes.Hash{
|
|
3, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
|
|
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
|
|
}
|
|
testHash4 = lntypes.Hash{
|
|
4, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
|
|
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,
|
|
}
|
|
|
|
testAmount = &lnrpc.Amount{
|
|
Sat: 456,
|
|
Msat: 456789,
|
|
}
|
|
emptyAmount = &lnrpc.Amount{
|
|
Sat: 0,
|
|
Msat: 0,
|
|
}
|
|
)
|
|
|
|
type mockService struct {
|
|
acctBalanceMsat lnwire.MilliSatoshi
|
|
|
|
trackedInvoices map[lntypes.Hash]AccountID
|
|
trackedPayments AccountPayments
|
|
|
|
*requestValuesStore
|
|
}
|
|
|
|
func (m *mockService) CreditAccount(_ context.Context, _ AccountID,
|
|
_ lnwire.MilliSatoshi) (*OffChainBalanceAccount, error) {
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *mockService) DebitAccount(_ context.Context, _ AccountID,
|
|
_ lnwire.MilliSatoshi) (*OffChainBalanceAccount, error) {
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
func newMockService() *mockService {
|
|
return &mockService{
|
|
acctBalanceMsat: 0,
|
|
trackedInvoices: make(map[lntypes.Hash]AccountID),
|
|
trackedPayments: make(AccountPayments),
|
|
requestValuesStore: newRequestValuesStore(),
|
|
}
|
|
}
|
|
|
|
func (m *mockService) CheckBalance(_ context.Context, _ AccountID,
|
|
wantBalance lnwire.MilliSatoshi) error {
|
|
|
|
if wantBalance > m.acctBalanceMsat {
|
|
return fmt.Errorf("invalid balance")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (m *mockService) AssociateInvoice(_ context.Context, id AccountID,
|
|
hash lntypes.Hash) error {
|
|
|
|
m.trackedInvoices[hash] = id
|
|
|
|
return nil
|
|
}
|
|
|
|
func (m *mockService) AssociatePayment(_ context.Context, id AccountID,
|
|
paymentHash lntypes.Hash, amt lnwire.MilliSatoshi) error {
|
|
|
|
return nil
|
|
}
|
|
|
|
func (m *mockService) PaymentErrored(_ context.Context, id AccountID,
|
|
hash lntypes.Hash) error {
|
|
|
|
return nil
|
|
}
|
|
|
|
func (m *mockService) TrackPayment(_ context.Context, _ AccountID,
|
|
hash lntypes.Hash, amt lnwire.MilliSatoshi) error {
|
|
|
|
m.trackedPayments[hash] = &PaymentEntry{
|
|
Status: lnrpc.Payment_UNKNOWN,
|
|
FullAmount: amt,
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (m *mockService) RemovePayment(_ context.Context,
|
|
hash lntypes.Hash) error {
|
|
|
|
delete(m.trackedPayments, hash)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (*mockService) IsRunning() bool {
|
|
return true
|
|
}
|
|
|
|
var _ Service = (*mockService)(nil)
|
|
|
|
// TestAccountChecker makes sure all round trip checkers can be instantiated
|
|
// correctly without panicking.
|
|
func TestAccountChecker(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
checker := NewAccountChecker(nil, nil, 0)
|
|
for checkerName := range checker.checkers {
|
|
t.Logf("Checker registered: %v", checkerName)
|
|
}
|
|
}
|
|
|
|
// TestAccountCheckers tests the account request checkers.
|
|
func TestAccountCheckers(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const reqID = uint64(55)
|
|
|
|
testCases := []struct {
|
|
name string
|
|
fullURI string
|
|
setup func(s *mockService,
|
|
acct *OffChainBalanceAccount)
|
|
originalRequest proto.Message
|
|
requestErr string
|
|
originalResponse proto.Message
|
|
replacedResponse proto.Message
|
|
responseErr string
|
|
validate func(t *testing.T, s *mockService,
|
|
acct *OffChainBalanceAccount)
|
|
}{{
|
|
name: "node info pass through",
|
|
fullURI: "/lnrpc.Lightning/GetNodeInfo",
|
|
originalRequest: &lnrpc.NodeInfoRequest{
|
|
PubKey: "foobarbaz",
|
|
},
|
|
originalResponse: &lnrpc.NodeInfo{
|
|
Node: &lnrpc.LightningNode{
|
|
LastUpdate: 1234,
|
|
PubKey: "foobarbaz",
|
|
Alias: "baz",
|
|
Color: "green",
|
|
},
|
|
},
|
|
}, {
|
|
name: "add invoice",
|
|
fullURI: "/lnrpc.Lightning/AddInvoice",
|
|
originalRequest: &lnrpc.Invoice{},
|
|
originalResponse: &lnrpc.AddInvoiceResponse{
|
|
RHash: testHash[:],
|
|
},
|
|
validate: func(t *testing.T, s *mockService,
|
|
acct *OffChainBalanceAccount) {
|
|
|
|
require.Contains(t, s.trackedInvoices, testHash)
|
|
},
|
|
}, {
|
|
name: "list invoices, not mapped to account",
|
|
fullURI: "/lnrpc.Lightning/ListInvoices",
|
|
originalRequest: &lnrpc.ListInvoiceRequest{},
|
|
originalResponse: &lnrpc.ListInvoiceResponse{
|
|
Invoices: []*lnrpc.Invoice{{
|
|
RHash: testHash[:],
|
|
}},
|
|
},
|
|
replacedResponse: &lnrpc.ListInvoiceResponse{
|
|
Invoices: []*lnrpc.Invoice{},
|
|
},
|
|
}, {
|
|
name: "list invoices, mapped to account",
|
|
fullURI: "/lnrpc.Lightning/ListInvoices",
|
|
setup: func(s *mockService, acct *OffChainBalanceAccount) {
|
|
acct.Invoices[testHash] = struct{}{}
|
|
},
|
|
originalRequest: &lnrpc.ListInvoiceRequest{},
|
|
originalResponse: &lnrpc.ListInvoiceResponse{
|
|
Invoices: []*lnrpc.Invoice{{
|
|
RHash: testHash[:],
|
|
}},
|
|
},
|
|
replacedResponse: &lnrpc.ListInvoiceResponse{
|
|
Invoices: []*lnrpc.Invoice{{
|
|
RHash: testHash[:],
|
|
}},
|
|
},
|
|
}, {
|
|
name: "lookup invoice, not mapped to account",
|
|
fullURI: "/lnrpc.Lightning/LookupInvoice",
|
|
originalRequest: &lnrpc.PaymentHash{
|
|
RHash: testHash[:],
|
|
},
|
|
requestErr: "invoice does not belong to this account",
|
|
}, {
|
|
name: "lookup invoice, mapped to account",
|
|
fullURI: "/lnrpc.Lightning/LookupInvoice",
|
|
setup: func(s *mockService, acct *OffChainBalanceAccount) {
|
|
acct.Invoices[testHash] = struct{}{}
|
|
},
|
|
originalRequest: &lnrpc.PaymentHash{
|
|
RHash: testHash[:],
|
|
},
|
|
originalResponse: &lnrpc.Invoice{
|
|
RHash: testHash[:],
|
|
},
|
|
}, {
|
|
name: "send payment v2, not enough balance",
|
|
fullURI: "/routerrpc.Router/SendPaymentV2",
|
|
originalRequest: &routerrpc.SendPaymentRequest{
|
|
AmtMsat: 5000,
|
|
PaymentHash: testHash[:],
|
|
},
|
|
requestErr: "error validating account balance: invalid balance",
|
|
}, {
|
|
name: "send payment v2, not enough balance because of fee",
|
|
fullURI: "/routerrpc.Router/SendPaymentV2",
|
|
setup: func(s *mockService, acct *OffChainBalanceAccount) {
|
|
s.acctBalanceMsat = 5000
|
|
},
|
|
originalRequest: &routerrpc.SendPaymentRequest{
|
|
AmtMsat: 5000,
|
|
FeeLimitMsat: 50,
|
|
PaymentHash: testHash[:],
|
|
},
|
|
requestErr: "error validating account balance: invalid balance",
|
|
}, {
|
|
name: "send payment v2, exact balance",
|
|
fullURI: "/routerrpc.Router/SendPaymentV2",
|
|
setup: func(s *mockService, acct *OffChainBalanceAccount) {
|
|
s.acctBalanceMsat = 5123
|
|
},
|
|
originalRequest: &routerrpc.SendPaymentRequest{
|
|
AmtMsat: 5000,
|
|
FeeLimitMsat: 123,
|
|
PaymentHash: testHash[:],
|
|
},
|
|
originalResponse: &lnrpc.Payment{
|
|
PaymentHash: hex.EncodeToString(testHash[:]),
|
|
ValueMsat: 5000,
|
|
FeeMsat: 123,
|
|
Status: lnrpc.Payment_IN_FLIGHT,
|
|
},
|
|
validate: func(t *testing.T, s *mockService,
|
|
acct *OffChainBalanceAccount) {
|
|
|
|
require.Contains(t, s.trackedPayments, testHash)
|
|
payment := s.trackedPayments[testHash]
|
|
require.EqualValues(t, 5123, payment.FullAmount)
|
|
},
|
|
}, {
|
|
name: "list payments, not mapped to account",
|
|
fullURI: "/lnrpc.Lightning/ListPayments",
|
|
originalRequest: &lnrpc.ListPaymentsRequest{},
|
|
originalResponse: &lnrpc.ListPaymentsResponse{
|
|
Payments: []*lnrpc.Payment{{
|
|
PaymentHash: hex.EncodeToString(testHash[:]),
|
|
}},
|
|
},
|
|
replacedResponse: &lnrpc.ListPaymentsResponse{
|
|
Payments: []*lnrpc.Payment{},
|
|
},
|
|
}, {
|
|
name: "list payments, mapped to account",
|
|
fullURI: "/lnrpc.Lightning/ListPayments",
|
|
setup: func(s *mockService, acct *OffChainBalanceAccount) {
|
|
acct.Payments[testHash] = &PaymentEntry{}
|
|
},
|
|
originalRequest: &lnrpc.ListPaymentsRequest{},
|
|
originalResponse: &lnrpc.ListPaymentsResponse{
|
|
Payments: []*lnrpc.Payment{{
|
|
PaymentHash: hex.EncodeToString(testHash[:]),
|
|
}},
|
|
},
|
|
replacedResponse: &lnrpc.ListPaymentsResponse{
|
|
Payments: []*lnrpc.Payment{{
|
|
PaymentHash: hex.EncodeToString(testHash[:]),
|
|
}},
|
|
},
|
|
}, {
|
|
name: "track payment, not mapped to account",
|
|
fullURI: "/routerrpc.Router/TrackPaymentV2",
|
|
originalRequest: &routerrpc.TrackPaymentRequest{
|
|
PaymentHash: testHash[:],
|
|
},
|
|
requestErr: "payment does not belong to this account",
|
|
}, {
|
|
name: "track payment, mapped to account",
|
|
fullURI: "/routerrpc.Router/TrackPaymentV2",
|
|
setup: func(s *mockService, acct *OffChainBalanceAccount) {
|
|
acct.Payments[testHash] = &PaymentEntry{}
|
|
},
|
|
originalRequest: &routerrpc.TrackPaymentRequest{
|
|
PaymentHash: testHash[:],
|
|
},
|
|
originalResponse: &lnrpc.Payment{
|
|
PaymentHash: hex.EncodeToString(testHash[:]),
|
|
},
|
|
}, {
|
|
name: "deprecated: router send payment v1",
|
|
fullURI: "/routerrpc.Router/SendPayment",
|
|
originalRequest: &routerrpc.SendPaymentRequest{
|
|
PaymentHash: testHash[:],
|
|
},
|
|
requestErr: "this RPC call is not supported with restricted " +
|
|
"account macaroons",
|
|
}, {
|
|
name: "deprecated: router send to route v1",
|
|
fullURI: "/routerrpc.Router/SendToRoute",
|
|
originalRequest: &routerrpc.SendToRouteRequest{
|
|
PaymentHash: testHash[:],
|
|
},
|
|
requestErr: "this RPC call is not supported with restricted " +
|
|
"account macaroons",
|
|
}, {
|
|
name: "deprecated: router track payment v1",
|
|
fullURI: "/routerrpc.Router/TrackPayment",
|
|
originalRequest: &routerrpc.TrackPaymentRequest{
|
|
PaymentHash: testHash[:],
|
|
},
|
|
requestErr: "this RPC call is not supported with restricted " +
|
|
"account macaroons",
|
|
}, {
|
|
name: "empty response: pending channels",
|
|
fullURI: "/lnrpc.Lightning/PendingChannels",
|
|
originalRequest: &lnrpc.PendingChannelsRequest{},
|
|
originalResponse: &lnrpc.PendingChannelsResponse{
|
|
TotalLimboBalance: 123456,
|
|
// nolint:ll
|
|
PendingOpenChannels: []*lnrpc.PendingChannelsResponse_PendingOpenChannel{
|
|
{},
|
|
},
|
|
},
|
|
replacedResponse: &lnrpc.PendingChannelsResponse{},
|
|
}, {
|
|
name: "empty response: list channels",
|
|
fullURI: "/lnrpc.Lightning/ListChannels",
|
|
originalRequest: &lnrpc.ListChannelsRequest{},
|
|
originalResponse: &lnrpc.ListChannelsResponse{
|
|
Channels: []*lnrpc.Channel{
|
|
{},
|
|
},
|
|
},
|
|
replacedResponse: &lnrpc.ListChannelsResponse{},
|
|
}, {
|
|
name: "empty response: closed channels",
|
|
fullURI: "/lnrpc.Lightning/ClosedChannels",
|
|
originalRequest: &lnrpc.ClosedChannelsRequest{},
|
|
originalResponse: &lnrpc.ClosedChannelsResponse{
|
|
Channels: []*lnrpc.ChannelCloseSummary{
|
|
{},
|
|
},
|
|
},
|
|
replacedResponse: &lnrpc.ClosedChannelsResponse{},
|
|
}, {
|
|
name: "channel balance",
|
|
fullURI: "/lnrpc.Lightning/ChannelBalance",
|
|
setup: func(s *mockService, acct *OffChainBalanceAccount) {
|
|
acct.CurrentBalance = 4455667788
|
|
},
|
|
originalRequest: &lnrpc.ChannelBalanceRequest{},
|
|
originalResponse: &lnrpc.ChannelBalanceResponse{
|
|
Balance: 123, // nolint
|
|
PendingOpenBalance: 456, // nolint
|
|
LocalBalance: testAmount,
|
|
RemoteBalance: testAmount,
|
|
UnsettledLocalBalance: testAmount,
|
|
UnsettledRemoteBalance: testAmount,
|
|
PendingOpenLocalBalance: testAmount,
|
|
PendingOpenRemoteBalance: testAmount,
|
|
},
|
|
replacedResponse: &lnrpc.ChannelBalanceResponse{
|
|
Balance: 4455667, // nolint
|
|
PendingOpenBalance: 0, // nolint
|
|
LocalBalance: &lnrpc.Amount{
|
|
Sat: 4455667,
|
|
Msat: 4455667788,
|
|
},
|
|
RemoteBalance: emptyAmount,
|
|
UnsettledLocalBalance: emptyAmount,
|
|
UnsettledRemoteBalance: emptyAmount,
|
|
PendingOpenLocalBalance: emptyAmount,
|
|
PendingOpenRemoteBalance: emptyAmount,
|
|
},
|
|
}, {
|
|
name: "get info",
|
|
fullURI: "/lnrpc.Lightning/GetInfo",
|
|
originalRequest: &lnrpc.GetInfoRequest{},
|
|
originalResponse: &lnrpc.GetInfoResponse{
|
|
Version: "foobar",
|
|
NumActiveChannels: 123,
|
|
NumPeers: 790,
|
|
NumPendingChannels: 777,
|
|
},
|
|
replacedResponse: &lnrpc.GetInfoResponse{
|
|
Version: "foobar",
|
|
},
|
|
}}
|
|
|
|
for _, tc := range testCases {
|
|
tc := tc
|
|
|
|
t.Run(tc.name, func(tt *testing.T) {
|
|
tt.Parallel()
|
|
|
|
service := newMockService()
|
|
checkers := NewAccountChecker(service, chainParams, 0)
|
|
acct := &OffChainBalanceAccount{
|
|
ID: testID,
|
|
Type: TypeInitialBalance,
|
|
Invoices: make(AccountInvoices),
|
|
Payments: make(AccountPayments),
|
|
}
|
|
ctx := AddAccountToContext(context.Background(), acct)
|
|
ctx = AddRequestIDToContext(ctx, reqID)
|
|
|
|
// Is a setup call required to initialize initial
|
|
// conditions?
|
|
if tc.setup != nil {
|
|
tc.setup(service, acct)
|
|
}
|
|
|
|
err := checkers.checkIncomingRequest(
|
|
ctx, tc.fullURI, tc.originalRequest,
|
|
)
|
|
|
|
// Did we expect an error?
|
|
if tc.requestErr != "" {
|
|
require.ErrorContains(tt, err, tc.requestErr)
|
|
return
|
|
}
|
|
require.NoError(tt, err)
|
|
|
|
replaced, err := checkers.replaceOutgoingResponse(
|
|
ctx, tc.fullURI, tc.originalResponse,
|
|
)
|
|
|
|
// Did we expect an error?
|
|
if tc.responseErr != "" {
|
|
require.ErrorContains(tt, err, tc.responseErr)
|
|
return
|
|
}
|
|
require.NoError(tt, err)
|
|
|
|
assertMessagesEqual(tt, tc.replacedResponse, replaced)
|
|
|
|
// Any post execution validation that we need to run?
|
|
if tc.validate != nil {
|
|
tc.validate(tt, service, acct)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSendPaymentV2 performs test coverage on the SendPaymentV2 checker.
|
|
func TestSendPaymentV2(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
|
|
}
|
|
clock := clock.NewTestClock(time.Now())
|
|
store := NewTestDB(t, clock)
|
|
service, err := NewService(store, errFunc)
|
|
require.NoError(t, err)
|
|
|
|
err = service.Start(ctx, lndMock, routerMock, chainParams)
|
|
require.NoError(t, err)
|
|
|
|
assertBalance := func(id AccountID, expectedBalance int64) {
|
|
acct, err := service.Account(ctx, id)
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, expectedBalance,
|
|
calcAvailableAccountBalance(acct))
|
|
}
|
|
|
|
// This should error because there is no account in the context.
|
|
err = service.checkers.checkIncomingRequest(
|
|
ctx, uri, &routerrpc.SendPaymentRequest{},
|
|
)
|
|
require.ErrorContains(t, err, "no account found in context")
|
|
|
|
// Create an account and add it to the context.
|
|
acct, err := service.NewAccount(
|
|
ctx, 5000, clock.Now().Add(time.Hour), "test",
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
ctxWithAcct := AddAccountToContext(ctx, acct)
|
|
|
|
// This should error because there is no request ID in the context.
|
|
err = service.checkers.checkIncomingRequest(
|
|
ctxWithAcct, uri, &routerrpc.SendPaymentRequest{},
|
|
)
|
|
require.ErrorContains(t, err, "no request ID found in context")
|
|
|
|
reqID1 := nextRequestID()
|
|
ctx = AddRequestIDToContext(ctxWithAcct, reqID1)
|
|
|
|
// This should error because no payment hash is provided.
|
|
err = service.checkers.checkIncomingRequest(
|
|
ctx, uri, &routerrpc.SendPaymentRequest{},
|
|
)
|
|
require.ErrorContains(t, err, "a payment hash is required")
|
|
|
|
// This should error because of an insufficient account balance.
|
|
err = service.checkers.checkIncomingRequest(
|
|
ctx, uri, &routerrpc.SendPaymentRequest{
|
|
Amt: 1000,
|
|
PaymentHash: testHash[:],
|
|
},
|
|
)
|
|
require.ErrorContains(t, err, "account balance insufficient")
|
|
|
|
// Assert that the balance of the account is still un-changed since none
|
|
// of the requests have gone through yet.
|
|
assertBalance(acct.ID, 5000)
|
|
|
|
// This should work.
|
|
err = service.checkers.checkIncomingRequest(
|
|
ctx, uri, &routerrpc.SendPaymentRequest{
|
|
AmtMsat: 1000,
|
|
PaymentHash: testHash[:],
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
// Alright, now assert that the pending amount has been accounted for.
|
|
assertBalance(acct.ID, 4000)
|
|
|
|
// Try let the same request go through with the same payment hash. This
|
|
// should fail and the balance should remain unchanged.
|
|
err = service.checkers.checkIncomingRequest(
|
|
ctx, uri, &routerrpc.SendPaymentRequest{
|
|
AmtMsat: 1000,
|
|
PaymentHash: testHash[:],
|
|
},
|
|
)
|
|
require.ErrorContains(t, err, "is already in flight")
|
|
assertBalance(acct.ID, 4000)
|
|
|
|
// Now let the response come through for the first request.
|
|
_, err = service.checkers.replaceOutgoingResponse(
|
|
ctx, uri, &lnrpc.Payment{
|
|
PaymentHash: hex.EncodeToString(testHash[:]),
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
assertBalance(acct.ID, 4000)
|
|
|
|
// A repeated response should have no impact.
|
|
_, err = service.checkers.replaceOutgoingResponse(
|
|
ctx, uri, &lnrpc.Payment{
|
|
PaymentHash: hex.EncodeToString(testHash[:]),
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
assertBalance(acct.ID, 4000)
|
|
|
|
routerMock.assertPaymentRequests(t, map[lntypes.Hash]struct{}{
|
|
testHash: {},
|
|
})
|
|
|
|
nextRequestID()
|
|
|
|
reqID2 := nextRequestID()
|
|
ctx = AddRequestIDToContext(ctxWithAcct, reqID2)
|
|
|
|
// Ok now we will test an errored request. First send through a valid
|
|
// send request and assert that the available balance is reduced.
|
|
err = service.checkers.checkIncomingRequest(
|
|
ctx, uri, &routerrpc.SendPaymentRequest{
|
|
AmtMsat: 1000,
|
|
PaymentHash: testHash2[:],
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
assertBalance(acct.ID, 3000)
|
|
|
|
// Now return an error response.
|
|
_, err = service.checkers.handleErrorResponse(ctx, uri, nil)
|
|
require.NoError(t, err)
|
|
|
|
// The balance should have gone back to what it was before the payment
|
|
// was initiated.
|
|
assertBalance(acct.ID, 4000)
|
|
|
|
routerMock.assertNoPaymentRequest(t)
|
|
|
|
// The final test we will do is to have two send requests initiated
|
|
// before the response for the first one has been received.
|
|
reqID3 := nextRequestID()
|
|
ctx = AddRequestIDToContext(ctxWithAcct, reqID3)
|
|
|
|
err = service.checkers.checkIncomingRequest(
|
|
ctx, uri, &routerrpc.SendPaymentRequest{
|
|
AmtMsat: 2000,
|
|
PaymentHash: testHash3[:],
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
assertBalance(acct.ID, 2000)
|
|
|
|
reqID4 := nextRequestID()
|
|
ctx = AddRequestIDToContext(ctxWithAcct, reqID4)
|
|
err = service.checkers.checkIncomingRequest(
|
|
ctx, uri, &routerrpc.SendPaymentRequest{
|
|
AmtMsat: 2000,
|
|
PaymentHash: testHash4[:],
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
assertBalance(acct.ID, 0)
|
|
|
|
// Ok, now let the response for the second request come through.
|
|
_, err = service.checkers.replaceOutgoingResponse(
|
|
ctx, uri, &lnrpc.Payment{
|
|
PaymentHash: hex.EncodeToString(testHash4[:]),
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
assertBalance(acct.ID, 0)
|
|
|
|
// Let the first request error.
|
|
ctx = AddRequestIDToContext(ctxWithAcct, reqID3)
|
|
_, err = service.checkers.handleErrorResponse(ctx, uri, nil)
|
|
require.NoError(t, err)
|
|
assertBalance(acct.ID, 2000)
|
|
|
|
// Finally, replicate the streaming scenario. Since SendPaymentV2 is a
|
|
// streaming endpoint, the request values are deleted as soon as a
|
|
// terminal Payment response is handled. If lnd then sends a terminal
|
|
// error response for the same request, the error handler finds no
|
|
// request values. It must pass the original error through unchanged
|
|
// instead of masking it with a confusing "no request values found"
|
|
// error.
|
|
reqID5 := nextRequestID()
|
|
ctx = AddRequestIDToContext(ctxWithAcct, reqID5)
|
|
|
|
// Send a valid request so the request values are registered.
|
|
err = service.checkers.checkIncomingRequest(
|
|
ctx, uri, &routerrpc.SendPaymentRequest{
|
|
AmtMsat: 1000,
|
|
PaymentHash: testHash2[:],
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
// A failed Payment response deletes the stored request values.
|
|
_, err = service.checkers.replaceOutgoingResponse(
|
|
ctx, uri, &lnrpc.Payment{
|
|
PaymentHash: hex.EncodeToString(testHash2[:]),
|
|
Status: lnrpc.Payment_FAILED,
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
// A subsequent error response for the same request must not be masked.
|
|
originalErr := fmt.Errorf("account balance insufficient")
|
|
returnedErr, err := service.checkers.handleErrorResponse(
|
|
ctx, uri, originalErr,
|
|
)
|
|
require.NoError(t, err)
|
|
require.ErrorContains(t, returnedErr, "account balance insufficient")
|
|
}
|
|
|
|
// TestSendToRouteV2 performs test coverage on the SendToRouteV2 checker.
|
|
func TestSendToRouteV2(t *testing.T) {
|
|
var (
|
|
uri = "/routerrpc.Router/SendToRouteV2"
|
|
ctx = context.Background()
|
|
requestID uint64
|
|
)
|
|
|
|
nextRequestID := func() uint64 {
|
|
requestID++
|
|
|
|
return requestID
|
|
}
|
|
|
|
lndMock := newMockLnd()
|
|
routerMock := newMockRouter()
|
|
errFunc := func(err error) {
|
|
lndMock.mainErrChan <- err
|
|
}
|
|
clock := clock.NewTestClock(time.Now())
|
|
store := NewTestDB(t, clock)
|
|
service, err := NewService(store, errFunc)
|
|
require.NoError(t, err)
|
|
|
|
err = service.Start(ctx, lndMock, routerMock, chainParams)
|
|
require.NoError(t, err)
|
|
|
|
assertBalance := func(id AccountID, expectedBalance int64) {
|
|
acct, err := service.Account(ctx, id)
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, expectedBalance,
|
|
calcAvailableAccountBalance(acct))
|
|
}
|
|
|
|
// This should error because there is no account in the context.
|
|
err = service.checkers.checkIncomingRequest(
|
|
ctx, uri, &routerrpc.SendToRouteRequest{},
|
|
)
|
|
require.ErrorContains(t, err, "no account found in context")
|
|
|
|
// Create an account and add it to the context.
|
|
acct, err := service.NewAccount(
|
|
ctx, 5000, clock.Now().Add(time.Hour), "test",
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
ctxWithAcct := AddAccountToContext(ctx, acct)
|
|
|
|
// This should error because there is no request ID in the context.
|
|
err = service.checkers.checkIncomingRequest(
|
|
ctxWithAcct, uri, &routerrpc.SendToRouteRequest{},
|
|
)
|
|
require.ErrorContains(t, err, "no request ID found in context")
|
|
|
|
reqID1 := nextRequestID()
|
|
ctx = AddRequestIDToContext(ctxWithAcct, reqID1)
|
|
|
|
// This should error because no payment hash is provided.
|
|
err = service.checkers.checkIncomingRequest(
|
|
ctx, uri, &routerrpc.SendToRouteRequest{},
|
|
)
|
|
require.ErrorContains(t, err, "invalid hash length")
|
|
|
|
// This should error because of an insufficient account balance.
|
|
err = service.checkers.checkIncomingRequest(
|
|
ctx, uri, &routerrpc.SendToRouteRequest{
|
|
Route: &lnrpc.Route{
|
|
TotalAmt: 1000,
|
|
},
|
|
PaymentHash: testHash[:],
|
|
},
|
|
)
|
|
require.ErrorContains(t, err, "account balance insufficient")
|
|
|
|
// Assert that the balance of the account is still un-changed since none
|
|
// of the requests have gone through yet.
|
|
assertBalance(acct.ID, 5000)
|
|
|
|
// This should work.
|
|
err = service.checkers.checkIncomingRequest(
|
|
ctx, uri, &routerrpc.SendToRouteRequest{
|
|
Route: &lnrpc.Route{
|
|
TotalAmtMsat: 1000,
|
|
},
|
|
PaymentHash: testHash[:],
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
// Alright, now assert that the pending amount has been accounted for.
|
|
assertBalance(acct.ID, 4000)
|
|
|
|
// Try let the same request go through with the same payment hash. This
|
|
// should fail and the balance should remain unchanged.
|
|
err = service.checkers.checkIncomingRequest(
|
|
ctx, uri, &routerrpc.SendToRouteRequest{
|
|
Route: &lnrpc.Route{
|
|
TotalAmtMsat: 1000,
|
|
},
|
|
PaymentHash: testHash[:],
|
|
},
|
|
)
|
|
require.ErrorContains(t, err, "is already in flight")
|
|
assertBalance(acct.ID, 4000)
|
|
|
|
// Now let the response come through for the first request. Even though
|
|
// this response does not contain the payment hash, it should still be
|
|
// linked correctly since we track this in the request values store.
|
|
_, err = service.checkers.replaceOutgoingResponse(
|
|
ctx, uri, &lnrpc.HTLCAttempt{},
|
|
)
|
|
require.NoError(t, err)
|
|
assertBalance(acct.ID, 4000)
|
|
|
|
// A repeated response should have no impact.
|
|
_, err = service.checkers.replaceOutgoingResponse(
|
|
ctx, uri, &lnrpc.HTLCAttempt{},
|
|
)
|
|
require.NoError(t, err)
|
|
assertBalance(acct.ID, 4000)
|
|
|
|
routerMock.assertPaymentRequests(t, map[lntypes.Hash]struct{}{
|
|
testHash: {},
|
|
})
|
|
|
|
nextRequestID()
|
|
|
|
reqID2 := nextRequestID()
|
|
ctx = AddRequestIDToContext(ctxWithAcct, reqID2)
|
|
|
|
// Ok now we will test an errored request. First send through a valid
|
|
// send request and assert that the available balance is reduced.
|
|
err = service.checkers.checkIncomingRequest(
|
|
ctx, uri, &routerrpc.SendToRouteRequest{
|
|
Route: &lnrpc.Route{
|
|
TotalAmtMsat: 1000,
|
|
},
|
|
PaymentHash: testHash2[:],
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
assertBalance(acct.ID, 3000)
|
|
|
|
// Now return an error response.
|
|
_, err = service.checkers.handleErrorResponse(ctx, uri, nil)
|
|
require.NoError(t, err)
|
|
|
|
// The balance should have gone back to what it was before the payment
|
|
// was initiated.
|
|
assertBalance(acct.ID, 4000)
|
|
|
|
routerMock.assertNoPaymentRequest(t)
|
|
|
|
// The final test we will do is to have two send requests initiated
|
|
// before the response for the first one has been received.
|
|
reqID3 := nextRequestID()
|
|
ctx = AddRequestIDToContext(ctxWithAcct, reqID3)
|
|
|
|
err = service.checkers.checkIncomingRequest(
|
|
ctx, uri, &routerrpc.SendToRouteRequest{
|
|
Route: &lnrpc.Route{
|
|
TotalAmtMsat: 2000,
|
|
},
|
|
PaymentHash: testHash3[:],
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
assertBalance(acct.ID, 2000)
|
|
|
|
reqID4 := nextRequestID()
|
|
ctx = AddRequestIDToContext(ctxWithAcct, reqID4)
|
|
err = service.checkers.checkIncomingRequest(
|
|
ctx, uri, &routerrpc.SendToRouteRequest{
|
|
Route: &lnrpc.Route{
|
|
TotalAmtMsat: 2000,
|
|
},
|
|
PaymentHash: testHash4[:],
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
assertBalance(acct.ID, 0)
|
|
|
|
// Ok, now let the response for the second request come through.
|
|
_, err = service.checkers.replaceOutgoingResponse(
|
|
ctx, uri, &lnrpc.HTLCAttempt{},
|
|
)
|
|
require.NoError(t, err)
|
|
assertBalance(acct.ID, 0)
|
|
|
|
// Let the first request error.
|
|
ctx = AddRequestIDToContext(ctxWithAcct, reqID3)
|
|
_, err = service.checkers.handleErrorResponse(ctx, uri, nil)
|
|
require.NoError(t, err)
|
|
assertBalance(acct.ID, 2000)
|
|
}
|
|
|
|
// assertMessagesEqual makes sure two proto messages are equal by JSON
|
|
// serializing them.
|
|
func assertMessagesEqual(t *testing.T, expected, actual proto.Message) {
|
|
expectedJSON, err := marshalOptions.Marshal(expected)
|
|
require.NoError(t, err)
|
|
|
|
actualJSON, err := marshalOptions.Marshal(actual)
|
|
require.NoError(t, err)
|
|
|
|
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)
|
|
}
|