arena: per-author block gap, timestamp drift and consecutive-block series #12

Merged
grenade merged 1 commits from arena/gap-and-drift into main 2026-09-04 05:38:54 +00:00
2 changed files with 359 additions and 1 deletions

View File

@@ -280,6 +280,87 @@ def decode_compact(data: bytes):
return int.from_bytes(data[1 : 1 + n], "little"), 1 + n
# Inter-block gap by author, in seconds. A miner whose gaps pile into the
# lowest buckets right after other people's blocks is releasing withheld blocks
# (selfish mining); the network's own distribution is exponential around the
# target.
GAP_BUCKETS = [0.5, 1, 2, 3, 5, 8, 12, 16, 24, 32, 48, 64, 128]
# Block timestamp minus the moment this exporter first saw the block, seconds.
# Negative is ordinary propagation plus poll lag; positive means the author
# dated the block in the future, which the Homestead-style retarget rewards a
# little per block (the inherent allows up to 30 s of drift).
DRIFT_BUCKETS = [-16, -8, -4, -2, -1, -0.5, 0, 0.5, 1, 2, 4, 8, 16, 32]
class Hist:
"""A Prometheus histogram kept as raw bucket counts; rendered cumulatively."""
def __init__(self, buckets):
self.buckets = buckets
self.counts = [0] * (len(buckets) + 1)
self.sum = 0.0
self.count = 0
def observe(self, v):
i = 0
while i < len(self.buckets) and v > self.buckets[i]:
i += 1
self.counts[i] += 1
self.sum += v
self.count += 1
def merge(self, other):
for i, c in enumerate(other.counts):
self.counts[i] += c
self.sum += other.sum
self.count += other.count
return self
def render(self, out, name, labels):
acc = 0
for b, c in zip(self.buckets, self.counts):
acc += c
out.append(f'{name}_bucket{{{labels},le="{b}"}} {acc}')
acc += self.counts[-1]
out.append(f'{name}_bucket{{{labels},le="+Inf"}} {acc}')
out.append(f'{name}_sum{{{labels}}} {self.sum:.3f}')
out.append(f'{name}_count{{{labels}}} {self.count}')
def scale_compact(b: bytes, i: int):
"""Decode a SCALE compact integer at offset i; returns (value, next offset)."""
mode = b[i] & 3
if mode == 0:
return b[i] >> 2, i + 1
if mode == 1:
return int.from_bytes(b[i:i + 2], "little") >> 2, i + 2
if mode == 2:
return int.from_bytes(b[i:i + 4], "little") >> 2, i + 4
n = (b[i] >> 2) + 4
return int.from_bytes(b[i + 1:i + 1 + n], "little"), i + 1 + n
# Timestamp pallet index in the runtime (runtime/src/lib.rs, pallet_index(1)),
# call 0 = set. The inherent is always the first extrinsic of a block.
TIMESTAMP_PALLET_INDEX = 1
def timestamp_inherent_ms(extrinsic_hex: str):
"""The block author's timestamp from the `Timestamp::set` inherent, or None."""
try:
b = bytes.fromhex(extrinsic_hex[2:] if extrinsic_hex.startswith("0x") else extrinsic_hex)
_, i = scale_compact(b, 0) # length prefix
version = b[i] # 0x04 (v4 unsigned) or 0x05 (v5 bare)
if version not in (0x04, 0x05):
return None
if b[i + 1] != TIMESTAMP_PALLET_INDEX or b[i + 2] != 0:
return None
ts, _ = scale_compact(b, i + 3)
return ts
except (IndexError, ValueError):
return None
def author_preimage(header: dict):
"""Extract the miner's 32-byte reward preimage from a header's digest.
@@ -331,6 +412,12 @@ class Arena:
# blocks. Not capped to top N: a counter that appears and disappears
# loses increments, and the label set is bounded by distinct miners.
self.authored_total = {}
# Last tip block recorded: (height, author timestamp ms, preimage). Feeds
# the per-author gap, drift and consecutive-block series below.
self.last_tip = None
self.gap_hist = {} # preimage -> Hist(GAP_BUCKETS)
self.drift_hist = {} # preimage -> Hist(DRIFT_BUCKETS)
self.consecutive = {} # preimage -> blocks authored directly after its own
# Tip observations only — used for the block interval, which is real
# elapsed time between blocks and therefore only valid at the tip.
self.tip_samples = deque(maxlen=200) # (unix_ts, height)
@@ -361,6 +448,33 @@ class Arena:
self.authored_total[pre] = self.authored_total.get(pre, 0) + 1
if at_tip:
self.tip_samples.append((time.time(), height))
if at_tip:
self.observe_tip(height, pre)
def block_timestamp_ms(self, height):
"""Author timestamp of the block at `height`, from its inherent."""
try:
h = self.rpc.call("chain_getBlockHash", [height])
blk = self.rpc.call("chain_getBlock", [h]) if h else None
xts = (blk or {}).get("block", {}).get("extrinsics") or []
return timestamp_inherent_ms(xts[0]) if xts else None
except Exception as e: # noqa: BLE001 - one bad block must not stop the loop
LOG.warning("block timestamp at %s unavailable: %s", height, e)
return None
def observe_tip(self, height, pre):
ts = self.block_timestamp_ms(height)
if ts is None:
return
seen = time.time()
with self.lock:
last = self.last_tip
if last is not None and height == last[0] + 1:
self.gap_hist.setdefault(pre, Hist(GAP_BUCKETS)).observe((ts - last[1]) / 1000.0)
if last[2] == pre:
self.consecutive[pre] = self.consecutive.get(pre, 0) + 1
self.drift_hist.setdefault(pre, Hist(DRIFT_BUCKETS)).observe(ts / 1000.0 - seen)
self.last_tip = (height, ts, pre)
def poll_once(self):
health = self.rpc.call("system_health", [])
@@ -508,6 +622,52 @@ class Arena:
is_self = "true" if pre == self.self_preimage else "false"
out.append(f'quantus_blocks_authored_total{{preimage="{pre}",self="{is_self}"}} {n}')
# Per-author timing, same top-N cap as the leaderboard; everyone else
# is merged into preimage="other" and the whole network into "all".
with self.lock:
gaps = {k: v for k, v in self.gap_hist.items()}
drifts = {k: v for k, v in self.drift_hist.items()}
consecutive = dict(self.consecutive)
def labelled(hists):
shown = []
other = None
everyone = None
for pre, h in hists.items():
everyone = Hist(h.buckets).merge(h) if everyone is None else everyone.merge(h)
if pre in keep:
is_self = "true" if pre == self.self_preimage else "false"
shown.append((f'preimage="{pre}",self="{is_self}"', h))
else:
other = Hist(h.buckets).merge(h) if other is None else other.merge(h)
if other is not None:
shown.append(('preimage="other",self="false"', other))
if everyone is not None:
shown.append(('preimage="all",self="false"', everyone))
return shown
if gaps:
out.append("# HELP quantus_block_gap_seconds Seconds between a block and the previous one, by the author of the later block (author timestamps). Bursts in the lowest buckets for one author right after others' blocks are withheld blocks.")
out.append("# TYPE quantus_block_gap_seconds histogram")
for labels, h in labelled(gaps):
h.render(out, "quantus_block_gap_seconds", labels)
if drifts:
out.append("# HELP quantus_block_timestamp_drift_seconds Block timestamp minus the time this exporter first saw the block, by author. Negative is propagation and poll lag; positive is a future-dated block.")
out.append("# TYPE quantus_block_timestamp_drift_seconds histogram")
for labels, h in labelled(drifts):
h.render(out, "quantus_block_timestamp_drift_seconds", labels)
if consecutive:
out.append("# HELP quantus_consecutive_self_blocks_total Blocks authored directly after the same author's previous block, since exporter start.")
out.append("# TYPE quantus_consecutive_self_blocks_total counter")
other_n = 0
for pre, n in sorted(consecutive.items(), key=lambda kv: kv[1], reverse=True):
if pre in keep:
is_self = "true" if pre == self.self_preimage else "false"
out.append(f'quantus_consecutive_self_blocks_total{{preimage="{pre}",self="{is_self}"}} {n}')
else:
other_n += n
out.append(f'quantus_consecutive_self_blocks_total{{preimage="other",self="false"}} {other_n}')
if self.balance is not None and self.decimals is not None:
free, reserved, frozen, nonce = self.balance
scale = 10 ** self.decimals

View File

@@ -9,7 +9,7 @@
],
"timezone": "browser",
"schemaVersion": 39,
"version": 12,
"version": 13,
"refresh": "30s",
"time": {
"from": "now-6h",
@@ -3278,6 +3278,204 @@
"sort": "desc"
}
}
},
{
"type": "row",
"title": "Author timing (what the other miners are doing)",
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 149
},
"panels": []
},
{
"type": "timeseries",
"title": "Shortest gaps by author (p10, 6h)",
"description": "10th percentile of the time between a block and the previous one, by the author of the later block. The network's gaps are exponential around the target; an author whose p10 sits near zero is releasing blocks right after other people's, which is what withheld blocks look like.",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"h": 9,
"w": 8,
"x": 0,
"y": 150
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "s",
"custom": {
"lineWidth": 1,
"fillOpacity": 0,
"spanNulls": true
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "histogram_quantile(0.10, sum by (preimage, le) (rate(quantus_block_gap_seconds_bucket{preimage!=\"other\"}[6h])))",
"legendFormat": "{{preimage}}",
"range": true,
"refId": "A"
}
],
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"type": "timeseries",
"title": "Timestamp drift by author (median, 6h)",
"description": "Block timestamp minus the moment the arena exporter first saw the block. Negative is propagation plus poll lag (a few seconds). Positive is a future-dated block; the retarget allows up to 30 s and rewards it slightly.",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"h": 9,
"w": 8,
"x": 8,
"y": 150
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "s",
"custom": {
"lineWidth": 1,
"fillOpacity": 0,
"spanNulls": true
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "histogram_quantile(0.5, sum by (preimage, le) (rate(quantus_block_timestamp_drift_seconds_bucket{preimage!=\"other\"}[6h])))",
"legendFormat": "{{preimage}}",
"range": true,
"refId": "A"
}
],
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"type": "timeseries",
"title": "Consecutive self-blocks per hour",
"description": "Blocks authored directly after the same author's previous block. Expected rate is roughly (share)^2 of blocks; persistently above that means blocks are being held and released together.",
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"gridPos": {
"h": 9,
"w": 8,
"x": 16,
"y": 150
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"unit": "short",
"custom": {
"lineWidth": 1,
"fillOpacity": 0,
"spanNulls": true
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
}
},
"overrides": []
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"editorMode": "code",
"expr": "sum by (preimage) (increase(quantus_consecutive_self_blocks_total{preimage!=\"other\"}[1h]))",
"legendFormat": "{{preimage}}",
"range": true,
"refId": "A"
}
],
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
}
],
"annotations": {