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