From aa97671018eedd65ecd07d677526df91e9cfd749 Mon Sep 17 00:00:00 2001 From: infraclaw-dash <283232465+infraclaw-dash@users.noreply.github.com> Date: Tue, 23 Jun 2026 01:34:32 +0000 Subject: [PATCH] Add testnet difficulty pause alert --- package.json | 1 + server/difficultyAlert.test.js | 67 ++++++++++++++++++++++++++++++ server/parser.js | 4 ++ server/parser.test.js | 12 ++++++ src/components/Dashboard.jsx | 5 +++ src/components/DifficultyAlert.jsx | 37 +++++++++++++++++ src/components/NodeDetail.jsx | 2 + src/utils/difficultyAlert.js | 42 +++++++++++++++++++ 8 files changed, 170 insertions(+) create mode 100644 server/difficultyAlert.test.js create mode 100644 src/components/DifficultyAlert.jsx create mode 100644 src/utils/difficultyAlert.js diff --git a/package.json b/package.json index 364390a..a63cdb5 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "dev:frontend": "vite", "dev:backend": "node server/index.js", "build": "vite build", + "test": "node --test", "start": "node server/index.js", "lint": "eslint .", "preview": "vite preview" diff --git a/server/difficultyAlert.test.js b/server/difficultyAlert.test.js new file mode 100644 index 0000000..1d6b4b8 --- /dev/null +++ b/server/difficultyAlert.test.js @@ -0,0 +1,67 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { getDifficultyAlert } from '../src/utils/difficultyAlert.js'; + +function node(overrides = {}) { + const { status: statusOverrides = {}, ...nodeOverrides } = overrides; + return { + name: 'masternode-1', + lastUpdated: 1000, + ...nodeOverrides, + status: { + coreServiceStatus: 'up', + coreHeight: 100, + coreDifficulty: 10, + coreBestBlockHash: '000abc', + coreBestBlockTime: 1000, + ...statusOverrides, + }, + }; +} + +test('getDifficultyAlert returns null when the best block is recent', () => { + const alert = getDifficultyAlert([node()], 1200 * 1000, { + staleBlockSeconds: 300, + difficultyThreshold: 1, + }); + assert.equal(alert, null); +}); + +test('getDifficultyAlert returns null when stale block difficulty is below threshold', () => { + const alert = getDifficultyAlert([node({ status: { coreDifficulty: 0.1 } })], 2000 * 1000, { + staleBlockSeconds: 300, + difficultyThreshold: 1, + }); + assert.equal(alert, null); +}); + +test('getDifficultyAlert reports stale high-difficulty tips', () => { + const alert = getDifficultyAlert([node()], 2000 * 1000, { + staleBlockSeconds: 300, + difficultyThreshold: 1, + }); + assert.deepEqual(alert, { + type: 'testnet_difficulty_stall', + severity: 'info', + sourceNode: 'masternode-1', + height: 100, + bestBlockHash: '000abc', + bestBlockTime: 1000, + difficulty: 10, + ageSeconds: 1000, + }); +}); + +test('getDifficultyAlert uses the highest reported core height', () => { + const alert = getDifficultyAlert([ + node({ name: 'masternode-1', status: { coreHeight: 100, coreBestBlockTime: 1000 } }), + node({ name: 'masternode-2', status: { coreHeight: 101, coreBestBlockHash: '000def', coreBestBlockTime: 1100 } }), + ], 2000 * 1000, { + staleBlockSeconds: 300, + difficultyThreshold: 1, + }); + + assert.equal(alert.sourceNode, 'masternode-2'); + assert.equal(alert.height, 101); + assert.equal(alert.bestBlockHash, '000def'); +}); diff --git a/server/parser.js b/server/parser.js index c32aeeb..a870d3c 100644 --- a/server/parser.js +++ b/server/parser.js @@ -148,6 +148,10 @@ export function parseDashCliStatus(blockchainJson, masternodeJson) { const chain = JSON.parse(extractJsonBlob(blockchainJson)); result.network = chain.chain === 'test' ? 'testnet' : chain.chain; result.coreHeight = chain.blocks || null; + result.coreDifficulty = Number.isFinite(chain.difficulty) ? chain.difficulty : null; + result.coreBestBlockHash = chain.bestblockhash || null; + result.coreBestBlockTime = Number.isFinite(chain.time) ? chain.time : null; + result.coreMedianTime = Number.isFinite(chain.mediantime) ? chain.mediantime : null; result.coreServiceStatus = chain.initialblockdownload ? 'syncing' : 'up'; const progress = chain.verificationprogress; if (progress != null) { diff --git a/server/parser.test.js b/server/parser.test.js index 1f75be5..d3e3817 100644 --- a/server/parser.test.js +++ b/server/parser.test.js @@ -5,6 +5,10 @@ import { parseHpStatus, parseTenderdashInfo } from './parser.js'; const blockchainJson = JSON.stringify({ chain: 'test', blocks: 1000, + bestblockhash: '000000000abc', + difficulty: 42.5, + time: 1766450000, + mediantime: 1766449900, initialblockdownload: false, verificationprogress: 0.99995, size_on_disk: 5 * 1024 ** 3, @@ -105,6 +109,14 @@ test('parseHpStatus preserves coreSize and posePenalty from dash-cli JSON', () = assert.equal(status.posePenalty, 0); }); +test('parseHpStatus preserves best block metadata from getblockchaininfo', () => { + const status = parseHpStatus(blockchainJson, masternodeJson, null, networkInfoJson); + assert.equal(status.coreDifficulty, 42.5); + assert.equal(status.coreBestBlockHash, '000000000abc'); + assert.equal(status.coreBestBlockTime, 1766450000); + assert.equal(status.coreMedianTime, 1766449900); +}); + // Contract boundary: scripts/dashmon-check.sh is responsible for unwrapping // Tenderdash's JSON-RPC envelope ({jsonrpc, id, result}) and emitting a flat // assembly object. parseTenderdashInfo only sees that post-unwrap blob -- it diff --git a/src/components/Dashboard.jsx b/src/components/Dashboard.jsx index 157963a..e8b424c 100644 --- a/src/components/Dashboard.jsx +++ b/src/components/Dashboard.jsx @@ -3,6 +3,8 @@ import { useNodeData } from '../hooks/useNodeData'; import SummaryBar from './SummaryBar'; import NodeCard from './NodeCard'; import NodeDetail from './NodeDetail'; +import DifficultyAlert from './DifficultyAlert'; +import { getDifficultyAlert } from '../utils/difficultyAlert'; export default function Dashboard() { const { nodes, proposer, loading } = useNodeData(); @@ -18,6 +20,7 @@ export default function Dashboard() { const hpNodes = nodes.filter((n) => n.type === 'hp'); const mnNodes = nodes.filter((n) => n.type === 'mn'); + const difficultyAlert = getDifficultyAlert(nodes); return (
@@ -40,6 +43,8 @@ export default function Dashboard() {
+ + {/* HP Masternodes */} {hpNodes.length > 0 && (
diff --git a/src/components/DifficultyAlert.jsx b/src/components/DifficultyAlert.jsx new file mode 100644 index 0000000..ed0e8ec --- /dev/null +++ b/src/components/DifficultyAlert.jsx @@ -0,0 +1,37 @@ +function formatDuration(seconds) { + if (!Number.isFinite(seconds)) return 'unknown'; + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + if (hours > 0 && minutes > 0) return `${hours}h ${minutes}m`; + if (hours > 0) return `${hours}h`; + return `${Math.max(1, minutes)}m`; +} + +export default function DifficultyAlert({ alert }) { + if (!alert) return null; + + const difficulty = alert.difficulty.toLocaleString(undefined, { + maximumFractionDigits: alert.difficulty >= 10 ? 0 : 6, + }); + const tipAge = formatDuration(alert.ageSeconds); + const height = alert.height.toLocaleString(); + + return ( +
+
+
+
+ Possible testnet difficulty pause +
+
+ Best Core block {height} is {tipAge} old while difficulty is {difficulty}. This can happen after + ASIC mining spikes testnet difficulty; CPU miners may resume once min-difficulty rules allow it. +
+
+
+ {alert.sourceNode} +
+
+
+ ); +} diff --git a/src/components/NodeDetail.jsx b/src/components/NodeDetail.jsx index 64354f6..56b0ec9 100644 --- a/src/components/NodeDetail.jsx +++ b/src/components/NodeDetail.jsx @@ -106,7 +106,9 @@ export default function NodeDetail({ node, onClose }) { + +
diff --git a/src/utils/difficultyAlert.js b/src/utils/difficultyAlert.js new file mode 100644 index 0000000..013348f --- /dev/null +++ b/src/utils/difficultyAlert.js @@ -0,0 +1,42 @@ +export const DEFAULT_DIFFICULTY_ALERT_THRESHOLD = 1; +export const DEFAULT_STALE_BLOCK_SECONDS = 45 * 60; + +function isUsableNode(node) { + const status = node.status || {}; + return status.coreServiceStatus === 'up' + && Number.isFinite(status.coreHeight) + && Number.isFinite(status.coreDifficulty) + && Number.isFinite(status.coreBestBlockTime); +} + +export function getDifficultyAlert(nodes, nowMs = Date.now(), options = {}) { + const difficultyThreshold = options.difficultyThreshold ?? DEFAULT_DIFFICULTY_ALERT_THRESHOLD; + const staleBlockSeconds = options.staleBlockSeconds ?? DEFAULT_STALE_BLOCK_SECONDS; + + const bestNode = [...nodes] + .filter(isUsableNode) + .sort((a, b) => { + const heightDelta = b.status.coreHeight - a.status.coreHeight; + if (heightDelta !== 0) return heightDelta; + return (b.lastUpdated || 0) - (a.lastUpdated || 0); + })[0]; + + if (!bestNode) return null; + + const status = bestNode.status; + const ageSeconds = Math.max(0, Math.floor(nowMs / 1000 - status.coreBestBlockTime)); + if (ageSeconds < staleBlockSeconds || status.coreDifficulty < difficultyThreshold) { + return null; + } + + return { + type: 'testnet_difficulty_stall', + severity: 'info', + sourceNode: bestNode.name, + height: status.coreHeight, + bestBlockHash: status.coreBestBlockHash, + bestBlockTime: status.coreBestBlockTime, + difficulty: status.coreDifficulty, + ageSeconds, + }; +}