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 # How often to scrape /clients (ms). Datum updates per-worker hashrate every
# few seconds; 5s is a sane default. # few seconds; 5s is a sane default.
DATUM_POLL_INTERVAL_MS=5000 DATUM_POLL_INTERVAL_MS=5000
CONTRIBUTION_LEDGER_PATH=/data/contribution-ledger.json
# Sovereign mempool API used for block height, network hashrate, and difficulty. # Sovereign mempool API used for block height, network hashrate, and difficulty.
MEMPOOL_API_URL=https://tx1138.com/api 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. from the stack env vars.
- By default the stack uses host networking and polls Datum through Umbrel's - 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`. 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. 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_USER: z.string().default("admin"),
DATUM_ADMIN_PASSWORD: z.string().min(1), DATUM_ADMIN_PASSWORD: z.string().min(1),
DATUM_POLL_INTERVAL_MS: z.coerce.number().int().min(1000).default(5000), 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"), MEMPOOL_API_URL: z.string().url().default("https://tx1138.com/api"),
NOSTR_ALLOWED_NPUBS: z.string().min(1), NOSTR_ALLOWED_NPUBS: z.string().min(1),
@@ -57,6 +58,7 @@ export const config = {
adminUser: parsed.DATUM_ADMIN_USER, adminUser: parsed.DATUM_ADMIN_USER,
adminPassword: parsed.DATUM_ADMIN_PASSWORD, adminPassword: parsed.DATUM_ADMIN_PASSWORD,
pollIntervalMs: parsed.DATUM_POLL_INTERVAL_MS, pollIntervalMs: parsed.DATUM_POLL_INTERVAL_MS,
contributionLedgerPath: parsed.CONTRIBUTION_LEDGER_PATH,
}, },
mempool: { mempool: {
url: parsed.MEMPOOL_API_URL.replace(/\/$/, ""), url: parsed.MEMPOOL_API_URL.replace(/\/$/, ""),

View File

@@ -3,6 +3,8 @@ import { logger } from "../logger.js";
import { digestFetch } from "./digest.js"; import { digestFetch } from "./digest.js";
import { parseClientsHtml, parseThreadsHtml } from "./parse.js"; import { parseClientsHtml, parseThreadsHtml } from "./parse.js";
import type { CurrentJob, DatumSnapshot, MinerStat, NetworkStat, PoolStat } from "./types.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; const DEFAULT_TIMEOUT_MS = 10_000;
@@ -66,6 +68,15 @@ type ContributionEntry = {
}; };
const contributionLedger = new Map<string, 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 { function formatErr(err: unknown): string {
if (!(err instanceof Error)) return String(err); if (!(err instanceof Error)) return String(err);
@@ -115,7 +126,79 @@ function minerIdentity(m: MinerStat): string {
return m.nickname || m.authUsername || m.remoteHost; 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()) { for (const entry of contributionLedger.values()) {
entry.currentlyConnected = false; entry.currentlyConnected = false;
} }
@@ -137,6 +220,7 @@ function applyContributionLedger(miners: MinerStat[], fetchedAt: number): MinerS
lastSeenAt: fetchedAt, lastSeenAt: fetchedAt,
currentlyConnected: true, currentlyConnected: true,
}); });
changed = true;
continue; continue;
} }
@@ -145,6 +229,7 @@ function applyContributionLedger(miners: MinerStat[], fetchedAt: number): MinerS
existing.totalAcceptedWork += workDelta >= 0 ? workDelta : miner.diffAcceptedSum; existing.totalAcceptedWork += workDelta >= 0 ? workDelta : miner.diffAcceptedSum;
existing.totalAcceptedShares += shareDelta >= 0 ? shareDelta : miner.diffAcceptedCount; existing.totalAcceptedShares += shareDelta >= 0 ? shareDelta : miner.diffAcceptedCount;
if (workDelta > 0 || shareDelta > 0 || workDelta < 0 || shareDelta < 0) changed = true;
existing.lastRawAcceptedWork = miner.diffAcceptedSum; existing.lastRawAcceptedWork = miner.diffAcceptedSum;
existing.lastRawAcceptedShares = miner.diffAcceptedCount; existing.lastRawAcceptedShares = miner.diffAcceptedCount;
existing.lastSeenAt = fetchedAt; existing.lastSeenAt = fetchedAt;
@@ -152,6 +237,8 @@ function applyContributionLedger(miners: MinerStat[], fetchedAt: number): MinerS
existing.miner = miner; existing.miner = miner;
} }
if (changed) scheduleContributionLedgerSave();
const totalWork = [...contributionLedger.values()].reduce((sum, entry) => sum + entry.totalAcceptedWork, 0); const totalWork = [...contributionLedger.values()].reduce((sum, entry) => sum + entry.totalAcceptedWork, 0);
const decorate = (miner: MinerStat, entry: ContributionEntry): MinerStat => ({ const decorate = (miner: MinerStat, entry: ContributionEntry): MinerStat => ({
...miner, ...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) : []; const threads = threadsHtml ? parseThreadsHtml(threadsHtml) : [];
if (miners.length === 0) { if (miners.length === 0) {
logger.warn( logger.warn(

View File

@@ -35,6 +35,18 @@ const bestWorkByMiner = computed(() => {
return out; return out;
}); });
const poolBestWork = computed(() => Math.max(0, ...Object.values(bestWorkByMiner.value))); 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 = { const boomerHeater = {
authUsername: "fiat.heat", authUsername: "fiat.heat",
remoteHost: "wall-socket", 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> <span class="muted">derived from accepted difficulty jumps while this browser has history</span>
</section> </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"> <div class="grid-row">
<LotteryWidget :total-ths="totalThs" :network-eh="networkEh" /> <LotteryWidget :total-ths="totalThs" :network-eh="networkEh" />
<ShareTicker :snapshot="stats.snapshot" /> <ShareTicker :snapshot="stats.snapshot" />
@@ -142,6 +175,37 @@ function minerKey(m: { nickname: string; authUsername: string; remoteHost: strin
text-align: right; text-align: right;
overflow-wrap: anywhere; 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 { .miners {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
@@ -161,5 +225,13 @@ function minerKey(m: { nickname: string; authUsername: string; remoteHost: strin
.best span { .best span {
text-align: left; 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> </style>

View File

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