Skip to content

Commit 64b3ba4

Browse files
committed
add: show next block based on stratum jobs
This subscribes to a stratum jobs feed (currently from stream.stratum.work) and renders to-be-mined blocks. This allows us to see which pool is mining on which block. The feed is keyed by pool, not by the block being mined on: a pool mines on exactly one block at a time, so a new job replaces whatever we knew about that pool. Otherwise a pool that switched to a new block would keep haunting the one it had already left until its TTL ran out — right after a new block is found, that would show most pools mining on two blocks at once. This is experimental and needs `?mining` as an argument to the URL to be enabled.
1 parent f230512 commit 64b3ba4

2 files changed

Lines changed: 194 additions & 8 deletions

File tree

www/js/blocktree.js

Lines changed: 111 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,93 @@ let countdownLayer = g
142142
.append("g")
143143
.attr("id", "countdown")
144144

145+
// context from the last draw, so job updates can refresh just the pool cloud (and
146+
// detect whether a full redraw is actually needed) without re-rendering the blocks.
147+
let miningDrawCtx = null
148+
149+
// invert the current stratum jobs (state_stratum_jobs, one entry per pool, kept up to
150+
// date in main.js) into a map of prev_hash -> { parent, pool_names }: the pools mining
151+
// on each block we recognise. Expired pools are pruned here so the set stays current
152+
// without a separate timer.
153+
function current_mining_by_prev(header_infos) {
154+
let result = new Map()
155+
if (typeof state_stratum_jobs === "undefined" || state_stratum_jobs.size === 0) {
156+
return result
157+
}
158+
const now = Date.now()
159+
// index the real headers by hash so we can resolve a job's parent block
160+
let by_hash = new Map()
161+
header_infos.forEach(h => by_hash.set(h.hash, h))
162+
163+
state_stratum_jobs.forEach((job, pool_name) => {
164+
// drop pools we haven't heard from in a while
165+
if (now - job.last_seen > STRATUM_JOB_TTL_MS) {
166+
state_stratum_jobs.delete(pool_name)
167+
return
168+
}
169+
// only show ghosts that build on a block we actually know about
170+
let parent = by_hash.get(job.prev_hash)
171+
if (parent === undefined) return
172+
let entry = result.get(job.prev_hash)
173+
if (entry === undefined) {
174+
entry = { parent, pool_names: [] }
175+
result.set(job.prev_hash, entry)
176+
}
177+
entry.pool_names.push(pool_name)
178+
})
179+
// stable order, so the labels don't get reshuffled between refreshes
180+
result.forEach(entry => entry.pool_names.sort())
181+
return result
182+
}
183+
184+
// build synthetic "being mined" header objects to inject into the tree. One ghost
185+
// block per prev_hash we recognise, aggregating all pools mining on top of it.
186+
function build_mining_headers(header_infos) {
187+
let mining_headers = []
188+
current_mining_by_prev(header_infos).forEach(({ parent, pool_names }, prev_hash) => {
189+
mining_headers.push({
190+
id: "mining-" + prev_hash,
191+
prev_id: parent.id,
192+
height: parent.height + 1,
193+
hash: "mining-" + prev_hash,
194+
prev_blockhash: prev_hash,
195+
// pool names are shown in a force-positioned cloud around the block, so the
196+
// block itself carries no miner label
197+
miner: "",
198+
// not MIN_DIFFICULTY, so it doesn't get the accent-colored stroke
199+
difficulty_int: 0,
200+
status: "mining",
201+
mining_pools: pool_names,
202+
})
203+
})
204+
return mining_headers
205+
}
206+
207+
// called when new stratum jobs arrive. If the set of being-mined blocks is unchanged
208+
// (the common case: same tip, just a shifting pool set) only the pool cloud is
209+
// refreshed, leaving the block DOM — and its running pulse animation — untouched. A
210+
// full redraw happens only when a ghost block appears or disappears.
211+
function refresh_mining() {
212+
if (miningDrawCtx === null) {
213+
draw({ preserveView: true })
214+
return
215+
}
216+
let desired = current_mining_by_prev(miningDrawCtx.header_infos)
217+
let current_keys = miningDrawCtx.ghostByPrev
218+
let same = desired.size === current_keys.size &&
219+
Array.from(desired.keys()).every(k => current_keys.has(k))
220+
if (!same) {
221+
draw({ preserveView: true })
222+
return
223+
}
224+
// same set of ghosts: update their pool lists in place and re-lay-out the cloud only
225+
desired.forEach(({ pool_names }, prev_hash) => {
226+
let node = current_keys.get(prev_hash)
227+
if (node) node.data.data.mining_pools = pool_names
228+
})
229+
draw_mining_pool_clouds(miningDrawCtx.root_node, miningDrawCtx.htoi)
230+
}
231+
145232
function preprocess_data(data) {
146233
let header_infos = data.header_infos;
147234
let node_infos = data.nodes;
@@ -166,13 +253,18 @@ function preprocess_data(data) {
166253
header_info.is_tip = status != undefined
167254
})
168255

