Persist reward contribution ledger
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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(/\/$/, ""),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user