mirror of
https://github.com/lightninglabs/faraday.git
synced 2026-08-13 12:33:35 +02:00
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:
parent
c9fc67a0b5
commit
9d0d198ac6
5 changed files with 987 additions and 2 deletions
374
frdrpc/forwarding_ability_codec.go
Normal file
374
frdrpc/forwarding_ability_codec.go
Normal 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
|
||||
}
|
||||
587
frdrpc/forwarding_ability_codec_test.go
Normal file
587
frdrpc/forwarding_ability_codec_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
4
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=
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue