From c256726cfa5b9de56e02f0ac7da0aaf94bc3f1eb Mon Sep 17 00:00:00 2001 From: Dorian Date: Sat, 9 May 2026 16:59:06 +0100 Subject: [PATCH] Track miner reward contribution --- apps/api/src/datum/poller.ts | 88 ++++++++++++++++++++++++++- apps/api/src/datum/types.ts | 6 ++ apps/web/src/components/MinerCard.vue | 18 ++++-- apps/web/src/types.ts | 6 ++ 4 files changed, 112 insertions(+), 6 deletions(-) diff --git a/apps/api/src/datum/poller.ts b/apps/api/src/datum/poller.ts index b7874ee..6c98b35 100644 --- a/apps/api/src/datum/poller.ts +++ b/apps/api/src/datum/poller.ts @@ -2,7 +2,7 @@ import { config } from "../config.js"; import { logger } from "../logger.js"; import { digestFetch } from "./digest.js"; import { parseClientsHtml, parseThreadsHtml } from "./parse.js"; -import type { CurrentJob, DatumSnapshot, NetworkStat, PoolStat } from "./types.js"; +import type { CurrentJob, DatumSnapshot, MinerStat, NetworkStat, PoolStat } from "./types.js"; const DEFAULT_TIMEOUT_MS = 10_000; @@ -54,6 +54,19 @@ let lastSnapshot: DatumSnapshot = { let timer: NodeJS.Timeout | null = null; let pollInFlight = false; +type ContributionEntry = { + miner: MinerStat; + lastRawAcceptedWork: number; + lastRawAcceptedShares: number; + totalAcceptedWork: number; + totalAcceptedShares: number; + firstSeenAt: number; + lastSeenAt: number; + currentlyConnected: boolean; +}; + +const contributionLedger = new Map(); + function formatErr(err: unknown): string { if (!(err instanceof Error)) return String(err); const cause = (err as Error & { cause?: unknown }).cause; @@ -98,6 +111,77 @@ type MempoolHashrate = { currentDifficulty?: number; }; +function minerIdentity(m: MinerStat): string { + return m.nickname || m.authUsername || m.remoteHost; +} + +function applyContributionLedger(miners: MinerStat[], fetchedAt: number): MinerStat[] { + for (const entry of contributionLedger.values()) { + entry.currentlyConnected = false; + } + + const connectedKeys = new Set(); + for (const miner of miners) { + const key = minerIdentity(miner); + connectedKeys.add(key); + const existing = contributionLedger.get(key); + + if (!existing) { + contributionLedger.set(key, { + miner, + lastRawAcceptedWork: miner.diffAcceptedSum, + lastRawAcceptedShares: miner.diffAcceptedCount, + totalAcceptedWork: miner.diffAcceptedSum, + totalAcceptedShares: miner.diffAcceptedCount, + firstSeenAt: fetchedAt, + lastSeenAt: fetchedAt, + currentlyConnected: true, + }); + continue; + } + + const workDelta = miner.diffAcceptedSum - existing.lastRawAcceptedWork; + const shareDelta = miner.diffAcceptedCount - existing.lastRawAcceptedShares; + + existing.totalAcceptedWork += workDelta >= 0 ? workDelta : miner.diffAcceptedSum; + existing.totalAcceptedShares += shareDelta >= 0 ? shareDelta : miner.diffAcceptedCount; + existing.lastRawAcceptedWork = miner.diffAcceptedSum; + existing.lastRawAcceptedShares = miner.diffAcceptedCount; + existing.lastSeenAt = fetchedAt; + existing.currentlyConnected = true; + existing.miner = miner; + } + + const totalWork = [...contributionLedger.values()].reduce((sum, entry) => sum + entry.totalAcceptedWork, 0); + const decorate = (miner: MinerStat, entry: ContributionEntry): MinerStat => ({ + ...miner, + contributionAcceptedWork: entry.totalAcceptedWork, + contributionAcceptedShares: entry.totalAcceptedShares, + contributionPct: totalWork > 0 ? (entry.totalAcceptedWork / totalWork) * 100 : 0, + contributionFirstSeenAt: entry.firstSeenAt, + contributionLastSeenAt: entry.lastSeenAt, + currentlyConnected: entry.currentlyConnected, + }); + + const live = miners.map((miner) => decorate(miner, contributionLedger.get(minerIdentity(miner))!)); + const offline = [...contributionLedger.entries()] + .filter(([key]) => !connectedKeys.has(key)) + .map(([, entry]) => + decorate( + { + ...entry.miner, + hashrateThs: 0, + hashrateAgeS: null, + subscribed: false, + status: "idle", + }, + entry, + ), + ); + + return [...live, ...offline].sort((a, b) => (b.contributionAcceptedWork ?? 0) - (a.contributionAcceptedWork ?? 0)); +} + async function fetchNetworkStats(): Promise { const timeout = abortableSignal(DEFAULT_TIMEOUT_MS); try { @@ -216,7 +300,7 @@ async function pollOnce(): Promise { ); } - const miners = parseClientsHtml(clientsHtml); + const miners = applyContributionLedger(parseClientsHtml(clientsHtml), fetchedAt); const threads = threadsHtml ? parseThreadsHtml(threadsHtml) : []; if (miners.length === 0) { logger.warn( diff --git a/apps/api/src/datum/types.ts b/apps/api/src/datum/types.ts index d4ab897..642496c 100644 --- a/apps/api/src/datum/types.ts +++ b/apps/api/src/datum/types.ts @@ -29,6 +29,12 @@ export type MinerStat = { userAgent: string; subscribed: boolean; status: 'hashing' | 'stale' | 'idle'; + contributionAcceptedWork?: number; + contributionAcceptedShares?: number; + contributionPct?: number; + contributionFirstSeenAt?: number; + contributionLastSeenAt?: number; + currentlyConnected?: boolean; }; export type PoolStat = { diff --git a/apps/web/src/components/MinerCard.vue b/apps/web/src/components/MinerCard.vue index 44fdaeb..8bfe819 100644 --- a/apps/web/src/components/MinerCard.vue +++ b/apps/web/src/components/MinerCard.vue @@ -46,6 +46,12 @@ const lastShareLabel = computed(() => { const lifetimeShares = computed(() => props.miner.diffAcceptedCount); const acceptedWork = computed(() => isBoomerHeater.value ? "Zero. Obviously." : formatBig(props.miner.diffAcceptedSum)); +const contributionWork = computed(() => + isBoomerHeater.value ? "Zero. Obviously." : formatBig(props.miner.contributionAcceptedWork ?? props.miner.diffAcceptedSum), +); +const contributionShare = computed(() => + isBoomerHeater.value ? "0.00%" : `${(props.miner.contributionPct ?? 0).toFixed(2)}%`, +); const bestWork = computed(() => { if (isBoomerHeater.value) return "Fuck all"; return props.bestWork && props.bestWork > 0 ? formatBig(props.bestWork) : "collecting"; @@ -102,8 +108,12 @@ function formatBig(n: number): string {
{{ bestWork }}
-
accepted work
-
{{ acceptedWork }}
+
reward share
+
{{ contributionShare }}
+
+
+
reward work
+
{{ contributionWork }}
reject %
@@ -112,8 +122,8 @@ function formatBig(n: number): string {
-
vdiff
-
{{ isBoomerHeater ? "Fiat" : miner.vdiff.toLocaleString() }}
+
accepted work
+
{{ acceptedWork }}
diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 6ac659d..51dd4b8 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -18,6 +18,12 @@ export type MinerStat = { userAgent: string; subscribed: boolean; status: "hashing" | "stale" | "idle"; + contributionAcceptedWork?: number; + contributionAcceptedShares?: number; + contributionPct?: number; + contributionFirstSeenAt?: number; + contributionLastSeenAt?: number; + currentlyConnected?: boolean; }; export type PoolStat = {