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