package routing import ( "math" "testing" "time" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/routing/route" "github.com/stretchr/testify/require" ) const ( smallAmount = lnwire.MilliSatoshi(400_000) largeAmount = lnwire.MilliSatoshi(5_000_000) capacity = lnwire.MilliSatoshi(10_000_000) scale = lnwire.MilliSatoshi(400_000) ) // TestSuccessProbability tests that we get correct probability estimates for // the direct channel probability. func TestSuccessProbability(t *testing.T) { t.Parallel() tests := []struct { name string expectedProbability float64 tolerance float64 successAmount lnwire.MilliSatoshi failAmount lnwire.MilliSatoshi amount lnwire.MilliSatoshi capacity lnwire.MilliSatoshi }{ // We can't send more than the capacity. { name: "no info, larger than capacity", capacity: capacity, successAmount: 0, failAmount: capacity, amount: capacity + 1, expectedProbability: 0.0, }, // With the current model we don't prefer any channels if the // send amount is large compared to the scale but small compared // to the capacity. { name: "no info, large amount", capacity: capacity, successAmount: 0, failAmount: capacity, amount: largeAmount, expectedProbability: 0.5, }, // We always expect to be able to "send" an amount of 0. { name: "no info, zero amount", capacity: capacity, successAmount: 0, failAmount: capacity, amount: 0, expectedProbability: 1.0, }, // We can't send the whole capacity. { name: "no info, full capacity", capacity: capacity, successAmount: 0, failAmount: capacity, amount: capacity, expectedProbability: 0.0, }, // Sending a small amount will have a higher probability to go // through than a large amount. { name: "no info, small amount", capacity: capacity, successAmount: 0, failAmount: capacity, amount: smallAmount, expectedProbability: 0.684, tolerance: 0.001, }, // If we had an unsettled success, we are sure we can send a // lower amount. { name: "previous success, lower amount", capacity: capacity, successAmount: largeAmount, failAmount: capacity, amount: smallAmount, expectedProbability: 1.0, }, // If we had an unsettled success, we are sure we can send the // same amount. { name: "previous success, success amount", capacity: capacity, successAmount: largeAmount, failAmount: capacity, amount: largeAmount, expectedProbability: 1.0, }, // If we had an unsettled success with a small amount, we know // with increased probability that we can send a comparable // higher amount. { name: "previous success, larger amount", capacity: capacity, successAmount: smallAmount / 2, failAmount: capacity, amount: smallAmount, expectedProbability: 0.851, tolerance: 0.001, }, // If we had a large unsettled success before, we know we can // send even larger payments with high probability. { name: "previous large success, larger " + "amount", capacity: capacity, successAmount: largeAmount / 2, failAmount: capacity, amount: largeAmount, expectedProbability: 0.998, tolerance: 0.001, }, // If we had a failure before, we can't send with the fail // amount. { name: "previous failure, fail amount", capacity: capacity, failAmount: largeAmount, amount: largeAmount, expectedProbability: 0.0, }, // We can't send a higher amount than the fail amount either. { name: "previous failure, larger fail " + "amount", capacity: capacity, failAmount: largeAmount, amount: largeAmount + smallAmount, expectedProbability: 0.0, }, // We expect a diminished non-zero probability if we try to send // an amount that's lower than the last fail amount. { name: "previous failure, lower than fail " + "amount", capacity: capacity, failAmount: largeAmount, amount: smallAmount, expectedProbability: 0.368, tolerance: 0.001, }, // From here on we deal with mixed previous successes and // failures. // We expect to be always able to send a tiny amount. { name: "previous f/s, very small amount", capacity: capacity, failAmount: largeAmount, successAmount: smallAmount, amount: 0, expectedProbability: 1.0, }, // We expect to be able to send up to the previous success // amount will full certainty. { name: "previous f/s, success amount", capacity: capacity, failAmount: largeAmount, successAmount: smallAmount, amount: smallAmount, expectedProbability: 1.0, }, // This tests a random value between small amount and large // amount. { name: "previous f/s, between f/s", capacity: capacity, failAmount: largeAmount, successAmount: smallAmount, amount: smallAmount + largeAmount/10, expectedProbability: 0.287, tolerance: 0.001, }, // We still can't send the fail amount. { name: "previous f/s, fail amount", capacity: capacity, failAmount: largeAmount, successAmount: smallAmount, amount: largeAmount, expectedProbability: 0.0, }, // Same success and failure amounts (illogical). { name: "previous f/s, same", capacity: capacity, failAmount: largeAmount, successAmount: largeAmount, amount: largeAmount, expectedProbability: 0.0, }, // Higher success than failure amount (illogical). { name: "previous f/s, higher success", capacity: capacity, failAmount: smallAmount, successAmount: largeAmount, expectedProbability: 0.0, }, } estimator := BimodalEstimator{ BimodalConfig: BimodalConfig{BimodalScaleMsat: scale}, } for _, test := range tests { test := test t.Run(test.name, func(t *testing.T) { t.Parallel() p, err := estimator.probabilityFormula( test.capacity, test.successAmount, test.failAmount, test.amount, ) require.InDelta(t, test.expectedProbability, p, test.tolerance) require.NoError(t, err) }) } // We expect an error when the capacity is zero. t.Run("zero capacity", func(t *testing.T) { t.Parallel() _, err := estimator.probabilityFormula( 0, 0, 0, 0, ) require.ErrorIs(t, err, ErrZeroCapacity) }) } // TestIntegral tests certain limits of the probability distribution integral. func TestIntegral(t *testing.T) { t.Parallel() defaultScale := lnwire.NewMSatFromSatoshis(300_000) tests := []struct { name string capacity float64 lower float64 upper float64 scale lnwire.MilliSatoshi expected float64 }{ { name: "all zero", expected: math.NaN(), scale: defaultScale, }, { name: "all same", capacity: 1, lower: 1, upper: 1, scale: defaultScale, }, { name: "large numbers, low lower", capacity: 21e17, lower: 0, upper: 21e17, expected: 1, scale: defaultScale, }, { name: "large numbers, high lower", capacity: 21e17, lower: 21e17, upper: 21e17, scale: defaultScale, }, { name: "same scale and capacity", capacity: 21e17, lower: 21e17, upper: 21e17, scale: 21e17, }, } for _, test := range tests { test := test t.Run(test.name, func(t *testing.T) { t.Parallel() estimator := BimodalEstimator{ BimodalConfig: BimodalConfig{ BimodalScaleMsat: test.scale, }, } p := estimator.integral( test.capacity, test.lower, test.upper, ) require.InDelta(t, test.expected, p, 0.001) }) } } // TestCanSend tests that the success amount drops to zero over time. func TestCanSend(t *testing.T) { t.Parallel() successAmount := lnwire.MilliSatoshi(1_000_000) successTime := time.Unix(1_000, 0) now := time.Unix(2_000, 0) decayTime := time.Duration(1_000) * time.Second infinity := time.Unix(10_000_000_000, 0) // Test an immediate retry. require.Equal(t, successAmount, canSend( successAmount, successTime, successTime, decayTime, )) // Test that after the decay time, the successAmount is 1/e of its // value. decayAmount := lnwire.MilliSatoshi(float64(successAmount) / math.E) require.Equal(t, decayAmount, canSend( successAmount, now, successTime, decayTime, )) // After a long time, we want the amount to approach 0. require.Equal(t, lnwire.MilliSatoshi(0), canSend( successAmount, infinity, successTime, decayTime, )) } // TestCannotSend tests that the fail amount approaches the capacity over time. func TestCannotSend(t *testing.T) { t.Parallel() failAmount := lnwire.MilliSatoshi(1_000_000) failTime := time.Unix(1_000, 0) now := time.Unix(2_000, 0) decayTime := time.Duration(1_000) * time.Second infinity := time.Unix(10_000_000_000, 0) capacity := lnwire.MilliSatoshi(3_000_000) // Test immediate retry. require.EqualValues(t, failAmount, cannotSend( failAmount, capacity, failTime, failTime, decayTime, )) // After the decay time we want to be between the fail amount and // the capacity. summand := lnwire.MilliSatoshi(float64(capacity-failAmount) / math.E) expected := capacity - summand require.Equal(t, expected, cannotSend( failAmount, capacity, now, failTime, decayTime, )) // After a long time, we want the amount to approach the capacity. require.Equal(t, capacity, cannotSend( failAmount, capacity, infinity, failTime, decayTime, )) } // TestComputeProbability tests the inclusion of previous forwarding results of // other channels of the node into the total probability. func TestComputeProbability(t *testing.T) { t.Parallel() nodeWeight := 1 / 5. toNode := route.Vertex{10} tolerance := 0.01 now := time.Unix(0, 0) decayTime := time.Duration(1) * time.Hour * 24 // makeNodeResults prepares forwarding data for the other channels of // the node. makeNodeResults := func(successes []bool, resultsTime time.Time, directResultTime time.Time) NodeResults { results := make(NodeResults, len(successes)) for i, s := range successes { vertex := route.Vertex{byte(i)} results[vertex] = TimedPairResult{ FailTime: resultsTime, FailAmt: 1, } if s { results[vertex] = TimedPairResult{ SuccessTime: resultsTime, SuccessAmt: 1, } } } // Include a direct result. results[toNode] = TimedPairResult{ SuccessTime: directResultTime, SuccessAmt: 1, } return results } tests := []struct { name string directProbability float64 recentDirectResult bool otherResults []bool expectedProbability float64 resultsTimeAgo time.Duration }{ // If no other information is available, use the direct // probability. { name: "unknown, only direct", directProbability: 0.5, expectedProbability: 0.5, }, // If there was a single success, expect increased success // probability. { name: "unknown, single success", directProbability: 0.5, otherResults: []bool{true}, expectedProbability: 0.583, }, // If there were many successes, expect even higher success // probability. { name: "unknown, many successes", directProbability: 0.5, otherResults: []bool{ true, true, true, true, true, }, expectedProbability: 0.75, }, // If there was a single failure, we expect a slightly decreased // probability. { name: "unknown, single failure", directProbability: 0.5, otherResults: []bool{false}, expectedProbability: 0.416, }, // If there were many failures, we expect a strongly decreased // probability. { name: "unknown, many failures", directProbability: 0.5, otherResults: []bool{ false, false, false, false, false, }, expectedProbability: 0.25, }, // A success and a failure neutralize themselves. { name: "unknown, mixed even", directProbability: 0.5, otherResults: []bool{true, false}, expectedProbability: 0.5, }, // A mixed result history leads to increase/decrease of the most // experienced successes/failures. { name: "unknown, mixed uneven", directProbability: 0.5, otherResults: []bool{ true, true, false, false, false, }, expectedProbability: 0.45, }, // Many successes don't elevate the probability above 1. { name: "success, successes", directProbability: 1.0, otherResults: []bool{ true, true, true, true, true, }, expectedProbability: 1.0, }, // Five failures on a very certain channel will lower its // success probability to the unknown probability. { name: "success, failures", directProbability: 1.0, otherResults: []bool{ false, false, false, false, false, }, expectedProbability: 0.5, }, // If we are sure that the channel can send, a single failure // will not decrease the outcome significantly. { name: "success, single failure", directProbability: 1.0, otherResults: []bool{false}, expectedProbability: 0.8333, }, { name: "success, many failures", directProbability: 1.0, otherResults: []bool{ false, false, false, false, false, false, false, }, expectedProbability: 0.416, }, // Failures won't decrease the probability below zero. { name: "fail, failures", directProbability: 0.0, otherResults: []bool{false, false, false}, expectedProbability: 0.0, }, { name: "fail, successes", directProbability: 0.0, otherResults: []bool{ true, true, true, true, true, }, expectedProbability: 0.5, }, // We test forgetting information with the time decay. // A past success won't alter the certain success probability. { name: "success, single success, decay " + "time", directProbability: 1.0, otherResults: []bool{true}, resultsTimeAgo: decayTime, expectedProbability: 1.00, }, // A failure that was experienced some time ago won't influence // as much as a recent one. { name: "success, single fail, decay time", directProbability: 1.0, otherResults: []bool{false}, resultsTimeAgo: decayTime, expectedProbability: 0.9314, }, // Information from a long time ago doesn't have any effect. { name: "success, single fail, long ago", directProbability: 1.0, otherResults: []bool{false}, resultsTimeAgo: 10 * decayTime, expectedProbability: 1.0, }, { name: "fail, successes decay time", directProbability: 0.0, otherResults: []bool{ true, true, true, true, true, }, resultsTimeAgo: decayTime, expectedProbability: 0.269, }, // Very recent info approaches the case with no time decay. { name: "unknown, successes close", directProbability: 0.5, otherResults: []bool{ true, true, true, true, true, }, resultsTimeAgo: decayTime / 10, expectedProbability: 0.741, }, // If we have recent info on the direct probability, we don't // include node-wide info. Here we check that a recent failure // is not pinned to a high probability by many successes on the // node. { name: "recent direct result", directProbability: 0.1, recentDirectResult: true, otherResults: []bool{ true, true, true, true, true, }, expectedProbability: 0.1, }, } estimator := BimodalEstimator{ BimodalConfig: BimodalConfig{ BimodalScaleMsat: scale, BimodalNodeWeight: nodeWeight, BimodalDecayTime: decayTime, }, } for _, test := range tests { test := test t.Run(test.name, func(t *testing.T) { t.Parallel() var directResultTime time.Time if test.recentDirectResult { directResultTime = now.Add(-decayTime / 2) } else { directResultTime = now.Add(-2 * decayTime) } resultsTime := now.Add(-test.resultsTimeAgo) results := makeNodeResults( test.otherResults, resultsTime, directResultTime, ) p := estimator.calculateProbability( test.directProbability, now, results, toNode, ) require.InDelta(t, test.expectedProbability, p, tolerance) }) } } // TestLocalPairProbability tests that we reduce probability for failed direct // neighbors. func TestLocalPairProbability(t *testing.T) { t.Parallel() decayTime := time.Hour now := time.Unix(1000000000, 0) toNode := route.Vertex{1} createFailedResult := func(timeAgo time.Duration) NodeResults { return NodeResults{ toNode: TimedPairResult{ FailTime: now.Add(-timeAgo), }, } } tests := []struct { name string expectedProbability float64 results NodeResults }{ { name: "no results", expectedProbability: 1.0, }, { name: "recent failure", results: createFailedResult(0), expectedProbability: 0.0, }, { name: "after decay time", results: createFailedResult(decayTime), expectedProbability: 1 - 1/math.E, }, { name: "long ago", results: createFailedResult(10 * decayTime), expectedProbability: 1.0, }, } estimator := BimodalEstimator{ BimodalConfig: BimodalConfig{BimodalDecayTime: decayTime}, } for _, test := range tests { test := test t.Run(test.name, func(t *testing.T) { t.Parallel() p := estimator.LocalPairProbability( now, test.results, toNode, ) require.InDelta(t, test.expectedProbability, p, 0.001) }) } } // FuzzProbability checks that we don't encounter errors related to NaNs. func FuzzProbability(f *testing.F) { estimator := BimodalEstimator{ BimodalConfig: BimodalConfig{BimodalScaleMsat: scale}, } f.Fuzz(func(t *testing.T, capacity, successAmt, failAmt, amt uint64) { if capacity == 0 { return } _, err := estimator.probabilityFormula( lnwire.MilliSatoshi(capacity), lnwire.MilliSatoshi(successAmt), lnwire.MilliSatoshi(failAmt), lnwire.MilliSatoshi(amt), ) require.NoError(t, err, "c: %v s: %v f: %v a: %v", capacity, successAmt, failAmt, amt) }) }