routing/test: test probability extrapolation

Adds an integrated routing test of probability extrapolation for untried
channels. The larger part of this commit is mock code to simulate the
Lightning Network.

The difference between this test and the existing pathfinding tests, is that
this test focuses on the feedback loop from result interpretation via
mission control updates and probability estimation back to pathfinding.
Improvements like probability extrapolation were previously only
validated by reasoning, while this setup makes it possible to assert the
improvement in a test and guard it for the future.
This commit is contained in:
Joost Jager 2020-01-27 15:40:33 +01:00
parent 06bdeb56e2
commit 29476ec6a3
No known key found for this signature in database
GPG Key ID: A61B9D4C393C59C7
3 changed files with 516 additions and 0 deletions

@ -0,0 +1,194 @@
package routing
import (
"io/ioutil"
"os"
"testing"
"time"
"github.com/coreos/bbolt"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/routing/route"
)
// integratedRoutingContext defines the context in which integrated routing
// tests run.
type integratedRoutingContext struct {
graph *mockGraph
t *testing.T
source *mockNode
target *mockNode
amt lnwire.MilliSatoshi
finalExpiry int32
mcCfg MissionControlConfig
pathFindingCfg PathFindingConfig
}
// newIntegratedRoutingContext instantiates a new integrated routing test
// context with a source and a target node.
func newIntegratedRoutingContext(t *testing.T) *integratedRoutingContext {
// Instantiate a mock graph.
source := newMockNode()
target := newMockNode()
graph := newMockGraph(t)
graph.addNode(source)
graph.addNode(target)
graph.source = source
// Initiate the test context with a set of default configuration values.
// We don't use the lnd defaults here, because otherwise changing the
// defaults would break the unit tests. The actual values picked aren't
// critical to excite certain behavior, but do need to be aligned with
// the test case assertions.
ctx := integratedRoutingContext{
t: t,
graph: graph,
amt: 100000,
finalExpiry: 40,
mcCfg: MissionControlConfig{
PenaltyHalfLife: 30 * time.Minute,
AprioriHopProbability: 0.6,
AprioriWeight: 0.5,
SelfNode: source.pubkey,
},
pathFindingCfg: PathFindingConfig{
PaymentAttemptPenalty: 1000,
},
source: source,
target: target,
}
return &ctx
}
// testPayment launches a test payment and asserts that it is completed after
// the expected number of attempts.
func (c *integratedRoutingContext) testPayment(expectedNofAttempts int) {
var nextPid uint64
// Create temporary database for mission control.
file, err := ioutil.TempFile("", "*.db")
if err != nil {
c.t.Fatal(err)
}
dbPath := file.Name()
defer os.Remove(dbPath)
db, err := bbolt.Open(dbPath, 0600, nil)
if err != nil {
c.t.Fatal(err)
}
defer db.Close()
// Instantiate a new mission control with the current configuration
// values.
mc, err := NewMissionControl(db, &c.mcCfg)
if err != nil {
c.t.Fatal(err)
}
// Instruct pathfinding to use mission control as a probabiltiy source.
restrictParams := RestrictParams{
ProbabilitySource: mc.GetProbability,
FeeLimit: lnwire.MaxMilliSatoshi,
}
// Now the payment control loop starts. It will keep trying routes until
// the payment succeeds.
for {
// Create bandwidth hints based on local channel balances.
bandwidthHints := map[uint64]lnwire.MilliSatoshi{}
for _, ch := range c.graph.nodes[c.source.pubkey].channels {
bandwidthHints[ch.id] = ch.balance
}
// Find a route.
path, err := findPathInternal(
nil, bandwidthHints, c.graph,
&restrictParams,
&c.pathFindingCfg,
c.source.pubkey, c.target.pubkey,
c.amt, c.finalExpiry,
)
if err != nil {
c.t.Fatal(err)
}
finalHop := finalHopParams{
amt: c.amt,
cltvDelta: uint16(c.finalExpiry),
}
route, err := newRoute(c.source.pubkey, path, 0, finalHop)
if err != nil {
c.t.Fatal(err)
}
// Send out the htlc on the mock graph.
pid := nextPid
nextPid++
htlcResult, err := c.graph.sendHtlc(route)
if err != nil {
c.t.Fatal(err)
}
// Process the result.
if htlcResult.failure == nil {
err := mc.ReportPaymentSuccess(pid, route)
if err != nil {
c.t.Fatal(err)
}
// If the payment is successful, the control loop can be
// broken out of.
break
}
// Failure, update mission control and retry.
c.t.Logf("fail: %v @ %v\n", htlcResult.failure, htlcResult.failureSource)
finalResult, err := mc.ReportPaymentFail(
pid, route,
getNodeIndex(route, htlcResult.failureSource),
htlcResult.failure,
)
if err != nil {
c.t.Fatal(err)
}
if finalResult != nil {
c.t.Logf("final result: %v\n", finalResult)
break
}
}
c.t.Logf("Payment attempts: %v\n", nextPid)
if expectedNofAttempts != int(nextPid) {
c.t.Fatalf("expected %v attempts, but needed %v",
expectedNofAttempts, nextPid)
}
}
// getNodeIndex returns the zero-based index of the given node in the route.
func getNodeIndex(route *route.Route, failureSource route.Vertex) *int {
if failureSource == route.SourcePubKey {
idx := 0
return &idx
}
for i, h := range route.Hops {
if h.PubKeyBytes == failureSource {
idx := i + 1
return &idx
}
}
return nil
}

