mirror of
https://github.com/luxfi/node.git
synced 2026-10-30 15:24:05 +00:00
The module already called itself github.com/luxfi/node while containing no node in Go: cmd/luxd was twenty-one lines that printed the size of a few types, and the Go column of the matrix measured a VM plugin. A repo that fetches the thing it claims to be has not replaced it, so the Go sources move in — 1515 files, the daemon and everything under it — and the build reaches outside this module for nothing. Rust and C++ sit beside it unchanged and are still measured against it by the differential. That is the point of one repo: the reference implementation and the two ports are the same checkout, so parity is a thing you can run rather than a thing you assert. Verified: builds, and the closure carries no lux-private — checked at build time rather than believed. The daemon starts twelve chains on a local network (P X C Q Z A B G K M F, plus D through its plugin) and reports healthy.
484 lines
15 KiB
Go
484 lines
15 KiB
Go
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package platformvm
|
|
|
|
import (
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/luxfi/database"
|
|
"github.com/luxfi/ids"
|
|
)
|
|
|
|
// fakeUptimeState implements uptime.State for testing, mirroring how the real
|
|
// platformvm state stores uptime: an up-duration plus a second-granular
|
|
// lastUpdated, returned as a "duration since the Unix epoch". Validators must be
|
|
// registered first; GetUptime/SetUptime on an unregistered node return
|
|
// database.ErrNotFound, exactly like metadata_validator.go.
|
|
type fakeUptimeState struct {
|
|
uptimes map[ids.NodeID]time.Duration
|
|
lastUpdate map[ids.NodeID]time.Time
|
|
startTimes map[ids.NodeID]time.Time
|
|
}
|
|
|
|
func newFakeUptimeState() *fakeUptimeState {
|
|
return &fakeUptimeState{
|
|
uptimes: make(map[ids.NodeID]time.Duration),
|
|
lastUpdate: make(map[ids.NodeID]time.Time),
|
|
startTimes: make(map[ids.NodeID]time.Time),
|
|
}
|
|
}
|
|
|
|
func (f *fakeUptimeState) addValidator(nodeID ids.NodeID, _ ids.ID, startTime time.Time) {
|
|
f.uptimes[nodeID] = 0
|
|
f.lastUpdate[nodeID] = startTime
|
|
f.startTimes[nodeID] = startTime
|
|
}
|
|
|
|
func (f *fakeUptimeState) GetUptime(nodeID ids.NodeID, _ ids.ID) (time.Duration, time.Duration, error) {
|
|
up, ok := f.uptimes[nodeID]
|
|
if !ok {
|
|
return 0, 0, database.ErrNotFound
|
|
}
|
|
lastUpdatedDuration := time.Duration(f.lastUpdate[nodeID].Unix()) * time.Second
|
|
return up, lastUpdatedDuration, nil
|
|
}
|
|
|
|
func (f *fakeUptimeState) SetUptime(nodeID ids.NodeID, _ ids.ID, uptime time.Duration, lastUpdated time.Time) error {
|
|
if _, ok := f.uptimes[nodeID]; !ok {
|
|
return database.ErrNotFound
|
|
}
|
|
f.uptimes[nodeID] = uptime
|
|
f.lastUpdate[nodeID] = lastUpdated
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeUptimeState) GetStartTime(nodeID ids.NodeID, _ ids.ID) (time.Time, error) {
|
|
st, ok := f.startTimes[nodeID]
|
|
if !ok {
|
|
return time.Time{}, database.ErrNotFound
|
|
}
|
|
return st, nil
|
|
}
|
|
|
|
// TestUptimeTrackerLongRunningValidatorAccruesUptime is the regression test for
|
|
// the ~165M LUX reward gate. A validator that has been staked for 30 days must
|
|
// report ~100% uptime, not 0%.
|
|
//
|
|
// This test uses ONLY the shared Calculator surface (newUptimeTracker +
|
|
// CalculateUptimePercentFrom) that both the old and the fixed tracker expose, so
|
|
// it can be run against either implementation:
|
|
// - OLD tracker: returns ~0.0 — stored upDuration is 0 and the tracker had no
|
|
// baseline for the un-measured window, so upDuration/total = 0/30d = 0.
|
|
// - FIXED tracker: returns ~1.0 — before tracking begins, a validator is
|
|
// assumed online since its last persisted update.
|
|
func TestUptimeTrackerLongRunningValidatorAccruesUptime(t *testing.T) {
|
|
require := require.New(t)
|
|
|
|
state := newFakeUptimeState()
|
|
netID := ids.GenerateTestID()
|
|
nodeID := ids.GenerateTestNodeID()
|
|
|
|
now := time.Now()
|
|
clk := func() time.Time { return now }
|
|
|
|
startTime := now.Add(-30 * 24 * time.Hour) // staked 30 days ago
|
|
state.addValidator(nodeID, netID, startTime)
|
|
|
|
tracker := newUptimeTracker(state, netID, clk)
|
|
|
|
pct, err := tracker.CalculateUptimePercentFrom(nodeID, netID, startTime)
|
|
require.NoError(err)
|
|
require.InDelta(1.0, pct, 0.001, "a continuously-staked validator must report ~100%% uptime, not 0%%")
|
|
}
|
|
|
|
// TestUptimeTrackerStartTrackingBaselines verifies that StartTracking credits the
|
|
// un-measured pre-tracking window (assuming the validator was online) and moves
|
|
// the tracker into live-tracking mode.
|
|
func TestUptimeTrackerStartTrackingBaselines(t *testing.T) {
|
|
require := require.New(t)
|
|
|
|
state := newFakeUptimeState()
|
|
netID := ids.GenerateTestID()
|
|
nodeID := ids.GenerateTestNodeID()
|
|
|
|
now := time.Now().Truncate(time.Second)
|
|
clk := func() time.Time { return now }
|
|
|
|
startTime := now.Add(-time.Hour)
|
|
state.addValidator(nodeID, netID, startTime)
|
|
|
|
tracker := newUptimeTracker(state, netID, clk)
|
|
require.False(tracker.StartedTracking())
|
|
|
|
require.NoError(tracker.StartTracking([]ids.NodeID{nodeID}))
|
|
require.True(tracker.StartedTracking())
|
|
|
|
// The hour since lastUpdated (== startTime) is baked into the persisted
|
|
// up-duration, and lastUpdated advanced to now.
|
|
require.Equal(time.Hour, state.uptimes[nodeID])
|
|
require.Equal(now.Unix(), state.lastUpdate[nodeID].Unix())
|
|
}
|
|
|
|
// TestUptimeTrackerContinuouslyConnectedClimbs is the core behavioral fix: a
|
|
// validator that connects (during bootstrap) and stays connected accrues uptime
|
|
// over time WITHOUT ever disconnecting, and IsConnected reports true.
|
|
func TestUptimeTrackerContinuouslyConnectedClimbs(t *testing.T) {
|
|
require := require.New(t)
|
|
|
|
state := newFakeUptimeState()
|
|
netID := ids.GenerateTestID()
|
|
nodeID := ids.GenerateTestNodeID()
|
|
|
|
now := time.Now().Truncate(time.Second)
|
|
clk := func() time.Time { return now }
|
|
|
|
startTime := now
|
|
state.addValidator(nodeID, netID, startTime)
|
|
|
|
tracker := newUptimeTracker(state, netID, clk)
|
|
|
|
// Peer connects during bootstrap, before tracking starts.
|
|
tracker.Connect(nodeID)
|
|
require.True(tracker.IsConnected(nodeID))
|
|
|
|
// Normal operations begin.
|
|
require.NoError(tracker.StartTracking([]ids.NodeID{nodeID}))
|
|
|
|
// One hour passes with the validator continuously connected — no Disconnect.
|
|
now = now.Add(time.Hour)
|
|
|
|
pct, err := tracker.CalculateUptimePercent(nodeID, netID)
|
|
require.NoError(err)
|
|
require.InDelta(1.0, pct, 0.001, "continuously-connected validator must climb to ~100%%")
|
|
require.True(tracker.IsConnected(nodeID))
|
|
|
|
// Two hours in, still ~100%.
|
|
now = now.Add(time.Hour)
|
|
pct, err = tracker.CalculateUptimePercent(nodeID, netID)
|
|
require.NoError(err)
|
|
require.InDelta(1.0, pct, 0.001)
|
|
}
|
|
|
|
// TestUptimeTrackerConnectDisconnectFlush verifies that, once tracking, a
|
|
// connected session is flushed into persistent state on Disconnect.
|
|
func TestUptimeTrackerConnectDisconnectFlush(t *testing.T) {
|
|
require := require.New(t)
|
|
|
|
state := newFakeUptimeState()
|
|
netID := ids.GenerateTestID()
|
|
nodeID := ids.GenerateTestNodeID()
|
|
|
|
now := time.Now().Truncate(time.Second)
|
|
clk := func() time.Time { return now }
|
|
|
|
startTime := now
|
|
state.addValidator(nodeID, netID, startTime)
|
|
|
|
tracker := newUptimeTracker(state, netID, clk)
|
|
require.NoError(tracker.StartTracking([]ids.NodeID{nodeID}))
|
|
require.Equal(time.Duration(0), state.uptimes[nodeID])
|
|
|
|
tracker.Connect(nodeID)
|
|
now = now.Add(30 * time.Minute)
|
|
mutated, err := tracker.Disconnect(nodeID)
|
|
require.NoError(err)
|
|
require.True(mutated) // a tracked validator's session flush writes uptime
|
|
|
|
require.Equal(30*time.Minute, state.uptimes[nodeID])
|
|
require.False(tracker.IsConnected(nodeID))
|
|
|
|
// After disconnect, no further live-session bonus; percent reflects 30m/60m.
|
|
now = now.Add(30 * time.Minute)
|
|
pct, err := tracker.CalculateUptimePercent(nodeID, netID)
|
|
require.NoError(err)
|
|
require.InDelta(0.5, pct, 0.001)
|
|
}
|
|
|
|
// TestUptimeTrackerDisconnectBeforeTrackingIsNoWrite is the regression test for
|
|
// the empty-commit elimination (RED round-2 LOW). A peer disconnect during
|
|
// bootstrap — before StartTracking — must report mutated=false so VM.Disconnected
|
|
// skips an empty state.Commit (a full-write+fsync). Under bootstrap churn that
|
|
// commit ran on every disconnect for no reason.
|
|
func TestUptimeTrackerDisconnectBeforeTrackingIsNoWrite(t *testing.T) {
|
|
require := require.New(t)
|
|
|
|
state := newFakeUptimeState()
|
|
netID := ids.GenerateTestID()
|
|
nodeID := ids.GenerateTestNodeID()
|
|
|
|
now := time.Now().Truncate(time.Second)
|
|
clk := func() time.Time { return now }
|
|
|
|
state.addValidator(nodeID, netID, now)
|
|
|
|
tracker := newUptimeTracker(state, netID, clk)
|
|
// Deliberately NO StartTracking — we are still bootstrapping.
|
|
|
|
tracker.Connect(nodeID)
|
|
now = now.Add(30 * time.Minute)
|
|
|
|
mutated, err := tracker.Disconnect(nodeID)
|
|
require.NoError(err)
|
|
require.False(mutated) // no write → VM.Disconnected must skip Commit
|
|
require.False(tracker.IsConnected(nodeID))
|
|
require.Equal(time.Duration(0), state.uptimes[nodeID]) // untouched
|
|
}
|
|
|
|
// TestUptimeTrackerDisconnectedValidatorGetsZero verifies that, once tracking, a
|
|
// validator that never connects earns 0% uptime (it is offline from this node's
|
|
// perspective) — the property that lets the reward gate withhold rewards.
|
|
func TestUptimeTrackerDisconnectedValidatorGetsZero(t *testing.T) {
|
|
require := require.New(t)
|
|
|
|
state := newFakeUptimeState()
|
|
netID := ids.GenerateTestID()
|
|
nodeID := ids.GenerateTestNodeID()
|
|
|
|
now := time.Now().Truncate(time.Second)
|
|
clk := func() time.Time { return now }
|
|
|
|
startTime := now
|
|
state.addValidator(nodeID, netID, startTime)
|
|
|
|
tracker := newUptimeTracker(state, netID, clk)
|
|
require.NoError(tracker.StartTracking([]ids.NodeID{nodeID}))
|
|
|
|
now = now.Add(time.Hour) // an hour passes, never connected
|
|
|
|
pct, err := tracker.CalculateUptimePercent(nodeID, netID)
|
|
require.NoError(err)
|
|
require.InDelta(0.0, pct, 0.001, "never-connected validator (while tracking) must earn 0%%")
|
|
require.False(tracker.IsConnected(nodeID))
|
|
}
|
|
|
|
// TestUptimeTrackerStopTrackingFlushesAll verifies that StopTracking persists all
|
|
// connected validators' sessions and leaves tracking mode.
|
|
func TestUptimeTrackerStopTrackingFlushesAll(t *testing.T) {
|
|
require := require.New(t)
|
|
|
|
state := newFakeUptimeState()
|
|
netID := ids.GenerateTestID()
|
|
|
|
now := time.Now().Truncate(time.Second)
|
|
clk := func() time.Time { return now }
|
|
|
|
const numValidators = 10
|
|
nodeIDs := make([]ids.NodeID, numValidators)
|
|
for i := range nodeIDs {
|
|
nodeIDs[i] = ids.GenerateTestNodeID()
|
|
state.addValidator(nodeIDs[i], netID, now)
|
|
}
|
|
|
|
tracker := newUptimeTracker(state, netID, clk)
|
|
require.NoError(tracker.StartTracking(nodeIDs))
|
|
for _, nid := range nodeIDs {
|
|
tracker.Connect(nid)
|
|
}
|
|
|
|
now = now.Add(3 * time.Minute)
|
|
require.NoError(tracker.StopTracking(nodeIDs))
|
|
require.False(tracker.StartedTracking())
|
|
|
|
for _, nid := range nodeIDs {
|
|
require.Equal(3*time.Minute, state.uptimes[nid])
|
|
}
|
|
}
|
|
|
|
// TestUptimeTrackerDoubleStartTracking verifies StartTracking is not re-entrant.
|
|
func TestUptimeTrackerDoubleStartTracking(t *testing.T) {
|
|
require := require.New(t)
|
|
|
|
state := newFakeUptimeState()
|
|
netID := ids.GenerateTestID()
|
|
nodeID := ids.GenerateTestNodeID()
|
|
now := time.Now().Truncate(time.Second)
|
|
|
|
state.addValidator(nodeID, netID, now)
|
|
tracker := newUptimeTracker(state, netID, func() time.Time { return now })
|
|
|
|
require.NoError(tracker.StartTracking([]ids.NodeID{nodeID}))
|
|
require.ErrorIs(tracker.StartTracking([]ids.NodeID{nodeID}), errAlreadyStartedTracking)
|
|
}
|
|
|
|
// TestUptimeTrackerStopWithoutStart verifies StopTracking errors before start.
|
|
func TestUptimeTrackerStopWithoutStart(t *testing.T) {
|
|
require := require.New(t)
|
|
|
|
state := newFakeUptimeState()
|
|
netID := ids.GenerateTestID()
|
|
now := time.Now().Truncate(time.Second)
|
|
tracker := newUptimeTracker(state, netID, func() time.Time { return now })
|
|
|
|
require.ErrorIs(tracker.StopTracking(nil), errNotStartedTracking)
|
|
}
|
|
|
|
// TestUptimeTrackerUnknownValidator verifies non-validators are handled safely:
|
|
// StartTracking/Connect/Disconnect skip them without error, and a percent query
|
|
// surfaces the not-found error.
|
|
func TestUptimeTrackerUnknownValidator(t *testing.T) {
|
|
require := require.New(t)
|
|
|
|
state := newFakeUptimeState()
|
|
netID := ids.GenerateTestID()
|
|
unknownNode := ids.GenerateTestNodeID()
|
|
|
|
now := time.Now().Truncate(time.Second)
|
|
clk := func() time.Time { return now }
|
|
|
|
tracker := newUptimeTracker(state, netID, clk)
|
|
|
|
// StartTracking must not fail on a node that has no state record.
|
|
require.NoError(tracker.StartTracking([]ids.NodeID{unknownNode}))
|
|
|
|
// Connecting then disconnecting an unknown node is a no-op, not an error, and
|
|
// must NOT report a state mutation (no uptime record to write).
|
|
tracker.Connect(unknownNode)
|
|
now = now.Add(time.Minute)
|
|
mutated, err := tracker.Disconnect(unknownNode)
|
|
require.NoError(err)
|
|
require.False(mutated)
|
|
|
|
// A percent query for an unknown validator surfaces the error.
|
|
_, err = tracker.CalculateUptimePercent(unknownNode, netID)
|
|
require.Error(err)
|
|
}
|
|
|
|
// TestUptimeTrackerWrongNet verifies a query for a different network returns 0.
|
|
func TestUptimeTrackerWrongNet(t *testing.T) {
|
|
require := require.New(t)
|
|
|
|
state := newFakeUptimeState()
|
|
netID := ids.GenerateTestID()
|
|
otherNet := ids.GenerateTestID()
|
|
nodeID := ids.GenerateTestNodeID()
|
|
|
|
now := time.Now().Truncate(time.Second)
|
|
clk := func() time.Time { return now }
|
|
|
|
state.addValidator(nodeID, netID, now.Add(-time.Hour))
|
|
tracker := newUptimeTracker(state, netID, clk)
|
|
|
|
pct, err := tracker.CalculateUptimePercent(nodeID, otherNet)
|
|
require.NoError(err)
|
|
require.Equal(0.0, pct)
|
|
|
|
up, total, err := tracker.CalculateUptime(nodeID, otherNet)
|
|
require.NoError(err)
|
|
require.Equal(time.Duration(0), up)
|
|
require.Equal(time.Duration(0), total)
|
|
}
|
|
|
|
// TestUptimeTrackerDoubleConnect verifies a duplicate Connect keeps the original
|
|
// connection time (repeated router dispatch cannot inflate uptime).
|
|
func TestUptimeTrackerDoubleConnect(t *testing.T) {
|
|
require := require.New(t)
|
|
|
|
state := newFakeUptimeState()
|
|
netID := ids.GenerateTestID()
|
|
nodeID := ids.GenerateTestNodeID()
|
|
|
|
now := time.Now().Truncate(time.Second)
|
|
clk := func() time.Time { return now }
|
|
|
|
state.addValidator(nodeID, netID, now)
|
|
tracker := newUptimeTracker(state, netID, clk)
|
|
require.NoError(tracker.StartTracking([]ids.NodeID{nodeID}))
|
|
|
|
tracker.Connect(nodeID)
|
|
now = now.Add(5 * time.Minute)
|
|
tracker.Connect(nodeID) // duplicate — must NOT reset the 5-minute-old session
|
|
now = now.Add(5 * time.Minute)
|
|
mutated, err := tracker.Disconnect(nodeID)
|
|
require.NoError(err)
|
|
require.True(mutated)
|
|
|
|
// Ten minutes total, not five.
|
|
require.Equal(10*time.Minute, state.uptimes[nodeID])
|
|
}
|
|
|
|
// TestUptimeTrackerRapidConnectDisconnect verifies rapid cycling never fabricates
|
|
// uptime beyond the exact connected intervals.
|
|
func TestUptimeTrackerRapidConnectDisconnect(t *testing.T) {
|
|
require := require.New(t)
|
|
|
|
state := newFakeUptimeState()
|
|
netID := ids.GenerateTestID()
|
|
nodeID := ids.GenerateTestNodeID()
|
|
|
|
now := time.Now().Truncate(time.Second)
|
|
clk := func() time.Time { return now }
|
|
|
|
state.addValidator(nodeID, netID, now)
|
|
tracker := newUptimeTracker(state, netID, clk)
|
|
require.NoError(tracker.StartTracking([]ids.NodeID{nodeID}))
|
|
|
|
// 100 cycles with no clock advance — zero accrual.
|
|
for i := 0; i < 100; i++ {
|
|
tracker.Connect(nodeID)
|
|
_, err := tracker.Disconnect(nodeID)
|
|
require.NoError(err)
|
|
}
|
|
require.Equal(time.Duration(0), state.uptimes[nodeID])
|
|
|
|
// 50 cycles, each holding the connection for one second.
|
|
for i := 0; i < 50; i++ {
|
|
tracker.Connect(nodeID)
|
|
now = now.Add(time.Second)
|
|
_, err := tracker.Disconnect(nodeID)
|
|
require.NoError(err)
|
|
}
|
|
require.Equal(50*time.Second, state.uptimes[nodeID])
|
|
}
|
|
|
|
// TestUptimeTrackerConcurrent races Connect/Disconnect/reads and StartTracking to
|
|
// assert there are no data races (run with -race).
|
|
func TestUptimeTrackerConcurrent(t *testing.T) {
|
|
require := require.New(t)
|
|
|
|
state := newFakeUptimeState()
|
|
netID := ids.GenerateTestID()
|
|
nodeID := ids.GenerateTestNodeID()
|
|
|
|
var clkMu sync.Mutex
|
|
now := time.Now().Truncate(time.Second)
|
|
clk := func() time.Time {
|
|
clkMu.Lock()
|
|
defer clkMu.Unlock()
|
|
return now
|
|
}
|
|
|
|
state.addValidator(nodeID, netID, now)
|
|
tracker := newUptimeTracker(state, netID, clk)
|
|
require.NoError(tracker.StartTracking([]ids.NodeID{nodeID}))
|
|
|
|
const goroutines = 100
|
|
var wg sync.WaitGroup
|
|
wg.Add(goroutines)
|
|
for i := 0; i < goroutines; i++ {
|
|
go func(idx int) {
|
|
defer wg.Done()
|
|
switch idx % 4 {
|
|
case 0:
|
|
tracker.Connect(nodeID)
|
|
case 1:
|
|
_, _ = tracker.Disconnect(nodeID)
|
|
case 2:
|
|
_, _ = tracker.CalculateUptimePercent(nodeID, netID)
|
|
case 3:
|
|
_ = tracker.IsConnected(nodeID)
|
|
}
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
clkMu.Lock()
|
|
now = now.Add(time.Minute)
|
|
clkMu.Unlock()
|
|
require.NoError(tracker.StopTracking([]ids.NodeID{nodeID}))
|
|
}
|