Skip to content

Commit 17b6d23

Browse files
committed
Per-worker audit view on the admin dashboard
New /admin/worker/:id page that answers 'why is my accrued balance what it is?' end-to-end: - Header: worker name, Thunder payout address, first/last seen - Ledger card: owed, accrued (stored), paid, last updated - Cross-check card: accepted shares, blocks found Σ difficulty rate (= POOL_PPS_SATS_PER_DIFF env) Σ FLOOR(difficulty × rate) — the authoritative sum pps_credits.accrued (stored) with ✓ or ⚠ match indicator + note on per-share vs sum-then-truncate drift - Daily breakdown table (last 30 days with activity): day, shares, Σ diff, sats credited, blocks - Recent shares table (last 100, newest first): id, when, difficulty, credit_sats, is_block, running_accrued Runing_accrued is computed client-side by walking oldest-first over the recent slice, so miners can see their balance grow one share at a time. lib/admin.js exposes workerAudit(handle, workerId, { rate, ... }) — does the SQL for the cross-check (per-share truncation matches what the C proxy does), the daily rollup, and the recent-shares slice in three prepared statements against the read-only handle. server.js: adds /admin/worker/:id (HTML) and /api/admin/worker/:id (JSON) behind the same basic auth. New POOL_PPS_SATS_PER_DIFF env var defaults to 1000 and MUST equal proxy.conf's pps_sats_per_diff or the cross-check flips to ⚠. admin.ejs: adds an 'audit →' link on each worker row of the summary page so operators drill down with one click. deploy/systemd/simplepool-dashboard.service: documents the new env var alongside the existing admin config.
1 parent 41cc0b1 commit 17b6d23

5 files changed

Lines changed: 320 additions & 0 deletions

File tree

dashboard/lib/admin.js

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,103 @@ export function inFlight(handle) {
7777
}));
7878
}
7979

