Fix network diff adjustment prediction algo

This commit is contained in:
satoshi committed 2026-09-02 06:44:07 +03:00
1 parent 89e9b32efd
commit 3df739e5b3
5 files changed
+186 -28

No files matched your search

+70 -26
View File
@@ -98,6 +98,12 @@ type Snapshot struct {
// observed block interval since the current epoch started.
NextDifficultyPercent float64 `json:"next_difficulty_percent"`
// Observed mean seconds per block so far this epoch, measured
// between block timestamps. Zero before the first block of an epoch.
// The UI uses it to project the retarget ETA at the pace the network
// is actually keeping rather than a flat 600s.
EpochAvgBlockSeconds float64 `json:"epoch_avg_block_seconds,omitempty"`
// Bitcoin Core fields
Chain *bitcoind.BlockchainInfo `json:"chain"`
NetworkHashrateHs float64 `json:"network_hashrate_hs"`
@@ -172,6 +178,10 @@ type Snapshot struct {
}
const (
// Bitcoin's difficulty retarget window and its target block spacing.
retargetInterval = 2016
targetBlockSeconds = 600
maxHistoryPoints = 1440 // 24h at 1 sample/min
// kvCumulativeWork is versioned: "cumulative_shares" (v1) accidentally
@@ -279,6 +289,12 @@ type Aggregator struct {
retargetEpoch int64
retargetStartUnix int64
// Chain-tip block timestamp, refetched only when the tip hash
// changes. The retarget estimate measures between block timestamps,
// so it needs the tip's own time rather than wall-clock time.
tipTimeHash string
tipTimeUnix int64
// ckFailStreak counts consecutive refreshes where CKPool returned an
// error. The first failure after a streak of successes is logged at
// DEBUG (likely a transient warm-up or lock hiccup); repeated failures
@@ -557,44 +573,47 @@ func (a *Aggregator) refresh(ctx context.Context) {
}
// --- predicted difficulty adjustment ---
// Fetch the timestamp of the first block in the current retarget
// epoch (height - height%2016) once per epoch and cache it, then
// compute (expected / elapsed - 1) * 100 where expected = blocks_in *
// 600 seconds. Bitcoin clamps the consensus adjustment factor to
// [1/4, 4x], i.e. [-75%, +300%].
// Project the retarget from the pace the epoch has kept so far,
// measured strictly between block timestamps: from the epoch's first
// block to the tip is exactly `inEpoch` inter-block intervals.
//
// Measuring to wall-clock time instead adds the partial, in-progress
// interval to the elapsed span without adding an interval to divide
// it by. That skews the estimate toward "difficulty will fall" by
// however long it has been since the last block — which averages a
// full 600s and, a handful of blocks into an epoch, is most of the
// sample. It washes out only once enough blocks accumulate to dwarf
// it, which is why the estimate used to settle down around mid-epoch.
if next.Chain != nil && next.Chain.Blocks > 0 {
height := next.Chain.Blocks
epoch := height / 2016
inEpoch := height % 2016
epoch := height / retargetInterval
inEpoch := height % retargetInterval
if inEpoch > 0 {
if a.retargetEpoch != epoch || a.retargetStartUnix == 0 {
startHeight := epoch * 2016
startHeight := epoch * retargetInterval
lookupCtx, cancelLookup := context.WithTimeout(ctx, 3*time.Second)
if hash, err := a.RPC.GetBlockHash(lookupCtx, startHeight); err == nil {
if blk, err := a.RPC.GetBlock(lookupCtx, hash); err == nil {
if hdr, err := a.RPC.GetBlockHeader(lookupCtx, hash); err == nil {
a.retargetEpoch = epoch
a.retargetStartUnix = blk.Time
a.retargetStartUnix = hdr.Time
}
}
cancelLookup()
}
if a.retargetStartUnix > 0 {
elapsed := float64(time.Now().Unix() - a.retargetStartUnix)
// Match Bitcoin Core / mempool.space: project nActualTimespan
// by treating the elapsed window as covering (inEpoch + 1)
// block intervals, since at retarget the consensus formula
// uses (lastBlock.time - firstBlock.time) across all 2016
// blocks of the epoch.
expected := float64(inEpoch+1) * 600
// Tip timestamp, refetched only when the tip actually moves.
if next.Chain.BestBlockHash != "" && a.tipTimeHash != next.Chain.BestBlockHash {
lookupCtx, cancelLookup := context.WithTimeout(ctx, 3*time.Second)
if hdr, err := a.RPC.GetBlockHeader(lookupCtx, next.Chain.BestBlockHash); err == nil {
a.tipTimeHash = next.Chain.BestBlockHash
a.tipTimeUnix = hdr.Time
}
cancelLookup()
}
if a.retargetStartUnix > 0 && a.tipTimeUnix > 0 {
elapsed := float64(a.tipTimeUnix - a.retargetStartUnix)
next.NextDifficultyPercent = estimateRetargetPercent(inEpoch, elapsed)
if elapsed > 0 {
factor := expected / elapsed
if factor > 4 {
factor = 4
}
if factor < 0.25 {
factor = 0.25
}
next.NextDifficultyPercent = (factor - 1) * 100
next.EpochAvgBlockSeconds = elapsed / float64(inEpoch)
}
}
}
@@ -837,6 +856,31 @@ func (a *Aggregator) ResetAckedBestDiff() {
}
}
// estimateRetargetPercent projects the next difficulty adjustment from
// the pace of the current epoch: inEpoch inter-block intervals took
// elapsed seconds, both measured between block timestamps. Returns the
// percentage change, clamped to Bitcoin's consensus range of
// [-75, +300].
func estimateRetargetPercent(inEpoch int64, elapsed float64) float64 {
if inEpoch <= 0 || elapsed <= 0 {
return 0
}
// Consensus budgets retargetInterval*600 seconds for an epoch but
// spans them across retargetInterval-1 inter-block intervals — the
// long-standing off-by-one — so on-target pace is fractionally above
// 600s per block, and a perfectly on-time epoch retargets by ~0%
// rather than a hair negative.
targetPerInterval := float64(retargetInterval*targetBlockSeconds) / float64(retargetInterval-1)
factor := (float64(inEpoch) * targetPerInterval) / elapsed
if factor > 4 {
factor = 4
}
if factor < 0.25 {
factor = 0.25
}
return (factor - 1) * 100
}
// diffBucket returns the index [0..5] for a share difficulty:
//
// 0: < 1M 1: 1M–100M 2: 100M–1G
+89
View File
@@ -0,0 +1,89 @@
package state
import (
"math"
"testing"
)
// The estimate must be stable from the very first blocks of an epoch:
// three blocks on target pace should read the same ~0% as a thousand.
// The old wall-clock measurement made early readings swing wildly
// because the time since the last block — 600s on average — was counted
// into the elapsed span without a matching interval to divide it by.
func TestEstimateRetargetPercent_StableEarlyInEpoch(t *testing.T) {
// Exactly on-pace: each interval takes the consensus target.
onPace := float64(retargetInterval*targetBlockSeconds) / float64(retargetInterval-1)
for _, inEpoch := range []int64{1, 3, 10, 100, 1008, 2015} {
got := estimateRetargetPercent(inEpoch, float64(inEpoch)*onPace)
if math.Abs(got) > 0.01 {
t.Errorf("inEpoch=%d on-pace: got %+.4f%%, want ~0%%", inEpoch, got)
}
}
}
func TestEstimateRetargetPercent_Pace(t *testing.T) {
// On-target pace per inter-block interval, including the consensus
// 2016/2015 off-by-one. Expressing the cases against it keeps the
// expected percentages exact instead of approximate.
onPace := float64(retargetInterval*targetBlockSeconds) / float64(retargetInterval-1)
tests := []struct {
name string
inEpoch int64
avgSecs float64
want float64
}{
// Hashrate 1.1x target -> difficulty rises 10% to match it.
{"fast blocks raise difficulty", 500, onPace / 1.1, 10},
// Blocks taking 1.1x as long -> difficulty falls to 1/1.1.
{"slow blocks lower difficulty", 500, onPace * 1.1, -9.09},
// Consensus clamps: 10x fast is capped at +300%.
{"clamped at +300", 100, 60, 300},
// 10x slow is capped at -75%.
{"clamped at -75", 100, 6000, -75},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := estimateRetargetPercent(tc.inEpoch, float64(tc.inEpoch)*tc.avgSecs)
if math.Abs(got-tc.want) > 0.1 {
t.Errorf("got %+.2f%%, want %+.2f%%", got, tc.want)
}
})
}
}
// Guards against a divide-by-zero or a nonsense reading at the exact
// epoch boundary, where no interval has elapsed yet.
func TestEstimateRetargetPercent_Degenerate(t *testing.T) {
for _, tc := range []struct {
inEpoch int64
elapsed float64
}{{0, 0}, {0, 1000}, {5, 0}, {-1, 600}, {5, -600}} {
if got := estimateRetargetPercent(tc.inEpoch, tc.elapsed); got != 0 {
t.Errorf("inEpoch=%d elapsed=%v: got %v, want 0", tc.inEpoch, tc.elapsed, got)
}
}
}
// The bias the fix removes: measuring to wall-clock adds the partial
// interval since the last block to the elapsed span. This reproduces
// that arithmetic to show how large the resulting error was early in an
// epoch versus at the midpoint.
func TestEstimateRetargetPercent_WallClockBiasWasWorstEarly(t *testing.T) {
onPace := float64(retargetInterval*targetBlockSeconds) / float64(retargetInterval-1)
const sinceLastBlock = 600.0 // the long-run average wait
early := estimateRetargetPercent(3, 3*onPace+sinceLastBlock)
mid := estimateRetargetPercent(1008, 1008*onPace+sinceLastBlock)
if math.Abs(early) < 15 {
t.Errorf("early-epoch wall-clock error = %+.2f%%, expected a large skew", early)
}
if math.Abs(mid) > 0.5 {
t.Errorf("mid-epoch wall-clock error = %+.2f%%, expected it to have washed out", mid)
}
// And with block timestamps, that same on-pace epoch reads ~0 at
// both points — which is the actual fix.
if got := estimateRetargetPercent(3, 3*onPace); math.Abs(got) > 0.01 {
t.Errorf("block-timestamp measurement at 3 blocks = %+.4f%%, want ~0%%", got)
}
}