feat: track accrued rewards in the arena exporter
deploy / fetch (push) Successful in 16s
deploy / deploy-node (bob.hanzalova.internal, 0x134e73f06fa9bdb1dbfa909e149c563f5860ceb71a0e7307918f7033970edf59, benjy.hanzalova.internal quadbrat.hanzalova.internal, --public-addr /dns4/nh.thgttg.com/tcp/30333) (push) Successful in 31s
deploy / deploy-miner (1, benjy.hanzalova.internal, bob.hanzalova.internal) (push) Successful in 41s
deploy / deploy-miner (1, quadbrat.hanzalova.internal, bob.hanzalova.internal) (push) Successful in 40s
deploy / deploy-metrics (push) Successful in 8s

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.
This commit is contained in:
Rob Thijssen
2026-09-01 04:07:03 +03:00
parent adc04f5bc5
commit ca94e3a6c0
5 changed files with 350 additions and 5 deletions
+20 -1
View File
@@ -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 \
+146 -2
View File
@@ -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():
+7
View File
@@ -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}}
+175 -1
View File
@@ -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"
}
}
}
]
}
+2 -1
View File
@@ -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