Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions dashboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,26 @@ snapshot cron — SQLite's WAL mode makes that safe too, just less isolated.
| `/api/status` | Everything at once: pool, node, health, versions |
| `/healthz` | `{ ok: true, db_ready: bool }` |
| `/health` | Full hard-failure detail; 503 when a check is failing |
| `/slipstream` | Slipstream fees and recent txs (only with `SLIPSTREAM_API_URL`) |

`/api/status` is the one URL to poll if you want a single document: pool
totals and hashrate, mainchain tip, the health checks, and which commit of
each component is running. It always returns 200 — it is a report, and a
report that a check is failing was still produced successfully. Watch
`health.ok` for the condition and `/health` for a status code to alert on.

## Slipstream

Set `SLIPSTREAM_API_URL` (where this process reaches the [slipstream
service](../slipstream/README.md), e.g. `http://127.0.0.1:8124`) and the
public nav gains a **Slipstream** page: the minimum submission rate, the
current mineable rate, how to submit, and each recent tx with where it
stands. `PUBLIC_SLIPSTREAM_URL` is the address shown to submitters.

The page reads the service's API, never `slipstream.db`, so the service stays
the only owner of its schema; if it is down, the page says so and the rest of
the dashboard is unaffected.

## Network difficulty

The node-tip card on `/` — and `/api/node` — report the chain's current
Expand Down
39 changes: 39 additions & 0 deletions dashboard/lib/slipstream.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/* The slipstream service, as the dashboard shows it.
*
* Read through the service's own API rather than its database: the service
* owns slipstream.db, and a second reader of its schema is a second place to
* keep in step with it. Every call degrades to { ok: false, error } so a
* service that is down costs the page one card, not the page.
*/

const TIMEOUT_MS = 3000;