256+
// synthetic "being mined" blocks from the stratum jobs feed, injected as children
257+
// of the block each pool builds on. max_height (below) stays based on the real
258+
// headers only, so these ghosts are not treated as the animated newest block.
259+
let mining_headers = build_mining_headers(header_infos)
260+
169261
var treeData = d3
170262
.stratify()
171263
.id(d => d.id)
172264
.parentId(function (d) {
173265
// d3js requires the first prev block hash to be null
174266
return (d.prev_id == MAX_USIZE ? null : d.prev_id)
175-
})(header_infos);
267+
})(header_infos.concat(mining_headers));
176268

177269
stripUninteresting(treeData, 4)
178270

@@ -214,7 +306,8 @@ function preprocess_data(data) {
214306
return [root_node, max_height, htoi]
215307
}
216308

217-
function draw() {
309+
function draw(opts) {
310+
opts = opts || {}
218311
let data = state_data
219312

220313
// nothing to draw if there are no headers
@@ -434,6 +527,13 @@ function draw() {
434527
// pool names orbiting each "being mined" block, laid out with a force simulation
435528
draw_mining_pool_clouds(root_node, htoi)
436529

530+
// remember what we drew so incoming jobs can refresh the cloud in place, without a
531+
// full redraw that would restart the ghost blocks' pulse animation
532+
let ghostByPrev = new Map()
533+
root_node.descendants().filter(d => d.data.data.status == "mining")
534+
.forEach(d => ghostByPrev.set(d.data.data.prev_blockhash, d))
535+
miningDrawCtx = { root_node, htoi, header_infos: data.header_infos, ghostByPrev }
536+
437537
// size the miner background box to fit its (already positioned) text
438538
recalc_miner_boxes()
439539

@@ -519,18 +619,21 @@ function draw() {
519619
})
520620
descLayer.raise()
521621

522-
// most redraws (a node's data changed) re-render the very same layout, so the block
523-
// we anchor on hasn't moved. Panning the camera to where it already is still runs a
524-
// zoom transition, and that is visible as a jump — so only re-anchor when there is
525-
// something to re-anchor to.
622+
// most redraws (a node's data changed, a new stratum job) re-render the very same
623+
// layout, so the block we anchor on hasn't moved. Panning the camera to where it
624+
// already is still runs a zoom transition, and that is visible as a jump — so only
625+
// re-anchor when there is something to re-anchor to.
526626
let anchor_moved = offset_x != lastTipPos.x || offset_y != lastTipPos.y
527627
lastTipPos = { x: offset_x, y: offset_y }
528628

529-
if (initialDraw || anchor_moved) {
629+
// job-triggered redraws (new stratum jobs arriving) pass preserveView so the
630+
// viewport isn't yanked back to the tip while the user is panning around.
631+
if (!opts.preserveView && (initialDraw || anchor_moved)) {
530632
zoom.scaleBy(svg, 1);
531633
let svgSize = d3.select("#drawing-area").node().getBoundingClientRect();
532634
zoom.translateTo(svg.transition(d3.transition().duration(initialDraw ? 0 : 750)), offset_x, offset_y, o.tip_anchor(svgSize.width, svgSize.height))
533-
635+
// only clear this once the view has actually been anchored, so a job-triggered
636+
// preserveView redraw can't consume it before the first real draw
534637
initialDraw = false
535638
}
536639
}

www/js/main.js

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,3 +308,86 @@ changeSSE.addEventListener("cache_changed", (e) => {
308308

309309

310310
run()
311+
312+
// ---------------------------------------------------------------------------
313+
// Stratum jobs feed: show which blocks pools are currently mining on top of.
314+
// Off by default; enable for testing by adding ?mining to the URL.
315+
// ---------------------------------------------------------------------------
316+
317+
const MINING_ENABLED = new URLSearchParams(window.location.search).has("mining")
318+
const STRATUM_SSE_URL = "https://stream.stratum.work/"
319+
// forget a pool entirely if we haven't heard a job from it within this window, so
320+
// pools that stop sending (or disappear from the feed) don't linger forever.
321+
const STRATUM_JOB_TTL_MS = 120000
322+
// pool_name -> { prev_hash, last_seen }: the one block each pool is currently mining
323+
// on. Read by build_mining_headers() in blocktree.js on every draw, which groups it
324+
// the other way round, by the block being mined on.
325+
var state_stratum_jobs = new Map()
326+
let stratum_redraw_scheduled = false
327+
328+
// the feed's prev_hash lists the header's 4-byte words in header (little-endian)
329+
// order; reversing the word order gives the big-endian display hash used
330+
// everywhere else in the app (header_infos[].hash), so ghost blocks resolve
331+
// against the real tree.
332+
function stratum_prevhash_to_display(hex) {
333+
let words = []
334+
for (let i = 0; i < hex.length; i += 8) words.push(hex.slice(i, i + 8))
335+
return words.reverse().join("")
336+
}
337+
338+
// A pool mines on exactly one block at a time, so a new job replaces whatever we knew
339+
// about that pool. Keying the state by pool (rather than by the block being mined on)
340+
// is what makes that replacement automatic: keyed the other way round, a pool that
341+
// switched to a new block would keep haunting the old one until its TTL ran out, and
342+
// look like it was mining two blocks at once.
343+
function record_stratum_job(job) {
344+
if (job == null || !job.prev_hash || !job.pool_name) return
345+
state_stratum_jobs.set(job.pool_name, {
346+
prev_hash: stratum_prevhash_to_display(job.prev_hash),
347+
last_seen: Date.now(),
348+
})
349+
}
350+
351+
// jobs arrive several times a second; coalesce them into at most one redraw per
352+
// window and never recenter the viewport for them.
353+
function schedule_stratum_redraw() {
354+
if (stratum_redraw_scheduled) return
355+
stratum_redraw_scheduled = true
356+
setTimeout(() => {
357+
stratum_redraw_scheduled = false
358+
if (state_data.header_infos && state_data.header_infos.length > 0) {
359+
// refresh_mining() only does a full redraw when the set of being-mined blocks
360+
// changes; otherwise it just re-lays-out the pool cloud, leaving the ghost
361+
// blocks (and their pulse animation) untouched.
362+
refresh_mining()
363+
}
364+
}, 1500)
365+
}
366+
367+
let stratumSource = null
368+
function connect_stratum() {
369+
try {
370+
// EventSource reconnects on its own after an error (with the server's
371+
// requested retry delay, or a browser default), so no manual backoff here.
372+
stratumSource = new EventSource(STRATUM_SSE_URL)
373+
} catch (e) {
374+
console.error("could not open stratum jobs stream", e)
375+
return
376+
}
377+
stratumSource.addEventListener("message", (e) => {
378+
let job
379+
try { job = JSON.parse(e.data) } catch (_) { return }
380+
record_stratum_job(job)
381+
schedule_stratum_redraw()
382+
})
383+
stratumSource.addEventListener("error", (e) => {
384+
console.debug("stratum jobs stream error, browser will retry", e)
385+
})
386+
}
387+
388+
// only connect (and thus show any being-mined blocks) when opted in via ?mining
389+
if (MINING_ENABLED) {
390+
connect_stratum()
391+
} else {
392+
console.debug("mining jobs feed disabled; add ?mining to the URL to enable it")
393+
}

0 commit comments

Comments
 (0)