Skip to content

Commit 676aa0a

Browse files
committed
Track payouts + deposits with txids on the admin dashboard
Three new sections on /admin — 'Recent payouts', 'Deposits (mainchain → Thunder)', 'Recent blocks (coinbase → pool BTC wallet)' — so operators (and miners on /admin/worker/:id) can trace the full BTC → Thunder → miner chain by txid. Schema: - New 'payouts' table (append-only history). Populated by the payout worker inside the same atomic transaction that clears payouts_in_flight and increments pps_credits.paid_sats. Both schema.sql and the C-side SCHEMA_SQL declare it so a fresh DB starts complete. - The existing 'deposits' table (previously a placeholder from CLASSIC_PAYOUTS.md) is now actively used. scripts/log-deposit.sh is a tiny CLI to append a row after each manual CreateDepositTransaction (rejects wrapper-form Thunder addresses with the same guardrail as thunder_address_decode). Payout worker: - finalizePayout now writes one row into 'payouts' per successful Thunder transfer, in the same SQLite transaction as the pps_credits UPDATE and the payouts_in_flight DELETE. Preserves the at-most-once protocol; adds an audit trail. - Signature gains an explicit fee_sats param (previously hardcoded 100 in TX_FEE_SATS and lost after the tx). Dashboard: - lib/admin.js: recentPayouts / payoutsForWorker / recentDeposits / recentBlocksFound queries. All read-only, join to workers so we can link to the per-worker audit page from any row. - server.js: adminSummary now returns payouts + deposits + blocks; workerAuditFor attaches per-worker payout history. - views/admin.ejs: three new cards below in-flight, each with a txid column (truncated with hover-title showing the full hash). Recent-blocks card notes that the coinbase txid is derivable via . - views/admin-worker.ejs: 'Payouts to this worker' card lists every Thunder tx we've sent this address with full txid + amount + fee. Verified: 7/7 test suites still green; new schema applies cleanly (CREATE IF NOT EXISTS on the store side, so live DBs pick up the 'payouts' table on the next simplepool restart).
1 parent f4f7695 commit 676aa0a

9 files changed

Lines changed: 342 additions & 11 deletions

File tree

dashboard/lib/admin.js

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,86 @@ export function workerAudit(handle, workerId, { rate, recentLimit = 100, dayLimi
174174
};
175175
}
176176

177+
/* Most recent successful payouts, newest first. Joined to workers so
178+
* the operator can eyeball who was paid + jump to their audit page. */
179+
export function recentPayouts(handle, limit = 25) {
180+
const db = unwrap(handle);
181+
if (!db) return [];
182+
return db.prepare(`
183+
SELECT p.id, p.worker_id, w.name AS worker_name,
184+
w.payout_address AS thunder_address,
185+
p.sats, p.fee_sats, p.txid, p.paid_at, p.note
186+
FROM payouts p
187+
JOIN workers w ON w.id = p.worker_id
188+
ORDER BY p.paid_at DESC, p.id DESC
189+
LIMIT ?
190+
`).all(limit).map(r => ({
191+
...r,
192+
sats: Number(r.sats),
193+
fee_sats: Number(r.fee_sats),
194+
paid_at: Number(r.paid_at),
195+
}));
196+
}
197+
198+
/* Payouts for a specific worker (used on the per-worker audit page). */
199+
export function payoutsForWorker(handle, workerId, limit = 100) {
200+
const db = unwrap(handle);
201+
if (!db) return [];
202+
return db.prepare(`
203+
SELECT id, sats, fee_sats, txid, paid_at, note
204+
FROM payouts
205+
WHERE worker_id = ?
206+
ORDER BY paid_at DESC, id DESC
207+
LIMIT ?
208+
`).all(workerId, limit).map(r => ({
209+
...r,
210+
sats: Number(r.sats),
211+
fee_sats: Number(r.fee_sats),
212+
paid_at: Number(r.paid_at),
213+
}));
214+
}
215+
216+
/* Mainchain → Thunder deposits the operator has made, newest first. */
217+
export function recentDeposits(handle, limit = 25) {
218+
const db = unwrap(handle);
219+
if (!db) return [];
220+
return db.prepare(`
221+
SELECT id, ts, btc_txid, sats_deposited, fee_sats, thunder_recipient,
222+
ctip_seq_before, ctip_seq_after, notes
223+
FROM deposits
224+
ORDER BY ts DESC, id DESC
225+
LIMIT ?
226+
`).all(limit).map(r => ({
227+
...r,
228+
ts: Number(r.ts),
229+
sats_deposited: Number(r.sats_deposited),
230+
fee_sats: Number(r.fee_sats),
231+
}));
232+
}
233+
234+
/* Recent blocks the pool has found — same query the public dashboard
235+
* uses (block.hash is the identifier; the coinbase txid is derivable
236+
* from `bitcoin-cli getblock <hash>`). Included here so admin ops see
237+
* the full BTC-flow chain in one place. */
238+
export function recentBlocksFound(handle, limit = 15) {
239+
const db = unwrap(handle);
240+
if (!db) return [];
241+
return db.prepare(`
242+
SELECT b.id, b.ts, b.height, b.hash, b.finder_id, b.finder_address,
243+
b.reward_sats, b.fee_sats,
244+
w.name AS finder_name
245+
FROM blocks_found b
246+
LEFT JOIN workers w ON w.id = b.finder_id
247+
ORDER BY b.ts DESC, b.id DESC
248+
LIMIT ?
249+
`).all(limit).map(r => ({
250+
...r,
251+
ts: Number(r.ts),
252+
reward_sats: Number(r.reward_sats || 0),
253+
fee_sats: Number(r.fee_sats || 0),
254+
}));
255+
}
256+
177257
/* Minimal Thunder JSON-RPC client — we only need `balance()`. Short
178258
* timeout so the admin page renders promptly even when Thunder is
179259
* unreachable. */

