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.
This commit is contained in:
bitromortac 2026-06-08 19:12:50 +02:00
parent c9fc67a0b5
commit 9d0d198ac6
5 changed files with 987 additions and 2 deletions

View file

@ -0,0 +1,374 @@
package frdrpc
import (
"bytes"
"encoding/hex"
"errors"
"fmt"
"math"
"sort"
"strings"
)
// maxPackedPeers is the largest peer set a response can address. packed_idx
// splits a uint32 into two 16-bit indices (in << 16 | out), so each direction
// can reference at most 65535 distinct peers.
const maxPackedPeers = 1<<16 - 1
// ForwardingAbility is a client-facing mirror of the raw forwarding facts for
// one direction of a peer pair over the analysis window. Derived rates and
// categories are left to the consumer.
type ForwardingAbility struct {
// EffectiveUptimeS is the seconds the pair held at least the requested
// liquidity floor of directional forwardable liquidity over the window.
// The value is whole seconds: sub-second uptime floors to zero, so a
// pair that forwarded volume over a fleeting qualifying window can
// report a zero uptime alongside a non-zero ForwardedSat.
EffectiveUptimeS int64
// ForwardedSat is the total successfully forwarded amount over the
// window, in satoshis.
ForwardedSat int64
}
// abilityTier classifies how a pair is encoded: a full entry, a single "up but
// idle" bit, or omitted entirely.
type abilityTier int
const (
// tierAbsent omits the pair: it neither forwarded nor held enough
// uptime to clear the threshold. Consumers treat absence as zero.
tierAbsent abilityTier = iota
// tierBit flags the pair in the up-but-idle bitmask: it held at least
// the uptime threshold but did not forward.
tierBit
// tierEntry emits a full entry carrying the pair's exact uptime and
// forwarded volume. Reserved for pairs that actually forwarded.
tierEntry
)
// tier decides how a pair is encoded given the minimum qualifying uptime in
// seconds. Forwarding always wins, so a pair that forwarded keeps its exact
// facts even if its uptime is below the threshold; otherwise the pair is
// compacted to a bit when it was up enough, and dropped when it was not.
func (a ForwardingAbility) tier(minUptimeS int64) abilityTier {
switch {
case a.ForwardedSat > 0:
return tierEntry
case a.EffectiveUptimeS >= minUptimeS:
return tierBit
default:
return tierAbsent
}
}
// MinQualifyingUptime converts an uptime fraction threshold into the smallest
// whole-second uptime that clears it over the given window. It is the single
// source of truth shared by the encoder (to bucket pairs) and the server (to
// apply the node-down guard), so the two cannot drift. A pair clears the
// threshold when EffectiveUptimeS >= the returned value, matching the "at least
// the threshold fraction" contract. The result is floored at one second so a
// pair with zero uptime is never treated as up. A non-positive window admits
// nothing.
func MinQualifyingUptime(threshold float64, windowSeconds int64) int64 {
if windowSeconds <= 0 {
return math.MaxInt64
}
v := int64(math.Ceil(threshold * float64(windowSeconds)))
if v < 1 {
v = 1
}
return v
}
// setBit sets the bit at the given index in a packed bitmask. The index is an
// int64 because an n*n bitmask over the full peer set overflows a 32-bit int.
func setBit(mask []byte, index int64) {
mask[index/8] |= 1 << (index % 8)
}
// getBit reports whether the bit at the given index in a packed bitmask is set.
// The index is an int64 because an n*n bitmask over the full peer set overflows
// a 32-bit int.
func getBit(mask []byte, index int64) bool {
return mask[index/8]&(1<<(index%8)) != 0
}
// EncodeForwardingAbility serializes a nested map of peer forwarding abilities
// into a memory-efficient sparse gRPC response over [startTime, endTime]. To
// optimize payload size it tiers each pair: pairs that forwarded keep a full
// entry, pairs that were up at least uptimeThreshold of the window but did not
// forward collapse to a single bit in the up-but-idle bitmask, and pairs below
// the threshold that did not forward are omitted entirely. Public keys are
// deduplicated and peer pairs packed into 32-bit indices.
func EncodeForwardingAbility(abilities map[string]map[string]ForwardingAbility,
startTime, endTime int64,
uptimeThreshold float64) (*ForwardingAbilityResponse, error) {
minUptimeS := MinQualifyingUptime(uptimeThreshold, endTime-startTime)
// First, find all unique peers involved in pairs that warrant either an
// entry or a bit. Keys are normalized to lower-case hex so a peer that
// appears in mixed case across entries collapses to a single index
// rather than being silently dropped at lookup time.
peerSet := make(map[string]struct{})
for inPeer, outMap := range abilities {
for outPeer, ability := range outMap {
if ability.tier(minUptimeS) == tierAbsent {
continue
}
peerSet[strings.ToLower(inPeer)] = struct{}{}
peerSet[strings.ToLower(outPeer)] = struct{}{}
}
}
// Decode to raw bytes and sort.
var rawPeers [][]byte
for peerHex := range peerSet {
b, err := hex.DecodeString(peerHex)
if err != nil {
return nil, err
}
rawPeers = append(rawPeers, b)
}
sort.Slice(
rawPeers,
func(i, j int) bool {
return bytes.Compare(rawPeers[i], rawPeers[j]) < 0
},
)
// Peer indices occupy 16 bits each in packed_idx, so the set must stay
// within maxPackedPeers. Beyond it, an index would overflow its field
// and silently decode to the wrong peer pair, so fail loudly instead.
if len(rawPeers) > maxPackedPeers {
return nil, fmt.Errorf("peer set of %d exceeds the %d "+
"addressable by packed_idx", len(rawPeers),
maxPackedPeers)
}
// Create map for index lookup using normalized lowercase hex strings.
peerIndex := make(map[string]uint32)
for idx, b := range rawPeers {
peerIndex[hex.EncodeToString(b)] = uint32(idx)
}
// The bitmask addresses every ordered pair over the peer set, so it
// needs n*n bits. Allocation is deferred until a bit is actually set so
// a response with no up-but-idle pairs carries no bitmask at all.
n := int64(len(rawPeers))
var bitmask []byte
// Build the entries and bitmask. seen guards against two input keys
// that differ only by hex case collapsing onto the same packed pair.
var entries []*ForwardingAbilityEntry
seen := make(map[uint32]struct{})
// addEntry appends a full entry for a forwarded pair.
addEntry := func(packed uint32, a ForwardingAbility) {
entries = append(entries, &ForwardingAbilityEntry{
PackedIdx: packed,
EffectiveUptimeS: a.EffectiveUptimeS,
ForwardedSat: a.ForwardedSat,
})
}
for inPeer, outMap := range abilities {
inIdx, okIn := peerIndex[strings.ToLower(inPeer)]
if !okIn {
continue
}
for outPeer, ability := range outMap {
tier := ability.tier(minUptimeS)
if tier == tierAbsent {
continue
}
outIdx, okOut := peerIndex[strings.ToLower(outPeer)]
if !okOut {
continue
}
// Pack the in-peer index into the high 16 bits and the
// out-peer index into the low 16.
packed := (inIdx << 16) | outIdx
// Reject a case-folded collision rather than silently
// dropping one of the two entries' facts.
if _, dup := seen[packed]; dup {
return nil, fmt.Errorf("duplicate peer pair "+
"after case normalization: "+
"in=%s out=%s", inPeer, outPeer)
}
seen[packed] = struct{}{}
switch tier {
case tierEntry:
addEntry(packed, ability)
case tierBit:
// The bitmask addresses n*n ordered pairs, one
// bit each.
if bitmask == nil {
bitmask = make(
[]byte, (n*n+7)/8,
)
}
setBit(
bitmask,
int64(inIdx)*n+int64(outIdx),
)
}
}
}
// Sort entries by packed_idx for deterministic output and testability.
sort.Slice(
entries,
func(i, j int) bool {
return entries[i].PackedIdx < entries[j].PackedIdx
},
)
return &ForwardingAbilityResponse{
Peers: rawPeers,
Entries: entries,
StartTime: startTime,
EndTime: endTime,
UpButIdleBitmask: bitmask,
UptimeThreshold: uptimeThreshold,
}, nil
}
// DecodeForwardingAbility reconstructs the nested map of peer forwarding
// abilities from a sparse packed gRPC response. Forwarded pairs come back with
// their exact facts; up-but-idle pairs flagged in the bitmask come back at full
// window uptime with zero forwarded volume. It validates packed indices and the
// bitmask length against the decoded peer list to prevent out-of-bounds errors.
func DecodeForwardingAbility(resp *ForwardingAbilityResponse) (
map[string]map[string]ForwardingAbility, error) {
result := make(map[string]map[string]ForwardingAbility)
if resp == nil {
return result, nil
}
numPeers := len(resp.Peers)
// packed_idx addresses peers with 16-bit indices, so a response with
// more than maxPackedPeers peers is malformed. Rejecting it here also
// keeps the n*n bitmask-length computation below from overflowing a
// 32-bit int.
if numPeers > maxPackedPeers {
return nil, fmt.Errorf("peer set of %d exceeds the %d "+
"addressable by packed_idx", numPeers, maxPackedPeers)
}
record := func(inIdx, outIdx int, ability ForwardingAbility) {
inPeer := hex.EncodeToString(resp.Peers[inIdx])
outPeer := hex.EncodeToString(resp.Peers[outIdx])
if _, ok := result[inPeer]; !ok {
result[inPeer] = make(map[string]ForwardingAbility)
}
result[inPeer][outPeer] = ability
}
// Decode the forwarded entries first so they take precedence over any
// bit set for the same pair.
for _, entry := range resp.Entries {
// Unpack the pair: the in-peer index is the high 16 bits, the
// out-peer index the low 16.
inIdx := int(entry.PackedIdx >> 16)
outIdx := int(entry.PackedIdx & 0xffff)
if inIdx >= numPeers || outIdx >= numPeers {
return nil, errors.New("decoded peer index out of " +
"bounds")
}
record(
inIdx, outIdx, ForwardingAbility{
EffectiveUptimeS: entry.EffectiveUptimeS,
ForwardedSat: entry.ForwardedSat,
},
)
}
// Expand the up-but-idle bitmask. An absent bitmask simply means no
// pair was flagged; a present one must address exactly the n*n pairs.
bitmask := resp.UpButIdleBitmask
if len(bitmask) == 0 {
return result, nil
}
// Compute the expected length in int64 so the n*n multiplication does
// not overflow a 32-bit int for a large peer set.
totalPairs := int64(numPeers) * int64(numPeers)
if want := int((totalPairs + 7) / 8); len(bitmask) != want {
return nil, fmt.Errorf("bitmask length %d does not match the "+
"%d expected for %d peers", len(bitmask), want,
numPeers)
}
// Up-but-idle pairs were up the whole window by definition of the
// threshold bucket, so reconstruct them at full window uptime with zero
// forwarded volume. Iterate over the bitmask bytes directly, skipping
// zero bytes, so cost scales with the number of set bits rather than
// the O(n*n) pair space; padding bits beyond n*n are ignored.
windowSeconds := resp.EndTime - resp.StartTime
for i, b := range bitmask {
if b == 0 {
continue
}
for bit := range 8 {
// If the bit is not set, skip the pair. This also
// implicitly ignores any padding bits in the last byte
// beyond the n*n pairs.
if b&(1<<bit) == 0 {
continue
}
// Compute the pair index from the byte index and bit
// position.
k := int64(i)*8 + int64(bit)
if k >= totalPairs {
break
}
// Unpack the pair: the in-peer index is the high 16
// bits, the out-peer index the low 16. The bounds were
// already checked against the bitmask length, so this
// cannot overflow.
inIdx := int(k / int64(numPeers))
outIdx := int(k % int64(numPeers))
// An entry for this pair takes precedence; never
// overwrite it.
inPeer := hex.EncodeToString(resp.Peers[inIdx])
outPeer := hex.EncodeToString(resp.Peers[outIdx])
if _, ok := result[inPeer][outPeer]; ok {
continue
}
record(
inIdx, outIdx, ForwardingAbility{
EffectiveUptimeS: windowSeconds,
ForwardedSat: 0,
},
)
}
}
return result, nil
}

