diff --git a/chain/difficulty.go b/chain/difficulty.go index 7f54745..3679e35 100644 --- a/chain/difficulty.go +++ b/chain/difficulty.go @@ -12,21 +12,27 @@ import ( "forge.lthn.ai/core/go-blockchain/difficulty" ) -// NextDifficulty computes the expected difficulty for the block at the given -// height, using the LWMA algorithm over stored block history. +// nextDifficultyWith computes the expected difficulty for the block at the +// given height using the LWMA algorithm, parameterised by pre/post-HF6 targets. // // The genesis block (height 0) is excluded from the difficulty window, // matching the C++ daemon's load_targetdata_cache which skips index 0. // -// The target block time depends on the hardfork schedule: 120s pre-HF2, -// 240s post-HF2 (matching DIFFICULTY_POW_TARGET_HF6 in the C++ source). -func (c *Chain) NextDifficulty(height uint64, forks []config.HardFork) (uint64, error) { +// The target block time depends on the hardfork schedule: +// - Pre-HF6: baseTarget (120s for both PoW and PoS on Lethean) +// - Post-HF6: hf6Target (240s -- halves block rate, halves emission) +// +// NOTE: This was originally gated on HF2, matching the Zano upstream where +// HF2 coincides with the difficulty target change. Lethean mainnet keeps 120s +// blocks between HF2 (height 10,080) and HF6 (height 999,999,999), so the +// gate was corrected to HF6 in March 2026. +func (c *Chain) nextDifficultyWith(height uint64, forks []config.HardFork, baseTarget, hf6Target uint64) (uint64, error) { if height == 0 { return 1, nil } // LWMA needs N+1 entries (N solve-time intervals). - // Start from height 1 — genesis is excluded from the difficulty window. + // Start from height 1 -- genesis is excluded from the difficulty window. maxLookback := difficulty.LWMAWindow + 1 lookback := min(height, maxLookback) // height excludes genesis since we start from 1 @@ -48,7 +54,7 @@ func (c *Chain) NextDifficulty(height uint64, forks []config.HardFork) (uint64, for i := range count { meta, err := c.getBlockMeta(startHeight + uint64(i)) if err != nil { - // Fewer blocks than expected — use what we have. + // Fewer blocks than expected -- use what we have. timestamps = timestamps[:i] cumulDiffs = cumulDiffs[:i] break @@ -58,12 +64,24 @@ func (c *Chain) NextDifficulty(height uint64, forks []config.HardFork) (uint64, } // Determine the target block time based on hardfork status. - // HF2 doubles the target from 120s to 240s. - target := config.DifficultyPowTarget - if config.IsHardForkActive(forks, config.HF2, height) { - target = config.DifficultyPowTargetHF6 + // HF6 doubles the target from 120s to 240s (corrected from HF2 gate). + target := baseTarget + if config.IsHardForkActive(forks, config.HF6, height) { + target = hf6Target } result := difficulty.NextDifficulty(timestamps, cumulDiffs, target) return result.Uint64(), nil } + +// NextDifficulty computes the expected PoW difficulty for the block at the +// given height. Pre-HF6 the target is 120s; post-HF6 it doubles to 240s. +func (c *Chain) NextDifficulty(height uint64, forks []config.HardFork) (uint64, error) { + return c.nextDifficultyWith(height, forks, config.DifficultyPowTarget, config.DifficultyPowTargetHF6) +} + +// NextPoSDifficulty computes the expected PoS difficulty for the block at the +// given height. Pre-HF6 the target is 120s; post-HF6 it doubles to 240s. +func (c *Chain) NextPoSDifficulty(height uint64, forks []config.HardFork) (uint64, error) { + return c.nextDifficultyWith(height, forks, config.DifficultyPosTarget, config.DifficultyPosTargetHF6) +} diff --git a/chain/difficulty_test.go b/chain/difficulty_test.go index 427991e..db9a3dd 100644 --- a/chain/difficulty_test.go +++ b/chain/difficulty_test.go @@ -14,19 +14,46 @@ import ( "github.com/stretchr/testify/require" ) -// preHF2Forks is a fork schedule where HF2 never activates, -// so the target stays at 120s. -var preHF2Forks = []config.HardFork{ +// preHF6Forks is a fork schedule where HF6 never activates, +// so both PoW and PoS targets stay at 120s. +var preHF6Forks = []config.HardFork{ {Version: config.HF0Initial, Height: 0}, } +// hf6ActiveForks is a fork schedule where HF6 activates at height 100, +// switching both PoW and PoS targets to 240s from block 101 onwards. +var hf6ActiveForks = []config.HardFork{ + {Version: config.HF0Initial, Height: 0}, + {Version: config.HF1, Height: 0}, + {Version: config.HF2, Height: 0}, + {Version: config.HF3, Height: 0}, + {Version: config.HF4Zarcanum, Height: 0}, + {Version: config.HF5, Height: 0}, + {Version: config.HF6, Height: 100}, +} + +// storeBlocks inserts count blocks with constant intervals and difficulty. +func storeBlocks(t *testing.T, c *Chain, count int, interval uint64, baseDiff uint64) { + t.Helper() + for i := uint64(0); i < uint64(count); i++ { + err := c.PutBlock(&types.Block{}, &BlockMeta{ + Hash: types.Hash{byte(i + 1)}, + Height: i, + Timestamp: i * interval, + Difficulty: baseDiff, + CumulativeDiff: baseDiff * (i + 1), + }) + require.NoError(t, err) + } +} + func TestNextDifficulty_Genesis(t *testing.T) { s, err := store.New(":memory:") require.NoError(t, err) defer s.Close() c := New(s) - diff, err := c.NextDifficulty(0, preHF2Forks) + diff, err := c.NextDifficulty(0, preHF6Forks) require.NoError(t, err) require.Equal(t, uint64(1), diff) } @@ -40,26 +67,14 @@ func TestNextDifficulty_FewBlocks(t *testing.T) { // Store genesis + 4 blocks with constant 120s intervals and difficulty 1000. // Genesis at height 0 is excluded from the LWMA window. - baseDiff := uint64(1000) - for i := uint64(0); i < 5; i++ { - err := c.PutBlock(&types.Block{}, &BlockMeta{ - Hash: types.Hash{byte(i + 1)}, - Height: i, - Timestamp: i * 120, - Difficulty: baseDiff, - CumulativeDiff: baseDiff * (i + 1), - }) - require.NoError(t, err) - } + storeBlocks(t, c, 5, 120, 1000) // Next difficulty for height 5 uses blocks 1-4 (n=3 intervals). - // LWMA formula with constant D and T gives D/n = 1000/3 ≈ 333. - diff, err := c.NextDifficulty(5, preHF2Forks) + // LWMA formula with constant D and T gives D/n = 1000/3 = 333. + diff, err := c.NextDifficulty(5, preHF6Forks) require.NoError(t, err) require.Greater(t, diff, uint64(0)) - // LWMA gives total_work * T * (n+1) / (2 * weighted_solvetimes * n). - // For constant intervals: D/n = 1000/3 = 333. expected := uint64(333) require.Equal(t, expected, diff) } @@ -71,8 +86,124 @@ func TestNextDifficulty_EmptyChain(t *testing.T) { c := New(s) - // Height 1 with no blocks stored — should return starter difficulty. - diff, err := c.NextDifficulty(1, preHF2Forks) + // Height 1 with no blocks stored -- should return starter difficulty. + diff, err := c.NextDifficulty(1, preHF6Forks) + require.NoError(t, err) + require.Equal(t, uint64(1), diff) +} + +// --- HF6 boundary tests --- + +func TestNextDifficulty_HF6Boundary_Good(t *testing.T) { + // Verify that blocks at height <= 100 use the 120s target and blocks + // at height > 100 use the 240s target, given hf6ActiveForks. + s, err := store.New(":memory:") + require.NoError(t, err) + defer s.Close() + + c := New(s) + storeBlocks(t, c, 105, 120, 1000) + + // Height 100 -- HF6 activates at heights > 100, so this is pre-HF6. + diffPre, err := c.NextDifficulty(100, hf6ActiveForks) + require.NoError(t, err) + + // Height 101 -- HF6 is active (height > 100), target becomes 240s. + diffPost, err := c.NextDifficulty(101, hf6ActiveForks) + require.NoError(t, err) + + // With 120s actual intervals and a 240s target, LWMA should produce + // lower difficulty than with a 120s target. The post-HF6 difficulty + // should differ from the pre-HF6 difficulty because the target doubled. + require.NotEqual(t, diffPre, diffPost, + "difficulty should change across HF6 boundary (120s vs 240s target)") +} + +func TestNextDifficulty_HF6Boundary_Bad(t *testing.T) { + // HF6 at height 999,999,999 (mainnet default) -- should never activate + // for realistic heights, so the target stays at 120s. + s, err := store.New(":memory:") + require.NoError(t, err) + defer s.Close() + + c := New(s) + storeBlocks(t, c, 105, 120, 1000) + + forks := config.MainnetForks + diff100, err := c.NextDifficulty(100, forks) + require.NoError(t, err) + + diff101, err := c.NextDifficulty(101, forks) + require.NoError(t, err) + + // Both should use the same 120s target -- no HF6 in sight. + require.Equal(t, diff100, diff101, + "difficulty should be identical when HF6 is far in the future") +} + +func TestNextDifficulty_HF6Boundary_Ugly(t *testing.T) { + // HF6 at height 0 (active from genesis) -- the 240s target should + // apply from the very first difficulty calculation. + s, err := store.New(":memory:") + require.NoError(t, err) + defer s.Close() + + c := New(s) + storeBlocks(t, c, 5, 240, 1000) + + genesisHF6 := []config.HardFork{ + {Version: config.HF0Initial, Height: 0}, + {Version: config.HF6, Height: 0}, + } + + diff, err := c.NextDifficulty(4, genesisHF6) + require.NoError(t, err) + require.Greater(t, diff, uint64(0)) +} + +// --- PoS difficulty tests --- + +func TestNextPoSDifficulty_Good(t *testing.T) { + s, err := store.New(":memory:") + require.NoError(t, err) + defer s.Close() + + c := New(s) + storeBlocks(t, c, 5, 120, 1000) + + // Pre-HF6: PoS target should be 120s (same as PoW). + diff, err := c.NextPoSDifficulty(5, preHF6Forks) + require.NoError(t, err) + require.Equal(t, uint64(333), diff) +} + +func TestNextPoSDifficulty_HF6Boundary_Good(t *testing.T) { + s, err := store.New(":memory:") + require.NoError(t, err) + defer s.Close() + + c := New(s) + storeBlocks(t, c, 105, 120, 1000) + + // Height 100 -- pre-HF6. + diffPre, err := c.NextPoSDifficulty(100, hf6ActiveForks) + require.NoError(t, err) + + // Height 101 -- post-HF6, target becomes 240s. + diffPost, err := c.NextPoSDifficulty(101, hf6ActiveForks) + require.NoError(t, err) + + require.NotEqual(t, diffPre, diffPost, + "PoS difficulty should change across HF6 boundary") +} + +func TestNextPoSDifficulty_Genesis(t *testing.T) { + s, err := store.New(":memory:") + require.NoError(t, err) + defer s.Close() + + c := New(s) + diff, err := c.NextPoSDifficulty(0, preHF6Forks) require.NoError(t, err) require.Equal(t, uint64(1), diff) }