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
|
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.
|
// 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
|
// Verbosity 2 expands each tx into its full object so we can read the
|
||||||
// coinbase output values without a second getrawtransaction call.
|
// coinbase output values without a second getrawtransaction call.
|
||||||
|
|||||||
@@ -98,6 +98,12 @@ type Snapshot struct {
|
|||||||
// observed block interval since the current epoch started.
|
// observed block interval since the current epoch started.
|
||||||
NextDifficultyPercent float64 `json:"next_difficulty_percent"`
|
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
|
// Bitcoin Core fields
|
||||||
Chain *bitcoind.BlockchainInfo `json:"chain"`
|
Chain *bitcoind.BlockchainInfo `json:"chain"`
|
||||||
NetworkHashrateHs float64 `json:"network_hashrate_hs"`
|
NetworkHashrateHs float64 `json:"network_hashrate_hs"`
|
||||||
@@ -172,6 +178,10 @@ type Snapshot struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
// Bitcoin's difficulty retarget window and its target block spacing.
|
||||||
|
retargetInterval = 2016
|
||||||
|
targetBlockSeconds = 600
|
||||||
|
|
||||||
maxHistoryPoints = 1440 // 24h at 1 sample/min
|
maxHistoryPoints = 1440 // 24h at 1 sample/min
|
||||||
|
|
||||||
// kvCumulativeWork is versioned: "cumulative_shares" (v1) accidentally
|
// kvCumulativeWork is versioned: "cumulative_shares" (v1) accidentally
|
||||||
@@ -279,6 +289,12 @@ type Aggregator struct {
|
|||||||
retargetEpoch int64
|
retargetEpoch int64
|
||||||
retargetStartUnix 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
|
// ckFailStreak counts consecutive refreshes where CKPool returned an
|
||||||
// error. The first failure after a streak of successes is logged at
|
// error. The first failure after a streak of successes is logged at
|
||||||
// DEBUG (likely a transient warm-up or lock hiccup); repeated failures
|
// 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 ---
|
// --- predicted difficulty adjustment ---
|
||||||
// Fetch the timestamp of the first block in the current retarget
|
// Project the retarget from the pace the epoch has kept so far,
|
||||||
// epoch (height - height%2016) once per epoch and cache it, then
|
// measured strictly between block timestamps: from the epoch's first
|
||||||
// compute (expected / elapsed - 1) * 100 where expected = blocks_in *
|
// block to the tip is exactly `inEpoch` inter-block intervals.
|
||||||
// 600 seconds. Bitcoin clamps the consensus adjustment factor to
|
//
|
||||||
// [1/4, 4x], i.e. [-75%, +300%].
|
// 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 {
|
if next.Chain != nil && next.Chain.Blocks > 0 {
|
||||||
height := next.Chain.Blocks
|
height := next.Chain.Blocks
|
||||||
epoch := height / 2016
|
epoch := height / retargetInterval
|
||||||
inEpoch := height % 2016
|
inEpoch := height % retargetInterval
|
||||||
if inEpoch > 0 {
|
if inEpoch > 0 {
|
||||||
if a.retargetEpoch != epoch || a.retargetStartUnix == 0 {
|
if a.retargetEpoch != epoch || a.retargetStartUnix == 0 {
|
||||||
startHeight := epoch * 2016
|
startHeight := epoch * retargetInterval
|
||||||
lookupCtx, cancelLookup := context.WithTimeout(ctx, 3*time.Second)
|
lookupCtx, cancelLookup := context.WithTimeout(ctx, 3*time.Second)
|
||||||
if hash, err := a.RPC.GetBlockHash(lookupCtx, startHeight); err == nil {
|
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.retargetEpoch = epoch
|
||||||
a.retargetStartUnix = blk.Time
|
a.retargetStartUnix = hdr.Time
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cancelLookup()
|
cancelLookup()
|
||||||
}
|
}
|
||||||
if a.retargetStartUnix > 0 {
|
// Tip timestamp, refetched only when the tip actually moves.
|
||||||
elapsed := float64(time.Now().Unix() - a.retargetStartUnix)
|
if next.Chain.BestBlockHash != "" && a.tipTimeHash != next.Chain.BestBlockHash {
|
||||||
// Match Bitcoin Core / mempool.space: project nActualTimespan
|
lookupCtx, cancelLookup := context.WithTimeout(ctx, 3*time.Second)
|
||||||
// by treating the elapsed window as covering (inEpoch + 1)
|
if hdr, err := a.RPC.GetBlockHeader(lookupCtx, next.Chain.BestBlockHash); err == nil {
|
||||||
// block intervals, since at retarget the consensus formula
|
a.tipTimeHash = next.Chain.BestBlockHash
|
||||||
// uses (lastBlock.time - firstBlock.time) across all 2016
|
a.tipTimeUnix = hdr.Time
|
||||||
// blocks of the epoch.
|
}
|
||||||
expected := float64(inEpoch+1) * 600
|
cancelLookup()
|
||||||
|
}
|
||||||
|
if a.retargetStartUnix > 0 && a.tipTimeUnix > 0 {
|
||||||
|
elapsed := float64(a.tipTimeUnix - a.retargetStartUnix)
|
||||||
|
next.NextDifficultyPercent = estimateRetargetPercent(inEpoch, elapsed)
|
||||||
if elapsed > 0 {
|
if elapsed > 0 {
|
||||||
factor := expected / elapsed
|
next.EpochAvgBlockSeconds = elapsed / float64(inEpoch)
|
||||||
if factor > 4 {
|
|
||||||
factor = 4
|
|
||||||
}
|
|
||||||
if factor < 0.25 {
|
|
||||||
factor = 0.25
|
|
||||||
}
|
|
||||||
next.NextDifficultyPercent = (factor - 1) * 100
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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:
|
// diffBucket returns the index [0..5] for a share difficulty:
|
||||||
//
|
//
|
||||||
// 0: < 1M 1: 1M–100M 2: 100M–1G
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,12 +36,16 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Blocks remaining + ETA until the retarget. The predicted adjustment
|
// 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(() => {
|
const retarget = $derived.by(() => {
|
||||||
if (!height) return { remaining: 2016, eta: "", progress: 0 };
|
if (!height) return { remaining: 2016, eta: "", progress: 0 };
|
||||||
const inEpoch = height % 2016;
|
const inEpoch = height % 2016;
|
||||||
const remaining = 2016 - inEpoch;
|
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;
|
const progress = (inEpoch / 2016) * 100;
|
||||||
return { remaining, eta: formatDuration(etaSec), progress };
|
return { remaining, eta: formatDuration(etaSec), progress };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -166,6 +166,9 @@ export type Snapshot = {
|
|||||||
cumulative_shares: number;
|
cumulative_shares: number;
|
||||||
next_block_reward_btc: number;
|
next_block_reward_btc: number;
|
||||||
next_difficulty_percent: 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;
|
chain: BlockchainInfo | null;
|
||||||
network_hashrate_hs: number;
|
network_hashrate_hs: number;
|
||||||
recent_blocks?: BlockRecord[];
|
recent_blocks?: BlockRecord[];
|
||||||
|
|||||||
Reference in new issue
Block a user