View file

@ -0,0 +1,587 @@
package frdrpc
import (
"encoding/hex"
"fmt"
"math"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
// fwdKey returns a distinct 33-byte compressed-pubkey hex string for n. Keys
// sort ascending in n, matching the byte ordering the encoder applies.
func fwdKey(n int) string {
return fmt.Sprintf("02%064x", n)
}
// pair is one expected decoded entry, flattened from the nested result map for
// easy comparison.
type pair struct {
in string
out string
ability ForwardingAbility
}
// TestForwardingAbilityCodecRoundTrip verifies the three-tier encoding: pairs
// that forwarded keep exact facts as entries, pairs up at least the threshold
// but idle collapse to a bitmask bit (decoded at full window uptime), and
// sub-threshold idle pairs are dropped. The window is [0, 100) and the
// threshold 0.5, so the minimum qualifying uptime is 50 seconds.
func TestForwardingAbilityCodecRoundTrip(t *testing.T) {
const (
startTime, endTime int64 = 0, 100
threshold float64 = 0.5
)
tests := []struct {
name string
abilities map[string]map[string]ForwardingAbility
wantPeers []string
wantPairs []pair
wantBitmask bool
}{
{
// Forwarding wins regardless of uptime, so a
// zero-uptime pair that moved volume survives with its
// exact facts and never lands in the bitmask.
name: "forwarded pairs keep exact facts",
abilities: map[string]map[string]ForwardingAbility{
fwdKey(1): {
fwdKey(2): {
EffectiveUptimeS: 80,
ForwardedSat: 1500,
},
},
fwdKey(2): {
fwdKey(1): {
EffectiveUptimeS: 0,
ForwardedSat: 2500,
},
},
},
wantPeers: []string{
fwdKey(1),
fwdKey(2),
},
wantPairs: []pair{
{
fwdKey(1),
fwdKey(2),
ForwardingAbility{
80,
1500,
},
},
{
fwdKey(2),
fwdKey(1),
ForwardingAbility{
0,
2500,
},
},
},
wantBitmask: false,
},
{
// Up at or above the threshold but no forwards: a bit,
// decoded back at the full window's uptime.
name: "up but idle becomes a bit",
abilities: map[string]map[string]ForwardingAbility{
fwdKey(1): {
fwdKey(2): {
EffectiveUptimeS: 80,
},
},
fwdKey(3): {
fwdKey(1): {
EffectiveUptimeS: 50,
},
},
},
wantPeers: []string{
fwdKey(1),
fwdKey(2),
fwdKey(3),
},
wantPairs: []pair{
{
fwdKey(1),
fwdKey(2),
ForwardingAbility{
100,
0,
},
},
{
fwdKey(3),
fwdKey(1),
ForwardingAbility{
100,
0,
},
},
},
wantBitmask: true,
},
{
// Below the threshold with no forwards: dropped.
name: "sub-threshold idle pairs dropped",
abilities: map[string]map[string]ForwardingAbility{
fwdKey(1): {
fwdKey(2): {
EffectiveUptimeS: 49,
},
},
},
wantPeers: []string{},
wantPairs: []pair{},
wantBitmask: false,
},
{
// All three tiers at once, including a peer that only
// appears via the bitmask.
name: "mixed tiers",
abilities: map[string]map[string]ForwardingAbility{
fwdKey(1): {
fwdKey(2): {
EffectiveUptimeS: 80,
ForwardedSat: 1500,
},
fwdKey(3): {
EffectiveUptimeS: 60,
},
},
fwdKey(2): {
fwdKey(3): {
EffectiveUptimeS: 10,
},
},
fwdKey(3): {
fwdKey(1): {
ForwardedSat: 500,
},
},
},
wantPeers: []string{
fwdKey(1),
fwdKey(2),
fwdKey(3),
},
wantPairs: []pair{
{
fwdKey(1),
fwdKey(2),
ForwardingAbility{
80,
1500,
},
},
{
fwdKey(1),
fwdKey(3),
ForwardingAbility{
100,
0,
},
},
{
fwdKey(3),
fwdKey(1),
ForwardingAbility{
0,
500,
},
},
},
wantBitmask: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
resp, err := EncodeForwardingAbility(
tc.abilities, startTime, endTime, threshold,
)
require.NoError(t, err)
require.Equal(t, startTime, resp.StartTime)
require.Equal(t, endTime, resp.EndTime)
require.Equal(t, threshold, resp.UptimeThreshold)
require.Equal(
t, tc.wantBitmask,
len(resp.UpButIdleBitmask) > 0,
)
// A present bitmask must address exactly n*n bits.
if tc.wantBitmask {
n := len(resp.Peers)
require.Len(t, resp.UpButIdleBitmask, (n*n+7)/8)
}
gotPeers := make([]string, len(resp.Peers))
for i, p := range resp.Peers {
gotPeers[i] = hex.EncodeToString(p)
}
require.Equal(t, tc.wantPeers, gotPeers)
decoded, err := DecodeForwardingAbility(resp)
require.NoError(t, err)
got := make(map[string]ForwardingAbility)
for in, outMap := range decoded {
for out, ability := range outMap {
got[in+"->"+out] = ability
}
}
require.Len(t, got, len(tc.wantPairs))
for _, wp := range tc.wantPairs {
require.Equal(
t, wp.ability, got[wp.in+"->"+wp.out],
)
}
})
}
}
// TestMinQualifyingUptime verifies the threshold-to-seconds conversion shared
// by the encoder and the server guard, including its boundary behavior.
func TestMinQualifyingUptime(t *testing.T) {
tests := []struct {
name string
threshold float64
window int64
want int64
}{
{
"half of clean window",
0.5,
100,
50,
},
{
"rounds up a fraction",
0.333,
100,
34,
},
{
"integer boundary",
0.9,
2_592_000,
2_332_800,
},
{
"floored at one second",
0.0,
100,
1,
},
{
"non-positive window admits nothing",
0.5,
0,
math.MaxInt64,
},
}
for _, tc := range tests {
t.Run(
tc.name,
func(t *testing.T) {
require.Equal(
t, tc.want, MinQualifyingUptime(
tc.threshold, tc.window,
),
)
},
)
}
}
// TestBitmaskHelpers verifies that setBit and getBit address the same bit.
func TestBitmaskHelpers(t *testing.T) {
mask := make([]byte, 2)
require.False(t, getBit(mask, 9))
setBit(mask, 9)
require.True(t, getBit(mask, 9))
require.False(t, getBit(mask, 8))
require.False(t, getBit(mask, 10))
}
// TestForwardingAbilityDecodeEntryPrecedence verifies that when a pair is both
// listed as an entry and flagged in the bitmask, the entry's exact facts win.
func TestForwardingAbilityDecodeEntryPrecedence(t *testing.T) {
// Two peers => a 2*2 bitmask needs (4+7)/8 = 1 byte. Set the bit for
// pair (0, 1) at index 0*2+1 = 1, and also list it as an entry.
mask := make([]byte, 1)
setBit(mask, 1)
resp := &ForwardingAbilityResponse{
Peers: [][]byte{
{
1,
},
{
2,
},
},
StartTime: 0,
EndTime: 100,
Entries: []*ForwardingAbilityEntry{
{
PackedIdx: (0 << 16) | 1,
EffectiveUptimeS: 42,
ForwardedSat: 7,
},
},
UpButIdleBitmask: mask,
}
decoded, err := DecodeForwardingAbility(resp)
require.NoError(t, err)
require.Equal(
t, ForwardingAbility{42, 7},
decoded[hex.EncodeToString([]byte{1})][hex.EncodeToString(
[]byte{2},
)],
)
}
// TestForwardingAbilityDecodeBadIndex verifies that a packed index referencing
// a peer beyond the decoded peer list is rejected rather than silently mapped.
func TestForwardingAbilityDecodeBadIndex(t *testing.T) {
resp := &ForwardingAbilityResponse{
Peers: [][]byte{
{
1,
2,
3,
},
},
Entries: []*ForwardingAbilityEntry{
{
// Out index 1 is out of bounds for a single
// peer.
PackedIdx: (0 << 16) | 1,
EffectiveUptimeS: 3600,
ForwardedSat: 1000,
},
},
}
_, err := DecodeForwardingAbility(resp)
require.ErrorContains(t, err, "peer index out of bounds")
}
// TestForwardingAbilityDecodeBadBitmaskLen verifies that a bitmask whose length
// does not match the n*n pairs of the peer set is rejected.
func TestForwardingAbilityDecodeBadBitmaskLen(t *testing.T) {
resp := &ForwardingAbilityResponse{
// Two peers expect a 1-byte bitmask; supply two bytes.
Peers: [][]byte{
{
1,
},
{
2,
},
},
UpButIdleBitmask: []byte{
0x00,
0x00,
},
}
_, err := DecodeForwardingAbility(resp)
require.ErrorContains(t, err, "bitmask length")
}
// TestForwardingAbilityEncodePeerCap verifies that a peer set too large to
// address with packed_idx is rejected loudly instead of overflowing an index
// into the wrong peer pair.
func TestForwardingAbilityEncodePeerCap(t *testing.T) {
outMap := make(map[string]ForwardingAbility)
for i := 1; i <= maxPackedPeers+1; i++ {
// Use forwarded volume so inclusion is threshold-independent.
outMap[fwdKey(i)] = ForwardingAbility{ForwardedSat: 1}
}
abilities := map[string]map[string]ForwardingAbility{
fwdKey(0): outMap,
}
_, err := EncodeForwardingAbility(abilities, 0, 1, 0.5)
require.ErrorContains(t, err, "exceeds")
}
// TestForwardingAbilityEncodeNormalizesCase verifies that a peer appearing in
// mixed hex case collapses to a single index rather than producing a duplicate
// peer entry.
func TestForwardingAbilityEncodeNormalizesCase(t *testing.T) {
// Use a key with hex letters so its upper- and lower-case forms are
// genuinely distinct map keys.
peer := fwdKey(0xabcdef)
abilities := map[string]map[string]ForwardingAbility{
strings.ToUpper(peer): {
fwdKey(2): {
ForwardedSat: 20,
},
},
peer: {
fwdKey(3): {
ForwardedSat: 40,
},
},
}
resp, err := EncodeForwardingAbility(abilities, 0, 1, 0.5)
require.NoError(t, err)
// The upper- and lower-case forms of the shared peer must dedup to one
// index, leaving exactly three distinct peers.
require.Len(t, resp.Peers, 3)
decoded, err := DecodeForwardingAbility(resp)
require.NoError(t, err)
require.Equal(
t, ForwardingAbility{
ForwardedSat: 20,
},
decoded[peer][fwdKey(2)],
)
require.Equal(
t, ForwardingAbility{
ForwardedSat: 40,
},
decoded[peer][fwdKey(3)],
)
}
// TestForwardingAbilityEncodeRejectsCaseCollision verifies that two input keys
// that differ only by hex case but address the same peer pair are rejected
// rather than silently collapsing onto one packed index and dropping a fact.
func TestForwardingAbilityEncodeRejectsCaseCollision(t *testing.T) {
inPeer := fwdKey(0xabcdef)
outPeer := fwdKey(2)
// Both in-peer spellings normalize to the same index and share the same
// out-peer, so they collide on packed_idx.
abilities := map[string]map[string]ForwardingAbility{
strings.ToUpper(inPeer): {
outPeer: {
ForwardedSat: 10,
},
},
inPeer: {
outPeer: {
ForwardedSat: 20,
},
},
}
_, err := EncodeForwardingAbility(abilities, 0, 1, 0.5)
require.Error(t, err)
}
// TestForwardingAbilityCodecRoundTripHighIndices round-trips a large peer set
// so that packed indices exceed a single byte and exercise the high bits of
// each 16-bit direction field, and the up-but-idle bitmask spans many bytes. It
// guards index packing and bitmask addressing against regressions that only
// surface beyond the small indices the other round-trip cases use.
func TestForwardingAbilityCodecRoundTripHighIndices(t *testing.T) {
const (
numPeers = 300
startTime, endTime = int64(0), int64(100)
threshold = 0.5
)
// Build a cycle so every peer appears and takes a stable index equal to
// its fwdKey ordinal. Even edges forward (kept as exact entries); odd
// edges are up but idle at >= threshold (collapsed to a bitmask bit,
// decoded back at the full window uptime).
abilities := make(map[string]map[string]ForwardingAbility, numPeers)
want := make(map[string]ForwardingAbility, numPeers)
for i := range numPeers {
in, out := fwdKey(i), fwdKey((i+1)%numPeers)
var enc, dec ForwardingAbility
if i%2 == 0 {
// Add pair that forwarded.
enc = ForwardingAbility{
EffectiveUptimeS: 70,
ForwardedSat: int64(i + 1),
}
dec = enc
} else {
// Add up, but idle pair.
enc = ForwardingAbility{EffectiveUptimeS: 60}
dec = ForwardingAbility{
EffectiveUptimeS: endTime - startTime,
}
}
abilities[in] = map[string]ForwardingAbility{out: enc}
want[in+"->"+out] = dec
}
resp, err := EncodeForwardingAbility(
abilities, startTime, endTime, threshold,
)
require.NoError(t, err)
require.Len(t, resp.Peers, numPeers)
// With 300 peers the indices exceed one byte, so at least one packed
// index must use the high bits of its 16-bit field.
var sawHighIdx bool
for _, e := range resp.Entries {
if e.PackedIdx>>16 > 0xff || e.PackedIdx&0xffff > 0xff {
sawHighIdx = true
break
}
}
require.True(t, sawHighIdx, "expected an index beyond one byte")
decoded, err := DecodeForwardingAbility(resp)
require.NoError(t, err)
got := make(map[string]ForwardingAbility)
for in, outMap := range decoded {
for out, ability := range outMap {
got[in+"->"+out] = ability
}
}
require.Equal(t, want, got)
}
// TestForwardingAbilityDecodeNil verifies that decoding a nil response yields
// an empty map rather than panicking.
func TestForwardingAbilityDecodeNil(t *testing.T) {
decoded, err := DecodeForwardingAbility(nil)
require.NoError(t, err)
require.Empty(t, decoded)
}
// TestForwardingAbilityDecodeIgnoresPaddingBit verifies that a bit set in the
// padding region beyond the n*n pairs of the final byte is ignored rather than
// decoded into a bogus pair.
func TestForwardingAbilityDecodeIgnoresPaddingBit(t *testing.T) {
// Two peers => 2*2 = 4 valid bits in a 1-byte mask; bits 4..7 are
// padding. Set padding bit 5 and assert nothing decodes from it.
mask := make([]byte, 1)
setBit(mask, 5)
resp := &ForwardingAbilityResponse{
Peers: [][]byte{{1}, {2}},
StartTime: 0,
EndTime: 100,
UpButIdleBitmask: mask,
}
decoded, err := DecodeForwardingAbility(resp)
require.NoError(t, err)
require.Empty(t, decoded)
}

View file

@ -2,16 +2,22 @@ module github.com/lightninglabs/faraday/frdrpc
require (
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0
github.com/stretchr/testify v1.10.0
google.golang.org/grpc v1.65.0
google.golang.org/protobuf v1.34.2
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.15.0 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.23.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
go 1.25.5

View file

@ -1,7 +1,20 @@
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
@ -16,3 +29,8 @@ google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc=
google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

4
go.sum
View file

@ -470,8 +470,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
github.com/rogpeppe/fastuuid v1.2.0 h1:Ppwyp6VYCF1nvBTXL3trRso7mXMlRrw9ooo375wvi2s=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=