whitelist.go raw

   1  package grapevine
   2  
   3  import (
   4  	"context"
   5  	"encoding/hex"
   6  	"time"
   7  
   8  	"git.smesh.lol/orly/pkg/lol/log"
   9  	"git.smesh.lol/orly/pkg/nostr/encoders/bech32encoding"
  10  )
  11  
  12  // WhitelistUpdater manages automatic whitelist updates based on GrapeVine scores.
  13  type WhitelistUpdater struct {
  14  	engine            *Engine
  15  	influenceThreshold float64
  16  	updateInterval    time.Duration
  17  	onUpdate          func(pubkeys []string) error
  18  	ctx               context.Context
  19  	cancel            context.CancelFunc
  20  	running           bool
  21  }
  22  
  23  // WhitelistConfig configures the whitelist updater.
  24  type WhitelistConfig struct {
  25  	// InfluenceThreshold is the minimum influence score for whitelist inclusion (default: 0.5)
  26  	InfluenceThreshold float64
  27  	// UpdateInterval is how often to recalculate and update the whitelist (default: 6h)
  28  	UpdateInterval time.Duration
  29  	// OnUpdate is called with the new whitelist pubkeys when an update occurs
  30  	OnUpdate func(pubkeys []string) error
  31  }
  32  
  33  // NewWhitelistUpdater creates a new whitelist updater.
  34  func NewWhitelistUpdater(ctx context.Context, engine *Engine, cfg WhitelistConfig) *WhitelistUpdater {
  35  	if cfg.InfluenceThreshold <= 0 {
  36  		cfg.InfluenceThreshold = 0.5
  37  	}
  38  	if cfg.UpdateInterval <= 0 {
  39  		cfg.UpdateInterval = 6 * time.Hour
  40  	}
  41  
  42  	ctx, cancel := context.WithCancel(ctx)
  43  	return &WhitelistUpdater{
  44  		engine:            engine,
  45  		influenceThreshold: cfg.InfluenceThreshold,
  46  		updateInterval:    cfg.UpdateInterval,
  47  		onUpdate:          cfg.OnUpdate,
  48  		ctx:               ctx,
  49  		cancel:            cancel,
  50  	}
  51  }
  52  
  53  // Start begins the automatic whitelist update loop.
  54  func (w *WhitelistUpdater) Start(observerHex string) error {
  55  	if w.running {
  56  		return nil
  57  	}
  58  	w.running = true
  59  
  60  	go w.updateLoop(observerHex)
  61  	log.I.F("grapevine whitelist updater: started (threshold: %.2f, interval: %v)",
  62  		w.influenceThreshold, w.updateInterval)
  63  	return nil
  64  }
  65  
  66  // Stop stops the whitelist updater.
  67  func (w *WhitelistUpdater) Stop() {
  68  	if !w.running {
  69  		return
  70  	}
  71  	w.running = false
  72  	w.cancel()
  73  	log.I.F("grapevine whitelist updater: stopped")
  74  }
  75  
  76  // updateLoop runs the periodic whitelist update.
  77  func (w *WhitelistUpdater) updateLoop(observerHex string) {
  78  	// Run immediately on start
  79  	w.updateOnce(observerHex)
  80  
  81  	ticker := time.NewTicker(w.updateInterval)
  82  	defer ticker.Stop()
  83  
  84  	for {
  85  		select {
  86  		case <-w.ctx.Done():
  87  			return
  88  		case <-ticker.C:
  89  			w.updateOnce(observerHex)
  90  		}
  91  	}
  92  }
  93  
  94  // updateOnce performs a single whitelist update.
  95  func (w *WhitelistUpdater) updateOnce(observerHex string) {
  96  	start := time.Now()
  97  	log.I.F("grapevine whitelist updater: starting update for observer %s", observerHex[:12])
  98  
  99  	// Compute fresh GrapeVine scores
 100  	scoreSet, err := w.engine.Compute(observerHex)
 101  	if err != nil {
 102  		log.E.F("grapevine whitelist updater: failed to compute scores: %v", err)
 103  		return
 104  	}
 105  
 106  	// Filter pubkeys by influence threshold
 107  	var whitelist []string
 108  	for _, score := range scoreSet.Scores {
 109  		if score.Influence >= w.influenceThreshold {
 110  			whitelist = append(whitelist, score.PubkeyHex)
 111  		}
 112  	}
 113  
 114  	log.I.F("grapevine whitelist updater: computed %d whitelisted pubkeys (threshold: %.2f, total: %d)",
 115  		len(whitelist), w.influenceThreshold, len(scoreSet.Scores))
 116  
 117  	// Call the update callback
 118  	if w.onUpdate != nil {
 119  		if err := w.onUpdate(whitelist); err != nil {
 120  			log.E.F("grapevine whitelist updater: failed to apply whitelist update: %v", err)
 121  			return
 122  		}
 123  	}
 124  
 125  	log.I.F("grapevine whitelist updater: completed update in %v", time.Since(start))
 126  }
 127  
 128  // TriggerNow forces an immediate whitelist update.
 129  func (w *WhitelistUpdater) TriggerNow(observerHex string) {
 130  	go w.updateOnce(observerHex)
 131  }
 132  
 133  // GetWhitelist returns the current whitelist for an observer without updating.
 134  func (w *WhitelistUpdater) GetWhitelist(observerHex string) ([]string, error) {
 135  	scoreSet, err := w.engine.GetScores(observerHex)
 136  	if err != nil {
 137  		return nil, err
 138  	}
 139  	if scoreSet == nil {
 140  		return nil, nil
 141  	}
 142  
 143  	var whitelist []string
 144  	for _, score := range scoreSet.Scores {
 145  		if score.Influence >= w.influenceThreshold {
 146  			whitelist = append(whitelist, score.PubkeyHex)
 147  		}
 148  	}
 149  	return whitelist, nil
 150  }
 151  
 152  // PubkeysToHexStrings converts binary pubkeys to hex strings.
 153  func PubkeysToHexStrings(pubkeys [][]byte) []string {
 154  	result := make([]string, 0, len(pubkeys))
 155  	for _, pk := range pubkeys {
 156  		result = append(result, hex.EncodeToString(pk))
 157  	}
 158  	return result
 159  }
 160  
 161  // HexStringsToPubkeys converts hex strings to binary pubkeys.
 162  func HexStringsToPubkeys(hexKeys []string) ([][]byte, error) {
 163  	result := make([][]byte, 0, len(hexKeys))
 164  	for _, h := range hexKeys {
 165  		pk, err := hex.DecodeString(h)
 166  		if err != nil {
 167  			// Try npub format
 168  			pk, err = bech32encoding.NpubOrHexToPublicKeyBinary(h)
 169  			if err != nil {
 170  				continue
 171  			}
 172  		}
 173  		if len(pk) == 32 {
 174  			result = append(result, pk)
 175  		}
 176  	}
 177  	return result, nil
 178  }
 179