From 7e578518843596fd6c9fe42616b70e25bcaa9f48 Mon Sep 17 00:00:00 2001 From: rob Date: Fri, 25 Sep 2026 20:20:08 +0200 Subject: [PATCH 1/2] dashboard: a Slipstream page, and withdrawing a slipstream tx from admin With SLIPSTREAM_API_URL set, the public nav gains /slipstream: the minimum submission rate and current mineable rate, how to submit, and each recent tx with where it stands -- in template, mined (ours or not), confirmed, dropped with its reason. The page reads the slipstream service's API rather than slipstream.db, so the service stays the only owner of its schema, and a service that is down costs the page one card. Admin -> Tools lists the txs still open, each with a Withdraw button. That calls removeslipstreamtx on the enforcer, which holds the txs; the service records the tx as dropped (withdrawn) on its next poll. --- dashboard/README.md | 19 ++++ dashboard/lib/admin-router.js | 21 +++- dashboard/lib/slipstream.js | 79 +++++++++++++++ dashboard/server.js | 25 +++++ dashboard/test/slipstream.test.js | 129 +++++++++++++++++++++++++ dashboard/views/admin-tools.ejs | 42 ++++++++ dashboard/views/partial/nav-public.ejs | 5 +- dashboard/views/slipstream.ejs | 95 ++++++++++++++++++ deploy/docker/docker-compose.yml | 6 ++ 9 files changed, 419 insertions(+), 2 deletions(-) create mode 100644 dashboard/lib/slipstream.js create mode 100644 dashboard/test/slipstream.test.js create mode 100644 dashboard/views/slipstream.ejs diff --git a/dashboard/README.md b/dashboard/README.md index 8ba28fe..1d99d38 100644 --- a/dashboard/README.md +++ b/dashboard/README.md @@ -67,6 +67,7 @@ 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 @@ -74,6 +75,24 @@ 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. + +Admin → Tools lists the txs still open, each with a **Withdraw** button. That +calls `removeslipstreamtx` on the enforcer (`ENFORCER_GBT_URL`, its block +template server), which holds the txs; the service then records the tx as +dropped (`withdrawn`). Without `ENFORCER_GBT_URL` the list shows, and the +buttons do not. + ## Network difficulty The node-tip card on `/` — and `/api/node` — report the chain's current diff --git a/dashboard/lib/admin-router.js b/dashboard/lib/admin-router.js index 92ff683..d02f350 100644 --- a/dashboard/lib/admin-router.js +++ b/dashboard/lib/admin-router.js @@ -1,4 +1,4 @@ -/* Admin router: the 5 sub-pages + the 4 write-action POSTs + the +/* Admin router: the 5 sub-pages + the write-action POSTs + the * per-worker audit page + /admin/logout. * * Every route on this router is protected by requireAdminAuth (mounted @@ -12,6 +12,7 @@ import * as admin from './admin.js'; import * as actions from './actions.js'; import { issueToken, consumeToken } from './csrf.js'; import { depositStatuses, summarise } from './deposit-status.js'; +import { fetchSlipstream, withdrawSlipstreamTx } from './slipstream.js'; export function createAdminRouter({ db, // read-only handle @@ -21,6 +22,8 @@ export function createAdminRouter({ ENFORCER_GRPC_ADDR, THUNDER_SIDECHAIN_ID, RESERVE_ADDRESS, + SLIPSTREAM_API_URL, + ENFORCER_GBT_URL, } = {}) { const router = express.Router(); const parseAdminForm = express.urlencoded({ extended: false, limit: '4kb' }); @@ -104,6 +107,14 @@ export function createAdminRouter({ reserve: { ok: false, error: e.message }, errors: [e.message] })); } + if (view === 'admin-tools') { + /* Only what can still be withdrawn: a mined tx is past taking + * back. */ + extra.slipstream = await fetchSlipstream(SLIPSTREAM_API_URL, { + statuses: ['pending', 'in_template'], limit: 200, + }); + extra.slipstreamWithdrawEnabled = !!ENFORCER_GBT_URL; + } res.render(view, { ...summary, ...extra, @@ -199,6 +210,14 @@ export function createAdminRouter({ } }); + router.post('/action/slipstream-withdraw', parseAdminForm, requireCsrf, async (req, res) => { + const r = await withdrawSlipstreamTx({ + enforcerGbtUrl: ENFORCER_GBT_URL, + txid: (req.body?.txid || '').trim(), + }); + flashRedirect(res, r, returnTarget(req)); + }); + router.post('/action/deposit', parseAdminForm, requireCsrf, async (req, res) => { const r = await actions.createDeposit({ enforcerGrpcAddr: ENFORCER_GRPC_ADDR, diff --git a/dashboard/lib/slipstream.js b/dashboard/lib/slipstream.js new file mode 100644 index 0000000..6101c3a --- /dev/null +++ b/dashboard/lib/slipstream.js @@ -0,0 +1,79 @@ +/* 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. + * + * Withdrawing goes to the enforcer, which holds the tx, not to the service, + * which has no authenticated surface to take it from. The service sees the + * tx leave on its next poll and records it as dropped (withdrawn). + */ + +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 }; + } +} + +/* { ok, msg, detail } like every admin action. Never throws. */ +export async function withdrawSlipstreamTx({ enforcerGbtUrl, txid }) { + if (!enforcerGbtUrl) { + return { ok: false, msg: 'withdraw unavailable', detail: 'ENFORCER_GBT_URL is not set' }; + } + if (!/^[0-9a-fA-F]{64}$/.test(txid || '')) { + return { ok: false, msg: 'withdraw refused', detail: 'txid must be 64 hex chars' }; + } + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + try { + const res = await fetch(enforcerGbtUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'removeslipstreamtx', + params: [txid.toLowerCase()] }), + signal: ctrl.signal, + }); + const body = await res.json(); + if (body.error) { + return { ok: false, msg: 'withdraw failed', + detail: `${body.error.code} ${body.error.message}` }; + } + const removed = body.result ?? []; + return removed.length > 0 + ? { ok: true, msg: `withdrew ${removed.length} tx(s)`, detail: removed.join(', ') } + : { ok: false, msg: 'nothing withdrawn', + detail: 'the enforcer is not holding that tx (already mined or gone), ' + + 'or the node holds its own copy and it stays in the mempool' }; + } catch (e) { + return { ok: false, msg: 'withdraw failed', detail: e.message }; + } finally { + clearTimeout(t); + } +} diff --git a/dashboard/server.js b/dashboard/server.js index f216cb1..f197b04 100644 --- a/dashboard/server.js +++ b/dashboard/server.js @@ -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); @@ -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(); }); @@ -75,6 +78,14 @@ startHealthMonitor(db, { intervalMs: HEALTH_INTERVAL_MS }); /* --- public-side config ------------------------------------------------- */ const PUBLIC_STRATUM_URL = process.env.PUBLIC_STRATUM_URL || 'stratum+tcp://: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. ENFORCER_GBT_URL, the enforcer's block template server, is + * what the admin withdraw action talks to: the enforcer holds the txs. */ +const SLIPSTREAM_API_URL = process.env.SLIPSTREAM_API_URL || ''; +const PUBLIC_SLIPSTREAM_URL = process.env.PUBLIC_SLIPSTREAM_URL || ''; +const ENFORCER_GBT_URL = process.env.ENFORCER_GBT_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 @@ -199,6 +210,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) || {})); @@ -290,6 +313,8 @@ app.use('/admin', ENFORCER_GRPC_ADDR, THUNDER_SIDECHAIN_ID, RESERVE_ADDRESS, + SLIPSTREAM_API_URL, + ENFORCER_GBT_URL, })); /* ================================ 404 =================================== */ diff --git a/dashboard/test/slipstream.test.js b/dashboard/test/slipstream.test.js new file mode 100644 index 0000000..50ba36e --- /dev/null +++ b/dashboard/test/slipstream.test.js @@ -0,0 +1,129 @@ +/* The slipstream page, its nav link, and the admin withdraw action. + * + * 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, withdrawSlipstreamTx } 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>1\.00 sat\/vB/); + assert.match(html, /Current mineable rate<\/label>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*\(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"/); +}); + +test('admin tools lists open txs with a withdraw button', async () => { + const html = await render('admin-tools.ejs', { + flash: null, csrfToken: 'tok', + slipstream: { configured: true, ok: true, fees: FEES, + txs: [{ ...TXS[0], status: 'in_template' }] }, + slipstreamWithdrawEnabled: true, + }); + assert.match(html, /action="\/admin\/action\/slipstream-withdraw"/); + assert.match(html, new RegExp(`name="txid" value="${TXID}"`)); + const off = await render('admin-tools.ejs', { flash: null, csrfToken: 'tok' }); + assert.doesNotMatch(off, /Slipstream: open transactions/); +}); + +/* A stub HTTP server answering one handler, for the two clients. */ +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); +}); + +test('withdrawing asks the enforcer to remove the tx', async () => { + const s = await stub((_url, body) => ({ json: { jsonrpc: '2.0', id: body.id, result: [body.params[0]] } })); + try { + const r = await withdrawSlipstreamTx({ enforcerGbtUrl: s.url, txid: TXID.toUpperCase() }); + assert.equal(r.ok, true); + assert.equal(s.calls[0].body.method, 'removeslipstreamtx'); + assert.deepEqual(s.calls[0].body.params, [TXID]); + } finally { + await s.close(); + } + assert.equal((await withdrawSlipstreamTx({ enforcerGbtUrl: '', txid: TXID })).ok, false); + assert.equal((await withdrawSlipstreamTx({ enforcerGbtUrl: 'http://x', txid: 'nope' })).ok, false); +}); diff --git a/dashboard/views/admin-tools.ejs b/dashboard/views/admin-tools.ejs index 322cd6d..da29b2e 100644 --- a/dashboard/views/admin-tools.ejs +++ b/dashboard/views/admin-tools.ejs @@ -52,6 +52,48 @@ + + <% if (typeof slipstream !== 'undefined' && slipstream.configured) { %> +
+

Slipstream: open transactions

+

+ Transactions submitted through slipstream that are not mined + yet. Withdrawing takes a tx, and any slipstream txs spending it, + out of the enforcer's mempool, so no template carries it again; + the slipstream service then records it as dropped + (withdrawn). Nothing is broadcast either way. +

+ <% if (!slipstream.ok) { %> +

The slipstream service is not answering (<%= slipstream.error %>).

+ <% } else if (slipstream.txs.length === 0) { %> +

Nothing open.

+ <% } else { %> + + + + <% for (const tx of slipstream.txs) { %> + + + + + + + + <% } %> + +
txidfee ratestatussubmitted
<%= tx.txid.slice(0, 16) %>…<%= fmtF(tx.fee_rate, 2) %> sat/vB<%= tx.status.replace('_', ' ') %><%= ago(tx.submitted_at) %> + <% if (slipstreamWithdrawEnabled) { %> +
+ + + + +
+ <% } else { %>set ENFORCER_GBT_URL<% } %> +
+ <% } %> +
+ <% } %>