80+
/* Per-worker audit — "why is my accrued balance N sats?" answered with
81+
* the formula, a cross-check, per-day rollup, and the last N shares.
82+
*
83+
* The C proxy credits each accepted share `FLOOR(difficulty * rate)`
84+
* sats where `rate` is proxy.conf's pps_sats_per_diff. Truncation is
85+
* per-share, not once at the end, so `Σ FLOOR(diff × rate)` is the
86+
* authoritative cross-check — NOT `FLOOR(Σ diff × rate)`. Both are
87+
* shown so operators can spot a config drift immediately. */
88+
export function workerAudit(handle, workerId, { rate, recentLimit = 100, dayLimit = 30 } = {}) {
89+
const db = unwrap(handle);
90+
if (!db) return null;
91+
const worker = db.prepare(`
92+
SELECT w.id, w.name, w.payout_address, w.first_seen, w.last_seen,
93+
c.accrued_sats, c.paid_sats, c.last_updated
94+
FROM workers w
95+
LEFT JOIN pps_credits c ON c.worker_id = w.id
96+
WHERE w.id = ?
97+
`).get(workerId);
98+
if (!worker) return null;
99+
100+
const totals = db.prepare(`
101+
SELECT COUNT(*) AS share_count,
102+
COALESCE(SUM(difficulty), 0) AS sum_difficulty,
103+
COALESCE(SUM(CAST(difficulty * ? AS INTEGER)), 0) AS accrued_computed,
104+
MIN(ts) AS first_ts,
105+
MAX(ts) AS last_ts,
106+
COUNT(*) FILTER (WHERE is_block = 1) AS blocks_found
107+
FROM shares
108+
WHERE worker_id = ? AND is_block IS NOT NULL
109+
`).get(rate, workerId);
110+
111+
/* Day-level rollup: (day, shares, sum_diff, sats_credited). Only
112+
* days with activity, most-recent first. */
113+
const days = db.prepare(`
114+
SELECT DATE(ts, 'unixepoch') AS day,
115+
COUNT(*) AS shares,
116+
SUM(difficulty) AS sum_diff,
117+
SUM(CAST(difficulty * ? AS INTEGER)) AS accrued_delta,
118+
COUNT(*) FILTER (WHERE is_block = 1) AS blocks
119+
FROM shares
120+
WHERE worker_id = ?
121+
GROUP BY day
122+
ORDER BY day DESC
123+
LIMIT ?
124+
`).all(rate, workerId, dayLimit);
125+
126+
/* Most-recent shares, cheapest to derive running_accrued client-side. */
127+
const recent = db.prepare(`
128+
SELECT id, ts, difficulty, is_block, block_hash,
129+
CAST(difficulty * ? AS INTEGER) AS credit_sats
130+
FROM shares
131+
WHERE worker_id = ?
132+
ORDER BY ts DESC
133+
LIMIT ?
134+
`).all(rate, workerId, recentLimit);
135+
136+
return {
137+
worker: {
138+
id: Number(worker.id),
139+
name: worker.name,
140+
thunder_address: worker.payout_address,
141+
first_seen: Number(worker.first_seen),
142+
last_seen: Number(worker.last_seen),
143+
},
144+
rate,
145+
ledger: {
146+
accrued: Number(worker.accrued_sats || 0),
147+
paid: Number(worker.paid_sats || 0),
148+
owed: Number(worker.accrued_sats || 0) - Number(worker.paid_sats || 0),
149+
last_updated: Number(worker.last_updated || 0),
150+
},
151+
totals: {
152+
share_count: Number(totals.share_count),
153+
sum_difficulty: Number(totals.sum_difficulty),
154+
accrued_computed: Number(totals.accrued_computed),
155+
first_ts: Number(totals.first_ts || 0),
156+
last_ts: Number(totals.last_ts || 0),
157+
blocks_found: Number(totals.blocks_found),
158+
},
159+
days: days.map(d => ({
160+
day: d.day,
161+
shares: Number(d.shares),
162+
sum_diff: Number(d.sum_diff),
163+
accrued_delta: Number(d.accrued_delta),
164+
blocks: Number(d.blocks),
165+
})),
166+
recent: recent.map(r => ({
167+
id: Number(r.id),
168+
ts: Number(r.ts),
169+
difficulty: Number(r.difficulty),
170+
is_block: Number(r.is_block),
171+
block_hash: r.block_hash,
172+
credit_sats: Number(r.credit_sats),
173+
})),
174+
};
175+
}
176+
80177
/* Minimal Thunder JSON-RPC client — we only need `balance()`. Short
81178
* timeout so the admin page renders promptly even when Thunder is
82179
* unreachable. */

dashboard/server.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ const ADMIN_USER = process.env.ADMIN_USER || '';
7676
const ADMIN_PASS = process.env.ADMIN_PASSWORD || '';
7777
const RESERVE_ADDRESS = process.env.POOL_THUNDER_RESERVE_ADDRESS || '(unset)';
7878
const THUNDER_RPC_URL = process.env.THUNDER_RPC_URL || 'http://127.0.0.1:6009';
79+
/* The rate the C proxy uses to convert difficulty → sats. Must match
80+
* proxy.conf's pps_sats_per_diff or the worker-audit cross-check goes
81+
* red. Passed via the same env-driven config path as the admin creds. */
82+
const PPS_SATS_PER_DIFF = parseFloat(process.env.POOL_PPS_SATS_PER_DIFF || '1000');
7983