@ -0,0 +1,57 @@
package routing
import (
"testing"
)
// TestProbabilityExtrapolation tests that probabilities for tried channels are
// extrapolated to untried channels. This is a way to improve pathfinding
// success by steering away from bad nodes.
func TestProbabilityExtrapolation(t *testing.T) {
ctx := newIntegratedRoutingContext(t)
// Create the following network of nodes:
// source -> expensiveNode (charges routing fee) -> target
// source -> intermediate1 (free routing) -> intermediate(1-10) (free routing) -> target
g := ctx.graph
expensiveNode := newMockNode()
expensiveNode.baseFee = 10000
g.addNode(expensiveNode)
g.addChannel(ctx.source, expensiveNode, 100000)
g.addChannel(ctx.target, expensiveNode, 100000)
intermediate1 := newMockNode()
g.addNode(intermediate1)
g.addChannel(ctx.source, intermediate1, 100000)
for i := 0; i < 10; i++ {
imNode := newMockNode()
g.addNode(imNode)
g.addChannel(imNode, ctx.target, 100000)
g.addChannel(imNode, intermediate1, 100000)
// The channels from intermediate1 all have insufficient balance.
g.nodes[intermediate1.pubkey].channels[imNode.pubkey].balance = 0
}
// It is expected that pathfinding will try to explore the routes via
// intermediate1 first, because those are free. But as failures happen,
// the node probability of intermediate1 will go down in favor of the
// paid route via expensiveNode.
//
// The exact number of attempts required is dependent on mission control
// config. For this test, it would have been enough to only assert that
// we are not trying all routes via intermediate1. However, we do assert
// a specific number of attempts to safe-guard against accidental
// modifications anywhere in the chain of components that is involved in
// this test.
ctx.testPayment(5)
// If we use a static value for the node probability (no extrapolation
// of data from other channels), all ten bad channels will be tried
// first before switching to the paid channel.
ctx.mcCfg.AprioriWeight = 1
ctx.testPayment(11)
}

265
routing/mock_graph_test.go Normal file

