diff --git a/chanevents/analyzer.go b/chanevents/analyzer.go index b0cdcca..e010d1b 100644 --- a/chanevents/analyzer.go +++ b/chanevents/analyzer.go @@ -6,7 +6,6 @@ import ( "fmt" "iter" "log/slog" - "math" "sort" "time" @@ -38,7 +37,8 @@ type EventsSource interface { // GetChannelEvents fetches up to limit events for a channel with id > // afterID and timestamp in [startTime, endTime), ordered by id ASC. - // A large limit (e.g. math.MaxInt32) retrieves the entire range. + // Callers page through a range by passing the last returned id as + // afterID until a short page comes back. GetChannelEvents(ctx context.Context, channelID, afterID int64, startTime, endTime time.Time, limit int32) ([]*ChannelEvent, error) @@ -64,21 +64,19 @@ type ForwardingAnalyzer struct { // paired with a propagated error value. type channelEventSeq = iter.Seq2[*ChannelEvent, error] -// ForwardingAbility quantifies the historical routing performance of a peer -// pair. Inconsistent flags the pathological case where forwards were observed -// without the pair ever crossing the liquidity threshold; Velocity is zero in -// that case because the rate is undefined over zero qualifying uptime. +// ForwardingAbility holds the raw forwarding facts for one direction of a peer +// pair over the analysis window. It carries no derived rates or categories. The +// consumer derives velocity and uptime fraction from these and the window, and +// reconstructs any categorization (such as forwards observed without qualifying +// uptime) from EffectiveUptime and ForwardedAmount. type ForwardingAbility struct { - // Velocity is the forwarding velocity in sat/s during effective uptime. - Velocity float64 + // EffectiveUptime is the time the pair held at least the liquidity floor + // of directional forwardable liquidity over the window. + EffectiveUptime time.Duration - // UptimeFraction is the ratio of effective uptime to the full window - // duration, in [0, 1]. - UptimeFraction float64 - - // Inconsistent is set when forwards landed but effective uptime was - // zero, indicating the input data and the threshold model disagree. - Inconsistent bool + // ForwardedAmount is the total successfully forwarded amount over the + // window. + ForwardedAmount btcutil.Amount } // PeerPair identifies a unidirectional routing edge from PeerIn to PeerOut. @@ -89,13 +87,6 @@ type PeerPair struct { PeerOut string } -// pairInputs encapsulates the routing performance thresholds for a single -// direction. -type pairInputs struct { - threshold btcutil.Amount - totalSuccessfulAmount btcutil.Amount -} - // channelState is the per-channel snapshot the uptime walk carries forward as // it consumes events: liveness plus the two balances that determine forwarding // liquidity. @@ -117,25 +108,18 @@ func NewForwardingAnalyzer(store EventsSource, // EffectiveUptime returns a ForwardingAbility for every (peerIn, peerOut) pair // over [startTime, endTime). Closed channels are folded into the considered set -// so survivorship bias does not skew the uptime denominator. The liquidity -// floor is the fwdPercentile-th percentile of successful forward amounts (with -// fwdPercentile in [0, 100]), bounded below by threshold. When forwards land -// but the floor is never crossed, the returned ability is flagged Inconsistent. +// so survivorship bias does not skew the uptime denominator. A single +// liquidityFloor is applied uniformly to every pair, so effective uptime is the +// time each pair held at least that much directional forwardable liquidity. func (a *ForwardingAnalyzer) EffectiveUptime(ctx context.Context, startTime, - endTime time.Time, fwdPercentile float64, threshold btcutil.Amount) ( + endTime time.Time, liquidityFloor btcutil.Amount) ( map[PeerPair]ForwardingAbility, error) { - if fwdPercentile < 0 || fwdPercentile > 100 { - return nil, fmt.Errorf("fwdPercentile %v outside [0, 100]", - fwdPercentile) - } - log.DebugS( ctx, "Calculating effective uptime", slog.Time("startTime", startTime), slog.Time("endTime", endTime), - slog.Float64("fwdPercentile", fwdPercentile), - slog.Int64("threshold", int64(threshold)), + slog.Int64("liquidityFloor", int64(liquidityFloor)), ) scidToPeer, err := a.store.ScidToPeerMap(ctx) @@ -175,37 +159,63 @@ func (a *ForwardingAnalyzer) EffectiveUptime(ctx context.Context, startTime, ) return calculateAllPairsUptime( - ctx, a.store, startTime, endTime, fwdPercentile, threshold, + ctx, a.store, startTime, endTime, liquidityFloor, successfulForwards, initialStates, peerChannels, ) } -// getForwardingData returns successful forwards and channels from lnd's -// forwarding history over [startTime, endTime), indexed by peer pair. Unknown -// channels are skipped. +// getForwardingData queries lnd's forwarding history sequentially in paginated +// batches to retrieve successful forwarding events within the specified time +// range, indexing the results by peer pair. func (a *ForwardingAnalyzer) getForwardingData(ctx context.Context, startTime, endTime time.Time, scidToPeer map[uint64]string) ( map[PeerPair][]btcutil.Amount, map[uint64]string, error) { - fwds, err := a.lnd.Client.ForwardingHistory( - ctx, lndclient.ForwardingHistoryRequest{ - StartTime: startTime, - EndTime: endTime, - }, - ) - if err != nil { - return nil, nil, err + var events []lndclient.ForwardingEvent + var offset uint32 + const forwardingPageSize = 1000 + + for { + fwds, err := a.lnd.Client.ForwardingHistory( + ctx, lndclient.ForwardingHistoryRequest{ + StartTime: startTime, + EndTime: endTime, + Offset: offset, + MaxEvents: forwardingPageSize, + }, + ) + if err != nil { + return nil, nil, err + } + + if len(fwds.Events) == 0 { + break + } + + events = append(events, fwds.Events...) + if len(fwds.Events) < forwardingPageSize { + break + } + + // Guard against a non-advancing offset: if lnd does not move + // LastIndexOffset past the cursor we already queried, stop + // rather than re-fetch the same page forever. + if fwds.LastIndexOffset <= offset { + break + } + offset = fwds.LastIndexOffset } + log.DebugS( ctx, "Found forwarding events", slog.Int( - "count", len(fwds.Events), + "count", len(events), ), ) channelPeersConsidered := make(map[uint64]string) successfulForwards := make(map[PeerPair][]btcutil.Amount) - for _, fwd := range fwds.Events { + for _, fwd := range events { inPeer, ok := scidToPeer[fwd.ChannelIn] if !ok { log.WarnS( @@ -416,7 +426,7 @@ func (a *ForwardingAnalyzer) getInitialChannelState(ctx context.Context, // calculateAllPairsUptime returns forwarding abilities for every peer pair, // computing both directions (A→B and B→A) in a single pass. func calculateAllPairsUptime(ctx context.Context, store EventsSource, startTime, - endTime time.Time, fwdPercentile float64, threshold btcutil.Amount, + endTime time.Time, liquidityFloor btcutil.Amount, successfulForwards map[PeerPair][]btcutil.Amount, initialStates map[string]map[int64]*channelState, peerChannels map[string][]int64) ( @@ -496,21 +506,12 @@ func calculateAllPairsUptime(ctx context.Context, store EventsSource, startTime, statesB := initialStates[peerB] sumsB := initialSums[peerB] - inputsAB, err := pairThresholdInputs( - fwdPercentile, threshold, successfulForwards, - peerA, peerB, + forwardedAB := pairForwardedTotal( + successfulForwards, peerA, peerB, ) - if err != nil { - return nil, err - } - - inputsBA, err := pairThresholdInputs( - fwdPercentile, threshold, successfulForwards, - peerB, peerA, + forwardedBA := pairForwardedTotal( + successfulForwards, peerB, peerA, ) - if err != nil { - return nil, err - } sliceB := sliceA if i != j { @@ -523,11 +524,12 @@ func calculateAllPairsUptime(ctx context.Context, store EventsSource, startTime, abilityAB, abilityBA, err := calculateBothDirectionsUptime( ctx, startTime, endTime, - inputsAB, inputsBA, + liquidityFloor, statesA, statesB, sumsA.remote, sumsA.local, sumsB.remote, sumsB.local, mergeEventSlices(sliceA, sliceB), + forwardedAB, forwardedBA, ) if err != nil { return nil, err @@ -543,22 +545,39 @@ func calculateAllPairsUptime(ctx context.Context, store EventsSource, startTime, return results, nil } +// eventPageSize bounds a single channel-event page so the per-channel fetch +// never asks the store for an unbounded result set. +const eventPageSize = 1000 + // loadPeerEvents fetches every event in [startTime, endTime) on the given // channels and returns them merged into a single chronologically sorted slice. -// Events sharing a timestamp are ordered by ascending id so the result is -// deterministic. +// Each channel is paged through in id-ascending batches so no single store +// query is unbounded. Events sharing a timestamp are ordered by ascending id so +// the result is deterministic. func loadPeerEvents(ctx context.Context, store EventsSource, startTime, endTime time.Time, chanIDs []int64) ([]*ChannelEvent, error) { var events []*ChannelEvent for _, chanID := range chanIDs { - chanEvents, err := store.GetChannelEvents( - ctx, chanID, 0, startTime, endTime, math.MaxInt32, - ) - if err != nil { - return nil, err + var afterID int64 + for { + page, err := store.GetChannelEvents( + ctx, chanID, afterID, startTime, endTime, + eventPageSize, + ) + if err != nil { + return nil, err + } + + events = append(events, page...) + if len(page) < eventPageSize { + break + } + + // Events come back id-ASC, so the last id is the + // largest; continue the next page after it. + afterID = page[len(page)-1].ID } - events = append(events, chanEvents...) } sort.SliceStable( @@ -575,57 +594,32 @@ func loadPeerEvents(ctx context.Context, store EventsSource, startTime, return events, nil } -// pairThresholdInputs resolves the liquidity floor and cumulative forwarded -// amount for one direction of a peer pair, applying the percentile rule when -// historical forwards exist. -func pairThresholdInputs(fwdPercentile float64, threshold btcutil.Amount, - successfulForwards map[PeerPair][]btcutil.Amount, - peerIn, peerOut string) (pairInputs, error) { - - successAmts := successfulForwards[PeerPair{ - PeerIn: peerIn, PeerOut: peerOut, - }] - t, err := determineThreshold(fwdPercentile, threshold, successAmts) - if err != nil { - return pairInputs{}, err - } +// pairForwardedTotal sums the successfully forwarded amounts for one direction +// of a peer pair over the analysis window. +func pairForwardedTotal(successfulForwards map[PeerPair][]btcutil.Amount, + peerIn, peerOut string) btcutil.Amount { var total btcutil.Amount - for _, amt := range successAmts { + for _, amt := range successfulForwards[PeerPair{ + PeerIn: peerIn, PeerOut: peerOut, + }] { total += amt } - return pairInputs{threshold: t, totalSuccessfulAmount: total}, nil -} - -// determineThreshold establishes the required liquidity floor based on the -// user's manual threshold or the calculated percentile of successful forwards. -func determineThreshold(forwardPercentile float64, - thresholdAmount btcutil.Amount, - successAmts []btcutil.Amount) (btcutil.Amount, error) { - - if len(successAmts) == 0 { - return thresholdAmount, nil - } - - q := forwardPercentile / 100 - p, err := Quantile(successAmts, q) - if err != nil { - return 0, err - } - - return max(btcutil.Amount(math.RoundToEven(p)), thresholdAmount), nil + return total } // calculateBothDirectionsUptime computes the effective forwarding uptime for // both directions of a peer pair in a single chronological walk of the merged -// event stream. Only the liquidity-direction roles and the per-direction -// thresholds differ between the two accumulators. For self-pair calls (statesA -// == statesB, inputsAB == inputsBA) both returned abilities are equal. +// event stream. Only the liquidity-direction roles differ between the two +// accumulators, as both share the same uniform liquidityFloor. forwardedAB and +// forwardedBA carry each direction's total forwarded volume through to the +// returned abilities. For self-pair calls both returned abilities are equal. func calculateBothDirectionsUptime(ctx context.Context, startTime, - endTime time.Time, inputsAB, inputsBA pairInputs, statesA, + endTime time.Time, liquidityFloor btcutil.Amount, statesA, statesB map[int64]*channelState, sumARemote, sumALocal, sumBRemote, - sumBLocal btcutil.Amount, mergedEvents channelEventSeq) ( + sumBLocal btcutil.Amount, mergedEvents channelEventSeq, + forwardedAB, forwardedBA btcutil.Amount) ( *ForwardingAbility, *ForwardingAbility, error) { traceOn := log.Level() <= btclog.LevelTrace @@ -667,13 +661,8 @@ func calculateBothDirectionsUptime(ctx context.Context, startTime, ) } log.TraceS( - ctx, "Using final forwarding liquidity thresholds", - slog.Int64( - "thresholdAB", int64(inputsAB.threshold), - ), - slog.Int64( - "thresholdBA", int64(inputsBA.threshold), - ), + ctx, "Using uniform forwarding liquidity floor", + slog.Int64("liquidityFloor", int64(liquidityFloor)), ) } @@ -704,10 +693,16 @@ func calculateBothDirectionsUptime(ctx context.Context, startTime, ), ) } - if liqAB > inputsAB.threshold { + + // A direction qualifies when its bottleneck liquidity is at + // least the floor, matching the "at least" contract documented + // on the ForwardingAbility proto, struct, and CLI flag. The + // liquidity must also be strictly positive: zero forwardable + // liquidity can never route a payment, even when the floor is 0. + if liqAB >= liquidityFloor && liqAB > 0 { uptimeAB += intervalDuration } - if liqBA > inputsBA.threshold { + if liqBA >= liquidityFloor && liqBA > 0 { uptimeBA += intervalDuration } } @@ -792,12 +787,8 @@ func calculateBothDirectionsUptime(ctx context.Context, startTime, ) } - abilityAB := makeAbility( - startTime, endTime, uptimeAB, inputsAB.totalSuccessfulAmount, - ) - abilityBA := makeAbility( - startTime, endTime, uptimeBA, inputsBA.totalSuccessfulAmount, - ) + abilityAB := makeAbility(uptimeAB, forwardedAB) + abilityBA := makeAbility(uptimeBA, forwardedBA) return abilityAB, abilityBA, nil } @@ -892,19 +883,13 @@ func applyEvent(state *channelState, event *ChannelEvent) error { } // makeAbility folds an accumulated uptime and successful-amount total into a -// ForwardingAbility. When uptime is zero and forwards landed, the result is -// flagged Inconsistent with zero Velocity. -func makeAbility(startTime, endTime time.Time, totalUptime time.Duration, +// ForwardingAbility carrying the raw facts. Derived rates and categories are +// left to the consumer. +func makeAbility(totalUptime time.Duration, totalAmt btcutil.Amount) *ForwardingAbility { - if totalUptime == 0 { - return &ForwardingAbility{Inconsistent: totalAmt > 0} - } - - totalDuration := endTime.Sub(startTime) - return &ForwardingAbility{ - Velocity: float64(totalAmt) / totalUptime.Seconds(), - UptimeFraction: float64(totalUptime) / float64(totalDuration), + EffectiveUptime: totalUptime, + ForwardedAmount: totalAmt, } } diff --git a/chanevents/analyzer_test.go b/chanevents/analyzer_test.go index b49e313..ced5e5d 100644 --- a/chanevents/analyzer_test.go +++ b/chanevents/analyzer_test.go @@ -225,12 +225,10 @@ func TestCalculateBothDirectionsUptime(t *testing.T) { inEvents []*ChannelEvent outEvents []*ChannelEvent - successAmts []btcutil.Amount - thresholdAmount btcutil.Amount - forwardPercentile float64 + successAmts []btcutil.Amount + liquidityFloor btcutil.Amount - expected *ForwardingAbility - expectedErr string + expectedUptime time.Duration }{ { name: "Basic case always online", @@ -249,10 +247,28 @@ func TestCalculateBothDirectionsUptime(t *testing.T) { successAmts: []btcutil.Amount{ 100, }, - expected: &ForwardingAbility{ - Velocity: 1, // 100 sats / 100s - UptimeFraction: 1.0, + expectedUptime: 100 * time.Second, + }, + { + // Liquidity sits exactly on the floor for the whole + // window. The "at least" contract counts equality, so + // the pair accrues the full window (a strict greater- + // than comparison would wrongly report zero). + name: "Liquidity exactly at floor qualifies", + inStates: map[int64]*channelState{ + chanInID: { + online: true, + remoteBalance: 500, + }, }, + outStates: map[int64]*channelState{ + chanOutID: { + online: true, + localBalance: 500, + }, + }, + liquidityFloor: 500, + expectedUptime: 100 * time.Second, }, { // The forward in successAmts updates both channels: @@ -297,16 +313,13 @@ func TestCalculateBothDirectionsUptime(t *testing.T) { successAmts: []btcutil.Amount{ 200, }, - thresholdAmount: 900, + liquidityFloor: 900, // t=100..150 (50s): liq = min(1500, 1000) = 1000 // > 900 → qualifies. // t=150..200 (50s): liq = min(1300, 800) = 800 // < 900 → drops out. // Total uptime = 50s, total amount = 200 sats. - expected: &ForwardingAbility{ - Velocity: 4, // 200 sats / 50s - UptimeFraction: 0.5, - }, + expectedUptime: 50 * time.Second, }, { name: "Channel goes offline", @@ -328,11 +341,8 @@ func TestCalculateBothDirectionsUptime(t *testing.T) { successAmts: []btcutil.Amount{ 100, }, - thresholdAmount: 1, - expected: &ForwardingAbility{ - Velocity: 2, // 100 sats / 50s - UptimeFraction: 0.5, - }, + liquidityFloor: 1, + expectedUptime: 50 * time.Second, }, { name: "Balance change", @@ -357,15 +367,12 @@ func TestCalculateBothDirectionsUptime(t *testing.T) { successAmts: []btcutil.Amount{ 100, }, - thresholdAmount: 1, + liquidityFloor: 1, // Balance changes at t=150, so for the first 50s the // liquidity is 800, then it's 1000 for the next 50s. // The total effective uptime is 100s, because the // liquidity threshold is low. - expected: &ForwardingAbility{ - Velocity: 1, // 100 sats / 100s - UptimeFraction: 1, - }, + expectedUptime: 100 * time.Second, }, { name: "Duplicate event timestamps", @@ -397,10 +404,7 @@ func TestCalculateBothDirectionsUptime(t *testing.T) { // (50s), liquidity is min(1000, 800) = 800. After // t=150, chanIn is offline, so liquidity is 0 for the // remaining 50s. - expected: &ForwardingAbility{ - Velocity: 2, // 100 sats / 50s - UptimeFraction: 0.5, - }, + expectedUptime: 50 * time.Second, }, { name: "No initial state", @@ -427,17 +431,13 @@ func TestCalculateBothDirectionsUptime(t *testing.T) { successAmts: []btcutil.Amount{ 100, }, - thresholdAmount: 1, + liquidityFloor: 1, // We don't have initial balance states, so we can't // determine liquidity until we see an event on both // channels. At t=140 we know the liquidity is 800, and // it's online for the remaining 60s of the 100s total. // So uptime fraction is 0.6 for 800. - expected: &ForwardingAbility{ - // 100 sats / 60s - Velocity: 1.6666666666666667, - UptimeFraction: 0.6, - }, + expectedUptime: 60 * time.Second, }, { name: "Multiple channels for out peer", @@ -466,15 +466,12 @@ func TestCalculateBothDirectionsUptime(t *testing.T) { successAmts: []btcutil.Amount{ 100, }, - thresholdAmount: 900, + liquidityFloor: 900, // We expect the liquidity to be the sum of the // available balances of the out channels. t=100-150: // min(1000, 800 + 500) = 1000 t=150-200: min(1000, 1200 // + 500) = 1000 - expected: &ForwardingAbility{ - Velocity: 1, // 100 sats / 100s - UptimeFraction: 1.0, - }, + expectedUptime: 100 * time.Second, }, { name: "Circular payment ability", @@ -505,13 +502,10 @@ func TestCalculateBothDirectionsUptime(t *testing.T) { successAmts: []btcutil.Amount{ 100, }, - thresholdAmount: 1, + liquidityFloor: 1, // For the first 50s, liquidity is min(1000, 0) = 0. For // the next 50s, liquidity is min(500, 500) = 500. - expected: &ForwardingAbility{ - Velocity: 2, // 100 sats / 50s - UptimeFraction: 0.5, - }, + expectedUptime: 50 * time.Second, }, { name: "Self route multiple channels", @@ -551,19 +545,16 @@ func TestCalculateBothDirectionsUptime(t *testing.T) { successAmts: []btcutil.Amount{ 100, }, - thresholdAmount: 1500, + liquidityFloor: 1500, // Initial fwdLiquidity = min(2000, 2000) = 2000. 2000 > // 1500, so first 50s accrue. At t=150, chanOut local // drops to 0. outStates total local becomes 1000 (from // chanIn). fwdLiquidity = min(2000, 1000) = 1000. 1000 // is not > 1500, so last 50s do not accrue. - expected: &ForwardingAbility{ - Velocity: 2, // 100 sats / 50s - UptimeFraction: 0.5, - }, + expectedUptime: 50 * time.Second, }, { - name: "Zero uptime no forwards yields zero velocity", + name: "Zero uptime no forwards", inStates: map[int64]*channelState{ chanInID: { online: false, @@ -574,13 +565,14 @@ func TestCalculateBothDirectionsUptime(t *testing.T) { online: false, }, }, - expected: &ForwardingAbility{ - Velocity: 0, - UptimeFraction: 0, - }, + expectedUptime: 0, }, { - name: "Zero uptime with forwards is flagged inconsistent", + // Forwards landed but the pair never held qualifying + // liquidity: zero uptime, yet the forwarded volume is + // still reported so the consumer keeps the demand + // signal. + name: "Zero uptime with forwards retains volume", inStates: map[int64]*channelState{ chanInID: { online: false, @@ -594,11 +586,7 @@ func TestCalculateBothDirectionsUptime(t *testing.T) { successAmts: []btcutil.Amount{ 100, }, - expected: &ForwardingAbility{ - Velocity: 0, - UptimeFraction: 0, - Inconsistent: true, - }, + expectedUptime: 0, }, } @@ -617,16 +605,6 @@ func TestCalculateBothDirectionsUptime(t *testing.T) { tc.inEvents, tc.outEvents, ) - inputsAB := pairInputs{ - threshold: tc.thresholdAmount, - totalSuccessfulAmount: totalSuccessfulAmount, - } - - // The (B→A) inputs are not asserted by this - // test. Pass zero so the second ability is - // well-defined but ignored. - var inputsBA pairInputs - // Precompute the starting balances. var sumARemote, sumALocal, sumBRemote, sumBLocal btcutil.Amount @@ -644,18 +622,25 @@ func TestCalculateBothDirectionsUptime(t *testing.T) { } } + // The (B→A) forwarded total is not asserted by + // this test; pass zero so the second ability is + // well-defined but ignored. abilityAB, _, err := calculateBothDirectionsUptime( context.Background(), startTime, endTime, - inputsAB, inputsBA, + tc.liquidityFloor, tc.inStates, tc.outStates, sumARemote, sumALocal, sumBRemote, sumBLocal, mergedEvents, + totalSuccessfulAmount, 0, ) require.NoError(t, err) - require.Equal(t, tc.expected, abilityAB) + require.Equal(t, &ForwardingAbility{ + EffectiveUptime: tc.expectedUptime, + ForwardedAmount: totalSuccessfulAmount, + }, abilityAB) }, ) } @@ -692,17 +677,10 @@ func TestCalculateBothDirectionsUptimeAsymmetric(t *testing.T) { }, } - // Threshold sits between the two directions: A→B has min(1000, 1000) + // The floor sits between the two directions: A→B has min(1000, 1000) // = 1000 ≥ 500 (qualifying); B→A has min(100, 100) = 100 < 500 (not // qualifying). - inputsAB := pairInputs{ - threshold: 500, - totalSuccessfulAmount: 100, - } - inputsBA := pairInputs{ - threshold: 500, - totalSuccessfulAmount: 50, - } + const liquidityFloor btcutil.Amount = 500 // Precompute the starting balances. var sumARemote, sumALocal, sumBRemote, sumBLocal btcutil.Amount @@ -721,24 +699,25 @@ func TestCalculateBothDirectionsUptimeAsymmetric(t *testing.T) { abilityAB, abilityBA, err := calculateBothDirectionsUptime( context.Background(), startTime, endTime, - inputsAB, inputsBA, statesA, statesB, + liquidityFloor, statesA, statesB, sumARemote, sumALocal, sumBRemote, sumBLocal, mergeEventSlices(nil, nil), + 100, 50, ) require.NoError(t, err) require.Equal( t, &ForwardingAbility{ - Velocity: 1, // 100 sats / 100s - UptimeFraction: 1.0, + EffectiveUptime: 100 * time.Second, + ForwardedAmount: 100, }, abilityAB, ) require.Equal( t, &ForwardingAbility{ - Velocity: 0, - UptimeFraction: 0, - // Forwards landed but BA never crossed threshold. - Inconsistent: true, + // Forwards landed but BA never crossed the floor: zero + // uptime, volume still reported. + EffectiveUptime: 0, + ForwardedAmount: 50, }, abilityBA, ) } @@ -836,13 +815,17 @@ func (s *stubLndChannelClient) ClosedChannels(_ context.Context) ( } func (s *stubLndChannelClient) ForwardingHistory(_ context.Context, - _ lndclient.ForwardingHistoryRequest) ( + req lndclient.ForwardingHistoryRequest) ( *lndclient.ForwardingHistoryResponse, error) { if s.forwardingHistory == nil { return &lndclient.ForwardingHistoryResponse{}, nil } + if req.Offset > 0 { + return &lndclient.ForwardingHistoryResponse{}, nil + } + return s.forwardingHistory, nil } @@ -934,7 +917,7 @@ func TestEffectiveUptimeIncludesClosedChannels(t *testing.T) { startTime := seedTime.Add(time.Second) endTime := startTime.Add(time.Minute) - abilities, err := a.EffectiveUptime(ctx, startTime, endTime, 0, 0) + abilities, err := a.EffectiveUptime(ctx, startTime, endTime, 0) require.NoError(t, err) // Cross-pair entries in both directions are the cleanest assertion @@ -952,9 +935,9 @@ func TestEffectiveUptimeIncludesClosedChannels(t *testing.T) { ) } -// TestEffectiveUptimeArgs exercises the fwdPercentile, threshold, startTime, -// and endTime arguments of EffectiveUptime, verifying that they correctly -// govern the calculated forwarding liquidity floor and final uptime metrics. +// TestEffectiveUptimeArgs exercises the liquidityFloor, startTime, and endTime +// arguments of EffectiveUptime, verifying that the single uniform floor governs +// effective uptime and that forwarded volume is reported regardless of uptime. func TestEffectiveUptimeArgs(t *testing.T) { t.Parallel() @@ -1035,28 +1018,24 @@ func TestEffectiveUptimeArgs(t *testing.T) { startTime := seedTime.Add(time.Second) endTime := startTime.Add(time.Minute) - // Case 1: fwdPercentile = 50 (percentile = 200k), threshold = 50k. - // Since liquidity is 1M > max(200k, 50k) = 200k, uptime must be 1.0. - abilities, err := a.EffectiveUptime( - ctx, startTime, endTime, 50.0, 50_000, - ) + // Case 1: liquidityFloor = 50k. Liquidity of 1M exceeds the floor for + // the whole 60s window, so effective uptime is the full minute and the + // forwarded volume is the 400k sat total. + abilities, err := a.EffectiveUptime(ctx, startTime, endTime, 50_000) require.NoError(t, err) pair := PeerPair{PeerIn: validPubKey1, PeerOut: validPubKey2} require.Contains(t, abilities, pair) - require.Equal(t, 1.0, abilities[pair].UptimeFraction) - require.False(t, abilities[pair].Inconsistent) + require.Equal(t, time.Minute, abilities[pair].EffectiveUptime) + require.Equal(t, btcutil.Amount(400_000), abilities[pair].ForwardedAmount) - // Case 2: fwdPercentile = 50, threshold = 1_500_000. - // The threshold is now 1.5M, which is greater than the liquidity of 1M. - // Therefore, the liquidity never crosses the floor, resulting in - // zero uptime and the Inconsistent flag being true. - abilities, err = a.EffectiveUptime( - ctx, startTime, endTime, 50.0, 1_500_000, - ) + // Case 2: liquidityFloor = 1.5M, above the 1M liquidity, so the floor is + // never crossed and effective uptime is zero. The forwarded volume is + // still reported so the consumer keeps the demand signal. + abilities, err = a.EffectiveUptime(ctx, startTime, endTime, 1_500_000) require.NoError(t, err) require.Contains(t, abilities, pair) - require.Equal(t, 0.0, abilities[pair].UptimeFraction) - require.True(t, abilities[pair].Inconsistent) + require.Zero(t, abilities[pair].EffectiveUptime) + require.Equal(t, btcutil.Amount(400_000), abilities[pair].ForwardedAmount) } diff --git a/chanevents/quantile.go b/chanevents/quantile.go deleted file mode 100644 index d0562eb..0000000 --- a/chanevents/quantile.go +++ /dev/null @@ -1,55 +0,0 @@ -package chanevents - -import ( - "errors" - "sort" - - "golang.org/x/exp/constraints" -) - -// number is the type constraint Quantile accepts: any sortable numeric type. -type number interface { - constraints.Integer | constraints.Float -} - -// Quantile computes the q-quantile of a slice of comparable values. This can be -// used to compute the median (q=0.5) or the min (q=0) or max (q=1). -func Quantile[T number](xs []T, q float64) (float64, error) { - if q < 0 || q > 1 { - return 0, errors.New("quantile must be between 0 and 1") - } - - if len(xs) == 0 { - return 0, errors.New("cannot compute quantile of empty slice") - } - - if len(xs) == 1 { - return float64(xs[0]), nil - } - - // Create a copy of the slice to avoid mutating the original. - ys := make([]T, len(xs)) - copy(ys, xs) - - sort.Slice(ys, func(i, j int) bool { - return ys[i] < ys[j] - }) - - // Compute fractional index of q-quantile. - if q == 1.0 { - return float64(ys[len(ys)-1]), nil - } - i := q * float64(len(ys)-1) - - // Interpolate between the two consecutive values, depending on the - // fractional index position in between. - lowerIdx := int(i) - upperIdx := lowerIdx + 1 - - lowerVal := float64(ys[lowerIdx]) - upperVal := float64(ys[upperIdx]) - - indexDiff := i - float64(lowerIdx) - - return lowerVal + (upperVal-lowerVal)*indexDiff, nil -} diff --git a/chanevents/quantile_test.go b/chanevents/quantile_test.go deleted file mode 100644 index c5c61f8..0000000 --- a/chanevents/quantile_test.go +++ /dev/null @@ -1,162 +0,0 @@ -package chanevents - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -// TestQuantile pins the interpolation contract and the error paths Quantile -// surfaces to callers. -func TestQuantile(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - q float64 - xs []float64 - want float64 - expectErr bool - }{ - { - name: "empty slice", - xs: []float64{}, - expectErr: true, - }, - { - name: "single value", - xs: []float64{ - 1, - }, - want: 1.0, - }, - { - name: "single value median", - xs: []float64{ - 1, - }, - q: 0.5, - want: 1.0, - }, - { - name: "quantile out of bound below", - xs: []float64{}, - q: -0.1, - expectErr: true, - }, - { - name: "quantile out of bound above", - xs: []float64{}, - q: 1.1, - expectErr: true, - }, - { - name: "median odd values", - q: 0.5, - xs: []float64{ - 1, - 2, - 3, - 4, - 5, - }, - want: 3.0, - }, - { - name: "median even values", - q: 0.5, - xs: []float64{ - 1, - 2, - 3, - 4, - }, - want: 2.5, - }, - { - name: "median unsorted", - q: 0.5, - xs: []float64{ - 1, - 3, - 2, - 4, - }, - want: 2.5, - }, - { - name: "0 percentile", - q: 0, - xs: []float64{ - 1, - 2, - 3, - 4, - 5, - }, - want: 1.0, - }, - { - name: "25 percentile", - q: 0.25, - xs: []float64{ - 1, - 2, - 3, - 4, - 5, - }, - want: 2.0, - }, - { - name: "75 percentile", - q: 0.75, - xs: []float64{ - 1, - 2, - 3, - 4, - 5, - }, - want: 4.0, - }, - { - name: "0.875 percentile", - q: 0.875, - xs: []float64{ - 1, - 2, - 3, - 4, - 5, - }, - want: 4.5, - }, - { - name: "100 percentile", - q: 1.0, - xs: []float64{ - 1, - 2, - 3, - 4, - 5, - }, - want: 5.0, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(tt *testing.T) { - tt.Parallel() - - got, err := Quantile(tc.xs, tc.q) - if tc.expectErr { - require.Error(tt, err) - return - } - - require.InDelta(tt, tc.want, got, 1e-6) - }) - } -} diff --git a/cmd/frcli/forwarding_ability.go b/cmd/frcli/forwarding_ability.go new file mode 100644 index 0000000..f956708 --- /dev/null +++ b/cmd/frcli/forwarding_ability.go @@ -0,0 +1,122 @@ +package main + +import ( + "context" + "sort" + + "github.com/lightninglabs/faraday/frdrpc" + "github.com/urfave/cli" +) + +var forwardingAbilityCommand = cli.Command{ + Name: "forwardingability", + Category: "insights", + Usage: "Get forwarding ability analysis of peer pairs.", + Flags: []cli.Flag{ + cli.Uint64Flag{ + Name: "start_time", + Usage: "start time of the query range as a unix " + + "timestamp", + }, + cli.Uint64Flag{ + Name: "end_time", + Usage: "end time of the query range as a unix " + + "timestamp; zero defaults to the server's " + + "current time", + }, + cli.Uint64Flag{ + Name: "liquidity_floor_sat", + Usage: "the minimum directional liquidity in " + + "satoshis for a pair to count as " + + "economically forwardable; zero uses the " + + "server default", + }, + cli.Float64Flag{ + Name: "uptime_threshold", + Usage: "the uptime fraction in [0,1] at or above " + + "which a non-forwarding pair is reported as " + + "up but idle; zero uses the server default", + }, + }, + Action: queryForwardingAbility, +} + +type pairView struct { + PeerIn string `json:"peer_in"` + PeerOut string `json:"peer_out"` + EffectiveUptimeS int64 `json:"effective_uptime_s"` + ForwardedSat int64 `json:"forwarded_sat"` + UptimeFraction float64 `json:"uptime_fraction"` + Velocity float64 `json:"velocity"` +} + +func queryForwardingAbility(ctx *cli.Context) error { + client, cleanup := getClient(ctx) + defer cleanup() + + req := &frdrpc.ForwardingAbilityRequest{ + StartTime: ctx.Uint64("start_time"), + EndTime: ctx.Uint64("end_time"), + LiquidityFloorSat: ctx.Uint64("liquidity_floor_sat"), + UptimeThreshold: ctx.Float64("uptime_threshold"), + } + + rpcCtx := context.Background() + resp, err := client.ForwardingAbility(rpcCtx, req) + if err != nil { + return err + } + + abilities, err := frdrpc.DecodeForwardingAbility(resp) + if err != nil { + return err + } + + // The metrics are raw, so derive uptime fraction and velocity here from + // the window the server reported. + windowSeconds := resp.EndTime - resp.StartTime + + var views []pairView + for inPeer, outMap := range abilities { + for outPeer, ability := range outMap { + var uptimeFraction, velocity float64 + if windowSeconds > 0 { + uptimeFraction = float64( + ability.EffectiveUptimeS, + ) / float64(windowSeconds) + } + + if ability.EffectiveUptimeS > 0 { + velocity = float64(ability.ForwardedSat) / + float64(ability.EffectiveUptimeS) + } + + views = append( + views, pairView{ + PeerIn: inPeer, + PeerOut: outPeer, + EffectiveUptimeS: ability.EffectiveUptimeS, + ForwardedSat: ability.ForwardedSat, + UptimeFraction: uptimeFraction, + Velocity: velocity, + }, + ) + } + } + + // Stable sort by PeerIn, then PeerOut. + sort.SliceStable( + views, + func(i, j int) bool { + if views[i].PeerIn != views[j].PeerIn { + return views[i].PeerIn < views[j].PeerIn + } + + return views[i].PeerOut < views[j].PeerOut + }, + ) + + printJSON(views) + + return nil +} diff --git a/cmd/frcli/main.go b/cmd/frcli/main.go index 208520f..264d7e4 100644 --- a/cmd/frcli/main.go +++ b/cmd/frcli/main.go @@ -58,6 +58,7 @@ func main() { onChainReportCommand, closeReportCommand, chanEventsCommand, + forwardingAbilityCommand, } if err := app.Run(os.Args); err != nil { diff --git a/faraday.go b/faraday.go index 314c0cf..23b5255 100644 --- a/faraday.go +++ b/faraday.go @@ -177,10 +177,14 @@ func (f *Faraday) Start() error { return fmt.Errorf("error initializing faraday: %v", err) } + fwdAnalyzer := chanevents.NewForwardingAnalyzer( + f.stores.ChanEventsStore, f.lnd.LndServices, + ) cfg := &frdrpcserver.Config{ - Lnd: f.lnd.LndServices, - ChanEvents: f.stores.ChanEventsStore, - BitcoinClient: f.bitcoinClient, + Lnd: f.lnd.LndServices, + ChanEvents: f.stores.ChanEventsStore, + ForwardingAnalyzer: fwdAnalyzer, + BitcoinClient: f.bitcoinClient, } // Create the RPC server. @@ -400,10 +404,14 @@ func (f *Faraday) StartAsSubserver(lndGrpc *lndclient.GrpcLndServices, return fmt.Errorf("error initializing faraday: %v", err) } + fwdAnalyzer := chanevents.NewForwardingAnalyzer( + f.stores.ChanEventsStore, lndGrpc.LndServices, + ) cfg := &frdrpcserver.Config{ - Lnd: lndGrpc.LndServices, - ChanEvents: f.stores.ChanEventsStore, - BitcoinClient: f.bitcoinClient, + Lnd: lndGrpc.LndServices, + ChanEvents: f.stores.ChanEventsStore, + ForwardingAnalyzer: fwdAnalyzer, + BitcoinClient: f.bitcoinClient, } // Create the RPC server, but don't start it. diff --git a/frdrpc/faraday.pb.go b/frdrpc/faraday.pb.go index 5164bde..e854008 100644 --- a/frdrpc/faraday.pb.go +++ b/frdrpc/faraday.pb.go @@ -2210,6 +2210,263 @@ func (x *ChannelEvent) GetId() int64 { return 0 } +type ForwardingAbilityRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // The start time of the query range as unix seconds. A value of 0 means + // the earliest available data. + StartTime uint64 `protobuf:"varint,1,opt,name=start_time,json=startTime,proto3" json:"start_time,omitempty"` + // The end time of the query range as unix seconds. A value of 0 means the + // server's current time. + EndTime uint64 `protobuf:"varint,2,opt,name=end_time,json=endTime,proto3" json:"end_time,omitempty"` + // The minimum directional liquidity in satoshis for a peer pair to count as + // economically forwardable. A value of 0 selects the server default. Hold + // this constant across calls for comparable series. + LiquidityFloorSat uint64 `protobuf:"varint,3,opt,name=liquidity_floor_sat,json=liquidityFloorSat,proto3" json:"liquidity_floor_sat,omitempty"` + // The uptime fraction in [0, 1] at or above which a peer pair that did not + // forward is reported compactly as a single bit in up_but_idle_bitmask + // rather than as a full entry. A value of 0 selects the server default. + // Pairs below this threshold that also did not forward are omitted + // entirely. If no pair meets the threshold the server returns a + // FailedPrecondition error, since that indicates the node itself was down + // for the window and the data carries no signal. + UptimeThreshold float64 `protobuf:"fixed64,4,opt,name=uptime_threshold,json=uptimeThreshold,proto3" json:"uptime_threshold,omitempty"` +} + +func (x *ForwardingAbilityRequest) Reset() { + *x = ForwardingAbilityRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_faraday_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ForwardingAbilityRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ForwardingAbilityRequest) ProtoMessage() {} + +func (x *ForwardingAbilityRequest) ProtoReflect() protoreflect.Message { + mi := &file_faraday_proto_msgTypes[25] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ForwardingAbilityRequest.ProtoReflect.Descriptor instead. +func (*ForwardingAbilityRequest) Descriptor() ([]byte, []int) { + return file_faraday_proto_rawDescGZIP(), []int{25} +} + +func (x *ForwardingAbilityRequest) GetStartTime() uint64 { + if x != nil { + return x.StartTime + } + return 0 +} + +func (x *ForwardingAbilityRequest) GetEndTime() uint64 { + if x != nil { + return x.EndTime + } + return 0 +} + +func (x *ForwardingAbilityRequest) GetLiquidityFloorSat() uint64 { + if x != nil { + return x.LiquidityFloorSat + } + return 0 +} + +func (x *ForwardingAbilityRequest) GetUptimeThreshold() float64 { + if x != nil { + return x.UptimeThreshold + } + return 0 +} + +type ForwardingAbilityResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Sorted list of unique compressed 33-byte public keys of the peers. + Peers [][]byte `protobuf:"bytes,1,rep,name=peers,proto3" json:"peers,omitempty"` + // Sparse list of forwarding ability entries. An entry is present only for a + // pair that forwarded volume, carrying its exact effective_uptime_s and + // forwarded_sat. Pairs that did not forward are never listed here; they are + // either flagged in quiet_uptime_bitmask or absent. Entries take precedence + // over the bitmask for the same pair. + Entries []*ForwardingAbilityEntry `protobuf:"bytes,2,rep,name=entries,proto3" json:"entries,omitempty"` + // The start of the window the metrics cover, as unix seconds. + StartTime int64 `protobuf:"varint,3,opt,name=start_time,json=startTime,proto3" json:"start_time,omitempty"` + // The end of the window the metrics cover, as unix seconds. + EndTime int64 `protobuf:"varint,4,opt,name=end_time,json=endTime,proto3" json:"end_time,omitempty"` + // A packed bitmask over the n*n ordered peer pairs, where n is the length + // of peers. The bit at index in*n+out is set when that pair held at least + // the uptime_threshold fraction of effective uptime over the window but did + // not forward: the dense "up but idle" population. A pair with a forwarded + // entry is never flagged here. A pair that is neither listed in entries nor + // flagged here had sub-threshold uptime and no forwards: treat it as zero. + UpButIdleBitmask []byte `protobuf:"bytes,5,opt,name=up_but_idle_bitmask,json=upButIdleBitmask,proto3" json:"up_but_idle_bitmask,omitempty"` + // The uptime fraction in [0, 1] used to populate up_but_idle_bitmask, + // echoed back so consumers know the threshold the server applied. + UptimeThreshold float64 `protobuf:"fixed64,6,opt,name=uptime_threshold,json=uptimeThreshold,proto3" json:"uptime_threshold,omitempty"` +} + +func (x *ForwardingAbilityResponse) Reset() { + *x = ForwardingAbilityResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_faraday_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ForwardingAbilityResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ForwardingAbilityResponse) ProtoMessage() {} + +func (x *ForwardingAbilityResponse) ProtoReflect() protoreflect.Message { + mi := &file_faraday_proto_msgTypes[26] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ForwardingAbilityResponse.ProtoReflect.Descriptor instead. +func (*ForwardingAbilityResponse) Descriptor() ([]byte, []int) { + return file_faraday_proto_rawDescGZIP(), []int{26} +} + +func (x *ForwardingAbilityResponse) GetPeers() [][]byte { + if x != nil { + return x.Peers + } + return nil +} + +func (x *ForwardingAbilityResponse) GetEntries() []*ForwardingAbilityEntry { + if x != nil { + return x.Entries + } + return nil +} + +func (x *ForwardingAbilityResponse) GetStartTime() int64 { + if x != nil { + return x.StartTime + } + return 0 +} + +func (x *ForwardingAbilityResponse) GetEndTime() int64 { + if x != nil { + return x.EndTime + } + return 0 +} + +func (x *ForwardingAbilityResponse) GetUpButIdleBitmask() []byte { + if x != nil { + return x.UpButIdleBitmask + } + return nil +} + +func (x *ForwardingAbilityResponse) GetUptimeThreshold() float64 { + if x != nil { + return x.UptimeThreshold + } + return 0 +} + +type ForwardingAbilityEntry struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // The indices of the incoming and outgoing peers packed into a single + // 32-bit integer: packed_idx = (in << 16) | out. This caps the peer set at + // 65535 peers per direction. + PackedIdx uint32 `protobuf:"varint,1,opt,name=packed_idx,json=packedIdx,proto3" json:"packed_idx,omitempty"` + // Seconds the peer pair held at least the requested liquidity floor of + // directional forwardable liquidity over the window. + EffectiveUptimeS int64 `protobuf:"varint,2,opt,name=effective_uptime_s,json=effectiveUptimeS,proto3" json:"effective_uptime_s,omitempty"` + // Total successfully forwarded amount over the window, in satoshis. + ForwardedSat int64 `protobuf:"varint,3,opt,name=forwarded_sat,json=forwardedSat,proto3" json:"forwarded_sat,omitempty"` +} + +func (x *ForwardingAbilityEntry) Reset() { + *x = ForwardingAbilityEntry{} + if protoimpl.UnsafeEnabled { + mi := &file_faraday_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ForwardingAbilityEntry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ForwardingAbilityEntry) ProtoMessage() {} + +func (x *ForwardingAbilityEntry) ProtoReflect() protoreflect.Message { + mi := &file_faraday_proto_msgTypes[27] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ForwardingAbilityEntry.ProtoReflect.Descriptor instead. +func (*ForwardingAbilityEntry) Descriptor() ([]byte, []int) { + return file_faraday_proto_rawDescGZIP(), []int{27} +} + +func (x *ForwardingAbilityEntry) GetPackedIdx() uint32 { + if x != nil { + return x.PackedIdx + } + return 0 +} + +func (x *ForwardingAbilityEntry) GetEffectiveUptimeS() int64 { + if x != nil { + return x.EffectiveUptimeS + } + return 0 +} + +func (x *ForwardingAbilityEntry) GetForwardedSat() int64 { + if x != nil { + return x.ForwardedSat + } + return 0 +} + var File_faraday_proto protoreflect.FileDescriptor var file_faraday_proto_rawDesc = []byte{ @@ -2472,95 +2729,137 @@ var file_faraday_proto_rawDesc = []byte{ 0x6e, 0x63, 0x65, 0x12, 0x25, 0x0a, 0x0e, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x5f, 0x62, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0d, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, - 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x02, 0x69, 0x64, 0x2a, 0xa1, 0x01, 0x0a, 0x0b, 0x47, - 0x72, 0x61, 0x6e, 0x75, 0x6c, 0x61, 0x72, 0x69, 0x74, 0x79, 0x12, 0x17, 0x0a, 0x13, 0x55, 0x4e, - 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x5f, 0x47, 0x52, 0x41, 0x4e, 0x55, 0x4c, 0x41, 0x52, 0x49, 0x54, - 0x59, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x4d, 0x49, 0x4e, 0x55, 0x54, 0x45, 0x10, 0x01, 0x12, - 0x10, 0x0a, 0x0c, 0x46, 0x49, 0x56, 0x45, 0x5f, 0x4d, 0x49, 0x4e, 0x55, 0x54, 0x45, 0x53, 0x10, - 0x02, 0x12, 0x13, 0x0a, 0x0f, 0x46, 0x49, 0x46, 0x54, 0x45, 0x45, 0x4e, 0x5f, 0x4d, 0x49, 0x4e, - 0x55, 0x54, 0x45, 0x53, 0x10, 0x03, 0x12, 0x12, 0x0a, 0x0e, 0x54, 0x48, 0x49, 0x52, 0x54, 0x59, - 0x5f, 0x4d, 0x49, 0x4e, 0x55, 0x54, 0x45, 0x53, 0x10, 0x04, 0x12, 0x08, 0x0a, 0x04, 0x48, 0x4f, - 0x55, 0x52, 0x10, 0x05, 0x12, 0x0d, 0x0a, 0x09, 0x53, 0x49, 0x58, 0x5f, 0x48, 0x4f, 0x55, 0x52, - 0x53, 0x10, 0x06, 0x12, 0x10, 0x0a, 0x0c, 0x54, 0x57, 0x45, 0x4c, 0x56, 0x45, 0x5f, 0x48, 0x4f, - 0x55, 0x52, 0x53, 0x10, 0x07, 0x12, 0x07, 0x0a, 0x03, 0x44, 0x41, 0x59, 0x10, 0x08, 0x2a, 0x6a, - 0x0a, 0x0b, 0x46, 0x69, 0x61, 0x74, 0x42, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x12, 0x17, 0x0a, - 0x13, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x5f, 0x46, 0x49, 0x41, 0x54, 0x42, 0x41, 0x43, - 0x4b, 0x45, 0x4e, 0x44, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x43, 0x4f, 0x49, 0x4e, 0x43, 0x41, - 0x50, 0x10, 0x01, 0x12, 0x0c, 0x0a, 0x08, 0x43, 0x4f, 0x49, 0x4e, 0x44, 0x45, 0x53, 0x4b, 0x10, - 0x02, 0x12, 0x0a, 0x0a, 0x06, 0x43, 0x55, 0x53, 0x54, 0x4f, 0x4d, 0x10, 0x03, 0x12, 0x0d, 0x0a, - 0x09, 0x43, 0x4f, 0x49, 0x4e, 0x47, 0x45, 0x43, 0x4b, 0x4f, 0x10, 0x04, 0x12, 0x0c, 0x0a, 0x08, - 0x42, 0x49, 0x54, 0x46, 0x49, 0x4e, 0x45, 0x58, 0x10, 0x05, 0x2a, 0xa2, 0x02, 0x0a, 0x09, 0x45, - 0x6e, 0x74, 0x72, 0x79, 0x54, 0x79, 0x70, 0x65, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, - 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x16, 0x0a, 0x12, 0x4c, 0x4f, 0x43, 0x41, 0x4c, 0x5f, 0x43, - 0x48, 0x41, 0x4e, 0x4e, 0x45, 0x4c, 0x5f, 0x4f, 0x50, 0x45, 0x4e, 0x10, 0x01, 0x12, 0x17, 0x0a, - 0x13, 0x52, 0x45, 0x4d, 0x4f, 0x54, 0x45, 0x5f, 0x43, 0x48, 0x41, 0x4e, 0x4e, 0x45, 0x4c, 0x5f, - 0x4f, 0x50, 0x45, 0x4e, 0x10, 0x02, 0x12, 0x14, 0x0a, 0x10, 0x43, 0x48, 0x41, 0x4e, 0x4e, 0x45, - 0x4c, 0x5f, 0x4f, 0x50, 0x45, 0x4e, 0x5f, 0x46, 0x45, 0x45, 0x10, 0x03, 0x12, 0x11, 0x0a, 0x0d, - 0x43, 0x48, 0x41, 0x4e, 0x4e, 0x45, 0x4c, 0x5f, 0x43, 0x4c, 0x4f, 0x53, 0x45, 0x10, 0x04, 0x12, - 0x0b, 0x0a, 0x07, 0x52, 0x45, 0x43, 0x45, 0x49, 0x50, 0x54, 0x10, 0x05, 0x12, 0x0b, 0x0a, 0x07, - 0x50, 0x41, 0x59, 0x4d, 0x45, 0x4e, 0x54, 0x10, 0x06, 0x12, 0x07, 0x0a, 0x03, 0x46, 0x45, 0x45, - 0x10, 0x07, 0x12, 0x14, 0x0a, 0x10, 0x43, 0x49, 0x52, 0x43, 0x55, 0x4c, 0x41, 0x52, 0x5f, 0x52, - 0x45, 0x43, 0x45, 0x49, 0x50, 0x54, 0x10, 0x08, 0x12, 0x0b, 0x0a, 0x07, 0x46, 0x4f, 0x52, 0x57, - 0x41, 0x52, 0x44, 0x10, 0x09, 0x12, 0x0f, 0x0a, 0x0b, 0x46, 0x4f, 0x52, 0x57, 0x41, 0x52, 0x44, - 0x5f, 0x46, 0x45, 0x45, 0x10, 0x0a, 0x12, 0x14, 0x0a, 0x10, 0x43, 0x49, 0x52, 0x43, 0x55, 0x4c, - 0x41, 0x52, 0x5f, 0x50, 0x41, 0x59, 0x4d, 0x45, 0x4e, 0x54, 0x10, 0x0b, 0x12, 0x10, 0x0a, 0x0c, - 0x43, 0x49, 0x52, 0x43, 0x55, 0x4c, 0x41, 0x52, 0x5f, 0x46, 0x45, 0x45, 0x10, 0x0c, 0x12, 0x09, - 0x0a, 0x05, 0x53, 0x57, 0x45, 0x45, 0x50, 0x10, 0x0d, 0x12, 0x0d, 0x0a, 0x09, 0x53, 0x57, 0x45, - 0x45, 0x50, 0x5f, 0x46, 0x45, 0x45, 0x10, 0x0e, 0x12, 0x15, 0x0a, 0x11, 0x43, 0x48, 0x41, 0x4e, - 0x4e, 0x45, 0x4c, 0x5f, 0x43, 0x4c, 0x4f, 0x53, 0x45, 0x5f, 0x46, 0x45, 0x45, 0x10, 0x0f, 0x2a, - 0x70, 0x0a, 0x10, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x54, - 0x79, 0x70, 0x65, 0x12, 0x16, 0x0a, 0x12, 0x43, 0x48, 0x41, 0x4e, 0x5f, 0x45, 0x56, 0x45, 0x4e, - 0x54, 0x5f, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x15, 0x0a, 0x11, 0x43, - 0x48, 0x41, 0x4e, 0x5f, 0x45, 0x56, 0x45, 0x4e, 0x54, 0x5f, 0x4f, 0x4e, 0x4c, 0x49, 0x4e, 0x45, - 0x10, 0x01, 0x12, 0x16, 0x0a, 0x12, 0x43, 0x48, 0x41, 0x4e, 0x5f, 0x45, 0x56, 0x45, 0x4e, 0x54, - 0x5f, 0x4f, 0x46, 0x46, 0x4c, 0x49, 0x4e, 0x45, 0x10, 0x02, 0x12, 0x15, 0x0a, 0x11, 0x43, 0x48, - 0x41, 0x4e, 0x5f, 0x45, 0x56, 0x45, 0x4e, 0x54, 0x5f, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x10, - 0x03, 0x32, 0xa9, 0x05, 0x0a, 0x0d, 0x46, 0x61, 0x72, 0x61, 0x64, 0x61, 0x79, 0x53, 0x65, 0x72, - 0x76, 0x65, 0x72, 0x12, 0x65, 0x0a, 0x16, 0x4f, 0x75, 0x74, 0x6c, 0x69, 0x65, 0x72, 0x52, 0x65, - 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x25, 0x2e, - 0x66, 0x72, 0x64, 0x72, 0x70, 0x63, 0x2e, 0x4f, 0x75, 0x74, 0x6c, 0x69, 0x65, 0x72, 0x52, 0x65, - 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x66, 0x72, 0x64, 0x72, 0x70, 0x63, 0x2e, 0x43, 0x6c, - 0x6f, 0x73, 0x65, 0x52, 0x65, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x64, 0x61, 0x74, 0x69, 0x6f, - 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x69, 0x0a, 0x18, 0x54, 0x68, - 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x52, 0x65, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x64, - 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x27, 0x2e, 0x66, 0x72, 0x64, 0x72, 0x70, 0x63, 0x2e, - 0x54, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x52, 0x65, 0x63, 0x6f, 0x6d, 0x6d, 0x65, - 0x6e, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, - 0x24, 0x2e, 0x66, 0x72, 0x64, 0x72, 0x70, 0x63, 0x2e, 0x43, 0x6c, 0x6f, 0x73, 0x65, 0x52, 0x65, - 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4c, 0x0a, 0x0d, 0x52, 0x65, 0x76, 0x65, 0x6e, 0x75, 0x65, - 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x1c, 0x2e, 0x66, 0x72, 0x64, 0x72, 0x70, 0x63, 0x2e, - 0x52, 0x65, 0x76, 0x65, 0x6e, 0x75, 0x65, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x66, 0x72, 0x64, 0x72, 0x70, 0x63, 0x2e, 0x52, 0x65, - 0x76, 0x65, 0x6e, 0x75, 0x65, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x52, 0x0a, 0x0f, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x49, 0x6e, - 0x73, 0x69, 0x67, 0x68, 0x74, 0x73, 0x12, 0x1e, 0x2e, 0x66, 0x72, 0x64, 0x72, 0x70, 0x63, 0x2e, - 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x49, 0x6e, 0x73, 0x69, 0x67, 0x68, 0x74, 0x73, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x66, 0x72, 0x64, 0x72, 0x70, 0x63, 0x2e, - 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x49, 0x6e, 0x73, 0x69, 0x67, 0x68, 0x74, 0x73, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x49, 0x0a, 0x0c, 0x45, 0x78, 0x63, 0x68, 0x61, - 0x6e, 0x67, 0x65, 0x52, 0x61, 0x74, 0x65, 0x12, 0x1b, 0x2e, 0x66, 0x72, 0x64, 0x72, 0x70, 0x63, - 0x2e, 0x45, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x52, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x66, 0x72, 0x64, 0x72, 0x70, 0x63, 0x2e, 0x45, 0x78, - 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x52, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x40, 0x0a, 0x09, 0x4e, 0x6f, 0x64, 0x65, 0x41, 0x75, 0x64, 0x69, 0x74, 0x12, - 0x18, 0x2e, 0x66, 0x72, 0x64, 0x72, 0x70, 0x63, 0x2e, 0x4e, 0x6f, 0x64, 0x65, 0x41, 0x75, 0x64, - 0x69, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x66, 0x72, 0x64, 0x72, - 0x70, 0x63, 0x2e, 0x4e, 0x6f, 0x64, 0x65, 0x41, 0x75, 0x64, 0x69, 0x74, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x46, 0x0a, 0x0b, 0x43, 0x6c, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x70, - 0x6f, 0x72, 0x74, 0x12, 0x1a, 0x2e, 0x66, 0x72, 0x64, 0x72, 0x70, 0x63, 0x2e, 0x43, 0x6c, 0x6f, - 0x73, 0x65, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, - 0x1b, 0x2e, 0x66, 0x72, 0x64, 0x72, 0x70, 0x63, 0x2e, 0x43, 0x6c, 0x6f, 0x73, 0x65, 0x52, 0x65, - 0x70, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4f, 0x0a, 0x10, - 0x47, 0x65, 0x74, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, - 0x12, 0x1c, 0x2e, 0x66, 0x72, 0x64, 0x72, 0x70, 0x63, 0x2e, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, - 0x6c, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, - 0x2e, 0x66, 0x72, 0x64, 0x72, 0x70, 0x63, 0x2e, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x45, - 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x29, 0x5a, - 0x27, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6c, 0x69, 0x67, 0x68, - 0x74, 0x6e, 0x69, 0x6e, 0x67, 0x6c, 0x61, 0x62, 0x73, 0x2f, 0x66, 0x61, 0x72, 0x61, 0x64, 0x61, - 0x79, 0x2f, 0x66, 0x72, 0x64, 0x72, 0x70, 0x63, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x02, 0x69, 0x64, 0x22, 0xaf, 0x01, 0x0a, 0x18, 0x46, + 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x41, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x74, 0x61, 0x72, 0x74, + 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x09, 0x73, 0x74, 0x61, + 0x72, 0x74, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x65, 0x6e, 0x64, 0x5f, 0x74, 0x69, + 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x07, 0x65, 0x6e, 0x64, 0x54, 0x69, 0x6d, + 0x65, 0x12, 0x2e, 0x0a, 0x13, 0x6c, 0x69, 0x71, 0x75, 0x69, 0x64, 0x69, 0x74, 0x79, 0x5f, 0x66, + 0x6c, 0x6f, 0x6f, 0x72, 0x5f, 0x73, 0x61, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x04, 0x52, 0x11, + 0x6c, 0x69, 0x71, 0x75, 0x69, 0x64, 0x69, 0x74, 0x79, 0x46, 0x6c, 0x6f, 0x6f, 0x72, 0x53, 0x61, + 0x74, 0x12, 0x29, 0x0a, 0x10, 0x75, 0x70, 0x74, 0x69, 0x6d, 0x65, 0x5f, 0x74, 0x68, 0x72, 0x65, + 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x01, 0x52, 0x0f, 0x75, 0x70, 0x74, + 0x69, 0x6d, 0x65, 0x54, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x22, 0xff, 0x01, 0x0a, + 0x19, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x41, 0x62, 0x69, 0x6c, 0x69, + 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x70, 0x65, + 0x65, 0x72, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, + 0x12, 0x38, 0x0a, 0x07, 0x65, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x1e, 0x2e, 0x66, 0x72, 0x64, 0x72, 0x70, 0x63, 0x2e, 0x46, 0x6f, 0x72, 0x77, 0x61, + 0x72, 0x64, 0x69, 0x6e, 0x67, 0x41, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x45, 0x6e, 0x74, 0x72, + 0x79, 0x52, 0x07, 0x65, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x74, + 0x61, 0x72, 0x74, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, + 0x73, 0x74, 0x61, 0x72, 0x74, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x65, 0x6e, 0x64, + 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x07, 0x65, 0x6e, 0x64, + 0x54, 0x69, 0x6d, 0x65, 0x12, 0x2d, 0x0a, 0x13, 0x75, 0x70, 0x5f, 0x62, 0x75, 0x74, 0x5f, 0x69, + 0x64, 0x6c, 0x65, 0x5f, 0x62, 0x69, 0x74, 0x6d, 0x61, 0x73, 0x6b, 0x18, 0x05, 0x20, 0x01, 0x28, + 0x0c, 0x52, 0x10, 0x75, 0x70, 0x42, 0x75, 0x74, 0x49, 0x64, 0x6c, 0x65, 0x42, 0x69, 0x74, 0x6d, + 0x61, 0x73, 0x6b, 0x12, 0x29, 0x0a, 0x10, 0x75, 0x70, 0x74, 0x69, 0x6d, 0x65, 0x5f, 0x74, 0x68, + 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x01, 0x52, 0x0f, 0x75, + 0x70, 0x74, 0x69, 0x6d, 0x65, 0x54, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x22, 0x8a, + 0x01, 0x0a, 0x16, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x41, 0x62, 0x69, + 0x6c, 0x69, 0x74, 0x79, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x61, 0x63, + 0x6b, 0x65, 0x64, 0x5f, 0x69, 0x64, 0x78, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x70, + 0x61, 0x63, 0x6b, 0x65, 0x64, 0x49, 0x64, 0x78, 0x12, 0x2c, 0x0a, 0x12, 0x65, 0x66, 0x66, 0x65, + 0x63, 0x74, 0x69, 0x76, 0x65, 0x5f, 0x75, 0x70, 0x74, 0x69, 0x6d, 0x65, 0x5f, 0x73, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x03, 0x52, 0x10, 0x65, 0x66, 0x66, 0x65, 0x63, 0x74, 0x69, 0x76, 0x65, 0x55, + 0x70, 0x74, 0x69, 0x6d, 0x65, 0x53, 0x12, 0x23, 0x0a, 0x0d, 0x66, 0x6f, 0x72, 0x77, 0x61, 0x72, + 0x64, 0x65, 0x64, 0x5f, 0x73, 0x61, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x66, + 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x65, 0x64, 0x53, 0x61, 0x74, 0x2a, 0xa1, 0x01, 0x0a, 0x0b, + 0x47, 0x72, 0x61, 0x6e, 0x75, 0x6c, 0x61, 0x72, 0x69, 0x74, 0x79, 0x12, 0x17, 0x0a, 0x13, 0x55, + 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x5f, 0x47, 0x52, 0x41, 0x4e, 0x55, 0x4c, 0x41, 0x52, 0x49, + 0x54, 0x59, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x4d, 0x49, 0x4e, 0x55, 0x54, 0x45, 0x10, 0x01, + 0x12, 0x10, 0x0a, 0x0c, 0x46, 0x49, 0x56, 0x45, 0x5f, 0x4d, 0x49, 0x4e, 0x55, 0x54, 0x45, 0x53, + 0x10, 0x02, 0x12, 0x13, 0x0a, 0x0f, 0x46, 0x49, 0x46, 0x54, 0x45, 0x45, 0x4e, 0x5f, 0x4d, 0x49, + 0x4e, 0x55, 0x54, 0x45, 0x53, 0x10, 0x03, 0x12, 0x12, 0x0a, 0x0e, 0x54, 0x48, 0x49, 0x52, 0x54, + 0x59, 0x5f, 0x4d, 0x49, 0x4e, 0x55, 0x54, 0x45, 0x53, 0x10, 0x04, 0x12, 0x08, 0x0a, 0x04, 0x48, + 0x4f, 0x55, 0x52, 0x10, 0x05, 0x12, 0x0d, 0x0a, 0x09, 0x53, 0x49, 0x58, 0x5f, 0x48, 0x4f, 0x55, + 0x52, 0x53, 0x10, 0x06, 0x12, 0x10, 0x0a, 0x0c, 0x54, 0x57, 0x45, 0x4c, 0x56, 0x45, 0x5f, 0x48, + 0x4f, 0x55, 0x52, 0x53, 0x10, 0x07, 0x12, 0x07, 0x0a, 0x03, 0x44, 0x41, 0x59, 0x10, 0x08, 0x2a, + 0x6a, 0x0a, 0x0b, 0x46, 0x69, 0x61, 0x74, 0x42, 0x61, 0x63, 0x6b, 0x65, 0x6e, 0x64, 0x12, 0x17, + 0x0a, 0x13, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x5f, 0x46, 0x49, 0x41, 0x54, 0x42, 0x41, + 0x43, 0x4b, 0x45, 0x4e, 0x44, 0x10, 0x00, 0x12, 0x0b, 0x0a, 0x07, 0x43, 0x4f, 0x49, 0x4e, 0x43, + 0x41, 0x50, 0x10, 0x01, 0x12, 0x0c, 0x0a, 0x08, 0x43, 0x4f, 0x49, 0x4e, 0x44, 0x45, 0x53, 0x4b, + 0x10, 0x02, 0x12, 0x0a, 0x0a, 0x06, 0x43, 0x55, 0x53, 0x54, 0x4f, 0x4d, 0x10, 0x03, 0x12, 0x0d, + 0x0a, 0x09, 0x43, 0x4f, 0x49, 0x4e, 0x47, 0x45, 0x43, 0x4b, 0x4f, 0x10, 0x04, 0x12, 0x0c, 0x0a, + 0x08, 0x42, 0x49, 0x54, 0x46, 0x49, 0x4e, 0x45, 0x58, 0x10, 0x05, 0x2a, 0xa2, 0x02, 0x0a, 0x09, + 0x45, 0x6e, 0x74, 0x72, 0x79, 0x54, 0x79, 0x70, 0x65, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, + 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x16, 0x0a, 0x12, 0x4c, 0x4f, 0x43, 0x41, 0x4c, 0x5f, + 0x43, 0x48, 0x41, 0x4e, 0x4e, 0x45, 0x4c, 0x5f, 0x4f, 0x50, 0x45, 0x4e, 0x10, 0x01, 0x12, 0x17, + 0x0a, 0x13, 0x52, 0x45, 0x4d, 0x4f, 0x54, 0x45, 0x5f, 0x43, 0x48, 0x41, 0x4e, 0x4e, 0x45, 0x4c, + 0x5f, 0x4f, 0x50, 0x45, 0x4e, 0x10, 0x02, 0x12, 0x14, 0x0a, 0x10, 0x43, 0x48, 0x41, 0x4e, 0x4e, + 0x45, 0x4c, 0x5f, 0x4f, 0x50, 0x45, 0x4e, 0x5f, 0x46, 0x45, 0x45, 0x10, 0x03, 0x12, 0x11, 0x0a, + 0x0d, 0x43, 0x48, 0x41, 0x4e, 0x4e, 0x45, 0x4c, 0x5f, 0x43, 0x4c, 0x4f, 0x53, 0x45, 0x10, 0x04, + 0x12, 0x0b, 0x0a, 0x07, 0x52, 0x45, 0x43, 0x45, 0x49, 0x50, 0x54, 0x10, 0x05, 0x12, 0x0b, 0x0a, + 0x07, 0x50, 0x41, 0x59, 0x4d, 0x45, 0x4e, 0x54, 0x10, 0x06, 0x12, 0x07, 0x0a, 0x03, 0x46, 0x45, + 0x45, 0x10, 0x07, 0x12, 0x14, 0x0a, 0x10, 0x43, 0x49, 0x52, 0x43, 0x55, 0x4c, 0x41, 0x52, 0x5f, + 0x52, 0x45, 0x43, 0x45, 0x49, 0x50, 0x54, 0x10, 0x08, 0x12, 0x0b, 0x0a, 0x07, 0x46, 0x4f, 0x52, + 0x57, 0x41, 0x52, 0x44, 0x10, 0x09, 0x12, 0x0f, 0x0a, 0x0b, 0x46, 0x4f, 0x52, 0x57, 0x41, 0x52, + 0x44, 0x5f, 0x46, 0x45, 0x45, 0x10, 0x0a, 0x12, 0x14, 0x0a, 0x10, 0x43, 0x49, 0x52, 0x43, 0x55, + 0x4c, 0x41, 0x52, 0x5f, 0x50, 0x41, 0x59, 0x4d, 0x45, 0x4e, 0x54, 0x10, 0x0b, 0x12, 0x10, 0x0a, + 0x0c, 0x43, 0x49, 0x52, 0x43, 0x55, 0x4c, 0x41, 0x52, 0x5f, 0x46, 0x45, 0x45, 0x10, 0x0c, 0x12, + 0x09, 0x0a, 0x05, 0x53, 0x57, 0x45, 0x45, 0x50, 0x10, 0x0d, 0x12, 0x0d, 0x0a, 0x09, 0x53, 0x57, + 0x45, 0x45, 0x50, 0x5f, 0x46, 0x45, 0x45, 0x10, 0x0e, 0x12, 0x15, 0x0a, 0x11, 0x43, 0x48, 0x41, + 0x4e, 0x4e, 0x45, 0x4c, 0x5f, 0x43, 0x4c, 0x4f, 0x53, 0x45, 0x5f, 0x46, 0x45, 0x45, 0x10, 0x0f, + 0x2a, 0x70, 0x0a, 0x10, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x45, 0x76, 0x65, 0x6e, 0x74, + 0x54, 0x79, 0x70, 0x65, 0x12, 0x16, 0x0a, 0x12, 0x43, 0x48, 0x41, 0x4e, 0x5f, 0x45, 0x56, 0x45, + 0x4e, 0x54, 0x5f, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x15, 0x0a, 0x11, + 0x43, 0x48, 0x41, 0x4e, 0x5f, 0x45, 0x56, 0x45, 0x4e, 0x54, 0x5f, 0x4f, 0x4e, 0x4c, 0x49, 0x4e, + 0x45, 0x10, 0x01, 0x12, 0x16, 0x0a, 0x12, 0x43, 0x48, 0x41, 0x4e, 0x5f, 0x45, 0x56, 0x45, 0x4e, + 0x54, 0x5f, 0x4f, 0x46, 0x46, 0x4c, 0x49, 0x4e, 0x45, 0x10, 0x02, 0x12, 0x15, 0x0a, 0x11, 0x43, + 0x48, 0x41, 0x4e, 0x5f, 0x45, 0x56, 0x45, 0x4e, 0x54, 0x5f, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, + 0x10, 0x03, 0x32, 0x83, 0x06, 0x0a, 0x0d, 0x46, 0x61, 0x72, 0x61, 0x64, 0x61, 0x79, 0x53, 0x65, + 0x72, 0x76, 0x65, 0x72, 0x12, 0x65, 0x0a, 0x16, 0x4f, 0x75, 0x74, 0x6c, 0x69, 0x65, 0x72, 0x52, + 0x65, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x25, + 0x2e, 0x66, 0x72, 0x64, 0x72, 0x70, 0x63, 0x2e, 0x4f, 0x75, 0x74, 0x6c, 0x69, 0x65, 0x72, 0x52, + 0x65, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x66, 0x72, 0x64, 0x72, 0x70, 0x63, 0x2e, 0x43, + 0x6c, 0x6f, 0x73, 0x65, 0x52, 0x65, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x64, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x69, 0x0a, 0x18, 0x54, + 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x52, 0x65, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, + 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x27, 0x2e, 0x66, 0x72, 0x64, 0x72, 0x70, 0x63, + 0x2e, 0x54, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x52, 0x65, 0x63, 0x6f, 0x6d, 0x6d, + 0x65, 0x6e, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x24, 0x2e, 0x66, 0x72, 0x64, 0x72, 0x70, 0x63, 0x2e, 0x43, 0x6c, 0x6f, 0x73, 0x65, 0x52, + 0x65, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4c, 0x0a, 0x0d, 0x52, 0x65, 0x76, 0x65, 0x6e, 0x75, + 0x65, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x12, 0x1c, 0x2e, 0x66, 0x72, 0x64, 0x72, 0x70, 0x63, + 0x2e, 0x52, 0x65, 0x76, 0x65, 0x6e, 0x75, 0x65, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x66, 0x72, 0x64, 0x72, 0x70, 0x63, 0x2e, 0x52, + 0x65, 0x76, 0x65, 0x6e, 0x75, 0x65, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x52, 0x0a, 0x0f, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x49, + 0x6e, 0x73, 0x69, 0x67, 0x68, 0x74, 0x73, 0x12, 0x1e, 0x2e, 0x66, 0x72, 0x64, 0x72, 0x70, 0x63, + 0x2e, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x49, 0x6e, 0x73, 0x69, 0x67, 0x68, 0x74, 0x73, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x66, 0x72, 0x64, 0x72, 0x70, 0x63, + 0x2e, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x49, 0x6e, 0x73, 0x69, 0x67, 0x68, 0x74, 0x73, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x49, 0x0a, 0x0c, 0x45, 0x78, 0x63, 0x68, + 0x61, 0x6e, 0x67, 0x65, 0x52, 0x61, 0x74, 0x65, 0x12, 0x1b, 0x2e, 0x66, 0x72, 0x64, 0x72, 0x70, + 0x63, 0x2e, 0x45, 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x52, 0x61, 0x74, 0x65, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x66, 0x72, 0x64, 0x72, 0x70, 0x63, 0x2e, 0x45, + 0x78, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x52, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x40, 0x0a, 0x09, 0x4e, 0x6f, 0x64, 0x65, 0x41, 0x75, 0x64, 0x69, 0x74, + 0x12, 0x18, 0x2e, 0x66, 0x72, 0x64, 0x72, 0x70, 0x63, 0x2e, 0x4e, 0x6f, 0x64, 0x65, 0x41, 0x75, + 0x64, 0x69, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x66, 0x72, 0x64, + 0x72, 0x70, 0x63, 0x2e, 0x4e, 0x6f, 0x64, 0x65, 0x41, 0x75, 0x64, 0x69, 0x74, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x46, 0x0a, 0x0b, 0x43, 0x6c, 0x6f, 0x73, 0x65, 0x52, 0x65, + 0x70, 0x6f, 0x72, 0x74, 0x12, 0x1a, 0x2e, 0x66, 0x72, 0x64, 0x72, 0x70, 0x63, 0x2e, 0x43, 0x6c, + 0x6f, 0x73, 0x65, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x1b, 0x2e, 0x66, 0x72, 0x64, 0x72, 0x70, 0x63, 0x2e, 0x43, 0x6c, 0x6f, 0x73, 0x65, 0x52, + 0x65, 0x70, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4f, 0x0a, + 0x10, 0x47, 0x65, 0x74, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x45, 0x76, 0x65, 0x6e, 0x74, + 0x73, 0x12, 0x1c, 0x2e, 0x66, 0x72, 0x64, 0x72, 0x70, 0x63, 0x2e, 0x43, 0x68, 0x61, 0x6e, 0x6e, + 0x65, 0x6c, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x1d, 0x2e, 0x66, 0x72, 0x64, 0x72, 0x70, 0x63, 0x2e, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, + 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x58, + 0x0a, 0x11, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x41, 0x62, 0x69, 0x6c, + 0x69, 0x74, 0x79, 0x12, 0x20, 0x2e, 0x66, 0x72, 0x64, 0x72, 0x70, 0x63, 0x2e, 0x46, 0x6f, 0x72, + 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x41, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x66, 0x72, 0x64, 0x72, 0x70, 0x63, 0x2e, 0x46, + 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x67, 0x41, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x29, 0x5a, 0x27, 0x67, 0x69, 0x74, 0x68, + 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6c, 0x69, 0x67, 0x68, 0x74, 0x6e, 0x69, 0x6e, 0x67, + 0x6c, 0x61, 0x62, 0x73, 0x2f, 0x66, 0x61, 0x72, 0x61, 0x64, 0x61, 0x79, 0x2f, 0x66, 0x72, 0x64, + 0x72, 0x70, 0x63, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -2576,7 +2875,7 @@ func file_faraday_proto_rawDescGZIP() []byte { } var file_faraday_proto_enumTypes = make([]protoimpl.EnumInfo, 5) -var file_faraday_proto_msgTypes = make([]protoimpl.MessageInfo, 26) +var file_faraday_proto_msgTypes = make([]protoimpl.MessageInfo, 29) var file_faraday_proto_goTypes = []any{ (Granularity)(0), // 0: frdrpc.Granularity (FiatBackend)(0), // 1: frdrpc.FiatBackend @@ -2608,7 +2907,10 @@ var file_faraday_proto_goTypes = []any{ (*ChannelEventsRequest)(nil), // 27: frdrpc.ChannelEventsRequest (*ChannelEventsResponse)(nil), // 28: frdrpc.ChannelEventsResponse (*ChannelEvent)(nil), // 29: frdrpc.ChannelEvent - nil, // 30: frdrpc.RevenueReport.PairReportsEntry + (*ForwardingAbilityRequest)(nil), // 30: frdrpc.ForwardingAbilityRequest + (*ForwardingAbilityResponse)(nil), // 31: frdrpc.ForwardingAbilityResponse + (*ForwardingAbilityEntry)(nil), // 32: frdrpc.ForwardingAbilityEntry + nil, // 33: frdrpc.RevenueReport.PairReportsEntry } var file_faraday_proto_depIdxs = []int32{ 4, // 0: frdrpc.CloseRecommendationRequest.metric:type_name -> frdrpc.CloseRecommendationRequest.Metric @@ -2616,7 +2918,7 @@ var file_faraday_proto_depIdxs = []int32{ 5, // 2: frdrpc.ThresholdRecommendationsRequest.rec_request:type_name -> frdrpc.CloseRecommendationRequest 9, // 3: frdrpc.CloseRecommendationsResponse.recommendations:type_name -> frdrpc.Recommendation 12, // 4: frdrpc.RevenueReportResponse.reports:type_name -> frdrpc.RevenueReport - 30, // 5: frdrpc.RevenueReport.pair_reports:type_name -> frdrpc.RevenueReport.PairReportsEntry + 33, // 5: frdrpc.RevenueReport.pair_reports:type_name -> frdrpc.RevenueReport.PairReportsEntry 16, // 6: frdrpc.ChannelInsightsResponse.channel_insights:type_name -> frdrpc.ChannelInsight 0, // 7: frdrpc.ExchangeRateRequest.granularity:type_name -> frdrpc.Granularity 1, // 8: frdrpc.ExchangeRateRequest.fiat_backend:type_name -> frdrpc.FiatBackend @@ -2632,28 +2934,31 @@ var file_faraday_proto_depIdxs = []int32{ 23, // 18: frdrpc.NodeAuditResponse.reports:type_name -> frdrpc.ReportEntry 29, // 19: frdrpc.ChannelEventsResponse.events:type_name -> frdrpc.ChannelEvent 3, // 20: frdrpc.ChannelEvent.event_type:type_name -> frdrpc.ChannelEventType - 13, // 21: frdrpc.RevenueReport.PairReportsEntry.value:type_name -> frdrpc.PairReport - 6, // 22: frdrpc.FaradayServer.OutlierRecommendations:input_type -> frdrpc.OutlierRecommendationsRequest - 7, // 23: frdrpc.FaradayServer.ThresholdRecommendations:input_type -> frdrpc.ThresholdRecommendationsRequest - 10, // 24: frdrpc.FaradayServer.RevenueReport:input_type -> frdrpc.RevenueReportRequest - 14, // 25: frdrpc.FaradayServer.ChannelInsights:input_type -> frdrpc.ChannelInsightsRequest - 17, // 26: frdrpc.FaradayServer.ExchangeRate:input_type -> frdrpc.ExchangeRateRequest - 21, // 27: frdrpc.FaradayServer.NodeAudit:input_type -> frdrpc.NodeAuditRequest - 25, // 28: frdrpc.FaradayServer.CloseReport:input_type -> frdrpc.CloseReportRequest - 27, // 29: frdrpc.FaradayServer.GetChannelEvents:input_type -> frdrpc.ChannelEventsRequest - 8, // 30: frdrpc.FaradayServer.OutlierRecommendations:output_type -> frdrpc.CloseRecommendationsResponse - 8, // 31: frdrpc.FaradayServer.ThresholdRecommendations:output_type -> frdrpc.CloseRecommendationsResponse - 11, // 32: frdrpc.FaradayServer.RevenueReport:output_type -> frdrpc.RevenueReportResponse - 15, // 33: frdrpc.FaradayServer.ChannelInsights:output_type -> frdrpc.ChannelInsightsResponse - 18, // 34: frdrpc.FaradayServer.ExchangeRate:output_type -> frdrpc.ExchangeRateResponse - 24, // 35: frdrpc.FaradayServer.NodeAudit:output_type -> frdrpc.NodeAuditResponse - 26, // 36: frdrpc.FaradayServer.CloseReport:output_type -> frdrpc.CloseReportResponse - 28, // 37: frdrpc.FaradayServer.GetChannelEvents:output_type -> frdrpc.ChannelEventsResponse - 30, // [30:38] is the sub-list for method output_type - 22, // [22:30] is the sub-list for method input_type - 22, // [22:22] is the sub-list for extension type_name - 22, // [22:22] is the sub-list for extension extendee - 0, // [0:22] is the sub-list for field type_name + 32, // 21: frdrpc.ForwardingAbilityResponse.entries:type_name -> frdrpc.ForwardingAbilityEntry + 13, // 22: frdrpc.RevenueReport.PairReportsEntry.value:type_name -> frdrpc.PairReport + 6, // 23: frdrpc.FaradayServer.OutlierRecommendations:input_type -> frdrpc.OutlierRecommendationsRequest + 7, // 24: frdrpc.FaradayServer.ThresholdRecommendations:input_type -> frdrpc.ThresholdRecommendationsRequest + 10, // 25: frdrpc.FaradayServer.RevenueReport:input_type -> frdrpc.RevenueReportRequest + 14, // 26: frdrpc.FaradayServer.ChannelInsights:input_type -> frdrpc.ChannelInsightsRequest + 17, // 27: frdrpc.FaradayServer.ExchangeRate:input_type -> frdrpc.ExchangeRateRequest + 21, // 28: frdrpc.FaradayServer.NodeAudit:input_type -> frdrpc.NodeAuditRequest + 25, // 29: frdrpc.FaradayServer.CloseReport:input_type -> frdrpc.CloseReportRequest + 27, // 30: frdrpc.FaradayServer.GetChannelEvents:input_type -> frdrpc.ChannelEventsRequest + 30, // 31: frdrpc.FaradayServer.ForwardingAbility:input_type -> frdrpc.ForwardingAbilityRequest + 8, // 32: frdrpc.FaradayServer.OutlierRecommendations:output_type -> frdrpc.CloseRecommendationsResponse + 8, // 33: frdrpc.FaradayServer.ThresholdRecommendations:output_type -> frdrpc.CloseRecommendationsResponse + 11, // 34: frdrpc.FaradayServer.RevenueReport:output_type -> frdrpc.RevenueReportResponse + 15, // 35: frdrpc.FaradayServer.ChannelInsights:output_type -> frdrpc.ChannelInsightsResponse + 18, // 36: frdrpc.FaradayServer.ExchangeRate:output_type -> frdrpc.ExchangeRateResponse + 24, // 37: frdrpc.FaradayServer.NodeAudit:output_type -> frdrpc.NodeAuditResponse + 26, // 38: frdrpc.FaradayServer.CloseReport:output_type -> frdrpc.CloseReportResponse + 28, // 39: frdrpc.FaradayServer.GetChannelEvents:output_type -> frdrpc.ChannelEventsResponse + 31, // 40: frdrpc.FaradayServer.ForwardingAbility:output_type -> frdrpc.ForwardingAbilityResponse + 32, // [32:41] is the sub-list for method output_type + 23, // [23:32] is the sub-list for method input_type + 23, // [23:23] is the sub-list for extension type_name + 23, // [23:23] is the sub-list for extension extendee + 0, // [0:23] is the sub-list for field type_name } func init() { file_faraday_proto_init() } @@ -2962,6 +3267,42 @@ func file_faraday_proto_init() { return nil } } + file_faraday_proto_msgTypes[25].Exporter = func(v any, i int) any { + switch v := v.(*ForwardingAbilityRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_faraday_proto_msgTypes[26].Exporter = func(v any, i int) any { + switch v := v.(*ForwardingAbilityResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_faraday_proto_msgTypes[27].Exporter = func(v any, i int) any { + switch v := v.(*ForwardingAbilityEntry); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } } type x struct{} out := protoimpl.TypeBuilder{ @@ -2969,7 +3310,7 @@ func file_faraday_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_faraday_proto_rawDesc, NumEnums: 5, - NumMessages: 26, + NumMessages: 29, NumExtensions: 0, NumServices: 1, }, diff --git a/frdrpc/faraday.proto b/frdrpc/faraday.proto index 453805d..cd551d0 100644 --- a/frdrpc/faraday.proto +++ b/frdrpc/faraday.proto @@ -70,6 +70,12 @@ service FaradayServer { Get a list of channel events that occurred for a given channel. */ rpc GetChannelEvents (ChannelEventsRequest) returns (ChannelEventsResponse); + + /** + Get forwarding ability analysis of peer pairs. + */ + rpc ForwardingAbility (ForwardingAbilityRequest) + returns (ForwardingAbilityResponse); } message CloseRecommendationRequest { @@ -675,3 +681,71 @@ message ChannelEvent { // request's last_id when paginating. int64 id = 5; } + +message ForwardingAbilityRequest { + // The start time of the query range as unix seconds. A value of 0 means + // the earliest available data. + uint64 start_time = 1; + + // The end time of the query range as unix seconds. A value of 0 means the + // server's current time. + uint64 end_time = 2; + + // The minimum directional liquidity in satoshis for a peer pair to count as + // economically forwardable. A value of 0 selects the server default. Hold + // this constant across calls for comparable series. + uint64 liquidity_floor_sat = 3; + + // The uptime fraction in [0, 1] at or above which a peer pair that did not + // forward is reported compactly as a single bit in up_but_idle_bitmask + // rather than as a full entry. A value of 0 selects the server default. + // Pairs below this threshold that also did not forward are omitted + // entirely. If no pair meets the threshold the server returns a + // FailedPrecondition error, since that indicates the node itself was down + // for the window and the data carries no signal. + double uptime_threshold = 4; +} + +message ForwardingAbilityResponse { + // Sorted list of unique compressed 33-byte public keys of the peers. + repeated bytes peers = 1; + + // Sparse list of forwarding ability entries. An entry is present only for a + // pair that forwarded volume, carrying its exact effective_uptime_s and + // forwarded_sat. Pairs that did not forward are never listed here; they are + // either flagged in quiet_uptime_bitmask or absent. Entries take precedence + // over the bitmask for the same pair. + repeated ForwardingAbilityEntry entries = 2; + + // The start of the window the metrics cover, as unix seconds. + int64 start_time = 3; + + // The end of the window the metrics cover, as unix seconds. + int64 end_time = 4; + + // A packed bitmask over the n*n ordered peer pairs, where n is the length + // of peers. The bit at index in*n+out is set when that pair held at least + // the uptime_threshold fraction of effective uptime over the window but did + // not forward: the dense "up but idle" population. A pair with a forwarded + // entry is never flagged here. A pair that is neither listed in entries nor + // flagged here had sub-threshold uptime and no forwards: treat it as zero. + bytes up_but_idle_bitmask = 5; + + // The uptime fraction in [0, 1] used to populate up_but_idle_bitmask, + // echoed back so consumers know the threshold the server applied. + double uptime_threshold = 6; +} + +message ForwardingAbilityEntry { + // The indices of the incoming and outgoing peers packed into a single + // 32-bit integer: packed_idx = (in << 16) | out. This caps the peer set at + // 65535 peers per direction. + uint32 packed_idx = 1; + + // Seconds the peer pair held at least the requested liquidity floor of + // directional forwardable liquidity over the window. + int64 effective_uptime_s = 2; + + // Total successfully forwarded amount over the window, in satoshis. + int64 forwarded_sat = 3; +} diff --git a/frdrpc/faraday.swagger.json b/frdrpc/faraday.swagger.json index feeb137..2bcc0db 100644 --- a/frdrpc/faraday.swagger.json +++ b/frdrpc/faraday.swagger.json @@ -1025,6 +1025,67 @@ "default": "UNKNOWN_FIATBACKEND", "description": "FiatBackend is the API endpoint to be used for any fiat related queries.\n\n - COINCAP: Use the CoinCap API for fiat price information.\nThis API is reached through the following URL:\nhttps://api.coincap.io/v2/assets/bitcoin/history\n - COINDESK: Use the CoinDesk API for fiat price information.\nThis API is reached through the following URL:\nhttps://api.coindesk.com/v1/bpi/historical/close.json\n - CUSTOM: Use custom price data provided in a CSV file for fiat price information.\n - COINGECKO: Use the CoinGecko API for fiat price information.\nThis API is reached through the following URL:\nhttps://api.coingecko.com/api/v3/coins/bitcoin/market_chart\n - BITFINEX: Use the Bitfinex API for fiat price information.\nThis API is reached through the following URL:\nhttps://api-pub.bitfinex.com/v2/candles/trade:1h:tBTCUSD/hist" }, + "frdrpcForwardingAbilityEntry": { + "type": "object", + "properties": { + "packed_idx": { + "type": "integer", + "format": "int64", + "description": "The indices of the incoming and outgoing peers packed into a single\n32-bit integer: packed_idx = (in \u003c\u003c 16) | out. This caps the peer set at\n65535 peers per direction." + }, + "effective_uptime_s": { + "type": "string", + "format": "int64", + "description": "Seconds the peer pair held at least the requested liquidity floor of\ndirectional forwardable liquidity over the window." + }, + "forwarded_sat": { + "type": "string", + "format": "int64", + "description": "Total successfully forwarded amount over the window, in satoshis." + } + } + }, + "frdrpcForwardingAbilityResponse": { + "type": "object", + "properties": { + "peers": { + "type": "array", + "items": { + "type": "string", + "format": "byte" + }, + "description": "Sorted list of unique compressed 33-byte public keys of the peers." + }, + "entries": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/frdrpcForwardingAbilityEntry" + }, + "description": "Sparse list of forwarding ability entries. An entry is present only for a\npair that forwarded volume, carrying its exact effective_uptime_s and\nforwarded_sat. Pairs that did not forward are never listed here; they are\neither flagged in quiet_uptime_bitmask or absent. Entries take precedence\nover the bitmask for the same pair." + }, + "start_time": { + "type": "string", + "format": "int64", + "description": "The start of the window the metrics cover, as unix seconds." + }, + "end_time": { + "type": "string", + "format": "int64", + "description": "The end of the window the metrics cover, as unix seconds." + }, + "up_but_idle_bitmask": { + "type": "string", + "format": "byte", + "description": "A packed bitmask over the n*n ordered peer pairs, where n is the length\nof peers. The bit at index in*n+out is set when that pair held at least\nthe uptime_threshold fraction of effective uptime over the window but did\nnot forward: the dense \"up but idle\" population. A pair with a forwarded\nentry is never flagged here. A pair that is neither listed in entries nor\nflagged here had sub-threshold uptime and no forwards: treat it as zero." + }, + "uptime_threshold": { + "type": "number", + "format": "double", + "description": "The uptime fraction in [0, 1] used to populate up_but_idle_bitmask,\nechoed back so consumers know the threshold the server applied." + } + } + }, "frdrpcGranularity": { "type": "string", "enum": [ diff --git a/frdrpc/faraday_grpc.pb.go b/frdrpc/faraday_grpc.pb.go index 0fc3aca..120ec5a 100644 --- a/frdrpc/faraday_grpc.pb.go +++ b/frdrpc/faraday_grpc.pb.go @@ -65,6 +65,9 @@ type FaradayServerClient interface { // * // Get a list of channel events that occurred for a given channel. GetChannelEvents(ctx context.Context, in *ChannelEventsRequest, opts ...grpc.CallOption) (*ChannelEventsResponse, error) + // * + // Get forwarding ability analysis of peer pairs. + ForwardingAbility(ctx context.Context, in *ForwardingAbilityRequest, opts ...grpc.CallOption) (*ForwardingAbilityResponse, error) } type faradayServerClient struct { @@ -147,6 +150,15 @@ func (c *faradayServerClient) GetChannelEvents(ctx context.Context, in *ChannelE return out, nil } +func (c *faradayServerClient) ForwardingAbility(ctx context.Context, in *ForwardingAbilityRequest, opts ...grpc.CallOption) (*ForwardingAbilityResponse, error) { + out := new(ForwardingAbilityResponse) + err := c.cc.Invoke(ctx, "/frdrpc.FaradayServer/ForwardingAbility", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + // FaradayServerServer is the server API for FaradayServer service. // All implementations must embed UnimplementedFaradayServerServer // for forward compatibility @@ -198,6 +210,9 @@ type FaradayServerServer interface { // * // Get a list of channel events that occurred for a given channel. GetChannelEvents(context.Context, *ChannelEventsRequest) (*ChannelEventsResponse, error) + // * + // Get forwarding ability analysis of peer pairs. + ForwardingAbility(context.Context, *ForwardingAbilityRequest) (*ForwardingAbilityResponse, error) mustEmbedUnimplementedFaradayServerServer() } @@ -229,6 +244,9 @@ func (UnimplementedFaradayServerServer) CloseReport(context.Context, *CloseRepor func (UnimplementedFaradayServerServer) GetChannelEvents(context.Context, *ChannelEventsRequest) (*ChannelEventsResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method GetChannelEvents not implemented") } +func (UnimplementedFaradayServerServer) ForwardingAbility(context.Context, *ForwardingAbilityRequest) (*ForwardingAbilityResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ForwardingAbility not implemented") +} func (UnimplementedFaradayServerServer) mustEmbedUnimplementedFaradayServerServer() {} // UnsafeFaradayServerServer may be embedded to opt out of forward compatibility for this service. @@ -386,6 +404,24 @@ func _FaradayServer_GetChannelEvents_Handler(srv interface{}, ctx context.Contex return interceptor(ctx, in, info, handler) } +func _FaradayServer_ForwardingAbility_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ForwardingAbilityRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(FaradayServerServer).ForwardingAbility(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/frdrpc.FaradayServer/ForwardingAbility", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(FaradayServerServer).ForwardingAbility(ctx, req.(*ForwardingAbilityRequest)) + } + return interceptor(ctx, in, info, handler) +} + // FaradayServer_ServiceDesc is the grpc.ServiceDesc for FaradayServer service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -425,6 +461,10 @@ var FaradayServer_ServiceDesc = grpc.ServiceDesc{ MethodName: "GetChannelEvents", Handler: _FaradayServer_GetChannelEvents_Handler, }, + { + MethodName: "ForwardingAbility", + Handler: _FaradayServer_ForwardingAbility_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "faraday.proto", diff --git a/frdrpc/faradayserver.pb.json.go b/frdrpc/faradayserver.pb.json.go index 4ee11e0..538108d 100644 --- a/frdrpc/faradayserver.pb.json.go +++ b/frdrpc/faradayserver.pb.json.go @@ -220,4 +220,29 @@ func RegisterFaradayServerJSONCallbacks(registry map[string]func(ctx context.Con } callback(string(respBytes), nil) } + + registry["frdrpc.FaradayServer.ForwardingAbility"] = func(ctx context.Context, + conn *grpc.ClientConn, reqJSON string, callback func(string, error)) { + + req := &ForwardingAbilityRequest{} + err := marshaler.Unmarshal([]byte(reqJSON), req) + if err != nil { + callback("", err) + return + } + + client := NewFaradayServerClient(conn) + resp, err := client.ForwardingAbility(ctx, req) + if err != nil { + callback("", err) + return + } + + respBytes, err := marshaler.Marshal(resp) + if err != nil { + callback("", err) + return + } + callback(string(respBytes), nil) + } } diff --git a/frdrpc/forwarding_ability_codec.go b/frdrpc/forwarding_ability_codec.go new file mode 100644 index 0000000..e105ac6 --- /dev/null +++ b/frdrpc/forwarding_ability_codec.go @@ -0,0 +1,374 @@ +package frdrpc + +import ( + "bytes" + "encoding/hex" + "errors" + "fmt" + "math" + "sort" + "strings" +) + +// maxPackedPeers is the largest peer set a response can address. packed_idx +// splits a uint32 into two 16-bit indices (in << 16 | out), so each direction +// can reference at most 65535 distinct peers. +const maxPackedPeers = 1<<16 - 1 + +// ForwardingAbility is a client-facing mirror of the raw forwarding facts for +// one direction of a peer pair over the analysis window. Derived rates and +// categories are left to the consumer. +type ForwardingAbility struct { + // EffectiveUptimeS is the seconds the pair held at least the requested + // liquidity floor of directional forwardable liquidity over the window. + // The value is whole seconds: sub-second uptime floors to zero, so a + // pair that forwarded volume over a fleeting qualifying window can + // report a zero uptime alongside a non-zero ForwardedSat. + EffectiveUptimeS int64 + + // ForwardedSat is the total successfully forwarded amount over the + // window, in satoshis. + ForwardedSat int64 +} + +// abilityTier classifies how a pair is encoded: a full entry, a single "up but +// idle" bit, or omitted entirely. +type abilityTier int + +const ( + // tierAbsent omits the pair: it neither forwarded nor held enough + // uptime to clear the threshold. Consumers treat absence as zero. + tierAbsent abilityTier = iota + + // tierBit flags the pair in the up-but-idle bitmask: it held at least + // the uptime threshold but did not forward. + tierBit + + // tierEntry emits a full entry carrying the pair's exact uptime and + // forwarded volume. Reserved for pairs that actually forwarded. + tierEntry +) + +// tier decides how a pair is encoded given the minimum qualifying uptime in +// seconds. Forwarding always wins, so a pair that forwarded keeps its exact +// facts even if its uptime is below the threshold; otherwise the pair is +// compacted to a bit when it was up enough, and dropped when it was not. +func (a ForwardingAbility) tier(minUptimeS int64) abilityTier { + switch { + case a.ForwardedSat > 0: + return tierEntry + + case a.EffectiveUptimeS >= minUptimeS: + return tierBit + + default: + return tierAbsent + } +} + +// MinQualifyingUptime converts an uptime fraction threshold into the smallest +// whole-second uptime that clears it over the given window. It is the single +// source of truth shared by the encoder (to bucket pairs) and the server (to +// apply the node-down guard), so the two cannot drift. A pair clears the +// threshold when EffectiveUptimeS >= the returned value, matching the "at least +// the threshold fraction" contract. The result is floored at one second so a +// pair with zero uptime is never treated as up. A non-positive window admits +// nothing. +func MinQualifyingUptime(threshold float64, windowSeconds int64) int64 { + if windowSeconds <= 0 { + return math.MaxInt64 + } + + v := int64(math.Ceil(threshold * float64(windowSeconds))) + if v < 1 { + v = 1 + } + + return v +} + +// setBit sets the bit at the given index in a packed bitmask. The index is an +// int64 because an n*n bitmask over the full peer set overflows a 32-bit int. +func setBit(mask []byte, index int64) { + mask[index/8] |= 1 << (index % 8) +} + +// getBit reports whether the bit at the given index in a packed bitmask is set. +// The index is an int64 because an n*n bitmask over the full peer set overflows +// a 32-bit int. +func getBit(mask []byte, index int64) bool { + return mask[index/8]&(1<<(index%8)) != 0 +} + +// EncodeForwardingAbility serializes a nested map of peer forwarding abilities +// into a memory-efficient sparse gRPC response over [startTime, endTime]. To +// optimize payload size it tiers each pair: pairs that forwarded keep a full +// entry, pairs that were up at least uptimeThreshold of the window but did not +// forward collapse to a single bit in the up-but-idle bitmask, and pairs below +// the threshold that did not forward are omitted entirely. Public keys are +// deduplicated and peer pairs packed into 32-bit indices. +func EncodeForwardingAbility(abilities map[string]map[string]ForwardingAbility, + startTime, endTime int64, + uptimeThreshold float64) (*ForwardingAbilityResponse, error) { + + minUptimeS := MinQualifyingUptime(uptimeThreshold, endTime-startTime) + + // First, find all unique peers involved in pairs that warrant either an + // entry or a bit. Keys are normalized to lower-case hex so a peer that + // appears in mixed case across entries collapses to a single index + // rather than being silently dropped at lookup time. + peerSet := make(map[string]struct{}) + for inPeer, outMap := range abilities { + for outPeer, ability := range outMap { + if ability.tier(minUptimeS) == tierAbsent { + continue + } + + peerSet[strings.ToLower(inPeer)] = struct{}{} + peerSet[strings.ToLower(outPeer)] = struct{}{} + } + } + + // Decode to raw bytes and sort. + var rawPeers [][]byte + for peerHex := range peerSet { + b, err := hex.DecodeString(peerHex) + if err != nil { + return nil, err + } + rawPeers = append(rawPeers, b) + } + + sort.Slice( + rawPeers, + func(i, j int) bool { + return bytes.Compare(rawPeers[i], rawPeers[j]) < 0 + }, + ) + + // Peer indices occupy 16 bits each in packed_idx, so the set must stay + // within maxPackedPeers. Beyond it, an index would overflow its field + // and silently decode to the wrong peer pair, so fail loudly instead. + if len(rawPeers) > maxPackedPeers { + return nil, fmt.Errorf("peer set of %d exceeds the %d "+ + "addressable by packed_idx", len(rawPeers), + maxPackedPeers) + } + + // Create map for index lookup using normalized lowercase hex strings. + peerIndex := make(map[string]uint32) + for idx, b := range rawPeers { + peerIndex[hex.EncodeToString(b)] = uint32(idx) + } + + // The bitmask addresses every ordered pair over the peer set, so it + // needs n*n bits. Allocation is deferred until a bit is actually set so + // a response with no up-but-idle pairs carries no bitmask at all. + n := int64(len(rawPeers)) + var bitmask []byte + + // Build the entries and bitmask. seen guards against two input keys + // that differ only by hex case collapsing onto the same packed pair. + var entries []*ForwardingAbilityEntry + seen := make(map[uint32]struct{}) + + // addEntry appends a full entry for a forwarded pair. + addEntry := func(packed uint32, a ForwardingAbility) { + entries = append(entries, &ForwardingAbilityEntry{ + PackedIdx: packed, + EffectiveUptimeS: a.EffectiveUptimeS, + ForwardedSat: a.ForwardedSat, + }) + } + + for inPeer, outMap := range abilities { + inIdx, okIn := peerIndex[strings.ToLower(inPeer)] + if !okIn { + continue + } + + for outPeer, ability := range outMap { + tier := ability.tier(minUptimeS) + if tier == tierAbsent { + continue + } + + outIdx, okOut := peerIndex[strings.ToLower(outPeer)] + if !okOut { + continue + } + + // Pack the in-peer index into the high 16 bits and the + // out-peer index into the low 16. + packed := (inIdx << 16) | outIdx + + // Reject a case-folded collision rather than silently + // dropping one of the two entries' facts. + if _, dup := seen[packed]; dup { + return nil, fmt.Errorf("duplicate peer pair "+ + "after case normalization: "+ + "in=%s out=%s", inPeer, outPeer) + } + seen[packed] = struct{}{} + + switch tier { + case tierEntry: + addEntry(packed, ability) + + case tierBit: + // The bitmask addresses n*n ordered pairs, one + // bit each. + if bitmask == nil { + bitmask = make( + []byte, (n*n+7)/8, + ) + } + setBit( + bitmask, + int64(inIdx)*n+int64(outIdx), + ) + } + } + } + + // Sort entries by packed_idx for deterministic output and testability. + sort.Slice( + entries, + func(i, j int) bool { + return entries[i].PackedIdx < entries[j].PackedIdx + }, + ) + + return &ForwardingAbilityResponse{ + Peers: rawPeers, + Entries: entries, + StartTime: startTime, + EndTime: endTime, + UpButIdleBitmask: bitmask, + UptimeThreshold: uptimeThreshold, + }, nil +} + +// DecodeForwardingAbility reconstructs the nested map of peer forwarding +// abilities from a sparse packed gRPC response. Forwarded pairs come back with +// their exact facts; up-but-idle pairs flagged in the bitmask come back at full +// window uptime with zero forwarded volume. It validates packed indices and the +// bitmask length against the decoded peer list to prevent out-of-bounds errors. +func DecodeForwardingAbility(resp *ForwardingAbilityResponse) ( + map[string]map[string]ForwardingAbility, error) { + + result := make(map[string]map[string]ForwardingAbility) + if resp == nil { + return result, nil + } + + numPeers := len(resp.Peers) + + // packed_idx addresses peers with 16-bit indices, so a response with + // more than maxPackedPeers peers is malformed. Rejecting it here also + // keeps the n*n bitmask-length computation below from overflowing a + // 32-bit int. + if numPeers > maxPackedPeers { + return nil, fmt.Errorf("peer set of %d exceeds the %d "+ + "addressable by packed_idx", numPeers, maxPackedPeers) + } + + record := func(inIdx, outIdx int, ability ForwardingAbility) { + inPeer := hex.EncodeToString(resp.Peers[inIdx]) + outPeer := hex.EncodeToString(resp.Peers[outIdx]) + + if _, ok := result[inPeer]; !ok { + result[inPeer] = make(map[string]ForwardingAbility) + } + result[inPeer][outPeer] = ability + } + + // Decode the forwarded entries first so they take precedence over any + // bit set for the same pair. + for _, entry := range resp.Entries { + // Unpack the pair: the in-peer index is the high 16 bits, the + // out-peer index the low 16. + inIdx := int(entry.PackedIdx >> 16) + outIdx := int(entry.PackedIdx & 0xffff) + + if inIdx >= numPeers || outIdx >= numPeers { + return nil, errors.New("decoded peer index out of " + + "bounds") + } + + record( + inIdx, outIdx, ForwardingAbility{ + EffectiveUptimeS: entry.EffectiveUptimeS, + ForwardedSat: entry.ForwardedSat, + }, + ) + } + + // Expand the up-but-idle bitmask. An absent bitmask simply means no + // pair was flagged; a present one must address exactly the n*n pairs. + bitmask := resp.UpButIdleBitmask + if len(bitmask) == 0 { + return result, nil + } + + // Compute the expected length in int64 so the n*n multiplication does + // not overflow a 32-bit int for a large peer set. + totalPairs := int64(numPeers) * int64(numPeers) + if want := int((totalPairs + 7) / 8); len(bitmask) != want { + return nil, fmt.Errorf("bitmask length %d does not match the "+ + "%d expected for %d peers", len(bitmask), want, + numPeers) + } + + // Up-but-idle pairs were up the whole window by definition of the + // threshold bucket, so reconstruct them at full window uptime with zero + // forwarded volume. Iterate over the bitmask bytes directly, skipping + // zero bytes, so cost scales with the number of set bits rather than + // the O(n*n) pair space; padding bits beyond n*n are ignored. + windowSeconds := resp.EndTime - resp.StartTime + for i, b := range bitmask { + if b == 0 { + continue + } + + for bit := range 8 { + // If the bit is not set, skip the pair. This also + // implicitly ignores any padding bits in the last byte + // beyond the n*n pairs. + if b&(1<= totalPairs { + break + } + + // Unpack the pair: the in-peer index is the high 16 + // bits, the out-peer index the low 16. The bounds were + // already checked against the bitmask length, so this + // cannot overflow. + inIdx := int(k / int64(numPeers)) + outIdx := int(k % int64(numPeers)) + + // An entry for this pair takes precedence; never + // overwrite it. + inPeer := hex.EncodeToString(resp.Peers[inIdx]) + outPeer := hex.EncodeToString(resp.Peers[outIdx]) + if _, ok := result[inPeer][outPeer]; ok { + continue + } + + record( + inIdx, outIdx, ForwardingAbility{ + EffectiveUptimeS: windowSeconds, + ForwardedSat: 0, + }, + ) + } + } + + return result, nil +} diff --git a/frdrpc/forwarding_ability_codec_test.go b/frdrpc/forwarding_ability_codec_test.go new file mode 100644 index 0000000..7029138 --- /dev/null +++ b/frdrpc/forwarding_ability_codec_test.go @@ -0,0 +1,587 @@ +package frdrpc + +import ( + "encoding/hex" + "fmt" + "math" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// fwdKey returns a distinct 33-byte compressed-pubkey hex string for n. Keys +// sort ascending in n, matching the byte ordering the encoder applies. +func fwdKey(n int) string { + return fmt.Sprintf("02%064x", n) +} + +// pair is one expected decoded entry, flattened from the nested result map for +// easy comparison. +type pair struct { + in string + out string + ability ForwardingAbility +} + +// TestForwardingAbilityCodecRoundTrip verifies the three-tier encoding: pairs +// that forwarded keep exact facts as entries, pairs up at least the threshold +// but idle collapse to a bitmask bit (decoded at full window uptime), and +// sub-threshold idle pairs are dropped. The window is [0, 100) and the +// threshold 0.5, so the minimum qualifying uptime is 50 seconds. +func TestForwardingAbilityCodecRoundTrip(t *testing.T) { + const ( + startTime, endTime int64 = 0, 100 + threshold float64 = 0.5 + ) + + tests := []struct { + name string + abilities map[string]map[string]ForwardingAbility + wantPeers []string + wantPairs []pair + wantBitmask bool + }{ + { + // Forwarding wins regardless of uptime, so a + // zero-uptime pair that moved volume survives with its + // exact facts and never lands in the bitmask. + name: "forwarded pairs keep exact facts", + abilities: map[string]map[string]ForwardingAbility{ + fwdKey(1): { + fwdKey(2): { + EffectiveUptimeS: 80, + ForwardedSat: 1500, + }, + }, + fwdKey(2): { + fwdKey(1): { + EffectiveUptimeS: 0, + ForwardedSat: 2500, + }, + }, + }, + wantPeers: []string{ + fwdKey(1), + fwdKey(2), + }, + wantPairs: []pair{ + { + fwdKey(1), + fwdKey(2), + ForwardingAbility{ + 80, + 1500, + }, + }, + { + fwdKey(2), + fwdKey(1), + ForwardingAbility{ + 0, + 2500, + }, + }, + }, + wantBitmask: false, + }, + { + // Up at or above the threshold but no forwards: a bit, + // decoded back at the full window's uptime. + name: "up but idle becomes a bit", + abilities: map[string]map[string]ForwardingAbility{ + fwdKey(1): { + fwdKey(2): { + EffectiveUptimeS: 80, + }, + }, + fwdKey(3): { + fwdKey(1): { + EffectiveUptimeS: 50, + }, + }, + }, + wantPeers: []string{ + fwdKey(1), + fwdKey(2), + fwdKey(3), + }, + wantPairs: []pair{ + { + fwdKey(1), + fwdKey(2), + ForwardingAbility{ + 100, + 0, + }, + }, + { + fwdKey(3), + fwdKey(1), + ForwardingAbility{ + 100, + 0, + }, + }, + }, + wantBitmask: true, + }, + { + // Below the threshold with no forwards: dropped. + name: "sub-threshold idle pairs dropped", + abilities: map[string]map[string]ForwardingAbility{ + fwdKey(1): { + fwdKey(2): { + EffectiveUptimeS: 49, + }, + }, + }, + wantPeers: []string{}, + wantPairs: []pair{}, + wantBitmask: false, + }, + { + // All three tiers at once, including a peer that only + // appears via the bitmask. + name: "mixed tiers", + abilities: map[string]map[string]ForwardingAbility{ + fwdKey(1): { + fwdKey(2): { + EffectiveUptimeS: 80, + ForwardedSat: 1500, + }, + fwdKey(3): { + EffectiveUptimeS: 60, + }, + }, + fwdKey(2): { + fwdKey(3): { + EffectiveUptimeS: 10, + }, + }, + fwdKey(3): { + fwdKey(1): { + ForwardedSat: 500, + }, + }, + }, + wantPeers: []string{ + fwdKey(1), + fwdKey(2), + fwdKey(3), + }, + wantPairs: []pair{ + { + fwdKey(1), + fwdKey(2), + ForwardingAbility{ + 80, + 1500, + }, + }, + { + fwdKey(1), + fwdKey(3), + ForwardingAbility{ + 100, + 0, + }, + }, + { + fwdKey(3), + fwdKey(1), + ForwardingAbility{ + 0, + 500, + }, + }, + }, + wantBitmask: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + resp, err := EncodeForwardingAbility( + tc.abilities, startTime, endTime, threshold, + ) + require.NoError(t, err) + require.Equal(t, startTime, resp.StartTime) + require.Equal(t, endTime, resp.EndTime) + require.Equal(t, threshold, resp.UptimeThreshold) + + require.Equal( + t, tc.wantBitmask, + len(resp.UpButIdleBitmask) > 0, + ) + + // A present bitmask must address exactly n*n bits. + if tc.wantBitmask { + n := len(resp.Peers) + require.Len(t, resp.UpButIdleBitmask, (n*n+7)/8) + } + + gotPeers := make([]string, len(resp.Peers)) + for i, p := range resp.Peers { + gotPeers[i] = hex.EncodeToString(p) + } + require.Equal(t, tc.wantPeers, gotPeers) + + decoded, err := DecodeForwardingAbility(resp) + require.NoError(t, err) + + got := make(map[string]ForwardingAbility) + for in, outMap := range decoded { + for out, ability := range outMap { + got[in+"->"+out] = ability + } + } + require.Len(t, got, len(tc.wantPairs)) + for _, wp := range tc.wantPairs { + require.Equal( + t, wp.ability, got[wp.in+"->"+wp.out], + ) + } + }) + } +} + +// TestMinQualifyingUptime verifies the threshold-to-seconds conversion shared +// by the encoder and the server guard, including its boundary behavior. +func TestMinQualifyingUptime(t *testing.T) { + tests := []struct { + name string + threshold float64 + window int64 + want int64 + }{ + { + "half of clean window", + 0.5, + 100, + 50, + }, + { + "rounds up a fraction", + 0.333, + 100, + 34, + }, + { + "integer boundary", + 0.9, + 2_592_000, + 2_332_800, + }, + { + "floored at one second", + 0.0, + 100, + 1, + }, + { + "non-positive window admits nothing", + 0.5, + 0, + math.MaxInt64, + }, + } + + for _, tc := range tests { + t.Run( + tc.name, + func(t *testing.T) { + require.Equal( + t, tc.want, MinQualifyingUptime( + tc.threshold, tc.window, + ), + ) + }, + ) + } +} + +// TestBitmaskHelpers verifies that setBit and getBit address the same bit. +func TestBitmaskHelpers(t *testing.T) { + mask := make([]byte, 2) + require.False(t, getBit(mask, 9)) + + setBit(mask, 9) + require.True(t, getBit(mask, 9)) + require.False(t, getBit(mask, 8)) + require.False(t, getBit(mask, 10)) +} + +// TestForwardingAbilityDecodeEntryPrecedence verifies that when a pair is both +// listed as an entry and flagged in the bitmask, the entry's exact facts win. +func TestForwardingAbilityDecodeEntryPrecedence(t *testing.T) { + // Two peers => a 2*2 bitmask needs (4+7)/8 = 1 byte. Set the bit for + // pair (0, 1) at index 0*2+1 = 1, and also list it as an entry. + mask := make([]byte, 1) + setBit(mask, 1) + + resp := &ForwardingAbilityResponse{ + Peers: [][]byte{ + { + 1, + }, + { + 2, + }, + }, + StartTime: 0, + EndTime: 100, + Entries: []*ForwardingAbilityEntry{ + { + PackedIdx: (0 << 16) | 1, + EffectiveUptimeS: 42, + ForwardedSat: 7, + }, + }, + UpButIdleBitmask: mask, + } + + decoded, err := DecodeForwardingAbility(resp) + require.NoError(t, err) + require.Equal( + t, ForwardingAbility{42, 7}, + decoded[hex.EncodeToString([]byte{1})][hex.EncodeToString( + []byte{2}, + )], + ) +} + +// TestForwardingAbilityDecodeBadIndex verifies that a packed index referencing +// a peer beyond the decoded peer list is rejected rather than silently mapped. +func TestForwardingAbilityDecodeBadIndex(t *testing.T) { + resp := &ForwardingAbilityResponse{ + Peers: [][]byte{ + { + 1, + 2, + 3, + }, + }, + Entries: []*ForwardingAbilityEntry{ + { + // Out index 1 is out of bounds for a single + // peer. + PackedIdx: (0 << 16) | 1, + EffectiveUptimeS: 3600, + ForwardedSat: 1000, + }, + }, + } + + _, err := DecodeForwardingAbility(resp) + require.ErrorContains(t, err, "peer index out of bounds") +} + +// TestForwardingAbilityDecodeBadBitmaskLen verifies that a bitmask whose length +// does not match the n*n pairs of the peer set is rejected. +func TestForwardingAbilityDecodeBadBitmaskLen(t *testing.T) { + resp := &ForwardingAbilityResponse{ + // Two peers expect a 1-byte bitmask; supply two bytes. + Peers: [][]byte{ + { + 1, + }, + { + 2, + }, + }, + UpButIdleBitmask: []byte{ + 0x00, + 0x00, + }, + } + + _, err := DecodeForwardingAbility(resp) + require.ErrorContains(t, err, "bitmask length") +} + +// TestForwardingAbilityEncodePeerCap verifies that a peer set too large to +// address with packed_idx is rejected loudly instead of overflowing an index +// into the wrong peer pair. +func TestForwardingAbilityEncodePeerCap(t *testing.T) { + outMap := make(map[string]ForwardingAbility) + for i := 1; i <= maxPackedPeers+1; i++ { + // Use forwarded volume so inclusion is threshold-independent. + outMap[fwdKey(i)] = ForwardingAbility{ForwardedSat: 1} + } + abilities := map[string]map[string]ForwardingAbility{ + fwdKey(0): outMap, + } + + _, err := EncodeForwardingAbility(abilities, 0, 1, 0.5) + require.ErrorContains(t, err, "exceeds") +} + +// TestForwardingAbilityEncodeNormalizesCase verifies that a peer appearing in +// mixed hex case collapses to a single index rather than producing a duplicate +// peer entry. +func TestForwardingAbilityEncodeNormalizesCase(t *testing.T) { + // Use a key with hex letters so its upper- and lower-case forms are + // genuinely distinct map keys. + peer := fwdKey(0xabcdef) + + abilities := map[string]map[string]ForwardingAbility{ + strings.ToUpper(peer): { + fwdKey(2): { + ForwardedSat: 20, + }, + }, + peer: { + fwdKey(3): { + ForwardedSat: 40, + }, + }, + } + + resp, err := EncodeForwardingAbility(abilities, 0, 1, 0.5) + require.NoError(t, err) + + // The upper- and lower-case forms of the shared peer must dedup to one + // index, leaving exactly three distinct peers. + require.Len(t, resp.Peers, 3) + + decoded, err := DecodeForwardingAbility(resp) + require.NoError(t, err) + require.Equal( + t, ForwardingAbility{ + ForwardedSat: 20, + }, + decoded[peer][fwdKey(2)], + ) + require.Equal( + t, ForwardingAbility{ + ForwardedSat: 40, + }, + decoded[peer][fwdKey(3)], + ) +} + +// TestForwardingAbilityEncodeRejectsCaseCollision verifies that two input keys +// that differ only by hex case but address the same peer pair are rejected +// rather than silently collapsing onto one packed index and dropping a fact. +func TestForwardingAbilityEncodeRejectsCaseCollision(t *testing.T) { + inPeer := fwdKey(0xabcdef) + outPeer := fwdKey(2) + + // Both in-peer spellings normalize to the same index and share the same + // out-peer, so they collide on packed_idx. + abilities := map[string]map[string]ForwardingAbility{ + strings.ToUpper(inPeer): { + outPeer: { + ForwardedSat: 10, + }, + }, + inPeer: { + outPeer: { + ForwardedSat: 20, + }, + }, + } + + _, err := EncodeForwardingAbility(abilities, 0, 1, 0.5) + require.Error(t, err) +} + +// TestForwardingAbilityCodecRoundTripHighIndices round-trips a large peer set +// so that packed indices exceed a single byte and exercise the high bits of +// each 16-bit direction field, and the up-but-idle bitmask spans many bytes. It +// guards index packing and bitmask addressing against regressions that only +// surface beyond the small indices the other round-trip cases use. +func TestForwardingAbilityCodecRoundTripHighIndices(t *testing.T) { + const ( + numPeers = 300 + startTime, endTime = int64(0), int64(100) + threshold = 0.5 + ) + + // Build a cycle so every peer appears and takes a stable index equal to + // its fwdKey ordinal. Even edges forward (kept as exact entries); odd + // edges are up but idle at >= threshold (collapsed to a bitmask bit, + // decoded back at the full window uptime). + abilities := make(map[string]map[string]ForwardingAbility, numPeers) + want := make(map[string]ForwardingAbility, numPeers) + for i := range numPeers { + in, out := fwdKey(i), fwdKey((i+1)%numPeers) + + var enc, dec ForwardingAbility + if i%2 == 0 { + // Add pair that forwarded. + enc = ForwardingAbility{ + EffectiveUptimeS: 70, + ForwardedSat: int64(i + 1), + } + dec = enc + } else { + // Add up, but idle pair. + enc = ForwardingAbility{EffectiveUptimeS: 60} + dec = ForwardingAbility{ + EffectiveUptimeS: endTime - startTime, + } + } + + abilities[in] = map[string]ForwardingAbility{out: enc} + want[in+"->"+out] = dec + } + + resp, err := EncodeForwardingAbility( + abilities, startTime, endTime, threshold, + ) + require.NoError(t, err) + require.Len(t, resp.Peers, numPeers) + + // With 300 peers the indices exceed one byte, so at least one packed + // index must use the high bits of its 16-bit field. + var sawHighIdx bool + for _, e := range resp.Entries { + if e.PackedIdx>>16 > 0xff || e.PackedIdx&0xffff > 0xff { + sawHighIdx = true + break + } + } + require.True(t, sawHighIdx, "expected an index beyond one byte") + + decoded, err := DecodeForwardingAbility(resp) + require.NoError(t, err) + + got := make(map[string]ForwardingAbility) + for in, outMap := range decoded { + for out, ability := range outMap { + got[in+"->"+out] = ability + } + } + require.Equal(t, want, got) +} + +// TestForwardingAbilityDecodeNil verifies that decoding a nil response yields +// an empty map rather than panicking. +func TestForwardingAbilityDecodeNil(t *testing.T) { + decoded, err := DecodeForwardingAbility(nil) + require.NoError(t, err) + require.Empty(t, decoded) +} + +// TestForwardingAbilityDecodeIgnoresPaddingBit verifies that a bit set in the +// padding region beyond the n*n pairs of the final byte is ignored rather than +// decoded into a bogus pair. +func TestForwardingAbilityDecodeIgnoresPaddingBit(t *testing.T) { + // Two peers => 2*2 = 4 valid bits in a 1-byte mask; bits 4..7 are + // padding. Set padding bit 5 and assert nothing decodes from it. + mask := make([]byte, 1) + setBit(mask, 5) + + resp := &ForwardingAbilityResponse{ + Peers: [][]byte{{1}, {2}}, + StartTime: 0, + EndTime: 100, + UpButIdleBitmask: mask, + } + + decoded, err := DecodeForwardingAbility(resp) + require.NoError(t, err) + require.Empty(t, decoded) +} diff --git a/frdrpc/go.mod b/frdrpc/go.mod index 77c885a..d889566 100644 --- a/frdrpc/go.mod +++ b/frdrpc/go.mod @@ -2,16 +2,22 @@ module github.com/lightninglabs/faraday/frdrpc require ( github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 + github.com/stretchr/testify v1.10.0 google.golang.org/grpc v1.65.0 google.golang.org/protobuf v1.34.2 ) require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rogpeppe/go-internal v1.15.0 // indirect golang.org/x/net v0.38.0 // indirect golang.org/x/sys v0.31.0 // indirect golang.org/x/text v0.23.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) go 1.25.5 diff --git a/frdrpc/go.sum b/frdrpc/go.sum index 71cd5ab..c5f2aca 100644 --- a/frdrpc/go.sum +++ b/frdrpc/go.sum @@ -1,7 +1,20 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys= github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= +github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= @@ -16,3 +29,8 @@ google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc= google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ= google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/frdrpcserver/forwarding_ability.go b/frdrpcserver/forwarding_ability.go new file mode 100644 index 0000000..2d4088b --- /dev/null +++ b/frdrpcserver/forwarding_ability.go @@ -0,0 +1,169 @@ +package frdrpcserver + +import ( + "context" + "log/slog" + "math" + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/lightninglabs/faraday/frdrpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// defaultLiquidityFloorSat is the liquidity floor applied when the request +// leaves liquidity_floor_sat unset. It approximates the smallest amount a +// rebalancer would still move, below which a pair is not economically +// forwardable. +const defaultLiquidityFloorSat = 50_000 + +// defaultUptimeThreshold is the uptime fraction applied when the request leaves +// uptime_threshold unset. A pair that was up at least this fraction of the +// window but did not forward is reported as a single bit rather than a full +// entry. It is high so that only reliably available pairs are flagged, keeping +// the response sparse and the node-down guard meaningful. +const defaultUptimeThreshold = 0.9 + +// ForwardingAbility returns the raw effective-uptime and forwarded-volume facts +// for each peer pair over the requested window. An unset end_time defaults to +// the current time and an unset liquidity_floor_sat to +// defaultLiquidityFloorSat. +func (s *RPCServer) ForwardingAbility(ctx context.Context, + req *frdrpc.ForwardingAbilityRequest) ( + *frdrpc.ForwardingAbilityResponse, error) { + + log.DebugS( + ctx, "Handling ForwardingAbility request", + slog.Uint64("start_time", req.StartTime), + slog.Uint64("end_time", req.EndTime), + slog.Uint64("liquidity_floor_sat", req.LiquidityFloorSat), + ) + + // time.Unix takes an int64, so reject any request value that would + // overflow when its uint64 seconds are narrowed below. + if req.StartTime > math.MaxInt64 { + return nil, status.Error( + codes.InvalidArgument, + "start_time exceeds maximum allowed value", + ) + } + if req.EndTime > math.MaxInt64 { + return nil, status.Error( + codes.InvalidArgument, + "end_time exceeds maximum allowed value", + ) + } + + startTime := time.Unix(int64(req.StartTime), 0) + endTime := time.Now() + if req.EndTime != 0 { + endTime = time.Unix(int64(req.EndTime), 0) + } + + if startTime.After(endTime) { + return nil, status.Error( + codes.InvalidArgument, + "start_time must be less than or equal to end_time", + ) + } + + if s.cfg.ForwardingAnalyzer == nil { + return nil, status.Error( + codes.Unavailable, + "forwarding analyzer is not configured", + ) + } + + liquidityFloor := req.LiquidityFloorSat + if liquidityFloor == 0 { + liquidityFloor = defaultLiquidityFloorSat + } + + uptimeThreshold := req.UptimeThreshold + if uptimeThreshold == 0 { + uptimeThreshold = defaultUptimeThreshold + } + + // Reject NaN explicitly: NaN comparisons are always false, so a bare + // range check would let it slip through. + if math.IsNaN(uptimeThreshold) || uptimeThreshold < 0 || + uptimeThreshold > 1 { + + return nil, status.Error( + codes.InvalidArgument, + "uptime_threshold must be in [0, 1]", + ) + } + + abilities, err := s.cfg.ForwardingAnalyzer.EffectiveUptime( + ctx, startTime, endTime, btcutil.Amount(liquidityFloor), + ) + if err != nil { + log.ErrorS( + ctx, "EffectiveUptime failed", err, + slog.Time("start_time", startTime), + slog.Time("end_time", endTime), + slog.Uint64("liquidity_floor_sat", liquidityFloor), + ) + + return nil, status.Errorf(codes.Internal, "failed to "+ + "calculate effective uptime: %v", err) + } + + // Convert the flat map into the nested map the codec expects, carrying + // the raw facts through unchanged. EffectiveUptime is truncated to + // whole seconds to match the second-granularity wire field. A pair with + // only sub-second qualifying uptime therefore reports zero uptime while + // still carrying its forwarded volume. + nested := make(map[string]map[string]frdrpc.ForwardingAbility) + for pair, ability := range abilities { + if _, ok := nested[pair.PeerIn]; !ok { + nested[pair.PeerIn] = + make(map[string]frdrpc.ForwardingAbility) + } + + nested[pair.PeerIn][pair.PeerOut] = frdrpc.ForwardingAbility{ + EffectiveUptimeS: int64( + ability.EffectiveUptime.Seconds(), + ), + ForwardedSat: int64(ability.ForwardedAmount), + } + } + + // Guard against returning data when the node itself was down for the + // window. If no pair held at least the threshold fraction of uptime, + // the response carries no signal and lowering the threshold to surface + // something would only inflate it, so fail loudly instead. + minUptimeS := frdrpc.MinQualifyingUptime( + uptimeThreshold, endTime.Unix()-startTime.Unix(), + ) + var qualifying int + for _, outMap := range nested { + for _, ability := range outMap { + if ability.EffectiveUptimeS >= minUptimeS { + qualifying++ + } + } + } + if qualifying == 0 { + return nil, status.Error(codes.FailedPrecondition, "no peer "+ + "pair met the uptime threshold over the window; the "+ + "node may have been offline") + } + + resp, err := frdrpc.EncodeForwardingAbility( + nested, startTime.Unix(), endTime.Unix(), uptimeThreshold, + ) + if err != nil { + log.ErrorS( + ctx, "EncodeForwardingAbility failed", err, + slog.Int("pairs", len(abilities)), + ) + + return nil, status.Errorf(codes.Internal, "failed to encode "+ + "forwarding ability: %v", err) + } + + return resp, nil +} diff --git a/frdrpcserver/forwarding_ability_test.go b/frdrpcserver/forwarding_ability_test.go new file mode 100644 index 0000000..f842dc6 --- /dev/null +++ b/frdrpcserver/forwarding_ability_test.go @@ -0,0 +1,299 @@ +package frdrpcserver + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/lightninglabs/faraday/chanevents" + "github.com/lightninglabs/faraday/frdrpc" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type mockForwardingAnalyzer struct { + effectiveUptimeFunc func(ctx context.Context, startTime, endTime time.Time, + liquidityFloor btcutil.Amount) ( + map[chanevents.PeerPair]chanevents.ForwardingAbility, error) +} + +func (m *mockForwardingAnalyzer) EffectiveUptime(ctx context.Context, startTime, + endTime time.Time, liquidityFloor btcutil.Amount) ( + map[chanevents.PeerPair]chanevents.ForwardingAbility, error) { + + return m.effectiveUptimeFunc(ctx, startTime, endTime, liquidityFloor) +} + +// TestForwardingAbility tests the ForwardingAbility RPC method, covering both +// successful and error cases. +func TestForwardingAbility(t *testing.T) { + const ( + peerIn = "02aaaabbbbcccc0000000000000000000000000000000000000000000000000001" + peerOut = "02aaaabbbbcccc0000000000000000000000000000000000000000000000000002" + ) + + // analyzerResult is the canned analyzer return for a case. A nil + // analyzerResult means the case leaves ForwardingAnalyzer unconfigured. + type analyzerResult func() ( + map[chanevents.PeerPair]chanevents.ForwardingAbility, error) + + tests := []struct { + name string + analyzer analyzerResult + req *frdrpc.ForwardingAbilityRequest + + // wantCode is the expected gRPC status; codes.OK denotes + // success. + wantCode codes.Code + + // check runs on success with the response and the floor the + // handler resolved and passed to the analyzer. + check func(t *testing.T, resp *frdrpc.ForwardingAbilityResponse, + gotFloor btcutil.Amount) + }{ + { + name: "encodes analyzer facts", + analyzer: func() ( + map[chanevents.PeerPair]chanevents.ForwardingAbility, + error) { + + return map[chanevents.PeerPair]chanevents.ForwardingAbility{ + { + PeerIn: peerIn, + PeerOut: peerOut, + }: { + EffectiveUptime: 90 * time.Second, + ForwardedAmount: 550, + }, + }, nil + }, + req: &frdrpc.ForwardingAbilityRequest{ + StartTime: 100, + EndTime: 200, + LiquidityFloorSat: 1000, + }, + wantCode: codes.OK, + check: func(t *testing.T, + resp *frdrpc.ForwardingAbilityResponse, + gotFloor btcutil.Amount) { + + // The explicit floor passes straight through. + require.Equal(t, btcutil.Amount(1000), gotFloor) + require.Len(t, resp.Peers, 2) + require.Len(t, resp.Entries, 1) + require.EqualValues(t, 100, resp.StartTime) + require.EqualValues(t, 200, resp.EndTime) + require.EqualValues( + t, 90, resp.Entries[0].EffectiveUptimeS, + ) + require.EqualValues( + t, 550, resp.Entries[0].ForwardedSat, + ) + + // An unset threshold echoes the server default, + // and a forwarded pair leaves the bitmask empty. + require.Equal( + t, defaultUptimeThreshold, + resp.UptimeThreshold, + ) + require.Empty(t, resp.UpButIdleBitmask) + }, + }, + { + name: "unset floor uses server default", + analyzer: func() ( + map[chanevents.PeerPair]chanevents.ForwardingAbility, + error) { + + // Return a fully-up pair so the node-down guard + // passes and the default floor can be observed. + return map[chanevents.PeerPair]chanevents.ForwardingAbility{ + {PeerIn: peerIn, PeerOut: peerOut}: { + EffectiveUptime: 100 * time.Second, + }, + }, nil + }, + req: &frdrpc.ForwardingAbilityRequest{ + StartTime: 100, + EndTime: 200, + }, + wantCode: codes.OK, + check: func(t *testing.T, + _ *frdrpc.ForwardingAbilityResponse, + gotFloor btcutil.Amount) { + + require.Equal( + t, btcutil.Amount( + defaultLiquidityFloorSat, + ), gotFloor, + ) + }, + }, + { + name: "node down trips guard", + analyzer: func() ( + map[chanevents.PeerPair]chanevents.ForwardingAbility, + error) { + + // A single pair, up well below the default 0.9 + // threshold over the 100s window and with no + // forwards, leaves nothing that clears it. + return map[chanevents.PeerPair]chanevents.ForwardingAbility{ + {PeerIn: peerIn, PeerOut: peerOut}: { + EffectiveUptime: 10 * time.Second, + }, + }, nil + }, + req: &frdrpc.ForwardingAbilityRequest{ + StartTime: 100, + EndTime: 200, + }, + wantCode: codes.FailedPrecondition, + }, + { + name: "low-uptime forward does not rescue guard", + analyzer: func() ( + map[chanevents.PeerPair]chanevents.ForwardingAbility, + error) { + + // Forwarded volume at sub-threshold uptime must + // not satisfy the guard. + return map[chanevents.PeerPair]chanevents.ForwardingAbility{ + {PeerIn: peerIn, PeerOut: peerOut}: { + EffectiveUptime: 10 * time.Second, + ForwardedAmount: 999, + }, + }, nil + }, + req: &frdrpc.ForwardingAbilityRequest{ + StartTime: 100, + EndTime: 200, + }, + wantCode: codes.FailedPrecondition, + }, + { + name: "explicit threshold flags up-but-idle pair", + analyzer: func() ( + map[chanevents.PeerPair]chanevents.ForwardingAbility, + error) { + + return map[chanevents.PeerPair]chanevents.ForwardingAbility{ + {PeerIn: peerIn, PeerOut: peerOut}: { + EffectiveUptime: 60 * time.Second, + }, + }, nil + }, + req: &frdrpc.ForwardingAbilityRequest{ + StartTime: 100, + EndTime: 200, + UptimeThreshold: 0.5, + }, + wantCode: codes.OK, + check: func(t *testing.T, + resp *frdrpc.ForwardingAbilityResponse, + _ btcutil.Amount) { + + // Up 60s of a 100s window at a 0.5 threshold: + // idle, so a bit and no entry. + require.Equal(t, 0.5, resp.UptimeThreshold) + require.Empty(t, resp.Entries) + require.NotEmpty(t, resp.UpButIdleBitmask) + }, + }, + { + name: "out of range threshold is rejected", + analyzer: func() ( + map[chanevents.PeerPair]chanevents.ForwardingAbility, + error) { + + return nil, nil + }, + req: &frdrpc.ForwardingAbilityRequest{ + StartTime: 100, + EndTime: 200, + UptimeThreshold: 1.5, + }, + wantCode: codes.InvalidArgument, + }, + { + name: "start after end is rejected", + req: &frdrpc.ForwardingAbilityRequest{ + StartTime: 200, + EndTime: 100, + }, + wantCode: codes.InvalidArgument, + }, + { + name: "missing analyzer is unavailable", + req: &frdrpc.ForwardingAbilityRequest{ + StartTime: 100, + EndTime: 200, + LiquidityFloorSat: 1000, + }, + wantCode: codes.Unavailable, + }, + { + name: "analyzer error is internal", + analyzer: func() ( + map[chanevents.PeerPair]chanevents.ForwardingAbility, + error) { + + return nil, errors.New("db lookup failed") + }, + req: &frdrpc.ForwardingAbilityRequest{ + StartTime: 100, + EndTime: 200, + LiquidityFloorSat: 1000, + }, + wantCode: codes.Internal, + }, + } + + for _, tc := range tests { + t.Run( + tc.name, + func(t *testing.T) { + var gotFloor btcutil.Amount + + cfg := &Config{} + if tc.analyzer != nil { + cfg.ForwardingAnalyzer = &mockForwardingAnalyzer{ + effectiveUptimeFunc: func( + _ context.Context, _, + _ time.Time, + floor btcutil.Amount) ( + map[chanevents.PeerPair]chanevents.ForwardingAbility, + error) { + + gotFloor = floor + + return tc.analyzer() + }, + } + } + + server := NewRPCServer(cfg) + resp, err := server.ForwardingAbility( + t.Context(), tc.req, + ) + + if tc.wantCode != codes.OK { + st, ok := status.FromError(err) + require.True(t, ok) + require.Equal(t, tc.wantCode, st.Code()) + + return + } + + require.NoError(t, err) + require.NotNil(t, resp) + if tc.check != nil { + tc.check(t, resp, gotFloor) + } + }, + ) + } +} diff --git a/frdrpcserver/perms/perms.go b/frdrpcserver/perms/perms.go index c17c541..6e20801 100644 --- a/frdrpcserver/perms/perms.go +++ b/frdrpcserver/perms/perms.go @@ -37,4 +37,8 @@ var RequiredPermissions = map[string][]bakery.Op{ Entity: "events", Action: "read", }}, + "/frdrpc.FaradayServer/ForwardingAbility": {{ + Entity: "insights", + Action: "read", + }}, } diff --git a/frdrpcserver/rpcserver.go b/frdrpcserver/rpcserver.go index f248490..c0e7b12 100644 --- a/frdrpcserver/rpcserver.go +++ b/frdrpcserver/rpcserver.go @@ -12,7 +12,9 @@ package frdrpcserver import ( "context" "errors" + "time" + "github.com/btcsuite/btcd/btcutil" "github.com/lightninglabs/faraday/accounting" "github.com/lightninglabs/faraday/chain" "github.com/lightninglabs/faraday/chanevents" @@ -53,6 +55,15 @@ type RPCServer struct { cfg *Config } +// ForwardingAnalyzer is the seam the RPC server uses to compute per-peer-pair +// forwarding facts. It is an interface so the handler can be exercised against +// a mock analyzer in tests. +type ForwardingAnalyzer interface { + EffectiveUptime(ctx context.Context, startTime, endTime time.Time, + liquidityFloor btcutil.Amount) ( + map[chanevents.PeerPair]chanevents.ForwardingAbility, error) +} + // Config provides closures and settings required to run the rpc server. type Config struct { // Lnd is a client which can be used to query lnd. @@ -61,6 +72,11 @@ type Config struct { // ChanEvents is a database of channel events. ChanEvents *chanevents.Store + // ForwardingAnalyzer computes forwarding-ability facts for the + // ForwardingAbility RPC. When nil, that endpoint returns + // codes.Unavailable. + ForwardingAnalyzer ForwardingAnalyzer + // BitcoinClient is an optional client which can be used to query // on-chain data from a connected bitcoin node. If nil, faraday will // not be able to serve endpoints which require on-chain data. diff --git a/go.mod b/go.mod index 4f03fea..f0459a0 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,6 @@ require ( github.com/shopspring/decimal v1.2.0 github.com/stretchr/testify v1.10.0 github.com/urfave/cli v1.22.14 - golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b google.golang.org/grpc v1.65.0 google.golang.org/protobuf v1.34.2 gopkg.in/macaroon-bakery.v2 v2.0.1 @@ -164,6 +163,7 @@ require ( go.uber.org/multierr v1.6.0 // indirect go.uber.org/zap v1.17.0 // indirect golang.org/x/crypto v0.39.0 // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/net v0.41.0 // indirect golang.org/x/sync v0.15.0 // indirect golang.org/x/sys v0.34.0 // indirect diff --git a/go.sum b/go.sum index 22e447c..c57b770 100644 --- a/go.sum +++ b/go.sum @@ -470,8 +470,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq github.com/rogpeppe/fastuuid v1.2.0 h1:Ppwyp6VYCF1nvBTXL3trRso7mXMlRrw9ooo375wvi2s= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= +github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= diff --git a/itest/channel_events_test.go b/itest/channel_events_test.go index 3100bdb..4def9f6 100644 --- a/itest/channel_events_test.go +++ b/itest/channel_events_test.go @@ -11,6 +11,8 @@ import ( "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lnwire" "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) // TestGetChannelEvents pins the GetChannelEvents RPC contract: a regtest @@ -146,3 +148,215 @@ func TestGetChannelEvents(t *testing.T) { "event order mismatch at index %d", i) } } + +// TestForwardingAbility integration test opens a channel, sends payments to +// seed events, and verifies that calling the ForwardingAbility RPC returns +// the peer pair analytics successfully and can be decoded. +func TestForwardingAbility(t *testing.T) { + c := newTestContext(t) + defer c.stop() + + ctx := context.Background() + + // Connect nodes and open a channel from alice to bob. + var aliceChannelAmt = btcutil.Amount(500000) + + err := c.aliceClient.Client.Connect( + ctx, c.bobPubkey, "localhost:10012", true, + ) + require.NoError(c.t, err, "could not connect nodes") + + _, _ = c.openChannel( + c.aliceClient.Client, c.bobPubkey, aliceChannelAmt, + ) + + // Wait until alice can route a payment to bob. + var paymentAmount lnwire.MilliSatoshi = 20000000 + c.eventuallyf(func() bool { + return c.channelRoutable(c.bobPubkey, paymentAmount) + }, "channel did not become routable") + + // Send a payment from alice to bob. + hash, payreq := c.addInvoice(c.bobClient.Client, paymentAmount) + c.makePayment( + c.aliceClient.LndServices, c.bobClient.LndServices, + lndclient.SendPaymentRequest{ + Invoice: payreq, + PaymentHash: &hash, + Timeout: paymentTimeout, + }, lnrpc.Payment_SUCCEEDED, + ) + + // The alice->bob payment moved liquidity onto bob's side of the only + // channel, so from this point on the bob self-pair holds at least the + // requested floor. Measuring over a window that starts now keeps the + // pair's uptime fraction high, which both clears the uptime threshold + // and keeps the node-down guard satisfied. A future end time extends + // the window over the still-funded state. + bobHex := c.bobPubkey.String() + windowStart := time.Now() + + // The events store ingests channel updates asynchronously, so retry + // until the bob self-pair surfaces. + var ability frdrpc.ForwardingAbility + c.eventuallyf(func() bool { + endTime := time.Now().Add(2 * time.Second).Unix() + resp, err := c.faradayClient.ForwardingAbility( + ctx, &frdrpc.ForwardingAbilityRequest{ + StartTime: uint64(windowStart.Unix()), + EndTime: uint64(endTime), + LiquidityFloorSat: 1000, + UptimeThreshold: 0.1, + }, + ) + if err != nil { + return false + } + + decoded, err := frdrpc.DecodeForwardingAbility(resp) + if err != nil { + return false + } + + a, ok := decoded[bobHex][bobHex] + if !ok { + return false + } + ability = a + + return true + }, "expected bob self-pair in forwarding ability") + + // The bob self-pair was up but never forwarded through itself, so it + // surfaces via the up-but-idle bitmask: non-zero effective uptime and + // zero forwarded volume. + require.Greater(c.t, ability.EffectiveUptimeS, int64(0)) + require.Zero(c.t, ability.ForwardedSat) +} + +// TestForwardingDowntime exercises the offline/online plumbing end to end. It +// disconnects the only channel peer to take the channel offline, asserts that +// faraday records the resulting offline event, then reconnects the peer and +// asserts the recovering online event lands and the bob self-pair surfaces in +// ForwardingAbility again. This proves downtime and recovery flow through to +// the analyzer; the exact per-second uptime math is covered deterministically +// by the analyzer unit tests. +func TestForwardingDowntime(t *testing.T) { + c := newTestContext(t) + defer c.stop() + + ctx := context.Background() + + // Connect nodes and open a channel from alice to bob. + var aliceChannelAmt = btcutil.Amount(500000) + + err := c.aliceClient.Client.Connect( + ctx, c.bobPubkey, "localhost:10012", true, + ) + require.NoError(c.t, err, "could not connect nodes") + + aliceChannel, _ := c.openChannel( + c.aliceClient.Client, c.bobPubkey, aliceChannelAmt, + ) + + // Wait until alice can route a payment to bob. + var paymentAmount lnwire.MilliSatoshi = 20000000 + c.eventuallyf(func() bool { + return c.channelRoutable(c.bobPubkey, paymentAmount) + }, "channel did not become routable") + + // Move liquidity onto bob's side so the bob self-pair clears the + // liquidity floor while the channel is up. + hash, payreq := c.addInvoice(c.bobClient.Client, paymentAmount) + c.makePayment( + c.aliceClient.LndServices, c.bobClient.LndServices, + lndclient.SendPaymentRequest{ + Invoice: payreq, + PaymentHash: &hash, + Timeout: paymentTimeout, + }, lnrpc.Payment_SUCCEEDED, + ) + + chanPoint := aliceChannel.String() + bobHex := c.bobPubkey.String() + + // Snapshot the event counts before the disconnect so we can detect the + // new offline and online events the disconnect and recovery produce. + onlineBefore, offlineBefore := c.channelEventCounts(chanPoint) + + // Disconnect bob to take the only channel offline. + c.disconnectPeer(c.aliceClient, c.bobPubkey) + + // faraday should ingest the resulting offline event: this is the + // downtime signal that the channel went inactive. + c.eventuallyf(func() bool { + _, offline := c.channelEventCounts(chanPoint) + return offline > offlineBefore + }, "expected an offline event after disconnect") + + // An explicit DisconnectPeer is sticky: lnd does not auto-reconnect, so + // the channel stays offline until we reconnect. A window that sits + // entirely in this offline period leaves no pair clearing the uptime + // threshold, so the node-down guard rejects the request with + // FailedPrecondition rather than returning an empty response. The + // threshold is irrelevant here since the only pair has zero uptime. + c.eventuallyf(func() bool { + now := time.Now() + _, err := c.faradayClient.ForwardingAbility( + ctx, &frdrpc.ForwardingAbilityRequest{ + StartTime: uint64(now.Unix()), + EndTime: uint64( + now.Add(2 * time.Second).Unix(), + ), + LiquidityFloorSat: 1000, + UptimeThreshold: 0.9, + }, + ) + + return status.Code(err) == codes.FailedPrecondition + }, "expected node-down guard while bob is disconnected") + + // An explicit DisconnectPeer drops lnd's persistent connection, so the + // channel only comes back up once we reconnect. Reconnect bob to bring + // the channel active again. + err = c.aliceClient.Client.Connect( + ctx, c.bobPubkey, "localhost:10012", true, + ) + require.NoError(c.t, err, "could not reconnect nodes") + + // The channel goes active again on reconnect, which faraday records as + // an online event. + c.eventuallyf(func() bool { + online, _ := c.channelEventCounts(chanPoint) + return online > onlineBefore + }, "expected an online event after reconnect") + + // With the channel back up and liquidity still on bob's side, the bob + // self-pair surfaces in ForwardingAbility again over a fresh window + // that opens after recovery. + c.eventuallyf(func() bool { + now := time.Now() + resp, err := c.faradayClient.ForwardingAbility( + ctx, &frdrpc.ForwardingAbilityRequest{ + StartTime: uint64(now.Unix()), + EndTime: uint64( + now.Add(2 * time.Second).Unix(), + ), + LiquidityFloorSat: 1000, + UptimeThreshold: 0.1, + }, + ) + if err != nil { + return false + } + + decoded, err := frdrpc.DecodeForwardingAbility(resp) + if err != nil { + return false + } + + _, ok := decoded[bobHex][bobHex] + + return ok + }, "expected bob self-pair after reconnect") +} diff --git a/itest/test_context.go b/itest/test_context.go index 85fa2eb..0d6cdd4 100644 --- a/itest/test_context.go +++ b/itest/test_context.go @@ -466,6 +466,58 @@ func (c *testContext) channelRoutable(dest route.Vertex, return err == nil } +// disconnectPeer disconnects the given client from a peer, taking any channels +// between them offline. lnd normally refuses to disconnect from a peer with an +// active channel, but the itest lnd is a non-integration build where unsafe +// disconnect is always permitted. The raw lnrpc client is used because the +// high-level lndclient interface exposes no Disconnect, and the admin macaroon +// is attached at call time since the shared connection carries none. +func (c *testContext) disconnectPeer(client *lndclient.GrpcLndServices, + peer route.Vertex) { + + c.t.Helper() + + ctx, err := client.WithMacaroonAuthForService( + context.Background(), lndclient.AdminServiceMac, + ) + require.NoError(c.t, err, "could not attach macaroon") + + raw := lnrpc.NewLightningClient(client.ClientConn) + _, err = raw.DisconnectPeer(ctx, &lnrpc.DisconnectPeerRequest{ + PubKey: peer.String(), + }) + require.NoError(c.t, err, "could not disconnect peer") +} + +// channelEventCounts returns how many online and offline events faraday has +// recorded for the given channel up to the present. +func (c *testContext) channelEventCounts(chanPoint string) (online, + offline int) { + + c.t.Helper() + + endTime := time.Now().Add(time.Second).Unix() + events, err := c.faradayClient.GetChannelEvents( + context.Background(), &frdrpc.ChannelEventsRequest{ + ChanPoint: chanPoint, + EndTime: endTime, + }, + ) + require.NoError(c.t, err, "could not get channel events") + + for _, event := range events.Events { + switch event.EventType { + case frdrpc.ChannelEventType_CHAN_EVENT_ONLINE: + online++ + + case frdrpc.ChannelEventType_CHAN_EVENT_OFFLINE: + offline++ + } + } + + return online, offline +} + // findChannel finds a channel in a set of open channels, returning nil if it // is not found. // nolint:interfacer