Merge pull request #243 from bitromortac/2604-fwd-prep-12

rpc: add sparse `ForwardingAbility` endpoint
This commit is contained in:
bitromortac 2026-06-11 12:48:39 +02:00 committed by GitHub
commit edc3a405e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 2744 additions and 586 deletions

View file

@ -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,
}
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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)
})
}
}

View file

@ -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
}

View file

@ -58,6 +58,7 @@ func main() {
onChainReportCommand,
closeReportCommand,
chanEventsCommand,
forwardingAbilityCommand,
}
if err := app.Run(os.Args); err != nil {

View file

@ -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.

View file

@ -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,
},

View file

@ -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;
}

View file

@ -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": [

View file

@ -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",

View file

@ -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)
}
}

View file

@ -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<<bit) == 0 {
continue
}
// Compute the pair index from the byte index and bit
// position.
k := int64(i)*8 + int64(bit)
if k >= 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
}

View file

@ -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)
}

View file

@ -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

View file

@ -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=

View file

@ -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
}

View file

@ -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)
}
},
)
}
}

View file

@ -37,4 +37,8 @@ var RequiredPermissions = map[string][]bakery.Op{
Entity: "events",
Action: "read",
}},
"/frdrpc.FaradayServer/ForwardingAbility": {{
Entity: "insights",
Action: "read",
}},
}

View file

@ -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.

2
go.mod
View file

@ -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

4
go.sum
View file

@ -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=

View file

@ -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")
}

View file

@ -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