From 68b606b4894f906d2a79662549729a17aed6a78a Mon Sep 17 00:00:00 2001 From: Dorian Date: Sat, 9 May 2026 17:10:47 +0100 Subject: [PATCH] Persist reward contribution ledger --- .env.example | 1 + README.md | 2 + apps/api/src/config.ts | 2 + apps/api/src/datum/poller.ts | 91 +++++++++++++++++++++++++++- apps/web/src/views/DashboardView.vue | 72 ++++++++++++++++++++++ docker-compose.yml | 6 ++ 6 files changed, 172 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 9b788e3..96c5be1 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index d3f3655..8325d48 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 66b7ac6..8d34f50 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -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(/\/$/, ""), diff --git a/apps/api/src/datum/poller.ts b/apps/api/src/datum/poller.ts index 6c98b35..9705b88 100644 --- a/apps/api/src/datum/poller.ts +++ b/apps/api/src/datum/poller.ts @@ -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(); +let ledgerLoaded = false; +let ledgerDirty = false; +let ledgerWriteInFlight: Promise | 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 { + 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 { + 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 { + 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 { ); } - const miners = applyContributionLedger(parseClientsHtml(clientsHtml), fetchedAt); + const miners = await applyContributionLedger(parseClientsHtml(clientsHtml), fetchedAt); const threads = threadsHtml ? parseThreadsHtml(threadsHtml) : []; if (miners.length === 0) { logger.warn( diff --git a/apps/web/src/views/DashboardView.vue b/apps/web/src/views/DashboardView.vue index 8ce7c26..be8039b 100644 --- a/apps/web/src/views/DashboardView.vue +++ b/apps/web/src/views/DashboardView.vue @@ -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 derived from accepted difficulty jumps while this browser has history +
+
+
+
block reward split
+ accepted-work ledger +
+ persisted after this deploy +
+
+
+ + {{ row.name }} + offline + + {{ row.pct.toFixed(2) }}% + {{ row.work.toLocaleString(undefined, { maximumFractionDigits: 2 }) }} +
+
+
waiting for accepted work
+
+
@@ -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; + } } diff --git a/docker-compose.yml b/docker-compose.yml index d3679a8..941fd3d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: