package routing import ( "bytes" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "math" "net" "os" "reflect" "strings" "testing" "time" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/ecdsa" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" sphinx "github.com/lightningnetwork/lightning-onion" "github.com/lightningnetwork/lnd/channeldb" "github.com/lightningnetwork/lnd/channeldb/models" "github.com/lightningnetwork/lnd/fn" "github.com/lightningnetwork/lnd/htlcswitch" switchhop "github.com/lightningnetwork/lnd/htlcswitch/hop" "github.com/lightningnetwork/lnd/kvdb" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/record" "github.com/lightningnetwork/lnd/routing/route" "github.com/stretchr/testify/require" ) const ( // basicGraphFilePath is the file path for a basic graph used within // the tests. The basic graph consists of 5 nodes with 5 channels // connecting them. basicGraphFilePath = "testdata/basic_graph.json" // specExampleFilePath is a file path which stores an example which // implementations will use in order to ensure that they're calculating // the payload for each hop in path properly. specExampleFilePath = "testdata/spec_example.json" // noFeeLimit is the maximum value of a payment through Lightning. We // can use this value to signal there is no fee limit since payments // should never be larger than this. noFeeLimit = lnwire.MilliSatoshi(math.MaxUint32) ) var ( noRestrictions = &RestrictParams{ FeeLimit: noFeeLimit, ProbabilitySource: noProbabilitySource, CltvLimit: math.MaxUint32, } testPathFindingConfig = &PathFindingConfig{} tlvFeatures = lnwire.NewFeatureVector( lnwire.NewRawFeatureVector( lnwire.TLVOnionPayloadRequired, ), lnwire.Features, ) payAddrFeatures = lnwire.NewFeatureVector( lnwire.NewRawFeatureVector( lnwire.PaymentAddrOptional, ), lnwire.Features, ) tlvPayAddrFeatures = lnwire.NewFeatureVector( lnwire.NewRawFeatureVector( lnwire.TLVOnionPayloadRequired, lnwire.PaymentAddrOptional, ), lnwire.Features, ) mppFeatures = lnwire.NewRawFeatureVector( lnwire.TLVOnionPayloadRequired, lnwire.PaymentAddrOptional, lnwire.MPPOptional, ) unknownRequiredFeatures = lnwire.NewFeatureVector( lnwire.NewRawFeatureVector(100), lnwire.Features, ) ) var ( testRBytes, _ = hex.DecodeString("8ce2bc69281ce27da07e6683571319d18e949ddfa2965fb6caa1bf0314f882d7") testSBytes, _ = hex.DecodeString("299105481d63e0f4bc2a88121167221b6700d72a0ead154c03be696a292d24ae") testRScalar = new(btcec.ModNScalar) testSScalar = new(btcec.ModNScalar) _ = testRScalar.SetByteSlice(testRBytes) _ = testSScalar.SetByteSlice(testSBytes) testSig = ecdsa.NewSignature(testRScalar, testSScalar) testAuthProof = models.ChannelAuthProof{ NodeSig1Bytes: testSig.Serialize(), NodeSig2Bytes: testSig.Serialize(), BitcoinSig1Bytes: testSig.Serialize(), BitcoinSig2Bytes: testSig.Serialize(), } ) // noProbabilitySource is used in testing to return the same probability 1 for // all edges. func noProbabilitySource(route.Vertex, route.Vertex, lnwire.MilliSatoshi, btcutil.Amount) float64 { return 1 } // testGraph is the struct which corresponds to the JSON format used to encode // graphs within the files in the testdata directory. // // TODO(roasbeef): add test graph auto-generator type testGraph struct { Info []string `json:"info"` Nodes []testNode `json:"nodes"` Edges []testChan `json:"edges"` } // testNode represents a node within the test graph above. We skip certain // information such as the node's IP address as that information isn't needed // for our tests. Private keys are optional. If set, they should be consistent // with the public key. The private key is used to sign error messages // sent from the node. type testNode struct { Source bool `json:"source"` PubKey string `json:"pubkey"` PrivKey string `json:"privkey"` Alias string `json:"alias"` } // testChan represents the JSON version of a payment channel. This struct // matches the Json that's encoded under the "edges" key within the test graph. type testChan struct { Node1 string `json:"node_1"` Node2 string `json:"node_2"` ChannelID uint64 `json:"channel_id"` ChannelPoint string `json:"channel_point"` ChannelFlags uint8 `json:"channel_flags"` MessageFlags uint8 `json:"message_flags"` Expiry uint16 `json:"expiry"` MinHTLC int64 `json:"min_htlc"` MaxHTLC int64 `json:"max_htlc"` FeeBaseMsat int64 `json:"fee_base_msat"` FeeRate int64 `json:"fee_rate"` Capacity int64 `json:"capacity"` } // makeTestGraph creates a new instance of a channeldb.ChannelGraph for testing // purposes. func makeTestGraph(t *testing.T, useCache bool) (*channeldb.ChannelGraph, kvdb.Backend, error) { // Create channelgraph for the first time. backend, backendCleanup, err := kvdb.GetTestBackend(t.TempDir(), "cgr") if err != nil { return nil, nil, err } t.Cleanup(backendCleanup) opts := channeldb.DefaultOptions() graph, err := channeldb.NewChannelGraph( backend, opts.RejectCacheSize, opts.ChannelCacheSize, opts.BatchCommitInterval, opts.PreAllocCacheNumNodes, useCache, false, ) if err != nil { return nil, nil, err } return graph, backend, nil } // parseTestGraph returns a fully populated ChannelGraph given a path to a JSON // file which encodes a test graph. func parseTestGraph(t *testing.T, useCache bool, path string) ( *testGraphInstance, error) { graphJSON, err := os.ReadFile(path) if err != nil { return nil, err } // First unmarshal the JSON graph into an instance of the testGraph // struct. Using the struct tags created above in the struct, the JSON // will be properly parsed into the struct above. var g testGraph if err := json.Unmarshal(graphJSON, &g); err != nil { return nil, err } // We'll use this fake address for the IP address of all the nodes in // our tests. This value isn't needed for path finding so it doesn't // need to be unique. var testAddrs []net.Addr testAddr, err := net.ResolveTCPAddr("tcp", "192.0.0.1:8888") if err != nil { return nil, err } testAddrs = append(testAddrs, testAddr) // Next, create a temporary graph database for usage within the test. graph, graphBackend, err := makeTestGraph(t, useCache) if err != nil { return nil, err } aliasMap := make(map[string]route.Vertex) privKeyMap := make(map[string]*btcec.PrivateKey) channelIDs := make(map[route.Vertex]map[route.Vertex]uint64) links := make(map[lnwire.ShortChannelID]htlcswitch.ChannelLink) var source *channeldb.LightningNode // First we insert all the nodes within the graph as vertexes. for _, node := range g.Nodes { pubBytes, err := hex.DecodeString(node.PubKey) if err != nil { return nil, err } dbNode := &channeldb.LightningNode{ HaveNodeAnnouncement: true, AuthSigBytes: testSig.Serialize(), LastUpdate: testTime, Addresses: testAddrs, Alias: node.Alias, Features: testFeatures, } copy(dbNode.PubKeyBytes[:], pubBytes) // We require all aliases within the graph to be unique for our // tests. if _, ok := aliasMap[node.Alias]; ok { return nil, errors.New("aliases for nodes " + "must be unique!") } // If the alias is unique, then add the node to the // alias map for easy lookup. aliasMap[node.Alias] = dbNode.PubKeyBytes // private keys are needed for signing error messages. If set // check the consistency with the public key. privBytes, err := hex.DecodeString(node.PrivKey) if err != nil { return nil, err } if len(privBytes) > 0 { key, derivedPub := btcec.PrivKeyFromBytes( privBytes, ) if !bytes.Equal( pubBytes, derivedPub.SerializeCompressed(), ) { return nil, fmt.Errorf("%s public key and "+ "private key are inconsistent\n"+ "got %x\nwant %x\n", node.Alias, derivedPub.SerializeCompressed(), pubBytes, ) } privKeyMap[node.Alias] = key } // If the node is tagged as the source, then we create a // pointer to is so we can mark the source in the graph // properly. if node.Source { // If we come across a node that's marked as the // source, and we've already set the source in a prior // iteration, then the JSON has an error as only ONE // node can be the source in the graph. if source != nil { return nil, errors.New("JSON is invalid " + "multiple nodes are tagged as the " + "source") } source = dbNode } // With the node fully parsed, add it as a vertex within the // graph. if err := graph.AddLightningNode(dbNode); err != nil { return nil, err } } if source != nil { // Set the selected source node if err := graph.SetSourceNode(source); err != nil { return nil, err } } // With all the vertexes inserted, we can now insert the edges into the // test graph. for _, edge := range g.Edges { node1Bytes, err := hex.DecodeString(edge.Node1) if err != nil { return nil, err } node2Bytes, err := hex.DecodeString(edge.Node2) if err != nil { return nil, err } if bytes.Compare(node1Bytes, node2Bytes) == 1 { return nil, fmt.Errorf( "channel %v node order incorrect", edge.ChannelID, ) } fundingTXID := strings.Split(edge.ChannelPoint, ":")[0] txidBytes, err := chainhash.NewHashFromStr(fundingTXID) if err != nil { return nil, err } fundingPoint := wire.OutPoint{ Hash: *txidBytes, Index: 0, } // We first insert the existence of the edge between the two // nodes. edgeInfo := models.ChannelEdgeInfo{ ChannelID: edge.ChannelID, AuthProof: &testAuthProof, ChannelPoint: fundingPoint, Capacity: btcutil.Amount(edge.Capacity), } copy(edgeInfo.NodeKey1Bytes[:], node1Bytes) copy(edgeInfo.NodeKey2Bytes[:], node2Bytes) copy(edgeInfo.BitcoinKey1Bytes[:], node1Bytes) copy(edgeInfo.BitcoinKey2Bytes[:], node2Bytes) shortID := lnwire.NewShortChanIDFromInt(edge.ChannelID) links[shortID] = &mockLink{ bandwidth: lnwire.MilliSatoshi( edgeInfo.Capacity * 1000, ), } err = graph.AddChannelEdge(&edgeInfo) if err != nil && err != channeldb.ErrEdgeAlreadyExist { return nil, err } channelFlags := lnwire.ChanUpdateChanFlags(edge.ChannelFlags) isUpdate1 := channelFlags&lnwire.ChanUpdateDirection == 0 targetNode := edgeInfo.NodeKey1Bytes if isUpdate1 { targetNode = edgeInfo.NodeKey2Bytes } edgePolicy := &models.ChannelEdgePolicy{ SigBytes: testSig.Serialize(), MessageFlags: lnwire.ChanUpdateMsgFlags(edge.MessageFlags), ChannelFlags: channelFlags, ChannelID: edge.ChannelID, LastUpdate: testTime, TimeLockDelta: edge.Expiry, MinHTLC: lnwire.MilliSatoshi(edge.MinHTLC), MaxHTLC: lnwire.MilliSatoshi(edge.MaxHTLC), FeeBaseMSat: lnwire.MilliSatoshi(edge.FeeBaseMsat), FeeProportionalMillionths: lnwire.MilliSatoshi(edge.FeeRate), ToNode: targetNode, } if err := graph.UpdateEdgePolicy(edgePolicy); err != nil { return nil, err } // We also store the channel IDs info for each of the node. node1Vertex, err := route.NewVertexFromBytes(node1Bytes) if err != nil { return nil, err } node2Vertex, err := route.NewVertexFromBytes(node2Bytes) if err != nil { return nil, err } if _, ok := channelIDs[node1Vertex]; !ok { channelIDs[node1Vertex] = map[route.Vertex]uint64{} } channelIDs[node1Vertex][node2Vertex] = edge.ChannelID if _, ok := channelIDs[node2Vertex]; !ok { channelIDs[node2Vertex] = map[route.Vertex]uint64{} } channelIDs[node2Vertex][node1Vertex] = edge.ChannelID } return &testGraphInstance{ graph: graph, graphBackend: graphBackend, aliasMap: aliasMap, privKeyMap: privKeyMap, channelIDs: channelIDs, links: links, }, nil } type testChannelPolicy struct { Expiry uint16 MinHTLC lnwire.MilliSatoshi MaxHTLC lnwire.MilliSatoshi FeeBaseMsat lnwire.MilliSatoshi FeeRate lnwire.MilliSatoshi InboundFeeBaseMsat int64 InboundFeeRate int64 LastUpdate time.Time Disabled bool Features *lnwire.FeatureVector } type testChannelEnd struct { Alias string *testChannelPolicy } func symmetricTestChannel(alias1, alias2 string, capacity btcutil.Amount, policy *testChannelPolicy, chanID ...uint64) *testChannel { // Leaving id zero will result in auto-generation of a channel id during // graph construction. var id uint64 if len(chanID) > 0 { id = chanID[0] } policy2 := *policy return asymmetricTestChannel( alias1, alias2, capacity, policy, &policy2, id, ) } func asymmetricTestChannel(alias1, alias2 string, capacity btcutil.Amount, policy1, policy2 *testChannelPolicy, id uint64) *testChannel { return &testChannel{ Capacity: capacity, Node1: &testChannelEnd{ Alias: alias1, testChannelPolicy: policy1, }, Node2: &testChannelEnd{ Alias: alias2, testChannelPolicy: policy2, }, ChannelID: id, } } type testChannel struct { Node1 *testChannelEnd Node2 *testChannelEnd Capacity btcutil.Amount ChannelID uint64 } type testGraphInstance struct { graph *channeldb.ChannelGraph graphBackend kvdb.Backend // aliasMap is a map from a node's alias to its public key. This type is // provided in order to allow easily look up from the human memorable alias // to an exact node's public key. aliasMap map[string]route.Vertex // privKeyMap maps a node alias to its private key. This is used to be // able to mock a remote node's signing behaviour. privKeyMap map[string]*btcec.PrivateKey // channelIDs stores the channel ID for each node. channelIDs map[route.Vertex]map[route.Vertex]uint64 // links maps channel ids to a mock channel update handler. links map[lnwire.ShortChannelID]htlcswitch.ChannelLink } // getLink is a mocked link lookup function which looks up links in our test // graph. func (g *testGraphInstance) getLink(chanID lnwire.ShortChannelID) ( htlcswitch.ChannelLink, error) { link, ok := g.links[chanID] if !ok { return nil, fmt.Errorf("link not found in mock: %v", chanID) } return link, nil } // createTestGraphFromChannels returns a fully populated ChannelGraph based on a set of // test channels. Additional required information like keys are derived in // a deterministic way and added to the channel graph. A list of nodes is // not required and derived from the channel data. The goal is to keep // instantiating a test channel graph as light weight as possible. func createTestGraphFromChannels(t *testing.T, useCache bool, testChannels []*testChannel, source string, sourceFeatureBits ...lnwire.FeatureBit) (*testGraphInstance, error) { // We'll use this fake address for the IP address of all the nodes in // our tests. This value isn't needed for path finding so it doesn't // need to be unique. var testAddrs []net.Addr testAddr, err := net.ResolveTCPAddr("tcp", "192.0.0.1:8888") if err != nil { return nil, err } testAddrs = append(testAddrs, testAddr) // Next, create a temporary graph database for usage within the test. graph, graphBackend, err := makeTestGraph(t, useCache) if err != nil { return nil, err } aliasMap := make(map[string]route.Vertex) privKeyMap := make(map[string]*btcec.PrivateKey) nodeIndex := byte(0) addNodeWithAlias := func(alias string, features *lnwire.FeatureVector) ( *channeldb.LightningNode, error) { keyBytes := []byte{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, nodeIndex + 1, } privKey, pubKey := btcec.PrivKeyFromBytes(keyBytes) if features == nil { features = lnwire.EmptyFeatureVector() } dbNode := &channeldb.LightningNode{ HaveNodeAnnouncement: true, AuthSigBytes: testSig.Serialize(), LastUpdate: testTime, Addresses: testAddrs, Alias: alias, Features: features, } copy(dbNode.PubKeyBytes[:], pubKey.SerializeCompressed()) privKeyMap[alias] = privKey // With the node fully parsed, add it as a vertex within the // graph. if err := graph.AddLightningNode(dbNode); err != nil { return nil, err } aliasMap[alias] = dbNode.PubKeyBytes nodeIndex++ return dbNode, nil } // Add the source node. dbNode, err := addNodeWithAlias( source, lnwire.NewFeatureVector( lnwire.NewRawFeatureVector(sourceFeatureBits...), lnwire.Features, ), ) require.NoError(t, err) if err = graph.SetSourceNode(dbNode); err != nil { return nil, err } // Initialize variable that keeps track of the next channel id to assign // if none is specified. nextUnassignedChannelID := uint64(100000) links := make(map[lnwire.ShortChannelID]htlcswitch.ChannelLink) for _, testChannel := range testChannels { for _, node := range []*testChannelEnd{ testChannel.Node1, testChannel.Node2, } { _, exists := aliasMap[node.Alias] if !exists { var features *lnwire.FeatureVector if node.testChannelPolicy != nil { features = node.testChannelPolicy.Features } _, err := addNodeWithAlias( node.Alias, features, ) if err != nil { return nil, err } } } channelID := testChannel.ChannelID // If no channel id is specified, generate an id. if channelID == 0 { channelID = nextUnassignedChannelID nextUnassignedChannelID++ } var hash [sha256.Size]byte hash[len(hash)-1] = byte(channelID) fundingPoint := &wire.OutPoint{ Hash: chainhash.Hash(hash), Index: 0, } capacity := lnwire.MilliSatoshi(testChannel.Capacity * 1000) shortID := lnwire.NewShortChanIDFromInt(channelID) links[shortID] = &mockLink{ bandwidth: capacity, } // Sort nodes node1 := testChannel.Node1 node2 := testChannel.Node2 node1Vertex := aliasMap[node1.Alias] node2Vertex := aliasMap[node2.Alias] if bytes.Compare(node1Vertex[:], node2Vertex[:]) == 1 { node1, node2 = node2, node1 node1Vertex, node2Vertex = node2Vertex, node1Vertex } // We first insert the existence of the edge between the two // nodes. edgeInfo := models.ChannelEdgeInfo{ ChannelID: channelID, AuthProof: &testAuthProof, ChannelPoint: *fundingPoint, Capacity: testChannel.Capacity, NodeKey1Bytes: node1Vertex, BitcoinKey1Bytes: node1Vertex, NodeKey2Bytes: node2Vertex, BitcoinKey2Bytes: node2Vertex, } err = graph.AddChannelEdge(&edgeInfo) if err != nil && err != channeldb.ErrEdgeAlreadyExist { return nil, err } getExtraData := func( end *testChannelEnd) lnwire.ExtraOpaqueData { var extraData lnwire.ExtraOpaqueData inboundFee := lnwire.Fee{ BaseFee: int32(end.InboundFeeBaseMsat), FeeRate: int32(end.InboundFeeRate), } require.NoError(t, extraData.PackRecords(&inboundFee)) return extraData } if node1.testChannelPolicy != nil { var msgFlags lnwire.ChanUpdateMsgFlags if node1.MaxHTLC != 0 { msgFlags |= lnwire.ChanUpdateRequiredMaxHtlc } var channelFlags lnwire.ChanUpdateChanFlags if node1.Disabled { channelFlags |= lnwire.ChanUpdateDisabled } edgePolicy := &models.ChannelEdgePolicy{ SigBytes: testSig.Serialize(), MessageFlags: msgFlags, ChannelFlags: channelFlags, ChannelID: channelID, LastUpdate: node1.LastUpdate, TimeLockDelta: node1.Expiry, MinHTLC: node1.MinHTLC, MaxHTLC: node1.MaxHTLC, FeeBaseMSat: node1.FeeBaseMsat, FeeProportionalMillionths: node1.FeeRate, ToNode: node2Vertex, ExtraOpaqueData: getExtraData(node1), } if err := graph.UpdateEdgePolicy(edgePolicy); err != nil { return nil, err } } if node2.testChannelPolicy != nil { var msgFlags lnwire.ChanUpdateMsgFlags if node2.MaxHTLC != 0 { msgFlags |= lnwire.ChanUpdateRequiredMaxHtlc } var channelFlags lnwire.ChanUpdateChanFlags if node2.Disabled { channelFlags |= lnwire.ChanUpdateDisabled } channelFlags |= lnwire.ChanUpdateDirection edgePolicy := &models.ChannelEdgePolicy{ SigBytes: testSig.Serialize(), MessageFlags: msgFlags, ChannelFlags: channelFlags, ChannelID: channelID, LastUpdate: node2.LastUpdate, TimeLockDelta: node2.Expiry, MinHTLC: node2.MinHTLC, MaxHTLC: node2.MaxHTLC, FeeBaseMSat: node2.FeeBaseMsat, FeeProportionalMillionths: node2.FeeRate, ToNode: node1Vertex, ExtraOpaqueData: getExtraData(node2), } if err := graph.UpdateEdgePolicy(edgePolicy); err != nil { return nil, err } } channelID++ } return &testGraphInstance{ graph: graph, graphBackend: graphBackend, aliasMap: aliasMap, privKeyMap: privKeyMap, links: links, }, nil } // TestPathFinding tests all path finding related cases both with the in-memory // graph cached turned on and off. func TestPathFinding(t *testing.T) { testCases := []struct { name string fn func(t *testing.T, useCache bool) }{{ name: "lowest fee path", fn: runFindLowestFeePath, }, { name: "basic graph path finding", fn: runBasicGraphPathFinding, }, { name: "path finding with additional edges", fn: runPathFindingWithAdditionalEdges, }, { name: "path finding with redundant additional edges", fn: runPathFindingWithRedundantAdditionalEdges, }, { name: "new route path too long", fn: runNewRoutePathTooLong, }, { name: "path not available", fn: runPathNotAvailable, }, { name: "destination tlv graph fallback", fn: runDestTLVGraphFallback, }, { name: "missing feature dependency", fn: runMissingFeatureDep, }, { name: "unknown required features", fn: runUnknownRequiredFeatures, }, { name: "destination payment address", fn: runDestPaymentAddr, }, { name: "path insufficient capacity", fn: runPathInsufficientCapacity, }, { name: "route fail min HTLC", fn: runRouteFailMinHTLC, }, { name: "route fail max HTLC", fn: runRouteFailMaxHTLC, }, { name: "route fail disabled edge", fn: runRouteFailDisabledEdge, }, { name: "path source edges bandwidth", fn: runPathSourceEdgesBandwidth, }, { name: "restrict outgoing channel", fn: runRestrictOutgoingChannel, }, { name: "restrict last hop", fn: runRestrictLastHop, }, { name: "CLTV limit", fn: runCltvLimit, }, { name: "probability routing", fn: runProbabilityRouting, }, { name: "equal cost route selection", fn: runEqualCostRouteSelection, }, { name: "no cycle", fn: runNoCycle, }, { name: "route to self", fn: runRouteToSelf, }, { name: "with metadata", fn: runFindPathWithMetadata, }, { name: "inbound fees", fn: runInboundFees, }} // Run with graph cache enabled. for _, tc := range testCases { tc := tc t.Run("cache=true/"+tc.name, func(tt *testing.T) { tt.Parallel() tc.fn(tt, true) }) } // And with the DB fallback to make sure everything works the same // still. for _, tc := range testCases { tc := tc t.Run("cache=false/"+tc.name, func(tt *testing.T) { tt.Parallel() tc.fn(tt, false) }) } } // runFindPathWithMetadata tests that metadata is taken into account during // pathfinding. func runFindPathWithMetadata(t *testing.T, useCache bool) { testChannels := []*testChannel{ symmetricTestChannel("alice", "bob", 100000, &testChannelPolicy{ Expiry: 144, FeeRate: 400, MinHTLC: 1, MaxHTLC: 100000000, }), } ctx := newPathFindingTestContext(t, useCache, testChannels, "alice") paymentAmt := lnwire.NewMSatFromSatoshis(100) target := ctx.keyFromAlias("bob") // Assert that a path is found when metadata is specified. ctx.restrictParams.Metadata = []byte{1, 2, 3} ctx.restrictParams.DestFeatures = tlvFeatures path, err := ctx.findPath(target, paymentAmt) require.NoError(t, err) require.Len(t, path, 1) // Assert that no path is found when metadata is too large. ctx.restrictParams.Metadata = make([]byte, 2000) _, err = ctx.findPath(target, paymentAmt) require.ErrorIs(t, errNoPathFound, err) } // runFindLowestFeePath tests that out of two routes with identical total // time lock values, the route with the lowest total fee should be returned. // The fee rates are chosen such that the test failed on the previous edge // weight function where one of the terms was fee squared. func runFindLowestFeePath(t *testing.T, useCache bool) { // Set up a test graph with two paths from roasbeef to target. Both // paths have equal total time locks, but the path through b has lower // fees (700 compared to 800 for the path through a). testChannels := []*testChannel{ symmetricTestChannel("roasbeef", "first", 100000, &testChannelPolicy{ Expiry: 144, FeeRate: 400, MinHTLC: 1, MaxHTLC: 100000000, }), symmetricTestChannel("first", "a", 100000, &testChannelPolicy{ Expiry: 144, FeeRate: 400, MinHTLC: 1, MaxHTLC: 100000000, }), symmetricTestChannel("a", "target", 100000, &testChannelPolicy{ Expiry: 144, FeeRate: 400, MinHTLC: 1, MaxHTLC: 100000000, }), symmetricTestChannel("first", "b", 100000, &testChannelPolicy{ Expiry: 144, FeeRate: 100, MinHTLC: 1, MaxHTLC: 100000000, }), symmetricTestChannel("b", "target", 100000, &testChannelPolicy{ Expiry: 144, FeeRate: 600, MinHTLC: 1, MaxHTLC: 100000000, }), } ctx := newPathFindingTestContext(t, useCache, testChannels, "roasbeef") const ( startingHeight = 100 finalHopCLTV = 1 ) paymentAmt := lnwire.NewMSatFromSatoshis(100) target := ctx.keyFromAlias("target") path, err := ctx.findPath(target, paymentAmt) require.NoError(t, err, "unable to find path") route, err := newRoute( ctx.source, path, startingHeight, finalHopParams{ amt: paymentAmt, cltvDelta: finalHopCLTV, records: nil, }, nil, ) require.NoError(t, err, "unable to create path") // Assert that the lowest fee route is returned. if route.Hops[1].PubKeyBytes != ctx.keyFromAlias("b") { t.Fatalf("expected route to pass through b, "+ "but got a route through %v", ctx.aliasFromKey(route.Hops[1].PubKeyBytes)) } } func getAliasFromPubKey(pubKey route.Vertex, aliases map[string]route.Vertex) string { for alias, key := range aliases { if key == pubKey { return alias } } return "" } type expectedHop struct { alias string fee lnwire.MilliSatoshi fwdAmount lnwire.MilliSatoshi timeLock uint32 } type basicGraphPathFindingTestCase struct { target string paymentAmt btcutil.Amount feeLimit lnwire.MilliSatoshi expectedTotalAmt lnwire.MilliSatoshi expectedTotalTimeLock uint32 expectedHops []expectedHop expectFailureNoPath bool } var basicGraphPathFindingTests = []basicGraphPathFindingTestCase{ // Basic route with one intermediate hop. {target: "sophon", paymentAmt: 100, feeLimit: noFeeLimit, expectedTotalTimeLock: 102, expectedTotalAmt: 100110, expectedHops: []expectedHop{ {alias: "songoku", fwdAmount: 100000, fee: 110, timeLock: 101}, {alias: "sophon", fwdAmount: 100000, fee: 0, timeLock: 101}, }}, // Basic direct (one hop) route. {target: "luoji", paymentAmt: 100, feeLimit: noFeeLimit, expectedTotalTimeLock: 101, expectedTotalAmt: 100000, expectedHops: []expectedHop{ {alias: "luoji", fwdAmount: 100000, fee: 0, timeLock: 101}, }}, // Three hop route where fees need to be added in to the forwarding amount. // The high fee hop phamnewun should be avoided. {target: "elst", paymentAmt: 50000, feeLimit: noFeeLimit, expectedTotalTimeLock: 103, expectedTotalAmt: 50050210, expectedHops: []expectedHop{ {alias: "songoku", fwdAmount: 50000200, fee: 50010, timeLock: 102}, {alias: "sophon", fwdAmount: 50000000, fee: 200, timeLock: 101}, {alias: "elst", fwdAmount: 50000000, fee: 0, timeLock: 101}, }}, // Three hop route where fees need to be added in to the forwarding amount. // However this time the fwdAmount becomes too large for the roasbeef <-> // songoku channel. Then there is no other option than to choose the // expensive phamnuwen channel. This test case was failing before // the route search was executed backwards. {target: "elst", paymentAmt: 100000, feeLimit: noFeeLimit, expectedTotalTimeLock: 103, expectedTotalAmt: 110010220, expectedHops: []expectedHop{ {alias: "phamnuwen", fwdAmount: 100000200, fee: 10010020, timeLock: 102}, {alias: "sophon", fwdAmount: 100000000, fee: 200, timeLock: 101}, {alias: "elst", fwdAmount: 100000000, fee: 0, timeLock: 101}, }}, // Basic route with fee limit. {target: "sophon", paymentAmt: 100, feeLimit: 50, expectFailureNoPath: true, }, } func runBasicGraphPathFinding(t *testing.T, useCache bool) { testGraphInstance, err := parseTestGraph(t, useCache, basicGraphFilePath) require.NoError(t, err, "unable to create graph") // With the test graph loaded, we'll test some basic path finding using // the pre-generated graph. Consult the testdata/basic_graph.json file // to follow along with the assumptions we'll use to test the path // finding. for _, testCase := range basicGraphPathFindingTests { t.Run(testCase.target, func(subT *testing.T) { testBasicGraphPathFindingCase(subT, testGraphInstance, &testCase) }) } } func testBasicGraphPathFindingCase(t *testing.T, graphInstance *testGraphInstance, test *basicGraphPathFindingTestCase) { aliases := graphInstance.aliasMap expectedHops := test.expectedHops expectedHopCount := len(expectedHops) sourceNode, err := graphInstance.graph.SourceNode() require.NoError(t, err, "unable to fetch source node") sourceVertex := route.Vertex(sourceNode.PubKeyBytes) const ( startingHeight = 100 finalHopCLTV = 1 ) paymentAmt := lnwire.NewMSatFromSatoshis(test.paymentAmt) target := graphInstance.aliasMap[test.target] path, err := dbFindPath( graphInstance.graph, nil, &mockBandwidthHints{}, &RestrictParams{ FeeLimit: test.feeLimit, ProbabilitySource: noProbabilitySource, CltvLimit: math.MaxUint32, }, testPathFindingConfig, sourceNode.PubKeyBytes, target, paymentAmt, 0, startingHeight+finalHopCLTV, ) if test.expectFailureNoPath { if err == nil { t.Fatal("expected no path to be found") } return } require.NoError(t, err, "unable to find path") route, err := newRoute( sourceVertex, path, startingHeight, finalHopParams{ amt: paymentAmt, cltvDelta: finalHopCLTV, records: nil, }, nil, ) require.NoError(t, err, "unable to create path") if len(route.Hops) != len(expectedHops) { t.Fatalf("route is of incorrect length, expected %v got %v", expectedHopCount, len(route.Hops)) } // Check hop nodes for i := 0; i < len(expectedHops); i++ { if route.Hops[i].PubKeyBytes != aliases[expectedHops[i].alias] { t.Fatalf("%v-th hop should be %v, is instead: %v", i, expectedHops[i], getAliasFromPubKey(route.Hops[i].PubKeyBytes, aliases)) } } // Next, we'll assert that the "next hop" field in each route payload // properly points to the channel ID that the HTLC should be forwarded // along. sphinxPath, err := route.ToSphinxPath() require.NoError(t, err, "unable to make sphinx path") if sphinxPath.TrueRouteLength() != expectedHopCount { t.Fatalf("incorrect number of hop payloads: expected %v, got %v", expectedHopCount, sphinxPath.TrueRouteLength()) } // Hops should point to the next hop for i := 0; i < len(expectedHops)-1; i++ { payload, _, err := switchhop.ParseTLVPayload( bytes.NewReader(sphinxPath[i].HopPayload.Payload), ) require.NoError(t, err) require.Equal( t, route.Hops[i+1].ChannelID, payload.FwdInfo.NextHop.ToUint64(), ) } lastHopIndex := len(expectedHops) - 1 payload, _, err := switchhop.ParseTLVPayload( bytes.NewReader(sphinxPath[lastHopIndex].HopPayload.Payload), ) require.NoError(t, err) // The final hop should have a next hop value of all zeroes in order // to indicate it's the exit hop. require.Zero(t, payload.FwdInfo.NextHop.ToUint64()) var expectedTotalFee lnwire.MilliSatoshi for i := 0; i < expectedHopCount; i++ { // We'll ensure that the amount to forward, and fees // computed for each hop are correct. fee := route.HopFee(i) if fee != expectedHops[i].fee { t.Fatalf("fee incorrect for hop %v: expected %v, got %v", i, expectedHops[i].fee, fee) } if route.Hops[i].AmtToForward != expectedHops[i].fwdAmount { t.Fatalf("forwarding amount for hop %v incorrect: "+ "expected %v, got %v", i, expectedHops[i].fwdAmount, route.Hops[i].AmtToForward) } // We'll also assert that the outgoing CLTV value for each // hop was set accordingly. if route.Hops[i].OutgoingTimeLock != expectedHops[i].timeLock { t.Fatalf("outgoing time-lock for hop %v is incorrect: "+ "expected %v, got %v", i, expectedHops[i].timeLock, route.Hops[i].OutgoingTimeLock) } expectedTotalFee += expectedHops[i].fee } if route.TotalAmount != test.expectedTotalAmt { t.Fatalf("total amount incorrect: "+ "expected %v, got %v", test.expectedTotalAmt, route.TotalAmount) } if route.TotalTimeLock != test.expectedTotalTimeLock { t.Fatalf("expected time lock of %v, instead have %v", 2, route.TotalTimeLock) } } // runPathFindingWithAdditionalEdges asserts that we are able to find paths to // nodes that do not exist in the graph by way of hop hints. We also test that // the path can support custom TLV records for the receiver under the // appropriate circumstances. func runPathFindingWithAdditionalEdges(t *testing.T, useCache bool) { graph, err := parseTestGraph(t, useCache, basicGraphFilePath) require.NoError(t, err, "unable to create graph") sourceNode, err := graph.graph.SourceNode() require.NoError(t, err, "unable to fetch source node") paymentAmt := lnwire.NewMSatFromSatoshis(100) // In this test, we'll test that we're able to find paths through // private channels when providing them as additional edges in our path // finding algorithm. To do so, we'll create a new node, doge, and // create a private channel between it and songoku. We'll then attempt // to find a path from our source node, roasbeef, to doge. dogePubKeyHex := "03dd46ff29a6941b4a2607525b043ec9b020b3f318a1bf281536fd7011ec59c882" dogePubKeyBytes, err := hex.DecodeString(dogePubKeyHex) require.NoError(t, err, "unable to decode public key") dogePubKey, err := btcec.ParsePubKey(dogePubKeyBytes) require.NoError(t, err, "unable to parse public key from bytes") doge := &channeldb.LightningNode{} doge.AddPubKey(dogePubKey) doge.Alias = "doge" copy(doge.PubKeyBytes[:], dogePubKeyBytes) graph.aliasMap["doge"] = doge.PubKeyBytes // Create the channel edge going from songoku to doge and include it in // our map of additional edges. songokuToDogePolicy := &models.CachedEdgePolicy{ ToNodePubKey: func() route.Vertex { return doge.PubKeyBytes }, ToNodeFeatures: lnwire.EmptyFeatureVector(), ChannelID: 1337, FeeBaseMSat: 1, FeeProportionalMillionths: 1000, TimeLockDelta: 9, } additionalEdges := map[route.Vertex][]AdditionalEdge{ graph.aliasMap["songoku"]: {&PrivateEdge{ policy: songokuToDogePolicy, }}, } find := func(r *RestrictParams) ( []*unifiedEdge, error) { return dbFindPath( graph.graph, additionalEdges, &mockBandwidthHints{}, r, testPathFindingConfig, sourceNode.PubKeyBytes, doge.PubKeyBytes, paymentAmt, 0, 0, ) } // We should now be able to find a path from roasbeef to doge. path, err := find(noRestrictions) require.NoError(t, err, "unable to find private path to doge") // The path should represent the following hops: // roasbeef -> songoku -> doge assertExpectedPath(t, graph.aliasMap, path, "songoku", "doge") // Now, set custom records for the final hop. This should fail since no // dest features are set, and we won't have a node ann to fall back on. restrictions := *noRestrictions restrictions.DestCustomRecords = record.CustomSet{70000: []byte{}} // Finally, set the tlv feature in the payload and assert we found the // same path as before. restrictions.DestFeatures = tlvFeatures path, err = find(&restrictions) require.NoError(t, err, "path should have been found") assertExpectedPath(t, graph.aliasMap, path, "songoku", "doge") } // runPathFindingWithRedundantAdditionalEdges asserts that we are able to find // paths to nodes ignoring additional edges that are already known by self node. func runPathFindingWithRedundantAdditionalEdges(t *testing.T, useCache bool) { t.Helper() var realChannelID uint64 = 3145 var hintChannelID uint64 = 1618 testChannels := []*testChannel{ symmetricTestChannel("alice", "bob", 100000, &testChannelPolicy{ Expiry: 144, FeeRate: 400, MinHTLC: 1, MaxHTLC: 100000000, }, realChannelID), } ctx := newPathFindingTestContext(t, useCache, testChannels, "alice") target := ctx.keyFromAlias("bob") paymentAmt := lnwire.NewMSatFromSatoshis(100) // Create the channel edge going from alice to bob and include it in // our map of additional edges. aliceToBobPolicy := &models.CachedEdgePolicy{ ToNodePubKey: func() route.Vertex { return target }, ToNodeFeatures: lnwire.EmptyFeatureVector(), ChannelID: hintChannelID, FeeBaseMSat: 1, FeeProportionalMillionths: 1000, TimeLockDelta: 9, } additionalEdges := map[route.Vertex][]AdditionalEdge{ ctx.source: {&PrivateEdge{ policy: aliceToBobPolicy, }}, } path, err := dbFindPath( ctx.graph, additionalEdges, ctx.bandwidthHints, &ctx.restrictParams, &ctx.pathFindingConfig, ctx.source, target, paymentAmt, ctx.timePref, 0, ) require.NoError(t, err, "unable to find path to bob") require.Len(t, path, 1) require.Equal(t, realChannelID, path[0].policy.ChannelID, "additional edge for known edge wasn't ignored") } // TestNewRoute tests whether the construction of hop payloads by newRoute // is executed correctly. func TestNewRoute(t *testing.T) { var sourceKey [33]byte sourceVertex := route.Vertex(sourceKey) testPaymentAddr := [32]byte{0x01, 0x02, 0x03} const ( startingHeight = 100 finalHopCLTV = 1 ) createHop := func(baseFee lnwire.MilliSatoshi, feeRate lnwire.MilliSatoshi, bandwidth lnwire.MilliSatoshi, timeLockDelta uint16) *models.CachedEdgePolicy { return &models.CachedEdgePolicy{ ToNodePubKey: func() route.Vertex { return route.Vertex{} }, ToNodeFeatures: lnwire.NewFeatureVector(nil, nil), FeeProportionalMillionths: feeRate, FeeBaseMSat: baseFee, TimeLockDelta: timeLockDelta, } } testCases := []struct { // name identifies the test case in the test output. name string // hops is the list of hops (the route) that gets passed into // the call to newRoute. hops []*models.CachedEdgePolicy // paymentAmount is the amount that is send into the route // indicated by hops. paymentAmount lnwire.MilliSatoshi // destFeatures is a feature vector, that if non-nil, will // overwrite the final hop's feature vector in the graph. destFeatures *lnwire.FeatureVector paymentAddr fn.Option[[32]byte] // metadata is the payment metadata to attach to the route. metadata []byte // expectedFees is a list of fees that every hop is expected // to charge for forwarding. expectedFees []lnwire.MilliSatoshi // expectedTimeLocks is a list of time lock values that every // hop is expected to specify in its outgoing HTLC. The time // lock values in this list are relative to the current block // height. expectedTimeLocks []uint32 // expectedTotalAmount is the total amount that is expected to // be returned from newRoute. This amount should include all // the fees to be paid to intermediate hops. expectedTotalAmount lnwire.MilliSatoshi // expectedTotalTimeLock is the time lock that is expected to // be returned from newRoute. This is the time lock that should // be specified in the HTLC that is sent by the source node. // expectedTotalTimeLock is relative to the current block height. expectedTotalTimeLock uint32 // expectError indicates whether the newRoute call is expected // to fail or succeed. expectError bool expectedMPP *record.MPP }{ { // For a single hop payment, no fees are expected to be paid. name: "single hop", paymentAmount: 100000, hops: []*models.CachedEdgePolicy{ createHop(100, 1000, 1000000, 10), }, metadata: []byte{1, 2, 3}, expectedFees: []lnwire.MilliSatoshi{0}, expectedTimeLocks: []uint32{1}, expectedTotalAmount: 100000, expectedTotalTimeLock: 1, }, { // For a two hop payment, only the fee for the first hop // needs to be paid. The destination hop does not require // a fee to receive the payment. name: "two hop", paymentAmount: 100000, hops: []*models.CachedEdgePolicy{ createHop(0, 1000, 1000000, 10), createHop(30, 1000, 1000000, 5), }, expectedFees: []lnwire.MilliSatoshi{130, 0}, expectedTimeLocks: []uint32{1, 1}, expectedTotalAmount: 100130, expectedTotalTimeLock: 6, }, { // For a two hop payment, only the fee for the first hop // needs to be paid. The destination hop does not require // a fee to receive the payment. name: "two hop tlv onion feature", destFeatures: tlvFeatures, paymentAmount: 100000, hops: []*models.CachedEdgePolicy{ createHop(0, 1000, 1000000, 10), createHop(30, 1000, 1000000, 5), }, expectedFees: []lnwire.MilliSatoshi{130, 0}, expectedTimeLocks: []uint32{1, 1}, expectedTotalAmount: 100130, expectedTotalTimeLock: 6, }, { // For a two hop payment, only the fee for the first hop // needs to be paid. The destination hop does not require // a fee to receive the payment. name: "two hop single shot mpp", destFeatures: tlvPayAddrFeatures, paymentAddr: fn.Some(testPaymentAddr), paymentAmount: 100000, hops: []*models.CachedEdgePolicy{ createHop(0, 1000, 1000000, 10), createHop(30, 1000, 1000000, 5), }, expectedFees: []lnwire.MilliSatoshi{130, 0}, expectedTimeLocks: []uint32{1, 1}, expectedTotalAmount: 100130, expectedTotalTimeLock: 6, expectedMPP: record.NewMPP( 100000, testPaymentAddr, ), }, { // A three hop payment where the first and second hop // will both charge 1 msat. The fee for the first hop // is actually slightly higher than 1, because the amount // to forward also includes the fee for the second hop. This // gets rounded down to 1. name: "three hop", paymentAmount: 100000, hops: []*models.CachedEdgePolicy{ createHop(0, 10, 1000000, 10), createHop(0, 10, 1000000, 5), createHop(0, 10, 1000000, 3), }, expectedFees: []lnwire.MilliSatoshi{1, 1, 0}, expectedTotalAmount: 100002, expectedTimeLocks: []uint32{4, 1, 1}, expectedTotalTimeLock: 9, }, { // A three hop payment where the fee of the first hop // is slightly higher (11) than the fee at the second hop, // because of the increase amount to forward. name: "three hop with fee carry over", paymentAmount: 100000, hops: []*models.CachedEdgePolicy{ createHop(0, 10000, 1000000, 10), createHop(0, 10000, 1000000, 5), createHop(0, 10000, 1000000, 3), }, expectedFees: []lnwire.MilliSatoshi{1010, 1000, 0}, expectedTotalAmount: 102010, expectedTimeLocks: []uint32{4, 1, 1}, expectedTotalTimeLock: 9, }, { // A three hop payment where the fee policies of the first and // second hop are just high enough to show the fee carry over // effect. name: "three hop with minimal fees for carry over", paymentAmount: 100000, hops: []*models.CachedEdgePolicy{ createHop(0, 10000, 1000000, 10), // First hop charges 0.1% so the second hop fee // should show up in the first hop fee as 1 msat // extra. createHop(0, 1000, 1000000, 5), // Second hop charges a fixed 1000 msat. createHop(1000, 0, 1000000, 3), }, expectedFees: []lnwire.MilliSatoshi{101, 1000, 0}, expectedTotalAmount: 101101, expectedTimeLocks: []uint32{4, 1, 1}, expectedTotalTimeLock: 9, }} for _, testCase := range testCases { testCase := testCase // Overwrite the final hop's features if the test requires a // custom feature vector. if testCase.destFeatures != nil { finalHop := testCase.hops[len(testCase.hops)-1] finalHop.ToNodeFeatures = testCase.destFeatures } assertRoute := func(t *testing.T, route *route.Route) { if route.TotalAmount != testCase.expectedTotalAmount { t.Errorf("Expected total amount is be %v"+ ", but got %v instead", testCase.expectedTotalAmount, route.TotalAmount) } for i := 0; i < len(testCase.expectedFees); i++ { fee := route.HopFee(i) if testCase.expectedFees[i] != fee { t.Errorf("Expected fee for hop %v to "+ "be %v, but got %v instead", i, testCase.expectedFees[i], fee) } } expectedTimeLockHeight := startingHeight + testCase.expectedTotalTimeLock if route.TotalTimeLock != expectedTimeLockHeight { t.Errorf("Expected total time lock to be %v"+ ", but got %v instead", expectedTimeLockHeight, route.TotalTimeLock) } for i := 0; i < len(testCase.expectedTimeLocks); i++ { expectedTimeLockHeight := startingHeight + testCase.expectedTimeLocks[i] if expectedTimeLockHeight != route.Hops[i].OutgoingTimeLock { t.Errorf("Expected time lock for hop "+ "%v to be %v, but got %v instead", i, expectedTimeLockHeight, route.Hops[i].OutgoingTimeLock) } } finalHop := route.Hops[len(route.Hops)-1] if !reflect.DeepEqual( finalHop.MPP, testCase.expectedMPP, ) { t.Errorf("Expected final hop mpp field: %v, "+ " but got: %v instead", testCase.expectedMPP, finalHop.MPP) } if !bytes.Equal(finalHop.Metadata, testCase.metadata) { t.Errorf("Expected final metadata field: %v, "+ " but got: %v instead", testCase.metadata, finalHop.Metadata) } } t.Run(testCase.name, func(t *testing.T) { var unifiedHops []*unifiedEdge for _, hop := range testCase.hops { unifiedHops = append(unifiedHops, &unifiedEdge{ policy: hop, }, ) } route, err := newRoute( sourceVertex, unifiedHops, startingHeight, finalHopParams{ amt: testCase.paymentAmount, totalAmt: testCase.paymentAmount, cltvDelta: finalHopCLTV, records: nil, paymentAddr: testCase.paymentAddr, metadata: testCase.metadata, }, nil, ) require.NoError(t, err) assertRoute(t, route) }) } } func runNewRoutePathTooLong(t *testing.T, useCache bool) { var testChannels []*testChannel // Setup a linear network of 26 hops. fromNode := "start" for i := 0; i < 26; i++ { toNode := fmt.Sprintf("node-%v", i+1) c := symmetricTestChannel(fromNode, toNode, 100000, &testChannelPolicy{ Expiry: 144, FeeRate: 400, MinHTLC: 1, MaxHTLC: 100000001, }) testChannels = append(testChannels, c) fromNode = toNode } ctx := newPathFindingTestContext(t, useCache, testChannels, "start") // Assert that we can find 25 hop routes. node25 := ctx.keyFromAlias("node-25") payAmt := lnwire.MilliSatoshi(100001) _, err := ctx.findPath(node25, payAmt) require.NoError(t, err, "unexpected pathfinding failure") // Assert that finding a 26 hop route fails. node26 := ctx.keyFromAlias("node-26") _, err = ctx.findPath(node26, payAmt) require.ErrorIs(t, err, errNoPathFound) // Assert that we can't find a 20 hop route if custom records make it // exceed the maximum payload size. ctx.restrictParams.DestFeatures = tlvFeatures ctx.restrictParams.DestCustomRecords = map[uint64][]byte{ 100000: bytes.Repeat([]byte{1}, 100), } _, err = ctx.findPath(node25, payAmt) if err != errNoPathFound { t.Fatalf("not route error expected, but got %v", err) } } func runPathNotAvailable(t *testing.T, useCache bool) { graph, err := parseTestGraph(t, useCache, basicGraphFilePath) require.NoError(t, err, "unable to create graph") sourceNode, err := graph.graph.SourceNode() require.NoError(t, err, "unable to fetch source node") // With the test graph loaded, we'll test that queries for target that // are either unreachable within the graph, or unknown result in an // error. unknownNodeStr := "03dd46ff29a6941b4a2607525b043ec9b020b3f318a1bf281536fd7011ec59c882" unknownNodeBytes, err := hex.DecodeString(unknownNodeStr) require.NoError(t, err, "unable to parse bytes") var unknownNode route.Vertex copy(unknownNode[:], unknownNodeBytes) _, err = dbFindPath( graph.graph, nil, &mockBandwidthHints{}, noRestrictions, testPathFindingConfig, sourceNode.PubKeyBytes, unknownNode, 100, 0, 0, ) if err != errNoPathFound { t.Fatalf("path shouldn't have been found: %v", err) } } // runDestTLVGraphFallback asserts that we properly detect when we can send TLV // records to a receiver, and also that we fallback to the receiver's node // announcement if we don't have an invoice features. func runDestTLVGraphFallback(t *testing.T, useCache bool) { testChannels := []*testChannel{ asymmetricTestChannel("roasbeef", "luoji", 100000, &testChannelPolicy{ Expiry: 144, FeeRate: 400, MinHTLC: 1, MaxHTLC: 100000000, }, &testChannelPolicy{ Expiry: 144, FeeRate: 400, MinHTLC: 1, MaxHTLC: 100000000, }, 0), asymmetricTestChannel("roasbeef", "satoshi", 100000, &testChannelPolicy{ Expiry: 144, FeeRate: 400, MinHTLC: 1, MaxHTLC: 100000000, }, &testChannelPolicy{ Expiry: 144, FeeRate: 400, MinHTLC: 1, MaxHTLC: 100000000, Features: tlvFeatures, }, 0), } ctx := newPathFindingTestContext(t, useCache, testChannels, "roasbeef") sourceNode, err := ctx.graph.SourceNode() require.NoError(t, err, "unable to fetch source node") find := func(r *RestrictParams, target route.Vertex) ([]*unifiedEdge, error) { return dbFindPath( ctx.graph, nil, &mockBandwidthHints{}, r, testPathFindingConfig, sourceNode.PubKeyBytes, target, 100, 0, 0, ) } // Luoji's node ann has an empty feature vector. luoji := ctx.testGraphInstance.aliasMap["luoji"] // Satoshi's node ann supports TLV. satoshi := ctx.testGraphInstance.aliasMap["satoshi"] restrictions := *noRestrictions // Add custom records w/o any dest features. restrictions.DestCustomRecords = record.CustomSet{70000: []byte{}} // However, path to satoshi should succeed via the fallback because his // node ann features have the TLV bit. path, err := find(&restrictions, satoshi) require.NoError(t, err, "path should have been found") assertExpectedPath(t, ctx.testGraphInstance.aliasMap, path, "satoshi") // Finally, set the TLV dest feature. We should succeed in finding a // path to luoji. restrictions.DestFeatures = tlvFeatures path, err = find(&restrictions, luoji) require.NoError(t, err, "path should have been found") assertExpectedPath(t, ctx.testGraphInstance.aliasMap, path, "luoji") } // runMissingFeatureDep asserts that we fail path finding when the // destination's features are broken, in that the feature vector doesn't signal // all transitive dependencies. func runMissingFeatureDep(t *testing.T, useCache bool) { testChannels := []*testChannel{ asymmetricTestChannel("roasbeef", "conner", 100000, &testChannelPolicy{ Expiry: 144, FeeRate: 400, MinHTLC: 1, MaxHTLC: 100000000, }, &testChannelPolicy{ Expiry: 144, FeeRate: 400, MinHTLC: 1, MaxHTLC: 100000000, Features: payAddrFeatures, }, 0, ), asymmetricTestChannel("conner", "joost", 100000, &testChannelPolicy{ Expiry: 144, FeeRate: 400, MinHTLC: 1, MaxHTLC: 100000000, Features: payAddrFeatures, }, &testChannelPolicy{ Expiry: 144, FeeRate: 400, MinHTLC: 1, MaxHTLC: 100000000, }, 0, ), } ctx := newPathFindingTestContext(t, useCache, testChannels, "roasbeef") // Conner's node in the graph has a broken feature vector, since it // signals payment addresses without signaling tlv onions. Pathfinding // should fail since we validate transitive feature dependencies for the // final node. conner := ctx.keyFromAlias("conner") joost := ctx.keyFromAlias("joost") _, err := ctx.findPath(conner, 100) if err != errMissingDependentFeature { t.Fatalf("path shouldn't have been found: %v", err) } // Now, set the TLV and payment addresses features to override the // broken features found in the graph. We should succeed in finding a // path to conner. ctx.restrictParams.DestFeatures = tlvPayAddrFeatures path, err := ctx.findPath(conner, 100) require.NoError(t, err, "path should have been found") assertExpectedPath(t, ctx.testGraphInstance.aliasMap, path, "conner") // Finally, try to find a route to joost through conner. The // destination features are set properly from the previous assertions, // but conner's feature vector in the graph is still broken. We expect // errNoPathFound and not the missing feature dep err above since // intermediate hops are simply skipped if they have invalid feature // vectors, leaving no possible route to joost. _, err = ctx.findPath(joost, 100) if err != errNoPathFound { t.Fatalf("path shouldn't have been found: %v", err) } } // runUnknownRequiredFeatures asserts that we fail path finding when the // destination requires an unknown required feature, and that we skip // intermediaries that signal unknown required features. func runUnknownRequiredFeatures(t *testing.T, useCache bool) { testChannels := []*testChannel{ asymmetricTestChannel("roasbeef", "conner", 100000, &testChannelPolicy{ Expiry: 144, FeeRate: 400, MinHTLC: 1, MaxHTLC: 100000000, }, &testChannelPolicy{ Expiry: 144, FeeRate: 400, MinHTLC: 1, MaxHTLC: 100000000, Features: unknownRequiredFeatures, }, 0, ), asymmetricTestChannel("conner", "joost", 100000, &testChannelPolicy{ Expiry: 144, FeeRate: 400, MinHTLC: 1, MaxHTLC: 100000000, Features: unknownRequiredFeatures, }, &testChannelPolicy{ Expiry: 144, FeeRate: 400, MinHTLC: 1, MaxHTLC: 100000000, }, 0, ), } ctx := newPathFindingTestContext(t, useCache, testChannels, "roasbeef") conner := ctx.keyFromAlias("conner") joost := ctx.keyFromAlias("joost") // Conner's node in the graph has an unknown required feature (100). // Pathfinding should fail since we check the destination's features for // unknown required features before beginning pathfinding. _, err := ctx.findPath(conner, 100) if !reflect.DeepEqual(err, errUnknownRequiredFeature) { t.Fatalf("path shouldn't have been found: %v", err) } // Now, try to find a route to joost through conner. The destination // features are valid, but conner's feature vector in the graph still // requires feature 100. We expect errNoPathFound and not the error // above since intermediate hops are simply skipped if they have invalid // feature vectors, leaving no possible route to joost. This asserts // that we don't try to route _through_ nodes with unknown required // features. _, err = ctx.findPath(joost, 100) if err != errNoPathFound { t.Fatalf("path shouldn't have been found: %v", err) } } // runDestPaymentAddr asserts that we properly detect when we can send a // payment address to a receiver, and also that we fallback to the receiver's // node announcement if we don't have an invoice features. func runDestPaymentAddr(t *testing.T, useCache bool) { testChannels := []*testChannel{ symmetricTestChannel("roasbeef", "luoji", 100000, &testChannelPolicy{ Expiry: 144, FeeRate: 400, MinHTLC: 1, MaxHTLC: 100000000, }, ), } ctx := newPathFindingTestContext(t, useCache, testChannels, "roasbeef") luoji := ctx.keyFromAlias("luoji") // Add payment address w/o any invoice features. ctx.restrictParams.PaymentAddr = fn.Some([32]byte{1}) // Add empty destination features. This should cause us to fail, since // this overrides anything in the graph. ctx.restrictParams.DestFeatures = lnwire.EmptyFeatureVector() _, err := ctx.findPath(luoji, 100) if err != errNoPaymentAddr { t.Fatalf("path shouldn't have been found: %v", err) } // Now, set the TLV and payment address features for the destination. We // should succeed in finding a path to luoji. ctx.restrictParams.DestFeatures = tlvPayAddrFeatures path, err := ctx.findPath(luoji, 100) require.NoError(t, err, "path should have been found") assertExpectedPath(t, ctx.testGraphInstance.aliasMap, path, "luoji") } func runPathInsufficientCapacity(t *testing.T, useCache bool) { graph, err := parseTestGraph(t, useCache, basicGraphFilePath) require.NoError(t, err, "unable to create graph") sourceNode, err := graph.graph.SourceNode() require.NoError(t, err, "unable to fetch source node") // Next, test that attempting to find a path in which the current // channel graph cannot support due to insufficient capacity triggers // an error. // To test his we'll attempt to make a payment of 1 BTC, or 100 million // satoshis. The largest channel in the basic graph is of size 100k // satoshis, so we shouldn't be able to find a path to sophon even // though we have a 2-hop link. target := graph.aliasMap["sophon"] payAmt := lnwire.NewMSatFromSatoshis(btcutil.SatoshiPerBitcoin) _, err = dbFindPath( graph.graph, nil, &mockBandwidthHints{}, noRestrictions, testPathFindingConfig, sourceNode.PubKeyBytes, target, payAmt, 0, 0, ) if err != errInsufficientBalance { t.Fatalf("graph shouldn't be able to support payment: %v", err) } } // runRouteFailMinHTLC tests that if we attempt to route an HTLC which is // smaller than the advertised minHTLC of an edge, then path finding fails. func runRouteFailMinHTLC(t *testing.T, useCache bool) { graph, err := parseTestGraph(t, useCache, basicGraphFilePath) require.NoError(t, err, "unable to create graph") sourceNode, err := graph.graph.SourceNode() require.NoError(t, err, "unable to fetch source node") // We'll not attempt to route an HTLC of 10 SAT from roasbeef to Son // Goku. However, the min HTLC of Son Goku is 1k SAT, as a result, this // attempt should fail. target := graph.aliasMap["songoku"] payAmt := lnwire.MilliSatoshi(10) _, err = dbFindPath( graph.graph, nil, &mockBandwidthHints{}, noRestrictions, testPathFindingConfig, sourceNode.PubKeyBytes, target, payAmt, 0, 0, ) if err != errNoPathFound { t.Fatalf("graph shouldn't be able to support payment: %v", err) } } // runRouteFailMaxHTLC tests that if we attempt to route an HTLC which is // larger than the advertised max HTLC of an edge, then path finding fails. func runRouteFailMaxHTLC(t *testing.T, useCache bool) { // Set up a test graph: // roasbeef <--> firstHop <--> secondHop <--> target // We will be adjusting the max HTLC of the edge between the first and // second hops. var firstToSecondID uint64 = 1 testChannels := []*testChannel{ symmetricTestChannel("roasbeef", "first", 100000, &testChannelPolicy{ Expiry: 144, FeeRate: 400, MinHTLC: 1, MaxHTLC: 100000001, }), symmetricTestChannel("first", "second", 100000, &testChannelPolicy{ Expiry: 144, FeeRate: 400, MinHTLC: 1, MaxHTLC: 100000002, }, firstToSecondID), symmetricTestChannel("second", "target", 100000, &testChannelPolicy{ Expiry: 144, FeeRate: 400, MinHTLC: 1, MaxHTLC: 100000003, }), } ctx := newPathFindingTestContext(t, useCache, testChannels, "roasbeef") // First, attempt to send a payment greater than the max HTLC we are // about to set, which should succeed. target := ctx.keyFromAlias("target") payAmt := lnwire.MilliSatoshi(100001) _, err := ctx.findPath(target, payAmt) require.NoError(t, err, "graph should've been able to support payment") // Next, update the middle edge policy to only allow payments up to 100k // msat. graph := ctx.testGraphInstance.graph _, midEdge, _, err := graph.FetchChannelEdgesByID(firstToSecondID) require.NoError(t, err, "unable to fetch channel edges by ID") midEdge.MessageFlags = 1 midEdge.MaxHTLC = payAmt - 1 if err := graph.UpdateEdgePolicy(midEdge); err != nil { t.Fatalf("unable to update edge: %v", err) } // We'll now attempt to route through that edge with a payment above // 100k msat, which should fail. _, err = ctx.findPath(target, payAmt) if err != errNoPathFound { t.Fatalf("graph shouldn't be able to support payment: %v", err) } } // runRouteFailDisabledEdge tests that if we attempt to route to an edge // that's disabled, then that edge is disqualified, and the routing attempt // will fail. We also test that this is true only for non-local edges, as we'll // ignore the disable flags, with the assumption that the correct bandwidth is // found among the bandwidth hints. func runRouteFailDisabledEdge(t *testing.T, useCache bool) { graph, err := parseTestGraph(t, useCache, basicGraphFilePath) require.NoError(t, err, "unable to create graph") sourceNode, err := graph.graph.SourceNode() require.NoError(t, err, "unable to fetch source node") // First, we'll try to route from roasbeef -> sophon. This should // succeed without issue, and return a single path via phamnuwen target := graph.aliasMap["sophon"] payAmt := lnwire.NewMSatFromSatoshis(105000) _, err = dbFindPath( graph.graph, nil, &mockBandwidthHints{}, noRestrictions, testPathFindingConfig, sourceNode.PubKeyBytes, target, payAmt, 0, 0, ) require.NoError(t, err, "unable to find path") // Disable the edge roasbeef->phamnuwen. This should not impact the // path finding, as we don't consider the disable flag for local // channels (and roasbeef is the source). roasToPham := uint64(999991) _, e1, e2, err := graph.graph.FetchChannelEdgesByID(roasToPham) require.NoError(t, err, "unable to fetch edge") e1.ChannelFlags |= lnwire.ChanUpdateDisabled if err := graph.graph.UpdateEdgePolicy(e1); err != nil { t.Fatalf("unable to update edge: %v", err) } e2.ChannelFlags |= lnwire.ChanUpdateDisabled if err := graph.graph.UpdateEdgePolicy(e2); err != nil { t.Fatalf("unable to update edge: %v", err) } _, err = dbFindPath( graph.graph, nil, &mockBandwidthHints{}, noRestrictions, testPathFindingConfig, sourceNode.PubKeyBytes, target, payAmt, 0, 0, ) require.NoError(t, err, "unable to find path") // Now, we'll modify the edge from phamnuwen -> sophon, to read that // it's disabled. phamToSophon := uint64(99999) _, e, _, err := graph.graph.FetchChannelEdgesByID(phamToSophon) require.NoError(t, err, "unable to fetch edge") e.ChannelFlags |= lnwire.ChanUpdateDisabled if err := graph.graph.UpdateEdgePolicy(e); err != nil { t.Fatalf("unable to update edge: %v", err) } // If we attempt to route through that edge, we should get a failure as // it is no longer eligible. _, err = dbFindPath( graph.graph, nil, &mockBandwidthHints{}, noRestrictions, testPathFindingConfig, sourceNode.PubKeyBytes, target, payAmt, 0, 0, ) if err != errNoPathFound { t.Fatalf("graph shouldn't be able to support payment: %v", err) } } // runPathSourceEdgesBandwidth tests that explicitly passing in a set of // bandwidth hints is used by the path finding algorithm to consider whether to // use a local channel. func runPathSourceEdgesBandwidth(t *testing.T, useCache bool) { graph, err := parseTestGraph(t, useCache, basicGraphFilePath) require.NoError(t, err, "unable to create graph") sourceNode, err := graph.graph.SourceNode() require.NoError(t, err, "unable to fetch source node") // First, we'll try to route from roasbeef -> sophon. This should // succeed without issue, and return a path via songoku, as that's the // cheapest path. target := graph.aliasMap["sophon"] payAmt := lnwire.NewMSatFromSatoshis(50000) path, err := dbFindPath( graph.graph, nil, &mockBandwidthHints{}, noRestrictions, testPathFindingConfig, sourceNode.PubKeyBytes, target, payAmt, 0, 0, ) require.NoError(t, err, "unable to find path") assertExpectedPath(t, graph.aliasMap, path, "songoku", "sophon") // Now we'll set the bandwidth of the edge roasbeef->songoku and // roasbeef->phamnuwen to 0. roasToSongoku := uint64(12345) roasToPham := uint64(999991) bandwidths := &mockBandwidthHints{ hints: map[uint64]lnwire.MilliSatoshi{ roasToSongoku: 0, roasToPham: 0, }, } // Since both these edges has a bandwidth of zero, no path should be // found. _, err = dbFindPath( graph.graph, nil, bandwidths, noRestrictions, testPathFindingConfig, sourceNode.PubKeyBytes, target, payAmt, 0, 0, ) if err != errNoPathFound { t.Fatalf("graph shouldn't be able to support payment: %v", err) } // Set the bandwidth of roasbeef->phamnuwen high enough to carry the // payment. bandwidths.hints[roasToPham] = 2 * payAmt // Now, if we attempt to route again, we should find the path via // phamnuven, as the other source edge won't be considered. path, err = dbFindPath( graph.graph, nil, bandwidths, noRestrictions, testPathFindingConfig, sourceNode.PubKeyBytes, target, payAmt, 0, 0, ) require.NoError(t, err, "unable to find path") assertExpectedPath(t, graph.aliasMap, path, "phamnuwen", "sophon") // Finally, set the roasbeef->songoku bandwidth, but also set its // disable flag. bandwidths.hints[roasToSongoku] = 2 * payAmt _, e1, e2, err := graph.graph.FetchChannelEdgesByID(roasToSongoku) require.NoError(t, err, "unable to fetch edge") e1.ChannelFlags |= lnwire.ChanUpdateDisabled if err := graph.graph.UpdateEdgePolicy(e1); err != nil { t.Fatalf("unable to update edge: %v", err) } e2.ChannelFlags |= lnwire.ChanUpdateDisabled if err := graph.graph.UpdateEdgePolicy(e2); err != nil { t.Fatalf("unable to update edge: %v", err) } // Since we ignore disable flags for local channels, a path should // still be found. path, err = dbFindPath( graph.graph, nil, bandwidths, noRestrictions, testPathFindingConfig, sourceNode.PubKeyBytes, target, payAmt, 0, 0, ) require.NoError(t, err, "unable to find path") assertExpectedPath(t, graph.aliasMap, path, "songoku", "sophon") } func TestPathInsufficientCapacityWithFee(t *testing.T) { t.Parallel() // TODO(roasbeef): encode live graph to json // TODO(roasbeef): need to add a case, or modify the fee ratio for one // to ensure that has going forward, but when fees are applied doesn't // work } func TestPathFindSpecExample(t *testing.T) { t.Parallel() // All our path finding tests will assume a starting height of 100, so // we'll pass that in to ensure that the router uses 100 as the current // height. const startingHeight = 100 ctx := createTestCtxFromFile(t, startingHeight, specExampleFilePath) // We'll first exercise the scenario of a direct payment from Bob to // Carol, so we set "B" as the source node so path finding starts from // Bob. bob := ctx.aliases["B"] // Query for a route of 4,999,999 mSAT to carol. carol := ctx.aliases["C"] const amt lnwire.MilliSatoshi = 4999999 req, err := NewRouteRequest( bob, &carol, amt, 0, noRestrictions, nil, nil, nil, MinCLTVDelta, ) require.NoError(t, err, "invalid route request") route, _, err := ctx.router.FindRoute(req) require.NoError(t, err, "unable to find route") // Now we'll examine the route returned for correctness. // // It should be sending the exact payment amount as there are no // additional hops. require.Equal(t, amt, route.TotalAmount) require.Equal(t, amt, route.Hops[0].AmtToForward) require.Zero(t, route.HopFee(0)) // The CLTV expiry should be the current height plus 18 (the expiry for // the B -> C channel. require.EqualValues(t, startingHeight+MinCLTVDelta, route.TotalTimeLock) // Next, we'll set A as the source node so we can assert that we create // the proper route for any queries starting with Alice. alice := ctx.aliases["A"] ctx.router.cfg.SelfNode = alice // We'll now request a route from A -> B -> C. req, err = NewRouteRequest( alice, &carol, amt, 0, noRestrictions, nil, nil, nil, MinCLTVDelta, ) require.NoError(t, err, "invalid route request") route, _, err = ctx.router.FindRoute(req) require.NoError(t, err, "unable to find routes") // The route should be two hops. require.Len(t, route.Hops, 2) // The total amount should factor in a fee of 10199 and also use a CLTV // delta total of 38 (20 + 18), expectedAmt := lnwire.MilliSatoshi(5010198) require.Equal(t, expectedAmt, route.TotalAmount) expectedDelta := uint32(20 + MinCLTVDelta) require.Equal(t, startingHeight+expectedDelta, route.TotalTimeLock) // Ensure that the hops of the route are properly crafted. // // After taking the fee, Bob should be forwarding the remainder which // is the exact payment to Bob. require.Equal(t, amt, route.Hops[0].AmtToForward) // We shouldn't pay any fee for the first, hop, but the fee for the // second hop posted fee should be exactly: // The fee that we pay for the second hop will be "applied to the first // hop, so we should get a fee of exactly: // // * 200 + 4999999 * 2000 / 1000000 = 10199 require.EqualValues(t, 10199, route.HopFee(0)) // While for the final hop, as there's no additional hop afterwards, we // pay no fee. require.Zero(t, route.HopFee(1)) // The outgoing CLTV value itself should be the current height plus 30 // to meet Carol's requirements. require.EqualValues(t, startingHeight+MinCLTVDelta, route.Hops[0].OutgoingTimeLock) // For B -> C, we assert that the final hop also has the proper // parameters. lastHop := route.Hops[1] require.EqualValues(t, amt, lastHop.AmtToForward) require.EqualValues(t, startingHeight+MinCLTVDelta, lastHop.OutgoingTimeLock) } func assertExpectedPath(t *testing.T, aliasMap map[string]route.Vertex, path []*unifiedEdge, nodeAliases ...string) { require.Len(t, path, len(nodeAliases)) for i, hop := range path { require.Equal(t, aliasMap[nodeAliases[i]], hop.policy.ToNodePubKey()) } } // TestNewRouteFromEmptyHops tests that the NewRouteFromHops function returns an // error when the hop list is empty. func TestNewRouteFromEmptyHops(t *testing.T) { t.Parallel() var source route.Vertex _, err := route.NewRouteFromHops(0, 0, source, []*route.Hop{}) require.ErrorIs(t, err, route.ErrNoRouteHopsProvided) } // runRestrictOutgoingChannel asserts that a outgoing channel restriction is // obeyed by the path finding algorithm. func runRestrictOutgoingChannel(t *testing.T, useCache bool) { // Define channel id constants const ( chanSourceA = 1 chanATarget = 4 chanSourceB1 = 2 chanSourceB2 = 3 chanBTarget = 5 chanSourceTarget = 6 ) // Set up a test graph with three possible paths from roasbeef to // target. The path through chanSourceB1 is the highest cost path. testChannels := []*testChannel{ symmetricTestChannel("roasbeef", "a", 100000, &testChannelPolicy{ Expiry: 144, }, chanSourceA), symmetricTestChannel("a", "target", 100000, &testChannelPolicy{ Expiry: 144, FeeRate: 400, }, chanATarget), symmetricTestChannel("roasbeef", "b", 100000, &testChannelPolicy{ Expiry: 144, }, chanSourceB1), symmetricTestChannel("roasbeef", "b", 100000, &testChannelPolicy{ Expiry: 144, }, chanSourceB2), symmetricTestChannel("b", "target", 100000, &testChannelPolicy{ Expiry: 144, FeeRate: 800, }, chanBTarget), symmetricTestChannel("roasbeef", "target", 100000, &testChannelPolicy{ Expiry: 144, }, chanSourceTarget), } ctx := newPathFindingTestContext(t, useCache, testChannels, "roasbeef") paymentAmt := lnwire.NewMSatFromSatoshis(100) target := ctx.keyFromAlias("target") outgoingChannelID := uint64(chanSourceB1) // Find the best path given the restriction to only use channel 2 as the // outgoing channel. ctx.restrictParams.OutgoingChannelIDs = []uint64{outgoingChannelID} path, err := ctx.findPath(target, paymentAmt) require.NoError(t, err, "unable to find path") // Assert that the route starts with channel chanSourceB1, in line with // the specified restriction. if path[0].policy.ChannelID != chanSourceB1 { t.Fatalf("expected route to pass through channel %v, "+ "but channel %v was selected instead", chanSourceB1, path[0].policy.ChannelID) } // If a direct channel to target is allowed as well, that channel is // expected to be selected because the routing fees are zero. ctx.restrictParams.OutgoingChannelIDs = []uint64{ chanSourceB1, chanSourceTarget, } path, err = ctx.findPath(target, paymentAmt) require.NoError(t, err, "unable to find path") if path[0].policy.ChannelID != chanSourceTarget { t.Fatalf("expected route to pass through channel %v", chanSourceTarget) } } // runRestrictLastHop asserts that a last hop restriction is obeyed by the path // finding algorithm. func runRestrictLastHop(t *testing.T, useCache bool) { // Set up a test graph with three possible paths from roasbeef to // target. The path via channel 1 and 2 is the lowest cost path. testChannels := []*testChannel{ symmetricTestChannel("source", "a", 100000, &testChannelPolicy{ Expiry: 144, }, 1), symmetricTestChannel("a", "target", 100000, &testChannelPolicy{ Expiry: 144, FeeRate: 400, }, 2), symmetricTestChannel("source", "b", 100000, &testChannelPolicy{ Expiry: 144, }, 3), symmetricTestChannel("b", "target", 100000, &testChannelPolicy{ Expiry: 144, FeeRate: 800, }, 4), } ctx := newPathFindingTestContext(t, useCache, testChannels, "source") paymentAmt := lnwire.NewMSatFromSatoshis(100) target := ctx.keyFromAlias("target") lastHop := ctx.keyFromAlias("b") // Find the best path given the restriction to use b as the last hop. // This should force pathfinding to not take the lowest cost option. ctx.restrictParams.LastHop = &lastHop path, err := ctx.findPath(target, paymentAmt) require.NoError(t, err, "unable to find path") if path[0].policy.ChannelID != 3 { t.Fatalf("expected route to pass through channel 3, "+ "but channel %v was selected instead", path[0].policy.ChannelID) } } // runCltvLimit asserts that a cltv limit is obeyed by the path finding // algorithm. func runCltvLimit(t *testing.T, useCache bool) { t.Run("no limit", func(t *testing.T) { testCltvLimit(t, useCache, 2016, 1) }) t.Run("no path", func(t *testing.T) { testCltvLimit(t, useCache, 50, 0) }) t.Run("force high cost", func(t *testing.T) { testCltvLimit(t, useCache, 80, 3) }) } func testCltvLimit(t *testing.T, useCache bool, limit uint32, expectedChannel uint64) { t.Parallel() // Set up a test graph with three possible paths to the target. The path // through a is the lowest cost with a high time lock (144). The path // through b has a higher cost but a lower time lock (100). That path // through c and d (two hops) has the same case as the path through b, // but the total time lock is lower (60). testChannels := []*testChannel{ symmetricTestChannel("roasbeef", "a", 100000, &testChannelPolicy{}, 1), symmetricTestChannel("a", "target", 100000, &testChannelPolicy{ Expiry: 144, FeeBaseMsat: 10000, MinHTLC: 1, }), symmetricTestChannel("roasbeef", "b", 100000, &testChannelPolicy{}, 2), symmetricTestChannel("b", "target", 100000, &testChannelPolicy{ Expiry: 100, FeeBaseMsat: 20000, MinHTLC: 1, }), symmetricTestChannel("roasbeef", "c", 100000, &testChannelPolicy{}, 3), symmetricTestChannel("c", "d", 100000, &testChannelPolicy{ Expiry: 30, FeeBaseMsat: 10000, MinHTLC: 1, }), symmetricTestChannel("d", "target", 100000, &testChannelPolicy{ Expiry: 30, FeeBaseMsat: 10000, MinHTLC: 1, }), } ctx := newPathFindingTestContext(t, useCache, testChannels, "roasbeef") paymentAmt := lnwire.NewMSatFromSatoshis(100) target := ctx.keyFromAlias("target") ctx.restrictParams.CltvLimit = limit path, err := ctx.findPath(target, paymentAmt) if expectedChannel == 0 { // Finish test if we expect no route. if err == errNoPathFound { return } t.Fatal("expected no path to be found") } require.NoError(t, err, "unable to find path") const ( startingHeight = 100 finalHopCLTV = 1 ) route, err := newRoute( ctx.source, path, startingHeight, finalHopParams{ amt: paymentAmt, cltvDelta: finalHopCLTV, records: nil, }, nil, ) require.NoError(t, err, "unable to create path") // Assert that the route starts with the expected channel. if route.Hops[0].ChannelID != expectedChannel { t.Fatalf("expected route to pass through channel %v, "+ "but channel %v was selected instead", expectedChannel, route.Hops[0].ChannelID) } } // runProbabilityRouting asserts that path finding not only takes into account // fees but also success probability. func runProbabilityRouting(t *testing.T, useCache bool) { testCases := []struct { name string p10, p11, p20 float64 minProbability float64 expectedChan uint64 amount btcutil.Amount timePref float64 }{ // Test two variations with probabilities that should multiply // to the same total route probability. In both cases the three // hop route should be the best route. The three hop route has a // probability of 0.5 * 0.8 = 0.4. The fee is 5 (chan 10) + 8 // (chan 11) = 13. The attempt cost is 9 + 1% * 100 = 10. Path // finding distance should work out to: 13 + 10 (attempt // penalty) / 0.4 = 38. The two hop route is 25 + 10 / 0.7 = 39. { name: "three hop 1", p10: 0.8, p11: 0.5, p20: 0.7, minProbability: 0.1, expectedChan: 10, amount: 100, }, { name: "three hop 2", p10: 0.5, p11: 0.8, p20: 0.7, minProbability: 0.1, expectedChan: 10, amount: 100, }, // If we increase the time preference, we expect the algorithm // to choose - with everything else being equal - the more // expensive, more reliable route. { name: "three hop timepref", p10: 0.5, p11: 0.8, p20: 0.7, minProbability: 0.1, expectedChan: 20, amount: 100, timePref: 1, }, // If a larger amount is sent, the effect of the proportional // attempt cost becomes more noticeable. This amount in this // test brings the attempt cost to 9 + 1% * 300 = 12 sat. The // three hop path finding distance should work out to: 13 + 12 // (attempt penalty) / 0.4 = 43. The two hop route is 25 + 12 / // 0.7 = 42. For this higher amount, the two hop route is // expected to be selected. { name: "two hop high amount", p10: 0.8, p11: 0.5, p20: 0.7, minProbability: 0.1, expectedChan: 20, amount: 300, }, // If the probability of the two hop route is increased, its // distance becomes 25 + 10 / 0.85 = 37. This is less than the // three hop route with its distance 38. So with an attempt // penalty of 10, the higher fee route is chosen because of the // compensation for success probability. { name: "two hop higher cost", p10: 0.5, p11: 0.8, p20: 0.85, minProbability: 0.1, expectedChan: 20, amount: 100, }, // If the same probabilities are used with a probability lower bound of // 0.5, we expect the three hop route with probability 0.4 to be // excluded and the two hop route to be picked. { name: "probability limit", p10: 0.8, p11: 0.5, p20: 0.7, minProbability: 0.5, expectedChan: 20, amount: 100, }, // With a probability limit above the probability of both routes, we // expect no route to be returned. This expectation is signaled by using // expected channel 0. { name: "probability limit no routes", p10: 0.8, p11: 0.5, p20: 0.7, minProbability: 0.8, expectedChan: 0, amount: 100, }, } for _, tc := range testCases { tc := tc t.Run(tc.name, func(t *testing.T) { testProbabilityRouting( t, useCache, tc.amount, tc.timePref, tc.p10, tc.p11, tc.p20, tc.minProbability, tc.expectedChan, ) }) } } func testProbabilityRouting(t *testing.T, useCache bool, paymentAmt btcutil.Amount, timePref float64, p10, p11, p20, minProbability float64, expectedChan uint64) { t.Parallel() // Set up a test graph with two possible paths to the target: a three // hop path (via channels 10 and 11) and a two hop path (via channel // 20). testChannels := []*testChannel{ symmetricTestChannel("roasbeef", "a1", 100000, &testChannelPolicy{}), symmetricTestChannel("roasbeef", "b", 100000, &testChannelPolicy{}), symmetricTestChannel("a1", "a2", 100000, &testChannelPolicy{ Expiry: 144, FeeBaseMsat: lnwire.NewMSatFromSatoshis(5), MinHTLC: 1, }, 10), symmetricTestChannel("a2", "target", 100000, &testChannelPolicy{ Expiry: 144, FeeBaseMsat: lnwire.NewMSatFromSatoshis(8), MinHTLC: 1, }, 11), symmetricTestChannel("b", "target", 100000, &testChannelPolicy{ Expiry: 100, FeeBaseMsat: lnwire.NewMSatFromSatoshis(25), MinHTLC: 1, }, 20), } ctx := newPathFindingTestContext(t, useCache, testChannels, "roasbeef") alias := ctx.testGraphInstance.aliasMap target := ctx.testGraphInstance.aliasMap["target"] // Configure a probability source with the test parameters. ctx.restrictParams.ProbabilitySource = func(fromNode, toNode route.Vertex, amt lnwire.MilliSatoshi, capacity btcutil.Amount) float64 { if amt == 0 { t.Fatal("expected non-zero amount") } switch { case fromNode == alias["a1"] && toNode == alias["a2"]: return p10 case fromNode == alias["a2"] && toNode == alias["target"]: return p11 case fromNode == alias["b"] && toNode == alias["target"]: return p20 default: return 1 } } ctx.pathFindingConfig = PathFindingConfig{ AttemptCost: lnwire.NewMSatFromSatoshis(9), AttemptCostPPM: 10000, MinProbability: minProbability, } ctx.timePref = timePref path, err := ctx.findPath( target, lnwire.NewMSatFromSatoshis(paymentAmt), ) if expectedChan == 0 { if err != errNoPathFound { t.Fatalf("expected no path found, but got %v", err) } return } if err != nil { t.Fatal(err) } // Assert that the route passes through the expected channel. if path[1].policy.ChannelID != expectedChan { t.Fatalf("expected route to pass through channel %v, "+ "but channel %v was selected instead", expectedChan, path[1].policy.ChannelID) } } // runEqualCostRouteSelection asserts that route probability will be used as a // tie breaker in case the path finding probabilities are equal. func runEqualCostRouteSelection(t *testing.T, useCache bool) { // Set up a test graph with two possible paths to the target: via a and // via b. The routing fees and probabilities are chosen such that the // algorithm will first explore target->a->source (backwards search). // This route has fee 6 and a penalty of 4 for the 25% success // probability. The algorithm will then proceed with evaluating // target->b->source, which has a fee of 8 and a penalty of 2 for the // 50% success probability. Both routes have the same path finding cost // of 10. It is expected that in that case, the highest probability // route (through b) is chosen. testChannels := []*testChannel{ symmetricTestChannel("source", "a", 100000, &testChannelPolicy{}), symmetricTestChannel("source", "b", 100000, &testChannelPolicy{}), symmetricTestChannel("a", "target", 100000, &testChannelPolicy{ Expiry: 144, FeeBaseMsat: lnwire.NewMSatFromSatoshis(6), MinHTLC: 1, }, 1), symmetricTestChannel("b", "target", 100000, &testChannelPolicy{ Expiry: 100, FeeBaseMsat: lnwire.NewMSatFromSatoshis(8), MinHTLC: 1, }, 2), } ctx := newPathFindingTestContext(t, useCache, testChannels, "source") alias := ctx.testGraphInstance.aliasMap paymentAmt := lnwire.NewMSatFromSatoshis(100) target := ctx.testGraphInstance.aliasMap["target"] ctx.restrictParams.ProbabilitySource = func(fromNode, toNode route.Vertex, amt lnwire.MilliSatoshi, capacity btcutil.Amount) float64 { switch { case fromNode == alias["source"] && toNode == alias["a"]: return 0.25 case fromNode == alias["source"] && toNode == alias["b"]: return 0.5 default: return 1 } } ctx.pathFindingConfig = PathFindingConfig{ AttemptCost: lnwire.NewMSatFromSatoshis(1), } path, err := ctx.findPath(target, paymentAmt) if err != nil { t.Fatal(err) } if path[1].policy.ChannelID != 2 { t.Fatalf("expected route to pass through channel %v, "+ "but channel %v was selected instead", 2, path[1].policy.ChannelID) } } // runNoCycle tries to guide the path finding algorithm into reconstructing an // endless route. It asserts that the algorithm is able to handle this properly. func runNoCycle(t *testing.T, useCache bool) { // Set up a test graph with two paths: source->a->target and // source->b->c->target. The fees are setup such that, searching // backwards, the algorithm will evaluate the following end of the route // first: ->target->c->target. This does not make sense, because if // target is reached, there is no need to continue to c. A proper // implementation will then go on with alternative routes. It will then // consider ->a->target because its cost is lower than the alternative // ->b->c->target and finally find source->a->target as the best route. testChannels := []*testChannel{ symmetricTestChannel("source", "a", 100000, &testChannelPolicy{ Expiry: 144, }, 1), symmetricTestChannel("source", "b", 100000, &testChannelPolicy{ Expiry: 144, }, 2), symmetricTestChannel("b", "c", 100000, &testChannelPolicy{ Expiry: 144, FeeBaseMsat: 2000, }, 3), symmetricTestChannel("c", "target", 100000, &testChannelPolicy{ Expiry: 144, FeeBaseMsat: 0, }, 4), symmetricTestChannel("a", "target", 100000, &testChannelPolicy{ Expiry: 144, FeeBaseMsat: 600, }, 5), } ctx := newPathFindingTestContext(t, useCache, testChannels, "source") const ( startingHeight = 100 finalHopCLTV = 1 ) paymentAmt := lnwire.NewMSatFromSatoshis(100) target := ctx.keyFromAlias("target") // Find the best path given the restriction to only use channel 2 as the // outgoing channel. path, err := ctx.findPath(target, paymentAmt) require.NoError(t, err, "unable to find path") route, err := newRoute( ctx.source, path, startingHeight, finalHopParams{ amt: paymentAmt, cltvDelta: finalHopCLTV, records: nil, }, nil, ) require.NoError(t, err, "unable to create path") if len(route.Hops) != 2 { t.Fatalf("unexpected route") } if route.Hops[0].ChannelID != 1 { t.Fatalf("unexpected first hop") } if route.Hops[1].ChannelID != 5 { t.Fatalf("unexpected second hop") } } // runRouteToSelf tests that it is possible to find a route to the self node. func runRouteToSelf(t *testing.T, useCache bool) { testChannels := []*testChannel{ symmetricTestChannel("source", "a", 100000, &testChannelPolicy{ Expiry: 144, FeeBaseMsat: 500, }, 1), symmetricTestChannel("source", "b", 100000, &testChannelPolicy{ Expiry: 144, FeeBaseMsat: 1000, }, 2), symmetricTestChannel("a", "b", 100000, &testChannelPolicy{ Expiry: 144, FeeBaseMsat: 1000, }, 3), } ctx := newPathFindingTestContext(t, useCache, testChannels, "source") paymentAmt := lnwire.NewMSatFromSatoshis(100) target := ctx.source // Find the best path to self. We expect this to be source->a->source, // because a charges the lowest forwarding fee. path, err := ctx.findPath(target, paymentAmt) require.NoError(t, err, "unable to find path") ctx.assertPath(path, []uint64{1, 1}) outgoingChanID := uint64(1) lastHop := ctx.keyFromAlias("b") ctx.restrictParams.OutgoingChannelIDs = []uint64{outgoingChanID} ctx.restrictParams.LastHop = &lastHop // Find the best path to self given that we want to go out via channel 1 // and return through node b. path, err = ctx.findPath(target, paymentAmt) require.NoError(t, err, "unable to find path") ctx.assertPath(path, []uint64{1, 3, 2}) } // runInboundFees tests whether correct routes are built when inbound fees // apply. func runInboundFees(t *testing.T, useCache bool) { // Setup a test network. chanCapSat := btcutil.Amount(100000) features := lnwire.NewFeatureVector( lnwire.NewRawFeatureVector( lnwire.PaymentAddrOptional, lnwire.TLVOnionPayloadRequired, ), lnwire.Features, ) testChannels := []*testChannel{ asymmetricTestChannel("a", "b", chanCapSat, &testChannelPolicy{ MinHTLC: lnwire.NewMSatFromSatoshis(2), Features: features, }, &testChannelPolicy{ Features: features, InboundFeeRate: -60000, InboundFeeBaseMsat: -5000, }, 10, ), asymmetricTestChannel("b", "c", chanCapSat, &testChannelPolicy{ Expiry: 20, FeeRate: 50000, FeeBaseMsat: 1000, MinHTLC: lnwire.NewMSatFromSatoshis(2), Features: features, }, &testChannelPolicy{ Features: features, InboundFeeRate: 0, InboundFeeBaseMsat: 5000, }, 11, ), asymmetricTestChannel("c", "d", chanCapSat, &testChannelPolicy{ Expiry: 20, FeeRate: 50000, FeeBaseMsat: 0, MinHTLC: lnwire.NewMSatFromSatoshis(2), Features: features, }, &testChannelPolicy{ Features: features, InboundFeeRate: -50000, InboundFeeBaseMsat: -8000, }, 12, ), asymmetricTestChannel("d", "e", chanCapSat, &testChannelPolicy{ Expiry: 20, FeeRate: 100000, FeeBaseMsat: 9000, MinHTLC: lnwire.NewMSatFromSatoshis(2), Features: features, }, &testChannelPolicy{ Features: features, InboundFeeRate: 80000, InboundFeeBaseMsat: 2000, }, 13, ), } ctx := newPathFindingTestContext(t, useCache, testChannels, "a") payAddr := [32]byte{1} ctx.restrictParams.PaymentAddr = fn.Some(payAddr) ctx.restrictParams.DestFeatures = tlvPayAddrFeatures const ( startingHeight = 100 finalHopCLTV = 1 ) paymentAmt := lnwire.MilliSatoshi(100_000) target := ctx.keyFromAlias("e") path, err := ctx.findPath(target, paymentAmt) require.NoError(t, err, "unable to find path") rt, err := newRoute( ctx.source, path, startingHeight, finalHopParams{ amt: paymentAmt, cltvDelta: finalHopCLTV, records: nil, paymentAddr: fn.Some(payAddr), totalAmt: paymentAmt, }, nil, ) require.NoError(t, err, "unable to create path") expectedHops := []*route.Hop{ { PubKeyBytes: ctx.keyFromAlias("b"), ChannelID: 10, // The amount that c forwards (105_050) plus the out fee // (5_252) and in fee (5_000) of c. AmtToForward: 115_302, OutgoingTimeLock: 141, }, { PubKeyBytes: ctx.keyFromAlias("c"), ChannelID: 11, // The amount that d forwards (100_000) plus the out fee // (19_000) and in fee (-13_950) of d. AmtToForward: 105_050, OutgoingTimeLock: 121, }, { PubKeyBytes: ctx.keyFromAlias("d"), ChannelID: 12, AmtToForward: 100_000, OutgoingTimeLock: 101, }, { PubKeyBytes: ctx.keyFromAlias("e"), ChannelID: 13, AmtToForward: 100_000, OutgoingTimeLock: 101, MPP: record.NewMPP(100_000, payAddr), }, } expectedRt := &route.Route{ // The amount that b forwards (115_302) plus the out fee (6_765) // and in fee (-12324) of b. The total fee is floored at zero. TotalAmount: 115_302, TotalTimeLock: 161, SourcePubKey: ctx.keyFromAlias("a"), Hops: expectedHops, } require.Equal(t, expectedRt, rt) } type pathFindingTestContext struct { t *testing.T graph *channeldb.ChannelGraph restrictParams RestrictParams bandwidthHints bandwidthHints pathFindingConfig PathFindingConfig testGraphInstance *testGraphInstance source route.Vertex timePref float64 } func newPathFindingTestContext(t *testing.T, useCache bool, testChannels []*testChannel, source string, sourceFeatureBits ...lnwire.FeatureBit) *pathFindingTestContext { testGraphInstance, err := createTestGraphFromChannels( t, useCache, testChannels, source, sourceFeatureBits..., ) require.NoError(t, err, "unable to create graph") sourceNode, err := testGraphInstance.graph.SourceNode() require.NoError(t, err, "unable to fetch source node") ctx := &pathFindingTestContext{ t: t, testGraphInstance: testGraphInstance, source: route.Vertex(sourceNode.PubKeyBytes), pathFindingConfig: *testPathFindingConfig, graph: testGraphInstance.graph, restrictParams: *noRestrictions, bandwidthHints: &mockBandwidthHints{}, } return ctx } func (c *pathFindingTestContext) keyFromAlias(alias string) route.Vertex { return c.testGraphInstance.aliasMap[alias] } func (c *pathFindingTestContext) aliasFromKey(pubKey route.Vertex) string { for alias, key := range c.testGraphInstance.aliasMap { if key == pubKey { return alias } } return "" } func (c *pathFindingTestContext) findPath(target route.Vertex, amt lnwire.MilliSatoshi) ([]*unifiedEdge, error) { return dbFindPath( c.graph, nil, c.bandwidthHints, &c.restrictParams, &c.pathFindingConfig, c.source, target, amt, c.timePref, 0, ) } func (c *pathFindingTestContext) findBlindedPaths( restrictions *blindedPathRestrictions) ([][]blindedHop, error) { return dbFindBlindedPaths(c.graph, restrictions) } func (c *pathFindingTestContext) assertPath(path []*unifiedEdge, expected []uint64) { if len(path) != len(expected) { c.t.Fatalf("expected path of length %v, but got %v", len(expected), len(path)) } for i, edge := range path { if edge.policy.ChannelID != expected[i] { c.t.Fatalf("expected hop %v to be channel %v, "+ "but got %v", i, expected[i], edge.policy.ChannelID) } } } // dbFindPath calls findPath after getting a db transaction from the database // graph. func dbFindPath(graph *channeldb.ChannelGraph, additionalEdges map[route.Vertex][]AdditionalEdge, bandwidthHints bandwidthHints, r *RestrictParams, cfg *PathFindingConfig, source, target route.Vertex, amt lnwire.MilliSatoshi, timePref float64, finalHtlcExpiry int32) ([]*unifiedEdge, error) { sourceNode, err := graph.SourceNode() if err != nil { return nil, err } graphSessFactory := newMockGraphSessionFactoryFromChanDB(graph) graphSess, closeGraphSess, err := graphSessFactory.NewGraphSession() if err != nil { return nil, err } defer func() { if err := closeGraphSess(); err != nil { log.Errorf("Error closing graph session: %v", err) } }() route, _, err := findPath( &graphParams{ additionalEdges: additionalEdges, bandwidthHints: bandwidthHints, graph: graphSess, }, r, cfg, sourceNode.PubKeyBytes, source, target, amt, timePref, finalHtlcExpiry, ) return route, err } // dbFindBlindedPaths calls findBlindedPaths after getting a db transaction from // the database graph. func dbFindBlindedPaths(graph *channeldb.ChannelGraph, restrictions *blindedPathRestrictions) ([][]blindedHop, error) { sourceNode, err := graph.SourceNode() if err != nil { return nil, err } return findBlindedPaths( newMockGraphSessionChanDB(graph), sourceNode.PubKeyBytes, restrictions, ) } // TestBlindedRouteConstruction tests creation of a blinded route with the // following topology: // // A -- B -- C (introduction point) - D (blinded) - E (blinded). func TestBlindedRouteConstruction(t *testing.T) { t.Parallel() var ( // We need valid pubkeys for the blinded portion of our route, // so we just produce all of our pubkeys in the same way. _, alicePk = btcec.PrivKeyFromBytes([]byte{1}) _, bobPk = btcec.PrivKeyFromBytes([]byte{2}) _, carolPk = btcec.PrivKeyFromBytes([]byte{3}) _, daveBlindedPk = btcec.PrivKeyFromBytes([]byte{4}) _, eveBlindedPk = btcec.PrivKeyFromBytes([]byte{5}) _, blindingPk = btcec.PrivKeyFromBytes([]byte{9}) // Convenient type conversions for the pieces of code that use // vertexes. sourceVertex = route.NewVertex(alicePk) bobVertex = route.NewVertex(bobPk) carolVertex = route.NewVertex(carolPk) daveBlindedVertex = route.NewVertex(daveBlindedPk) eveBlindedVertex = route.NewVertex(eveBlindedPk) currentHeight uint32 = 100 // Create arbitrary encrypted data for each hop (we don't need // to actually read this data to test route construction, since // it's only used by forwarding nodes) and metadata for the // final hop. metadata = []byte{1, 2, 3} carolEncryptedData = []byte{4, 5, 6} daveEncryptedData = []byte{7, 8, 9} eveEncryptedData = []byte{10, 11, 12} // Create a blinded route with carol as the introduction point. blindedPath = &sphinx.BlindedPath{ IntroductionPoint: carolPk, BlindingPoint: blindingPk, BlindedHops: []*sphinx.BlindedHopInfo{ { // Note: no pubkey is provided for // Carol because we use the pubkey // given by IntroductionPoint. CipherText: carolEncryptedData, }, { BlindedNodePub: daveBlindedPk, CipherText: daveEncryptedData, }, { BlindedNodePub: eveBlindedPk, CipherText: eveEncryptedData, }, }, } // Create a blinded payment, which contains the aggregate relay // information and constraints for the blinded portion of the // path. blindedPayment = &BlindedPayment{ BlindedPath: blindedPath, CltvExpiryDelta: 120, // Set only a base fee for easier calculations. BaseFee: 5000, Features: tlvFeatures, } // Create channel edges for the unblinded portion of our // route. Proportional fees are omitted for easy test // calculations, but non-zero base fees ensure our fee is // still accounted for. aliceBobEdge = &models.CachedEdgePolicy{ ChannelID: 1, // We won't actually use this timelock / fee (since // it's the sender's outbound channel), but we include // it with different values so that the test will trip // up if we were to include the fee/delay. TimeLockDelta: 10, FeeBaseMSat: 50, ToNodePubKey: func() route.Vertex { return bobVertex }, ToNodeFeatures: tlvFeatures, } bobCarolEdge = &models.CachedEdgePolicy{ ChannelID: 2, TimeLockDelta: 15, FeeBaseMSat: 20, ToNodePubKey: func() route.Vertex { return carolVertex }, ToNodeFeatures: tlvFeatures, } // Create final hop parameters for payment amount = 110. totalAmt lnwire.MilliSatoshi = 110 finalHopParams = finalHopParams{ amt: totalAmt, totalAmt: totalAmt, metadata: metadata, // We set a CLTV delta here just to test that this will // be ignored by newRoute since this is a blinded path // where the accumulated CLTV delta for the route // communicated in the blinded path should be assumed to // include the CLTV delta of the final hop. cltvDelta: MaxCLTVDelta, } ) require.NoError(t, blindedPayment.Validate()) blindedPathSet, err := NewBlindedPaymentPathSet( []*BlindedPayment{blindedPayment}, ) require.NoError(t, err) // Generate route hints from our blinded payment and a set of edges // that make up the graph we'll give to route construction. The hints // map is keyed by source node, so we can retrieve our blinded edges // accordingly. blindedEdges, err := blindedPayment.toRouteHints( fn.None[*btcec.PublicKey](), ) require.NoError(t, err) carolDaveEdge := blindedEdges[carolVertex][0] daveEveEdge := blindedEdges[daveBlindedVertex][0] edges := []*unifiedEdge{ { policy: aliceBobEdge, }, { policy: bobCarolEdge, blindedPayment: blindedPayment, }, { policy: carolDaveEdge.EdgePolicy(), blindedPayment: blindedPayment, }, { policy: daveEveEdge.EdgePolicy(), blindedPayment: blindedPayment, }, } // Total timelock for the route should include: // - Starting block height // - CLTV delta for Bob -> Carol (unblinded hop) // - Aggregate cltv from blinded path, which includes // - CLTV delta for Carol -> Dave -> Eve (blinded route) // - Eve's chosen final CLTV delta totalTimelock := currentHeight + uint32(bobCarolEdge.TimeLockDelta) + uint32(blindedPayment.CltvExpiryDelta) // Total amount for the route should include: // - Total amount being sent // - Fee for Bob -> Carol (unblinded hop) // - Fee for Carol -> Dave -> Eve (blinded route) totalAmount := totalAmt + bobCarolEdge.FeeBaseMSat + lnwire.MilliSatoshi(blindedPayment.BaseFee) // Bob's outgoing timelock and amount are the total less his own // outgoing channel's delta and fee. bobTimelock := currentHeight + uint32(blindedPayment.CltvExpiryDelta) bobAmount := totalAmt + lnwire.MilliSatoshi( blindedPayment.BaseFee, ) aliceBobRouteHop := &route.Hop{ PubKeyBytes: bobVertex, ChannelID: aliceBobEdge.ChannelID, // Alice -> Bob is a regular hop, so it should include all the // regular forwarding values for Bob to send an outgoing HTLC // to Carol. OutgoingTimeLock: bobTimelock, AmtToForward: bobAmount, } bobCarolRouteHop := &route.Hop{ PubKeyBytes: carolVertex, ChannelID: bobCarolEdge.ChannelID, // Bob -> Carol sends the HTLC to the introduction node, so // it should not have forwarding values for Carol (these will // be obtained from the encrypted data) and should include both // the blinding point and encrypted data to be passed to Carol. OutgoingTimeLock: 0, AmtToForward: 0, BlindingPoint: blindingPk, EncryptedData: carolEncryptedData, } carolDaveRouteHop := &route.Hop{ PubKeyBytes: daveBlindedVertex, // Carol -> Dave is within the blinded route, so should not // set any outgoing values but must include Dave's encrypted // data. OutgoingTimeLock: 0, AmtToForward: 0, EncryptedData: daveEncryptedData, } daveEveRouteHop := &route.Hop{ PubKeyBytes: eveBlindedVertex, // Dave -> Eve is the final hop in a blinded route, so it // should include outgoing values for the final value, along // with any encrypted data for Eve. Since we have not added // any block padding to the final hop, this value is _just_ // the current height (as the final CTLV delta is covered in // the blinded path. OutgoingTimeLock: currentHeight, AmtToForward: totalAmt, EncryptedData: eveEncryptedData, // The last hop should also contain final-hop fields such as // metadata and total amount. Metadata: metadata, TotalAmtMsat: totalAmt, } expectedRoute := &route.Route{ SourcePubKey: sourceVertex, Hops: []*route.Hop{ aliceBobRouteHop, bobCarolRouteHop, carolDaveRouteHop, daveEveRouteHop, }, TotalTimeLock: totalTimelock, TotalAmount: totalAmount, } route, err := newRoute( sourceVertex, edges, currentHeight, finalHopParams, blindedPathSet, ) require.NoError(t, err) require.Equal(t, expectedRoute, route) } // TestLastHopPayloadSize tests the final hop payload size. The final hop // payload structure differes from the intermediate hop payload for both the // non-blinded and blinded case. func TestLastHopPayloadSize(t *testing.T) { t.Parallel() var ( metadata = []byte{21, 22} customRecords = map[uint64][]byte{ record.CustomTypeStart: {1, 2, 3}, } sizeEncryptedData = 100 encrypedData = bytes.Repeat( []byte{1}, sizeEncryptedData, ) _, blindedPoint = btcec.PrivKeyFromBytes([]byte{5}) paymentAddr = &[32]byte{1} ampOptions = &Options{} amtToForward = lnwire.MilliSatoshi(10000) finalHopExpiry int32 = 144 oneHopPath = &sphinx.BlindedPath{ BlindedHops: []*sphinx.BlindedHopInfo{ { CipherText: encrypedData, }, }, BlindingPoint: blindedPoint, } twoHopPath = &sphinx.BlindedPath{ BlindedHops: []*sphinx.BlindedHopInfo{ { CipherText: encrypedData, }, { CipherText: encrypedData, }, }, BlindingPoint: blindedPoint, } ) oneHopBlindedPayment, err := NewBlindedPaymentPathSet( []*BlindedPayment{{BlindedPath: oneHopPath}}, ) require.NoError(t, err) twoHopBlindedPayment, err := NewBlindedPaymentPathSet( []*BlindedPayment{{BlindedPath: twoHopPath}}, ) require.NoError(t, err) testCases := []struct { name string restrictions *RestrictParams finalHopExpiry int32 amount lnwire.MilliSatoshi }{ { name: "Non blinded final hop", restrictions: &RestrictParams{ PaymentAddr: fn.Some(*paymentAddr), DestCustomRecords: customRecords, Metadata: metadata, Amp: ampOptions, }, amount: amtToForward, finalHopExpiry: finalHopExpiry, }, { name: "Blinded final hop introduction point", restrictions: &RestrictParams{ BlindedPaymentPathSet: oneHopBlindedPayment, }, amount: amtToForward, finalHopExpiry: finalHopExpiry, }, { name: "Blinded final hop of a two hop payment", restrictions: &RestrictParams{ BlindedPaymentPathSet: twoHopBlindedPayment, }, amount: amtToForward, finalHopExpiry: finalHopExpiry, }, } for _, tc := range testCases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() mpp := fn.MapOptionZ(tc.restrictions.PaymentAddr, func(addr [32]byte) *record.MPP { return record.NewMPP(tc.amount, addr) }) // In case it's an AMP payment we use the max AMP record // size to estimate the final hop size. var amp *record.AMP if tc.restrictions.Amp != nil { amp = &record.MaxAmpPayLoadSize } var finalHop route.Hop if tc.restrictions.BlindedPaymentPathSet != nil { path := tc.restrictions.BlindedPaymentPathSet. LargestLastHopPayloadPath() blindedPath := path.BlindedPath.BlindedHops blindedPoint := path.BlindedPath.BlindingPoint //nolint:lll finalHop = route.Hop{ AmtToForward: tc.amount, OutgoingTimeLock: uint32(tc.finalHopExpiry), EncryptedData: blindedPath[len(blindedPath)-1].CipherText, } if len(blindedPath) == 1 { finalHop.BlindingPoint = blindedPoint } } else { //nolint:lll finalHop = route.Hop{ AmtToForward: tc.amount, OutgoingTimeLock: uint32(tc.finalHopExpiry), Metadata: tc.restrictions.Metadata, MPP: mpp, AMP: amp, CustomRecords: tc.restrictions.DestCustomRecords, } } payLoad, err := createHopPayload(finalHop, 0, true) require.NoErrorf(t, err, "failed to create hop payload") expectedPayloadSize := lastHopPayloadSize( tc.restrictions, tc.finalHopExpiry, tc.amount, ) require.Equal( t, expectedPayloadSize, uint64(payLoad.NumBytes()), ) }) } } // TestFindBlindedPaths tests that the findBlindedPaths function correctly // selects a set of blinded paths to a destination node given various // restrictions. func TestFindBlindedPaths(t *testing.T) { featuresWithRouteBlinding := lnwire.NewFeatureVector( lnwire.NewRawFeatureVector(lnwire.RouteBlindingOptional), lnwire.Features, ) policyWithRouteBlinding := &testChannelPolicy{ Expiry: 144, FeeRate: 400, MinHTLC: 1, MaxHTLC: 100000000, Features: featuresWithRouteBlinding, } policyWithoutRouteBlinding := &testChannelPolicy{ Expiry: 144, FeeRate: 400, MinHTLC: 1, MaxHTLC: 100000000, } // Set up the following graph where Dave will be our destination node. // All the nodes except for A will signal the Route Blinding feature // bit. // // A --- F // | | // G --- D --- B --- E // | | // C-----------/ // testChannels := []*testChannel{ symmetricTestChannel( "dave", "alice", 100000, policyWithoutRouteBlinding, 1, ), symmetricTestChannel( "dave", "bob", 100000, policyWithRouteBlinding, 2, ), symmetricTestChannel( "dave", "charlie", 100000, policyWithRouteBlinding, 3, ), symmetricTestChannel( "alice", "frank", 100000, policyWithRouteBlinding, 4, ), symmetricTestChannel( "bob", "frank", 100000, policyWithRouteBlinding, 5, ), symmetricTestChannel( "eve", "charlie", 100000, policyWithRouteBlinding, 6, ), symmetricTestChannel( "bob", "eve", 100000, policyWithRouteBlinding, 7, ), symmetricTestChannel( "dave", "george", 100000, policyWithRouteBlinding, 8, ), } ctx := newPathFindingTestContext( t, true, testChannels, "dave", lnwire.RouteBlindingOptional, ) // assertPaths checks that the set of selected paths contains all the // expected paths. assertPaths := func(paths [][]blindedHop, expectedPaths []string) { require.Len(t, paths, len(expectedPaths)) actualPaths := make(map[string]bool) for _, path := range paths { var label string for _, hop := range path { label += ctx.aliasFromKey(hop.vertex) + "," } actualPaths[strings.TrimRight(label, ",")] = true } for _, path := range expectedPaths { require.True(t, actualPaths[path]) } } // 1) Restrict the min & max path length such that we only include paths // with one hop other than the destination hop. paths, err := ctx.findBlindedPaths(&blindedPathRestrictions{ minNumHops: 1, maxNumHops: 1, }) require.NoError(t, err) // We expect the B->D and C->D paths to be chosen. // The A->D path is not chosen since A does not advertise the route // blinding feature bit. The G->D path is not chosen since G does not // have any other known channels. assertPaths(paths, []string{ "bob,dave", "charlie,dave", }) // 2) Extend the search to include 2 hops other than the destination. paths, err = ctx.findBlindedPaths(&blindedPathRestrictions{ minNumHops: 1, maxNumHops: 2, }) require.NoError(t, err) // We expect the following paths: // - B, D // - F, B, D // - E, B, D // - C, D // - E, C, D assertPaths(paths, []string{ "bob,dave", "frank,bob,dave", "eve,bob,dave", "charlie,dave", "eve,charlie,dave", }) // 3) Extend the search even further and also increase the minimum path // length. paths, err = ctx.findBlindedPaths(&blindedPathRestrictions{ minNumHops: 2, maxNumHops: 3, }) require.NoError(t, err) // We expect the following paths: // - F, B, D // - E, B, D // - E, C, D // - B, E, C, D // - C, E, B, D assertPaths(paths, []string{ "frank,bob,dave", "eve,bob,dave", "eve,charlie,dave", "bob,eve,charlie,dave", "charlie,eve,bob,dave", }) // 4) Repeat the above test but instruct the function to never use // charlie. paths, err = ctx.findBlindedPaths(&blindedPathRestrictions{ minNumHops: 2, maxNumHops: 3, nodeOmissionSet: fn.NewSet[route.Vertex]( ctx.keyFromAlias("charlie"), ), }) require.NoError(t, err) // We expect the following paths: // - F, B, D // - E, B, D assertPaths(paths, []string{ "frank,bob,dave", "eve,bob,dave", }) // 5) Finally, we will test the special case where the destination node // is also the recipient. paths, err = ctx.findBlindedPaths(&blindedPathRestrictions{ minNumHops: 0, maxNumHops: 0, }) require.NoError(t, err) assertPaths(paths, []string{ "dave", }) }