async function getJson(url) {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
try {
const res = await fetch(url, { signal: ctrl.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} finally {
clearTimeout(t);
}
}

/* Fees and recent txs. `statuses` narrows the list, e.g. to what is still
* open for the admin page. */
export async function fetchSlipstream(apiUrl, { limit = 50, statuses = null } = {}) {
if (!apiUrl) return { configured: false, ok: false, fees: null, txs: [] };
const base = apiUrl.replace(/\/+$/, '');
try {
const lists = statuses
? statuses.map(s => getJson(`${base}/api/txs?status=${encodeURIComponent(s)}&limit=${limit}`))
: [getJson(`${base}/api/txs?limit=${limit}`)];
const [fees, ...pages] = await Promise.all([getJson(`${base}/api/fees`), ...lists]);
const txs = pages.flatMap(p => p.txs ?? [])
.sort((a, b) => b.submitted_at - a.submitted_at);
return { configured: true, ok: true, fees, txs };
} catch (e) {
return { configured: true, ok: false, fees: null, txs: [], error: e.message };
}
}
21 changes: 21 additions & 0 deletions dashboard/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { startHealthMonitor, currentHealth } from './lib/health.js';
import { versions } from './lib/versions.js';
import * as fmt from './lib/fmt.js';
import { createAdminRouter } from './lib/admin-router.js';
import { fetchSlipstream } from './lib/slipstream.js';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = parseInt(process.env.PORT || '8081', 10);
Expand Down Expand Up @@ -55,6 +56,8 @@ app.use((_req, res, next) => {
* rather than being threaded through one render call. */
res.locals.stratumUrl = PUBLIC_STRATUM_URL;
res.locals.sidechainId = THUNDER_SIDECHAIN_ID;
/* The Slipstream nav link appears only on a pool that runs it. */
res.locals.slipstreamEnabled = !!SLIPSTREAM_API_URL;
next();
});

Expand All @@ -75,6 +78,12 @@ startHealthMonitor(db, { intervalMs: HEALTH_INTERVAL_MS });
/* --- public-side config ------------------------------------------------- */
const PUBLIC_STRATUM_URL = process.env.PUBLIC_STRATUM_URL || 'stratum+tcp://<pool-host>:3334';

/* Slipstream (optional). SLIPSTREAM_API_URL is where this process reaches
* the service, e.g. http://127.0.0.1:8124; PUBLIC_SLIPSTREAM_URL is where
* submitters do. */
const SLIPSTREAM_API_URL = process.env.SLIPSTREAM_API_URL || '';
const PUBLIC_SLIPSTREAM_URL = process.env.PUBLIC_SLIPSTREAM_URL || '';

/* The PPS rate is NOT configured here. It is read from pool_meta, which the
* proxy writes on every template change, so the dashboard always reports the
* rate that was actually applied rather than a second copy of the config
Expand Down Expand Up @@ -199,6 +208,18 @@ app.get('/templates', (req, res) => {
});
});

/* Slipstream: its fees and what has been submitted. 404 on a pool that
* does not run it, rather than a page explaining the absence of a feature. */
app.get('/slipstream', async (_req, res, next) => {
if (!SLIPSTREAM_API_URL) return res.status(404).render('404', { what: 'page' });
try {
res.render('slipstream', {
slip: await fetchSlipstream(SLIPSTREAM_API_URL, { limit: 50 }),
submitUrl: PUBLIC_SLIPSTREAM_URL || null,
});
} catch (e) { next(e); }
});

/* --- JSON API (unchanged) ---------------------------------------------- */
app.get('/api/overview', (_req, res) => res.json(stats.overview(db)));
app.get('/api/node', (_req, res) => res.json(stats.nodeStatus(db) || {}));
Expand Down
102 changes: 102 additions & 0 deletions dashboard/test/slipstream.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/* The slipstream page and its nav link.
*
* The page reads the slipstream service's API, never its database, and has
* to render when the service is down: a pool whose slipstream service fell
* over should lose one card, not the page.
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import { createServer } from 'node:http';
import { fileURLToPath } from 'node:url';
import ejs from 'ejs';

import * as fmt from '../lib/fmt.js';
import { fetchSlipstream } from '../lib/slipstream.js';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const VIEWS = path.resolve(__dirname, '../views');

const TXID = 'ab'.repeat(32);
const FEES = { min_submission_rate: 1, mineable_rate: 3.5, required_rate: 3.5,
template_height: 101, updated_at: Math.floor(Date.now() / 1000) };
const TXS = [
{ txid: TXID, fee_rate: 7.09, status: 'confirmed', status_reason: null,
mined_block_hash: 'cd'.repeat(32), mined_height: 100, mined_by_pool: 1,
submitted_at: Math.floor(Date.now() / 1000) - 600 },
{ txid: 'ef'.repeat(32), fee_rate: 2, status: 'dropped', status_reason: 'conflict_mined',
mined_block_hash: null, mined_height: null, mined_by_pool: null,
submitted_at: Math.floor(Date.now() / 1000) - 60 },
];

const render = (view, locals) => ejs.renderFile(path.join(VIEWS, view), {
...fmt.all, pool: null, health: null, stratumUrl: 'stratum+tcp://pool.example:3334',
sidechainId: 9, slipstreamEnabled: true, ...locals,
}, { views: [VIEWS] });

test('the page shows both rates, how to submit, and each tx where it stands', async () => {
const html = await render('slipstream.ejs', {
slip: { configured: true, ok: true, fees: FEES, txs: TXS },
submitUrl: 'https://slipstream.example/',
});
assert.match(html, /Minimum submission rate<\/label><strong>1\.00 sat\/vB/);
assert.match(html, /Current mineable rate<\/label><strong>3\.50 sat\/vB/);
assert.match(html, /curl -X POST --data-binary "\$RAW_TX_HEX" https:\/\/slipstream\.example\/api\/tx/);
assert.match(html, /href="https:\/\/slipstream\.example\/info\.json"/);
assert.match(html, /confirmed/);
assert.match(html, /\(ours\)/);
assert.match(html, /dropped<\/span>\s*<span class="muted small">\(conflict_mined\)/);
assert.match(html, /href="\/slipstream" class="active">Slipstream/);
});

test('a service that is down costs the page its content, not the page', async () => {
const html = await render('slipstream.ejs', {
slip: { configured: true, ok: false, fees: null, txs: [], error: 'HTTP 502' },
submitUrl: null,
});
assert.match(html, /not answering right now \(HTTP 502\)/);
assert.doesNotMatch(html, /Minimum submission rate/);
});

test('the nav links to slipstream only on a pool that runs it', async () => {
const nav = (slipstreamEnabled) => render('partial/nav-public.ejs', { slipstreamEnabled });
assert.match(await nav(true), /href="\/slipstream"/);
assert.doesNotMatch(await nav(false), /href="\/slipstream"/);
});

/* A stub HTTP server answering one handler. */
async function stub(handler) {
const calls = [];
const server = createServer(async (req, res) => {
let body = '';
for await (const chunk of req) body += chunk;
calls.push({ url: req.url, body: body ? JSON.parse(body) : null });
const { status = 200, json } = handler(req.url, body ? JSON.parse(body) : null);
res.statusCode = status;
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify(json));
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
return { url: `http://127.0.0.1:${server.address().port}`, calls,
close: () => new Promise(resolve => server.close(resolve)) };
}

test('fetchSlipstream reads fees and txs from the service API', async () => {
const s = await stub((url) => url.startsWith('/api/fees')
? { json: FEES }
: { json: { txs: url.includes('status=pending') ? [TXS[1]] : [TXS[0]] } });
try {
const all = await fetchSlipstream(s.url);
assert.equal(all.ok, true);
assert.equal(all.fees.mineable_rate, 3.5);
assert.equal(all.txs[0].txid, TXID);
const open = await fetchSlipstream(s.url + '/', { statuses: ['pending', 'in_template'] });
assert.ok(s.calls.some(c => c.url.startsWith('/api/txs?status=in_template')));
assert.equal(open.txs.length, 2);
} finally {
await s.close();
}
assert.equal((await fetchSlipstream('')).configured, false);
const down = await fetchSlipstream('http://127.0.0.1:1');
assert.equal(down.ok, false);
});
5 changes: 4 additions & 1 deletion dashboard/views/partial/nav-public.ejs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<%# Top nav for the public (read-only) dashboard.
Locals: active ('overview'|'blocks'|'templates'|'worker'). %>
Locals: active ('overview'|'blocks'|'templates'|'slipstream'|'worker'). %>
<% const _pactive = (typeof active === 'string') ? active : ''; %>
<%# The chip used to read "stratum stats", which every page already looks
like. It now carries the two facts that change what the numbers below
Expand All @@ -16,6 +16,9 @@
<a href="/" class="<%= _pactive === 'overview' ? 'active' : '' %>">Overview</a>
<a href="/blocks" class="<%= _pactive === 'blocks' ? 'active' : '' %>">Blocks</a>
<a href="/templates" class="<%= _pactive === 'templates' ? 'active' : '' %>">Templates</a>
<% if (typeof slipstreamEnabled !== 'undefined' && slipstreamEnabled) { %>
<a href="/slipstream" class="<%= _pactive === 'slipstream' ? 'active' : '' %>">Slipstream</a>
<% } %>
</nav>
<form action="/worker-lookup" method="GET" class="topsearch" role="search">
<input type="text"
Expand Down
95 changes: 95 additions & 0 deletions dashboard/views/slipstream.ejs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<%# Public view of the slipstream service. Locals: slip (from
lib/slipstream.js fetchSlipstream), submitUrl (PUBLIC_SLIPSTREAM_URL or
null). %>
<!doctype html>
<html lang="en">
<head>
<%- include('partial/head', { title: 'slipstream · simplepool', refresh: 20 }) %>
</head>
<body>
<%- include('partial/nav-public', { active: 'slipstream' }) %>
<main class="wrap">

<section class="card info">
<h2>Slipstream</h2>
<p class="muted small" style="margin-top:0">
Submit a transaction straight to this pool and it is taken into
the templates this pool's miners work on — including
transactions the network will not relay, such as BIP300/301
deposits, withdrawal bundles and BMM requests. Anything
consensus-valid is taken; the enforcer's BIP300 rules apply too.
Once accepted it is broadcast like any other transaction, so
another pool may mine it first.
</p>
</section>

<% if (!slip.ok) { %>
<section class="card">
<p class="bad">The slipstream service is not answering right now<%= slip.error ? ' (' + slip.error + ')' : '' %>.</p>
</section>
<% } else { const f = slip.fees; %>
<section class="card">
<h2>Fees</h2>
<div class="grid">
<div><label>Minimum submission rate</label><strong><%= fmtF(f.min_submission_rate, 2) %> sat/vB</strong></div>
<div><label>Current mineable rate</label><strong><%= fmtF(f.mineable_rate, 2) %> sat/vB</strong></div>
<div><label>Required now</label><strong><%= fmtF(f.required_rate, 2) %> sat/vB</strong></div>
<% if (f.updated_at) { %>
<div><label>Read from the template</label><strong title="<%= fmtTs(f.updated_at) %>"><%= ago(f.updated_at) %></strong></div>
<% } %>
</div>
<p class="muted small" style="margin-top:0.75em">
A transaction must pay the higher of the minimum submission rate
and the current mineable rate: the rate of the cheapest
transaction in the block being mined once it is full, the minimum
until then. It is checked once, when it is submitted.
</p>
</section>

<section class="card">
<h2>Submit</h2>
<% if (submitUrl) { %>
<pre class="mono small">curl -X POST --data-binary "$RAW_TX_HEX" <%= submitUrl.replace(/\/+$/, '') %>/api/tx</pre>
<p class="muted small">
Then follow it at <span class="mono"><%= submitUrl.replace(/\/+$/, '') %>/api/tx/&lt;txid&gt;</span>.
The pool's listing for directories is at
<a href="<%= submitUrl.replace(/\/+$/, '') %>/info.json">info.json</a>.
</p>
<% } else { %>
<p class="muted small">This pool has not published its slipstream address.</p>
<% } %>
</section>

<section class="card">
<h2>Recent transactions</h2>
<% if (slip.txs.length === 0) { %>
<p class="muted small">Nothing has been submitted yet.</p>
<% } else { %>
<table>
<thead><tr>
<th>txid</th><th>fee rate</th><th>status</th><th>block</th><th>submitted</th>
</tr></thead>
<tbody>
<% for (const tx of slip.txs) { %>
<tr>
<td class="mono" title="<%= tx.txid %>"><%= tx.txid.slice(0, 16) %>…</td>
<td><%= fmtF(tx.fee_rate, 2) %> sat/vB</td>
<td><span class="<%= ['dropped', 'expired'].includes(tx.status) ? 'bad' : (tx.status === 'confirmed' ? 'ok' : '') %>"><%= tx.status.replace('_', ' ') %></span><% if (tx.status_reason) { %>
<span class="muted small">(<%= tx.status_reason %>)</span><% } %></td>
<td><% if (tx.mined_block_hash) { %>
<span class="mono" title="<%= tx.mined_block_hash %>"><%= tx.mined_height !== null ? fmtN(tx.mined_height) : tx.mined_block_hash.slice(0, 12) + '…' %></span><% if (tx.mined_by_pool === 1) { %> <span class="muted small">(ours)</span><% } else if (tx.mined_by_pool === 0) { %> <span class="muted small">(another pool)</span><% } %>
<% } else { %><span class="muted">—</span><% } %></td>
<td title="<%= fmtTs(tx.submitted_at) %>"><%= ago(tx.submitted_at) %></td>
</tr>
<% } %>
</tbody>
</table>
<% } %>
</section>
<% } %>
</main>
<footer class="foot">
<span>slipstream</span>
</footer>
</body>
</html>
5 changes: 5 additions & 0 deletions deploy/docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ services:
PAYOUT_ADMIN_URL: ${PAYOUT_ADMIN_URL:-http://host.docker.internal:9080}
ENFORCER_GRPC_ADDR: ${ENFORCER_GRPC_ADDR:-host.docker.internal:50051}
THUNDER_SIDECHAIN_ID: ${THUNDER_SIDECHAIN_ID:-9}
# --- Slipstream (optional) ---
# Set SLIPSTREAM_API_URL=http://slipstream:8124 (the compose service
# below, over the compose network) to show the Slipstream page.
SLIPSTREAM_API_URL: ${SLIPSTREAM_API_URL:-}
PUBLIC_SLIPSTREAM_URL: ${PUBLIC_SLIPSTREAM_URL:-}
volumes:
# Dashboard now writes to the deposits table when the operator
# submits a deposit via /admin. Mount RW.
Expand Down
Loading