Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .markdownlint-cli2.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"**/node_modules/**",
"**/.venv/**",
"build/dashboard/mining_dashboard/client/tari/generated/**",
"docs/dev/test-inventory.md" // generated by `make test-inventory`; not hand-edited
"docs/dev/test-inventory.md", // generated by `make test-inventory`; not hand-edited
"research/**" // verbatim research records (numbered audit trails, quoted material) — reviewed as research, not house prose
]
}
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ lint: lint-sh lint-py lint-js lint-yaml lint-md lint-docs-voice lint-operator-st
lint-sh: ## shellcheck + shfmt over the CLI, build/* container scripts, release + test scripts
shellcheck --severity=warning pithead pithead-completion.bash scripts/*.sh build/*/*.sh tests/stack/run.sh tests/stack/test_compose.sh \
tests/inventory.sh tests/integration/*.sh tests/integration/mini-stack/*.sh
shfmt -i 4 -d pithead pithead-completion.bash $(shell git ls-files '*.sh')
shfmt -i 4 -d pithead pithead-completion.bash $(shell git ls-files '*.sh' | grep -v '^research/')

lint-py: ## ruff lint + format check on all repo Python (ruff runs via uv from the locked dev extra)
uv run --locked --project build/dashboard --extra dev ruff check .
Expand Down
5 changes: 4 additions & 1 deletion build/dashboard/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,10 @@ exclude_also = ["if __name__ == .__main__.:", "raise NotImplementedError"]
target-version = "py311"
line-length = 100
# Generated Tari gRPC stubs aren't ours to style — already omitted from coverage too.
extend-exclude = ["mining_dashboard/client/tari/generated"]
extend-exclude = [
"mining_dashboard/client/tari/generated",
"../../research",
] # research/: verbatim study-record scripts, kept byte-identical to the archived raw copy

[tool.ruff.lint]
# E/F/W pycodestyle+pyflakes · I import-sort · B bugbear · UP pyupgrade ·
Expand Down
477 changes: 477 additions & 0 deletions research/xvb-delivery-study/PAPER.md

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions research/xvb-delivery-study/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# XvB delivery study (public record)

The complete research record of the XMRvsBeast raffle delivery study (June–August 2026):
PAPER.md (IMRAD, with a full adversarial-corrections audit trail), the figures, every
analysis script, checksummed archives of all external sources, and the public winners-feed
schedule snapshots.

What is deliberately NOT here: the wallet's payout records, the experiment box's operational
log, per-wallet observer share dumps, and raw crawl results — they correlate a masked public
identity with a wallet's financial history (see PAPER.md §6, Data availability). The method
is reproducible for ANY wallet from public data alone: the winners feed plus the three
p2pool.observer instances.

Headline result: across 25 on-chain-audited won rounds, delivered prize work measured 33.1%
of advertised (95% CI 28–39%), with at most a small donation-margin effect; a 14-winner
public crawl corroborates. See PAPER.md.
10 changes: 10 additions & 0 deletions research/xvb-delivery-study/analysis/archive-winners.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/bin/bash
# Daily full winners-file snapshot: preserves the complete round schedule + qualifier counts
# beyond the file's ~45-day rolling window (via the stack Tor SOCKS; the file carries no wallet).
docker exec -i dashboard python3 /dev/stdin >"$HOME/xvb-experiment/archive/winners-$(date -u +%Y%m%d).txt" 2>>"$HOME/xvb-experiment/err.log" <<'PY'
import os, requests
proxy = os.environ.get("TOR_SOCKS_PROXY", "socks5h://tor:9050")
S = requests.Session()
S.proxies = {"http": proxy, "https": proxy}
print(S.get("https://xmrvsbeast.com/p2pool/winners_recent_full_pub.txt", timeout=60).text, end="")
PY
16 changes: 16 additions & 0 deletions research/xvb-delivery-study/analysis/final_stats.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/usr/bin/env python3
"""Bootstrap CIs for the paper's headline aggregates (seed pinned; §2.3 mapping row)."""
import json, random, subprocess, sys
random.seed(20260812)
rounds = json.loads(subprocess.run([sys.executable, "rounds.py", "--json"],
capture_output=True, text=True).stdout)
clean = [r for r in rounds if r["era"] == "experiment" and r["draw_utc"] != "08-05 00:12"]
hist = [r for r in rounds if r["era"] == "historical"]
def R(rs): return sum(r["delivered_G"] for r in rs) / sum(r["advertised_G"] for r in rs)
def boot(rs, n=20000):
vals = sorted(R([random.choice(rs) for _ in rs]) for _ in range(n))
return vals[int(0.025 * n)], vals[int(0.975 * n)]
out = {"clean_R": R(clean), "clean_CI": boot(clean), "hist_R": R(hist), "hist_CI": boot(hist),
"all_R": R(rounds), "all_CI": boot(rounds)}
json.dump(out, open("../figures/final_stats.json", "w"), indent=1)
print(json.dumps(out, indent=1))
30 changes: 30 additions & 0 deletions research/xvb-delivery-study/analysis/observer_check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import json, os, time, urllib.request, sqlite3

# Wallet from the container env (never printed in full); Tor SOCKS from the same config the
# dashboard's own XvB client uses — the query carries the wallet, so clearnet would correlate
# IP <-> wallet (#163).
addr = os.environ.get("MONERO_WALLET_ADDRESS", "")
proxy = os.environ.get("TOR_SOCKS_PROXY", "socks5h://tor:9050")
print("wallet:", addr[:8] + "..." + addr[-8:], "| proxy:", proxy)

import requests # available in the dashboard image

S = requests.Session()
S.proxies = {"http": proxy, "https": proxy}
BASE = "https://p2pool.observer/api"

info = S.get(f"{BASE}/miner_info/{addr}", timeout=60)
print("miner_info HTTP", info.status_code)
mi = info.json() if info.status_code == 200 else {}
print("main-chain miner id:", mi.get("id"), "| shares total:", json.dumps(mi.get("shares")))

# All recorded main-chain shares for this wallet (observer caps page size; ask big).
sh = S.get(f"{BASE}/shares?miner={addr}&limit=200", timeout=60)
print("shares HTTP", sh.status_code)
shares = sh.json() if sh.status_code == 200 else []
print("shares returned:", len(shares))
for s in shares[:50]:
ts = s.get("timestamp")
print(" share:", time.strftime("%m-%d %H:%M", time.localtime(ts)),
"| height", s.get("side_height"), "| diff", s.get("difficulty"),
"| uncle" if s.get("parent") else "")
74 changes: 74 additions & 0 deletions research/xvb-delivery-study/analysis/poll.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
#!/bin/bash
# XvB delivery experiment v2 (2026-08) — see README.md beside this script for the methodology.
# One self-contained poll per cron firing; every fetch fails independently and is recorded as
# its own *_err field, so a Tor hiccup degrades one field, never the record. Wallet-bearing
# fetches ride the stack Tor SOCKS (#163).
docker exec -i dashboard python3 - <<'EOF' >>"$HOME/xvb-experiment/log.jsonl" 2>>"$HOME/xvb-experiment/err.log"
import json, os, re, time, requests, urllib.request

addr = os.environ["MONERO_WALLET_ADDRESS"]
proxy = os.environ.get("TOR_SOCKS_PROXY", "socks5h://tor:9050")
S = requests.Session()
S.proxies = {"http": proxy, "https": proxy}
rec = {"t": round(time.time())}

def grab(key, fn):
try:
rec[key] = fn()
except Exception as e:
rec[key + "_err"] = str(e)[:60]

# 1. Delivered work, on-chain: our newest p2pool-main shares (height/ts/difficulty).
grab("shares", lambda: [
{"h": x.get("side_height"), "ts": x.get("timestamp"), "d": x.get("difficulty")}
for x in S.get(f"https://p2pool.observer/api/shares?miner={addr}&limit=10", timeout=50).json()
])
# 2. Collection, on-chain: which main blocks paid us, how much, when.
grab("payouts", lambda: [
{"h": x.get("main_height"), "ts": x.get("timestamp"), "a": x.get("coinbase_reward")}
for x in S.get(f"https://p2pool.observer/api/payouts/{addr}?search_limit=10", timeout=50).json()
])
# 3. Conversion context: pool hashrate + sidechain difficulty at this instant (expected-share math).
def _pool():
p = S.get("https://p2pool.observer/api/pool_info", timeout=50).json()
side = p.get("sidechain", {})
return {"diff": (side.get("difficulty")), "height": side.get("height"),
"mainchain_diff": (p.get("mainchain", {}) or {}).get("difficulty")}
grab("pool", _pool)
# 4. Our credited averages + fail count from XvB — the termination-margin timeline.
def _cred():
h = S.get("https://xmrvsbeast.com/cgi-bin/p2pool_bonus_history.cgi",
params={"address": addr}, timeout=50).text
out = {}
m = re.search(r"1hr avg:\s*([\d.]+\s*[kKmM]?H/s)", h)
out["1h"] = m.group(1) if m else None
m = re.search(r"24hr avg:\s*([\d.]+\s*[kKmM]?H/s)", h)
out["24h"] = m.group(1) if m else None
m = re.search(r"Fail Count:\s*(\d+)", h)
out["fail"] = int(m.group(1)) if m else None
return out
grab("cred", _cred)
# 5. The round schedule + advertised prize: top rows of XvB's winners file. A new row appears
# when a round is drawn, so a 2-min cadence bounds every round start (ours AND the next
# round's — which bounds OUR round's true end) to ±2 min, with the advertised bonus HR.
grab("rounds", lambda: S.get(
"https://xmrvsbeast.com/p2pool/winners_recent_full_pub.txt", timeout=50
).text.splitlines()[:3])
# 6. Our own side of the ledger, locally: what the proxy is actually routing and the mode —
# distinguishes "our donation sagged" from "XvB delivered less" without inference.
def _local():
d = json.load(urllib.request.urlopen("http://127.0.0.1:8000/api/state", timeout=10))
hr = d.get("hashrate", {})
return {"mode": hr.get("mode_name"), "routed_1h": hr.get("xvb_routed_1h"),
"p2p_1h": hr.get("p2p_1h"), "total": hr.get("total"),
"cred_1h": hr.get("xvb_1h"), "stale": hr.get("xvb_stale"),
"sw": (d.get("shares_window") or {}).get("count"),
"xmr_price": (d.get("energy") or {}).get("xmr_price")}
grab("local", _local)
# 7. The regime this record was taken under, from the container env itself — analysis needs no
# external timeline to know which donation configuration produced each observation.
grab("cfg", lambda: {"level": os.environ.get("XVB_DONATION_LEVEL"),
"frac": os.environ.get("XVB_MAX_DONATION_FRACTION", "0.85")})

print(json.dumps(rec))
EOF
14 changes: 14 additions & 0 deletions research/xvb-delivery-study/analysis/round2_final.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import json, time
WIN = 1786021262.0 # 08-06 09:01:02 local
rows = [json.loads(l) for l in open("/home/vijit/xvb-experiment/log.jsonl")]
r = rows[-1]
shares = [s for s in r.get("shares", []) if s["ts"] and s["ts"] >= WIN - 60]
diff = r["pool"]["diff"]
adv = 3536.5e3
work = sum(s["d"] for s in shares)
exp = adv * 3600
print(f"ROUND 2 FINAL (clean margin): {len(shares)} shares, delivered {work/1e9:.2f}G vs {exp/1e9:.2f}G expected -> {100*work/exp:.0f}%")
for s in shares:
print(f" share +{(s['ts']-WIN)/60:.1f} min, diff {s['d']/1e9:.2f}G")
cred = [x.get("cred", {}).get("1h") for x in rows if WIN <= x["t"] <= WIN + 4500]
print("credited min through round:", min(c for c in cred if c))
35 changes: 35 additions & 0 deletions research/xvb-delivery-study/analysis/round_live.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import json, time, datetime

rows = [json.loads(l) for l in open("/home/vijit/xvb-experiment/log.jsonl")]
r = rows[-1]
now = r["t"]
print("latest poll:", time.strftime("%H:%M:%S", time.localtime(now)),
"| errors:", [k for k in r if k.endswith("_err")] or "none")
win = None
for x in rows:
for line in x.get("rounds", []):
if line.startswith("48LNi6pk"):
f = line.split()
ts = datetime.datetime.fromisoformat(f[1] + " " + f[2]).replace(
tzinfo=datetime.timezone.utc).timestamp()
if win is None or ts > win[0]:
win = (ts, f[3], f[7], f[8])
print("WIN:", time.strftime("%m-%d %H:%M:%S", time.localtime(win[0])),
"| advertised:", win[1], "| players:", win[2], "| type:", win[3])
mins = (now - win[0]) / 60
print(f"round age: {mins:.1f} min")
shares = [s for s in r.get("shares", []) if s["ts"] and s["ts"] >= win[0] - 60]
diff = r["pool"]["diff"]
adv = float(win[1].replace("kH/s", "")) * 1000
exp_work = adv * min(mins, 60) * 60
work = sum(s["d"] for s in shares)
print(f"shares so far: {len(shares)} | delivered {work/1e9:.2f}G vs expected {exp_work/1e9:.2f}G "
f"-> delivery {100*work/exp_work if exp_work else 0:.0f}%")
for s in shares:
off = (s["ts"] - win[0]) / 60
print(f" share +{off:5.1f} min | diff {s['d']/1e9:.2f}G")
print("--- credited/routed through round ---")
for x in rows:
if win[0] - 240 <= x["t"] <= now and (x["t"] - win[0]) % 360 < 120:
print(time.strftime("%H:%M", time.localtime(x["t"])),
x.get("cred", {}).get("1h"), "| routed", (x.get("local") or {}).get("routed_1h"))
133 changes: 133 additions & 0 deletions research/xvb-delivery-study/analysis/rounds.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Canonical per-round delivery analysis — THE single source of truth for the study's
delivery numbers (supersedes the ad-hoc scripts that produced corrections #11-#13).

For every win of ours found in the archived round schedules:
slot = draw -> next draw (consecutive rows of the merged winners snapshots)
delivered = sum of share difficulty on EACH sidechain (main / mini / nano) inside the slot,
with the wallet's own mini mining subtracted as baseline work (we mine mini
ourselves; main and nano are XvB-only for this wallet)
advertised = advertised bonus HR (winners row) x min(slot, 60 min)
delivery R = delivered / advertised (difficulty-weighted, cross-chain)

Data inputs (all in ../data/): winners-*.txt + sources/xmrvsbeast-winners*.txt snapshots,
observer-all-chains-20260810.json, prod-db-dump.json (hashrate_hourly for mini baseline),
experiment-log.jsonl (credited margins per experiment round).
Usage: python3 rounds.py [--json]
"""
import glob
import json
import os
import sys
from datetime import datetime, timezone

D = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data")
MASK = "48LNi6pk"
EXPERIMENT_START = datetime(2026, 8, 4, 1, 0, tzinfo=timezone.utc).timestamp()

# --- round schedule: union of every archived winners snapshot ---------------------------
rows = {}
for path in glob.glob(f"{D}/winners-*.txt") + glob.glob(f"{D}/sources/xmrvsbeast-winners*.txt"):
for line in open(path):
f = line.split()
if len(f) < 9:
continue
try:
ts = datetime.fromisoformat(f[1] + " " + f[2]).replace(tzinfo=timezone.utc).timestamp()
except ValueError:
continue
rows[round(ts)] = {"ts": ts, "who": f[0], "adv": float(f[3].replace("kH/s", "")) * 1e3,
"players": int(f[7]), "typ": f[8]}
# experiment log's rounds stream adds rows newer than any snapshot
logpath = f"{D}/experiment-log.jsonl"
if os.path.exists(logpath):
for line in open(logpath):
try:
r = json.loads(line)
except json.JSONDecodeError:
continue
for row in r.get("rounds") or []:
f = row.split()
if len(f) < 9:
continue
try:
ts = datetime.fromisoformat(f[1] + " " + f[2]).replace(tzinfo=timezone.utc).timestamp()
except ValueError:
continue
rows.setdefault(round(ts), {"ts": ts, "who": f[0], "adv": float(f[3].replace("kH/s", "")) * 1e3,
"players": int(f[7]), "typ": f[8]})
sched = sorted(rows.values(), key=lambda r: r["ts"])
draws = [r["ts"] for r in sched]

# --- shares on all three chains ---------------------------------------------------------
import glob as _g
# newest by mtime, NOT by name (correction #20: "-final" sorts before ".json" lexicographically,
# which silently served a stale dump and manufactured a false zero round)
_chains_file = max(_g.glob(f"{D}/observer-all-chains-*.json"), key=os.path.getmtime)
chains = json.load(open(_chains_file))
mini_floor = min((s["ts"] for s in chains["mini"]), default=None) # mini dump depth limit

# --- our own mini baseline (H/s) from hourly hashrate dump ------------------------------
hh = {r["hour"]: r["p2pool"] for r in json.load(open(f"{D}/prod-db-dump.json"))["hashrate_hourly"]}

def own_mini_rate(ts):
h = int(ts // 3600) * 3600
for cand in (h, h - 3600, h + 3600):
if hh.get(cand):
return hh[cand]
return 0.0

# --- per-round accounting ---------------------------------------------------------------
def slot_of(ts):
later = [d for d in draws if d > ts + 1]
return (min(later) - ts) if later else None

out = []
for r in sched:
if not r["who"].startswith(MASK):
continue
slot = slot_of(r["ts"])
if slot is None:
continue
per = {}
for chain, shares in chains.items():
inslot = [s for s in shares if s["ts"] and r["ts"] - 60 <= s["ts"] <= r["ts"] + slot]
per[chain] = {"n": len(inslot), "work": sum(s["d"] for s in inslot),
"offs": sorted(round((s["ts"] - r["ts"]) / 60, 1) for s in inslot)}
# mini baseline: our own expected mini work during the slot
base = own_mini_rate(r["ts"]) * slot
mini_excess = max(0.0, per["mini"]["work"] - base)
mini_covered = mini_floor is not None and r["ts"] >= mini_floor
delivered = per["main"]["work"] + per["nano"]["work"] + (mini_excess if mini_covered else 0.0)
advertised = r["adv"] * min(slot, 3600)
out.append({
"draw_utc": datetime.fromtimestamp(r["ts"], timezone.utc).strftime("%m-%d %H:%M"),
"ts": r["ts"], "typ": r["typ"], "players": r["players"],
"adv_MHs": round(r["adv"] / 1e6, 2), "slot_min": round(slot / 60, 1),
"main_n": per["main"]["n"], "main_offs": per["main"]["offs"],
"nano_n": per["nano"]["n"], "mini_n": per["mini"]["n"],
"mini_covered": mini_covered,
"mini_excess_G": round(mini_excess / 1e9, 2) if mini_covered else None,
"delivered_G": round(delivered / 1e9, 2),
"advertised_G": round(advertised / 1e9, 2),
"R": round(delivered / advertised, 3),
"era": "experiment" if r["ts"] >= EXPERIMENT_START else "historical",
})

if "--json" in sys.argv:
print(json.dumps(out, indent=1))
else:
hdr = f"{'draw(UTC)':>11} {'type':12} {'adv':>5} {'slot':>6} {'main':>4} {'nano':>4} {'mini':>4} {'delG':>6} {'advG':>6} {'R':>6} era"
print(hdr)
for o in out:
print(f"{o['draw_utc']:>11} {o['typ']:12} {o['adv_MHs']:>5} {o['slot_min']:>6} "
f"{o['main_n']:>4} {o['nano_n']:>4} {o['mini_n']:>4} "
f"{o['delivered_G']:>6} {o['advertised_G']:>6} {o['R']:>6.1%} {o['era']}"
+ ("" if o["mini_covered"] else " (mini n/a)"))
for era in ("historical", "experiment"):
sub = [o for o in out if o["era"] == era]
if not sub:
continue
del_t = sum(o["delivered_G"] for o in sub)
adv_t = sum(o["advertised_G"] for o in sub)
print(f"{era}: {len(sub)} rounds, R = {del_t:.1f}G / {adv_t:.1f}G = {del_t/adv_t:.1%}")
20 changes: 20 additions & 0 deletions research/xvb-delivery-study/analysis/vip_stream.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import sqlite3, time
c = sqlite3.connect("/data/mining_data.db")
# VIP era: everything before the whale era began.
whale_lo = list(c.execute("select min(ts) from xvb_history where avg_24h>=100000"))[0][0]
wins = [r[0] for r in c.execute("select ts from raffle_wins where ts < ? order by ts", (whale_lo,))]
if not wins:
print("no pre-whale wins"); raise SystemExit
lo = wins[0] - 7 * 86400
pays = list(c.execute("select ts, amount_atomic from payouts where chain='monero' and ts between ? and ?", (lo, whale_lo)))
H, MAXOFF = 3600.0, 8
def near(t): return any(0 <= t - w < MAXOFF * H for w in wins)
base_amt = sum(a for t, a in pays if not near(t)) / 1e12
base_hours = (whale_lo - lo) / H - MAXOFF * len(wins) # approx; wins are far apart
base_rate = base_amt / base_hours
gross = sum(a for t, a in pays if near(t)) / 1e12
excess = gross - base_rate * MAXOFF * len(wins)
print(f"VIP era: {len(wins)} wins from {time.strftime('%m-%d', time.localtime(wins[0]))}, baseline {base_rate*1000:.3f} mXMR/h")
print(f"gross in 8h windows {gross*1000:.1f} mXMR, excess {excess*1000:.1f} = {excess/len(wins)*1000:.2f} mXMR/win")
# face value per VIP-era win: same bonus rig, so same ~17.5 face; realization:
print(f"realization vs 17.5 mXMR face: {excess/len(wins)/0.0175*100:.0f}%")
Loading
Loading