From ca94e3a6c0de32bfcf9165b706738153a14280c7 Mon Sep 17 00:00:00 2001 From: Rob Thijssen Date: Tue, 1 Sep 2026 04:07:03 +0300 Subject: [PATCH] feat: track accrued rewards in the arena exporter quantus-node has no balance subcommand, and the faucet_getAccountInfo RPC that MINING.md documents is absent from this build -- rpc_methods returns only account_nextIndex and system_accountNextIndex. So the exporter builds the System.Account storage key itself: twox128(System) ++ twox128(Account) ++ blake2_128_concat(account). XXH64 is inlined because python3-xxhash is not installed on the node hosts and this exporter stays dependency-free. It asserts the two universally-known prefixes at import, so a broken implementation fails loudly instead of silently querying the wrong key. The reward address is Poseidon2(inner_hash). Rather than reimplement Poseidon2 over Goldilocks, the deploy lifts it from the node's own startup log; empty is tolerated and the exporter simply omits balance metrics, which is what a genuinely first deploy will see. Verified live: 158.153404340522 PLK, 12 decimals, zero RPC errors. Also: str.replace fails silently on a missing anchor, which is how the first attempt shipped an exporter calling a method that was never inserted. Edits to this file now assert their anchors. --- .gitea/workflows/deploy.yaml | 21 ++- asset/arena/quantus-arena-exporter.py | 148 +++++++++++++++++++++- asset/config/arena.env.tmpl | 7 + asset/grafana/quantus.json | 176 +++++++++++++++++++++++++- asset/systemd/quantus-arena.service | 3 +- 5 files changed, 350 insertions(+), 5 deletions(-) diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index 89e462f..fc39fe3 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -222,7 +222,7 @@ jobs: pathlib.Path("node.env").write_text(out) a = pathlib.Path("asset/config/arena.env.tmpl").read_text() a = a.replace("{{QUANTUS_INNER_HASH}}", os.environ["QUANTUS_INNER_HASH"]) - pathlib.Path("arena.env").write_text(a) + pathlib.Path("arena.env.partial").write_text(a) PY - name: deploy node @@ -288,6 +288,25 @@ jobs: gitea_ci@"$HOST":/usr/local/bin/quantus-arena-exporter.py push --chmod=F0644 asset/systemd/quantus-arena.service \ gitea_ci@"$HOST":/etc/systemd/system/quantus-arena.service + # The reward address is Poseidon2(inner_hash); rather than reimplement + # that, take it from the node's own startup log, which prints it. Empty + # is tolerated — on a genuinely first deploy the node has not logged it + # yet, the exporter omits balance metrics, and the next deploy fills it. + run "journalctl -u quantus-node.service --no-pager" > node-journal.txt + if grep -qF "Rewards wormhole address" node-journal.txt; then + reward_addr=$(grep -F "Rewards wormhole address" node-journal.txt \ + | tail -1 | sed 's/.*address: //' | tr -d '[:space:]') + echo "reward address: ${reward_addr}" + else + reward_addr="" + echo "WARN no reward address in the node journal yet — balance metrics will be omitted" >&2 + fi + REWARD_ADDR="$reward_addr" python3 - <<'PY' + import os, pathlib + a = pathlib.Path("arena.env.partial").read_text() + a = a.replace("{{QUANTUS_REWARD_ADDRESS}}", os.environ["REWARD_ADDR"]) + pathlib.Path("arena.env").write_text(a) + PY push --chown=root:quantus-arena --chmod=F0640 arena.env \ gitea_ci@"$HOST":/etc/quantus-arena/arena.env pushfw --mkpath --chmod=F0644 asset/firewalld/quantus-arena.xml \ diff --git a/asset/arena/quantus-arena-exporter.py b/asset/arena/quantus-arena-exporter.py index 6a19289..a6304f8 100755 --- a/asset/arena/quantus-arena-exporter.py +++ b/asset/arena/quantus-arena-exporter.py @@ -15,6 +15,7 @@ and talks to its loopback RPC. """ import argparse +import hashlib import http.server import json import logging @@ -31,6 +32,98 @@ POW_ENGINE_ID = "706f775f" # b"pow_" DIGEST_PRE_RUNTIME = "06" # DigestItem::PreRuntime discriminant + +# --- Substrate storage-key helpers ----------------------------------------- +# +# quantus-node has no balance subcommand, and the faucet_getAccountInfo RPC that +# MINING.md documents is absent from this build (`rpc_methods` returns only +# account_nextIndex / system_accountNextIndex). So the System.Account key is +# built by hand. XXH64 is inlined because python3-xxhash is not installed on the +# node hosts and this exporter is deliberately dependency-free. + +_M = (1 << 64) - 1 +_P1, _P2, _P3, _P4, _P5 = (0x9E3779B185EBCA87, 0xC2B2AE3D27D4EB4F, + 0x165667B19E3779F9, 0x85EBCA77C2B2AE63, 0x27D4EB2F165667C5) + + +def _rotl(x, r): + return ((x << r) | (x >> (64 - r))) & _M + + +def _xround(acc, inp): + acc = (acc + (inp * _P2 & _M)) & _M + return (_rotl(acc, 31) * _P1) & _M + + +def _xmerge(acc, val): + acc ^= _xround(0, val) + return (acc * _P1 + _P4) & _M + + +def xxh64(data: bytes, seed: int = 0) -> int: + n, i = len(data), 0 + if n >= 32: + v = [(seed + _P1 + _P2) & _M, (seed + _P2) & _M, seed & _M, (seed - _P1) & _M] + while i + 32 <= n: + for j in range(4): + v[j] = _xround(v[j], int.from_bytes(data[i:i + 8], "little")) + i += 8 + h = (_rotl(v[0], 1) + _rotl(v[1], 7) + _rotl(v[2], 12) + _rotl(v[3], 18)) & _M + for j in range(4): + h = _xmerge(h, v[j]) + else: + h = (seed + _P5) & _M + h = (h + n) & _M + while i + 8 <= n: + h ^= _xround(0, int.from_bytes(data[i:i + 8], "little")) + h = (_rotl(h, 27) * _P1 + _P4) & _M + i += 8 + if i + 4 <= n: + h ^= (int.from_bytes(data[i:i + 4], "little") * _P1) & _M + h = (_rotl(h, 23) * _P2 + _P3) & _M + i += 4 + while i < n: + h ^= (data[i] * _P5) & _M + h = (_rotl(h, 11) * _P1) & _M + i += 1 + h ^= h >> 33 + h = (h * _P2) & _M + h ^= h >> 29 + h = (h * _P3) & _M + h ^= h >> 32 + return h + + +def twox128(b: bytes) -> bytes: + return xxh64(b, 0).to_bytes(8, "little") + xxh64(b, 1).to_bytes(8, "little") + + +# Fail loudly at import rather than silently querying a wrong key: these two +# prefixes are fixed across every Substrate chain in existence. +assert twox128(b"System").hex() == "26aa394eea5630e07c48ae0c9558cef7" +assert twox128(b"Account").hex() == "b99d880ec681799c0cf30e8886371da9" + +_B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + + +def ss58_to_account(addr: str) -> bytes: + """SS58 -> 32-byte account id. Works for any address-type prefix length, + because the account id is always the 32 bytes before the 2-byte checksum.""" + n = 0 + for c in addr: + n = n * 58 + _B58.index(c) + raw = n.to_bytes((n.bit_length() + 7) // 8, "big") + raw = b"\0" * (len(addr) - len(addr.lstrip("1"))) + raw + if len(raw) < 34: + raise ValueError(f"not a plausible SS58 address: {addr}") + return raw[:-2][-32:] + + +def account_storage_key(account: bytes) -> str: + return "0x" + (twox128(b"System") + twox128(b"Account") + + hashlib.blake2b(account, digest_size=16).digest() + account).hex() + + class RpcError(Exception): pass @@ -102,8 +195,14 @@ def u512_le(hex_str: str) -> int: class Arena: - def __init__(self, rpc, self_preimage, window_blocks, target_block_time, top_n): + def __init__(self, rpc, self_preimage, window_blocks, target_block_time, top_n, + reward_address=""): self.rpc = rpc + self.reward_address = reward_address or None + self.balance = None # (free, reserved, frozen, nonce) + self.decimals = None + self.symbol = None + self._balance_due = 0.0 self.self_preimage = (self_preimage or "").lower() or None self.top_n = top_n self.target_block_time = target_block_time @@ -161,11 +260,38 @@ class Arena: self.last_height = height self.difficulty = u512_le(self.rpc.call("state_call", ["QPoWApi_get_difficulty", "0x"])) + + if self.decimals is None: + props = self.rpc.call("system_properties", []) + self.decimals = props.get("tokenDecimals") + self.symbol = props.get("tokenSymbol") + + # Mining rewards land at most once per block; polling this as often as + # the head would be pointless load on the node for no extra resolution. + if self.reward_address and time.time() >= self._balance_due: + self._balance_due = time.time() + 30 + self.poll_balance() if self.max_difficulty is None: self.max_difficulty = u512_le( self.rpc.call("state_call", ["QPoWApi_get_max_difficulty", "0x"]) ) + def poll_balance(self): + acct = ss58_to_account(self.reward_address) + raw = self.rpc.call("state_getStorage", [account_storage_key(acct)]) + if raw is None: + # No account record at all — never received funds. Zero is the + # honest answer here, not "unknown". + self.balance = (0, 0, 0, 0) + return + b = bytes.fromhex(raw[2:]) + self.balance = ( + int.from_bytes(b[16:32], "little"), # free + int.from_bytes(b[32:48], "little"), # reserved + int.from_bytes(b[48:64], "little"), # frozen + int.from_bytes(b[0:4], "little"), # nonce + ) + # ---- derived ---------------------------------------------------------- def snapshot(self): @@ -254,6 +380,22 @@ class Arena: shown = sum(keep.values()) out.append(f"quantus_blocks_authored{{preimage=\"other\",self=\"false\"}} {total - shown}") + if self.balance is not None and self.decimals is not None: + free, reserved, frozen, nonce = self.balance + scale = 10 ** self.decimals + lbl = f'{{address="{self.reward_address}",symbol="{self.symbol}"}}' + g("quantus_reward_balance_planck", + "Mining rewards at our wormhole address, in the chain's smallest unit.", + free, lbl) + g("quantus_reward_balance", + "Mining rewards at our wormhole address, in whole tokens.", + free / scale, lbl) + g("quantus_reward_balance_reserved", "Reserved balance, smallest unit.", reserved, lbl) + g("quantus_reward_balance_frozen", "Frozen balance, smallest unit.", frozen, lbl) + g("quantus_reward_account_nonce", "Nonce of the reward account.", nonce, lbl) + if self.decimals is not None: + g("quantus_token_decimals", "Chain token decimals.", self.decimals) + if self.self_preimage: mine = counts.get(self.self_preimage, 0) g("quantus_blocks_authored_self", "Blocks we authored in the window.", mine) @@ -297,13 +439,15 @@ def main(): help="rolling leaderboard window, in blocks (~6h at a 6s target)") p.add_argument("--target-block-time", type=float, default=6.0) p.add_argument("--top-n", type=int, default=25) + p.add_argument("--reward-address", default="", + help="our wormhole SS58 address, to track accrued rewards") p.add_argument("--self-preimage", default="", help="our own --rewards-inner-hash, to label and score our share") args = p.parse_args() logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") arena = Arena(Rpc(args.rpc_url), args.self_preimage, args.window_blocks, - args.target_block_time, args.top_n) + args.target_block_time, args.top_n, args.reward_address) Handler.arena = arena def loop(): diff --git a/asset/config/arena.env.tmpl b/asset/config/arena.env.tmpl index 9bcb10d..17d00d6 100644 --- a/asset/config/arena.env.tmpl +++ b/asset/config/arena.env.tmpl @@ -3,3 +3,10 @@ # label our series and compute our observed share. Public data — it is in the # header of every block we author (doc/wormhole-rewards.md). QUANTUS_INNER_HASH={{QUANTUS_INNER_HASH}} + +# Our wormhole SS58 address, so the exporter can report accrued rewards. +# Substituted at deploy time from the node's own startup log rather than +# committed: it is Poseidon2(inner_hash), and deriving that here would mean +# reimplementing Poseidon2 over Goldilocks in the exporter for no gain. Empty is +# tolerated — the exporter simply omits the balance metrics. +QUANTUS_REWARD_ADDRESS={{QUANTUS_REWARD_ADDRESS}} diff --git a/asset/grafana/quantus.json b/asset/grafana/quantus.json index 5229e2b..08bfc05 100644 --- a/asset/grafana/quantus.json +++ b/asset/grafana/quantus.json @@ -9,7 +9,7 @@ ], "timezone": "browser", "schemaVersion": 39, - "version": 3, + "version": 4, "refresh": "30s", "time": { "from": "now-6h", @@ -1734,6 +1734,180 @@ "sort": "none" } } + }, + { + "type": "row", + "title": "Rewards", + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 72 + }, + "panels": [] + }, + { + "type": "stat", + "title": "Accrued rewards", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "gridPos": { + "h": 5, + "w": 8, + "x": 0, + "y": 73 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "quantus_reward_balance", + "legendFormat": "{{symbol}}", + "range": true, + "refId": "A" + } + ], + "description": "Free balance at our wormhole address, read from System.Account. Worth nothing until swaps open \u2014 expected ~1 Oct 2026 \u2014 at which point this line gets a price and the electricity question becomes answerable rather than speculative.", + "fieldConfig": { + "defaults": { + "decimals": 3, + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "options": { + "colorMode": "value", + "graphMode": "area", + "textMode": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + } + } + }, + { + "type": "timeseries", + "title": "Rewards accrued", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "gridPos": { + "h": 9, + "w": 16, + "x": 8, + "y": 73 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "quantus_reward_balance", + "legendFormat": "balance ({{symbol}})", + "range": true, + "refId": "A" + } + ], + "description": "Cumulative. The slope is what matters: earnings per hour at the current share.", + "fieldConfig": { + "defaults": { + "custom": { + "lineWidth": 2, + "fillOpacity": 12, + "showPoints": "never" + }, + "color": { + "mode": "palette-classic" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + } + }, + { + "type": "timeseries", + "title": "Earnings rate (per hour)", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "gridPos": { + "h": 5, + "w": 8, + "x": 0, + "y": 78 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "rate(quantus_reward_balance[30m]) * 3600", + "legendFormat": "{{symbol}}/h", + "range": true, + "refId": "A" + } + ], + "description": "Derivative of the balance. Against your measured ~1.4 kW draw this is the whole economic question, and it stays unanswerable until the token has a price.", + "fieldConfig": { + "defaults": { + "custom": { + "lineWidth": 2, + "fillOpacity": 8, + "showPoints": "never" + }, + "color": { + "mode": "palette-classic" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + } } ] } diff --git a/asset/systemd/quantus-arena.service b/asset/systemd/quantus-arena.service index f030a19..8104cdb 100644 --- a/asset/systemd/quantus-arena.service +++ b/asset/systemd/quantus-arena.service @@ -28,7 +28,8 @@ ExecStart=/usr/bin/python3 /usr/local/bin/quantus-arena-exporter.py \ --port 25033 \ --window-blocks 3600 \ --target-block-time 6 \ - --self-preimage ${QUANTUS_INNER_HASH} + --self-preimage ${QUANTUS_INNER_HASH} \ + --reward-address ${QUANTUS_REWARD_ADDRESS} # The node may be down or restarting; reconnecting is normal operation. The # exporter already survives RPC errors internally, so a restart here means it