arena: per-author block gap, timestamp drift and consecutive-block series #12
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user