go-blockchain/difficulty/difficulty_test.go
Claude 3c76dd7070
fix(difficulty): correct LWMA algorithm and hardfork-aware target
Rewrites the LWMA difficulty algorithm to match the C++ daemon exactly:
- Uses N=60 window with linear weighting (position 1..n)
- Clamps solve times to [-6T, 6T]
- Excludes genesis block from the difficulty window
- Selects target based on hardfork: 120s pre-HF2, 240s post-HF2

On testnet, HF2 activates at height 10 (active from height 11),
doubling the target from 120s to 240s. The previous fixed 120s target
produced exactly half the expected difficulty from height 11 onward.

Integration test verifies all 2576 testnet blocks match the daemon.

Co-Authored-By: Charon <charon@lethean.io>
2026-02-21 22:32:57 +00:00

149 lines
4.7 KiB
Go

// Copyright (c) 2017-2026 Lethean (https://lt.hn)
//
// Licensed under the European Union Public Licence (EUPL) version 1.2.
// SPDX-License-Identifier: EUPL-1.2
package difficulty
import (
"math/big"
"testing"
"forge.lthn.ai/core/go-blockchain/config"
)
func TestNextDifficulty_Good(t *testing.T) {
// Synthetic test: constant block times at exactly the target interval.
// With the LWMA-1 formula, constant D gives next_D = D/n for full window.
target := config.BlockTarget
const numBlocks = 100
timestamps := make([]uint64, numBlocks)
cumulativeDiffs := make([]*big.Int, numBlocks)
baseDifficulty := big.NewInt(1000)
for i := 0; i < numBlocks; i++ {
timestamps[i] = uint64(i) * target
cumulativeDiffs[i] = new(big.Int).Mul(baseDifficulty, big.NewInt(int64(i)))
}
result := NextDifficulty(timestamps, cumulativeDiffs, target)
if result.Sign() <= 0 {
t.Fatalf("NextDifficulty returned non-positive value: %s", result)
}
// LWMA trims to last 61 entries (N+1=61), giving n=60 intervals.
// Formula: D/n = 1000/60 = 16.
expected := big.NewInt(16)
if result.Cmp(expected) != 0 {
t.Errorf("NextDifficulty with constant intervals: got %s, expected %s", result, expected)
}
}
func TestNextDifficultyEmpty_Good(t *testing.T) {
// Empty input should return starter difficulty.
result := NextDifficulty(nil, nil, config.BlockTarget)
if result.Cmp(StarterDifficulty) != 0 {
t.Errorf("NextDifficulty(nil, nil, %d) = %s, want %s", config.BlockTarget, result, StarterDifficulty)
}
}
func TestNextDifficultySingleEntry_Good(t *testing.T) {
// A single entry is insufficient for calculation.
timestamps := []uint64{1000}
diffs := []*big.Int{big.NewInt(100)}
result := NextDifficulty(timestamps, diffs, config.BlockTarget)
if result.Cmp(StarterDifficulty) != 0 {
t.Errorf("NextDifficulty with single entry = %s, want %s", result, StarterDifficulty)
}
}
func TestNextDifficultyFastBlocks_Good(t *testing.T) {
// When blocks come faster than the target, difficulty should increase
// relative to the constant-rate result.
target := config.BlockTarget
const numBlocks = 50
const actualInterval uint64 = 60 // half the target — blocks are too fast
timestamps := make([]uint64, numBlocks)
cumulativeDiffs := make([]*big.Int, numBlocks)
baseDifficulty := big.NewInt(1000)
for i := 0; i < numBlocks; i++ {
timestamps[i] = uint64(i) * actualInterval
cumulativeDiffs[i] = new(big.Int).Mul(baseDifficulty, big.NewInt(int64(i)))
}
resultFast := NextDifficulty(timestamps, cumulativeDiffs, target)
// Now compute with on-target intervals for comparison.
timestampsTarget := make([]uint64, numBlocks)
for i := 0; i < numBlocks; i++ {
timestampsTarget[i] = uint64(i) * target
}
resultTarget := NextDifficulty(timestampsTarget, cumulativeDiffs, target)
if resultFast.Cmp(resultTarget) <= 0 {
t.Errorf("fast blocks (%s) should produce higher difficulty than target-rate blocks (%s)",
resultFast, resultTarget)
}
}
func TestNextDifficultySlowBlocks_Good(t *testing.T) {
// When blocks come slower than the target, difficulty should decrease
// relative to the constant-rate result.
target := config.BlockTarget
const numBlocks = 50
const actualInterval uint64 = 240 // double the target — blocks are too slow
timestamps := make([]uint64, numBlocks)
cumulativeDiffs := make([]*big.Int, numBlocks)
baseDifficulty := big.NewInt(1000)
for i := 0; i < numBlocks; i++ {
timestamps[i] = uint64(i) * actualInterval
cumulativeDiffs[i] = new(big.Int).Mul(baseDifficulty, big.NewInt(int64(i)))
}
resultSlow := NextDifficulty(timestamps, cumulativeDiffs, target)
// Compute with on-target intervals for comparison.
timestampsTarget := make([]uint64, numBlocks)
for i := 0; i < numBlocks; i++ {
timestampsTarget[i] = uint64(i) * target
}
resultTarget := NextDifficulty(timestampsTarget, cumulativeDiffs, target)
if resultSlow.Cmp(resultTarget) >= 0 {
t.Errorf("slow blocks (%s) should produce lower difficulty than target-rate blocks (%s)",
resultSlow, resultTarget)
}
}
func TestNextDifficulty_Ugly(t *testing.T) {
// Two entries with zero time span — should handle gracefully.
timestamps := []uint64{1000, 1000}
diffs := []*big.Int{big.NewInt(0), big.NewInt(100)}
result := NextDifficulty(timestamps, diffs, config.BlockTarget)
if result.Sign() <= 0 {
t.Errorf("NextDifficulty with zero time span should still return positive, got %s", result)
}
}
func TestConstants_Good(t *testing.T) {
if Window != 720 {
t.Errorf("Window: got %d, want 720", Window)
}
if Lag != 15 {
t.Errorf("Lag: got %d, want 15", Lag)
}
if Cut != 60 {
t.Errorf("Cut: got %d, want 60", Cut)
}
if BlocksCount != 735 {
t.Errorf("BlocksCount: got %d, want 735", BlocksCount)
}
if LWMAWindow != 60 {
t.Errorf("LWMAWindow: got %d, want 60", LWMAWindow)
}
}