From 4b0bc9a7c66fe5b012012204519bd7faebe4db64 Mon Sep 17 00:00:00 2001 From: positiveblue Date: Tue, 13 Sep 2022 13:41:35 -0700 Subject: [PATCH] invoicesrpc: unit tests for invoice hop hints --- lnrpc/invoicesrpc/addinvoice_test.go | 786 +++++++++++++++++++++++++++ 1 file changed, 786 insertions(+) create mode 100644 lnrpc/invoicesrpc/addinvoice_test.go diff --git a/lnrpc/invoicesrpc/addinvoice_test.go b/lnrpc/invoicesrpc/addinvoice_test.go new file mode 100644 index 000000000..d7ee620eb --- /dev/null +++ b/lnrpc/invoicesrpc/addinvoice_test.go @@ -0,0 +1,786 @@ +package invoicesrpc + +import ( + "encoding/hex" + "fmt" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/channeldb" + "github.com/lightningnetwork/lnd/lnwire" + "github.com/lightningnetwork/lnd/zpay32" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +type hopHintsConfigMock struct { + mock.Mock +} + +// IsPublicNode mocks node public state lookup. +func (h *hopHintsConfigMock) IsPublicNode(pubKey [33]byte) (bool, error) { + args := h.Mock.Called(pubKey) + return args.Bool(0), args.Error(1) +} + +// IsChannelActive is used to generate valid hop hints. +func (h *hopHintsConfigMock) IsChannelActive(chanID lnwire.ChannelID) bool { + args := h.Mock.Called(chanID) + return args.Bool(0) +} + +// GetAlias allows the peer's alias SCID to be retrieved for private +// option_scid_alias channels. +func (h *hopHintsConfigMock) GetAlias( + chanID lnwire.ChannelID) (lnwire.ShortChannelID, error) { + + args := h.Mock.Called(chanID) + return args.Get(0).(lnwire.ShortChannelID), args.Error(1) +} + +// FetchAllChannels retrieves all open channels currently stored +// within the database. +func (h *hopHintsConfigMock) FetchAllChannels() ([]*channeldb.OpenChannel, + error) { + + args := h.Mock.Called() + return args.Get(0).([]*channeldb.OpenChannel), args.Error(1) +} + +// FetchChannelEdgesByID attempts to lookup the two directed edges for +// the channel identified by the channel ID. +func (h *hopHintsConfigMock) FetchChannelEdgesByID(chanID uint64) ( + *channeldb.ChannelEdgeInfo, *channeldb.ChannelEdgePolicy, + *channeldb.ChannelEdgePolicy, error) { + + args := h.Mock.Called(chanID) + + // If our error is non-nil, we expect nil responses otherwise. Our + // casts below will fail with nil values, so we check our error and + // return early on failure first. + err := args.Error(3) + if err != nil { + return nil, nil, nil, err + } + + edgeInfo := args.Get(0).(*channeldb.ChannelEdgeInfo) + policy1 := args.Get(1).(*channeldb.ChannelEdgePolicy) + policy2 := args.Get(2).(*channeldb.ChannelEdgePolicy) + + return edgeInfo, policy1, policy2, err +} + +// getTestPubKey returns a valid parsed pub key to be used in our tests. +func getTestPubKey() *btcec.PublicKey { + pubkeyBytes, _ := hex.DecodeString( + "598ec453728e0ffe0ae2f5e174243cf58f2" + + "a3f2c83d2457b43036db568b11093", + ) + pubKeyY := new(btcec.FieldVal) + _ = pubKeyY.SetByteSlice(pubkeyBytes) + pubkey := btcec.NewPublicKey( + new(btcec.FieldVal).SetInt(4), + pubKeyY, + ) + return pubkey +} + +var shouldIncludeChannelTestCases = []struct { + name string + setupMock func(*hopHintsConfigMock) + channel *channeldb.OpenChannel + alreadyIncluded map[uint64]bool + cfg *SelectHopHintsCfg + hopHint zpay32.HopHint + remoteBalance lnwire.MilliSatoshi + include bool +}{{ + name: "already included channels should not be included " + + "again", + alreadyIncluded: map[uint64]bool{1: true}, + channel: &channeldb.OpenChannel{ + ShortChannelID: lnwire.NewShortChanIDFromInt(1), + }, + include: false, +}, { + name: "public channels should not be included", + setupMock: func(h *hopHintsConfigMock) { + fundingOutpoint := wire.OutPoint{ + Index: 0, + } + chanID := lnwire.NewChanIDFromOutPoint(&fundingOutpoint) + h.Mock.On( + "IsChannelActive", chanID, + ).Once().Return(true) + }, + channel: &channeldb.OpenChannel{ + FundingOutpoint: wire.OutPoint{ + Index: 0, + }, + ChannelFlags: lnwire.FFAnnounceChannel, + }, +}, { + name: "not active channels should not be included", + setupMock: func(h *hopHintsConfigMock) { + fundingOutpoint := wire.OutPoint{ + Index: 0, + } + chanID := lnwire.NewChanIDFromOutPoint(&fundingOutpoint) + h.Mock.On( + "IsChannelActive", chanID, + ).Once().Return(false) + }, + channel: &channeldb.OpenChannel{ + FundingOutpoint: wire.OutPoint{ + Index: 0, + }, + }, + include: false, +}, { + name: "a channel with a not public peer should not be included", + setupMock: func(h *hopHintsConfigMock) { + fundingOutpoint := wire.OutPoint{ + Index: 0, + } + chanID := lnwire.NewChanIDFromOutPoint(&fundingOutpoint) + + h.Mock.On( + "IsChannelActive", chanID, + ).Once().Return(true) + + h.Mock.On( + "IsPublicNode", mock.Anything, + ).Once().Return(false, nil) + }, + channel: &channeldb.OpenChannel{ + FundingOutpoint: wire.OutPoint{ + Index: 0, + }, + IdentityPub: getTestPubKey(), + }, + include: false, +}, { + name: "if we are unable to fetch the edge policy for the channel it " + + "should not be included", + setupMock: func(h *hopHintsConfigMock) { + fundingOutpoint := wire.OutPoint{ + Index: 0, + } + chanID := lnwire.NewChanIDFromOutPoint(&fundingOutpoint) + + h.Mock.On( + "IsChannelActive", chanID, + ).Once().Return(true) + + h.Mock.On( + "IsPublicNode", mock.Anything, + ).Once().Return(true, nil) + + h.Mock.On( + "FetchChannelEdgesByID", mock.Anything, + ).Once().Return(nil, nil, nil, fmt.Errorf("no edge")) + + // TODO(positiveblue): check that the func is called with the + // right scid when we have access to the `confirmedscid` form + // here. + h.Mock.On( + "FetchChannelEdgesByID", mock.Anything, + ).Once().Return(nil, nil, nil, fmt.Errorf("no edge")) + }, + channel: &channeldb.OpenChannel{ + FundingOutpoint: wire.OutPoint{ + Index: 0, + }, + IdentityPub: getTestPubKey(), + }, + include: false, +}, { + name: "channels with the option-scid-alias but not assigned alias " + + "yet should not be included", + setupMock: func(h *hopHintsConfigMock) { + fundingOutpoint := wire.OutPoint{ + Index: 0, + } + chanID := lnwire.NewChanIDFromOutPoint(&fundingOutpoint) + + h.Mock.On( + "IsChannelActive", chanID, + ).Once().Return(true) + + h.Mock.On( + "IsPublicNode", mock.Anything, + ).Once().Return(true, nil) + + h.Mock.On( + "FetchChannelEdgesByID", mock.Anything, + ).Once().Return( + &channeldb.ChannelEdgeInfo{}, + &channeldb.ChannelEdgePolicy{}, + &channeldb.ChannelEdgePolicy{}, nil, + ) + + h.Mock.On( + "GetAlias", mock.Anything, + ).Once().Return(lnwire.ShortChannelID{}, nil) + }, + channel: &channeldb.OpenChannel{ + FundingOutpoint: wire.OutPoint{ + Index: 0, + }, + IdentityPub: getTestPubKey(), + ChanType: channeldb.ScidAliasFeatureBit, + }, + include: false, +}, { + name: "channels with the option-scid-alias and an alias that has " + + "already been included should not be included again", + alreadyIncluded: map[uint64]bool{5: true}, + setupMock: func(h *hopHintsConfigMock) { + fundingOutpoint := wire.OutPoint{ + Index: 0, + } + chanID := lnwire.NewChanIDFromOutPoint(&fundingOutpoint) + + h.Mock.On( + "IsChannelActive", chanID, + ).Once().Return(true) + + h.Mock.On( + "IsPublicNode", mock.Anything, + ).Once().Return(true, nil) + + h.Mock.On( + "FetchChannelEdgesByID", mock.Anything, + ).Once().Return( + &channeldb.ChannelEdgeInfo{}, + &channeldb.ChannelEdgePolicy{}, + &channeldb.ChannelEdgePolicy{}, nil, + ) + alias := lnwire.ShortChannelID{TxPosition: 5} + h.Mock.On( + "GetAlias", mock.Anything, + ).Once().Return(alias, nil) + }, + channel: &channeldb.OpenChannel{ + FundingOutpoint: wire.OutPoint{ + Index: 0, + }, + IdentityPub: getTestPubKey(), + ChanType: channeldb.ScidAliasFeatureBit, + }, + include: false, +}, { + name: "channels that pass all the checks should be " + + "included, using policy 1", + alreadyIncluded: map[uint64]bool{5: true}, + setupMock: func(h *hopHintsConfigMock) { + fundingOutpoint := wire.OutPoint{ + Index: 1, + } + chanID := lnwire.NewChanIDFromOutPoint(&fundingOutpoint) + + h.Mock.On( + "IsChannelActive", chanID, + ).Once().Return(true) + + h.Mock.On( + "IsPublicNode", mock.Anything, + ).Once().Return(true, nil) + + var selectedPolicy [33]byte + copy(selectedPolicy[:], getTestPubKey().SerializeCompressed()) + + h.Mock.On( + "FetchChannelEdgesByID", mock.Anything, + ).Once().Return( + &channeldb.ChannelEdgeInfo{ + NodeKey1Bytes: selectedPolicy, + }, + &channeldb.ChannelEdgePolicy{ + FeeBaseMSat: 1000, + FeeProportionalMillionths: 20, + TimeLockDelta: 13, + }, + &channeldb.ChannelEdgePolicy{}, + nil, + ) + }, + channel: &channeldb.OpenChannel{ + FundingOutpoint: wire.OutPoint{ + Index: 1, + }, + IdentityPub: getTestPubKey(), + ShortChannelID: lnwire.NewShortChanIDFromInt(12), + }, + hopHint: zpay32.HopHint{ + NodeID: getTestPubKey(), + FeeBaseMSat: 1000, + FeeProportionalMillionths: 20, + ChannelID: 12, + CLTVExpiryDelta: 13, + }, + include: true, +}, { + name: "channels that pass all the checks should be " + + "included, using policy 2", + alreadyIncluded: map[uint64]bool{5: true}, + setupMock: func(h *hopHintsConfigMock) { + fundingOutpoint := wire.OutPoint{ + Index: 1, + } + chanID := lnwire.NewChanIDFromOutPoint(&fundingOutpoint) + + h.Mock.On( + "IsChannelActive", chanID, + ).Once().Return(true) + + h.Mock.On( + "IsPublicNode", mock.Anything, + ).Once().Return(true, nil) + + h.Mock.On( + "FetchChannelEdgesByID", mock.Anything, + ).Once().Return( + &channeldb.ChannelEdgeInfo{}, + &channeldb.ChannelEdgePolicy{}, + &channeldb.ChannelEdgePolicy{ + FeeBaseMSat: 1000, + FeeProportionalMillionths: 20, + TimeLockDelta: 13, + }, nil, + ) + }, + channel: &channeldb.OpenChannel{ + FundingOutpoint: wire.OutPoint{ + Index: 1, + }, + IdentityPub: getTestPubKey(), + ShortChannelID: lnwire.NewShortChanIDFromInt(12), + }, + hopHint: zpay32.HopHint{ + NodeID: getTestPubKey(), + FeeBaseMSat: 1000, + FeeProportionalMillionths: 20, + ChannelID: 12, + CLTVExpiryDelta: 13, + }, + include: true, +}, { + name: "channels that pass all the checks and have an alias " + + "should be included with the alias", + alreadyIncluded: map[uint64]bool{5: true}, + setupMock: func(h *hopHintsConfigMock) { + fundingOutpoint := wire.OutPoint{ + Index: 1, + } + chanID := lnwire.NewChanIDFromOutPoint(&fundingOutpoint) + + h.Mock.On( + "IsChannelActive", chanID, + ).Once().Return(true) + + h.Mock.On( + "IsPublicNode", mock.Anything, + ).Once().Return(true, nil) + + h.Mock.On( + "FetchChannelEdgesByID", mock.Anything, + ).Once().Return( + &channeldb.ChannelEdgeInfo{}, + &channeldb.ChannelEdgePolicy{}, + &channeldb.ChannelEdgePolicy{ + FeeBaseMSat: 1000, + FeeProportionalMillionths: 20, + TimeLockDelta: 13, + }, nil, + ) + + aliasSCID := lnwire.NewShortChanIDFromInt(15) + + h.Mock.On( + "GetAlias", mock.Anything, + ).Once().Return(aliasSCID, nil) + }, + channel: &channeldb.OpenChannel{ + FundingOutpoint: wire.OutPoint{ + Index: 1, + }, + IdentityPub: getTestPubKey(), + ShortChannelID: lnwire.NewShortChanIDFromInt(12), + ChanType: channeldb.ScidAliasFeatureBit, + }, + hopHint: zpay32.HopHint{ + NodeID: getTestPubKey(), + FeeBaseMSat: 1000, + FeeProportionalMillionths: 20, + ChannelID: 15, + CLTVExpiryDelta: 13, + }, + include: true, +}} + +func TestShouldIncludeChannel(t *testing.T) { + for _, tc := range shouldIncludeChannelTestCases { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Create mock and prime it for the test case. + mock := &hopHintsConfigMock{} + if tc.setupMock != nil { + tc.setupMock(mock) + } + defer mock.AssertExpectations(t) + + cfg := &SelectHopHintsCfg{ + IsPublicNode: mock.IsPublicNode, + IsChannelActive: mock.IsChannelActive, + FetchChannelEdgesByID: mock.FetchChannelEdgesByID, + GetAlias: mock.GetAlias, + } + + hopHint, remoteBalance, include := shouldIncludeChannel( + cfg, tc.channel, tc.alreadyIncluded, + ) + + require.Equal(t, tc.include, include) + if include { + require.Equal(t, tc.hopHint, hopHint) + require.Equal( + t, tc.remoteBalance, remoteBalance, + ) + } + }) + } +} + +var sufficientHintsTestCases = []struct { + name string + nHintsLeft int + currentAmount lnwire.MilliSatoshi + targetAmount lnwire.MilliSatoshi + done bool +}{{ + name: "not enoguh hints neither bandwidth", + nHintsLeft: 3, + currentAmount: 100, + targetAmount: 200, + done: false, +}, { + name: "enough hints", + nHintsLeft: 0, + done: true, +}, { + name: "enoguh bandwidth", + nHintsLeft: 1, + currentAmount: 200, + targetAmount: 200, + done: true, +}} + +func TestSufficientHints(t *testing.T) { + for _, tc := range sufficientHintsTestCases { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + enoughHints := sufficientHints( + tc.nHintsLeft, tc.currentAmount, + tc.targetAmount, + ) + require.Equal(t, tc.done, enoughHints) + }) + } +} + +var populateHopHintsTestCases = []struct { + name string + setupMock func(*hopHintsConfigMock) + amount lnwire.MilliSatoshi + maxHopHints int + forcedHints [][]zpay32.HopHint + expectedHopHints [][]zpay32.HopHint +}{{ + name: "populate hop hints with forced hints", + maxHopHints: 1, + forcedHints: [][]zpay32.HopHint{ + { + {ChannelID: 12}, + }, + }, + expectedHopHints: [][]zpay32.HopHint{ + { + {ChannelID: 12}, + }, + }, +}, { + name: "populate hop hints stops when we reached the max number of " + + "hop hints allowed", + setupMock: func(h *hopHintsConfigMock) { + fundingOutpoint := wire.OutPoint{Index: 9} + chanID := lnwire.NewChanIDFromOutPoint(&fundingOutpoint) + allChannels := []*channeldb.OpenChannel{ + { + FundingOutpoint: fundingOutpoint, + ShortChannelID: lnwire.NewShortChanIDFromInt(9), + IdentityPub: getTestPubKey(), + }, + // Have one empty channel that we should not process + // because we have already finished. + {}, + } + + h.Mock.On( + "FetchAllChannels", + ).Once().Return(allChannels, nil) + + h.Mock.On( + "IsChannelActive", chanID, + ).Once().Return(true) + + h.Mock.On( + "IsPublicNode", mock.Anything, + ).Once().Return(true, nil) + + h.Mock.On( + "FetchChannelEdgesByID", mock.Anything, + ).Once().Return( + &channeldb.ChannelEdgeInfo{}, + &channeldb.ChannelEdgePolicy{}, + &channeldb.ChannelEdgePolicy{}, nil, + ) + }, + maxHopHints: 1, + amount: 1_000_000, + expectedHopHints: [][]zpay32.HopHint{ + { + { + NodeID: getTestPubKey(), + ChannelID: 9, + }, + }, + }, +}, { + name: "populate hop hints stops when we reached the targeted bandwidth", + setupMock: func(h *hopHintsConfigMock) { + fundingOutpoint := wire.OutPoint{Index: 9} + chanID := lnwire.NewChanIDFromOutPoint(&fundingOutpoint) + remoteBalance := lnwire.MilliSatoshi(10_000_000) + allChannels := []*channeldb.OpenChannel{ + { + LocalCommitment: channeldb.ChannelCommitment{ + RemoteBalance: remoteBalance, + }, + FundingOutpoint: fundingOutpoint, + ShortChannelID: lnwire.NewShortChanIDFromInt(9), + IdentityPub: getTestPubKey(), + }, + // Have one empty channel that we should not process + // because we have already finished. + {}, + } + + h.Mock.On( + "FetchAllChannels", + ).Once().Return(allChannels, nil) + + h.Mock.On( + "IsChannelActive", chanID, + ).Once().Return(true) + + h.Mock.On( + "IsPublicNode", mock.Anything, + ).Once().Return(true, nil) + + h.Mock.On( + "FetchChannelEdgesByID", mock.Anything, + ).Once().Return( + &channeldb.ChannelEdgeInfo{}, + &channeldb.ChannelEdgePolicy{}, + &channeldb.ChannelEdgePolicy{}, nil, + ) + }, + maxHopHints: 10, + amount: 1_000_000, + expectedHopHints: [][]zpay32.HopHint{ + { + { + NodeID: getTestPubKey(), + ChannelID: 9, + }, + }, + }, +}, { + name: "populate hop hints tries to use the channels with higher " + + "remote balance frist", + setupMock: func(h *hopHintsConfigMock) { + fundingOutpoint := wire.OutPoint{Index: 9} + chanID := lnwire.NewChanIDFromOutPoint(&fundingOutpoint) + remoteBalance := lnwire.MilliSatoshi(10_000_000) + allChannels := []*channeldb.OpenChannel{ + // Because the channels with higher remote balance have + // enough bandwidth we should never use this one. + {}, + { + LocalCommitment: channeldb.ChannelCommitment{ + RemoteBalance: remoteBalance, + }, + FundingOutpoint: fundingOutpoint, + ShortChannelID: lnwire.NewShortChanIDFromInt(9), + IdentityPub: getTestPubKey(), + }, + } + + h.Mock.On( + "FetchAllChannels", + ).Once().Return(allChannels, nil) + + h.Mock.On( + "IsChannelActive", chanID, + ).Once().Return(true) + + h.Mock.On( + "IsPublicNode", mock.Anything, + ).Once().Return(true, nil) + + h.Mock.On( + "FetchChannelEdgesByID", mock.Anything, + ).Once().Return( + &channeldb.ChannelEdgeInfo{}, + &channeldb.ChannelEdgePolicy{}, + &channeldb.ChannelEdgePolicy{}, nil, + ) + }, + maxHopHints: 1, + amount: 1_000_000, + expectedHopHints: [][]zpay32.HopHint{ + { + { + NodeID: getTestPubKey(), + ChannelID: 9, + }, + }, + }, +}, { + name: "populate hop hints stops after having considered all the open " + + "channels", + setupMock: func(h *hopHintsConfigMock) { + fundingOutpoint1 := wire.OutPoint{Index: 9} + chanID1 := lnwire.NewChanIDFromOutPoint(&fundingOutpoint1) + remoteBalance1 := lnwire.MilliSatoshi(10_000_000) + + fundingOutpoint2 := wire.OutPoint{Index: 2} + chanID2 := lnwire.NewChanIDFromOutPoint(&fundingOutpoint2) + remoteBalance2 := lnwire.MilliSatoshi(1_000_000) + + allChannels := []*channeldb.OpenChannel{ + // After sorting we will first process chanID1 and then + // chanID2. + { + LocalCommitment: channeldb.ChannelCommitment{ + RemoteBalance: remoteBalance2, + }, + FundingOutpoint: fundingOutpoint2, + ShortChannelID: lnwire.NewShortChanIDFromInt(2), + IdentityPub: getTestPubKey(), + }, + { + LocalCommitment: channeldb.ChannelCommitment{ + RemoteBalance: remoteBalance1, + }, + FundingOutpoint: fundingOutpoint1, + ShortChannelID: lnwire.NewShortChanIDFromInt(9), + IdentityPub: getTestPubKey(), + }, + } + + h.Mock.On( + "FetchAllChannels", + ).Once().Return(allChannels, nil) + + // Prepare the mock for the first channel. + h.Mock.On( + "IsChannelActive", chanID1, + ).Once().Return(true) + + h.Mock.On( + "IsPublicNode", mock.Anything, + ).Once().Return(true, nil) + + h.Mock.On( + "FetchChannelEdgesByID", mock.Anything, + ).Once().Return( + &channeldb.ChannelEdgeInfo{}, + &channeldb.ChannelEdgePolicy{}, + &channeldb.ChannelEdgePolicy{}, nil, + ) + + // Prepare the mock for the second channel. + h.Mock.On( + "IsChannelActive", chanID2, + ).Once().Return(true) + + h.Mock.On( + "IsPublicNode", mock.Anything, + ).Once().Return(true, nil) + + h.Mock.On( + "FetchChannelEdgesByID", mock.Anything, + ).Once().Return( + &channeldb.ChannelEdgeInfo{}, + &channeldb.ChannelEdgePolicy{}, + &channeldb.ChannelEdgePolicy{}, nil, + ) + }, + maxHopHints: 10, + amount: 100_000_000, + expectedHopHints: [][]zpay32.HopHint{ + { + { + NodeID: getTestPubKey(), + ChannelID: 9, + }, + }, { + { + NodeID: getTestPubKey(), + ChannelID: 2, + }, + }, + }, +}} + +func TestPopulateHopHints(t *testing.T) { + for _, tc := range populateHopHintsTestCases { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Create mock and prime it for the test case. + mock := &hopHintsConfigMock{} + if tc.setupMock != nil { + tc.setupMock(mock) + } + defer mock.AssertExpectations(t) + + cfg := &SelectHopHintsCfg{ + IsPublicNode: mock.IsPublicNode, + IsChannelActive: mock.IsChannelActive, + FetchChannelEdgesByID: mock.FetchChannelEdgesByID, + GetAlias: mock.GetAlias, + FetchAllChannels: mock.FetchAllChannels, + MaxHopHints: tc.maxHopHints, + } + hopHints, err := PopulateHopHints( + cfg, tc.amount, tc.forcedHints, + ) + require.NoError(t, err) + // We shuffle the elements in the hop hint list so we + // need to compare the elements here. + require.ElementsMatch(t, tc.expectedHopHints, hopHints) + }) + } +}