diff --git a/dashboard/lib/health.js b/dashboard/lib/health.js index 72b4f8c..ecc3bce 100644 --- a/dashboard/lib/health.js +++ b/dashboard/lib/health.js @@ -368,14 +368,27 @@ export function health(handle) { * a template far behind the tip), not chain noise to be swallowed. * * Only settled candidates count toward the ratio: 'pending' means nothing - * has been able to verify it yet, which is not evidence either way. */ + * has been able to verify it yet, which is not evidence either way. + * + * The unit is a HEIGHT, not a row. A template stuck on an old tip (an + * enforcer wedged in a mempool re-sync, say) keeps miners solving the same + * height, and every solve is its own rejected row: one incident on + * vaultnet left six rows at 974,037, which kept this check red for over a + * week after the fix while every block since had confirmed. A height + * counts as reached if any candidate there confirmed, and as lost if all + * of them were orphaned or rejected. A pool losing blocks at many heights + * still fails exactly as before. */ checks.push(guard('orphan_rate', 'Blocks reaching the chain', () => { const r = one(d, ` - SELECT COUNT(*) FILTER (WHERE status = 'confirmed') AS good, - COUNT(*) FILTER (WHERE status IN ('orphaned','rejected')) AS lost - FROM (SELECT status FROM blocks_found - WHERE status <> 'pending' - ORDER BY ts DESC LIMIT 100)`); + SELECT COUNT(*) FILTER (WHERE reached = 1) AS good, + COUNT(*) FILTER (WHERE reached = 0) AS lost + FROM (SELECT height, + MAX(status = 'confirmed') AS reached, + MAX(ts) AS last_ts + FROM blocks_found + WHERE status IN ('confirmed','orphaned','rejected') + GROUP BY height + ORDER BY last_ts DESC LIMIT 100)`); const good = Number(r?.good || 0); const lost = Number(r?.lost || 0); const settled = good + lost; @@ -388,7 +401,7 @@ export function health(handle) { value: Math.round(pct), detail: pct >= 50 ? null - : `only ${good} of the last ${settled} candidates reached the chain`, + : `only ${good} of the last ${settled} heights the pool solved reached the chain`, }; })); diff --git a/dashboard/test/health.test.js b/dashboard/test/health.test.js index 2b74c9b..1ef0dce 100644 --- a/dashboard/test/health.test.js +++ b/dashboard/test/health.test.js @@ -126,6 +126,48 @@ test('a pool whose candidates never reach the chain is caught', () => { assert.ok(failing(h).includes('orphan_rate')); }); +/* The vaultnet incident, 2026-10-29: a wedged enforcer pinned the template at + * 974,037 and six solves there were rejected, one incident. Every block after + * the fix confirmed. Counted per row that is 4 of 10 and red for a week; + * counted per height it is 4 of 5. */ +test('many rejected solves at one stuck height are one incident', () => { + const db = makeDb(); + db.prepare('DELETE FROM blocks_found').run(); + const ins = db.prepare(`INSERT INTO blocks_found (ts,height,hash,reward_sats,fee_sats,status) + VALUES (?, ?, ?, 0, 0, ?)`); + ins.run(1700000000, 974036, 'ok0', 'confirmed'); + for (let i = 0; i < 6; i++) ins.run(1700000100 + i, 974037, `stuck${i}`, 'rejected'); + ins.run(1700001000, 982926, 'ok1', 'confirmed'); + ins.run(1700002000, 982965, 'ok2', 'confirmed'); + ins.run(1700003000, 983139, 'ok3', 'confirmed'); + const h = health(db); + assert.ok(!failing(h).includes('orphan_rate')); +}); + +/* A losing candidate at a height another candidate of ours won is not a lost + * height: the pool got that block. */ +test('a height the pool won is reached despite a losing sibling', () => { + const db = makeDb(); + db.prepare('DELETE FROM blocks_found').run(); + const ins = db.prepare(`INSERT INTO blocks_found (ts,height,hash,reward_sats,fee_sats,status) + VALUES (?, ?, ?, 0, 0, ?)`); + ins.run(1700000000, 500, 'lose', 'orphaned'); + ins.run(1700000001, 500, 'win', 'confirmed'); + const h = health(db); + assert.ok(!failing(h).includes('orphan_rate')); +}); + +/* Grouping must not hide a pool that really is losing blocks: rejects at + * many distinct heights still outweigh the confirmed ones. */ +test('lost blocks at many heights still fail', () => { + const db = makeDb(); + const ins = db.prepare(`INSERT INTO blocks_found (ts,height,hash,reward_sats,fee_sats,status) + VALUES (?, ?, ?, 0, 0, 'rejected')`); + for (let i = 0; i < 3; i++) ins.run(1700000100 + i, 900000 + i, `rej${i}`); + const h = health(db); + assert.ok(failing(h).includes('orphan_rate')); +}); + /* A pool that has found nothing yet has not failed at anything. */ test('no settled candidates is not an orphan-rate failure', () => { const db = makeDb();