From 617d3b968207382931d477e5a622c7b4e4864e95 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:03:28 +0000 Subject: [PATCH] Live DAG: side blocks carry their GHOSTDAG colour (blue paid, red excluded, pending) Observer: additive live_blocks.color (pending by default). Every chain block's mergeset marks its blues blue and its reds red, from the notification's verbose data or getBlock for chain blocks learned via virtualChainChanged; a reorg puts the removed chain blocks' mergesets back to pending. API serves color. Page: blue side blocks filled in the miner's hue at 70% with the ring, pending the faint outline, red a dark outline with a strike; tooltip and legend name the state. Co-Authored-By: Claude Fable 5.1 --- site/api/live.mjs | 4 +++- site/live.html | 18 +++++++++------- tools/observer/observer.mjs | 41 ++++++++++++++++++++++++++++++++++--- 3 files changed, 52 insertions(+), 11 deletions(-) diff --git a/site/api/live.mjs b/site/api/live.mjs index f37f91183..60ab8c432 100644 --- a/site/api/live.mjs +++ b/site/api/live.mjs @@ -38,7 +38,7 @@ export default async function handler(req, res) { sql(`SELECT now() AS now, (SELECT row_to_json(s) FROM live_state s WHERE s.id = 1) AS state, (SELECT json_agg(e) FROM (SELECT ts, kind, text FROM live_events ORDER BY ts DESC LIMIT 30) e) AS events`), - sql(`SELECT hash, blue_score, daa_score, timestamp_ms, parent_hashes, is_chain_block, vote_key_hash, received_at + sql(`SELECT hash, blue_score, daa_score, timestamp_ms, parent_hashes, is_chain_block, vote_key_hash, received_at, color FROM live_blocks WHERE received_at > now() - interval '90 seconds' ORDER BY received_at DESC @@ -112,6 +112,8 @@ export default async function handler(req, res) { parents: (b.parent_hashes || []).map(short), chain: !!b.is_chain_block, miner: minerId(b.vote_key_hash), + // GHOSTDAG colour from the observer: blue (merged by a chain block, paid), red (excluded) or pending (not merged yet) + color: b.color === 'blue' || b.color === 'red' ? b.color : 'pending', // Finality v2: a locked checkpoint block, and whether the block sits at or before the newest lock locked: lockedHashes.has(short(b.hash)), final: !!(newestLock && b.is_chain_block && num(b.blue_score) <= newestLock.blue_score), diff --git a/site/live.html b/site/live.html index 746758c4f..b007f1625 100644 --- a/site/live.html +++ b/site/live.html @@ -180,12 +180,14 @@ footer{border-top:1px solid var(--line);padding-block:48px 32px}
- chain block - side block (blue or red, off the selected chain) + chain block + included (blue): paid + pending + excluded (red) locked checkpoint, final just arrived selected chain - first parent of a side block + first parent 5d3fd79cone lane per miner, colour from its id hover or tap a block for its id, scores and parents
@@ -304,8 +306,8 @@ footer{border-top:1px solid var(--line);padding-block:48px 32px} function ingest(d){ var now=performance.now();serverNow=new Date(d.now).getTime();serverAt=now; d.blocks.forEach(function(b){var e=byHash[b.hash]; - if(e){e.chain=b.chain;e.locked=!!b.locked;e.final=!!b.final;return;} - e={hash:b.hash,ts:b.ts,blue_score:b.blue_score,daa:b.daa,parents:b.parents||[],chain:b.chain,miner:b.miner||'?',born:now,j:jitter(b.hash),locked:!!b.locked,final:!!b.final,px:0,py:0,on:false}; + if(e){e.chain=b.chain;e.locked=!!b.locked;e.final=!!b.final;e.color=b.color||'pending';return;} + e={hash:b.hash,ts:b.ts,blue_score:b.blue_score,daa:b.daa,parents:b.parents||[],chain:b.chain,color:b.color||'pending',miner:b.miner||'?',born:now,j:jitter(b.hash),locked:!!b.locked,final:!!b.final,px:0,py:0,on:false}; byHash[b.hash]=e;blocks.push(e);}); var cut=vnow()-((W-G)/Math.max(8,pxPerSec))*1000-20000; blocks=blocks.filter(function(b){var keep=b.ts>cut;if(!keep)delete byHash[b.hash];return keep;}); @@ -367,7 +369,9 @@ footer{border-top:1px solid var(--line);padding-block:48px 32px} for(i=0;i0){var g=x.createRadialGradient(b.px,b.py,0,b.px,b.py,S*1.6);g.addColorStop(0,'rgba(255,179,92,'+(0.5*fl)+')');g.addColorStop(1,'rgba(255,179,92,0)');x.fillStyle=g;x.beginPath();x.arc(b.px,b.py,S*1.6,0,Math.PI*2);x.fill();} if(b.chain){x.fillStyle='#F2541B';rrect(b.px-h,b.py-h,S,S,r);x.fill();x.strokeStyle=m?m.col:'#9A9A9E';x.lineWidth=1.5;rrect(b.px-h-1,b.py-h-1,S+2,S+2,r+1);x.stroke();tip=b;} - else{x.fillStyle='#0C0C0E';rrect(b.px-h,b.py-h,S,S,r);x.fill();x.strokeStyle=fl>0?'rgba(255,179,92,0.95)':'rgba(154,154,158,0.85)';x.lineWidth=1.5;rrect(b.px-h,b.py-h,S,S,r);x.stroke();} + else if(b.color==='blue'){var mc=m?m.col:'#9A9A9E';x.fillStyle=mc+'B3';rrect(b.px-h,b.py-h,S,S,r);x.fill();x.strokeStyle=fl>0?'rgba(255,179,92,0.95)':mc;x.lineWidth=1.5;rrect(b.px-h,b.py-h,S,S,r);x.stroke();} + else{x.fillStyle='#0C0C0E';rrect(b.px-h,b.py-h,S,S,r);x.fill();x.strokeStyle=fl>0?'rgba(255,179,92,0.95)':(b.color==='red'?'rgba(154,154,158,0.6)':'rgba(154,154,158,0.85)');x.lineWidth=1.5;rrect(b.px-h,b.py-h,S,S,r);x.stroke(); + if(b.color==='red'){x.strokeStyle='rgba(154,154,158,0.9)';x.lineWidth=1.5;x.beginPath();x.moveTo(b.px-h*0.5,b.py+h*0.5);x.lineTo(b.px+h*0.5,b.py-h*0.5);x.stroke();}} if(fl>0&&b.chain){x.strokeStyle='rgba(255,179,92,'+fl+')';x.lineWidth=2;rrect(b.px-h-3,b.py-h-3,S+6,S+6,r+2);x.stroke();} if(b.locked){x.strokeStyle='#FFB35C';x.lineWidth=2.5;x.beginPath();x.arc(b.px,b.py,S*0.95,0,Math.PI*2);x.stroke();} if(hv===b){x.strokeStyle='#F4F1EC';x.lineWidth=1.5;rrect(b.px-h-3,b.py-h-3,S+6,S+6,r+2);x.stroke();}} @@ -377,7 +381,7 @@ footer{border-top:1px solid var(--line);padding-block:48px 32px} if(fx>G){var ft=lk?'final #'+lk.hash.slice(0,8):'final',fw=x.measureText(ft).width;x.fillStyle='#FFB35C'; if(fx-G>fw+14){x.textAlign='right';x.fillText(ft,fx-6,12);}else{x.textAlign='left';x.fillText(ft,fx+6,12);}} // tooltip on the canvas, no DOM - if(hv){var l1=hv.hash.slice(0,8)+(hv.locked?' locked':(hv.chain?' chain':' side')),l2='blue '+compact(hv.blue_score)+' daa '+compact(hv.daa),l3=hv.parents.length+(hv.parents.length===1?' parent':' parents')+' '+hv.miner; + if(hv){var l1=hv.hash.slice(0,8)+' '+(hv.locked?'locked':hv.chain?'chain':hv.color==='blue'?'included, paid':hv.color==='red'?'excluded':'pending'),l2='blue '+compact(hv.blue_score)+' daa '+compact(hv.daa),l3=hv.parents.length+(hv.parents.length===1?' parent':' parents')+' '+hv.miner; var tw=Math.max(x.measureText(l1).width,x.measureText(l2).width,x.measureText(l3).width)+20,th=52,tx=hv.px+h+10,tty=hv.py-th/2; if(tx+tw>W-4)tx=hv.px-h-10-tw;if(tx (h || '').slice(0, 8); const pendingBlocks = []; // rows waiting for the next flush const pendingChain = { add: new Set(), remove: new Set() }; +const pendingMerge = new Map(); // chain block hash -> { blues, reds } from its verbose data, or null to fetch with getBlock +const pendingUnmerge = new Set(); // chain blocks removed by a reorg: their mergeset goes back to pending const miners = new Map(); // vote_key_hash -> { lastSeen ms, quiet bool } const minuteCounts = new Map(); // minute epoch -> count (last 60 minutes) const recentArrivals = []; // ms timestamps of arrivals in the last 60 s @@ -292,6 +296,8 @@ function onBlock(block) { const miner = minerFromCoinbase(block, addressPrefix); for (const cert of certificatesIn(block)) pendingCerts.push({ cert, carrier: h.hash }); const vk = h.voteKeyHash || null; + const vd = block.verboseData; + if (vd && vd.isChainBlock && Array.isArray(vd.mergeSetBluesHashes)) pendingMerge.set(h.hash, { blues: vd.mergeSetBluesHashes, reds: vd.mergeSetRedsHashes || [] }); pendingBlocks.push({ hash: h.hash, blue_score: h.blueScore, daa_score: h.daaScore, timestamp_ms: h.timestamp, parents: parents.length, parent_hashes: parents, @@ -325,6 +331,35 @@ async function flushBlocks() { if (pendingBlocks.length) await flushBlocks(); } +// Colour: every chain block's mergeset is blue (in the selected chain's past, paid) or red (excluded). Blocks no chain +// block has merged yet stay pending. A reorg puts the removed chain blocks' mergesets back to pending first. +async function mergesetOf(rpc, hash) { + const b = (await rpc.call('getBlock', { hash, includeTransactions: false })).block; + const vd = (b && b.verboseData) || {}; + return { blues: vd.mergeSetBluesHashes || [], reds: vd.mergeSetRedsHashes || [] }; +} +let colorsBusy = false; +async function flushColors(rpc) { + if (colorsBusy || (!pendingMerge.size && !pendingUnmerge.size)) return; + colorsBusy = true; + try { + if (pendingUnmerge.size) { + const back = []; + for (const h of [...pendingUnmerge].slice(0, 50)) { try { const m = await mergesetOf(rpc, h); back.push(...m.blues, ...m.reds); pendingUnmerge.delete(h); } catch (e) { log('unmerge fetch failed', h.slice(0, 8), e.message); pendingUnmerge.delete(h); } } + if (back.length) await sql(`UPDATE ${TB} SET color = 'pending' WHERE hash = ANY($1::text[]) AND color <> 'pending'`, [pgArray(back)]); + } + const blues = [], reds = []; + for (const [h, m] of [...pendingMerge].slice(0, 100)) { + let ms = m; + if (!ms) { try { ms = await mergesetOf(rpc, h); } catch (e) { log('mergeset fetch failed', h.slice(0, 8), e.message); pendingMerge.delete(h); continue; } } + blues.push(...ms.blues); reds.push(...ms.reds); pendingMerge.delete(h); + } + if (blues.length) await sql(`UPDATE ${TB} SET color = 'blue' WHERE hash = ANY($1::text[]) AND color <> 'blue'`, [pgArray(blues)]); + if (reds.length) await sql(`UPDATE ${TB} SET color = 'red' WHERE hash = ANY($1::text[]) AND color <> 'red'`, [pgArray(reds)]); + } catch (e) { log('color update failed', e.message); } + colorsBusy = false; +} + async function flushChain() { if (pendingChain.add.size) { const a = [...pendingChain.add]; pendingChain.add.clear(); try { await sql(`UPDATE ${TB} SET is_chain_block = true WHERE hash = ANY($1::text[]) AND NOT is_chain_block`, [pgArray(a)]); } catch (e) { log('chain update failed', e.message); } } if (pendingChain.remove.size) { const r = [...pendingChain.remove]; pendingChain.remove.clear(); try { await sql(`UPDATE ${TB} SET is_chain_block = false WHERE hash = ANY($1::text[]) AND is_chain_block`, [pgArray(r)]); } catch (e) { log('chain update failed', e.message); } } @@ -620,8 +655,8 @@ async function main() { } } else if (method === 'virtualChainChangedNotification' && inner) { - for (const h of inner.addedChainBlockHashes || []) { pendingChain.remove.delete(h); pendingChain.add.add(h); } - for (const h of inner.removedChainBlockHashes || []) { pendingChain.add.delete(h); pendingChain.remove.add(h); } + for (const h of inner.addedChainBlockHashes || []) { pendingChain.remove.delete(h); pendingChain.add.add(h); pendingUnmerge.delete(h); if (!pendingMerge.has(h)) pendingMerge.set(h, null); } + for (const h of inner.removedChainBlockHashes || []) { pendingChain.add.delete(h); pendingChain.remove.add(h); pendingMerge.delete(h); pendingUnmerge.add(h); } } }; let connected = false; @@ -644,7 +679,7 @@ async function main() { rpc.onClose = () => { setTimeout(connect, 2000); }; await connect(); - setInterval(async () => { await flushBlocks(); await flushChain(); await flushCertificates(rpc); }, FLUSH_EVERY_MS); + setInterval(async () => { await flushBlocks(); await flushChain(); await flushColors(rpc); await flushCertificates(rpc); }, FLUSH_EVERY_MS); setInterval(() => tick(rpc), STATE_EVERY_MS); setInterval(prune, PRUNE_EVERY_MS); tick(rpc); prune();