8084
function requireAdminAuth(req, res, next) {
8185
if (!ADMIN_USER || !ADMIN_PASS) {
@@ -116,6 +120,33 @@ app.get('/api/admin/summary', requireAdminAuth, async (req, res) => {
116120
res.status(500).json({ error: e.message });
117121
}
118122
});
123+
124+
/* Per-worker audit: "why is my balance N sats?" answered end-to-end. */
125+
function workerAuditFor(workerId) {
126+
const audit = admin.workerAudit(db, workerId, { rate: PPS_SATS_PER_DIFF });
127+
if (!audit) return null;
128+
return audit;
129+
}
130+
131+
app.get('/admin/worker/:id', requireAdminAuth, (req, res) => {
132+
try {
133+
const audit = workerAuditFor(parseInt(req.params.id, 10));
134+
if (!audit) return res.status(404).render('404', { what: 'worker' });
135+
res.render('admin-worker', { audit });
136+
} catch (e) {
137+
res.status(500).send('admin: ' + e.message);
138+
}
139+
});
140+
141+
app.get('/api/admin/worker/:id', requireAdminAuth, (req, res) => {
142+
try {
143+
const audit = workerAuditFor(parseInt(req.params.id, 10));
144+
if (!audit) return res.status(404).json({ error: 'unknown worker id' });
145+
res.json(audit);
146+
} catch (e) {
147+
res.status(500).json({ error: e.message });
148+
}
149+
});
119150
/* END PPS ADMIN PATCH */
120151

121152
app.use((req, res) => res.status(404).render('404', { what: 'page' }));

dashboard/views/admin-worker.ejs

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
<%
2+
const fmtN = (n) => new Intl.NumberFormat('en-US').format(n || 0);
3+
const fmtF = (n, d = 4) => (typeof n === 'number' ? n.toFixed(d) : '');
4+
const fmtTs = (t) => t ? new Date(t * 1000).toISOString().replace('T', ' ').slice(0, 19) + 'Z' : '';
5+
const ago = (t) => {
6+
if (!t) return '';
7+
const s = Math.max(0, Math.floor(Date.now() / 1000) - t);
8+
if (s < 60) return s + 's ago';
9+
if (s < 3600) return Math.floor(s / 60) + 'm ago';
10+
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
11+
return Math.floor(s / 86400) + 'd ago';
12+
};
13+
const fmtSats = (sats) => {
14+
if (!sats) return '0 sats';
15+
const btc = sats / 1e8;
16+
if (btc >= 0.01) return fmtN(sats) + ' sats (' + btc.toFixed(8) + ' BTC)';
17+
return fmtN(sats) + ' sats';
18+
};
19+
/* Precompute a running_accrued column for the recent-shares table.
20+
* The rows come newest-first, so we walk in reverse to accumulate. */
21+
const recentAsc = [...audit.recent].reverse();
22+
let running = 0;
23+
const withRunning = recentAsc.map(r => {
24+
running += r.credit_sats;
25+
return { ...r, running_accrued: running };
26+
}).reverse();
27+
const naiveAccrued = Math.floor(audit.totals.sum_difficulty * audit.rate);
28+
const drift = audit.totals.accrued_computed - naiveAccrued;
29+
%>
30+
<!doctype html>
31+
<html lang="en">
32+
<head>
33+
<meta charset="utf-8">
34+
<meta name="viewport" content="width=device-width, initial-scale=1">
35+
<meta http-equiv="refresh" content="30">
36+
<title>audit · <%= audit.worker.name %> · simplepool</title>
37+
<link rel="stylesheet" href="/static/style.css">
38+
</head>
39+
<body>
40+
<header class="top">
41+
<a href="/" class="brand">simplepool</a>
42+
<span class="tag">worker audit</span>
43+
<span style="margin-left:1em"><a href="/admin">← back to admin</a></span>
44+
</header>
45+
<main class="wrap">
46+
<section class="card info">
47+
<h2>Why is this balance what it is?</h2>
48+
<p class="muted small">
49+
Answers a miner's "where does <em>my</em> number come from"
50+
question at the byte level. Every accepted share credits
51+
<code>FLOOR(difficulty × rate)</code> sats, where <em>rate</em>
52+
comes from <code>proxy.conf</code>'s
53+
<code>pps_sats_per_diff</code>. Truncation is per-share; that's
54+
the authoritative sum shown below.
55+
</p>
56+
</section>
57+
58+
<section class="card">
59+
<h2><%= audit.worker.name %></h2>
60+
<div class="grid">
61+
<div><label>Thunder address</label><span class="mono small"><%= audit.worker.thunder_address || '' %></span></div>
62+
<div><label>First seen</label><span title="<%= fmtTs(audit.worker.first_seen) %>"><%= ago(audit.worker.first_seen) %></span></div>
63+
<div><label>Last seen</label><span title="<%= fmtTs(audit.worker.last_seen) %>"><%= ago(audit.worker.last_seen) %></span></div>
64+
</div>
65+
</section>
66+
67+
<section class="card">
68+
<h2>Ledger</h2>
69+
<div class="grid">
70+
<div><label>Owed (needs payout)</label><strong><%= fmtSats(audit.ledger.owed) %></strong></div>
71+
<div><label>Accrued (all-time, from pps_credits)</label><strong><%= fmtSats(audit.ledger.accrued) %></strong></div>
72+
<div><label>Paid (all-time)</label><strong><%= fmtSats(audit.ledger.paid) %></strong></div>
73+
<div><label>Last updated</label><span title="<%= fmtTs(audit.ledger.last_updated) %>"><%= ago(audit.ledger.last_updated) %></span></div>
74+
</div>
75+
</section>
76+
77+
<section class="card">
78+
<h2>Cross-check</h2>
79+
<p class="muted small" style="margin-top:0">
80+
The pool credits <code>FLOOR(difficulty × <%= fmtN(audit.rate) %>)</code>
81+
sats per accepted share. If the numbers below don't match, the
82+
DB has drifted from the C proxy's log — worth investigating.
83+
</p>
84+
<div class="grid">
85+
<div><label>Accepted shares</label><strong><%= fmtN(audit.totals.share_count) %></strong></div>
86+
<div><label>Blocks found</label><strong><%= fmtN(audit.totals.blocks_found) %></strong></div>
87+
<div><label>Σ difficulty</label><strong><%= fmtF(audit.totals.sum_difficulty, 6) %></strong></div>
88+
<div><label>Rate (sats × diff)</label><strong><%= fmtN(audit.rate) %></strong></div>
89+
<div><label>Σ FLOOR(diff × rate) — authoritative</label><strong><%= fmtSats(audit.totals.accrued_computed) %></strong></div>
90+
<div><label>pps_credits.accrued (stored)</label><strong><%= fmtSats(audit.ledger.accrued) %></strong></div>
91+
</div>
92+
<% if (audit.ledger.accrued === audit.totals.accrued_computed) { %>
93+
<p class="muted small" style="margin-top:0.75em;color:#5b8">
94+
✓ Stored accrued matches the per-share truncated sum.
95+
</p>
96+
<% } else { %>
97+
<p class="muted small" style="margin-top:0.75em;color:#c66">
98+
⚠ Stored accrued differs by
99+
<%= fmtSats(audit.ledger.accrued - audit.totals.accrued_computed) %>.
100+
This usually means the DB was populated across a rate change,
101+
OR someone edited pps_credits by hand. Look at the C
102+
proxy log for the WARN line at the last mode/rate change.
103+
</p>
104+
<% } %>
105+
<p class="muted small" style="margin-top:0.5em">
106+
(Naive sum FLOOR(Σ diff × rate) would be <%= fmtSats(naiveAccrued) %>;
107+
per-share truncation costs
108+
<%= drift === 0 ? '0 sats' : Math.abs(drift) + ' sats' %>
109+
over the window — the difference is the number of shares whose
110+
fractional-sat contribution was rounded down.)
111+
</p>
112+
</section>
113+
114+
<section class="card">
115+
<h2>Daily breakdown</h2>
116+
<% if (audit.days.length === 0) { %>
117+
<p class="muted">No activity yet.</p>
118+
<% } else { %>
119+
<table>
120+
<thead>
121+
<tr>
122+
<th>Day (UTC)</th>
123+
<th>Shares</th>
124+
<th>Σ difficulty</th>
125+
<th>Accrued this day</th>
126+
<th>Blocks</th>
127+
</tr>
128+
</thead>
129+
<tbody>
130+
<% audit.days.forEach(d => { %>
131+
<tr>
132+
<td class="mono small"><%= d.day %></td>
133+
<td><%= fmtN(d.shares) %></td>
134+
<td><%= fmtF(d.sum_diff, 6) %></td>
135+
<td><strong><%= fmtSats(d.accrued_delta) %></strong></td>
136+
<td><%= d.blocks > 0 ? d.blocks : '' %></td>
137+
</tr>
138+
<% }) %>
139+
</tbody>
140+
</table>
141+
<% } %>
142+
</section>
143+
144+
<section class="card">
145+
<h2>Recent shares (last <%= withRunning.length %>, newest first)</h2>
146+
<% if (withRunning.length === 0) { %>
147+
<p class="muted">No shares recorded.</p>
148+
<% } else { %>
149+
<p class="muted small" style="margin-top:0">
150+
The <em>credit</em> column is what this share added to your
151+
balance. <em>Running accrued</em> is the sum from the oldest
152+
visible share up to and including this one.
153+
</p>
154+
<table>
155+
<thead>
156+
<tr>
157+
<th>#</th>
158+
<th>When</th>
159+
<th>Difficulty</th>
160+
<th>Credit</th>
161+
<th>Block?</th>
162+
<th>Running accrued</th>
163+
</tr>
164+
</thead>
165+
<tbody>
166+
<% withRunning.forEach(r => { %>
167+
<tr<%= r.is_block ? ' style="font-weight:600"' : '' %>>
168+
<td class="mono small"><%= r.id %></td>
169+
<td class="mono small" title="<%= fmtTs(r.ts) %>"><%= ago(r.ts) %></td>
170+
<td><%= fmtF(r.difficulty, 6) %></td>
171+
<td><%= fmtSats(r.credit_sats) %></td>
172+
<td><%= r.is_block ? '' + (r.block_hash ? r.block_hash.slice(0, 10) + '' : '') : '' %></td>
173+
<td><%= fmtSats(r.running_accrued) %></td>
174+
</tr>
175+
<% }) %>
176+
</tbody>
177+
</table>
178+
<% } %>
179+
</section>
180+
</main>
181+
<footer class="foot">
182+
<span>read-only · auto-refresh 30s</span>
183+
<span class="sep">·</span>
184+
<span>worker audit</span>
185+
</footer>
186+
</body>
187+
</html>

dashboard/views/admin.ejs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ const fmtSats = (sats) => {
8787
<th>Accrued</th>
8888
<th>Paid</th>
8989
<th>Updated</th>
90+
<th></th>
9091
</tr>
9192
</thead>
9293
<tbody>
@@ -98,6 +99,7 @@ const fmtSats = (sats) => {
9899
<td><%= fmtSats(w.accrued) %></td>
99100
<td><%= fmtSats(w.paid) %></td>
100101
<td class="muted small" title="<%= fmtTs(w.last_updated) %>"><%= ago(w.last_updated) %></td>
102+
<td><a href="/admin/worker/<%= w.worker_id %>" class="small">audit →</a></td>
101103
</tr>
102104
<% }) %>
103105
</tbody>

deploy/systemd/simplepool-dashboard.service

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ Environment=THUNDER_RPC_URL=http://127.0.0.1:6009
1919
# Environment=POOL_THUNDER_RESERVE_ADDRESS=<paste from proxy.conf>
2020
# Environment=ADMIN_USER=admin
2121
# Environment=ADMIN_PASSWORD=<generate with `openssl rand -base64 21`>
22+
# Must equal proxy.conf's pps_sats_per_diff — used by the per-worker
23+
# audit view to cross-check the C proxy's accrual math.
24+
Environment=POOL_PPS_SATS_PER_DIFF=1000
2225
ExecStart=/usr/bin/node @ROOT@/dashboard/server.js
2326
Restart=on-failure
2427
RestartSec=3

0 commit comments

Comments
 (0)