Files
zeekay 920ad381e0 fix: seen map pruning to prevent unbounded memory growth
Add pruneSeen() that caps nonce tracking at 10000 entries per chain.
Called on every checkpoint save. Since nonces are monotonic, only
recent nonces need tracking — old ones are definitionally confirmed.

2 new tests verify pruning behavior.
2026-04-06 23:44:16 -07:00

254 lines
6.4 KiB
Go

// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package main
import (
"context"
"encoding/json"
"math/big"
"os"
"path/filepath"
"testing"
"github.com/luxfi/bridge/opnet-watcher/plugins"
)
// stubPlugin implements plugins.ChainPlugin for testing.
type stubPlugin struct {
name string
chainID uint64
events []plugins.DepositEvent
tip uint64
}
func (s *stubPlugin) Name() string { return s.name }
func (s *stubPlugin) ChainID() uint64 { return s.chainID }
func (s *stubPlugin) PollDeposits(_ context.Context, _ uint64) ([]plugins.DepositEvent, uint64, error) {
return s.events, s.tip, nil
}
func (s *stubPlugin) QueryBacking(_ context.Context) (*big.Int, error) {
return big.NewInt(0), nil
}
func TestCheckpointSaveLoad(t *testing.T) {
dir := t.TempDir()
cpPath := filepath.Join(dir, "cp.json")
cfg := DefaultConfig()
cfg.CheckpointPath = cpPath
cfg.StartBlock = 0
cfg.SourceRPCURL = "http://localhost"
cfg.SourceBridgeAddr = "0xdead"
cfg.TeleporterAddr = "0xbeef"
cfg.SignerKeyPath = "/dev/null"
plugin := &stubPlugin{name: "test", chainID: 42}
// Create watcher, mark some deposits as seen.
w := &Watcher{
plugin: plugin,
cfg: cfg,
lastPos: 100,
seen: make(map[uint64]map[uint64]bool),
}
w.seen[42] = map[uint64]bool{1: true, 2: true, 5: true}
// Save checkpoint.
if err := w.saveCheckpoint(); err != nil {
t.Fatalf("saveCheckpoint: %v", err)
}
// Verify file exists.
data, err := os.ReadFile(cpPath)
if err != nil {
t.Fatalf("read checkpoint: %v", err)
}
var cp checkpoint
if err := json.Unmarshal(data, &cp); err != nil {
t.Fatalf("unmarshal checkpoint: %v", err)
}
if cp.LastPos != 100 {
t.Errorf("LastPos = %d, want 100", cp.LastPos)
}
if !cp.Seen[42][1] || !cp.Seen[42][2] || !cp.Seen[42][5] {
t.Errorf("seen nonces not persisted: %v", cp.Seen)
}
// Create new watcher that loads checkpoint.
w2 := &Watcher{
plugin: plugin,
cfg: cfg,
lastPos: 0,
seen: make(map[uint64]map[uint64]bool),
}
if err := w2.loadCheckpoint(); err != nil {
t.Fatalf("loadCheckpoint: %v", err)
}
if w2.lastPos != 100 {
t.Errorf("loaded lastPos = %d, want 100", w2.lastPos)
}
if !w2.seen[42][1] || !w2.seen[42][2] || !w2.seen[42][5] {
t.Errorf("loaded seen nonces wrong: %v", w2.seen)
}
// Nonce 3 was never seen.
if w2.seen[42][3] {
t.Error("nonce 3 should not be seen")
}
}
func TestCheckpointEmptyPath(t *testing.T) {
w := &Watcher{
cfg: Config{CheckpointPath: ""},
seen: make(map[uint64]map[uint64]bool),
}
// Should be no-ops, not errors.
if err := w.saveCheckpoint(); err != nil {
t.Fatalf("saveCheckpoint with empty path: %v", err)
}
if err := w.loadCheckpoint(); err != nil {
t.Fatalf("loadCheckpoint with empty path: %v", err)
}
}
func TestCheckpointLoadMissingFile(t *testing.T) {
w := &Watcher{
cfg: Config{CheckpointPath: "/tmp/nonexistent-checkpoint-test.json"},
lastPos: 50,
seen: make(map[uint64]map[uint64]bool),
}
// Should return error but not crash. lastPos should remain unchanged.
err := w.loadCheckpoint()
if err == nil {
t.Fatal("expected error for missing file")
}
if w.lastPos != 50 {
t.Errorf("lastPos changed to %d after failed load", w.lastPos)
}
}
func TestCheckpointAtomicWrite(t *testing.T) {
dir := t.TempDir()
cpPath := filepath.Join(dir, "sub", "deep", "cp.json")
w := &Watcher{
cfg: Config{CheckpointPath: cpPath},
lastPos: 200,
seen: make(map[uint64]map[uint64]bool),
}
w.seen[1] = map[uint64]bool{10: true}
// saveCheckpoint should create intermediate dirs.
if err := w.saveCheckpoint(); err != nil {
t.Fatalf("saveCheckpoint: %v", err)
}
// Tmp file should not exist (renamed).
if _, err := os.Stat(cpPath + ".tmp"); err == nil {
t.Error("tmp file should not exist after save")
}
// Final file should exist.
if _, err := os.Stat(cpPath); err != nil {
t.Fatalf("checkpoint file missing: %v", err)
}
}
func TestCheckpointPreservesHigherLastPos(t *testing.T) {
dir := t.TempDir()
cpPath := filepath.Join(dir, "cp.json")
// Write checkpoint with lastPos=50.
data, _ := json.Marshal(checkpoint{LastPos: 50, Seen: nil})
os.WriteFile(cpPath, data, 0o644)
// Load into watcher with StartBlock=100 (higher than checkpoint).
w := &Watcher{
cfg: Config{CheckpointPath: cpPath, StartBlock: 100},
lastPos: 100,
seen: make(map[uint64]map[uint64]bool),
}
if err := w.loadCheckpoint(); err != nil {
t.Fatalf("loadCheckpoint: %v", err)
}
// lastPos should stay at 100 since it's higher.
if w.lastPos != 100 {
t.Errorf("lastPos = %d, want 100 (should not regress)", w.lastPos)
}
}
func TestPruneSeenCapsMemory(t *testing.T) {
w := &Watcher{
seen: make(map[uint64]map[uint64]bool),
}
// Fill chain 1 with maxSeenPerChain + 5000 entries.
w.seen[1] = make(map[uint64]bool)
total := maxSeenPerChain + 5000
for i := uint64(1); i <= uint64(total); i++ {
w.seen[1][i] = true
}
// Chain 2 has a small number — should not be pruned.
w.seen[2] = map[uint64]bool{1: true, 2: true, 3: true}
w.pruneSeen()
if len(w.seen[1]) > maxSeenPerChain {
t.Errorf("chain 1 seen after prune = %d, want <= %d", len(w.seen[1]), maxSeenPerChain)
}
// Highest nonces should be preserved.
maxNonce := uint64(total)
if !w.seen[1][maxNonce] {
t.Errorf("highest nonce %d should be preserved", maxNonce)
}
if !w.seen[1][maxNonce-1] {
t.Errorf("second highest nonce %d should be preserved", maxNonce-1)
}
// Old nonces should be pruned.
if w.seen[1][1] {
t.Error("nonce 1 should have been pruned")
}
// Chain 2 should be untouched.
if len(w.seen[2]) != 3 {
t.Errorf("chain 2 seen = %d, want 3 (should not be pruned)", len(w.seen[2]))
}
}
func TestPruneSeenNoopWhenSmall(t *testing.T) {
w := &Watcher{
seen: map[uint64]map[uint64]bool{
1: {1: true, 2: true, 3: true},
},
}
w.pruneSeen()
if len(w.seen[1]) != 3 {
t.Errorf("expected 3 entries, got %d", len(w.seen[1]))
}
}
func TestDedupSkipsSeen(t *testing.T) {
plugin := &stubPlugin{name: "test", chainID: 42}
w := &Watcher{
plugin: plugin,
seen: make(map[uint64]map[uint64]bool),
}
// Pre-mark nonce 5 as seen.
w.seen[42] = map[uint64]bool{5: true}
ev := plugins.DepositEvent{
SrcChainID: 42,
Nonce: 5,
Amount: big.NewInt(1000),
BlockHeight: 10,
}
// processDeposit should return nil (skip) without calling relay (which would panic since relay is nil).
err := w.processDeposit(context.Background(), ev)
if err != nil {
t.Fatalf("processDeposit for seen nonce: %v", err)
}
}