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
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:
@@ -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 \
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user