Fix network diff adjustment prediction algo
This commit is contained in:
5 files changed
+186
-28
No files matched your search
@@ -205,6 +205,24 @@ func (c *RPC) GetBlockHash(ctx context.Context, height int64) (string, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// BlockHeader is the subset of `getblockheader <hash>` 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 <hash> 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user