Persist reward contribution ledger

This commit is contained in:
Dorian
2026-05-09 17:10:47 +01:00
parent c256726cfa
commit 68b606b489
6 changed files with 172 additions and 2 deletions

View File

@@ -20,6 +20,7 @@ DATUM_ADMIN_PASSWORD=
# How often to scrape /clients (ms). Datum updates per-worker hashrate every
# few seconds; 5s is a sane default.
DATUM_POLL_INTERVAL_MS=5000
CONTRIBUTION_LEDGER_PATH=/data/contribution-ledger.json
# Sovereign mempool API used for block height, network hashrate, and difficulty.
MEMPOOL_API_URL=https://tx1138.com/api

View File

@@ -51,6 +51,8 @@ Allowlist of npubs is set via `NOSTR_ALLOWED_NPUBS`. Anything else is rejected b
from the stack env vars.
- By default the stack uses host networking and polls Datum through Umbrel's
host-published admin port: `DATUM_URL=http://127.0.0.1:21000`.
- Reward contribution accounting is persisted in the Docker volume
`gashboard_data` at `/data/contribution-ledger.json`.
4. Open the dashboard, log in with one of the allowed npubs, watch your boards lose at hashing in style.

View File

@@ -15,6 +15,7 @@ const RawEnv = z.object({
DATUM_ADMIN_USER: z.string().default("admin"),
DATUM_ADMIN_PASSWORD: z.string().min(1),
DATUM_POLL_INTERVAL_MS: z.coerce.number().int().min(1000).default(5000),
CONTRIBUTION_LEDGER_PATH: z.string().min(1).default("/data/contribution-ledger.json"),
MEMPOOL_API_URL: z.string().url().default("https://tx1138.com/api"),
NOSTR_ALLOWED_NPUBS: z.string().min(1),
@@ -57,6 +58,7 @@ export const config = {
adminUser: parsed.DATUM_ADMIN_USER,
adminPassword: parsed.DATUM_ADMIN_PASSWORD,
pollIntervalMs: parsed.DATUM_POLL_INTERVAL_MS,
contributionLedgerPath: parsed.CONTRIBUTION_LEDGER_PATH,
},
mempool: {
url: parsed.MEMPOOL_API_URL.replace(/\/$/, ""),

View File

@@ -3,6 +3,8 @@ import { logger } from "../logger.js";
import { digestFetch } from "./digest.js";
import { parseClientsHtml, parseThreadsHtml } from "./parse.js";
import type { CurrentJob, DatumSnapshot, MinerStat, NetworkStat, PoolStat } from "./types.js";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
const DEFAULT_TIMEOUT_MS = 10_000;
@@ -66,6 +68,15 @@ type ContributionEntry = {
};
const contributionLedger = new Map<string, ContributionEntry>();
let ledgerLoaded = false;
let ledgerDirty = false;
let ledgerWriteInFlight: Promise<void> | null = null;
type PersistedContributionLedger = {
version: 1;
updatedAt: number;
entries: Array<[string, ContributionEntry]>;
};
function formatErr(err: unknown): string {
if (!(err instanceof Error)) return String(err);
@@ -115,7 +126,79 @@ function minerIdentity(m: MinerStat): string {
return m.nickname || m.authUsername || m.remoteHost;
}
function applyContributionLedger(miners: MinerStat[], fetchedAt: number): MinerStat[] {
function validContributionEntry(entry: ContributionEntry): boolean {
return (
entry &&
typeof entry === "object" &&
typeof entry.lastRawAcceptedWork === "number" &&
typeof entry.lastRawAcceptedShares === "number" &&
typeof entry.totalAcceptedWork === "number" &&
typeof entry.totalAcceptedShares === "number" &&
typeof entry.firstSeenAt === "number" &&
typeof entry.lastSeenAt === "number" &&
entry.miner &&
typeof entry.miner === "object"
);
}
async function loadContributionLedger(): Promise<void> {
if (ledgerLoaded) return;
ledgerLoaded = true;
try {
const raw = await readFile(config.datum.contributionLedgerPath, "utf8");
const parsed = JSON.parse(raw) as PersistedContributionLedger;
if (parsed.version !== 1 || !Array.isArray(parsed.entries)) {
throw new Error("unsupported contribution ledger format");
}
contributionLedger.clear();
for (const [key, entry] of parsed.entries) {
if (typeof key === "string" && validContributionEntry(entry)) {
contributionLedger.set(key, { ...entry, currentlyConnected: false });
}
}
logger.info(
{ entries: contributionLedger.size, path: config.datum.contributionLedgerPath },
"contribution_ledger_loaded",
);
} catch (err) {
const code = err instanceof Error ? (err as Error & { code?: string }).code : undefined;
if (code === "ENOENT") return;
logger.warn({ reason: formatErr(err), path: config.datum.contributionLedgerPath }, "contribution_ledger_load_failed");
}
}
function scheduleContributionLedgerSave(): void {
ledgerDirty = true;
if (ledgerWriteInFlight) return;
ledgerWriteInFlight = saveContributionLedger()
.catch((err) => {
logger.warn({ reason: formatErr(err), path: config.datum.contributionLedgerPath }, "contribution_ledger_save_failed");
})
.finally(() => {
ledgerWriteInFlight = null;
if (ledgerDirty) scheduleContributionLedgerSave();
});
}
async function saveContributionLedger(): Promise<void> {
if (!ledgerDirty) return;
ledgerDirty = false;
const path = config.datum.contributionLedgerPath;
await mkdir(dirname(path), { recursive: true });
const tmpPath = `${path}.${process.pid}.tmp`;
const payload: PersistedContributionLedger = {
version: 1,
updatedAt: Date.now(),
entries: [...contributionLedger.entries()],
};
await writeFile(tmpPath, `${JSON.stringify(payload)}\n`, "utf8");
await rename(tmpPath, path);
}
async function applyContributionLedger(miners: MinerStat[], fetchedAt: number): Promise<MinerStat[]> {
await loadContributionLedger();
let changed = false;
for (const entry of contributionLedger.values()) {
entry.currentlyConnected = false;
}
@@ -137,6 +220,7 @@ function applyContributionLedger(miners: MinerStat[], fetchedAt: number): MinerS
lastSeenAt: fetchedAt,
currentlyConnected: true,
});
changed = true;
continue;
}
@@ -145,6 +229,7 @@ function applyContributionLedger(miners: MinerStat[], fetchedAt: number): MinerS
existing.totalAcceptedWork += workDelta >= 0 ? workDelta : miner.diffAcceptedSum;
existing.totalAcceptedShares += shareDelta >= 0 ? shareDelta : miner.diffAcceptedCount;
if (workDelta > 0 || shareDelta > 0 || workDelta < 0 || shareDelta < 0) changed = true;
existing.lastRawAcceptedWork = miner.diffAcceptedSum;
existing.lastRawAcceptedShares = miner.diffAcceptedCount;
existing.lastSeenAt = fetchedAt;
@@ -152,6 +237,8 @@ function applyContributionLedger(miners: MinerStat[], fetchedAt: number): MinerS
existing.miner = miner;
}
if (changed) scheduleContributionLedgerSave();
const totalWork = [...contributionLedger.values()].reduce((sum, entry) => sum + entry.totalAcceptedWork, 0);
const decorate = (miner: MinerStat, entry: ContributionEntry): MinerStat => ({
...miner,
@@ -300,7 +387,7 @@ async function pollOnce(): Promise<DatumSnapshot> {
);
}
const miners = applyContributionLedger(parseClientsHtml(clientsHtml), fetchedAt);
const miners = await applyContributionLedger(parseClientsHtml(clientsHtml), fetchedAt);
const threads = threadsHtml ? parseThreadsHtml(threadsHtml) : [];
if (miners.length === 0) {
logger.warn(

View File

@@ -35,6 +35,18 @@ const bestWorkByMiner = computed(() => {
return out;
});
const poolBestWork = computed(() => Math.max(0, ...Object.values(bestWorkByMiner.value)));
const contributionRows = computed(() => {
return miners.value
.filter((m) => (m.contributionAcceptedWork ?? m.diffAcceptedSum) > 0)
.map((m) => ({
key: minerKey(m),
name: m.nickname,
work: m.contributionAcceptedWork ?? m.diffAcceptedSum,
pct: m.contributionPct ?? 0,
connected: m.currentlyConnected ?? true,
}))
.sort((a, b) => b.work - a.work);
});
const boomerHeater = {
authUsername: "fiat.heat",
remoteHost: "wall-socket",
@@ -84,6 +96,27 @@ function minerKey(m: { nickname: string; authUsername: string; remoteHost: strin
<span class="muted">derived from accepted difficulty jumps while this browser has history</span>
</section>
<section class="split panel">
<div class="split-head">
<div>
<div class="label">block reward split</div>
<strong class="glow-green">accepted-work ledger</strong>
</div>
<span class="muted">persisted after this deploy</span>
</div>
<div v-if="contributionRows.length" class="split-rows">
<div v-for="row in contributionRows" :key="row.key" class="split-row">
<span>
{{ row.name }}
<small v-if="!row.connected" class="muted">offline</small>
</span>
<strong>{{ row.pct.toFixed(2) }}%</strong>
<em class="muted">{{ row.work.toLocaleString(undefined, { maximumFractionDigits: 2 }) }}</em>
</div>
</div>
<div v-else class="muted">waiting for accepted work</div>
</section>
<div class="grid-row">
<LotteryWidget :total-ths="totalThs" :network-eh="networkEh" />
<ShareTicker :snapshot="stats.snapshot" />
@@ -142,6 +175,37 @@ function minerKey(m: { nickname: string; authUsername: string; remoteHost: strin
text-align: right;
overflow-wrap: anywhere;
}
.split {
margin-top: 16px;
}
.split-head,
.split-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 14px;
align-items: center;
}
.split-head {
margin-bottom: 10px;
}
.split-rows {
display: grid;
gap: 8px;
}
.split-row {
padding-top: 8px;
border-top: 1px solid var(--line);
font-size: 13px;
}
.split-row small {
margin-left: 8px;
font-size: 10px;
text-transform: uppercase;
}
.split-row em {
font-style: normal;
font-size: 11px;
}
.miners {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
@@ -161,5 +225,13 @@ function minerKey(m: { nickname: string; authUsername: string; remoteHost: strin
.best span {
text-align: left;
}
.split-head,
.split-row {
grid-template-columns: minmax(0, 1fr) auto;
}
.split-head > span,
.split-row em {
grid-column: 1 / -1;
}
}
</style>

View File

@@ -26,14 +26,20 @@ services:
DATUM_ADMIN_USER: "${DATUM_ADMIN_USER:-admin}"
DATUM_ADMIN_PASSWORD: "${DATUM_ADMIN_PASSWORD?must be set}"
DATUM_POLL_INTERVAL_MS: "${DATUM_POLL_INTERVAL_MS:-5000}"
CONTRIBUTION_LEDGER_PATH: "${CONTRIBUTION_LEDGER_PATH:-/data/contribution-ledger.json}"
MEMPOOL_API_URL: "${MEMPOOL_API_URL:-https://tx1138.com/api}"
NOSTR_ALLOWED_NPUBS: "${NOSTR_ALLOWED_NPUBS?must be set}"
JWT_SECRET: "${JWT_SECRET?must be set}"
JWT_TTL_SECONDS: "${JWT_TTL_SECONDS:-86400}"
network_mode: host
volumes:
- gashboard_data:/data
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:1337/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
volumes:
gashboard_data: