diff --git a/asset/arena/quantus-arena-exporter.py b/asset/arena/quantus-arena-exporter.py index 0bd4747..68e2301 100755 --- a/asset/arena/quantus-arena-exporter.py +++ b/asset/arena/quantus-arena-exporter.py @@ -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 diff --git a/asset/grafana/quantus.json b/asset/grafana/quantus.json index e00ca0d..a1d6064 100644 --- a/asset/grafana/quantus.json +++ b/asset/grafana/quantus.json @@ -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": {