@ -0,0 +1,265 @@
package routing
import (
"bytes"
"fmt"
"testing"
"github.com/btcsuite/btcutil"
"github.com/lightningnetwork/lnd/channeldb"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/routing/route"
)
// nextTestPubkey is global variable that is used to deterministically generate
// test keys.
var nextTestPubkey byte
// createPubkey return a new test pubkey.
func createPubkey() route.Vertex {
pubkey := route.Vertex{nextTestPubkey}
nextTestPubkey++
return pubkey
}
// mockChannel holds the channel state of a channel in the mock graph.
type mockChannel struct {
id uint64
capacity btcutil.Amount
balance lnwire.MilliSatoshi
}
// mockNode holds a set of mock channels and routing policies for a node in the
// mock graph.
type mockNode struct {
channels map[route.Vertex]*mockChannel
baseFee lnwire.MilliSatoshi
pubkey route.Vertex
}
// newMockNode instantiates a new mock node with a newly generated pubkey.
func newMockNode() *mockNode {
pubkey := createPubkey()
return &mockNode{
channels: make(map[route.Vertex]*mockChannel),
pubkey: pubkey,
}
}
// fwd simulates an htlc forward through this node. If the from parameter is
// nil, this node is considered to be the sender of the payment. The route
// parameter describes the remaining route from this node onwards. If route.next
// is nil, this node is the final hop.
func (m *mockNode) fwd(from *mockNode, route *hop) (htlcResult, error) {
next := route.next
// Get the incoming channel, if any.
var inChan *mockChannel
if from != nil {
inChan = m.channels[from.pubkey]
}
// If there is no next node, this is the final node and we can settle the htlc.
if next == nil {
// Update the incoming balance.
inChan.balance += route.amtToFwd
return htlcResult{}, nil
}
// Check if the outgoing channel has enough balance.
outChan, ok := m.channels[next.node.pubkey]
if !ok {
return htlcResult{},
fmt.Errorf("%v: unknown next %v",
m.pubkey, next.node.pubkey)
}
if outChan.balance < route.amtToFwd {
return htlcResult{
failureSource: m.pubkey,
failure: lnwire.NewTemporaryChannelFailure(nil),
}, nil
}
// Htlc can be forwarded, update channel balances.
outChan.balance -= route.amtToFwd
if inChan != nil {
inChan.balance += route.amtToFwd
}
// Recursively forward down the given route.
result, err := next.node.fwd(m, route.next)
if err != nil {
return htlcResult{}, err
}
// Revert balances when a failure occurs.
if result.failure != nil {
outChan.balance += route.amtToFwd
if inChan != nil {
inChan.balance -= route.amtToFwd
}
}
return result, nil
}
// mockGraph contains a set of nodes that together for a mocked graph.
type mockGraph struct {
t *testing.T
nodes map[route.Vertex]*mockNode
nextChanID uint64
source *mockNode
}
// newMockGraph instantiates a new mock graph.
func newMockGraph(t *testing.T) *mockGraph {
return &mockGraph{
nodes: make(map[route.Vertex]*mockNode),
t: t,
}
}
// addNode adds the given mock node to the network.
func (m *mockGraph) addNode(node *mockNode) {
m.nodes[node.pubkey] = node
}
// addChannel adds a new channel between two existing nodes on the network. It
// sets the channel balance to 50/50%.
//
// Ignore linter error because addChannel isn't yet called with different
// capacities.
// nolint:unparam
func (m *mockGraph) addChannel(node1, node2 *mockNode, capacity btcutil.Amount) {
id := m.nextChanID
m.nextChanID++
m.nodes[node1.pubkey].channels[node2.pubkey] = &mockChannel{
capacity: capacity,
id: id,
balance: lnwire.NewMSatFromSatoshis(capacity / 2),
}
m.nodes[node2.pubkey].channels[node1.pubkey] = &mockChannel{
capacity: capacity,
id: id,
balance: lnwire.NewMSatFromSatoshis(capacity / 2),
}
}
// forEachNodeChannel calls the callback for every channel of the given node.
//
// NOTE: Part of the routingGraph interface.
func (m *mockGraph) forEachNodeChannel(nodePub route.Vertex,
cb func(*channeldb.ChannelEdgeInfo, *channeldb.ChannelEdgePolicy,
*channeldb.ChannelEdgePolicy) error) error {
// Look up the mock node.
node, ok := m.nodes[nodePub]
if !ok {
return channeldb.ErrGraphNodeNotFound
}
// Iterate over all of its channels.
for peer, channel := range node.channels {
// Lexicographically sort the pubkeys.
var node1, node2 route.Vertex
if bytes.Compare(nodePub[:], peer[:]) == -1 {
node1, node2 = peer, nodePub
} else {
node1, node2 = nodePub, peer
}
peerNode := m.nodes[peer]
// Call the per channel callback.
err := cb(
&channeldb.ChannelEdgeInfo{
NodeKey1Bytes: node1,
NodeKey2Bytes: node2,
},
&channeldb.ChannelEdgePolicy{
ChannelID: channel.id,
Node: &channeldb.LightningNode{
PubKeyBytes: peer,
Features: lnwire.EmptyFeatureVector(),
},
FeeBaseMSat: node.baseFee,
},
&channeldb.ChannelEdgePolicy{
ChannelID: channel.id,
Node: &channeldb.LightningNode{
PubKeyBytes: nodePub,
Features: lnwire.EmptyFeatureVector(),
},
FeeBaseMSat: peerNode.baseFee,
},
)
if err != nil {
return err
}
}
return nil
}
// sourceNode returns the source node of the graph.
//
// NOTE: Part of the routingGraph interface.
func (m *mockGraph) sourceNode() route.Vertex {
return m.source.pubkey
}
// fetchNodeFeatures returns the features of the given node.
//
// NOTE: Part of the routingGraph interface.
func (m *mockGraph) fetchNodeFeatures(nodePub route.Vertex) (
*lnwire.FeatureVector, error) {
return lnwire.EmptyFeatureVector(), nil
}
// htlcResult describes the resolution of an htlc. If failure is nil, the htlc
// was settled.
type htlcResult struct {
failureSource route.Vertex
failure lnwire.FailureMessage
}
// hop describes one hop of a route.
type hop struct {
node *mockNode
amtToFwd lnwire.MilliSatoshi
next *hop
}
// sendHtlc sends out an htlc on the mock network and synchronously returns the
// final resolution of the htlc.
func (m *mockGraph) sendHtlc(route *route.Route) (htlcResult, error) {
var next *hop
// Convert the route into a structure that is suitable for recursive
// processing.
for i := len(route.Hops) - 1; i >= 0; i-- {
routeHop := route.Hops[i]
node := m.nodes[routeHop.PubKeyBytes]
next = &hop{
node: node,
next: next,
amtToFwd: routeHop.AmtToForward,
}
}
// Create the starting hop instance.
source := m.nodes[route.SourcePubKey]
next = &hop{
node: source,
next: next,
amtToFwd: route.TotalAmount,
}
// Recursively walk the path and obtain the htlc resolution.
return source.fwd(nil, next)
}
// Compile-time check for the routingGraph interface.
var _ routingGraph = &mockGraph{}