From 3df739e5b33c5c857bda2fe2dcdafb294791274e Mon Sep 17 00:00:00 2001 From: satoshi Date: Wed, 2 Sep 2026 06:44:07 +0300 Subject: [PATCH] Fix network diff adjustment prediction algo --- api/internal/bitcoind/rpc.go | 18 ++++++ api/internal/state/aggregator.go | 96 +++++++++++++++++++++-------- api/internal/state/retarget_test.go | 89 ++++++++++++++++++++++++++ ui/src/lib/PoolOverview.svelte | 8 ++- ui/src/types.ts | 3 + 5 files changed, 186 insertions(+), 28 deletions(-) create mode 100644 api/internal/state/retarget_test.go diff --git a/api/internal/bitcoind/rpc.go b/api/internal/bitcoind/rpc.go index ccd7d61..098a4cd 100644 --- a/api/internal/bitcoind/rpc.go +++ b/api/internal/bitcoind/rpc.go @@ -205,6 +205,24 @@ func (c *RPC) GetBlockHash(ctx context.Context, height int64) (string, error) { return out, nil } +// BlockHeader is the subset of `getblockheader ` we care about. +type BlockHeader struct { + Hash string `json:"hash"` + Height int64 `json:"height"` + Time int64 `json:"time"` +} + +// GetBlockHeader returns a block's header. Preferred over GetBlock when +// only header fields are needed: getblock at verbosity 2 expands every +// transaction in the block, which for a full block is megabytes of JSON. +func (c *RPC) GetBlockHeader(ctx context.Context, hash string) (*BlockHeader, error) { + var out BlockHeader + if err := c.Call(ctx, "getblockheader", []any{hash, true}, &out); err != nil { + return nil, err + } + return &out, nil +} + // BlockVerbose2 is the subset of `getblock 2` we care about. // Verbosity 2 expands each tx into its full object so we can read the // coinbase output values without a second getrawtransaction call. diff --git a/api/internal/state/aggregator.go b/api/internal/state/aggregator.go index 8723da7..e558a15 100644 --- a/api/internal/state/aggregator.go +++ b/api/internal/state/aggregator.go @@ -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 diff --git a/api/internal/state/retarget_test.go b/api/internal/state/retarget_test.go new file mode 100644 index 0000000..3317a2c --- /dev/null +++ b/api/internal/state/retarget_test.go @@ -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) + } +} diff --git a/ui/src/lib/PoolOverview.svelte b/ui/src/lib/PoolOverview.svelte index d463c1e..61cd7d8 100644 --- a/ui/src/lib/PoolOverview.svelte +++ b/ui/src/lib/PoolOverview.svelte @@ -36,12 +36,16 @@ }); // Blocks remaining + ETA until the retarget. The predicted adjustment - // percent comes from the backend (computed from the epoch start time). + // percent comes from the backend (measured between block timestamps + // from the epoch's first block), as does the epoch's observed mean + // block time — projecting the ETA at the pace the network is actually + // keeping rather than a flat 600s, which is what mempool.space shows. const retarget = $derived.by(() => { if (!height) return { remaining: 2016, eta: "", progress: 0 }; const inEpoch = height % 2016; const remaining = 2016 - inEpoch; - const etaSec = remaining * 600; + const avgBlockSec = data.epoch_avg_block_seconds || 600; + const etaSec = remaining * avgBlockSec; const progress = (inEpoch / 2016) * 100; return { remaining, eta: formatDuration(etaSec), progress }; }); diff --git a/ui/src/types.ts b/ui/src/types.ts index 52e461b..b8db458 100644 --- a/ui/src/types.ts +++ b/ui/src/types.ts @@ -166,6 +166,9 @@ export type Snapshot = { cumulative_shares: number; next_block_reward_btc: number; next_difficulty_percent: number; + // Observed mean seconds per block so far this epoch, measured between + // block timestamps. Absent before the first block of an epoch. + epoch_avg_block_seconds?: number; chain: BlockchainInfo | null; network_hashrate_hs: number; recent_blocks?: BlockRecord[];