dashboard/server.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,13 +102,20 @@ function requireAdminAuth(req, res, next) {
102102
}
103103

104104
async function adminSummary() {
105-
const [reserve, totals, workers, inFlight] = await Promise.all([
105+
const [reserve, totals, workers, inFlight, payouts, deposits, blocks] = await Promise.all([
106106
admin.thunderBalance(THUNDER_RPC_URL),
107107
Promise.resolve(admin.poolTotals(db)),
108108
Promise.resolve(admin.perWorkerBalances(db)),
109109
Promise.resolve(admin.inFlight(db)),
110+
Promise.resolve(admin.recentPayouts(db, 25)),
111+
Promise.resolve(admin.recentDeposits(db, 25)),
112+
Promise.resolve(admin.recentBlocksFound(db, 15)),
110113
]);
111-
return { reserve, reserveAddress: RESERVE_ADDRESS, totals, workers, inFlight };
114+
return {
115+
reserve, reserveAddress: RESERVE_ADDRESS,
116+
totals, workers, inFlight,
117+
payouts, deposits, blocks,
118+
};
112119
}
113120

114121
app.get('/admin', requireAdminAuth, async (req, res) => {
@@ -132,6 +139,7 @@ app.get('/api/admin/summary', requireAdminAuth, async (req, res) => {
132139
function workerAuditFor(workerId) {
133140
const audit = admin.workerAudit(db, workerId, { rate: PPS_SATS_PER_DIFF });
134141
if (!audit) return null;
142+
audit.payouts = admin.payoutsForWorker(db, workerId, 100);
135143
return audit;
136144
}
137145

dashboard/views/admin-worker.ejs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,36 @@ const drift = audit.totals.accrued_computed - naiveAccrued;
141141
<% } %>
142142
</section>
143143

144+
<section class="card">
145+
<h2>Payouts to this worker</h2>
146+
<% if (!audit.payouts || audit.payouts.length === 0) { %>
147+
<p class="muted">No payouts yet. Once the payout worker fires a Thunder tx to this address, each one gets a row here with its txid.</p>
148+
<% } else { %>
149+
<table>
150+
<thead>
151+
<tr>
152+
<th>When</th>
153+
<th>Amount</th>
154+
<th>Fee</th>
155+
<th>Thunder txid</th>
156+
<th>Note</th>
157+
</tr>
158+
</thead>
159+
<tbody>
160+
<% audit.payouts.forEach(p => { %>
161+
<tr>
162+
<td class="muted small" title="<%= fmtTs(p.paid_at) %>"><%= ago(p.paid_at) %></td>
163+
<td><strong><%= fmtSats(p.sats) %></strong></td>
164+
<td class="muted small"><%= fmtSats(p.fee_sats) %></td>
165+
<td class="mono small"><%= p.txid %></td>
166+
<td class="muted small"><%= p.note || '' %></td>
167+
</tr>
168+
<% }) %>
169+
</tbody>
170+
</table>
171+
<% } %>
172+
</section>
173+
144174
<section class="card">
145175
<h2>Recent shares (last <%= withRunning.length %>, newest first)</h2>
146176
<% if (withRunning.length === 0) { %>

dashboard/views/admin.ejs

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,114 @@ const fmtSats = (sats) => {
144144
</table>
145145
<% } %>
146146
</section>
147+
148+
<section class="card">
149+
<h2>Recent payouts (Thunder)</h2>
150+
<% if (payouts.length === 0) { %>
151+
<p class="muted">No successful payouts yet. Populates once the payout worker sends a Thunder tx (or after a manual reconcile).</p>
152+
<% } else { %>
153+
<table>
154+
<thead>
155+
<tr>
156+
<th>When</th>
157+
<th>Worker</th>
158+
<th>Amount</th>
159+
<th>Fee</th>
160+
<th>Thunder txid</th>
161+
<th>Note</th>
162+
</tr>
163+
</thead>
164+
<tbody>
165+
<% payouts.forEach(p => { %>
166+
<tr>
167+
<td class="muted small" title="<%= fmtTs(p.paid_at) %>"><%= ago(p.paid_at) %></td>
168+
<td class="mono small"><a href="/admin/worker/<%= p.worker_id %>"><%= p.worker_name %></a></td>
169+
<td><strong><%= fmtSats(p.sats) %></strong></td>
170+
<td class="muted small"><%= fmtSats(p.fee_sats) %></td>
171+
<td class="mono small" title="<%= p.txid %>"><%= p.txid.slice(0,16) %><%= p.txid.slice(-6) %></td>
172+
<td class="muted small"><%= p.note || '' %></td>
173+
</tr>
174+
<% }) %>
175+
</tbody>
176+
</table>
177+
<% } %>
178+
</section>
179+
180+
<section class="card">
181+
<h2>Deposits (mainchain → Thunder)</h2>
182+
<% if (deposits.length === 0) { %>
183+
<p class="muted">No deposits recorded. See <code>OPERATOR_GUIDE.md</code> or use <code>scripts/log-deposit.sh</code> after issuing a CreateDepositTransaction.</p>
184+
<% } else { %>
185+
<table>
186+
<thead>
187+
<tr>
188+
<th>When</th>
189+
<th>Amount</th>
190+
<th>Fee</th>
191+
<th>Thunder recipient</th>
192+
<th>Mainchain txid</th>
193+
<th>Ctip seq</th>
194+
</tr>
195+
</thead>
196+
<tbody>
197+
<% deposits.forEach(d => { %>
198+
<tr>
199+
<td class="muted small" title="<%= fmtTs(d.ts) %>"><%= ago(d.ts) %></td>
200+
<td><strong><%= fmtSats(d.sats_deposited) %></strong></td>
201+
<td class="muted small"><%= fmtSats(d.fee_sats) %></td>
202+
<td class="mono small"><%= d.thunder_recipient %></td>
203+
<td class="mono small" title="<%= d.btc_txid %>"><%= d.btc_txid.slice(0,16) %><%= d.btc_txid.slice(-6) %></td>
204+
<td class="muted small"><%= d.ctip_seq_before %><%= d.ctip_seq_after %></td>
205+
</tr>
206+
<% }) %>
207+
</tbody>
208+
</table>
209+
<% } %>
210+
</section>
211+
212+
<section class="card">
213+
<h2>Recent blocks (coinbase → pool BTC wallet)</h2>
214+
<% if (blocks.length === 0) { %>
215+
<p class="muted">No blocks recorded yet.</p>
216+
<% } else { %>
217+
<p class="muted small" style="margin-top:0">
218+
Every block's coinbase pays the pool's <code>pool_btc_address</code> (in pps-classic
219+
mode) for <em>reward</em>. Operator batches accumulated BTC into Thunder via the
220+
deposits above. To see the coinbase txid on-chain:
221+
<code>bitcoin-cli getblock &lt;hash&gt; 2</code>.
222+
</p>
223+
<table>
224+
<thead>
225+
<tr>
226+
<th>When</th>
227+
<th>Height</th>
228+
<th>Block hash</th>
229+
<th>Finder</th>
230+
<th>Reward</th>
231+
<th>Fee</th>
232+
</tr>
233+
</thead>
234+
<tbody>
235+
<% blocks.forEach(b => { %>
236+
<tr>
237+
<td class="muted small" title="<%= fmtTs(b.ts) %>"><%= ago(b.ts) %></td>
238+
<td><%= fmtN(b.height) %></td>
239+
<td class="mono small" title="<%= b.hash %>"><%= b.hash.slice(0,16) %><%= b.hash.slice(-6) %></td>
240+
<td class="mono small">
241+
<% if (b.finder_id) { %>
242+
<a href="/admin/worker/<%= b.finder_id %>"><%= b.finder_name || b.finder_address || 'unknown' %></a>
243+
<% } else { %>
244+
<span class="muted">(unattributed)</span>
245+
<% } %>
246+
</td>
247+
<td><%= fmtSats(b.reward_sats) %></td>
248+
<td class="muted small"><%= fmtSats(b.fee_sats) %></td>
249+
</tr>
250+
<% }) %>
251+
</tbody>
252+
</table>
253+
<% } %>
254+
</section>
147255
</main>
148256
<footer class="foot">
149257
<span>read-only · auto-refresh 15s</span>

payout/lib/db.js

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,12 @@ export function beginPayout(db, workerId, sats, nowSec) {
6969
/* Atomic finalize after a successful Thunder broadcast:
7070
* 1. record the txid on the in-flight row (audit trail)
7171
* 2. increment pps_credits.paid_sats
72-
* 3. delete the in-flight row
73-
* All three happen in one SQLite transaction so a crash inside this
74-
* window leaves the row with the txid set, and the startup sweep can
75-
* finish what step 2/3 didn't. */
76-
export function finalizePayout(db, rowId, workerId, sats, txid, nowSec) {
72+
* 3. append to the permanent payouts ledger
73+
* 4. delete the in-flight row
74+
* All four happen in one SQLite transaction — so on any crash inside
75+
* this window the state stays consistent (either everything applied or
76+
* nothing did), and the startup sweep can finish what 3/4 didn't. */
77+
export function finalizePayout(db, rowId, workerId, sats, feeSats, txid, nowSec) {
7778
db.transaction(() => {
7879
db.prepare(`
7980
UPDATE payouts_in_flight SET txid = ? WHERE id = ?
@@ -84,6 +85,10 @@ export function finalizePayout(db, rowId, workerId, sats, txid, nowSec) {
8485
last_updated = ?
8586
WHERE worker_id = ?
8687
`).run(Number(sats), nowSec, workerId);
88+
db.prepare(`
89+
INSERT INTO payouts (worker_id, sats, fee_sats, txid, paid_at, note)
90+
VALUES (?, ?, ?, ?, ?, NULL)
91+
`).run(workerId, Number(sats), Number(feeSats), txid, nowSec);
8792
db.prepare(`
8893
DELETE FROM payouts_in_flight WHERE id = ?
8994
`).run(rowId);

payout/lib/payout.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ export async function runOnce(ctx, log) {
8484
continue;
8585
}
8686
try {
87-
finalizePayout(db, rowId, r.worker_id, r.owed_sats, txid, now_s);
87+
finalizePayout(db, rowId, r.worker_id, r.owed_sats, TX_FEE_SATS, txid, now_s);
8888
log.info(`payout: ${r.worker_name} -> ${r.thunder_address} ` +
8989
`${r.owed_sats} sats txid=${txid}`);
9090
paid++;

schema.sql

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,20 +66,37 @@ CREATE TABLE IF NOT EXISTS pps_credits (
6666

6767
/* Ledger of operator-triggered mainchain → Thunder deposits, used by
6868
* pool_mode=pps-classic. The C proxy does not touch this table; the
69-
* admin dashboard is the only writer. */
69+
* admin dashboard + a helper CLI are the writers. */
7070
CREATE TABLE IF NOT EXISTS deposits (
7171
id INTEGER PRIMARY KEY AUTOINCREMENT,
7272
ts INTEGER NOT NULL, /* unix seconds */
7373
btc_txid TEXT NOT NULL, /* mainchain deposit tx */
7474
sats_deposited INTEGER NOT NULL,
7575
fee_sats INTEGER NOT NULL,
76-
thunder_recipient TEXT NOT NULL, /* s9_<base58>_<hex6> */
76+
thunder_recipient TEXT NOT NULL, /* bare base58 Thunder addr */
7777
ctip_seq_before INTEGER,
7878
ctip_seq_after INTEGER,
7979
notes TEXT
8080
);
8181
CREATE INDEX IF NOT EXISTS deposits_ts_idx ON deposits(ts);
8282

83+
/* Permanent ledger of successful miner payouts. One row per settled
84+
* Thunder transfer — populated by the payout worker inside the same
85+
* atomic finalize transaction that increments pps_credits.paid_sats
86+
* and drops the payouts_in_flight row. Only append; never mutate
87+
* after write (audit trail). Empty in solo mode. */
88+
CREATE TABLE IF NOT EXISTS payouts (
89+
id INTEGER PRIMARY KEY AUTOINCREMENT,
90+
worker_id INTEGER NOT NULL REFERENCES workers(id),
91+
sats INTEGER NOT NULL,
92+
fee_sats INTEGER NOT NULL,
93+
txid TEXT NOT NULL, /* Thunder tx id (hex) */
94+
paid_at INTEGER NOT NULL, /* unix seconds */
95+
note TEXT /* 'manual' for hand-driven, else NULL */
96+
);
97+
CREATE INDEX IF NOT EXISTS payouts_worker_ts_idx ON payouts(worker_id, paid_at);
98+
CREATE INDEX IF NOT EXISTS payouts_paid_at_idx ON payouts(paid_at);
99+
83100
/* In-flight payout ledger. The payout worker INSERTs a row before
84101
* broadcasting a Thunder transaction; on successful broadcast it
85102
* atomically (in one tx) sets txid, increments pps_credits.paid_sats,

0 commit comments

Comments
 (0)