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 (