Files
lightwalletd/common/cache.go
DanS 2bab58c6d2 lightwalletd: adaptive reorg-lag tip + non-blocking startup coinsupply
Adaptive-lag: advertise a tip that trails the real tip by a reorg-rate-driven
lag (GetLatestBlock/GetLightdInfo) so wallets anchor shielded spends at a
settled height during reorg churn. Configurable via
-adaptive-lag/-lag-min/-lag-max/-lag-window; monitor_lwd.sh runs
-lag-min 4 -lag-max 12 -lag-window 30.

Startup coinsupply: the informational startup coinsupply RPC (result used only
for a log line) blocked the gRPC bind for minutes on a node whose supply index
is cold, keeping the lite endpoint down on every restart while it also starved
the block-cache ingestor. Run it in a goroutine so the bind is never blocked;
clients still get coinsupply on demand via the GetCoinsupply RPC.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 01:05:10 -05:00

223 lines
5.7 KiB
Go

package common
import (
"bytes"
"sync"
"time"
"git.hush.is/hush/lightwalletd/walletrpc"
"github.com/golang/protobuf/proto"
)
type BlockCacheEntry struct {
data []byte
hash []byte
}
type BlockCache struct {
MaxEntries int
FirstBlock int
LastBlock int
m map[int]*BlockCacheEntry
mutex sync.RWMutex
// Adaptive confirmation lag. lightwalletd advertises a tip that trails the
// real tip by a number of blocks that scales with the recent reorg rate, so
// wallets anchor shielded spends at a settled height while the chain is
// churning, and at (near) the real tip when it is stable. reorgTimes holds
// the times of recent reorg detections, pruned to lagWindow.
reorgTimes []time.Time
lagWindow time.Duration
lagMin int
lagMax int
adaptiveLag bool
// advTip is the cached advertised (lag-adjusted) tip, kept fresh by Add so
// the per-block serving cap (HeightAllowed) is a cheap read. -1 until ready.
advTip int
}
func NewBlockCache(maxEntries int) *BlockCache {
return &BlockCache{
MaxEntries: maxEntries,
FirstBlock: -1,
LastBlock: -1,
m: make(map[int]*BlockCacheEntry),
adaptiveLag: true,
lagWindow: 30 * time.Minute,
lagMin: 1,
lagMax: 12,
advTip: -1,
}
}
func (c *BlockCache) Add(height int, block *walletrpc.CompactBlock) (error, bool) {
c.mutex.Lock()
defer c.mutex.Unlock()
//println("Cache add", height)
if c.FirstBlock == -1 && c.LastBlock == -1 {
// If this is the first block, prep the data structure
c.FirstBlock = height
c.LastBlock = height - 1
}
// If we're adding a block in the middle of the cache, remove all
// blocks after it, since this might be a reorg, and we don't want
// Any outdated blocks returned
if height >= c.FirstBlock && height <= c.LastBlock {
for i := height; i <= c.LastBlock; i++ {
delete(c.m, i)
}
c.LastBlock = height - 1
}
// Don't allow out-of-order blocks. This is more of a sanity check than anything
// If there is a reorg, then the ingestor needs to handle it.
if c.m[height-1] != nil && !bytes.Equal(block.PrevHash, c.m[height-1].hash) {
// Record the reorg so the adaptive confirmation lag can react to it.
c.reorgTimes = append(c.reorgTimes, time.Now())
return nil, true
}
// Add the entry and update the counters
data, err := proto.Marshal(block)
if err != nil {
println("Error marshalling block!")
return err, false
}
c.m[height] = &BlockCacheEntry{
data: data,
hash: block.GetHash(),
}
c.LastBlock = height
// If the cache is full, remove the oldest block
if c.LastBlock-c.FirstBlock+1 > c.MaxEntries {
//println("Deleteing at height", c.FirstBlock)
delete(c.m, c.FirstBlock)
c.FirstBlock = c.FirstBlock + 1
}
// Keep the advertised (lag-adjusted) tip fresh for the block-serving cap.
c.advTip = c.LastBlock - c.computeLagLocked()
if c.advTip < c.FirstBlock {
c.advTip = c.FirstBlock
}
//println("Cache size is ", len(c.m))
return nil, false
}
func (c *BlockCache) Get(height int) *walletrpc.CompactBlock {
c.mutex.RLock()
defer c.mutex.RUnlock()
//println("Cache get", height)
if c.LastBlock == -1 || c.FirstBlock == -1 {
return nil
}
if height < c.FirstBlock || height > c.LastBlock {
//println("Cache miss: index out of range")
return nil
}
//println("Cache returned")
serialized := &walletrpc.CompactBlock{}
err := proto.Unmarshal(c.m[height].data, serialized)
if err != nil {
println("Error unmarshalling compact block")
return nil
}
return serialized
}
func (c *BlockCache) GetLatestBlock() int {
c.mutex.RLock()
defer c.mutex.RUnlock()
return c.LastBlock
}
// ConfigureLag sets the adaptive-confirmation-lag parameters. Called once at
// startup from the command-line flags.
func (c *BlockCache) ConfigureLag(adaptive bool, windowMinutes, min, max int) {
c.mutex.Lock()
defer c.mutex.Unlock()
c.adaptiveLag = adaptive
c.lagWindow = time.Duration(windowMinutes) * time.Minute
c.lagMin = min
c.lagMax = max
}
// computeLagLocked returns the confirmation lag (in blocks) to apply right now
// and prunes reorg events that have aged out of the window. The lag is the
// configured floor plus the number of reorgs seen within lagWindow, capped at
// lagMax. Caller must hold c.mutex.
func (c *BlockCache) computeLagLocked() int {
if !c.adaptiveLag {
return c.lagMin
}
cutoff := time.Now().Add(-c.lagWindow)
kept := c.reorgTimes[:0]
for _, t := range c.reorgTimes {
if t.After(cutoff) {
kept = append(kept, t)
}
}
c.reorgTimes = kept
lag := c.lagMin + len(kept)
if lag > c.lagMax {
lag = c.lagMax
}
if lag < c.lagMin {
lag = c.lagMin
}
return lag
}
// CurrentLag returns the confirmation lag currently being applied.
func (c *BlockCache) CurrentLag() int {
c.mutex.Lock()
defer c.mutex.Unlock()
return c.computeLagLocked()
}
// AdvertisedLatestBlock is the tip height lightwalletd reports to wallets: the
// real cache tip minus the adaptive confirmation lag. Wallets sync to and
// anchor shielded spends at this settled height, immune to tip reorgs. The
// internal cache and ingestor continue to track the real tip.
func (c *BlockCache) AdvertisedLatestBlock() int {
c.mutex.Lock()
defer c.mutex.Unlock()
if c.LastBlock < 0 {
return c.LastBlock
}
adv := c.LastBlock - c.computeLagLocked()
if adv < c.FirstBlock {
adv = c.FirstBlock
}
c.advTip = adv
return adv
}
// HeightAllowed reports whether height is at or below the advertised
// (lag-adjusted) tip. lightwalletd refuses to serve blocks above it so wallets
// cannot sync or anchor shielded spends into the unstable reorg zone. Cheap
// (read lock, no recompute); advTip is kept fresh by Add.
func (c *BlockCache) HeightAllowed(height int) bool {
c.mutex.RLock()
defer c.mutex.RUnlock()
if c.advTip < 0 {
return true
}
return height <= c.advTip
}