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
|
# 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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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(/\/$/, ""),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user