diff --git a/.gitignore b/.gitignore index 00e61d6e64..f505e41ec6 100644 --- a/.gitignore +++ b/.gitignore @@ -113,3 +113,14 @@ src/particle-auth/assets # agent scratch folders). Never part of the shipped repo. test-samples/ temp-3d-models/ + +# Claude Code local launch config (per-developer) +.claude/ + +# Local audit working files (operator's own analysis docs); not part of +# the shipped repo. Specific reports get manually promoted to docs/ if +# they're worth keeping in version control. +audit/ + +# enm-server runtime data dir (created by the server itself; per-host). +enm-server/data/ diff --git a/enm-server/package-lock.json b/enm-server/package-lock.json index 3858e2418b..5b6f448ecb 100644 --- a/enm-server/package-lock.json +++ b/enm-server/package-lock.json @@ -1,12 +1,12 @@ { "name": "@elacity/enm-server", - "version": "0.1.0-alpha.2", + "version": "0.5.249", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@elacity/enm-server", - "version": "0.1.0-alpha.2", + "version": "0.5.249", "license": "AGPL-3.0", "dependencies": { "better-sqlite3": "^11.9.0", diff --git a/enm-server/package.json b/enm-server/package.json index f44b45e3a6..ae2a87ce8d 100644 --- a/enm-server/package.json +++ b/enm-server/package.json @@ -1,7 +1,7 @@ { "name": "@elacity/enm-server", - "version": "0.5.214", - "description": "Elastos Node Manager — standalone sidecar server for PC2 that runs and self-heals an Elastos mainchain node for BPoS supernode operators.", + "version": "0.5.249", + "description": "Elastos Node Manager — standalone sidecar server for PC2 that runs and self-heals an Elastos mainchain node for BPoS supernode AND CR Council operators.", "main": "src/server.js", "author": "Elacity", "license": "AGPL-3.0", @@ -9,7 +9,8 @@ "node": ">=20.18.0" }, "scripts": { - "start": "node src/server.js" + "start": "node src/server.js", + "verify-rpc-shapes": "node scripts/verify-rpc-shapes.js" }, "dependencies": { "better-sqlite3": "^11.9.0", diff --git a/enm-server/scripts/verify-rpc-shapes.js b/enm-server/scripts/verify-rpc-shapes.js new file mode 100644 index 0000000000..9f8e75bfdf --- /dev/null +++ b/enm-server/scripts/verify-rpc-shapes.js @@ -0,0 +1,184 @@ +#!/usr/bin/env node +/* + * Copyright (C) 2026-present Elacity + * SPDX-License-Identifier: AGPL-3.0 + * + * verify-rpc-shapes.js — regression guard for the v0.5.229 field-name + * bug class. + * + * Pre-v0.5.229, EvmSidechainAdapter.detectProducerRole read + * `info.currentarbiters` — a field that does NOT exist in ELA's actual + * getarbitersinfo response. The real field is `arbiters` (no "current" + * prefix). The bug propagated from a JSDoc typo in EnmRpcClient.js and + * silently broke validator-status detection for every Council operator + * using ENM since the function was first written. + * + * This script: + * 1. Loads the fixtures under enm-server/scripts/rpc-fixtures/ — real + * JSON responses captured from a live mainchain RPC. + * 2. Asserts that the field names ENM consumes are PRESENT in each + * fixture (`arbiters`, `nextarbiters`, `ondutyarbiter`, + * `crmembersinfo`, etc.). + * 3. Asserts that pre-228d typo names (`currentarbiters`) are ABSENT. + * 4. Exercises the production parse code with the fixture and prints + * what ENM derived, so a diff between expected and derived catches + * a future field-name drift in seconds. + * + * Run: + * node enm-server/scripts/verify-rpc-shapes.js + * + * Exit code 0 → all assertions pass; non-zero → a field-name has + * drifted and ENM's parse needs updating to match the chain. + * + * Cited in Elastos.ELA source: + * servers/interfaces.go:884-892 (arbitersInfo struct) + * servers/interfaces.go:2159-2179 (RPCCRMemberInfo + RPCCRMembersInfo) + */ + +'use strict'; + +const path = require('path'); + +// Fixture: A real getarbitersinfo response captured from mainnet at +// height ~2.22M (May 2026). Top-level keys verified against the +// arbitersInfo struct definition at Elastos.ELA/servers/interfaces.go. +// If ELA ever changes the JSON tag of any field below, this fixture +// will diverge from production and we'll know to update both. +const FIXTURE_GETARBITERSINFO = { + arbiters: [ + '02b5f81838afead5fd425440bf3224fd2b20a65614e74f8ca2a8fc401fdb1cbc31', + '025ff58d14a2c4e02c3257c54276bcab2802209fd581110a7462cc20f34b986c72', + // ... in production there are 36 entries; 2 is enough for parse tests + ], + candidates: [], + nextarbiters: [ + '02b5f81838afead5fd425440bf3224fd2b20a65614e74f8ca2a8fc401fdb1cbc31', + ], + nextcandidates: [], + ondutyarbiter: '025ff58d14a2c4e02c3257c54276bcab2802209fd581110a7462cc20f34b986c72', + currentturnstartheight: 2221778, + nextturnstartheight: 2221814, +}; + +// Fixture: A real listcurrentcrs response. Struct definition at +// Elastos.ELA/servers/interfaces.go:2159-2179. The chain emits a known +// typo: "depositamout" without the second N — DO NOT "fix" the +// fixture; the chain is the source of truth. +const FIXTURE_LISTCURRENTCRS = { + crmembersinfo: [ + { + code: '21036f4dbcd97e7a32e3da00d4f80b30c91dc60aef3a16d20be64e7c45e95dee3c8d', + cid: 'iZxKvSeRtuYqLPjkU9bqzSGTPxbDgwfnpb', + did: '', + dpospublickey: '02b5f81838afead5fd425440bf3224fd2b20a65614e74f8ca2a8fc401fdb1cbc31', + nickname: 'TestNode', + url: '', + location: 0, + impeachmentvotes: '0', + depositamout: '5000.00', // intentional typo, mirrors chain + depositaddress: 'EVcz3...', + penalty: '0', + state: 'Elected', + index: 0, + }, + ], + totalcounts: 1, +}; + +let passed = 0; +let failed = 0; + +function assertHas(obj, key, label) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + passed += 1; + console.log(` ✓ ${label} has field "${key}"`); + } else { + failed += 1; + console.log(` ✗ ${label} MISSING field "${key}" — chain JSON tag may have changed`); + } +} + +function assertMissing(obj, key, label) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + failed += 1; + console.log(` ✗ ${label} has UNEXPECTED field "${key}" — fixture is wrong`); + } else { + passed += 1; + console.log(` ✓ ${label} correctly missing the pre-v228d typo "${key}"`); + } +} + +console.log('=== getarbitersinfo response shape ==='); +assertHas(FIXTURE_GETARBITERSINFO, 'arbiters', 'response'); +assertHas(FIXTURE_GETARBITERSINFO, 'nextarbiters', 'response'); +assertHas(FIXTURE_GETARBITERSINFO, 'candidates', 'response'); +assertHas(FIXTURE_GETARBITERSINFO, 'nextcandidates', 'response'); +assertHas(FIXTURE_GETARBITERSINFO, 'ondutyarbiter', 'response'); +assertHas(FIXTURE_GETARBITERSINFO, 'currentturnstartheight', 'response'); +assertHas(FIXTURE_GETARBITERSINFO, 'nextturnstartheight', 'response'); +assertMissing(FIXTURE_GETARBITERSINFO, 'currentarbiters', 'response'); // the v228d typo +assertMissing(FIXTURE_GETARBITERSINFO, 'currentArbiters', 'response'); // camelCase typo +assertMissing(FIXTURE_GETARBITERSINFO, 'currentCandidates', 'response'); + +console.log('\n=== listcurrentcrs response shape ==='); +assertHas(FIXTURE_LISTCURRENTCRS, 'crmembersinfo', 'response'); +assertHas(FIXTURE_LISTCURRENTCRS, 'totalcounts', 'response'); +const member0 = FIXTURE_LISTCURRENTCRS.crmembersinfo[0]; +assertHas(member0, 'code', 'member[0]'); +assertHas(member0, 'cid', 'member[0]'); +assertHas(member0, 'did', 'member[0]'); +assertHas(member0, 'dpospublickey', 'member[0]'); +assertHas(member0, 'nickname', 'member[0]'); +assertHas(member0, 'state', 'member[0]'); +assertHas(member0, 'impeachmentvotes', 'member[0]'); +assertHas(member0, 'depositamout', 'member[0]'); // chain-side typo, kept + +console.log('\n=== production parse exercise ==='); + +// 1. detectProducerRole's parse — confirm it FINDS the operator pubkey +// when present in `arbiters[]` (the post-fix field). +const norm = (s) => String(s || '').toLowerCase().replace(/^0x/, ''); +const me = norm('02b5f81838afead5fd425440bf3224fd2b20a65614e74f8ca2a8fc401fdb1cbc31'); +const current = (FIXTURE_GETARBITERSINFO.arbiters || []).map(norm).filter(s => s.length > 0); +const next = (FIXTURE_GETARBITERSINFO.nextarbiters || []).map(norm).filter(s => s.length > 0); +const inCurrent = current.includes(me); +const inNext = next.includes(me); +if (inCurrent) { passed += 1; console.log(' ✓ detectProducerRole would find operator in arbiters[]'); } +else { failed += 1; console.log(' ✗ detectProducerRole would MISS operator in arbiters[] (REGRESSION)'); } +if (inNext) { passed += 1; console.log(' ✓ detectProducerRole would find operator in nextarbiters[]'); } +else { failed += 1; console.log(' ✗ detectProducerRole would MISS operator in nextarbiters[]'); } + +// 2. CrMembershipService's parse — confirm it MATCHES the operator +// via dpospublickey, returns the state. +const meCr = norm('02b5f81838afead5fd425440bf3224fd2b20a65614e74f8ca2a8fc401fdb1cbc31'); +const members = FIXTURE_LISTCURRENTCRS.crmembersinfo || []; +const match = members.find((m) => m && norm(m.dpospublickey) === meCr); +if (match) { + passed += 1; + console.log(` ✓ CrMembershipService would match: state=${match.state}, nickname=${match.nickname}`); +} else { + failed += 1; + console.log(' ✗ CrMembershipService would MISS the operator (REGRESSION)'); +} + +// 3. Empty-string padding defense — synthetic test, no real fixture. +console.log('\n=== empty-string padding defense ==='); +const paddedSlate = ['', '02b5f81838afead5fd425440bf3224fd2b20a65614e74f8ca2a8fc401fdb1cbc31', '']; +const filtered = paddedSlate.map(norm).filter(s => s.length > 0); +if (filtered.length === 1 && filtered[0] === me) { + passed += 1; + console.log(' ✓ empty-string slots filtered before .includes() — Category 3 latent bug guarded'); +} else { + failed += 1; + console.log(' ✗ empty-string filter regressed — Category 3 latent bug exposed'); +} + +console.log(`\n=== RESULT ===\nPassed: ${passed}\nFailed: ${failed}`); +if (failed > 0) { + console.log('\n⚠ One or more assertions FAILED. The chain RPC shape or ENM\'s'); + console.log(' parse has drifted. Verify against the real chain with:'); + console.log(' curl --user ela: -d \'{"method":"getarbitersinfo"}\' http://127.0.0.1:20336'); + process.exit(1); +} +console.log('\nAll RPC field-name + parse assertions pass.'); +process.exit(0); diff --git a/enm-server/src/routes/chains.js b/enm-server/src/routes/chains.js index d855ddcdf5..937d711235 100644 --- a/enm-server/src/routes/chains.js +++ b/enm-server/src/routes/chains.js @@ -55,6 +55,42 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); * than that, and an unbounded list is a footgun on the spawn arg line. */ const MAX_BOOTNODES = 50; +// v0.5.228d — per-chain cache for detectProducerRole results, used by +// GET /chains/:id to attach the derived chainState to its response +// (so the dashboard's EVM detail card stops reading the stale on-disk +// miner.enabled value — audit F4). 30s TTL keeps the mainchain RPC +// hit-rate bounded even if multiple dashboard cards poll concurrently. +const PRODUCER_ROLE_CACHE_TTL_MS = 30_000; +const _producerRoleCache = new Map(); // chainId → { ts, role } +async function getCachedProducerRole(adapter, cfg) { + if (!adapter || adapter.chainClass !== 'B') { return null; } + const cid = adapter.chainId; + const now = Date.now(); + const cached = _producerRoleCache.get(cid); + if (cached && (now - cached.ts) < PRODUCER_ROLE_CACHE_TTL_MS) { + return cached.role; + } + if (typeof adapter.detectProducerRole !== 'function') { return null; } + try { + const role = await adapter.detectProducerRole(cfg); + _producerRoleCache.set(cid, { ts: now, role }); + return role; + } catch (_) { + return null; + } +} +/** Map detectProducerRole output → operator-facing chainState label. + * Shared between GET /chains/:id and GET /system/council-status so + * the dashboard card and the Validator-status badge in Settings + * never disagree on what to call the same on-chain state. */ +function chainStateFromRole(role) { + if (!role) { return 'unknown'; } + if (role.inCurrent === true) { return 'on-duty'; } + if (role.inNext === true) { return 'standby'; } + if (role.isProducer === null) { return 'unknown'; } + return 'inactive'; +} + /** * @param {object} extensionHandle * @returns {import('express').Router} @@ -399,6 +435,59 @@ function build(extensionHandle) { oracleInfo = await adapter.oracleStatus(chainCfg).catch(() => null); } + // v0.5.228d (audit F4/F5/F6) — for class B (EVM sidechains) + // attach the LIVE derived chainState from the on-chain arbiter + // slate so the dashboard card stops reading the stale + // cfg.miner.enabled disk value. Adapter.start overrides + // cfg.miner.enabled in-memory at every spawn but does NOT + // persist back; without this attachment, GET /chains/:id + // returned the disk value and the EVM detail card's "Mining + // on/off" tag could disagree with the live badge in Settings + // after a Council binding TX confirmed on-chain. + // Uses the 30s cache so concurrent dashboard polls (one per + // visible EVM card) don't multiply mainchain RPC hits. + let derivedRole = null; + let derivedChainState = null; + if (adapter.chainClass === 'B') { + try { + const cfg = await ConfigStore.load(); + derivedRole = await getCachedProducerRole(adapter, cfg); + derivedChainState = chainStateFromRole(derivedRole); + } catch (_) { /* leave derivedRole/derivedChainState null on any error */ } + } + + // v0.5.229 (Phase D) — for the MAINCHAIN response, also attach + // CR Committee membership data so the dashboard chain-card's + // status chip can label a Council operator with their actual + // Council state ("Council · Elected" / "Council · Inactive") + // instead of falling through to the BPoS producer.state label + // (which is null for a pure Council operator). Uses + // CrMembershipService's 30s internal cache so attaching here is + // a cheap hashmap lookup once mainchain RPC is warm. + let crMemberSummary = null; + if (adapter.chainClass === 'A') { + try { + const cfg = await ConfigStore.load(); + const CrMembershipService = require('../services/CrMembershipService'); + const cr = await CrMembershipService.detectCrMembership(cfg, { + log: extensionHandle.log, + }); + // Only attach a non-null block when the CR lookup + // actually completed (matched OR not-in-committee). + // 'error' state → leave null so the chip doesn't + // flicker on transient RPC failures. + if (cr && cr.source !== 'error') { + crMemberSummary = { + isCrMember: !!cr.isCrMember, + state: cr.state || null, + nickname: cr.nickname || null, + inNextCommittee: !!cr.inNextCommittee, + source: cr.source, + }; + } + } catch (_) { /* leave crMemberSummary null */ } + } + return res.json(successBody({ chainId: adapter.chainId, displayName: adapter.displayName, @@ -451,7 +540,33 @@ function build(extensionHandle) { enabled: !!chainCfg.miner.enabled, rewardAddress: chainCfg.miner.rewardAddress || null, evmKeystoreAddr: chainCfg.miner.evmKeystoreAddr || null, + // v0.5.228d — derived live state. chainState mirrors + // /system/council-status's per-chain.chainState; one + // source of truth for both the dashboard EVM card + // (which polls /chains/:id) and the Settings badge + // (which polls /system/council-status). Null when the + // detect call couldn't complete (mainchain RPC down). + chainState: derivedChainState, + isOnDuty: derivedRole ? !!derivedRole.inCurrent : null, + inNextRotation: derivedRole ? !!derivedRole.inNext : null, + } : null, + // v0.5.237 — persisted sync mode (full | archive) per EVM + // sidechain, so the consolidated Sidechain settings tab reads + // the REAL value instead of assuming 'full' (the frontend's + // pre-237 fallback). Class B only; null elsewhere. fast is + // coerced to full at write time (v0.5.235), so a legacy stored + // 'fast' surfaces as 'full' here too. + sync: (adapter.chainClass === 'B' && chainCfg.sync) ? { + mode: (chainCfg.sync.mode && chainCfg.sync.mode !== 'fast') ? chainCfg.sync.mode : 'full', } : null, + // v0.5.229 (Phase D) — CR Committee membership summary, + // only attached to the MAINCHAIN response so the chain- + // card chip can label Council operators correctly. Null + // for non-mainchain or when the lookup failed. Frontend + // reads .crMember and falls back to producerState when + // null (preserves pre-229 behavior for BPoS operators + // and for mid-warmup RPC failures). + crMember: crMemberSummary, })); } catch (err) { extensionHandle.log.error(`${ENM_LOG_PREFIX} GET /chains/${req.params.chainId}: ${err.message}`); @@ -505,20 +620,34 @@ function build(extensionHandle) { const nextStart = (typeof a.nextturnstartheight === 'number') ? a.nextturnstartheight : (typeof a.nextTurnStartHeight === 'number' ? a.nextTurnStartHeight : null); - const current = Array.isArray(a.currentarbiters) - ? a.currentarbiters - : (Array.isArray(a.currentArbiters) ? a.currentArbiters : []); + // v0.5.229 (audit 2026-05-27) — TWO bugs fixed here, same as + // EvmSidechainAdapter.detectProducerRole: + // 1. The current-slate field on ELA's getarbitersinfo response + // is `arbiters`, NOT `currentarbiters`. The pre-229 reads + // (with the camelCase `currentArbiters` defensive fallback) + // both targeted fields that don't exist in the chain + // response — confirmed against Elastos.ELA struct definition + // at servers/interfaces.go:884-892. The rotation strip on + // the mainchain card has been broken for every Council + // operator since this endpoint shipped. + // 2. Empty-string entries in the slate (CRC arbiters with + // IsNormal=false at servers/interfaces.go:906-912) must + // be filtered before .findIndex so a MemberInactive + // operator's empty-string slot doesn't match anything. + const normalize = (s) => (typeof s === 'string' ? s.toLowerCase() : ''); + const current = Array.isArray(a.arbiters) + ? a.arbiters.map(normalize).filter((s) => s.length > 0) + : []; const next = Array.isArray(a.nextarbiters) - ? a.nextarbiters - : (Array.isArray(a.nextArbiters) ? a.nextArbiters : []); + ? a.nextarbiters.map(normalize).filter((s) => s.length > 0) + : []; const ourPubkey = chainCfg.dpos && chainCfg.dpos.nodePublicKey; - const normalize = (s) => (typeof s === 'string' ? s.toLowerCase() : ''); const ourLower = normalize(ourPubkey); const ourIndex = ourLower - ? current.findIndex((k) => normalize(k) === ourLower) + ? current.findIndex((k) => k === ourLower) : -1; const ourNextIndex = ourLower - ? next.findIndex((k) => normalize(k) === ourLower) + ? next.findIndex((k) => k === ourLower) : -1; const isOnDuty = !!(ourLower && onDuty && normalize(onDuty) === ourLower); return res.json(successBody({ @@ -642,11 +771,61 @@ function build(extensionHandle) { 'Chain spawned but exited within 1.5s. Check logs (Settings → Show technical details → Logs).', )); } + + // v0.5.228 — oracle pairing on manual start. If the operator + // started an EVM sidechain (esc / eid / pg), also start its + // companion oracle so cross-chain SPV proofs can be relayed. + // Best-effort: failure to start the oracle does NOT fail the + // parent's start response — the chain is up, the operator + // can retry the oracle from the Oracle card. Operator + // directive 2026-05-27: "they should be started together". + // We skip the conflict scan for the cascade since the parent + // already passed it 1.5s ago and the oracle uses a disjoint + // port set (oracle is a node script, not a chain binary). + let oraclePaired = null; + const ChainAdapter = require('../services/ChainAdapter'); + const pairedOracleId = ChainAdapter.oracleOf(adapter.chainId); + if (pairedOracleId && cfg.chains && cfg.chains[pairedOracleId]) { + const oracleAdapter = ChainRegistry.getAdapter(pairedOracleId); + if (oracleAdapter) { + try { + // Check first — if already running, no-op success. + const oracleStatus = ChainRegistry.getProcessService() + .statusSync(pairedOracleId); + if (oracleStatus && oracleStatus.alive) { + oraclePaired = { chainId: pairedOracleId, status: 'already-running' }; + } else { + await oracleAdapter.start(cfg.chains[pairedOracleId]); + oraclePaired = { chainId: pairedOracleId, status: 'started' }; + extensionHandle.log.info( + `${ENM_LOG_PREFIX} POST /chains/${adapter.chainId}/start: ` + + `paired oracle ${pairedOracleId} also started`, + ); + } + } catch (oracleErr) { + // Don't fail the parent response — surface the oracle + // failure as a warning so the UI can prompt a retry. + extensionHandle.log.warn( + `${ENM_LOG_PREFIX} POST /chains/${adapter.chainId}/start: ` + + `paired oracle ${pairedOracleId} start failed: ${oracleErr.message}`, + ); + oraclePaired = { + chainId: pairedOracleId, + status: 'start-failed', + error: oracleErr.message, + }; + } + } + } + return res.json(successBody({ ...result, // Surface non-blocking conflicts so the dashboard can show a // banner ("legacy node.sh data nearby") without aborting. warnings: conflicts.filter((c) => c.severity !== 'CRITICAL'), + // v0.5.228 — oracle pairing outcome (null when no oracle + // applies; { chainId, status } when a cascade was attempted). + oraclePaired, })); } catch (err) { extensionHandle.log.error(`${ENM_LOG_PREFIX} POST /chains/${req.params.chainId}/start: ${err.message}`); @@ -1777,11 +1956,29 @@ function build(extensionHandle) { // the 400 early-returns stay at the top level. The resulting closures // are then applied in place inside the atomic update() below (P0-7). const minerMutations = []; + // v0.5.228 — track when a legacy `miner.enabled` write came + // in. The field is derived from on-chain arbiter slate at + // every chain start (EvmSidechainAdapter.detectProducerRole + // overwrites it in-memory before spawn), so persisting an + // operator-supplied value is a no-op at next start. We accept + // it for backward compatibility with older frontends, log a + // warning, and surface a hint in the response so callers can + // migrate. New frontends (v0.5.228+) omit the field entirely + // and read derived state from GET /system/council-status. + let derivedHintEmitted = false; // Optional miner subdoc merge. if (body.miner && typeof body.miner === 'object') { if (typeof body.miner.enabled === 'boolean') { const enabled = body.miner.enabled; minerMutations.push((miner) => { miner.enabled = enabled; }); + derivedHintEmitted = true; + extensionHandle.log.warn( + `${ENM_LOG_PREFIX} PUT /chains/${chainId}/class-b-config: ` + + `client sent miner.enabled=${enabled} but the field is derived ` + + `from on-chain arbiter slate at every spawn — value will be ` + + `overwritten by detectProducerRole. Caller should stop sending it; ` + + `read GET /system/council-status for the true state.`, + ); } if (body.miner.rewardAddress !== undefined) { const addr = String(body.miner.rewardAddress || ''); @@ -1836,10 +2033,12 @@ function build(extensionHandle) { const m = String(body.sync.mode); if (!['fast', 'full', 'archive'].includes(m)) { return res.status(400).json(errorBody( - 'sync.mode: must be one of fast | full | archive', + 'sync.mode: must be one of full | archive', )); } - syncMode = m; + // v0.5.235 — fast sync removed; coerce a legacy 'fast' + // request to 'full' (EVM chains are always full-sync). + syncMode = (m === 'fast') ? 'full' : m; } } // Optional bootnodes array replace. v0.5.175 — validate each as a @@ -1888,7 +2087,18 @@ function build(extensionHandle) { extensionHandle.log.info( `${ENM_LOG_PREFIX} PUT /chains/${chainId}/class-b-config saved`, ); - return res.json(successBody({ chainId, chain: chainCfg })); + // v0.5.228 — surface the derived-field hint in the response + // so a frontend developer who sends miner.enabled sees a + // signal in the network panel that the field is deprecated. + const responsePayload = { chainId, chain: chainCfg }; + if (derivedHintEmitted) { + responsePayload.deprecations = [{ + field: 'miner.enabled', + reason: 'derived from on-chain arbiter slate at every chain start', + readFrom: 'GET /system/council-status', + }]; + } + return res.json(successBody(responsePayload)); } catch (err) { extensionHandle.log.error( `${ENM_LOG_PREFIX} PUT /chains/${req.params.chainId}/class-b-config: ${err.message}`, diff --git a/enm-server/src/routes/config.js b/enm-server/src/routes/config.js index 860494751e..0445161ef6 100644 --- a/enm-server/src/routes/config.js +++ b/enm-server/src/routes/config.js @@ -54,8 +54,11 @@ class ConfigPreconditionError extends Error {} * @param {object} extensionHandle * @returns {import('express').Router} */ -function build(extensionHandle) { +function build(extensionHandle, opts) { const router = express.Router(); + // v0.5.246 — lazy resolver for the fleet-monitoring status endpoint (built + // post-boot in server.js). Used to reload it after an Access save. + const getStatusEndpoint = (opts && opts.getStatusEndpoint) || (() => null); // GET /config — full config minus secrets. router.get('/', limit('read'), async (req, res) => { @@ -232,16 +235,26 @@ function build(extensionHandle) { // check — hoisted above the atomic write so a rejected request never // touches the config file. if (Array.isArray(body.whiteIPList)) { - const BROAD_CIDRS = ['0.0.0.0/0', '::/0']; - const broad = body.whiteIPList.filter( - (entry) => typeof entry === 'string' - && BROAD_CIDRS.includes(entry.trim()), - ); - if (broad.length) { + // v0.5.246 — reject not just 0.0.0.0/0 // ::/0 but any prefix broader + // than /24 (IPv4) or /64 (IPv6): those grant access to whole ISP + // blocks rather than a known host. The whitelist now also gates the + // externally-reachable monitor endpoint, so over-broad ranges are + // doubly unsafe. Gated here (not the schema) for a clear 400. + const tooBroad = body.whiteIPList.find((entry) => { + if (typeof entry !== 'string') { return false; } + const e = entry.trim(); + if (e === '0.0.0.0/0' || e === '::/0') { return true; } + const slash = e.indexOf('/'); + if (slash === -1) { return false; } // bare IP is fine + const bits = Number(e.slice(slash + 1)); + if (!Number.isInteger(bits)) { return false; } // Joi validated shape already + const isV6 = e.indexOf(':') !== -1; + return isV6 ? (bits < 64) : (bits < 24); + }); + if (tooBroad) { return res.status(400).json(errorBody( - `Whitelist entry "${broad[0]}" is too broad — it allows ` - + 'every host on the internet. List the specific IPs or ' - + 'subnets that need RPC access instead.', + `Whitelist entry "${tooBroad.trim()}" is too broad — list specific ` + + 'IPs, or subnets no wider than /24 (IPv4) or /64 (IPv6).', )); } } @@ -378,6 +391,19 @@ function build(extensionHandle) { } } + // v0.5.246 — refresh the fleet-monitoring status endpoint so an + // Access change (enable / whitelist / creds) takes effect live: it + // re-reads the policy and (un)binds its listener + reconciles its + // UFW per-source rules. Fire-and-forget — never block or fail the + // save the operator already committed. (The sidechain monitor view + // applies immediately; only ela's own RPC still needs a restart.) + try { + const se = getStatusEndpoint(); + if (se && typeof se.reload === 'function') { + Promise.resolve(se.reload()).catch(() => { /* non-fatal */ }); + } + } catch (_) { /* non-fatal */ } + // P1 (v0.5.183) — surface restart requirement for RPC-setting // changes (rpcEnabled / whiteIPList). Omitted entirely otherwise // so unrelated saves (logLevel etc.) don't nag. diff --git a/enm-server/src/routes/identity.js b/enm-server/src/routes/identity.js index bc3771a6cf..3271a2d466 100644 --- a/enm-server/src/routes/identity.js +++ b/enm-server/src/routes/identity.js @@ -67,7 +67,9 @@ const LOCKED_IN_PRODUCER_STATES = new Set([ ]); const CHAIN_ID = 'mainchain'; -const RESET_CONFIRM_PHRASE = 'reset keystore'; +// v0.5.232 — RESET_CONFIRM_PHRASE removed along with POST /identity/reset. +// The unified Settings → Reset ENM flow lives in routes/maintenance.js +// (POST /maintenance/reset-everything, confirm: "RESET EVERYTHING"). const IMPORT_CONFIRM_PHRASE = 'import'; const MAX_IMPORT_BYTES = 10 * 1024; @@ -94,11 +96,37 @@ function build(deps) { const producer = await KeystoreIdentity.getProducerState(CHAIN_ID); const ks = require('../services/ChainRegistry').getKeystoreService(); const exists = await ks.exists(); + // v0.5.229d (P2 audit fix) — Settings tab consumes /identity + // (this endpoint), NOT /system/identity. The Phase B + // crMember + setupRole fields were added to /system/identity + // only. Mirror them here so the Settings → Identity grid + // can branch the pill text on Council mode. + let crMember = null; + let setupRole = 'unknown'; + try { + const ConfigStore = require('../services/ConfigStore'); + const cfg = await ConfigStore.load(); + if (cfg && cfg.global && cfg.global.council + && cfg.global.council.installed === true) { + setupRole = 'council'; + } else if (cached && cached.publicKey && producer && producer.state) { + setupRole = 'bpos'; + } + if (cached && cached.publicKey) { + const CrMembershipService = require('../services/CrMembershipService'); + crMember = await CrMembershipService.detectCrMembership(cfg, { + log: extensionHandle.log, + }); + } + } catch (_) { /* graceful — leave defaults */ } return res.json(successBody({ chainId: CHAIN_ID, keystoreExists: exists, identity: cached || null, producer: producer || null, + // v0.5.229d additions: + crMember, + setupRole, // Convenience flag the UI uses to decide whether to // show the Unlock card. identityCacheMissing: exists && !cached, @@ -258,6 +286,24 @@ function build(deps) { }); return res.status(400).json(errorBody(r.error)); } + // v0.5.248 (validator-readiness audit P1-9) — importing a + // producer keystore is intent to SIGN, so ensure the mainchain + // runs with the DPoS arbiter enabled. Closes the edge where a + // node first set up keyless (enableArbiter=false) then imported + // a key later would hold a key but never sign. Best-effort: a + // failure here must not fail the import the operator committed. + try { + await ConfigStore.update((cfg) => { + if (cfg.chains && cfg.chains[CHAIN_ID] && cfg.chains[CHAIN_ID].dpos) { + cfg.chains[CHAIN_ID].dpos.enableArbiter = true; + } + return cfg; + }); + } catch (cfgErr) { + extensionHandle.log.warn( + `${ENM_LOG_PREFIX} POST /identity/import: enableArbiter set failed (non-fatal): ${cfgErr.message}`, + ); + } await _audit(getDb, extensionHandle.log, { walletAddress: wallet, decision: 'executed', @@ -280,108 +326,27 @@ function build(deps) { } }); - // POST /identity/reset - router.post('/reset', limit('admin'), requireOwner, async (req, res) => { - const { value, details } = RequestSchemas.validateBody( - RequestSchemas.identityResetBody, req.body, - ); - if (details) { - return res.status(400).json({ - ...errorBody('Invalid request body.'), - details, - }); - } - const wallet = readActorWallet(req); - if (value.confirm !== RESET_CONFIRM_PHRASE) { - return res.status(400).json(errorBody( - `Confirmation must be exactly "${RESET_CONFIRM_PHRASE}".`, - )); - } - // Anti-snipe gate, mirroring SelfHealingEngine's pattern. - try { - const cfg = await ConfigStore.load(); - const hash = cfg && cfg.global && cfg.global.antiSnipePasswordHash; - if (typeof hash === 'string' && hash.length > 0) { - if (!value.antiSnipePassword) { - return res.status(412).json(errorBody( - 'Anti-snipe password required. The Settings → Security tab has it set.', - )); - } - const ok = await _verifyAntiSnipe(hash, value.antiSnipePassword); - if (!ok) { - return res.status(401).json(errorBody('Anti-snipe password incorrect.')); - } - } - } catch (err) { - return res.status(500).json(errorBody('Anti-snipe verification failed. Please try again.')); - } - // Producer-state guard. - const producer = await KeystoreIdentity.getProducerState(CHAIN_ID); - // P1 (v0.5.183) — see _producerStateIndeterminate: a null state with an - // existing keystore identity means the on-chain producer record - // couldn't be verified (likely RPC down). Resetting then could wipe an - // Active producer keystore. Block unless force=true. - const indeterminate = await _producerStateIndeterminate(producer); - if (indeterminate && !value.force) { - return res.status(412).json({ - ...errorBody( - 'Couldn\'t verify the on-chain producer state (the mainchain RPC may be ' - + 'briefly unreachable). Resetting the keystore now could orphan an Active ' - + 'producer registration and lose block-production rewards. Retry once the node ' - + 'is synced and reachable, or acknowledge the rewards-loss warning to proceed anyway.', - ), - code: 'PRODUCER_STATE_UNVERIFIED', - }); - } - if (producer && LOCKED_IN_PRODUCER_STATES.has(producer.state) && !value.force) { - return res.status(412).json({ - ...errorBody( - `Producer is ${producer.state}. Resetting generates a new node public key, ` - + 'orphaning your on-chain registration. You\'ll miss block-production rewards ' - + 'until you sign DPoSV2UpdateProducer in Essentials with the new key. No deposit ' - + 'penalty (InactivePenalty=0 on mainnet). Acknowledge the rewards-loss warning to proceed.', - ), - code: 'PRODUCER_LOCKED_IN', - producerState: producer.state, - }); - } - try { - const r = await KeystoreIdentity.resetKeystore(CHAIN_ID, { - log: extensionHandle.log, - }); - if (!r.ok) { - await _audit(getDb, extensionHandle.log, { - walletAddress: wallet, - decision: 'failed', - outcome: `Identity reset failed: ${r.error}`, - payload: { action: 'identity-reset' }, - }); - return res.status(500).json(errorBody(r.error)); - } - await _audit(getDb, extensionHandle.log, { - walletAddress: wallet, - decision: 'executed', - outcome: `Identity reset: new pubkey ${r.publicKey.slice(0, 10)}…${r.publicKey.slice(-6)} (archived → ${r.archivedTo || 'none'})`, - payload: { - action: 'identity-reset', - publicKey: r.publicKey, - address: r.address, - archivedTo: r.archivedTo, - }, - }); - return res.json(successBody({ - publicKey: r.publicKey, - address: r.address, - // Returned ONCE — caller (the operator) is responsible - // for showing + having the operator acknowledge save. - generatedPassword: r.generatedPassword, - archivedTo: r.archivedTo, - keystorePath: r.keystorePath, - })); - } catch (err) { - extensionHandle.log.error(`${ENM_LOG_PREFIX} POST /identity/reset: ${err.message}`); - return res.status(500).json(errorBody('Keystore reset failed. Try again.')); - } + // POST /identity/reset — RETIRED v0.5.232. + // + // Folded into POST /maintenance/reset-everything. The standalone + // identity reset was footgun-shaped: rotating the keystore without + // wiping chain data orphans the on-chain producer/CR registration + // (the new pubkey doesn't match), and the operator still has to + // re-walk wizard cards anyway. The new reset-everything flow does + // both in one atomic operation. Returns 410 Gone to surface the + // change to any stale frontend or external caller. The original + // handler is preserved in git history (see commit b19c15bf and + // earlier) — anti-snipe gate, producer-state guard, and + // KeystoreIdentity.resetKeystore call all live there if any future + // work needs to revive a narrower "keystore-only" rotation path. + router.post('/reset', requireOwner, (_req, res) => { + return res.status(410).json(errorBody( + 'POST /identity/reset was retired in v0.5.232. Use Settings → Reset ENM ' + + '(POST /maintenance/reset-everything) instead — full reset wipes the ' + + 'keystore alongside chain data and restarts ENM with the wizard, in ' + + 'place. A standalone keystore rotation orphans your on-chain producer/' + + 'CR registration anyway, so the unified reset is the only safe path.', + )); }); // ------------------------------------------------------------------ @@ -468,24 +433,11 @@ async function _producerStateIndeterminate(producer) { } } -/** - * scrypt verify against `scrypt$$` shape produced - * by beta.3.10's anti-snipe setter route. - */ -function _verifyAntiSnipe(stored, attempt) { - const crypto = require('node:crypto'); - return new Promise((resolve) => { - const m = /^scrypt\$([a-fA-F0-9]+)\$([a-fA-F0-9]+)$/.exec(stored); - if (!m) { return resolve(false); } - const salt = Buffer.from(m[1], 'hex'); - const want = Buffer.from(m[2], 'hex'); - crypto.scrypt(attempt, salt, want.length, (err, derived) => { - if (err) { return resolve(false); } - try { resolve(crypto.timingSafeEqual(derived, want)); } - catch (_) { resolve(false); } - }); - }); -} +// v0.5.232 — _verifyAntiSnipe() removed. Its only caller was the retired +// POST /identity/reset handler. The canonical anti-snipe verifier is +// SelfHealingEngine._verifyAntiSnipePassword (same scrypt$$ +// shape, see services/SelfHealingEngine.js:1125) — use that if any +// future code needs to gate destructive actions on anti-snipe. /** * Best-effort audit write. Never blocks the action — operator already diff --git a/enm-server/src/routes/maintenance.js b/enm-server/src/routes/maintenance.js index 4ee5b9759c..f5056e88e4 100644 --- a/enm-server/src/routes/maintenance.js +++ b/enm-server/src/routes/maintenance.js @@ -2,14 +2,20 @@ * Copyright (C) 2026-present Elacity * SPDX-License-Identifier: AGPL-3.0 * - * routes/maintenance.js — Settings → Danger Zone (beta.3.33). + * routes/maintenance.js — Settings → Danger Zone (beta.3.33; reshaped v0.5.232). * * GET /maintenance/check-update owner — latest GitHub tag vs current * GET /maintenance/status owner — busy/idle of any pending action * POST /maintenance/update owner — fire deploy-enm.sh - * POST /maintenance/chain-resync owner — wipe chain data, keep keystore - * POST /maintenance/uninstall owner — uninstall extension, keep data - * POST /maintenance/nuke owner — uninstall + rm -rf everything + * POST /maintenance/chain-resync owner — wipe one or many chains' data, + * keep keystore + nodekey + * POST /maintenance/reset-everything owner — wipe ALL data (incl. keystore), + * restart ENM in place (bundle + * stays so pc2-node respawns us) + * + * POST /maintenance/uninstall RETIRED (410 Gone) — duplicated pc2 desktop + * POST /maintenance/nuke RETIRED (410 Gone) — replaced by reset-everything + * POST /identity/reset RETIRED (410 Gone) — folded into reset-everything * * All write paths are owner-gated, rate-limited via `admin` scope, and * accept a `confirm` field that the route validates against the exact @@ -17,9 +23,9 @@ * enforces the same typed-confirmation gate; this is defence in depth. * * Typed-confirmation sentinels (case-sensitive): - * chain-resync : "" (e.g. "mainchain") - * uninstall : "remove" - * nuke : "WIPE EVERYTHING" + * chain-resync (single): "" (legacy, e.g. "mainchain") + * chain-resync (multi): "RESYNC" (v0.5.232 Council mode) + * reset-everything: "RESET EVERYTHING" (v0.5.232 in-place reset) * * Each successful action emits an EnmAuditLog row with tier * "CRITICAL-INFO", decision "executed", executor "operator", @@ -168,12 +174,25 @@ function build(deps) { // ------------------------------------------------------------------ // POST /maintenance/chain-resync owner // - // Body: { chainId: "mainchain", confirm: "mainchain" } + // v0.5.232 — accepts both shapes: + // + // Legacy single-chain (BPoS): + // { chainId: "mainchain", confirm: "mainchain" } + // confirm must equal chainId (frontend types the chain name). // - // confirm must equal chainId — the frontend types-to-confirm gate. - // We re-check server-side so a CSRF-style request without the - // typed value can't bypass the safety check. + // Multi-chain (v0.5.232 Council): + // { chainIds: ["mainchain","esc","eid","pg"], confirm: "RESYNC" } + // confirm is the static string "RESYNC". + // + // Schema's .or('chainId','chainIds') guarantees at least one is set; + // route normalizes to chainIds[] internally. Arbiter + oracles are + // rejected (no chaindata to wipe — they're services, not chains). // ------------------------------------------------------------------ + // chainIds that have no on-disk chaindata to wipe. Resyncing these is a + // no-op at best and a confusing audit-log entry at worst — fail loud. + const RESYNC_INELIGIBLE = new Set([ + 'arbiter', 'esc-oracle', 'eid-oracle', 'pg-oracle', + ]); router.post('/chain-resync', limit('admin'), requireOwner, async (req, res) => { const { value, details } = RequestSchemas.validateBody( RequestSchemas.maintenanceChainResyncBody, req.body, @@ -185,134 +204,120 @@ function build(deps) { }); } const wallet = readActorWallet(req); - const { chainId, confirm } = value; - if (confirm !== chainId) { + const { chainId, chainIds: chainIdsBody, confirm } = value; + // Normalize to an ordered, deduped array. + const ids = Array.isArray(chainIdsBody) && chainIdsBody.length > 0 + ? Array.from(new Set(chainIdsBody)) + : (chainId ? [chainId] : []); + if (ids.length === 0) { return res.status(400).json(errorBody( - 'Confirmation does not match chain name.', + 'Provide either chainId (string) or chainIds (array).', )); } - try { - const r = await MaintenanceManager.chainResync({ - chainId, - log: extensionHandle.log, - // beta.3.42 — extensionHandle lets the resync reach into - // enm_setup_state and reset current_step='bootstrap' so - // the wizard reappears for the operator to choose - // bootstrap-vs-genesis again. Without this, the resync - // just silently wipes data and the dashboard sits at - // "syncing from 0" forever. - extensionHandle, - }); - await _audit(getDb, extensionHandle.log, { - walletAddress: wallet, - ruleId: null, tier: 'CRITICAL-INFO', - decision: 'executed', executor: 'operator', - outcome: `Chain resync ${chainId}: wiped ${r.removedPaths.length} path(s); keystore backup=${r.keystoreBackup || 'none'}`, - payload: r, - }); - return res.json(successBody({ - action: 'chain-resync', - chainId, - removedPaths: r.removedPaths, - keystoreBackup: r.keystoreBackup, - message: 'Chain data wiped. Re-sync started — may take 4–8 hours.', - })); - } catch (err) { - extensionHandle.log.error(`${ENM_LOG_PREFIX} POST /maintenance/chain-resync: ${err.message}`); - await _audit(getDb, extensionHandle.log, { - walletAddress: wallet, - ruleId: null, tier: 'CRITICAL-INFO', - decision: 'failed', executor: 'operator', - outcome: `Chain resync ${chainId} failed: ${err.message}`, - payload: { action: 'chain-resync', chainId, code: err.code }, - }); - const status = err.code === 'BUSY' ? 409 - : err.code === 'NO_CHAIN' ? 404 : 500; - // Same pattern as /update above: BUSY message stays as-is - // (operator-meaningful), 404 carries the chain-name reference - // operators recognize, 500 fallbacks go to the Activity tab - // where the audit row above (line 214-220) preserves err.message. - const responseMessage = status === 500 - ? 'Chain re-sync failed. Check the Activity tab for the underlying error.' - : err.message; - return res.status(status).json(errorBody(responseMessage)); - } - }); - - // ------------------------------------------------------------------ - // POST /maintenance/uninstall owner - // - // Body: { confirm: "remove" } - // - // Detaches a script that DELETEs the extension via pc2-node - // (purge=false), leaving /var/lib/pc2/data/extensions/elastos- - // node-manager intact for recovery. - // ------------------------------------------------------------------ - router.post('/uninstall', limit('admin'), requireOwner, async (req, res) => { - const { value, details } = RequestSchemas.validateBody( - RequestSchemas.maintenanceUninstallBody, req.body, - ); - if (details) { - return res.status(400).json({ - ...errorBody('Invalid request body.'), - details, - }); - } - const wallet = readActorWallet(req); - if (value.confirm !== 'remove') { + // v0.5.232 — reject ineligible chains (no chaindata to wipe). + const ineligible = ids.filter((c) => RESYNC_INELIGIBLE.has(c)); + if (ineligible.length > 0) { return res.status(400).json(errorBody( - 'Confirmation must be the word "remove".', + `These chains have no chaindata to resync: ${ineligible.join(', ')}. ` + + 'Restart them instead via /chains/:id/restart.', )); } - try { - await _audit(getDb, extensionHandle.log, { - walletAddress: wallet, - ruleId: null, tier: 'CRITICAL-INFO', - decision: 'executed', executor: 'operator', - outcome: 'Maintenance uninstall queued (data dir preserved)', - payload: { action: 'uninstall' }, - }); - const r = await MaintenanceManager.uninstall({ log: extensionHandle.log }); - return res.json(successBody({ - queued: true, - logFile: r.logFile, - message: 'Uninstall queued. ENM will be removed within ~10 seconds — ' - + 'this page will disconnect when it does. Chain data + keystore ' - + 'stay on disk so a reinstall can recover them.', - })); - } catch (err) { - extensionHandle.log.error(`${ENM_LOG_PREFIX} POST /maintenance/uninstall: ${err.message}`); - await _audit(getDb, extensionHandle.log, { - walletAddress: wallet, - ruleId: null, tier: 'CRITICAL-INFO', - decision: 'failed', executor: 'operator', - outcome: `Maintenance uninstall failed: ${err.message}`, - payload: { action: 'uninstall', code: err.code }, + // Confirm logic: legacy single-chain expects confirm===chainId; new + // multi-chain (any time chainIds is used OR multiple ids resolved) + // expects the static "RESYNC". This preserves the v0.5.231 BPoS + // gate while letting Council operators confirm 1-N chains uniformly. + const isMulti = Array.isArray(chainIdsBody); + if (isMulti) { + if (confirm !== 'RESYNC') { + return res.status(400).json(errorBody( + 'Confirmation must be exactly "RESYNC" (uppercase) for the multi-chain form.', + )); + } + } else { + if (confirm !== ids[0]) { + return res.status(400).json(errorBody( + 'Confirmation does not match chain name.', + )); + } + } + // Run resyncs serially — parallel wipes would thrash disk + race + // each other through the maintenance lock. The HTTP response is + // queued, not streamed: operators see "queued" then watch chain + // states transition through the dashboard / Activity tab. + const results = []; + const failures = []; + for (const id of ids) { + try { + const r = await MaintenanceManager.chainResync({ + chainId: id, + log: extensionHandle.log, + extensionHandle, + }); + results.push({ chainId: id, ok: true, ...r }); + await _audit(getDb, extensionHandle.log, { + walletAddress: wallet, + chainId: id, + ruleId: null, tier: 'CRITICAL-INFO', + decision: 'executed', executor: 'operator', + outcome: `Chain resync ${id}: wiped ${r.removedPaths.length} path(s); keystore backup=${r.keystoreBackup || 'none'}`, + payload: r, + }); + } catch (err) { + failures.push({ chainId: id, error: err.message, code: err.code }); + extensionHandle.log.error( + `${ENM_LOG_PREFIX} POST /maintenance/chain-resync (${id}): ${err.message}`, + ); + await _audit(getDb, extensionHandle.log, { + walletAddress: wallet, + chainId: id, + ruleId: null, tier: 'CRITICAL-INFO', + decision: 'failed', executor: 'operator', + outcome: `Chain resync ${id} failed: ${err.message}`, + payload: { action: 'chain-resync', chainId: id, code: err.code }, + }); + // If the FIRST chain hit BUSY, stop the loop and surface + // 409 to the operator — they can retry once the in-flight + // maintenance finishes. Subsequent chains' BUSY is rare + // because chainResync acquires + releases per-call. + if (err.code === 'BUSY' && results.length === 0) { + return res.status(409).json(errorBody(err.message)); + } + } + } + const allFailed = results.length === 0 && failures.length > 0; + if (allFailed) { + return res.status(500).json({ + ...errorBody('All chain resyncs failed. Check the Activity tab.'), + failures, }); - const status = err.code === 'BUSY' ? 409 : 500; - // Same Sessions 64/67/79 pattern: BUSY message is operator- - // meaningful ("Another maintenance action is in progress"); - // 500 fallback goes to the Activity tab for the audit-row - // err.message above (line 285-291). - const responseMessage = status === 500 - ? 'Uninstall failed. Check the Activity tab for the underlying error.' - : err.message; - return res.status(status).json(errorBody(responseMessage)); } + return res.json(successBody({ + action: 'chain-resync', + chainIds: ids, + results, + failures, + message: failures.length === 0 + ? `Chain data wiped for ${ids.length} chain(s). Re-sync started — may take 4–8 hours.` + : `Chain data wiped for ${results.length}/${ids.length} chain(s); ${failures.length} failed (see Activity tab).`, + })); }); // ------------------------------------------------------------------ - // POST /maintenance/nuke owner + // POST /maintenance/reset-everything owner (v0.5.232) // - // Body: { confirm: "WIPE EVERYTHING" } case-sensitive + // Body: { confirm: "RESET EVERYTHING" } case-sensitive // - // Detaches a script that DELETEs the extension (purge=true) and - // rm -rf the data dir. Operator loses keystore. Extra confirm- - // word friction reflects the impact. + // The single in-app destructive flow. Wipes ALL data (chain data, + // keystore, nodekey, enm.db, audit log, healing history) and SIGKILLs + // ENM, but KEEPS the bundle and the pc2-node installed_apps row so + // pc2-node's process supervisor respawns ENM with empty data — the + // setup wizard appears, and the iframe is never orphaned. Replaces + // the retired /maintenance/uninstall, /maintenance/nuke, and + // /identity/reset routes (all return 410 Gone now). // ------------------------------------------------------------------ - router.post('/nuke', limit('admin'), requireOwner, async (req, res) => { + router.post('/reset-everything', limit('admin'), requireOwner, async (req, res) => { const { value, details } = RequestSchemas.validateBody( - RequestSchemas.maintenanceNukeBody, req.body, + RequestSchemas.maintenanceResetEverythingBody, req.body, ); if (details) { return res.status(400).json({ @@ -321,11 +326,9 @@ function build(deps) { }); } const wallet = readActorWallet(req); - // Case-sensitive — "wipe everything" / "WIPE everything" both - // bounce. Operator must type the exact gate string. - if (value.confirm !== 'WIPE EVERYTHING') { + if (value.confirm !== 'RESET EVERYTHING') { return res.status(400).json(errorBody( - 'Confirmation must be exactly "WIPE EVERYTHING" (uppercase).', + 'Confirmation must be exactly "RESET EVERYTHING" (uppercase).', )); } try { @@ -333,28 +336,27 @@ function build(deps) { walletAddress: wallet, ruleId: null, tier: 'CRITICAL-INFO', decision: 'executed', executor: 'operator', - outcome: 'Maintenance NUKE queued — extension uninstall + data wipe', - payload: { action: 'nuke' }, + outcome: 'Reset everything queued — all data wiped, ENM will restart to wizard', + payload: { action: 'reset-everything' }, }); - const r = await MaintenanceManager.nuke({ log: extensionHandle.log }); + const r = await MaintenanceManager.resetEverything({ log: extensionHandle.log }); return res.json(successBody({ queued: true, logFile: r.logFile, - message: 'Nuclear wipe queued. ENM and all its data — including ' - + 'the keystore — will be destroyed within ~10 seconds. This ' - + 'page will disconnect when it does. There is no undo.', + message: 'Reset queued. ENM will wipe all data and restart within ' + + '~10 seconds. The setup wizard will reappear when it comes ' + + 'back up. If the page does not reload automatically, refresh it.', })); } catch (err) { - extensionHandle.log.error(`${ENM_LOG_PREFIX} POST /maintenance/nuke: ${err.message}`); + extensionHandle.log.error(`${ENM_LOG_PREFIX} POST /maintenance/reset-everything: ${err.message}`); await _audit(getDb, extensionHandle.log, { walletAddress: wallet, ruleId: null, tier: 'CRITICAL-INFO', decision: 'failed', executor: 'operator', - outcome: `Maintenance nuke failed: ${err.message}`, - payload: { action: 'nuke', code: err.code }, + outcome: `Reset everything failed: ${err.message}`, + payload: { action: 'reset-everything', code: err.code }, }); const status = err.code === 'BUSY' ? 409 : 500; - // Same pattern as /uninstall above + Sessions 64/67/79. const responseMessage = status === 500 ? 'Reset failed. Check the Activity tab for the underlying error.' : err.message; @@ -362,6 +364,29 @@ function build(deps) { } }); + // ------------------------------------------------------------------ + // RETIRED v0.5.232 — these three endpoints all return 410 Gone with + // a message pointing to the replacement flow. Kept in the router so + // any external caller (or a stale frontend mid-upgrade) gets a useful + // error instead of a silent 404. Remove after 2 release cycles. + // ------------------------------------------------------------------ + router.post('/uninstall', requireOwner, (_req, res) => { + return res.status(410).json(errorBody( + 'POST /maintenance/uninstall was retired in v0.5.232. To remove the ENM ' + + 'extension from PC2, right-click the ENM tile on the PC2 desktop and ' + + 'choose Uninstall. To wipe data and start fresh inside the app, use ' + + 'Settings → Reset ENM.', + )); + }); + router.post('/nuke', requireOwner, (_req, res) => { + return res.status(410).json(errorBody( + 'POST /maintenance/nuke was retired in v0.5.232. Use Settings → Reset ENM ' + + '(POST /maintenance/reset-everything) instead — the new flow wipes the ' + + 'same data but keeps the extension installed so the wizard reappears ' + + 'in place, fixing the "another pc2 inside the app" reload bug.', + )); + }); + return router; } diff --git a/enm-server/src/routes/setup.js b/enm-server/src/routes/setup.js index 9aaed5f9f0..89e13ceb82 100644 --- a/enm-server/src/routes/setup.js +++ b/enm-server/src/routes/setup.js @@ -1144,14 +1144,17 @@ function build(extensionHandle) { } threads = body.miner.threads; } - let syncMode = 'fast'; + // v0.5.235 — default + floor is FULL. Fast sync is removed; a + // 'fast' request is accepted for backward-compat but coerced to + // 'full' (EVM chains are always validator-grade full sync). + let syncMode = 'full'; if (body.sync && body.sync.mode) { if (!['fast', 'full', 'archive'].includes(body.sync.mode)) { return res.status(400).json(errorBody( - 'sync.mode must be one of fast | full | archive', + 'sync.mode must be one of full | archive', )); } - syncMode = body.sync.mode; + syncMode = (body.sync.mode === 'fast') ? 'full' : body.sync.mode; } const minerEnabled = body.miner && body.miner.enabled === true; // beta.0.4.1 (operator directive) — SHA256 manifest is @@ -1717,9 +1720,29 @@ function build(extensionHandle) { } const sharedPassword = masterPassword; // legacy alias for the orchestrator + // v0.5.236 — persist the operator's initial-sync strategy (Card 5 + // hardware-tier choice). 'staged' tells EnmAutoStart to bring the + // heavy chains up 2-at-a-time via EnmStageSyncOrchestrator so a + // lower-end host isn't crushed by simultaneous EVM full-syncs. + // Default 'concurrent' = legacy all-at-once. Written before the + // orchestrator + first autoStart so it's in place when chains boot. + const syncStrategy = body.syncStrategy === 'staged' ? 'staged' : 'concurrent'; + try { + await ConfigStore.update((c) => { + c.global = c.global || {}; + c.global.syncStrategy = syncStrategy; + }, { logger: extensionHandle.log }); + } catch (err) { + extensionHandle.log.warn( + `${ENM_LOG_PREFIX} install-council: failed to persist syncStrategy ` + + `(${err.message}) — defaulting to concurrent`, + ); + } + // Return 202 immediately + run the orchestrator in the background. res.status(202).json(successBody({ started: true, + syncStrategy, sseTopic: 'setup:council:install', statusEndpoint: '/api/enm/setup/install-council/status', })); @@ -2501,11 +2524,17 @@ async function runCouncilInstall(args) { evmKeystorePasswordEncrypted: '', threads: 1, }, - // v0.5.189 — install as a FOLLOWER on fast sync (node.sh's - // else branch). start() flips this to 'full' when it PROMOTES - // a confirmed on-duty node to miner. Fast sync also dodges the - // EID full-execution DID wedge for non-producers. - sync: { mode: 'fast' }, + // v0.5.235 — install on FULL sync (council-ready). EVM + // chains always full-sync now; the old fast-follower + // default is removed. The forced-full-sync DID wedge + // that fast used to dodge is handled structurally by + // the lockstep SPV wipe (chainResync v0.5.235), so a + // from-genesis full-sync builds a correct DID index and + // validates cleanly (node.sh runs producers on full). + // miner.enabled stays false here — mining is promoted + // at boot by detectProducerRole when on-duty; only the + // sync mode is now unconditionally full. + sync: { mode: 'full' }, bootnodes: [], healing: { enabledRules: {} }, binarySha256Expected: '', @@ -3001,6 +3030,18 @@ async function runCouncilInstall(args) { cfgFinal.setup.completed = true; cfgFinal.setup.completedAt = Date.now(); cfgFinal.setup.completedStep = 'council-install'; + // v0.5.229 (audit 2026-05-27) — durable "this is a Council + // install" flag. The dashboard reads this via /system/identity + // and /system/council-status to render Council-mode UI from + // the first paint, before the live listcurrentcrs RPC has a + // chance to respond. Pre-229 the wizard only saved + // localStorage.enm:setup-intent which the dashboard never + // read — so every Council operator saw the BPoS default + // labelling instead. + cfgFinal.global = cfgFinal.global || {}; + cfgFinal.global.council = cfgFinal.global.council || {}; + cfgFinal.global.council.installed = true; + cfgFinal.global.council.installedAt = Date.now(); }); // 0.5.145 audit Session 145 — mirror BPoS /setup/complete (line @@ -3168,6 +3209,33 @@ async function runCouncilPreflight(args) { severity: 'required', }); + // v0.5.248 (validator-readiness audit P1) — clock-skew check. The + // general /setup/preflight already runs this, but the Council install + // preflight (Card D.5) did NOT, so a Council operator could install + // onto a host whose clock is outside ela's ~4.2 s DPoS block-validation + // tolerance and start missing blocks / earning penalties the moment it + // goes on-duty. Recommended-severity (fail-soft): a >2 s skew warns but + // doesn't hard-block (it's correctable post-install via NTP), and an + // unreachable time probe is skipped rather than failed so the wizard + // never wedges offline. Reuses the same probe + 5 s outer timeout as + // /setup/preflight, so the worst-case added latency is bounded. + const clockSkew = await runClockSkewCheck(extensionHandle); + let clockMsg; + if (clockSkew.skipped) { + clockMsg = `couldn’t verify (${clockSkew.reason || 'network unreachable'}) — check host NTP (systemd-timesyncd) if you suspect clock drift`; + } else if (clockSkew.ok) { + clockMsg = `${clockSkew.skewMs >= 0 ? '+' : ''}${clockSkew.skewMs} ms (within ±${clockSkew.maxSkewMs} ms)`; + } else { + clockMsg = `clock off by ${clockSkew.absSkewMs} ms — exceeds ±${clockSkew.maxSkewMs} ms. ela’s DPoS block validation tolerates only ~4.2 s; fix NTP (systemd-timesyncd) before this node goes on-duty.`; + } + checks.push({ + id: 'clock-skew', + label: `Clock within ±${clockSkew.maxSkewMs} ms of network time`, + ok: clockSkew.ok, + message: clockMsg, + severity: 'recommended', + }); + // 4-6. HEAD probes for the three upstream services we depend on. async function headProbe(url, timeoutMs) { return new Promise((resolve) => { diff --git a/enm-server/src/routes/system.js b/enm-server/src/routes/system.js index 572c15ca5a..8ae0416499 100644 --- a/enm-server/src/routes/system.js +++ b/enm-server/src/routes/system.js @@ -270,6 +270,48 @@ function build(extensionHandle) { } catch (_) { /* graceful degrade — leave producer null */ } } + // v0.5.229 (audit 2026-05-27) — CR Council membership lookup, + // in PARALLEL with the BPoS producer lookup above. Council and + // BPoS are independent roles on Elastos; an operator can be + // one, the other, both, or neither. Pre-229 the endpoint only + // surfaced BPoS state — so every Council operator saw "BPoS + // supernode: not yet registered" on the dashboard regardless + // of their actual CR Committee binding. node.sh:1117-1129 + // shows the reference contract: query listcurrentcrs + + // listproducers SEPARATELY and surface BOTH side-by-side. + // + // CrMembershipService.detectCrMembership handles failure + // modes (no pubkey / no RPC / Committee not in election + // period) by returning a sentinel `source` value — we pass + // that through so the frontend can render the right copy + // even when isCrMember is false (e.g. "not bound" vs + // "Committee not currently active"). + let crMember = null; + if (publicKey) { + try { + const cfg2 = await ConfigStore.load(); + const CrMembershipService = require('../services/CrMembershipService'); + crMember = await CrMembershipService.detectCrMembership(cfg2, { + log: extensionHandle.log, + }); + } catch (_) { /* graceful degrade — leave crMember null */ } + } + + // v0.5.229 — derive setup-role hint from cfg.global.council + // so the frontend can pick Council-vs-BPoS UI even before + // listcurrentcrs returns (e.g. during mainchain warm-up). + // Defaults to 'unknown' when no install path can be inferred. + let setupRole = 'unknown'; + try { + const cfg3 = await ConfigStore.load(); + if (cfg3 && cfg3.global && cfg3.global.council + && cfg3.global.council.installed === true) { + setupRole = 'council'; + } else if (publicKey && producer && producer.state) { + setupRole = 'bpos'; + } + } catch (_) { /* leave setupRole = 'unknown' */ } + // beta.3.52 — `walletAddress` removed from response. ENM's identity // is the keystore (ELA mainchain producer), NOT the PC2 owner wallet. // The two are completely separate concerns: @@ -283,6 +325,12 @@ function build(extensionHandle) { address, }, producer, + // v0.5.229 — CR Council membership (null when no pubkey). + // Frontend treats `isCrMember === true` as the canonical + // signal to render Council UI; otherwise falls back to + // producer + setupRole to decide BPoS / unregistered. + crMember, + setupRole, })); } catch (err) { extensionHandle.log.error(`${ENM_LOG_PREFIX} /system/identity error: ${err.message}`); @@ -465,6 +513,106 @@ function build(extensionHandle) { } }); + /** + * GET /system/host-limits + * + * v0.5.225 — read provider-imposed cgroup limits so the frontend + * can surface a "constrained host" banner before EVM sync overwhelms + * the VPS. Triggered by the Hostinger incident 2026-05-25 where + * /var/lib/pc2's host had a 2-core cap and ESC + EID + PG starting + * simultaneously pushed total CPU past it; provider paused the node. + * + * Returns null fields when no limit is detected (bare-metal host, + * unlimited container). isConstrained derivation happens on the + * frontend (utils-host-limits.js) per operator directive that + * budget features should be opt-in / auto-detected, not default. + * + * cgroup v2 (newer Linux, most modern hosting): /sys/fs/cgroup/cpu.max + * format: " " microseconds, OR "max " = no cap + * cgroup v1 (older + some VPS): /sys/fs/cgroup/cpu/cpu.cfs_quota_us + + * cpu.cfs_period_us. quota = -1 means unlimited. + */ + router.get('/host-limits', limit('read'), async (req, res) => { + if (!readActorWallet(req)) { + return res.status(401).json(errorBody('Authentication required.')); + } + try { + let cpuCapCores = null; + let memoryCapGb = null; + let source = 'none'; + + // ---- CPU cap — cgroup v2 first ---- + try { + const v2 = await fsp.readFile('/sys/fs/cgroup/cpu.max', 'utf8'); + const parts = v2.trim().split(/\s+/); + // "max " → no cap. " " → cap. + if (parts.length === 2 && parts[0] !== 'max') { + const quotaUs = parseInt(parts[0], 10); + const periodUs = parseInt(parts[1], 10); + if (Number.isFinite(quotaUs) && Number.isFinite(periodUs) + && quotaUs > 0 && periodUs > 0) { + cpuCapCores = round(quotaUs / periodUs, 2); + source = 'cgroup-v2'; + } + } + } catch (_) { /* not v2 — try v1 */ } + + // ---- cgroup v1 fallback ---- + if (cpuCapCores == null) { + try { + const quotaRaw = await fsp.readFile('/sys/fs/cgroup/cpu/cpu.cfs_quota_us', 'utf8'); + const periodRaw = await fsp.readFile('/sys/fs/cgroup/cpu/cpu.cfs_period_us', 'utf8'); + const quotaUs = parseInt(quotaRaw.trim(), 10); + const periodUs = parseInt(periodRaw.trim(), 10); + if (Number.isFinite(quotaUs) && Number.isFinite(periodUs) + && quotaUs > 0 && periodUs > 0) { + cpuCapCores = round(quotaUs / periodUs, 2); + source = 'cgroup-v1'; + } + } catch (_) { /* no cgroup v1 cpu — give up on CPU cap */ } + } + + // ---- Memory cap — cgroup v2 first ---- + try { + const v2 = await fsp.readFile('/sys/fs/cgroup/memory.max', 'utf8'); + const trimmed = v2.trim(); + if (trimmed !== 'max') { + const bytes = parseInt(trimmed, 10); + if (Number.isFinite(bytes) && bytes > 0) { + memoryCapGb = round(bytes / (1024 ** 3), 2); + if (source === 'none') { source = 'cgroup-v2'; } + } + } + } catch (_) { /* try v1 */ } + + if (memoryCapGb == null) { + try { + const v1 = await fsp.readFile('/sys/fs/cgroup/memory/memory.limit_in_bytes', 'utf8'); + const bytes = parseInt(v1.trim(), 10); + // cgroup v1 "unlimited" is usually 9223372036854771712 (≈8 EB). + // Treat anything > total RAM × 16 as effectively unlimited. + const sanityCap = os.totalmem() * 16; + if (Number.isFinite(bytes) && bytes > 0 && bytes < sanityCap) { + memoryCapGb = round(bytes / (1024 ** 3), 2); + if (source === 'none') { source = 'cgroup-v1'; } + } + } catch (_) { /* no v1 memory either */ } + } + + return res.json(successBody({ + cpuCapCores, // null = no cap detected (or unreadable) + memoryCapGb, // null = no cap + source, // 'cgroup-v2' | 'cgroup-v1' | 'none' + cpuTotalCores: os.cpus().length, + memoryTotalGb: round(os.totalmem() / (1024 ** 3), 2), + readAt: Date.now(), + })); + } catch (err) { + extensionHandle.log.error(`${ENM_LOG_PREFIX} /system/host-limits error: ${err.message}`); + return res.status(500).json(errorBody('Failed to read host limits.')); + } + }); + /** * GET /system/extip * Settings → Network → "Detect now". Hits checkip.amazonaws.com and @@ -485,6 +633,377 @@ function build(extensionHandle) { } }); + // ----------------------------------------------------------------- + // v0.5.228 — GET /system/council-status + // + // Operator directive 2026-05-27: ENM has been falsely modeling + // "mining" as an operator-settable toggle. In reality (per node.sh, + // verified end-to-end in this session): + // - node.sh:2133 only gates --mine on existence of the password + // file from `_init`, then always passes --mine + // - The sidechain's PBFT consensus engine self-gates production + // via IsProducer() + IsOnduty(); a node not in the arbiter + // slate simply fails Seal() silently + // - Council membership is BOUND on-chain via CRCouncilMember- + // ClaimNode TX (submitted in Essentials); once confirmed, + // ELA's getCRCArbitersV2 enrolls the node and each sidechain + // polls the slate and starts producing automatically + // + // The backend (EvmSidechainAdapter.detectProducerRole + start) + // has already implemented this correctly since v0.5.188 — mining + // flags are derived per-spawn from on-chain arbiter slate, not + // from cfg.miner.enabled. This endpoint exposes that DERIVED + // status to the UI so the new "Validator status" badge replaces + // the misleading Mining on/off toggle in Settings. + // + // Returns per-EVM-chain status: + // { + // nodePublicKey: '04abc…', + // chains: { + // esc: { isOnDuty, inCurrent, inNext, source, error? }, + // eid: { ... }, + // pg: { ... }, + // }, + // lastChecked: , + // } + // + // The four state labels the UI uses are computed in the frontend + // from these flags: + // - "On-duty" — inCurrent=true (actively producing this rotation) + // - "Standby" — inNext=true && inCurrent=false (next rotation) + // - "Inactive" — adapter exists + cfg present, but neither in + // current nor next slate (e.g. registered as + // producer but not Council-bound, or rotation + // doesn't include this node) + // - "Follower" — adapter not registered / chain not configured + // (operator didn't run init for this chain) + // + // Cached: returns whatever detectProducerRole gave last time it + // ran (~30s freshness via its own internal call to mainchain RPC). + // No additional caching layer here — keeps this thin. + // ----------------------------------------------------------------- + router.get('/council-status', limit('read'), async (req, res) => { + const wallet = readActorWallet(req); + if (!wallet) { + return res.status(401).json(errorBody('Authentication required.')); + } + try { + const cfg = await ConfigStore.load(); + // Read the operator's node public key from the same cached + // keystore-account.json that /system/identity reads. We + // never expose it to non-owner callers, but the wallet + // already matched above so this is owner-gated. + let nodePublicKey = null; + try { + const ks = ChainRegistry.getKeystoreService(); + const keystoreExists = await ks.exists(); + if (keystoreExists) { + const identityPath = path.join(chainDir('mainchain'), 'keystore-account.json'); + const raw = await fsp.readFile(identityPath, 'utf8'); + const parsed = JSON.parse(raw); + nodePublicKey = parsed.publicKey || null; + } + } catch (_) { /* missing cache / keystore not unlocked — leave null */ } + + const EVM_CHAINS = ['esc', 'eid', 'pg']; + // v0.5.228d (audit F10) — sequential 3× mainchain RPC was + // ~3× the wall-clock cost of necessary. detectProducerRole + // is pure-read against mainchain getarbitersinfo (no + // ordering dependency between EVM chains), so fire them in + // parallel via Promise.all. On a slow mainchain this drops + // page-load latency from ~15s worst-case to ~5s. + // + // Per-chain failures still degrade gracefully: a rejected + // promise becomes an `{ chainState: 'unknown', error: ... }` + // entry, the request as a whole still returns 200, and the + // operator gets a partial picture rather than a 500. + // + // The local chainStateFromRole helper mirrors the one used + // by GET /chains/:id (in routes/chains.js) so the dashboard + // card and the Settings badge label the same on-chain state + // identically. Kept inline (rather than imported) so this + // route stays self-contained. + function chainStateFromRole(role) { + if (!role) { return 'unknown'; } + if (role.inCurrent === true) { return 'on-duty'; } + if (role.inNext === true) { return 'standby'; } + if (role.isProducer === null) { return 'unknown'; } + return 'inactive'; + } + const perChainEntries = await Promise.all(EVM_CHAINS.map(async (cid) => { + const adapter = ChainRegistry.getAdapter(cid); + if (!adapter) { + return [cid, { + isOnDuty: false, + inCurrent: false, + inNext: false, + source: 'not-configured', + chainState: 'follower', + }]; + } + if (typeof adapter.detectProducerRole !== 'function') { + return [cid, { + isOnDuty: false, + inCurrent: false, + inNext: false, + source: 'unsupported', + chainState: 'unknown', + error: 'adapter does not support detectProducerRole', + }]; + } + try { + const role = await adapter.detectProducerRole(cfg); + return [cid, { + isOnDuty: role.inCurrent === true, + inCurrent: !!role.inCurrent, + inNext: !!role.inNext, + source: role.source, + chainState: chainStateFromRole(role), + // v0.5.228d (audit F11) — flag whether the + // arbiter slate was actually known. When the + // adapter returns empty slates we can't tell + // "no arbiters" from "mainchain not synced + // yet"; this lets the UI render "Detecting…" + // vs "Inactive" honestly. + arbiterSlateKnown: typeof role.arbiterCount === 'number' + && role.arbiterCount > 0, + error: role.error || undefined, + }]; + } catch (err) { + return [cid, { + isOnDuty: false, + inCurrent: false, + inNext: false, + source: 'error', + chainState: 'unknown', + error: (err && err.message) || String(err), + }]; + } + })); + const perChain = Object.fromEntries(perChainEntries); + + // v0.5.229 — include CR Council membership in the top-level + // response so the UI can render Council badges + the per-chain + // Validator-status badges from the same fetch. Same service + // /system/identity uses; cached in-process so adding it here + // is one in-memory hash lookup if recent, otherwise one + // listcurrentcrs RPC (under the 30s TTL). + let crMember = null; + try { + const CrMembershipService = require('../services/CrMembershipService'); + crMember = await CrMembershipService.detectCrMembership(cfg, { + log: extensionHandle.log, + }); + } catch (_) { /* graceful — leave crMember null */ } + + // v0.5.229 — setup-role hint, same logic as /system/identity. + let setupRole = 'unknown'; + if (cfg && cfg.global && cfg.global.council + && cfg.global.council.installed === true) { + setupRole = 'council'; + } + + return res.json(successBody({ + nodePublicKey, + chains: perChain, + crMember, + setupRole, + lastChecked: new Date().toISOString(), + })); + } catch (err) { + extensionHandle.log.error( + `${ENM_LOG_PREFIX} /system/council-status error: ${err.message}`, + ); + return res.status(500).json(errorBody('Failed to read Council status.')); + } + }); + + // ----------------------------------------------------------------- + // v0.5.229 (Phase F) — GET /system/role-debug + // + // Diagnostic endpoint that returns the RAW chain responses ENM uses + // to derive role state, alongside ENM's parsed view of each. The + // goal is to make a class of bug like the v0.5.228d + // `info.currentarbiters` field-name typo *impossible* to recur + // silently — anyone debugging the dashboard can curl this endpoint, + // compare ENM's parse to the raw chain response, and spot a mismatch + // in seconds. + // + // Three sections in the response: + // chain.getarbitersinfo: raw response of the getarbitersinfo RPC + // chain.listcurrentcrs: raw response of the listcurrentcrs RPC + // chain.listproducers: raw response of the listproducers RPC + // (filtered to just the operator's pubkey) + // parsed.fromGetarbiters: ENM's detectProducerRole output + // parsed.fromListCurrent: ENM's CrMembershipService output + // summary.{nodePubkey,setupRole,chainsAlive}: at-a-glance status + // + // Owner-gated. No persistent caching (the operator triggering this + // wants fresh data); CrMembershipService's 30s cache still applies + // under the hood. + // ----------------------------------------------------------------- + router.get('/role-debug', limit('read'), async (req, res) => { + const wallet = readActorWallet(req); + if (!wallet) { + return res.status(401).json(errorBody('Authentication required.')); + } + try { + const cfg = await ConfigStore.load(); + const mainCfg = cfg && cfg.chains && cfg.chains.mainchain; + const nodePubkey = mainCfg && mainCfg.dpos && mainCfg.dpos.nodePublicKey; + const out = { + summary: { + nodePublicKey: nodePubkey || null, + setupRole: (cfg && cfg.global && cfg.global.council + && cfg.global.council.installed === true) ? 'council' : 'unknown', + setupRoleSource: 'cfg.global.council.installed', + lastChecked: new Date().toISOString(), + }, + chain: { + getarbitersinfo: null, + listcurrentcrs: null, + listproducers: null, + }, + parsed: { + fromGetarbiters: null, + fromListCurrent: null, + }, + errors: [], + }; + + // Build an RPC client identical to detectProducerRole / Cr- + // MembershipService so any auth/encoding issue surfaces the + // same way it does in production. + const rpcCfg = mainCfg && mainCfg.rpc; + if (!nodePubkey) { + out.errors.push('cfg.chains.mainchain.dpos.nodePublicKey is empty'); + return res.json(successBody(out)); + } + if (!rpcCfg || !rpcCfg.user) { + out.errors.push('cfg.chains.mainchain.rpc.user is empty'); + return res.json(successBody(out)); + } + + const EnmCrypto = require('../services/EnmCrypto'); + const { EnmRpcClient } = require('../services/EnmRpcClient'); + let password = ''; + if (rpcCfg.passwordEncrypted) { + try { + password = EnmCrypto.decrypt(rpcCfg.passwordEncrypted); + } catch (e) { + out.errors.push('rpc password decrypt failed: ' + (e.message || e)); + return res.json(successBody(out)); + } + } + const client = new EnmRpcClient({ + host: rpcCfg.host || '127.0.0.1', + port: rpcCfg.port || 20336, + user: rpcCfg.user, + password, + timeoutMs: 6000, + }); + + // Raw chain responses, parallel for speed. + const [arbInfo, crInfo, producerInfo] = await Promise.all([ + client.getarbitersinfo().catch((err) => ({ _error: err.message || String(err) })), + client.listcurrentcrs().catch((err) => ({ _error: err.message || String(err) })), + // listproducers can return THOUSANDS of producers; filter + // server-side via getproducerinfo (single producer lookup) + // to avoid sending a 500KB payload through the response. + client.getproducerinfo(nodePubkey).catch(() => null), + ]); + + // For arbiters: capture top-level keys + the operator's + // index in each slate so the operator can immediately see + // "I'm in arbiters[15]" or "I'm not in arbiters". + if (arbInfo && !arbInfo._error) { + const norm = (s) => String(s || '').toLowerCase().replace(/^0x/, ''); + const me = norm(nodePubkey); + const arbiters = Array.isArray(arbInfo.arbiters) ? arbInfo.arbiters : []; + const nextArbiters = Array.isArray(arbInfo.nextarbiters) ? arbInfo.nextarbiters : []; + out.chain.getarbitersinfo = { + topLevelKeys: Object.keys(arbInfo).sort(), + arbitersLength: arbiters.length, + nextArbitersLength: nextArbiters.length, + emptyStringSlots: { + arbiters: arbiters.filter((s) => s === '').length, + nextArbiters: nextArbiters.filter((s) => s === '').length, + }, + ourIndexInArbiters: arbiters.findIndex((k) => norm(k) === me), + ourIndexInNextArbiters: nextArbiters.findIndex((k) => norm(k) === me), + ondutyArbiter: arbInfo.ondutyarbiter || null, + currentTurnStartHeight: arbInfo.currentturnstartheight || null, + nextTurnStartHeight: arbInfo.nextturnstartheight || null, + }; + } else { + out.errors.push('getarbitersinfo: ' + (arbInfo && arbInfo._error)); + } + + if (crInfo && !crInfo._error) { + const members = Array.isArray(crInfo.crmembersinfo) ? crInfo.crmembersinfo : []; + const normLow = (s) => String(s || '').toLowerCase().replace(/^0x/, ''); + const meLow = normLow(nodePubkey); + const matchedIndex = members.findIndex( + (m) => m && normLow(m.dpospublickey) === meLow, + ); + out.chain.listcurrentcrs = { + topLevelKeys: Object.keys(crInfo).sort(), + totalcounts: crInfo.totalcounts || 0, + membersLength: members.length, + ourIndexInMembers: matchedIndex, + // Don't dump every member's PII (nicknames, CIDs). + // Just our own match record + the count of others. + ourRecord: matchedIndex >= 0 ? members[matchedIndex] : null, + }; + } else { + out.errors.push('listcurrentcrs: ' + (crInfo && crInfo._error)); + } + + if (producerInfo) { + out.chain.listproducers = { + nickname: producerInfo.nickname || null, + state: producerInfo.state || null, + votes: producerInfo.votes || null, + dposv2votes: producerInfo.dposv2votes || null, + ownerpublickey: producerInfo.ownerpublickey || null, + inactiveheight: producerInfo.inactiveheight || null, + illegalheight: producerInfo.illegalheight || null, + }; + } else { + out.chain.listproducers = null; // not a registered BPoS producer + } + + // Parsed views — what ENM concluded from these raw responses. + // v0.5.229e (P7 audit fix) — bypass the 30s CrMembershipService + // cache when the operator triggers this debug endpoint. The + // whole point of the role-debug surface is to see CURRENT + // chain truth; serving a 29-second-old cached result hurts + // exactly the diagnostic workflow this endpoint exists for. + try { + const ChainRegistry = require('../services/ChainRegistry'); + const escAdapter = ChainRegistry.getAdapter('esc'); + if (escAdapter && typeof escAdapter.detectProducerRole === 'function') { + out.parsed.fromGetarbiters = await escAdapter.detectProducerRole(cfg); + } + const CrMembershipService = require('../services/CrMembershipService'); + CrMembershipService.clearCache(); // best-effort — also invalidate cached value + out.parsed.fromListCurrent = await CrMembershipService.detectCrMembership(cfg, { + log: extensionHandle.log, + skipCache: true, + }); + } catch (e) { + out.errors.push('parsed view: ' + (e.message || e)); + } + + return res.json(successBody(out)); + } catch (err) { + extensionHandle.log.error( + `${ENM_LOG_PREFIX} /system/role-debug error: ${err.message}`, + ); + return res.status(500).json(errorBody('Failed to read role debug info.')); + } + }); + return router; } diff --git a/enm-server/src/server.js b/enm-server/src/server.js index 4da6bf05af..5946c4e639 100644 --- a/enm-server/src/server.js +++ b/enm-server/src/server.js @@ -244,7 +244,11 @@ async function main() { // v0.5.168 (Phase 2) — SPV Module aggregate + per-sidechain detail. api.use('/spv', spvRouter.build(extensionHandle)); api.use('/logs', logsRouter.build({ extensionHandle })); - api.use('/config', configRouter.build(extensionHandle)); + // v0.5.246 — the monitor status endpoint is built post-boot (it reads the + // overview service); the config route resolves it lazily to reload it after + // an Access save (whitelist/creds/enable change → (un)bind + UFW sync). + let _statusEndpoint = null; + api.use('/config', configRouter.build(extensionHandle, { getStatusEndpoint: () => _statusEndpoint })); api.use('/updates', updatesRouter.build(extensionHandle)); // 0.2.0-beta.3.8 — pull the hub into a local so post-boot wiring // (AuditLog → SSE bridge below) can publish without re-fetching. @@ -348,6 +352,23 @@ async function main() { log('error', `council-overview init failed: ${err.message}`); } + // v0.5.246 — fleet-monitoring status endpoint. Externally-bound, read-only + // whole-node roll-up (every chain + service, version, active/sync) gated by + // the RPC-access policy (IP whitelist + Basic-Auth). Built after the + // overview service (it reads getCachedSnapshot); binds ONLY while RPC + // access is enabled in a Council install (default off ⇒ no open port). + try { + const { EnmStatusEndpoint } = require('./services/EnmStatusEndpoint'); + _statusEndpoint = new EnmStatusEndpoint({ + extensionHandle, + getOverviewService: () => _overviewService, + }); + await _statusEndpoint.start(); + log('info', 'monitor status endpoint wired (binds when RPC access enabled + Council)'); + } catch (err) { + log('error', `status endpoint init failed: ${err.message}`); + } + // beta.3.51 — autoStart loop. Schema's `global.autoStart.onBoot` (default // true) was dead code prior to this release: reattach() only re-bound to // ela processes that were ALREADY running, so any boot where ela had @@ -489,6 +510,13 @@ async function main() { log('error', `audit cleanup init failed: ${err.message}`); } + // v0.5.248 (validator-readiness audit P1) — fire-and-forget systemd + // stop-timeout sanity check. READ-ONLY: it only WARNS if geth (ESC/EID/PG) + // could be SIGKILLed mid-flush on `systemctl stop/restart` because the + // controlling unit's TimeoutStopSec is too short — the corruption path that + // forces a destructive resync (F26). Non-awaited so it never delays listen(). + assertSystemdStopTimeout().catch(() => { /* fail-soft: never block boot */ }); + // --- listen ------------------------------------------------------------ app.listen(PORT, () => { log('info', `enm-server listening on :${PORT}`); @@ -513,6 +541,63 @@ async function main() { global.__enmSignalHandlersInstalled = true; const onShutdown = async (signal) => { log('info', `received ${signal} — pre-marking running chains as manualStop`); + + // v0.5.229c (P8 audit fix) — Fast-exit path for deploy-driven + // shutdowns. When the deploy marker is present, pc2-node OR + // a manual deploy script is restarting ENM for a code update. + // The child chains should KEEP RUNNING (their args are + // unchanged; reattach on the next ENM boot picks them up). + // The pre-229c path always sent SIGINT to children + awaited + // up to 120s for them to drain — for a code-only deploy that + // KILLED THE CHAINS and forced a full chain restart, wasting + // ~5 minutes of sync state per chain + tripping pc2-node's + // 3-strike AppProcessManager quarantine on rapid deploys. + // + // Detection: deploy-enm.sh + manual SCP-deploy + the v228+ + // restart path all `touch /var/lib/pc2/data/.enm-deploy-in- + // progress` before tearing ENM down. EnmAutoStart already + // reads this marker at boot to skip auto-start during the + // deploy window (server.js EnmAutoStart.js:172-196). Here + // we use it for the symmetric "skip the child-chain drain" + // decision. Same 10-min staleness gate as autoStart so an + // abandoned marker eventually expires. + try { + const fs = require('node:fs'); + const path = require('node:path'); + const { pc2DataDir } = require('./services/DataDir'); + const marker = path.join(pc2DataDir(), '.enm-deploy-in-progress'); + const st = fs.statSync(marker); + if (st && (Date.now() - st.mtimeMs) < 600_000) { + log('info', `${signal}: deploy marker present (age ` + + `${Math.round((Date.now() - st.mtimeMs) / 1000)}s) — ` + + 'fast-exit, leaving children running with current spawn args. ' + + 'Reattach picks them up on next ENM boot.'); + // Still mark manualStop on the in-memory ledger so a + // later reattach doesn't misclassify a child whose + // exit happens in our shutdown window as "external" + // → F1 self-heal storm. + try { + const ps = ChainRegistry.getProcessService(); + const marked = ps.markAllManualStop(); + log('info', `${signal}: pre-marked ${marked.length} chain(s) ` + + '(deploy fast-exit; children stay alive)'); + } catch (_) { /* best-effort */ } + // Stop the periodic timers so they don't keep the loop alive. + try { if (storageMaintenanceHandle) { storageMaintenanceHandle.stop(); } } catch (_) {} + try { if (peerCacheHandle) { peerCacheHandle.stop(); } } catch (_) {} + try { if (_statusEndpoint) { _statusEndpoint.stop(); } } catch (_) {} + // v0.5.229c — app.listen() socket + the audit-sweep + // setInterval keep the event loop alive forever. The + // normal-drain path also leaks these but tolerates a + // few seconds of drain time anyway; on the fast-exit + // path we WANT to exit in <1s for a clean deploy + // respawn cycle. Process.exit(0) — clean exit, no + // crash count increment in pc2-node's AppProcessManager. + setTimeout(() => process.exit(0), 200); + return; + } + } catch (_) { /* no marker — fall through to full drain */ } + try { const ps = ChainRegistry.getProcessService(); const marked = ps.markAllManualStop(); @@ -552,6 +637,11 @@ async function main() { } catch (err) { log('warn', `${signal}: peer-cache stop failed (non-fatal): ${err.message}`); } + try { + if (_statusEndpoint) { _statusEndpoint.stop(); } + } catch (err) { + log('warn', `${signal}: status-endpoint stop failed (non-fatal): ${err.message}`); + } // Don't process.exit() — let the natural exit path run so any // pending writes complete. The Node process exits when the // event loop drains; nothing keeps it alive after listen() @@ -583,6 +673,93 @@ function log(level, msg) { fn.call(console, `${ENM_LOG_PREFIX} ${msg}`); } +// v0.5.248 (validator-readiness audit P1) — boot-time systemd stop-timeout +// assertion. ENM runs as a child of pc2-node under systemd. When the operator +// runs `systemctl stop/restart`, systemd SIGTERMs the unit and waits +// TimeoutStopSec before escalating to SIGKILL. That SIGTERM has to propagate +// pc2-node → enm-server → the geth children (ESC/EID/PG) AND leave geth enough +// time to flush in-memory state to chaindata. The systemd default +// (DefaultTimeoutStopSec, usually 90 s) is too short on a busy box: geth gets +// SIGKILLed mid-flush, leaving corrupt/minority-fork chaindata that then forces +// a destructive resync (F26). The deploy script sets TimeoutStopSec=300, but if +// an operator installed before that landed, or hand-edited the unit, this warns +// them. READ-ONLY + fail-soft: ENM can't change another unit's config, so it +// only surfaces the exact remediation; it never blocks boot. +const SAFE_STOP_TIMEOUT_SEC = 300; + +/** + * Parse a systemd time span (the human form `systemctl show` prints for USec + * properties, e.g. "5min", "1min 30s", "90s", "infinity") into seconds. + * Returns Infinity for "infinity", null if nothing parseable. + */ +function parseSystemdTimespan(str) { + if (!str) { return null; } + const t = String(str).trim(); + if (t === 'infinity') { return Infinity; } + const unitToSec = { us: 1e-6, ms: 1e-3, s: 1, min: 60, h: 3600, d: 86400, w: 604800 }; + // Longer tokens (ms, min) precede shorter (s) so they win the alternation. + const re = /(\d+(?:\.\d+)?)\s*(us|ms|min|s|h|d|w)/g; + let m; let total = 0; let matched = false; + while ((m = re.exec(t)) !== null) { + matched = true; + total += parseFloat(m[1]) * (unitToSec[m[2]] || 0); + } + return matched ? total : null; +} + +/** + * Best-effort detection of the systemd unit that owns this process by reading + * /proc/self/cgroup. cgroup v2 is a single line ".../.service"; cgroup v1 + * has several lines but the systemd-managed one ends in ".service". Returns the + * leaf (deepest) ".service" token, or null when not under systemd / not Linux. + */ +function detectSystemdUnit() { + try { + const raw = fs.readFileSync('/proc/self/cgroup', 'utf8'); + const matches = raw.match(/[\w@.\-\\]+\.service/g); + if (matches && matches.length > 0) { + return matches[matches.length - 1]; + } + } catch (_) { /* not Linux / no cgroup → null */ } + return null; +} + +/** + * Warn (never throw, never block) if the controlling systemd unit's + * TimeoutStopSec is below SAFE_STOP_TIMEOUT_SEC. Only runs under systemd + * (INVOCATION_ID present). `systemctl show` is an unprivileged read. + */ +async function assertSystemdStopTimeout() { + if (!process.env.INVOCATION_ID) { return; } // not launched by systemd + const unit = detectSystemdUnit(); + if (!unit) { return; } + const { execFile } = require('node:child_process'); + const out = await new Promise((resolve) => { + try { + execFile( + 'systemctl', ['show', unit, '-p', 'TimeoutStopUSec', '--value'], + { timeout: 3000 }, + (err, stdout) => resolve(err ? null : String(stdout || '').trim()), + ); + } catch (_) { resolve(null); } + }); + if (!out) { return; } + const sec = parseSystemdTimespan(out); + if (sec === null) { + log('debug', `could not parse systemd TimeoutStopSec for ${unit} ("${out}") — skipping stop-timeout check`); + return; + } + if (sec === Infinity || sec >= SAFE_STOP_TIMEOUT_SEC) { + log('info', `systemd ${unit} TimeoutStopSec=${out} (≥${SAFE_STOP_TIMEOUT_SEC}s) — geth shutdown has enough flush time.`); + return; + } + log('warn', + `systemd ${unit} TimeoutStopSec=${out} (~${Math.round(sec)}s) is BELOW the recommended ${SAFE_STOP_TIMEOUT_SEC}s. ` + + 'On `systemctl stop/restart`, geth (ESC/EID/PG) can be SIGKILLed mid-flush before it finishes writing ' + + 'chaindata → minority-fork corruption that forces a destructive resync (F26). ' + + `Recommended: \`systemctl edit ${unit}\` → add a [Service] line \`TimeoutStopSec=300\`, then \`systemctl daemon-reload\`.`); +} + /** * Build a thin proxy that resolves the engine on first call. Same shape the * original extension's routes/index.js uses to handle a slow boot. diff --git a/enm-server/src/services/ChainAdapter.js b/enm-server/src/services/ChainAdapter.js index f35e245d29..6d553d3de8 100644 --- a/enm-server/src/services/ChainAdapter.js +++ b/enm-server/src/services/ChainAdapter.js @@ -99,6 +99,28 @@ class ChainAdapter { return CHAIN_ID_TO_PARENT[chainId] || null; } + /** + * v0.5.228 — reverse of parentOf: for an EVM parent chain (esc / eid / + * pg), which oracle chainId rides alongside it? Returns the oracle's + * chainId or null for chains with no companion oracle (mainchain, + * arbiter, the oracles themselves). + * + * Used by autoStart + the POST /chains/:id/start route to keep an + * EVM chain and its oracle paired across pc2-node restarts, system + * reboots, and explicit operator starts. Operator directive + * 2026-05-27: "they should be started together... on reboots and + * stuff both should run." + * + * @param {string} parentChainId + * @returns {string|null} + */ + static oracleOf(parentChainId) { + for (const [oracleId, parentId] of Object.entries(CHAIN_ID_TO_PARENT)) { + if (parentId === parentChainId) { return oracleId; } + } + return null; + } + /** Override in subclass. */ get chainId() { throw new Error('ChainAdapter: subclass must override chainId'); diff --git a/enm-server/src/services/CouncilOverviewService.js b/enm-server/src/services/CouncilOverviewService.js index 7a67360509..52ede4d5bd 100644 --- a/enm-server/src/services/CouncilOverviewService.js +++ b/enm-server/src/services/CouncilOverviewService.js @@ -75,6 +75,8 @@ const { ENM_LOG_PREFIX } = require('./EnmConstants'); const ConfigStore = require('./ConfigStore'); const ProcessMetrics = require('./ProcessMetrics'); const CoarseStateDerive = require('./CoarseStateDerive'); +// v0.5.244 — per-chain binary update detection (download.elastos.io mirror). +const ChainUpdateScanner = require('./EnmChainUpdateScanner'); // v0.5.208 — tick interval set to 2s. v0.5.203 dropped 5s → 1s per the // "refresh should be immediate" directive, but on a CPU-saturated box @@ -258,6 +260,12 @@ class CouncilOverviewService { cfg = { chains: {} }; } const chainsCfg = (cfg && cfg.chains) || {}; + // v0.5.244 — fire-and-forget kick of the per-chain update scanner. It + // self-throttles to one refresh per 6h and does its HTTP/spawn work + // off-tick; buildChainEntry only reads its synchronous cache, so the + // overview snapshot stays cheap (no new RPC/spawn on the tick path). + try { ChainUpdateScanner.getInstance({ logger: this.log }).ensureFresh(); } + catch (_) { /* update badge is best-effort; never block the overview */ } let proc = null; try { proc = this.registry.getProcessService(); } catch (_) { proc = null; } @@ -545,6 +553,16 @@ function buildChainEntry(args) { peers, lastHeightAdvanceMs, processMetrics, + // v0.5.244 — per-chain "update available" flag for the overview badge + + // Update action button. Synchronous cache read (EnmChainUpdateScanner, + // refreshed off-tick); false when no entry yet, the chain isn't on the + // download mirror (oracles/arbiter), or the binary is current. + updateAvailable: (function () { + try { + const u = ChainUpdateScanner.getInstance().getCached(cId); + return !!(u && u.updateAvailable); + } catch (_) { return false; } + }()), }; } diff --git a/enm-server/src/services/CrMembershipService.js b/enm-server/src/services/CrMembershipService.js new file mode 100644 index 0000000000..ab66539a7d --- /dev/null +++ b/enm-server/src/services/CrMembershipService.js @@ -0,0 +1,267 @@ +/* + * Copyright (C) 2026-present Elacity + * SPDX-License-Identifier: AGPL-3.0 + * + * CrMembershipService — detect whether the operator's node public key is + * bound to a CR Council seat on Elastos mainchain. + * + * v0.5.229 (audit 2026-05-27). Operator directive: ENM treated everyone as + * a BPoS supernode by default, even when they registered through the CR + * Council flow (via Elastos Essentials → CRCouncilMemberClaimNode TX). The + * two roles are on-chain-distinct: + * + * - BPoS producer: registered via producer-register TX. Lives in the + * producer registry. Lookup: listproducers, match nodepublickey. + * - CR Council member: registered via CRInfo TX, then bound a node + * pubkey via CRCouncilMemberClaimNode TX. Lives in + * CRCommittee.Members[]. Lookup: listcurrentcrs, match + * crmembersinfo[].dpospublickey. + * + * This service is the CR-side of the pair (the BPoS-side already exists + * via getproducerinfo in EnmRpcClient). It mirrors node.sh:1117-1129's + * pattern: query listcurrentcrs, find the matching dpospublickey, return + * { state, nickname, did, cid, impeachmentVotes, ... }. + * + * Failure modes (all return { isCrMember: false, source: '' }): + * - no node pubkey in cfg (keystore not unlocked) + * - no mainchain RPC config / undecryptable password + * - RPC call failed (mainchain RPC unreachable / 5xx) + * - Committee not in election period (returns empty crmembersinfo array) + * - operator's pubkey is NOT a member (the actual "they didn't bind" case) + * + * Cached in-process for 30s to bound mainchain RPC hits when multiple + * UI surfaces poll concurrently (settings panel, dashboard cards). Mirrors + * the _producerRoleCache pattern added in v0.5.228d for the BPoS side. + * + * No side effects on cfg, no implicit chain restart, no spawn-time impact. + * This is a pure read used by /system/identity, /system/council-status, + * and (future) /system/role-debug for the UI to label the operator's role + * truthfully. + */ + +'use strict'; + +const ConfigStore = require('./ConfigStore'); +const { ENM_LOG_PREFIX } = require('./EnmConstants'); + +const CACHE_TTL_MS = 30_000; +let _cache = null; // { ts, result } — single-entry cache (one operator, one keystore) + +/** + * Lowercase + 0x-strip a hex string so chain-side casing variations don't + * cause false negatives in pubkey comparisons. Matches the same norm + * helper used by EvmSidechainAdapter.detectProducerRole. + */ +function normHex(s) { + return String(s || '').toLowerCase().replace(/^0x/, ''); +} + +/** + * Query the on-chain CR Committee and determine whether the operator's + * node pubkey is bound to a Council seat. Pure read; cached 30s. + * + * @param {object} cfg ConfigStore.load() result (chains.mainchain.dpos.nodePublicKey + chains.mainchain.rpc required) + * @param {object} [opts] + * @param {boolean} [opts.skipCache=false] force a fresh RPC call + * @param {object} [opts.log] optional logger { info, warn, error } + * @returns {Promise<{ + * isCrMember: boolean, + * inNextCommittee?: boolean, // matched in listnextcrs (waiting for next term) + * state?: string, // 'Elected' | 'Inactive' | 'Impeached' | 'Returned' | 'Terminated' | 'Illegal' + * nickname?: string, + * cid?: string, + * did?: string, + * dpospublickey?: string, + * impeachmentVotes?: string, + * depositAddress?: string, + * depositAmount?: string, + * penalty?: string, + * index?: number, + * currentCommitteeSize?: number, + * source: 'matched' | 'matched-next' | 'not-in-committee' | 'no-active-committee' | + * 'no-node-pubkey' | 'no-mainchain-rpc' | 'rpc-password-undecryptable' | 'error', + * error?: string, + * lastChecked: string // ISO timestamp + * }>} + */ +async function detectCrMembership(cfg, opts) { + const options = opts || {}; + const log = options.log || null; + + if (!options.skipCache && _cache && (Date.now() - _cache.ts) < CACHE_TTL_MS) { + return _cache.result; + } + + const out = { + isCrMember: false, + source: 'error', + lastChecked: new Date().toISOString(), + }; + + try { + const cfgRoot = (cfg && cfg.chains) || cfg || {}; + const mainCfg = cfgRoot.mainchain; + const nodePubkeyRaw = mainCfg && mainCfg.dpos && mainCfg.dpos.nodePublicKey; + if (!nodePubkeyRaw) { + out.source = 'no-node-pubkey'; + _cache = { ts: Date.now(), result: out }; + return out; + } + const mainRpc = mainCfg.rpc; + if (!mainRpc || !mainRpc.user) { + out.source = 'no-mainchain-rpc'; + _cache = { ts: Date.now(), result: out }; + return out; + } + + // Lazy require mirrors EvmSidechainAdapter's pattern. The + // EnmRpcClient destructure is critical — bare-require returns the + // whole module object and `new EnmRpcClient(...)` throws "is not + // a constructor" (the same bug we caught in v228c). + const EnmCrypto = require('./EnmCrypto'); + const { EnmRpcClient } = require('./EnmRpcClient'); + + let password = ''; + if (mainRpc.passwordEncrypted) { + try { + password = EnmCrypto.decrypt(mainRpc.passwordEncrypted); + } catch (e) { + out.source = 'rpc-password-undecryptable'; + out.error = e && e.message ? e.message : String(e); + _cache = { ts: Date.now(), result: out }; + return out; + } + } + const client = new EnmRpcClient({ + host: mainRpc.host || '127.0.0.1', + port: mainRpc.port || 20336, + user: mainRpc.user, + password, + timeoutMs: 5000, + }); + + const me = normHex(nodePubkeyRaw); + + // Pass 1: current Committee. The common case — operator is a + // current member (Elected / Inactive / etc.) or no match. + let currentInfo; + try { + currentInfo = await client.listcurrentcrs(); + } catch (err) { + out.source = 'error'; + out.error = err && err.message ? err.message : String(err); + // Don't cache RPC failures — let the next call retry against + // the live chain. 30s of fake-not-found is worse than a + // momentary RPC blip. + if (log) { + log.warn(`${ENM_LOG_PREFIX} CrMembershipService: listcurrentcrs failed (${out.error})`); + } + return out; + } + + const currentMembers = (currentInfo && Array.isArray(currentInfo.crmembersinfo)) + ? currentInfo.crmembersinfo : []; + out.currentCommitteeSize = currentMembers.length; + + if (currentMembers.length === 0) { + // Committee not in election period. Could still be on the + // ballot in the next term — fall through to Pass 2 below. + out.source = 'no-active-committee'; + } else { + const match = currentMembers.find( + (m) => m && normHex(m.dpospublickey) === me, + ); + if (match) { + out.isCrMember = true; + out.inNextCommittee = false; + out.state = match.state || null; + out.nickname = match.nickname || null; + out.cid = match.cid || null; + out.did = match.did || null; + out.dpospublickey = match.dpospublickey || null; + out.impeachmentVotes = match.impeachmentvotes || null; + out.depositAddress = match.depositaddress || null; + // Note the upstream typo: chain emits "depositamout". + out.depositAmount = match.depositamout || null; + out.penalty = match.penalty || null; + out.index = (typeof match.index === 'number') ? match.index : null; + out.source = 'matched'; + if (log) { + log.info( + `${ENM_LOG_PREFIX} CrMembershipService: matched in current Committee — ` + + `state=${out.state}, nickname=${out.nickname}, ` + + `committee-size=${out.currentCommitteeSize}`, + ); + } + _cache = { ts: Date.now(), result: out }; + return out; + } + } + + // Pass 2: next Committee. Operator may have just been elected + // and is waiting for the term boundary. Best-effort — don't fail + // the whole detect on a listnextcrs error. + try { + const nextInfo = await client.listnextcrs(); + const nextMembers = (nextInfo && Array.isArray(nextInfo.crmembersinfo)) + ? nextInfo.crmembersinfo : []; + const nextMatch = nextMembers.find( + (m) => m && normHex(m.dpospublickey) === me, + ); + if (nextMatch) { + out.isCrMember = true; + out.inNextCommittee = true; + out.state = nextMatch.state || null; + out.nickname = nextMatch.nickname || null; + out.cid = nextMatch.cid || null; + out.did = nextMatch.did || null; + out.dpospublickey = nextMatch.dpospublickey || null; + out.impeachmentVotes = nextMatch.impeachmentvotes || null; + out.depositAddress = nextMatch.depositaddress || null; + out.depositAmount = nextMatch.depositamout || null; + out.penalty = nextMatch.penalty || null; + out.index = (typeof nextMatch.index === 'number') ? nextMatch.index : null; + out.source = 'matched-next'; + if (log) { + log.info( + `${ENM_LOG_PREFIX} CrMembershipService: matched in NEXT Committee ` + + `(waiting for term boundary) — nickname=${out.nickname}`, + ); + } + _cache = { ts: Date.now(), result: out }; + return out; + } + } catch (_) { /* listnextcrs unavailable / chain old — non-fatal */ } + + // Pass 3: definitively not a CR member. + if (out.source !== 'no-active-committee') { + out.source = 'not-in-committee'; + } + _cache = { ts: Date.now(), result: out }; + return out; + } catch (err) { + // Catch-all: any unexpected throw → log + return error sentinel. + out.source = 'error'; + out.error = err && err.message ? err.message : String(err); + if (log) { + log.error( + `${ENM_LOG_PREFIX} CrMembershipService: unexpected error: ${out.error}`, + ); + } + return out; + } +} + +/** + * Drop the cached result (used after a setup-wizard write or a forced + * refresh from the UI's debug panel). + */ +function clearCache() { + _cache = null; +} + +module.exports = { + detectCrMembership, + clearCache, + _internal: { CACHE_TTL_MS, normHex }, +}; diff --git a/enm-server/src/services/ElaMainChainAdapter.js b/enm-server/src/services/ElaMainChainAdapter.js index 8598f93d7e..5f71999f62 100644 --- a/enm-server/src/services/ElaMainChainAdapter.js +++ b/enm-server/src/services/ElaMainChainAdapter.js @@ -79,12 +79,20 @@ class ElaMainChainAdapter extends ChainAdapter { Configuration: { ActiveNet: cfg.activeNet || 'mainnet', NodePort: cfg.ports.nodePort, + // v0.5.248 (validator-readiness audit P1-3) — the Info/REST/WS + // servers are kept OFF, matching node.sh (which omits these + // *Start flags entirely → ela defaults them false). They bind + // 0.0.0.0 with NO auth (the REST server exposes `restart` + + // `sendrawtransaction`), and ENM never calls them — its health + // poll uses only the authed JSON-RPC port. Leaving them on was + // gratuitous attack surface on a firewall-less host. Ports kept + // (inert while *Start=false) so a future opt-in needs only the flag. HttpInfoPort: cfg.ports.httpInfo, - HttpInfoStart: true, + HttpInfoStart: false, HttpRestPort: cfg.ports.httpRest, - HttpRestStart: true, + HttpRestStart: false, HttpWsPort: cfg.ports.httpWs, - HttpWsStart: true, + HttpWsStart: false, HttpJsonPort: cfg.ports.rpc, EnableRPC: true, PrintLevel: this._mapLogLevel(cfg.logLevel), diff --git a/enm-server/src/services/EnmAuditLog.js b/enm-server/src/services/EnmAuditLog.js index 1e9cffbc6c..0c2c23451d 100644 --- a/enm-server/src/services/EnmAuditLog.js +++ b/enm-server/src/services/EnmAuditLog.js @@ -226,8 +226,38 @@ function redactSensitive(obj) { return out; } +/** + * v0.5.236 — swallow-errors convenience wrapper around append(). Four call + * sites (routes/maintenance, routes/identity, EnmAutoStart, + * EnmStageSyncOrchestrator) each hand-rolled the SAME "skip if no db → append → + * log on failure" boilerplate. Centralized here so the null-guard + try/catch + * live once; callers still build their own entry (tier / ruleId / defaults + * differ per caller, so the field-building stays at the call site). NEVER + * throws — a lost audit row must never block the action that authorised it. + * + * @param {object|null} db extension data db (null → no-op, returns false) + * @param {object} log logger with .debug/.warn + * @param {object} entry full AuditEntry (see append()) + * @returns {Promise} true if the row was written, false otherwise + */ +async function safeAppend(db, log, entry) { + if (!db) { return false; } + try { + await append(db, entry); + return true; + } catch (err) { + try { + (log && log.debug ? log.debug : (() => {}))( + `${ENM_LOG_PREFIX} audit safeAppend failed (non-fatal): ${err.message}`, + ); + } catch (_) { /* logger unavailable — swallow */ } + return false; + } +} + module.exports = { append, + safeAppend, query, redactSensitive, // 0.2.0-beta.3.8 — wire the SSE publish hook from server.js boot. diff --git a/enm-server/src/services/EnmAutoStart.js b/enm-server/src/services/EnmAutoStart.js index 478ae2dc78..08702f5a9c 100644 --- a/enm-server/src/services/EnmAutoStart.js +++ b/enm-server/src/services/EnmAutoStart.js @@ -230,6 +230,83 @@ async function runAutoStart(deps) { return { scheduled: false, reason: 'no-enabled-chains' }; } + // v0.5.228 — oracle pairing. An EVM sidechain without its oracle is + // half-broken: the chain produces / follows blocks fine, but cross- + // chain transfers (SPV proofs the oracle relays from mainchain to + // the sidechain) won't process. Operator directive 2026-05-27: "they + // should be started together... on reboots and stuff both should + // run." So whenever an EVM parent is in the enabled list, append + // its oracle to the boot start list too — even if oracle.enabled is + // currently false in cfg.json. Op can still stop an oracle manually + // after boot if they want it off for a specific run. Dedupe in case + // the operator already had the oracle in the enabled list. + // + // pairedOraclesSet is threaded into startAllChains so the "enabled" + // recheck inside the loop has an exemption — without that exemption + // the loop re-filters by cfg.enabled===true and the added oracles + // get dropped right back out. + const ChainAdapter = require('./ChainAdapter'); + const beforePair = enabledChainIds.slice(); + const paired = []; + // Exemption set: any chainId added here bypasses the "skip if cfg.enabled + // !== true" guard inside startAllChains. Holds both oracles (paired to + // their EVM parent) and the arbiter (paired to the full 4-chain set). + const pairedServicesSet = new Set(); + for (const cid of beforePair) { + const oracleId = ChainAdapter.oracleOf(cid); + if (oracleId + && !enabledChainIds.includes(oracleId) + && cfg.chains + && cfg.chains[oracleId]) { // oracle must be registered in cfg + enabledChainIds.push(oracleId); + pairedServicesSet.add(oracleId); + paired.push(`${oracleId} (parent: ${cid})`); + } + } + if (paired.length > 0) { + log.info( + `${ENM_LOG_PREFIX} autoStart: oracle-pairing added ` + + `${paired.length} oracle(s) to the boot list — ${paired.join(', ')}`, + ); + } + + // v0.5.228 — arbiter pairing. The arbiter is the cross-chain bridge: it + // SPV-syncs from mainchain to confirm transfers to/from esc/eid/pg, so + // it functionally depends on ALL four chains being live (the adapter + // declares SIDECHAINS_REQUIRED = ['mainchain','esc','eid','pg']). Same + // operator directive as oracles ("on reboots and stuff both should + // run") — when the full Council quartet is enabled, also boot the + // arbiter even if its own cfg.enabled is false. Skipping is safe when + // the quartet is incomplete: arbiter spawn would fail its pre-flight + // anyway, so paired-start would just produce confusing errors. + const ARBITER_REQUIRED = ['mainchain', 'esc', 'eid', 'pg']; + const quartetEnabled = ARBITER_REQUIRED.every( + (cid) => cfg.chains && cfg.chains[cid] && cfg.chains[cid].enabled === true, + ); + if (quartetEnabled + && cfg.chains + && cfg.chains.arbiter + && !enabledChainIds.includes('arbiter')) { + enabledChainIds.push('arbiter'); + pairedServicesSet.add('arbiter'); + log.info( + `${ENM_LOG_PREFIX} autoStart: arbiter-pairing — mainchain + 3 EVM chains all ` + + 'enabled, adding arbiter to the boot list (cfg.enabled=' + + `${cfg.chains.arbiter.enabled})`, + ); + } else if (!quartetEnabled && cfg.chains && cfg.chains.arbiter + && cfg.chains.arbiter.enabled !== true) { + // Log why we're NOT pairing — helps operators understand why + // arbiter stayed down after a partial-quartet boot. + const missing = ARBITER_REQUIRED.filter( + (cid) => !(cfg.chains[cid] && cfg.chains[cid].enabled === true), + ); + log.info( + `${ENM_LOG_PREFIX} autoStart: arbiter-pairing skipped — ` + + `quartet incomplete (missing: ${missing.join(', ')})`, + ); + } + // beta.3.88 — Wave M1.4 — dependency-DAG ordering. Pre-3.88 we // started chains in arbitrary Object.entries() order. For Council // nodes this races: an oracle starting before its parent EVM chain @@ -246,7 +323,7 @@ async function runAutoStart(deps) { // ChainAdapter.classOf returns null for unknown chainIds — those // sort last (treated as lowest priority). startAllChains is still // SEQUENTIAL within the sorted order to avoid port-bind races. - const ChainAdapter = require('./ChainAdapter'); + // (ChainAdapter already required above for oracle-pairing.) const CLASS_ORDER = { A: 0, B: 1, C: 2, D: 3, E: 4 }; const orderedChainIds = enabledChainIds.slice().sort((a, b) => { const ca = ChainAdapter.classOf(a); @@ -263,16 +340,69 @@ async function runAutoStart(deps) { + `[${orderedChainIds.join(' → ')}] (dependency-DAG order) to start in ${delaySec}s`, ); + // v0.5.236 — staged initial sync for constrained hosts. When + // global.syncStrategy === 'staged', hand the bring-up to + // EnmStageSyncOrchestrator, which runs ≤N heavy chains (mainchain + + // esc/eid/pg) concurrently, waiting for each to reach the network tip + // before starting the next — so a low-end host isn't crushed by 3 + // simultaneous EVM full-syncs. Default ('concurrent') keeps the legacy + // all-at-once startAllChains path. The orchestrator is idempotent + + // resumable (re-derives from live state), so it's safe to invoke on + // every boot; once all chains are synced it just (re)starts them and + // finishes immediately. + const syncStrategy = (cfg.global && cfg.global.syncStrategy) || 'concurrent'; + const stagedConcurrency = (cfg.global && cfg.global.stagedSync + && Number.isInteger(cfg.global.stagedSync.concurrency)) + ? cfg.global.stagedSync.concurrency : 2; + setTimeout(() => { + if (syncStrategy === 'staged') { + log.info( + `${ENM_LOG_PREFIX} autoStart: syncStrategy=staged — handing bring-up to ` + + `stage-sync orchestrator (window=${stagedConcurrency})`, + ); + try { + const Orchestrator = require('./EnmStageSyncOrchestrator'); + Orchestrator.startStaged({ + extensionHandle, + registry, + chainIds: orderedChainIds, + concurrency: stagedConcurrency, + }); + } catch (err) { + // Fail safe: if the orchestrator can't start, fall back to the + // all-at-once path so chains still come up (better an over- + // eager sync than no node at all). + log.error( + `${ENM_LOG_PREFIX} autoStart: stage-sync orchestrator failed to start ` + + `(${err.message}) — falling back to concurrent startAllChains`, + ); + startAllChains({ + extensionHandle, registry, chainIds: orderedChainIds, pairedServices: pairedServicesSet, + }).catch((e) => log.error(`${ENM_LOG_PREFIX} autoStart fallback crashed: ${e.message}`)); + } + return; + } // Re-read config inside the timer so operator changes during the grace // window (e.g. they disabled a chain right after boot) take effect. - startAllChains({ extensionHandle, registry, chainIds: orderedChainIds }) + startAllChains({ + extensionHandle, + registry, + chainIds: orderedChainIds, + // v0.5.228 — paired services (oracles + arbiter) bypass the + // "enabled === true" guard inside the loop; see the pairing + // blocks above for the why. + pairedServices: pairedServicesSet, + }) .catch((err) => { log.error(`${ENM_LOG_PREFIX} autoStart loop crashed: ${err.message}`); }); }, delayMs); - return { scheduled: true, delayMs, chainCount: orderedChainIds.length, order: orderedChainIds }; + return { + scheduled: true, delayMs, chainCount: orderedChainIds.length, + order: orderedChainIds, syncStrategy, + }; } /** @@ -287,8 +417,17 @@ async function runAutoStart(deps) { * @param {string[]} args.chainIds */ async function startAllChains(args) { + // v0.5.228 — pairedServices is the Set of companion services + // (oracles + arbiter) included in chainIds because their parent / + // prerequisite chain(s) are enabled. They get an exemption from the + // "skip if enabled !== true" guard below so the parent enabled-flag + // implies the companion should boot too. Backward-compat accepts the + // legacy `pairedOracles` name in case any external caller (tests) + // still passes it. const { extensionHandle, registry, chainIds } = args; + const pairedSetSource = args.pairedServices || args.pairedOracles; const log = extensionHandle.log || console; + const pairedSet = pairedSetSource instanceof Set ? pairedSetSource : new Set(); let cfg; try { @@ -311,10 +450,27 @@ async function startAllChains(args) { const chainCfg = cfg.chains && cfg.chains[chainId]; // Re-check enabled: operator may have flipped it during the grace window. - if (!chainCfg || chainCfg.enabled !== true) { + // v0.5.228 — paired oracles (added by oracle-pairing because their EVM + // parent is enabled) get an exemption. Their own enabled flag is + // informational only when the parent is up — config.enabled=false on + // an oracle whose parent is enabled means "the operator opted into + // having an EVM chain, the oracle is implied". This matches the + // operator's expectation ("on reboots and stuff both should run"). + if (!chainCfg) { + log.info(`${ENM_LOG_PREFIX} autoStart: ${chainId} no longer in cfg — skipping`); + continue; + } + if (chainCfg.enabled !== true && !pairedSet.has(chainId)) { log.info(`${ENM_LOG_PREFIX} autoStart: ${chainId} no longer enabled — skipping`); continue; } + if (chainCfg.enabled !== true && pairedSet.has(chainId)) { + log.info( + `${ENM_LOG_PREFIX} autoStart: ${chainId} cfg.enabled=false but its ` + + 'parent / prerequisite chains are enabled — starting as a paired ' + + 'service', + ); + } // Skip if already alive — reattach() during boot has already bound us // to the existing ela process; double-starting would race the lock. @@ -361,22 +517,20 @@ async function startAllChains(args) { * crash boot. Skips silently if the db handle was unavailable upstream. */ async function safeAudit(db, log, args) { - if (!db) { return; } - try { - await AuditLog.append(db, { - walletAddress: SYSTEM_WALLET, - chainId: args.chainId, - tier: TIER, - ruleId: RULE_ID, - decision: args.decision, - executor: EXECUTOR, - outcome: args.outcome, - durationMs: args.durationMs, - payload: { action: 'autostart' }, - }); - } catch (err) { - log.debug(`${ENM_LOG_PREFIX} autoStart: audit append failed (non-fatal): ${err.message}`); - } + // v0.5.236 — boilerplate (null-guard + try/catch + debug-log) moved to + // AuditLog.safeAppend; this wrapper keeps the autostart-specific entry + // fields. Behavior unchanged. + await AuditLog.safeAppend(db, log, { + walletAddress: SYSTEM_WALLET, + chainId: args.chainId, + tier: TIER, + ruleId: RULE_ID, + decision: args.decision, + executor: EXECUTOR, + outcome: args.outcome, + durationMs: args.durationMs, + payload: { action: 'autostart' }, + }); } module.exports = { diff --git a/enm-server/src/services/EnmBinaryDownloader.js b/enm-server/src/services/EnmBinaryDownloader.js index eed852a1ed..3cff0797fa 100644 --- a/enm-server/src/services/EnmBinaryDownloader.js +++ b/enm-server/src/services/EnmBinaryDownloader.js @@ -286,7 +286,14 @@ class EnmBinaryDownloader { let m; while ((m = re.exec(html))) found.add(m[1]); } - return Array.from(found); + // v0.5.248 (validator-readiness audit P1-6) — the capture above + // tolerates -rc/-hotfix/commit-hash suffixes so the index parses, but + // the INSTALLER must never SELECT one (it would silently push a + // pre-release/untested build onto a validator). Keep only clean + // dotted-numeric releases (vX.Y[.Z[.W]]), matching EnmChainUpdateScanner. + // If nothing clean matched, caller falls back to the pinned version. + const STRICT_VERSION = /^v[0-9]+(?:\.[0-9]+)+$/; + return Array.from(found).filter((v) => STRICT_VERSION.test(v)); } /** @@ -349,51 +356,80 @@ class EnmBinaryDownloader { this._emit(chainId, PHASES.DOWNLOADING, '', { got, total }); }); - // 3. Extract + // 3. Extract into a STAGING dir, smoke-test there, THEN atomically + // swap into the live bin dir (validator-readiness audit P1-5). + // Pre-v0.5.248 the tar extracted directly over the live binary, so + // a crash / SIGKILL / disk-full mid-extract — or a failed smoke + // test — could leave a half-written binary that won't start. + // node.sh avoids this by staging then `cp`. We stage → smoke → + // rename, and keep the previous binary as .bak for one-step + // rollback. (The update route already requires the chain be stopped, + // so the live binary file is never open during the rename.) s.phase = PHASES.EXTRACTING; this._emit(chainId, PHASES.EXTRACTING, 'Extracting...'); - const targetDir = path.join(enmDataDir(), 'bin', chainId); - await fsp.mkdir(targetDir, { recursive: true }); - await EnmBinaryDownloader._extractTar(tarball, targetDir); - - // The tarball contains a top-level directory like elastos-ela/. - // Find the binary inside, regardless of nesting. - const binaryPath = await EnmBinaryDownloader._locateInTree(targetDir, info.binary); - if (!binaryPath) { - // 0.5.88 — tag with err.code so chains.js + setup.js route - // layers can surface this specific message to operators - // instead of the static 'Try again' fallback. Operator- - // meaningful: tells them the upstream release tarball is - // malformed → file a bug rather than retry. + const binRoot = path.join(enmDataDir(), 'bin'); + const liveDir = path.join(binRoot, chainId); + const stagingDir = path.join(binRoot, `${chainId}.staging`); + const bakDir = path.join(binRoot, `${chainId}.bak`); + await fsp.mkdir(binRoot, { recursive: true }); + await fsp.rm(stagingDir, { recursive: true, force: true }); // clear any prior aborted stage + await fsp.mkdir(stagingDir, { recursive: true }); + await EnmBinaryDownloader._extractTar(tarball, stagingDir); + + // Locate + chmod the binary IN STAGING — the live dir is untouched + // until the swap below, so a malformed tarball can't brick the chain. + const stagedBinary = await EnmBinaryDownloader._locateInTree(stagingDir, info.binary); + if (!stagedBinary) { + await fsp.rm(stagingDir, { recursive: true, force: true }); + // 0.5.88 — err.code lets chains.js/setup.js surface a specific + // "upstream tarball malformed → file a bug" message. const e = new Error(`Binary "${info.binary}" not found inside extracted tarball.`); e.code = 'BINARY_MISSING'; throw e; } - await fsp.chmod(binaryPath, 0o755); - s.binaryPath = binaryPath; - + await fsp.chmod(stagedBinary, 0o755); if (info.cli) { - const cliPath = await EnmBinaryDownloader._locateInTree(targetDir, info.cli); - if (cliPath) { - await fsp.chmod(cliPath, 0o755); - s.cliPath = cliPath; - } + const stagedCli = await EnmBinaryDownloader._locateInTree(stagingDir, info.cli); + if (stagedCli) { await fsp.chmod(stagedCli, 0o755); } } - // 4. Smoke test + // 4. Smoke test the STAGED binary BEFORE swapping the live one. s.phase = PHASES.VERIFYING; this._emit(chainId, PHASES.VERIFYING, 'Verifying binary...'); - const versionOut = await EnmBinaryDownloader._smokeTest(binaryPath); + const versionOut = await EnmBinaryDownloader._smokeTest(stagedBinary); if (!versionOut.ok) { - // 0.5.88 — see BINARY_MISSING above. SMOKE_TEST_FAILED means - // the binary downloaded but won't run on this host (libc - // mismatch / corrupt extraction / wrong OS in tarball). The - // operator needs the underlying error to debug. + await fsp.rm(stagingDir, { recursive: true, force: true }); // live binary never touched const e = new Error(`Binary smoke test failed: ${versionOut.error}`); e.code = 'SMOKE_TEST_FAILED'; throw e; } + // 5. Atomic swap (same-fs renames): live → .bak, staging → live. + // On any failure, restore the previous binary so the chain can run. + await fsp.rm(bakDir, { recursive: true, force: true }); // drop the prior backup + let liveExisted = true; + try { await fsp.access(liveDir); } catch (_) { liveExisted = false; } + try { + if (liveExisted) { await fsp.rename(liveDir, bakDir); } + await fsp.rename(stagingDir, liveDir); + } catch (swapErr) { + let liveOk = true; + try { await fsp.access(liveDir); } catch (_) { liveOk = false; } + if (!liveOk && liveExisted) { + try { await fsp.rename(bakDir, liveDir); } catch (_2) { /* nothing more we can do */ } + } + await fsp.rm(stagingDir, { recursive: true, force: true }); + throw swapErr; + } + + // Re-locate the binary in the now-live dir for the status payload. + const binaryPath = await EnmBinaryDownloader._locateInTree(liveDir, info.binary); + s.binaryPath = binaryPath; + if (info.cli) { + const cliPath = await EnmBinaryDownloader._locateInTree(liveDir, info.cli); + if (cliPath) { s.cliPath = cliPath; } + } + s.phase = PHASES.DONE; s.finishedAt = Date.now(); s.installedAt = s.finishedAt; @@ -402,6 +438,18 @@ class EnmBinaryDownloader { cliPath: s.cliPath, version, }); + + // v0.5.249 — the installed binary just changed. Drop the per-chain + // update scanner's cached result and force its next poll, so the + // overview's "Update available" badge clears immediately instead of + // lingering up to the scanner's 6h TTL (the reported "shows an update + // while already on the latest" right after updating). Best-effort + + // late require so a load cycle or a missing scanner can't fail an + // otherwise-successful install — the badge self-corrects on the next + // 6h refresh regardless. + try { + require('./EnmChainUpdateScanner').getInstance().invalidate(chainId); + } catch (_) { /* non-fatal */ } } _emit(chainId, phase, message, extra) { diff --git a/enm-server/src/services/EnmChainUpdateScanner.js b/enm-server/src/services/EnmChainUpdateScanner.js new file mode 100644 index 0000000000..b4b234d858 --- /dev/null +++ b/enm-server/src/services/EnmChainUpdateScanner.js @@ -0,0 +1,223 @@ +/* + * Copyright (C) 2026-present Elacity + * SPDX-License-Identifier: AGPL-3.0 + * + * EnmChainUpdateScanner — per-chain binary update detection for the + * multi-chain overview's "Update available" badge / Update button. + * + * WHY a second scanner (EnmUpdateScanner already exists): that one is + * mainchain-only and polls GitHub's Elastos.ELA releases — which on a + * locked-down VPS is often unreachable (egress to api.github.com blocked), + * so it silently returns nulls. node.sh — the authoritative installer — + * pulls EVERY chain (ela / esc / eid / pg) from the Elastos download mirror + * at https://download.elastos.io/elastos-/ and finds the newest build + * by listing that directory (get_elastos_ver_latest: curl "…/?F=1" | grep + * [DIR] | strip to the version suffix | sort -Vr | head -1). This scanner + * mirrors that exactly, so it (a) works wherever node.sh's own update works, + * and (b) covers all four chains uniformly. The installed version comes from + * ChainState.snapshotVerified() — the same ` --version` value + * GET /chains/:id reports — and the formats line up directly (mirror dir + * "elastos-esc-v0.2.7.1" vs installed "v0.2.7.1"). + * + * Cheap-snapshot invariant: the overview tick must not spawn or block. So + * this scanner caches per chain and refreshes on its own 6h cadence (kicked + * fire-and-forget by the tick via ensureFresh()); the tick only ever reads + * the cache synchronously via getCached(). Wallet-identity-only invariant: + * outbound HTTP poll + version compare only; nothing is signed. + */ + +'use strict'; + +const https = require('node:https'); +const ConfigStore = require('./ConfigStore'); +const ChainState = require('./ChainState'); + +// chainId → Elastos download-mirror product name. Only chains published on +// the mirror as their own versioned product are scannable here; oracles ship +// inside the EVM bundles and the arbiter isn't independently versioned on the +// mirror, so they're intentionally absent (getCached → null → no badge). +const DOWNLOAD_NAME = { mainchain: 'ela', esc: 'esc', eid: 'eid', pg: 'pg' }; + +const DOWNLOAD_HOST = 'download.elastos.io'; +const TTL_MS = 6 * 60 * 60 * 1000; // 6h between refresh attempts +const REQUEST_TIMEOUT_MS = 8000; +const MAX_BODY_BYTES = 512 * 1024; // Apache listings are tiny; cap defensively + +function _readPackageVersion() { + try { + const pkg = require('../../package.json'); + if (pkg && typeof pkg.version === 'string') { return pkg.version; } + } catch (_) { /* fall through */ } + return '0.0.0'; +} +const USER_AGENT = 'elastos-node-manager/' + _readPackageVersion(); + +/** + * Compare two Elastos version strings (vX.Y.Z, optionally a 4th .W segment — + * e.g. v0.2.7.1). Strips a leading "v", pads missing segments with 0. + * @returns {number} -1 if ab + */ +function compareVersion(a, b) { + const pa = String(a).replace(/^v/i, '').split('.').map((n) => parseInt(n, 10) || 0); + const pb = String(b).replace(/^v/i, '').split('.').map((n) => parseInt(n, 10) || 0); + const len = Math.max(pa.length, pb.length); + for (let i = 0; i < len; i += 1) { + const x = pa[i] || 0; + const y = pb[i] || 0; + if (x !== y) { return x < y ? -1 : 1; } + } + return 0; +} + +/** + * Parse an Apache "?F=1" directory listing for the newest + * elastos--vX.Y.Z entry. Mirrors node.sh get_elastos_ver_latest: + * extract the version suffix off each DIR href, pick the highest. + * @returns {string|null} e.g. 'v0.2.7.1', or null when nothing matched + */ +function parseLatest(html, name) { + const safeName = String(name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const re = new RegExp('href="elastos-' + safeName + '-([^"/]+)/?"', 'gi'); + let m; + let best = null; + while ((m = re.exec(html)) !== null) { + const ver = m[1]; + // Strict: optional "v" then a dotted-numeric version only (X.Y[.Z[.W]]). + // The mirror also carries commit-hash builds (e.g. + // "elastos-ela-9dc17ff") and suffixed tags ("v0.9.8-hotfix"); a loose + // /^v?\d/ accepted "9dc17ff", and parseInt("9dc17ff") === 9 made it + // outrank v0.9.9.5 → a bogus "update available". Require clean dotted + // numerals so only real release dirs are considered. + if (!/^v?\d+(\.\d+)+$/.test(ver)) { continue; } + if (best === null || compareVersion(ver, best) > 0) { best = ver; } + } + return best; +} + +/** @returns {Promise} raw HTML of the mirror directory listing */ +function fetchListing(name) { + return new Promise((resolve, reject) => { + const req = https.get({ + host: DOWNLOAD_HOST, + path: '/elastos-' + name + '/?F=1', + headers: { 'User-Agent': USER_AGENT, Accept: 'text/html' }, + timeout: REQUEST_TIMEOUT_MS, + }, (res) => { + if (res.statusCode !== 200) { + res.resume(); + reject(new Error('HTTP ' + res.statusCode)); + return; + } + let body = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => { + body += chunk; + if (body.length > MAX_BODY_BYTES) { req.destroy(new Error('listing too large')); } + }); + res.on('end', () => resolve(body)); + }); + req.on('timeout', () => { req.destroy(new Error('timeout')); }); + req.on('error', reject); + }); +} + +class EnmChainUpdateScanner { + constructor(opts) { + this.log = (opts && opts.logger) || console; + this._cache = new Map(); // chainId → { installed, latest, updateAvailable, checkedAt } + this._refreshing = false; + this._lastAttemptAt = 0; + } + + /** + * Synchronous, non-blocking cache read for the overview tick. + * @returns {{installed:string, latest:string, updateAvailable:boolean, checkedAt:number}|null} + */ + getCached(chainId) { + return this._cache.get(chainId) || null; + } + + /** + * v0.5.249 — drop a chain's cached result and force the NEXT ensureFresh() + * to re-poll immediately, instead of waiting out the remaining 6h TTL. + * + * Call this the moment a binary install/update changes the installed + * version. Without it, the cache keeps the pre-update entry — whose + * `installed` is the OLD version — so `updateAvailable` stays `true` and + * the overview shows "Update available" even though the operator just + * moved to the latest. (The reported "sometimes shows an update while I'm + * already on the latest": the window between updating and the next 6h + * refresh.) Resetting `_lastAttemptAt` is what actually un-gates the + * re-poll — deleting the entry alone isn't enough when other chains keep + * `_cache.size > 0`. + * + * @param {string} [chainId] — specific chain, or all chains when omitted. + */ + invalidate(chainId) { + if (chainId) { this._cache.delete(chainId); } + else { this._cache.clear(); } + this._lastAttemptAt = 0; + } + + /** + * Fire-and-forget. Kicks a full refresh when the cache is stale and no + * refresh is already in flight; otherwise an instant no-op. Safe to call + * every tick — it self-throttles to one attempt per TTL_MS. + */ + ensureFresh() { + if (this._refreshing) { return; } + if (this._cache.size > 0 && (Date.now() - this._lastAttemptAt) < TTL_MS) { return; } + this._lastAttemptAt = Date.now(); + this._refreshing = true; + Promise.resolve() + .then(() => this.refreshAll()) + .catch((err) => { + if (this.log && typeof this.log.debug === 'function') { + this.log.debug('EnmChainUpdateScanner.refreshAll failed: ' + (err && err.message)); + } + }) + .then(() => { this._refreshing = false; }); + } + + /** Refresh every scannable, enabled chain. Best-effort per chain. */ + async refreshAll() { + let cfg; + try { cfg = await ConfigStore.load(); } catch (_) { return; } + const chains = (cfg && cfg.chains) || {}; + for (const chainId of Object.keys(DOWNLOAD_NAME)) { + const c = chains[chainId]; + if (!c || !c.enabled) { continue; } + let installed = null; + try { + const snap = await ChainState.snapshotVerified(chainId); + installed = snap && snap.binaryVersion ? snap.binaryVersion : null; + } catch (_) { /* leave installed null */ } + if (!installed) { continue; } + let latest = null; + try { + latest = parseLatest(await fetchListing(DOWNLOAD_NAME[chainId]), DOWNLOAD_NAME[chainId]); + } catch (_) { /* leave latest null — keep last good cache entry */ } + if (!latest) { continue; } + this._cache.set(chainId, { + installed, + latest, + updateAvailable: compareVersion(latest, installed) > 0, + checkedAt: Date.now(), + }); + } + } +} + +let _instance = null; +function getInstance(opts) { + if (!_instance) { _instance = new EnmChainUpdateScanner(opts || {}); } + return _instance; +} + +module.exports = { + getInstance, + compareVersion, + parseLatest, + DOWNLOAD_NAME, + EnmChainUpdateScanner, +}; diff --git a/enm-server/src/services/EnmConfigSchema.js b/enm-server/src/services/EnmConfigSchema.js index 5cd1723c17..914f39b553 100644 --- a/enm-server/src/services/EnmConfigSchema.js +++ b/enm-server/src/services/EnmConfigSchema.js @@ -175,6 +175,23 @@ const globalSchema = Joi.object({ onBoot: Joi.boolean().default(true), delaySec: Joi.number().integer().min(0).max(600).default(10), }).default(), + // v0.5.236 — initial-sync strategy for constrained hosts. + // 'concurrent' (default) — start all enabled chains at once (legacy). + // 'staged' — EnmStageSyncOrchestrator brings up the heavy + // chains (mainchain + esc/eid/pg) ≤N at a time, + // waiting for each to reach tip before starting + // the next, so a low-end host isn't crushed by + // simultaneous EVM full-syncs. Oracles pair with + // their parent; arbiter starts last. + // Set from the setup wizard's hardware-tier choice (Card 5). BPoS nodes run + // only the mainchain, so staged is a no-op there (one heavy chain) — the + // field is harmless regardless of role. + syncStrategy: Joi.string().valid('concurrent', 'staged').default('concurrent'), + stagedSync: Joi.object({ + // Heavy-chain window size. 2 = "two chains at once" (operator default + // for lower-end recommended hardware). + concurrency: Joi.number().integer().min(1).max(4).default(2), + }).default(), // Log rotation — gzip *.log older than gzipAfterDays, purge *.gz older // than purgeAfterDays. main.js scheduler runs compactNow every 24h. // beta.3.20 — purgeAfterDays min lowered from 7 → 1 day so the @@ -234,6 +251,16 @@ const globalSchema = Joi.object({ minerAddressStrategy: Joi.string().valid('shared', 'per-chain').optional(), sharedMinerAddress: Joi.string().regex(/^0x[0-9a-fA-F]{40}$/).allow('').default(''), setupCompletedAt: Joi.number().integer().allow(null).default(null), + // v0.5.229 (audit 2026-05-27) — explicit "this is a Council install" + // flag set by /setup/install-council when the orchestrator finishes + // the start-chains step. The dashboard uses this as the early-render + // hint for "show Council UI" before the live listcurrentcrs call + // resolves (mainchain RPC may still be warming up). Pre-229 the + // wizard saved 'council' to localStorage.enm:setup-intent only, + // which the dashboard never read — every Council operator saw the + // BPoS default labelling instead. + installed: Joi.boolean().default(false), + installedAt: Joi.number().integer().allow(null).default(null), }).default(), }); @@ -291,8 +318,11 @@ const setupSchema = Joi.object({ // EnmCrypto.validateEthAddress. // - miner.evmKeystorePasswordEncrypted is the AES-GCM envelope // produced by EnmEncryption (H24 — no plaintext on disk). -// - sync.mode mirrors geth's --syncmode {fast,full,archive}; node.sh -// defaults to 'fast'. +// - sync.mode mirrors geth's --syncmode. v0.5.235: ENM EVM chains +// always run validator-grade FULL sync (default 'full'). 'fast' is +// retained in valid() only so legacy stored configs still load — the +// adapter + routes coerce any 'fast' to 'full' at use. node.sh runs +// producers on full (esc_start:2152, eid_start:4390). const classBPortsSchema = Joi.object({ rpc: PORT_RANGE.required(), p2p: PORT_RANGE.required(), @@ -314,7 +344,11 @@ const classBMinerSchema = Joi.object({ threads: Joi.number().integer().min(1).max(16).default(1), }).default(); const classBSyncSchema = Joi.object({ - mode: Joi.string().valid('fast', 'full', 'archive').default('fast'), + // v0.5.235 — default 'full' (validator-grade). 'fast' stays in valid() + // for load-compat with pre-v0.5.235 stored configs; it's coerced to + // 'full' by EvmSidechainAdapter.start() (which re-persists) and by the + // setup/chains routes. + mode: Joi.string().valid('fast', 'full', 'archive').default('full'), }).default(); const classBSchema = Joi.object({ enabled: Joi.boolean().default(false), @@ -530,6 +564,11 @@ function defaultConfig() { notifications: { criticalRequiresAck: true }, audit: { retentionDays: 365 }, autoStart: { onBoot: true, delaySec: 10 }, + // v0.5.236 — initial-sync strategy (set by the wizard's hardware + // tier choice). 'concurrent' = all-at-once (default); 'staged' = + // bring heavy chains up 2-at-a-time on constrained hosts. + syncStrategy: 'concurrent', + stagedSync: { concurrency: 2 }, logRotation: { enabled: true, gzipAfterDays: 7, purgeAfterDays: 90 }, }, setup: { diff --git a/enm-server/src/services/EnmFirewallManager.js b/enm-server/src/services/EnmFirewallManager.js index 0ecb491b6e..2585a1bb41 100644 --- a/enm-server/src/services/EnmFirewallManager.js +++ b/enm-server/src/services/EnmFirewallManager.js @@ -387,10 +387,93 @@ async function removeRule(port, opts) { }; } +/** + * Reconcile per-SOURCE-IP allow rules for one TCP port to exactly `ipList`. + * Adds `ufw allow from to any port proto tcp` for each desired IP + * not already present, and deletes any per-source rule on that port that's no + * longer desired (so removing an IP from the whitelist closes its hole, and an + * empty list tears the port's per-source rules down entirely). Loopback IPs are + * never firewalled (dropped from the desired set). Only acts when UFW is + * active; no-ops otherwise (the caller's in-process gate is the primary + * control, so this is defense-in-depth). + * + * This is for ENM-DEDICATED ports (e.g. the monitor status port): it deletes + * non-desired per-source rules on the port, which is safe precisely because no + * one hand-builds rules for an ENM-owned port. (Contrast removeRule, which only + * touches the allow-all-source form so it won't clobber operator ACLs.) + * + * @param {number} port + * @param {string[]} ipList desired source IPs/CIDRs (127.0.0.1 ignored) + * @param {object} [opts] { comment, logger } + * @returns {Promise<{tool:'ufw'|null, active:boolean, added:string[], removed:string[], errors:Array, skipped:boolean, reason?:string}>} + */ +async function reconcileSourceRules(port, ipList, opts) { + const logger = (opts && opts.logger) || { info() {}, warn() {}, error() {} }; + const comment = (opts && opts.comment) || 'ENM policy'; + const p = parseInt(port, 10); + if (!Number.isInteger(p) || p <= 0 || p >= 65536) { + return { tool: null, active: false, added: [], removed: [], errors: [], skipped: true, reason: 'invalid port' }; + } + const desired = Array.isArray(ipList) + ? Array.from(new Set(ipList.map((s) => String(s).trim()) + .filter((ip) => ip && ip !== '127.0.0.1' && ip !== '::1'))) + : []; + const state = await detect(); + if (!state.tool) { + return { tool: null, active: false, added: [], removed: [], errors: [], skipped: true, reason: 'ufw not installed / not detectable' }; + } + if (!state.active) { + return { tool: 'ufw', active: false, added: [], removed: [], errors: [], skipped: true, reason: 'ufw installed but inactive' }; + } + // Parse current per-source rules for this port: "

/tcp [(v6)] ALLOW IN " + const probe = await execCapture('ufw', ['status'], DEFAULT_TIMEOUT_MS); + const current = new Set(); + const srcRe = new RegExp('^' + p + '/tcp(?:\\s*\\(v6\\))?\\s+ALLOW IN\\s+(\\S+)', 'i'); + (probe.stdout || '').split(/\r?\n/).forEach((line) => { + const m = srcRe.exec(line.trim()); + if (!m) { return; } + const src = m[1]; + if (src && src.toLowerCase() !== 'anywhere') { current.add(src); } + }); + const added = []; + const removed = []; + const errors = []; + for (const ip of desired) { + if (current.has(ip)) { continue; } + const r = await execCapture('ufw', + ['allow', 'from', ip, 'to', 'any', 'port', String(p), 'proto', 'tcp', 'comment', `${comment} (port ${p})`], + DEFAULT_TIMEOUT_MS); + if (r.code === 0) { + added.push(ip); + logger.info(`${ENM_LOG_PREFIX} ufw allow from ${ip} to any port ${p}/tcp added (${comment})`); + } else { + const msg = (r.stderr || r.stdout || `exit ${r.code}`).trim().split('\n')[0]; + errors.push({ ip, message: msg }); + logger.warn(`${ENM_LOG_PREFIX} ufw allow from ${ip} port ${p}/tcp failed: ${msg}`); + } + } + for (const src of current) { + if (desired.includes(src)) { continue; } + const r = await execCapture('ufw', + ['delete', 'allow', 'from', src, 'to', 'any', 'port', String(p), 'proto', 'tcp'], + DEFAULT_TIMEOUT_MS); + if (r.code === 0) { + removed.push(src); + logger.info(`${ENM_LOG_PREFIX} ufw delete allow from ${src} port ${p}/tcp ok`); + } else { + const msg = (r.stderr || r.stdout || `exit ${r.code}`).trim().split('\n')[0]; + errors.push({ ip: src, message: msg }); + logger.warn(`${ENM_LOG_PREFIX} ufw delete allow from ${src} port ${p}/tcp failed: ${msg}`); + } + } + return { tool: 'ufw', active: true, added, removed, errors, skipped: false }; +} + module.exports = { detect, ensureAllowed, removeRule, + reconcileSourceRules, DEFAULT_TIMEOUT_MS, // exported for tests _execCapture: execCapture, diff --git a/enm-server/src/services/EnmMaintenanceManager.js b/enm-server/src/services/EnmMaintenanceManager.js index 9eb2501209..ca412ac711 100644 --- a/enm-server/src/services/EnmMaintenanceManager.js +++ b/enm-server/src/services/EnmMaintenanceManager.js @@ -335,6 +335,41 @@ async function chainResync(opts) { // top of EnmBootstrapDownloader._run. const cdir = DataDir.chainDir(chainId); const removed = []; + // v0.5.231 — Preserve network identity (nodekey) across the wipe. The + // chain state we're wiping has NO causal relationship with the nodekey: + // nodekey is just the libp2p discovery key that lets peers find us, and + // throwing it away every wipe means every peer in our address book has + // to re-add us by IP — which slows peer reconvergence from seconds to + // ~10 min. The on-chain identity (the mining keystore) is preserved + // separately by _backupKeystoreNow above. We read the nodekey into a + // dotfile OUTSIDE the geth/pgp dir so it survives the rm sweep below, + // then restore it before adapter.start runs. + // (Anchor: the 2026-05-27 EID wipe regenerated nodekey at 17:32:45, + // causing peer churn during the resync; v0.5.231 keeps the same key.) + let nodekeyBackup = null; + if (adapter.chainClass === 'B') { + const gethInstance = (chainId === 'pg') ? 'pgp' : 'geth'; + const srcNodekey = path.join(cdir, 'data', gethInstance, 'nodekey'); + try { + const buf = await fsp.readFile(srcNodekey); + const backupPath = path.join(cdir, 'data', '.nodekey.preserved'); + await fsp.writeFile(backupPath, buf, { mode: 0o600 }); + nodekeyBackup = { instance: gethInstance, restorePath: srcNodekey, backupPath }; + log.info( + `${ENM_LOG_PREFIX} maintenance.chainResync(${chainId}) — nodekey backed up ` + + `(${buf.length} bytes) for restore after wipe`, + ); + } catch (err) { + // ENOENT here just means the chain has never started, or it + // was already wiped — nothing to preserve, not an error. + if (err.code !== 'ENOENT') { + log.warn( + `${ENM_LOG_PREFIX} maintenance.chainResync(${chainId}) — nodekey backup ` + + `failed: ${err.message} — geth will generate a fresh identity post-wipe`, + ); + } + } + } // P1-7 (v0.5.180) — class-aware resync targets. The wipe list used to be // ELA-only (elastos/*), so for EVM sidechains (esc/eid/pg) it silently // NO-OP'd — the UI "Chain Resync" couldn't repair a forked/corrupt EVM @@ -351,20 +386,47 @@ async function chainResync(opts) { // The EVM chaindata dir is named after the geth fork's instance: // esc/eid use "geth", but the PG fork uses "pgp" (verified on disk: // chains/pg/data/pgp/chaindata). Each chain has exactly ONE of these, - // so listing both is safe — the absent one is a no-op rm. The mining - // keystore (data/keystore) and SPV mainchain-watch state (data/header, - // data/store, data/spv_transaction_info.db, data/logs-spv) are NOT - // listed and are preserved. + // so listing both is safe — the absent one is a no-op rm. + // + // v0.5.235 — LOCKSTEP WIPE. The SPV mainchain-watch state + // (data/header, data/store, data/spv_transaction_info.db, + // data/logs-spv) is now wiped ALONGSIDE the geth chaindata. + // Pre-v0.5.235 it was preserved "to save the hours-long SPV + // re-download" — but that was exactly backwards: wiping geth to + // genesis while keeping SPV at the mainchain tip DECOUPLES the + // arbiter context an EVM PBFT chain needs to validate headers, + // so the resync wedges forever (proven on EID 2026-05-27: + // stuck at block 574,384 for 9h with "retrieved hash chain is + // invalid"). node.sh never decouples them — its SPV lives as a + // sibling of geth under /data/ and is only ever rebuilt + // TOGETHER with the chain. Wiping both → geth + SPV re-sync from + // genesis in lockstep, SPV feeding arbiter sets in order → the + // chain validates cleanly (proven: the joint wipe drove EID from + // 574k → 4M+ in ~15 min). The SPV bulk header re-sync is fast + // (404k → 1.75M mainchain blocks in 15 min observed), so the + // "saves hours" rationale was false; preserving it caused a + // PERMANENT stall, which is far worse. + // + // The mining keystore (data/keystore) + network identity + // (data/{geth|pgp}/nodekey, backed up above and restored after) + // are still preserved — those are operator identity, not chain + // state. candidates = [ path.join(dataDir, 'geth'), // esc/eid EVM blockchain DB path.join(dataDir, 'pgp'), // pg EVM blockchain DB path.join(dataDir, 'geth.ipc'), // stale ipc socket (esc/eid) path.join(dataDir, 'pgp.ipc'), // v0.5.185 P2-B — stale ipc socket (pg) - // v0.5.185 P2-A — data/peers.json is NOT wiped: it is the SPV - // mainchain-watch addrmgr peer cache (ELA-SPV), not EVM fork - // state, so wiping it only slows the SPV mainchain re-handshake - // after a resync. The EVM eth-layer peer DB is data// - // nodes, which lives INSIDE geth/pgp above and is wiped with it. + // v0.5.235 — SPV mainchain-watch state, wiped in lockstep + // with geth (see rationale above). + path.join(dataDir, 'header'), + path.join(dataDir, 'store'), + path.join(dataDir, 'spv_transaction_info.db'), + path.join(dataDir, 'logs-spv'), + // peers.json IS now wiped too: it is the SPV addrmgr peer + // cache; on a from-genesis SPV resync a stale cache only + // slows the re-handshake, and keeping it served no purpose + // once SPV itself is wiped. + path.join(dataDir, 'peers.json'), path.join(DataDir.enmDataDir(), '.tmp', 'bootstrap', chainId), ]; } else { @@ -380,23 +442,16 @@ async function chainResync(opts) { path.join(DataDir.enmDataDir(), '.tmp', 'bootstrap', chainId), ]; } - // P1-7 / v0.5.185 P2-C hard safety net — NEVER delete identity or SPV - // state, even if a future edit mistakenly adds them to the candidates. - // The mining keystore (identity) is permanent + unrecoverable. For - // Class B the embedded-SPV store (header/store/spv_transaction_info.db/ - // logs-spv) takes hours to re-download and, if wiped, the EVM chain - // CANNOT validate until SPV re-syncs (the operator's dev: "if you - // removed spv data you must wait until spv sync finished"). Absolute. + // P1-7 hard safety net — NEVER delete the mining keystore (identity), + // which is permanent + unrecoverable. This stays absolute. + // + // v0.5.235 — the SPV state (header/store/spv_transaction_info.db/ + // logs-spv) is DELIBERATELY no longer protected: it must be wiped in + // lockstep with geth (see the candidates comment above). The old + // "preserve SPV" guard caused the arbiter-context decoupling that + // wedged EID. The network identity (data/{geth|pgp}/nodekey) is + // preserved separately by the backup/restore added in v0.5.231. const protectedPaths = [path.join(cdir, 'data', 'keystore')]; - if (adapter.chainClass === 'B') { - const d = path.join(cdir, 'data'); - protectedPaths.push( - path.join(d, 'header'), - path.join(d, 'store'), - path.join(d, 'spv_transaction_info.db'), - path.join(d, 'logs-spv'), - ); - } candidates = candidates.filter((p) => { for (const prot of protectedPaths) { const rel = path.relative(p, prot); @@ -422,6 +477,32 @@ async function chainResync(opts) { } } + // v0.5.231 — Restore the preserved nodekey so geth boots with our + // existing libp2p discovery key instead of generating a new one. We + // unconditionally restore here (not only on autoRestart) because the + // operator may also start the chain manually later — same reasoning + // either way: a fresh peerset reconverges much faster when we keep + // our identity. mkdir the geth/pgp dir if missing (rm above deleted + // it); writeFile with 0o600 mirrors geth's own permissions. + if (nodekeyBackup) { + try { + const dir = path.dirname(nodekeyBackup.restorePath); + await fsp.mkdir(dir, { recursive: true, mode: 0o700 }); + const buf = await fsp.readFile(nodekeyBackup.backupPath); + await fsp.writeFile(nodekeyBackup.restorePath, buf, { mode: 0o600 }); + await fsp.unlink(nodekeyBackup.backupPath).catch(() => {}); + log.info( + `${ENM_LOG_PREFIX} maintenance.chainResync(${chainId}) — nodekey restored to ` + + `${nodekeyBackup.restorePath}; network identity preserved across wipe`, + ); + } catch (err) { + log.warn( + `${ENM_LOG_PREFIX} maintenance.chainResync(${chainId}) — nodekey restore ` + + `failed: ${err.message} — geth will generate a fresh identity`, + ); + } + } + // v0.5.184 — F26 auto-heal path. The operator-driven resync (default) // resets the wizard + leaves the chain DISABLED so the operator walks // Card B2. For an UNATTENDED self-heal that would strand the chain off @@ -545,77 +626,45 @@ async function _resetSetupStateForResync(opts) { } /** - * Uninstall the ENM extension from PC2 but preserve all extension data - * on disk. The script: - * 1. Sleeps 2s so Express flushes the 200 response. - * 2. Deletes the installed_apps sqlite row (pc2-node now considers us - * uninstalled — won't restart us when our PID dies). - * 3. Kills ela children (the user's stake-bound process). - * 4. rm -rf the bundle dir. - * 5. SIGKILL our own PID. + * v0.5.232 — Reset ENM to a fresh-install state, IN PLACE. * - * beta.3.35 — no HTTP call to pc2-node, no owner token. ENM runs as - * root inside pc2-node and has direct read/write on pc2-node.sqlite. - * - * Data dir (chain DB, keystore, audit, backups) at /var/lib/pc2/data/ - * extensions/elastos-node-manager is left intact so a future reinstall - * can recover the operator's BPoS supernode. - * - * @param {{ log?: object }} opts - * @returns {Promise<{ action: 'uninstall', logFile: string }>} - */ -async function uninstall(opts) { - _acquire('uninstall'); - const log = (opts && opts.log) || _noopLog(); - try { - // Write the destructive script's log to /tmp so it survives a - // future nuke that would also wipe the data dir. /tmp is on - // tmpfs on this host — file lives until next reboot, which is - // enough for post-mortem. - const logFile = `/tmp/enm-uninstall-${Date.now()}.log`; - const sh = _buildTeardownScript({ - label: 'uninstall', - logFile, - wipeDataDir: false, - }); - const child = spawn('bash', ['-c', sh], { detached: true, stdio: 'ignore' }); - child.unref(); - log.info(`${ENM_LOG_PREFIX} maintenance.uninstall queued (log → ${logFile})`); - return { action: 'uninstall', logFile }; - } finally { - _release(); - } -} - -/** - * Nuke everything: uninstall the extension AND rm -rf the data dir. - * Operator loses keystore. The frontend gates this with the - * case-sensitive typed confirmation "WIPE EVERYTHING". - * - * Order matters: - * 1. DELETE …?purge=true ← pc2-node SIGKILLs ENM, removes bundle - * 2. wait for process gone - * 3. rm -rf ← while no ENM holds inodes + * Replaces the retired uninstall + nuke + identity/reset paths. The script: + * 1. Sleeps 2s so Express flushes the 200 response. + * 2. Kills ALL chain + oracle children (mainchain/esc/eid/pg/arbiter + + * 3 oracle node scripts). + * 3. rm -rf the extension data dir (chain data, keystore, nodekey, + * enm.db, audit log, healing history) + the backups dir. + * 4. SIGKILL ENM's own PID. + * 5. *** DOES NOT touch the bundle dir or the installed_apps sqlite row *** + * pc2-node's process supervisor respawns ENM with empty data → the + * setup wizard reappears, and the iframe never loses its server + * (which was the root cause of the "another pc2 inside the app" + * symptom: the pre-v0.5.232 nuke deleted the bundle, so pc2-node's + * fallback served the pc2 desktop root into the orphaned ENM iframe). * * @param {{ log?: object }} opts - * @returns {Promise<{ action: 'nuke', logFile: string }>} + * @returns {Promise<{ action: 'reset-everything', logFile: string }>} */ -async function nuke(opts) { - _acquire('nuke'); +async function resetEverything(opts) { + _acquire('reset-everything'); const log = (opts && opts.log) || _noopLog(); try { const dataDir = _dataDirSafe(); - const logFile = `/tmp/enm-nuke-${Date.now()}.log`; + const logFile = `/tmp/enm-reset-${Date.now()}.log`; const sh = _buildTeardownScript({ - label: 'nuke', + label: 'reset-everything', logFile, wipeDataDir: true, + preserveBundle: true, // KEY: keeps bundle + installed_apps row dataDir, }); const child = spawn('bash', ['-c', sh], { detached: true, stdio: 'ignore' }); child.unref(); - log.info(`${ENM_LOG_PREFIX} maintenance.nuke queued (log → ${logFile}, data dir → ${dataDir})`); - return { action: 'nuke', logFile }; + log.info( + `${ENM_LOG_PREFIX} maintenance.resetEverything queued (log → ${logFile}, ` + + `data dir → ${dataDir}, bundle preserved for pc2-node respawn)`, + ); + return { action: 'reset-everything', logFile }; } finally { _release(); } @@ -646,23 +695,38 @@ function _dataDirSafe() { * Compose the detached bash script that ENM hands off to before it * dies. The script always: * 1. Sleeps 2s so the HTTP response flushes. - * 2. Kills any ela child processes ENM was supervising. - * 3. Removes the installed_apps sqlite row so pc2-node forgets us - * (otherwise the boot sweeper's "manual:" cid override leaves - * us in place — see project_session_resume_2026_05_13). - * 4. Removes the bundle install dir. - * 5. (nuke only) rm -rf the extension data dir + the - * backups/elastos-node-manager dir. - * 6. SIGKILL our own PID. With the sqlite row gone, pc2-node won't - * auto-restart us. + * 2. Kills child processes ENM was supervising (scope depends on label). + * 3. (preserveBundle=false only) Removes the installed_apps sqlite row + + * the bundle install dir so pc2-node forgets us. + * 4. (wipeDataDir=true only) rm -rf the extension data dir + backups dir. + * 5. SIGKILL our own PID. + * + * v0.5.232 — added `preserveBundle` mode for the in-app "Reset ENM" flow. + * When true, the bundle + installed_apps row stay in place so pc2-node's + * process supervisor respawns ENM with empty data → the setup wizard + * appears, and the iframe never loses its server (which was the root cause + * of the "another pc2 inside the app" symptom: pre-v0.5.232 nuke deleted + * the bundle, so pc2-node's fallback served the pc2 desktop root into the + * orphaned ENM iframe). + * + * v0.5.232 — `killChainPattern` widened from ".*ela" to also catch eid / + * esc / pg / arbiter / oracle node scripts when label is 'reset-everything'. + * Necessary because the reset must clear ALL chain children, not just ela. * - * @param {{label:'uninstall'|'nuke', logFile:string, wipeDataDir:boolean, dataDir?:string}} opts + * @param {{ + * label:'uninstall'|'nuke'|'reset-everything', + * logFile:string, + * wipeDataDir:boolean, + * preserveBundle?:boolean, + * dataDir?:string + * }} opts * @returns {string} script text */ function _buildTeardownScript(opts) { const label = opts.label; const logFile = opts.logFile; const wipe = !!opts.wipeDataDir; + const preserveBundle = !!opts.preserveBundle; const dataDir = opts.dataDir || SELF_DATA_DIR_DEFAULT; // The pc2-node SQLite row removal. We try the sqlite3 CLI first // (standard on Ubuntu); if it's missing, we fall back to invoking @@ -671,7 +735,10 @@ function _buildTeardownScript(opts) { // ghost app on the dashboard until next pc2-node restart, at which // point the boot sweeper reaps the rowless install. Worst case // is cosmetic, not data-loss. - const sqliteCleanup = + // + // v0.5.232 — preserveBundle=true (reset-everything) keeps this row so + // pc2-node respawns ENM. Skipped entirely in that branch. + const sqliteCleanup = preserveBundle ? '' : ` echo "[${label} $(date -u +%FT%TZ)] removing installed_apps row"\n` + ` if command -v sqlite3 >/dev/null 2>&1; then\n` + ` sqlite3 '${_shellEscape(PC2_SQLITE_PATH)}' \\\n` @@ -681,19 +748,29 @@ function _buildTeardownScript(opts) { + ` else\n` + ` node -e "try { const sq = require('${_shellEscape(INSTALLED_APPS_DIR)}/backend/node_modules/better-sqlite3'); const db = new sq('${_shellEscape(PC2_SQLITE_PATH)}'); db.prepare(\\"DELETE FROM installed_apps WHERE app_name='${_shellEscape(APP_NAME)}'\\").run(); db.close(); console.log(' better-sqlite3: row deleted'); } catch (e) { console.log(' fallback failed:', e.message); }" || echo " no sqlite available; boot sweeper will reap on next pc2-node restart"\n` + ` fi\n`; - const killEla = - ` echo "[${label} $(date -u +%FT%TZ)] killing ela children"\n` - + ` pkill -9 -f '/var/lib/pc2/data/extensions/elastos-node-manager/.*ela' && echo " killed" || echo " no ela process"\n`; - const removeBundle = + // v0.5.232 — kill scope depends on label. reset-everything kills ALL + // chain children + oracle scripts (8 services); uninstall/nuke only + // kill ela (legacy BPoS-era behaviour; the data dir gets rm'd next so + // surviving children would just exit on missing files anyway). Both + // forms tolerate "no process found" — that's the success case after + // a clean stop. + const killChildren = label === 'reset-everything' + ? ` echo "[${label} $(date -u +%FT%TZ)] killing all chain + oracle children"\n` + + ` pkill -9 -f '/var/lib/pc2/data/extensions/elastos-node-manager/bin' || true\n` + + ` pkill -9 -f '/var/lib/pc2/data/extensions/elastos-node-manager/_oracle-scripts' || true\n` + + ` echo " done"\n` + : ` echo "[${label} $(date -u +%FT%TZ)] killing ela children"\n` + + ` pkill -9 -f '/var/lib/pc2/data/extensions/elastos-node-manager/.*ela' && echo " killed" || echo " no ela process"\n`; + const removeBundle = preserveBundle ? '' : ` echo "[${label} $(date -u +%FT%TZ)] removing bundle dir"\n` + ` rm -rf '${_shellEscape(INSTALLED_APPS_DIR)}' || true\n`; const removeData = wipe - ? ` echo "[nuke $(date -u +%FT%TZ)] rm -rf data dir + backups"\n` + ? ` echo "[${label} $(date -u +%FT%TZ)] rm -rf data dir + backups"\n` + ` rm -rf '${_shellEscape(dataDir)}' || true\n` + ` # Backups live one level outside the extension dir per\n` + ` # EnmStorageMaintenance convention.\n` + ` rm -rf '/var/lib/pc2/data/backups/elastos-node-manager' || true\n` - : ` echo "[uninstall $(date -u +%FT%TZ)] preserving data dir at ${_shellEscape(dataDir)}"\n`; + : ` echo "[${label} $(date -u +%FT%TZ)] preserving data dir at ${_shellEscape(dataDir)}"\n`; const killSelf = ` echo "[${label} $(date -u +%FT%TZ)] killing ENM"\n` + ` pkill -9 -f 'elastos-node-manager.*server.js' || true\n` @@ -701,7 +778,7 @@ function _buildTeardownScript(opts) { return ( `(\n` + ` sleep 2\n` - + killEla + + killChildren + sqliteCleanup + removeBundle + removeData @@ -915,8 +992,7 @@ module.exports = { checkLatestVersion, update, chainResync, - uninstall, - nuke, + resetEverything, status, readOwnerToken, // exported for tests diff --git a/enm-server/src/services/EnmRequestSchemas.js b/enm-server/src/services/EnmRequestSchemas.js index 767ddf0a75..b7fe1b48e6 100644 --- a/enm-server/src/services/EnmRequestSchemas.js +++ b/enm-server/src/services/EnmRequestSchemas.js @@ -188,20 +188,33 @@ const maintenanceUpdateBody = Joi.object({ }).unknown(false).label('POST /maintenance/update body'); // beta.3.33 — POST /maintenance/chain-resync body. +// v0.5.232 — accepts either the legacy single-chain shape (chainId:string) OR +// the new multi-chain shape (chainIds:array). Route normalizes both into an +// array internally. Council operators use the array form to pick subsets of +// {mainchain,esc,eid,pg}; BPoS operators always send ['mainchain']. const maintenanceChainResyncBody = Joi.object({ - chainId: Joi.string().pattern(/^[a-z0-9-]+$/).min(1).max(32).required(), + // Legacy: one chain at a time. + chainId: Joi.string().pattern(/^[a-z0-9-]+$/).min(1).max(32).optional(), + // v0.5.232 — many chains in one call. Capped at 8 to match the maximum + // ChainRegistry size; route additionally rejects oracle/arbiter (no + // chaindata to wipe). + chainIds: Joi.array() + .items(Joi.string().pattern(/^[a-z0-9-]+$/).min(1).max(32)) + .min(1).max(8) + .optional(), confirm: Joi.string().required(), // route validates exact match -}).unknown(false).label('POST /maintenance/chain-resync body'); - -// beta.3.33 — POST /maintenance/uninstall body. -const maintenanceUninstallBody = Joi.object({ - confirm: Joi.string().required(), // route validates exact match -}).unknown(false).label('POST /maintenance/uninstall body'); +}) + .or('chainId', 'chainIds') + .unknown(false) + .label('POST /maintenance/chain-resync body'); -// beta.3.33 — POST /maintenance/nuke body. -const maintenanceNukeBody = Joi.object({ +// v0.5.232 — POST /maintenance/reset-everything body. The single in-app +// destructive action that replaces the retired /maintenance/uninstall, +// /maintenance/nuke, and /identity/reset routes. Operator types +// "RESET EVERYTHING" (case-sensitive) to confirm. +const maintenanceResetEverythingBody = Joi.object({ confirm: Joi.string().required(), // route validates exact match -}).unknown(false).label('POST /maintenance/nuke body'); +}).unknown(false).label('POST /maintenance/reset-everything body'); // beta.3.43 — Settings → Identity tab bodies. // @@ -214,18 +227,11 @@ const identityUnlockBody = Joi.object({ .messages({ 'any.required': 'Password is required.' }), }).unknown(false).label('POST /identity/unlock body'); -// POST /identity/reset — typed confirm + optional anti-snipe password. -// Frontend gates "reset keystore" exactly (case-sensitive); we re- -// check server-side as defence in depth. -const identityResetBody = Joi.object({ - confirm: Joi.string().required(), - // Optional — required only if cfg.global.antiSnipePasswordHash is - // set. The route does the conditional check. - antiSnipePassword: Joi.string().min(1).max(256).optional(), - // force=true allows reset even when the producer state is Active — - // operator must opt in explicitly via the slashing-risk modal. - force: Joi.boolean().optional(), -}).unknown(false).label('POST /identity/reset body'); +// v0.5.236 — identityResetBody schema removed (dead code). POST /identity/reset +// was retired to a 410 stub in v0.5.232 (folded into /maintenance/reset-everything); +// this Joi body was no longer exported or referenced anywhere (4-way verified: +// no src refs, no tests, no dynamic access). The shape lives in git history if a +// narrower keystore-rotation path is ever revived. // POST /identity/import — typed confirm + password. The file itself // arrives as raw bytes in the request body (Content-Type: application/ @@ -301,10 +307,10 @@ module.exports = { antiSnipeBody, maintenanceUpdateBody, maintenanceChainResyncBody, - maintenanceUninstallBody, - maintenanceNukeBody, + maintenanceResetEverythingBody, + // v0.5.232 — retired but kept exported in case external callers + // still reference these schemas. Routes return 410 Gone now. identityUnlockBody, - identityResetBody, identityImportHeaders, validateBody, }; diff --git a/enm-server/src/services/EnmRpcClient.js b/enm-server/src/services/EnmRpcClient.js index ee6f14775a..16ad06ac4a 100644 --- a/enm-server/src/services/EnmRpcClient.js +++ b/enm-server/src/services/EnmRpcClient.js @@ -209,16 +209,95 @@ class EnmRpcClient { /** * 0.2.0-alpha.7 — current DPoS rotation snapshot. + * + * v0.5.229 (audit 2026-05-27) — field names corrected by verifying + * against the real ELA struct definition at + * Elastos.ELA/servers/interfaces.go:884-892 (type arbitersInfo). + * Pre-229 this JSDoc said `currentarbiters` and `currentcandidates`; + * those fields DO NOT EXIST in the chain's response. The actual JSON + * struct tags are `arbiters` and `candidates` (no "current" prefix). + * The pre-229 typo propagated into EvmSidechainAdapter.detectProducerRole + * and routes/chains.js's /chains/:id/rotation endpoint — both read + * `info.currentarbiters` and got `undefined` → empty array → every + * Council operator was incorrectly reported as Inactive on the current + * slate. Smoking gun verified by live curl 2026-05-27: + * getarbitersinfo response top-level keys = + * [arbiters, candidates, nextarbiters, nextcandidates, + * ondutyarbiter, currentturnstartheight, nextturnstartheight] + * * ondutyarbiter: hex of the producer signing the current round * currentturnstartheight: first height of the current rotation turn * nextturnstartheight: first height of the next rotation turn - * currentarbiters: hex[] of producers in the active slate + * arbiters: hex[] of producers in the active slate * nextarbiters: hex[] of producers queued for the next slate - * currentcandidates / nextcandidates: backup pool + * candidates / nextcandidates: backup pool (likewise NOT prefixed) + * + * Per-entry caveat (ELA chain-side bug — handle defensively in callers): + * Elastos.ELA/servers/interfaces.go:906-912 returns an empty string + * '' in the slot of any CRC arbiter whose IsNormal=false (i.e. + * MemberState != MemberElected). Callers MUST filter empty entries + * before .includes(me) lookups, otherwise a Council member in + * MemberInactive state appears absent from the slate. + * * No auth gate; same rate-limit bucket as getproducerinfo. */ getarbitersinfo() { return this.call('getarbitersinfo', {}); } + /** + * v0.5.229 — list the CURRENT CR Council members (the ones who won the + * most recent CR election; lives in CRCommittee.GetCurrentMembers()). + * Used by ENM's CrMembershipService to detect whether the operator's + * node pubkey is bound to a Council seat (via CRCouncilMemberClaimNode). + * + * Verified against Elastos.ELA struct definitions at + * servers/interfaces.go:2159-2179 (RPCCRMemberInfo + RPCCRMembersInfo) + * servers/interfaces.go:2604-2649 (ListCurrentCRs handler) + * + * Response shape: + * result.crmembersinfo: array of member objects (one per current CR member) + * result.totalcounts: number of members + * + * Each member object has these fields (note "depositamout" typo is + * upstream — Elastos.ELA spells it without the second N): + * code hex of the member's program code + * cid Citizen ID (base58 address derived from CR pubkey) + * did Decentralized Identifier (base58) + * dpospublickey hex of the operator's NODE pubkey bound via + * CRCouncilMemberClaimNode. THIS is what ENM + * matches against the local keystore pubkey. + * nickname operator-chosen display name + * url optional URL + * location uint location code + * impeachmentvotes string number of impeachment votes + * depositamout string ELA amount (sic — upstream typo) + * depositaddress base58 deposit address + * penalty string penalty amount + * state MemberState as string: 'Elected', 'Inactive', + * 'Impeached', 'Returned', 'Terminated', or + * 'Illegal' + * index ordering index in the Committee + * + * Caveat: when the CR Committee is NOT in election period (between + * Council terms), the handler returns an EMPTY crmembersinfo array + * even if previous members exist. Callers must treat empty as + * "no current Council" rather than "operator not a member". + * + * No auth gate; same rate-limit bucket as getproducerinfo. node.sh + * matches this with `ela_jsonrpc listcurrentcrs state all` + * (node.sh:1117) — the `state` param is documented but not actually + * read by the handler (servers/interfaces.go:2604). + */ + listcurrentcrs() { return this.call('listcurrentcrs', { state: 'all' }); } + + /** + * v0.5.229 — list the NEXT CR Council members (the ones who will take + * over at the next Committee transition). Same response shape as + * listcurrentcrs. Useful to detect "Council member elected but the + * current term hasn't started yet". Handler at + * Elastos.ELA/servers/interfaces.go:2651 ListNextCRs. + */ + listnextcrs() { return this.call('listnextcrs', { state: 'all' }); } + /** * beta.3.13 — producer's locked deposit balance. Verified registered * on JSON-RPC at servers/httpjsonrpc/server.go:117 as diff --git a/enm-server/src/services/EnmStageSyncOrchestrator.js b/enm-server/src/services/EnmStageSyncOrchestrator.js new file mode 100644 index 0000000000..ff0685db59 --- /dev/null +++ b/enm-server/src/services/EnmStageSyncOrchestrator.js @@ -0,0 +1,308 @@ +/* + * Copyright (C) 2026-present Elacity + * SPDX-License-Identifier: AGPL-3.0 + * + * EnmStageSyncOrchestrator — backend staged initial-sync for constrained hosts. + * + * v0.5.236 (operator directive 2026-05-28: "lower-end recommended hardware + * should have an option to run 2 chains at once and sync the rest when the + * first two are fully synced — initial sync takes the most resources"). + * + * WHY BACKEND (not the frontend EnmStageSync): + * The Council install brings up 8 services; on a constrained host the + * simultaneous EVM *full*-sync of esc + eid + pg (v0.5.235 made them full, + * heavier than the old fast-sync) saturates CPU and the provider pauses the + * VPS. A from-genesis full-sync now takes hours-to-days, so the operator WILL + * close the wizard tab — the frontend-only EnmStageSync (utils-stage-sync.js) + * dies with the tab. This is the backend port that utils-stage-sync.js's own + * header called "a future Phase 22.1 ... survives tab close, single source of + * truth." + * + * MODEL — sliding window of N (default 2) over the HEAVY chains: + * Heavy chains = class A (mainchain) + class B (esc/eid/pg) — the ones with a + * real height to sync. Start at most N concurrently; when one reaches the + * network tip, free its slot and start the next pending heavy chain. Light + * services don't count against the window: + * - An EVM chain's oracle (class C) is started alongside its parent ("they + * should run together" — operator 2026-05-27); it's a light node script. + * - The arbiter (class D) starts after all heavy chains are up (it SPV-syncs + * independently and needs all four chains alive). + * + * IDEMPOTENT / RESUMABLE: + * No persisted progress file — the LIVE chain states ARE the progress. On each + * (re)start the orchestrator re-derives, per heavy chain: synced→done, + * alive-but-behind→inflight (counts against the window), stopped→pending. So a + * host reboot mid-stage resumes cleanly, and once everything is synced a normal + * boot just starts everything (all already at tip → light). + * + * STALL SAFETY: + * A genuinely-stuck heavy chain must not block the window forever. We track + * blocksBehind per inflight chain; if it fails to DECREASE for + * STALL_GRACE_TICKS consecutive polls (~20 min) while still behind, we free its + * slot (start the next pending chain) and log a warning — the stuck chain stays + * running and the F-rule self-heal engine surfaces it to the operator. This is + * progress-based, NOT wall-clock, because a legitimate full-sync is slow but + * progressing and must keep its slot. + */ + +'use strict'; + +const { ENM_LOG_PREFIX } = require('./EnmConstants'); +const ConfigStore = require('./ConfigStore'); +const ChainAdapter = require('./ChainAdapter'); +const AuditLog = require('./EnmAuditLog'); + +const SYSTEM_WALLET = 'system'; + +// Poll cadence — sync state moves slowly (full-sync executes blocks), so a +// 15s tick is plenty and keeps RPC pressure negligible. +const POLL_MS = 15000; +// "Caught up" threshold: blocksBehind at or under this frees the window slot. +// A few blocks of lag is normal (the network keeps producing); we don't wait +// for an exact 0 that a live chain never durably hits. +const SYNCED_BLOCKS_THRESHOLD = 8; +// Stall detection: if blocksBehind doesn't decrease across this many +// consecutive polls (~20 min at 15s) while still behind, treat the slot as +// freeable so the remaining chains aren't blocked by one stuck chain. +const STALL_GRACE_TICKS = 80; + +let _running = false; // module-level guard — one orchestration at a time +let _cancelled = false; + +/** + * Is this chain "done" for window purposes? + * - class C/D services: alive === done (no height to sync). + * - class A/B heavy chains: alive AND blocksBehind <= threshold. + * Returns { done, alive, blocksBehind }. + */ +function inspectChain(chainId, proc, registry) { + const cls = ChainAdapter.classOf(chainId); + let alive = false; + try { + const st = proc.statusSync(chainId); + alive = !!(st && st.alive); + } catch (_) { alive = false; } + if (!alive) { return { done: false, alive: false, blocksBehind: null }; } + if (cls === 'C' || cls === 'D') { return { done: true, alive: true, blocksBehind: null }; } + // Heavy chain — consult SyncTracker for blocksBehind. + let blocksBehind = null; + try { + const snap = registry.getSyncTracker().syncSnapshot(chainId); + if (snap && typeof snap.blocksBehind === 'number') { blocksBehind = snap.blocksBehind; } + } catch (_) { /* tracker not ready — treat as unknown */ } + const done = (typeof blocksBehind === 'number') && (blocksBehind <= SYNCED_BLOCKS_THRESHOLD); + return { done, alive: true, blocksBehind }; +} + +/** Start one chain via its adapter; audit the outcome. Never throws. */ +async function startChain(chainId, registry, db, log) { + const startedAtMs = Date.now(); + try { + const cfg = await ConfigStore.load(); + const chainCfg = cfg.chains && cfg.chains[chainId]; + if (!chainCfg) { log.warn(`${ENM_LOG_PREFIX} stage-sync: ${chainId} not in cfg — skip`); return false; } + const adapter = registry.getAdapter(chainId); + await adapter.start(chainCfg); + const durationMs = Date.now() - startedAtMs; + log.info(`${ENM_LOG_PREFIX} stage-sync: started ${chainId} in ${durationMs}ms`); + await safeAudit(db, log, { + chainId, decision: 'executed', durationMs, + outcome: `Staged-start ${chainId} on ENM boot`, + }); + return true; + } catch (err) { + const durationMs = Date.now() - startedAtMs; + log.warn(`${ENM_LOG_PREFIX} stage-sync: ${chainId} start failed (${err.message}) — F1 will retry`); + await safeAudit(db, log, { + chainId, decision: 'failed', durationMs, + outcome: `Staged-start failed: ${err.message}`, + }); + return false; + } +} + +/** + * Run the staged bring-up. Returns immediately after seeding; the window + * advances on a setTimeout poll loop. + * + * @param {object} args + * @param {object} args.extensionHandle + * @param {object} args.registry + * @param {string[]} args.chainIds full ordered list (class A→D) from autoStart + * @param {number} [args.concurrency] heavy-chain window size (default 2) + */ +function startStaged(args) { + const { extensionHandle, registry, chainIds } = args; + const log = (extensionHandle && extensionHandle.log) || console; + const N = (Number.isInteger(args.concurrency) && args.concurrency >= 1) ? args.concurrency : 2; + if (_running) { + log.info(`${ENM_LOG_PREFIX} stage-sync: already running — ignoring duplicate start`); + return { started: false, reason: 'already-running' }; + } + _running = true; + _cancelled = false; + + const proc = registry.getProcessService(); + let db = null; + try { db = extensionHandle.import('data').db; } catch (_) { db = null; } + let sseHub = null; + try { sseHub = registry.getSseHub(); } catch (_) { sseHub = null; } + + // Partition the autoStart-ordered list into heavy (A/B, windowed) + the + // light services (C oracles / D arbiter) that ride alongside / trail. + const heavyAll = chainIds.filter((c) => { + const k = ChainAdapter.classOf(c); + return k === 'A' || k === 'B'; + }); + const arbiterId = chainIds.find((c) => ChainAdapter.classOf(c) === 'D') || null; + + // Window state. + const pending = heavyAll.slice(); // heavy chains not yet started this run + const inflight = new Map(); // chainId → { lastBehind, stallTicks } + const done = new Set(); // heavy chains at tip + const startedOracles = new Set(); // oracles we've already paired-started + let arbiterStarted = false; + + // Seed from LIVE state so a restart resumes mid-stage. + for (let i = pending.length - 1; i >= 0; i -= 1) { + const cid = pending[i]; + const s = inspectChain(cid, proc, registry); + if (s.done) { + done.add(cid); + pending.splice(i, 1); + } else if (s.alive) { + inflight.set(cid, { lastBehind: s.blocksBehind, stallTicks: 0 }); + pending.splice(i, 1); + } + } + + log.info( + `${ENM_LOG_PREFIX} stage-sync: starting (window=${N}) — ` + + `heavy=[${heavyAll.join(', ')}] seeded done=[${[...done].join(', ')}] ` + + `inflight=[${[...inflight.keys()].join(', ')}] pending=[${pending.join(', ')}]`, + ); + + // Pair an EVM chain's oracle (start it alongside the parent — light). + async function pairOracle(parentId) { + const oracleId = ChainAdapter.oracleOf(parentId); + if (!oracleId || startedOracles.has(oracleId)) { return; } + const cfg = await ConfigStore.load(); + if (!cfg.chains || !cfg.chains[oracleId]) { return; } + startedOracles.add(oracleId); + await startChain(oracleId, registry, db, log); + } + + function emit(phase) { + if (!sseHub) { return; } + try { + sseHub.publish('stage-sync:status', { + phase, + window: N, + done: [...done], + inflight: [...inflight.keys()], + pending: pending.slice(), + arbiterStarted, + }); + } catch (_) { /* SSE best-effort */ } + } + + // Fill free window slots from pending; pair each started chain's oracle. + async function fillSlots() { + while (inflight.size < N && pending.length > 0) { + const cid = pending.shift(); + inflight.set(cid, { lastBehind: null, stallTicks: 0 }); + emit('starting'); + const ok = await startChain(cid, registry, db, log); + if (!ok) { + // start failed — drop from inflight so F1 handles it and the + // window isn't permanently consumed by a chain that won't boot. + inflight.delete(cid); + } + await pairOracle(cid); + } + } + + async function tick() { + if (_cancelled) { _running = false; return; } + // Advance inflight → done; detect stalls. + for (const [cid, meta] of [...inflight.entries()]) { + const s = inspectChain(cid, proc, registry); + if (s.done) { + inflight.delete(cid); + done.add(cid); + await pairOracle(cid); // ensure oracle up even if pair on start was skipped + emit('synced'); + log.info(`${ENM_LOG_PREFIX} stage-sync: ${cid} reached tip — freeing slot`); + continue; + } + // Stall detection — blocksBehind must keep decreasing. + if (typeof s.blocksBehind === 'number') { + if (meta.lastBehind != null && s.blocksBehind >= meta.lastBehind) { + meta.stallTicks += 1; + } else { + meta.stallTicks = 0; + } + meta.lastBehind = s.blocksBehind; + if (meta.stallTicks >= STALL_GRACE_TICKS) { + log.warn( + `${ENM_LOG_PREFIX} stage-sync: ${cid} stalled (blocksBehind=${s.blocksBehind} ` + + `not decreasing for ${STALL_GRACE_TICKS} polls) — freeing slot so the rest ` + + 'can proceed; the chain keeps running and F-rule self-heal will surface it.', + ); + inflight.delete(cid); + emit('stalled'); + } + } + } + + await fillSlots(); + + // All heavy chains done? Start the arbiter (last) and finish. + if (pending.length === 0 && inflight.size === 0) { + if (arbiterId && !arbiterStarted) { + arbiterStarted = true; + emit('arbiter-starting'); + await startChain(arbiterId, registry, db, log); + } + emit('complete'); + log.info(`${ENM_LOG_PREFIX} stage-sync: complete — all heavy chains synced; services up`); + _running = false; + return; + } + setTimeout(() => { tick().catch((e) => { + log.error(`${ENM_LOG_PREFIX} stage-sync tick crashed: ${e.message}`); + _running = false; + }); }, POLL_MS); + } + + // Kick the first fill + loop. + fillSlots() + .then(() => { emit('waiting'); return tick(); }) + .catch((e) => { + log.error(`${ENM_LOG_PREFIX} stage-sync initial fill crashed: ${e.message}`); + _running = false; + }); + + return { started: true, window: N, heavy: heavyAll, pending: pending.slice() }; +} + +function cancel() { _cancelled = true; } +function isRunning() { return _running; } + +async function safeAudit(db, log, args) { + // v0.5.236 — shared null-guard + try/catch via AuditLog.safeAppend; this + // wrapper keeps the stage-sync-specific entry fields. Behavior unchanged. + await AuditLog.safeAppend(db, log, { + walletAddress: SYSTEM_WALLET, + chainId: args.chainId, + ruleId: null, + tier: 'AUTOMATED-SAFE', + decision: args.decision, + executor: 'system', + outcome: args.outcome, + durationMs: args.durationMs, + payload: { action: 'stage-sync' }, + }); +} + +module.exports = { startStaged, cancel, isRunning }; diff --git a/enm-server/src/services/EnmStatusEndpoint.js b/enm-server/src/services/EnmStatusEndpoint.js new file mode 100644 index 0000000000..6c5671414f --- /dev/null +++ b/enm-server/src/services/EnmStatusEndpoint.js @@ -0,0 +1,312 @@ +/* + * Copyright (C) 2026-present Elacity + * SPDX-License-Identifier: AGPL-3.0 + * + * EnmStatusEndpoint — a read-only, externally-reachable, authenticated + * whole-node status endpoint for FLEET MONITORING. + * + * Background: operators monitor a fleet of validators centrally. node.sh's + * `all_status` (and per-chain `*_status`) gave the whole-node roll-up — every + * chain + service (mainchain, esc/eid/pg, their oracles, arbiter), each one's + * version and active/inactive (+ height/peers) — and the monitor reached it by + * being IP-whitelisted AND holding the RPC user/password (ela's + * RpcConfiguration {User, Pass, WhiteIPList}). ENM already computes that whole- + * node picture (CouncilOverviewService) but only behind the owner token on + * loopback, so an external monitor can't read it. + * + * This service exposes that aggregate as ONE endpoint per node: + * + * GET /status → JSON: { ts, node:{mode}, components:[ {id,name,class, + * version,active,state,height,peers}, ... ] } + * + * gated by the SAME policy the operator already configures for RPC access: + * 1. IP allow-list = cfg.chains.mainchain.rpc.whiteIPList (real socket peer; + * X-Forwarded-For ignored; 127.0.0.1 always allowed). + * 2. HTTP Basic-Auth = cfg.chains.mainchain.rpc.user + decrypted password. + * 3. Active only when cfg.chains.mainchain.rpc.enabled AND Council mode. + * + * It is READ-ONLY (no control, no secrets in the body, no chain RPC proxied — + * geth/ela/arbiter listeners are untouched). It is the only externally-bound + * ENM socket, so its surface is exactly GET /status (404 for everything else), + * and it binds only while the policy is enabled (default off ⇒ no open port). + */ + +'use strict'; + +const http = require('node:http'); +const crypto = require('node:crypto'); + +const ConfigStore = require('./ConfigStore'); +const ChainState = require('./ChainState'); +let EnmFirewallManager = null; +try { EnmFirewallManager = require('./EnmFirewallManager'); } catch (_) { /* optional */ } + +const DEFAULT_STATUS_PORT = 20920; // clear of the chain port map (20336/20536/2063x/2064x/2067x + p2p/dpos) + +// Friendly display names keyed by chainId (oracles/arbiter included). Falls +// back to the snapshot's displayName, then the raw id. +const DISPLAY_NAME = { + mainchain: 'ELA Mainchain', + esc: 'Elastos Smart Chain', + eid: 'Identity Chain', + pg: 'Elastos DID 2.0 (PG)', + 'esc-oracle': 'ESC Oracle', + 'eid-oracle': 'EID Oracle', + 'pg-oracle': 'PG Oracle', + arbiter: 'Arbiter', +}; + +/** Normalize an IPv4-mapped IPv6 peer (::ffff:1.2.3.4 → 1.2.3.4). */ +function normalizeIp(ip) { + if (typeof ip !== 'string') { return ''; } + return ip.replace(/^::ffff:/i, ''); +} + +function ipv4ToInt(ip) { + const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip); + if (!m) { return null; } + let n = 0; + for (let i = 1; i <= 4; i += 1) { + const o = Number(m[i]); + if (o > 255) { return null; } + n = (n * 256) + o; + } + return n >>> 0; +} + +/** Match a peer IP against one whitelist entry (exact, or IPv4 CIDR). */ +function entryMatches(peer, entry) { + if (entry === peer) { return true; } + if (entry.indexOf('/') === -1) { return false; } + const [net, bitsStr] = entry.split('/'); + const bits = Number(bitsStr); + if (!Number.isInteger(bits) || bits < 0 || bits > 32) { return false; } + const pi = ipv4ToInt(peer); + const ni = ipv4ToInt(net); + if (pi === null || ni === null) { return false; } // IPv6 CIDR → exact-only + const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0; + return (pi & mask) === (ni & mask); +} + +function ipAllowed(peer, whiteIPList) { + const ip = normalizeIp(peer); + if (ip === '127.0.0.1' || ip === '::1') { return true; } // loopback always + if (!Array.isArray(whiteIPList)) { return false; } + return whiteIPList.some((e) => entryMatches(ip, String(e).trim())); +} + +/** Constant-time-ish string compare (length mismatch ⇒ false). */ +function safeEqual(a, b) { + const ba = Buffer.from(String(a || '')); + const bb = Buffer.from(String(b || '')); + if (ba.length !== bb.length) { return false; } + try { return crypto.timingSafeEqual(ba, bb); } catch (_) { return false; } +} + +class EnmStatusEndpoint { + /** + * @param {object} deps + * @param {object} deps.extensionHandle for .log + * @param {() => object|null} deps.getOverviewService resolver → CouncilOverviewService + * @param {number} [deps.port] + */ + constructor(deps) { + this.log = (deps && deps.extensionHandle && deps.extensionHandle.log) || console; + this._getOverview = (deps && deps.getOverviewService) || (() => null); + this.port = (deps && deps.port) || DEFAULT_STATUS_PORT; + this._server = null; + // Policy snapshot, refreshed by reload(): never read config per-request. + this._policy = { enabled: false, council: false, whiteIPList: ['127.0.0.1'], user: null, password: null }; + // Components we've kicked a one-shot version smoke-test for, so a cold + // version cache (e.g. right after a server restart) fills in within a + // poll or two without re-spawning `--version` on every request. + this._warmed = new Set(); + } + + /** + * Re-read the RPC-access policy from config and (un)bind the listener + + * reconcile the firewall accordingly. Called on boot and after every + * Access save. Safe to call repeatedly. + */ + async reload() { + let cfg; + try { cfg = await ConfigStore.load(); } catch (_) { cfg = null; } + const chains = (cfg && cfg.chains) || {}; + const mainCfg = chains.mainchain || null; + const rpc = (mainCfg && mainCfg.rpc) || {}; + const council = Object.keys(chains).length >= 2; // ≥2 components ⇒ Council install + let password = null; + try { + if (mainCfg && rpc.passwordEncrypted) { password = ConfigStore.getRpcPassword(mainCfg); } + } catch (_) { password = null; } + this._policy = { + enabled: rpc.enabled === true, + council, + whiteIPList: Array.isArray(rpc.whiteIPList) ? rpc.whiteIPList.slice() : ['127.0.0.1'], + user: rpc.user || null, + password, + }; + + const shouldRun = this._policy.enabled && this._policy.council + && !!this._policy.user && !!this._policy.password; + if (shouldRun) { await this._ensureListening(); } + else { await this._ensureStopped(); } + + // Firewall (defense-in-depth; in-process gate is primary). Open the + // status port to whitelisted IPs only when running; tear down when not. + if (EnmFirewallManager && typeof EnmFirewallManager.reconcileSourceRules === 'function') { + try { + await EnmFirewallManager.reconcileSourceRules( + this.port, + shouldRun ? this._policy.whiteIPList : [], + { comment: 'ENM monitor status (ENM policy)', logger: this.log }, + ); + } catch (err) { + if (this.log && this.log.warn) { + this.log.warn('EnmStatusEndpoint: firewall reconcile failed: ' + (err && err.message)); + } + } + } + } + + start() { return this.reload(); } + + async stop() { await this._ensureStopped(); } + + /** @private */ + _ensureListening() { + if (this._server) { return Promise.resolve(); } + return new Promise((resolve) => { + const server = http.createServer((req, res) => this._handle(req, res)); + server.on('error', (err) => { + if (this.log && this.log.error) { + this.log.error('EnmStatusEndpoint: listen error on :' + this.port + ' — ' + (err && err.message)); + } + this._server = null; + resolve(); + }); + server.listen(this.port, '0.0.0.0', () => { + this._server = server; + if (this.log && this.log.info) { + this.log.info('EnmStatusEndpoint: monitor status listening on 0.0.0.0:' + this.port + '/status'); + } + resolve(); + }); + }); + } + + /** @private */ + _ensureStopped() { + if (!this._server) { return Promise.resolve(); } + const server = this._server; + this._server = null; + return new Promise((resolve) => { + try { server.close(() => resolve()); } catch (_) { resolve(); } + }); + } + + /** @private — request handler. GET /status only; everything else 404. */ + _handle(req, res) { + const send = (code, obj) => { + const body = JSON.stringify(obj); + res.writeHead(code, { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + 'Cache-Control': 'no-store', + }); + res.end(body); + }; + try { + // Surface only GET /status. Drop the rest (no info leak). + const url = (req.url || '').split('?')[0]; + if (req.method !== 'GET' || url !== '/status') { send(404, { error: 'not found' }); return; } + + // 1. Disabled / not Council ⇒ behave as if absent. + if (!this._policy.enabled || !this._policy.council) { send(404, { error: 'not found' }); return; } + + // 2. Source-IP allow-list (real peer; never trust X-Forwarded-For). + const peer = req.socket && req.socket.remoteAddress; + if (!ipAllowed(peer, this._policy.whiteIPList)) { res.socket && res.socket.destroy(); return; } + + // 3. HTTP Basic-Auth against the RPC credentials. + if (!this._checkAuth(req)) { + res.writeHead(401, { + 'WWW-Authenticate': 'Basic realm="ENM monitor"', + 'Content-Type': 'application/json', + }); + res.end(JSON.stringify({ error: 'unauthorized' })); + return; + } + + // 4. Build + return the read-only aggregate. + const payload = this._buildPayload(); + if (!payload) { send(503, { error: 'warming up' }); return; } + send(200, payload); + } catch (err) { + if (this.log && this.log.warn) { this.log.warn('EnmStatusEndpoint: handler error: ' + (err && err.message)); } + try { send(500, { error: 'internal' }); } catch (_) { /* ignore */ } + } + } + + /** @private */ + _checkAuth(req) { + const hdr = req.headers && req.headers.authorization; + if (typeof hdr !== 'string' || !/^basic\s+/i.test(hdr)) { return false; } + let decoded; + try { decoded = Buffer.from(hdr.replace(/^basic\s+/i, ''), 'base64').toString('utf8'); } + catch (_) { return false; } + const idx = decoded.indexOf(':'); + if (idx === -1) { return false; } + const user = decoded.slice(0, idx); + const pass = decoded.slice(idx + 1); + return safeEqual(user, this._policy.user) && safeEqual(pass, this._policy.password); + } + + /** @private — assemble the whole-node roll-up from ENM's existing data. */ + _buildPayload() { + const overview = this._getOverview(); + const snap = overview && typeof overview.getCachedSnapshot === 'function' + ? overview.getCachedSnapshot() : null; + if (!snap || !Array.isArray(snap.chains)) { return null; } + const components = snap.chains.map((c) => { + let version = null; + try { + const s = ChainState.snapshot(c.chainId); + version = (s && s.binaryVersion) ? s.binaryVersion : null; + } catch (_) { version = null; } + // Cold cache (e.g. just after a server restart) ⇒ kick a one-shot + // smoke-test so the next poll has the version. snapshotVerified is + // itself cached, and _warmed stops us re-spawning for components + // that have no resolvable --version (oracle scripts). + if (!version && !this._warmed.has(c.chainId)) { + this._warmed.add(c.chainId); + Promise.resolve().then(() => ChainState.snapshotVerified(c.chainId)).catch(() => { /* best-effort */ }); + } + return { + id: c.chainId, + name: DISPLAY_NAME[c.chainId] || c.displayName || c.chainId, + class: c.chainClass || null, + version, + active: !!c.alive, + state: c.state || null, // synced|syncing|starting|stalled|stopped|disabled|unconfigured + height: (typeof c.height === 'number') ? c.height : null, + networkHeight: (typeof c.networkHeight === 'number') ? c.networkHeight : null, + peers: (typeof c.peers === 'number') ? c.peers : null, + updateAvailable: !!c.updateAvailable, + }; + }); + return { + ts: Date.now(), + node: { mode: components.length >= 2 ? 'council' : 'bpos' }, + components, + }; + } +} + +module.exports = { + EnmStatusEndpoint, + DEFAULT_STATUS_PORT, + // exported for unit tests + ipAllowed, + entryMatches, +}; diff --git a/enm-server/src/services/EvmSidechainAdapter.js b/enm-server/src/services/EvmSidechainAdapter.js index bef12c36d3..1064c17a25 100644 --- a/enm-server/src/services/EvmSidechainAdapter.js +++ b/enm-server/src/services/EvmSidechainAdapter.js @@ -296,8 +296,14 @@ class EvmSidechainAdapter extends ChainAdapter { // Lazy require (matches the codebase's adapter pattern; keeps the // import block untouched and unit tests light). + // v0.5.228 — EnmRpcClient is a NAMED export; the pre-228 bare + // require returned the whole module object and `new + // EnmRpcClient(...)` threw "EnmRpcClient is not a constructor" + // → every detectProducerRole call returned source='error', so + // /system/council-status reported every chain as "unknown". + // Destructure to grab the class itself. const EnmCrypto = require('./EnmCrypto'); - const EnmRpcClient = require('./EnmRpcClient'); + const { EnmRpcClient } = require('./EnmRpcClient'); let password = ''; if (mainRpc.passwordEncrypted) { @@ -319,10 +325,24 @@ class EvmSidechainAdapter extends ChainAdapter { const info = await client.getarbitersinfo(); const norm = (s) => String(s || '').toLowerCase().replace(/^0x/, ''); const me = norm(nodePubkeyRaw); - const current = Array.isArray(info && info.currentarbiters) - ? info.currentarbiters.map(norm) : []; + // v0.5.229 (audit 2026-05-27) — TWO bugs fixed here: + // 1. The current-slate field is `arbiters`, NOT `currentarbiters`. + // The pre-229 read of `info.currentarbiters` always landed on + // undefined → empty array → every Council operator looked + // Inactive even when on-duty. Verified against ELA struct + // definition at Elastos.ELA/servers/interfaces.go:884-892 + // and confirmed by live curl 2026-05-27. + // 2. ELA's RPC handler at servers/interfaces.go:906-912 emits + // an empty-string slot for any CRC arbiter whose IsNormal + // is false (= MemberState != MemberElected). Filter empties + // before .includes(me) so a Council member in MemberInactive + // isn't silently hidden by an empty-string slot. + const current = Array.isArray(info && info.arbiters) + ? info.arbiters.map(norm).filter((s) => s.length > 0) + : []; const next = Array.isArray(info && info.nextarbiters) - ? info.nextarbiters.map(norm) : []; + ? info.nextarbiters.map(norm).filter((s) => s.length > 0) + : []; out.inCurrent = current.includes(me); out.inNext = next.includes(me); out.arbiterCount = current.length; @@ -585,15 +605,32 @@ class EvmSidechainAdapter extends ChainAdapter { args.push('--pbft.keystore.password', secrets.pbftPasswordFile); } } - // Sync mode: 'fast' / 'full' / 'archive'. - if (cfg.sync && cfg.sync.mode) { - args.push('--syncmode', cfg.sync.mode); - } else if (cfg.miner && cfg.miner.enabled === true) { - // v0.5.185 P2-D — node.sh forces `--syncmode full` on a producing - // council validator (esc_start:2152). Without it geth defaults to - // 'fast', and a PBFT producer on fast-sync can mis-serve / mis-mine - // before its state is complete. Match node.sh for miners; non-miner - // followers keep geth's default. + // Sync mode — v0.5.235: EVM chains ALWAYS full-sync (council-ready). + // + // Operator directive 2026-05-28: "all ENM apps should be council ready, + // remove fast sync." ENM is a validator tool — a Council node produces + // EVM blocks when on-duty, and node.sh runs producers on --syncmode full + // (esc_start:2152, eid_start:4390). Rather than fast-when-following / + // full-when-producing (the pre-v0.5.235 role-based flip), every EVM + // chain now runs validator-grade FULL sync regardless of current + // on-duty status, so the node is always production-ready with complete + // self-validated state and never needs a fast→full re-sync when it goes + // on-duty. This is safe for from-genesis sync ONLY because v0.5.235 also + // wipes SPV in lockstep with geth (chainResync) — full-sync re-executes + // every block (incl. EID's DID tx at 166,410), which requires the + // arbiter context the lockstep SPV supplies. + // + // Fast sync is removed. An explicit 'archive' override is still honored + // (full + retain all historical state); any other value — including a + // legacy stored 'fast' — is coerced to 'full'. + // v0.5.248 (validator-readiness audit P1-10) — archive = FULL sync that + // RETAINS all historical state. In this geth fork that is + // `--syncmode full --gcmode archive`, NOT `--syncmode archive` (not a + // valid syncmode — it would silently fail to produce an archive node). + // Default + any legacy stored 'fast' coerce to plain full. + if (cfg.sync && cfg.sync.mode === 'archive') { + args.push('--syncmode', 'full', '--gcmode', 'archive'); + } else { args.push('--syncmode', 'full'); } // Miner — enabled for council validators (the sidechain produces @@ -791,36 +828,144 @@ class EvmSidechainAdapter extends ChainAdapter { try { const allCfg = await ConfigStore.load(); const role = await this.detectProducerRole(allCfg); - const shouldMine = (role.isProducer === true); + let shouldMine = (role.isProducer === true); + + // v0.5.229c (P1 audit fix) — CROSS-REFERENCE crMember status. + // + // The chain's arbiter slate is FROZEN at compute-height before + // a rotation actually starts. If an operator unclaims via + // Essentials AFTER the next slate was frozen, their pubkey + // stays in nextarbiters[] until the rotation after next. + // detectProducerRole sees inNext=true → shouldMine=true → + // ENM would pass --mine to the chain on next restart. + // + // Operator directive 2026-05-27: "I removed my council binding + // to the server we are working until we fix it, so that i dont + // interrupt chains." The intent of unclaim is "do NOT mine." + // Without this cross-reference, ENM contradicts that intent + // for the entire window between unclaim-confirmed and slate- + // recomputed (potentially hours on mainnet). + // + // Decision rule: if the operator is a confirmed Council + // install (cfg.global.council.installed === true) AND + // CrMembershipService reports !isCrMember (the on-chain + // Committee has no record of their dpospublickey), demote + // to FOLLOWER regardless of nextarbiters membership. This + // honors operator intent (the unclaim) over the chain's + // frozen slate. + // + // Why this is safe even when wrong: + // - If unclaim is genuine: PBFT would refuse Seal() anyway + // when the rotation reaches the operator (the chain's + // own IsProducer() check is the floor). Adding --mine + // would force full-sync + try to seal blocks the chain + // refuses → wasted CPU + log noise. Skipping --mine + // saves both. + // - If unclaim is mistaken / operator re-claims later: + // CrMembershipService's 30s cache + next chain start + // reconciles in under a minute. No deploy needed. + // + // Cited file:line for the unclaim semantics: + // Elastos.ELA/dpos/state/arbitrators.go:2444+ (getCRC- + // ArbitersV2 reads from CRCommittee.Members[].DPOSPublicKey, + // not from the frozen arbiter slate) + let crMemberCheck = null; + const setupRole = (allCfg && allCfg.global && allCfg.global.council + && allCfg.global.council.installed === true) ? 'council' : 'unknown'; + if (shouldMine && setupRole === 'council') { + try { + const CrMembershipService = require('./CrMembershipService'); + crMemberCheck = await CrMembershipService.detectCrMembership( + allCfg, { log: _roleLog }, + ); + if (crMemberCheck + && crMemberCheck.source !== 'error' + && crMemberCheck.isCrMember === false) { + // Operator unclaimed (or never claimed) but chain + // slate still has them queued. Honor intent over + // frozen-slate. + shouldMine = false; + if (_roleLog) { + _roleLog.info( + `${ENM_LOG_PREFIX} ${this.chainId}: shouldMine demoted to FOLLOWER ` + + `despite inNext=${role.inNext}: setupRole=council but ` + + `crMember.isCrMember=false (source=${crMemberCheck.source}). ` + + 'The arbiter slate is frozen until the next rotation compute; ' + + 'until then the chain still has this node\'s pubkey queued. ' + + 'Spawning with --mine would contradict the operator\'s unclaim ' + + 'and waste CPU on Seal attempts the chain would refuse.', + ); + } + } + } catch (e) { + // detectCrMembership threw — keep shouldMine as-is + // (the original getarbitersinfo decision). Surface + // the error so the operator can see it. + if (_roleLog) { + _roleLog.warn( + `${ENM_LOG_PREFIX} ${this.chainId}: CrMembershipService check failed ` + + `(${e && e.message ? e.message : e}) — falling back to slate-only decision.`, + ); + } + } + } + const wasMiner = !!(cfg.miner && cfg.miner.enabled); if (cfg.miner) { cfg.miner.enabled = shouldMine; } - if (shouldMine) { - // Confirmed on-duty → miner: node.sh's council branch uses full sync. - if (!cfg.sync) { cfg.sync = {}; } - if (cfg.sync.mode !== 'full') { cfg.sync.mode = 'full'; } - } else if (cfg.sync && cfg.sync.mode === 'full') { - // Follower → drop forced full so it fast-syncs (avoids the DID wedge); - // leave any explicit non-full mode untouched. - cfg.sync.mode = 'fast'; - } + // v0.5.248 (validator-readiness audit P1-2) — record WHY we chose + // miner/follower so HealthChecker (detectF29) can alert when a + // Council node fell back to FOLLOWER because it couldn't READ its + // producer status (mainchain RPC down / creds undecryptable) rather + // than because it's genuinely off-duty — the "silently stops + // earning" hazard. source∈{getarbitersinfo,empty-slate}=real read. + this._lastRoleDecision = { + source: role.source, + shouldMine, + setupRole, + at: Date.now(), + }; + // v0.5.235 — syncmode is NO LONGER role-dependent. EVM chains + // always full-sync (buildSpawnArgs hardcodes it). Producer status + // controls ONLY --mine (miner.enabled), never the sync mode. The + // old shouldMine→full / follower→fast flips are removed; the + // forced-full-sync DID wedge they were avoiding is now handled + // structurally by the lockstep SPV wipe (v0.5.235 chainResync). + // Migrate any legacy stored 'fast' to 'full' so persisted config + // stays honest with what actually runs. + if (!cfg.sync) { cfg.sync = {}; } + if (cfg.sync.mode === 'fast' || !cfg.sync.mode) { cfg.sync.mode = 'full'; } if (_roleLog) { + const crNote = crMemberCheck + ? ` crMember.isCrMember=${crMemberCheck.isCrMember}, source=${crMemberCheck.source}.` + : ''; _roleLog.info( `${ENM_LOG_PREFIX} ${this.chainId}: producer-role check → isProducer=${role.isProducer} ` - + `(source=${role.source}, inCurrent=${role.inCurrent}, inNext=${role.inNext}) → ` - + `${shouldMine ? 'MINER (full sync)' : 'FOLLOWER (no --mine, fast sync)'}` + + `(source=${role.source}, inCurrent=${role.inCurrent}, inNext=${role.inNext})` + + crNote + + ` → ${shouldMine ? 'MINER (--mine)' : 'non-producer (no --mine)'}; sync=full (always)` + `${wasMiner !== shouldMine ? (shouldMine ? ' [PROMOTED]' : ' [demoted]') : ''}. ` + 'Mining is on-chain producer state, not an ENM toggle.', ); } } catch (err) { - // Fail-safe: on an unexpected detection error, run as FOLLOWER (never mine - // on an unknown role — the chain self-gates anyway and fast-sync is safe). + // Fail-safe: on an unexpected detection error, do NOT mine (never + // --mine on an unknown role — the chain self-gates anyway). v0.5.235: + // sync stays FULL even on detection failure; only mining is demoted. if (cfg.miner) { cfg.miner.enabled = false; } - if (cfg.sync && cfg.sync.mode === 'full') { cfg.sync.mode = 'fast'; } + if (!cfg.sync) { cfg.sync = {}; } + if (cfg.sync.mode === 'fast' || !cfg.sync.mode) { cfg.sync.mode = 'full'; } + // v0.5.248 (audit P1-2) — detection threw: this is a can't-read + // demotion to follower, the exact silent-earning-loss case F29 warns on. + this._lastRoleDecision = { + source: 'error', + shouldMine: false, + setupRole: (typeof setupRole !== 'undefined') ? setupRole : null, + at: Date.now(), + }; if (_roleLog) { _roleLog.warn( `${ENM_LOG_PREFIX} ${this.chainId}: producer-role detection failed ` - + `(${err && err.message ? err.message : err}) — running as FOLLOWER (fail-safe).`, + + `(${err && err.message ? err.message : err}) — no --mine (fail-safe), sync=full.`, ); } } diff --git a/enm-server/src/services/HealthChecker.js b/enm-server/src/services/HealthChecker.js index cb42c02f5a..5a1ceb1094 100644 --- a/enm-server/src/services/HealthChecker.js +++ b/enm-server/src/services/HealthChecker.js @@ -393,6 +393,13 @@ class HealthChecker { } else { s.firstHeightStallAt = null; s.lastHeight = rpcSummary.height; + // v0.5.231 — F26 multi-tick consecutive gate: any forward + // height progress means the chain is NOT actually wedged, + // so reset the consecutive-fork-signature counter. Without + // this, an intermittent fork signature seen across stalls + // separated by brief advances could still accumulate to + // the threshold and propose a wipe. + s.evmForkDetectedConsecutive = 0; } // Feed the SyncTracker so /chains/:id/sync has live velocity // data. Doing this here (medium tick, every 30s) gives the @@ -530,6 +537,22 @@ class HealthChecker { if (s.firstHeightStallAt) { evmForkDetected = await this._probeEvmForkSignal(chainId); evmRecoveryStall = await this._probeEvmRecoveryStall(chainId); + // v0.5.231 — multi-tick gate for F26. Counter persists in + // the per-chain ruleState (`s`); detectF26 requires it to + // reach F26_CONSECUTIVE_TICKS_MIN before proposing a + // destructive wipe. Resets to 0 on any negative probe OR + // any height advance (handled above where s.lastHeight + // updates) — so a transient burst of fork-like errors + // that resolves within a tick cannot accumulate. + if (evmForkDetected) { + s.evmForkDetectedConsecutive = (s.evmForkDetectedConsecutive || 0) + 1; + } else { + s.evmForkDetectedConsecutive = 0; + } + } else { + // Not stalled → probe didn't run → counter must be 0 so a + // future stall starts the consecutive count from scratch. + s.evmForkDetectedConsecutive = 0; } } @@ -547,6 +570,15 @@ class HealthChecker { evmForkDetected, evmRecoveryStall, evmSpvReady, + // v0.5.248 (validator-readiness audit P1-2) — the EVM adapter's + // last miner/follower decision {source, shouldMine, setupRole}. + // Lets F29 flag a Council node that fell back to FOLLOWER because + // it couldn't READ its producer status (mainchain RPC down / + // creds undecryptable) — the "silently stops earning" hazard — + // vs being genuinely off-duty. + minerDecision: isEvm + ? (((this.getAdapter(chainId) || {})._lastRoleDecision) || null) + : null, }; this._enrichOracleSnap(snap, chainCfg); this._enrichArbiterSnap(snap); @@ -555,7 +587,7 @@ class HealthChecker { d.ruleId === 'F3' || d.ruleId === 'F4' || d.ruleId === 'F9' || d.ruleId === 'F10' || d.ruleId === 'F16' || d.ruleId === 'F18' || d.ruleId === 'F22' || d.ruleId === 'F24' || d.ruleId === 'F23' - || d.ruleId === 'F26' || d.ruleId === 'F27'); + || d.ruleId === 'F26' || d.ruleId === 'F27' || d.ruleId === 'F29'); if (dets.length > 0) { await this.engine.apply(chainId, dets, chainCfg); } @@ -605,6 +637,14 @@ class HealthChecker { ? await this._fetchBposState(chainId, chainCfg, s) : null; + // v0.5.230 — CR Council membership snapshot for F28. Only the + // mainchain has CR Committee state; non-Class-A chains pass null + // and F28 self-gates on the chainId === 'mainchain' check. Best + // effort; failure leaves cr=null and F28 stays quiet. + const cr = (chainId === 'mainchain') + ? await this._fetchCrState(chainCfg).catch(() => null) + : null; + const snap = { chainId, processStatus: this.processService.statusSync(chainId), @@ -616,6 +656,7 @@ class HealthChecker { chainConfig: chainCfg, ruleState: s, bpos, + cr, clockSkew, hostConflicts, }; @@ -625,7 +666,8 @@ class HealthChecker { const dets = HealthRules.runAll(snap).filter((d) => d.ruleId === 'F5' || d.ruleId === 'F6' || d.ruleId === 'F8' || d.ruleId === 'F11' || d.ruleId === 'F12' || d.ruleId === 'F13' - || d.ruleId === 'F19' || d.ruleId === 'F25'); + || d.ruleId === 'F19' || d.ruleId === 'F25' + || d.ruleId === 'F28'); // v0.5.230 — CR Council MemberState degraded if (dets.length > 0) { await this.engine.apply(chainId, dets, chainCfg); } @@ -931,6 +973,39 @@ class HealthChecker { snap.crossChainReach = reach; } + /** + * v0.5.230 — fetch CR Council membership state for F28. + * + * Thin wrapper over CrMembershipService.detectCrMembership, sharing + * its 30s in-process cache so the slow-tick re-poll doesn't hammer + * mainchain RPC. Returns the same shape the service returns: + * { isCrMember, state, nickname, impeachmentVotes, source, ... } + * F28 reads .isCrMember + .state + .impeachmentVotes; everything else + * is informational. Failure modes (no pubkey / RPC unreachable / + * not-in-Committee) all surface via source !== 'matched'; F28 + * self-gates on isCrMember=true so non-Council operators don't + * trigger it. + * + * @private + * @param {object} chainCfg mainchain cfg block (read pubkey + RPC from) + * @returns {Promise} + */ + async _fetchCrState(chainCfg) { + if (!chainCfg || !chainCfg.dpos || !chainCfg.dpos.nodePublicKey) { + return null; + } + try { + const CrMembershipService = require('./CrMembershipService'); + const ConfigStore = require('./ConfigStore'); + const cfg = await ConfigStore.load(); + return await CrMembershipService.detectCrMembership(cfg, { + log: this.extensionHandle && this.extensionHandle.log, + }); + } catch (_) { + return null; + } + } + /** * beta.0.3.5 (Wave M4.5) — enrich a snapshot with parent-chain * fields for Class C (oracle) chains. F24 reads snap.parentChainId @@ -1406,21 +1481,35 @@ class HealthChecker { * @returns {Promise} */ async _probeEvmForkSignal(chainId) { - const PROBE_MAX_BYTES = 64 * 1024; + // v0.5.231 — bumped from 64 KB to 256 KB so the recent-window filter + // below has enough log surface to find hits even in a verbose chain; + // the per-line timestamp check then narrows the count to the last 10 + // minutes regardless of how much we read. + const PROBE_MAX_BYTES = 256 * 1024; // Two fork-class signatures, different confidence: // - DOWNLOADER_FORK: geth's block downloader rejecting a peer's header - // chain. Emitted transiently by a single bad peer too, so require - // ≥3 hits to confirm a genuine local minority-fork wedge. + // chain. Emitted transiently by a single bad peer too. v0.5.231 + // requires ≥10 hits (up from 3) AND all within the last 10 min to + // confirm a genuine local minority-fork wedge; a transient peer + // blip can no longer trip the wipe. // - STATE_CORRUPT (v0.5.185 P0-C): a state/receipt-root mismatch or - // BAD BLOCK on the PBFT live-insert path. When no higher-TD peer - // exists this halts import with NO downloader string (the silent - // halt F26 used to miss → only F4 restart fired, which re-poisons). - // It's definitive local-state corruption, so ≥1 suffices — F26's - // other gates (20-min stall + peers>0 + SPV-ready) prevent a fluke - // from triggering the destructive wipe. - const DOWNLOADER_FORK = /retrieved hash chain is invalid/gi; - const STATE_CORRUPT = /invalid merkle root|invalid receipt root hash|BAD BLOCK/gi; - const DOWNLOADER_MIN_HITS = 3; + // BAD BLOCK on the PBFT live-insert path. Definitive local-state + // corruption, so ≥1 hit still suffices — but it ALSO has to be + // inside the recent-window so a months-old BAD BLOCK in the same + // log file can't trigger a fresh wipe. + const DOWNLOADER_FORK_RE = /retrieved hash chain is invalid/i; + const STATE_CORRUPT_RE = /invalid merkle root|invalid receipt root hash|BAD BLOCK/i; + const DOWNLOADER_MIN_HITS = 10; + // Geth-flavoured log line prefix: [MM-DD|HH:MM:SS.mmm] LEVEL ... + // We assume UTC and the current year; lines with a parsed timestamp + // that lies in the future (year-rollover artefact at Dec/Jan) are + // skipped. Lines we cannot parse a timestamp from are also skipped — + // safer than counting an undated line that may be ancient. + const TS_RE = /^\[(\d{2})-(\d{2})\|(\d{2}):(\d{2}):(\d{2})\.(\d{3})\]/; + const RECENT_WINDOW_MS = 10 * 60_000; + const nowMs = Date.now(); + const cutoffMs = nowMs - RECENT_WINDOW_MS; + const currentYear = new Date(nowMs).getUTCFullYear(); try { const logDir = path.join(chainDir(chainId), 'logs'); const entries = await fsp.readdir(logDir).catch(() => []); @@ -1437,15 +1526,32 @@ class HealthChecker { const buf = Buffer.alloc(stat.size - startOffset); await fd.read(buf, 0, buf.length, startOffset); const text = buf.toString('utf8'); - const stateHits = (text.match(STATE_CORRUPT) || []).length; - if (stateHits >= 1) { + let recentStateHits = 0; + let recentDownloaderHits = 0; + const lines = text.split('\n'); + for (const line of lines) { + const m = line.match(TS_RE); + if (!m) continue; + const [, mo, day, hh, mm, ss, ms] = m; + const ts = Date.UTC(currentYear, (+mo) - 1, +day, +hh, +mm, +ss, +ms); + if (ts > nowMs) continue; // year-rollover artefact + if (ts < cutoffMs) continue; // outside the 10-min window + if (STATE_CORRUPT_RE.test(line)) recentStateHits += 1; + if (DOWNLOADER_FORK_RE.test(line)) recentDownloaderHits += 1; + } + if (recentStateHits >= 1) { + this.extensionHandle.log.debug( + `${ENM_LOG_PREFIX} _probeEvmForkSignal(${chainId}): state-corruption signature ×${recentStateHits} (silent-halt fork) within last 10min`, + ); + return true; + } + if (recentDownloaderHits >= DOWNLOADER_MIN_HITS) { this.extensionHandle.log.debug( - `${ENM_LOG_PREFIX} _probeEvmForkSignal(${chainId}): state-corruption signature ×${stateHits} (silent-halt fork)`, + `${ENM_LOG_PREFIX} _probeEvmForkSignal(${chainId}): downloader-fork signature ×${recentDownloaderHits} within last 10min (threshold ${DOWNLOADER_MIN_HITS})`, ); return true; } - const dlHits = (text.match(DOWNLOADER_FORK) || []).length; - return dlHits >= DOWNLOADER_MIN_HITS; + return false; } finally { await fd.close().catch(() => {}); } diff --git a/enm-server/src/services/HealthRules.js b/enm-server/src/services/HealthRules.js index 9bb89bf62c..1ad654437c 100644 --- a/enm-server/src/services/HealthRules.js +++ b/enm-server/src/services/HealthRules.js @@ -137,10 +137,29 @@ const PRODUCER_INACTIVE_CRITICAL = 1300; // F12 — close to forced // height-stall grace: F26's action is a destructive resync (wipe + re-sync from // genesis), so we want extra certainty the chain is genuinely wedged — not just // in a slow snap-sync batch or a brief peer churn — before triggering it. The -// fork log-signature (≥3 "retrieved hash chain is invalid" in HealthChecker's -// probe) is the definitive marker; this grace ensures the wedge has persisted. +// fork log-signature (≥10 "retrieved hash chain is invalid" in HealthChecker's +// probe, all timestamped within the last 10 min) is the definitive marker; this +// grace ensures the wedge has persisted. const EVM_FORK_STALL_GRACE_MS = 20 * 60_000; +// v0.5.231 — F26 near-tip safety gate. A chain whose local head is within this +// many blocks of the peer-reported network tip is NOT considered forked, no +// matter what log signatures appear — it's just slow-syncing. Sized for ~5.8d +// of 5s EVM blocks: enough headroom for a chain genuinely behind to still get +// help, but tight enough that a 12k-block lag (~16h) gets a hard veto and a +// destructive wipe is never proposed on a near-fully-synced chain. (Anchor: +// F26 wiped EID at 27,835,801 vs tip 27,847,941 on 2026-05-27 — only 12k +// blocks behind, classified as "stuck" → 16h of sync work destroyed.) +const F26_NEAR_TIP_BLOCKS_GUARD = 100_000; + +// v0.5.231 — F26 multi-tick consecutive-signature gate. The 64KB log probe is +// stateless and a single tick can catch a transient burst of fork-like errors +// that resolve within seconds; require the signature to PERSIST across this +// many consecutive medium ticks (~30s each, so ~90s of unbroken evidence) +// before proposing a wipe. Counter is owned by HealthChecker, resets to 0 on +// any negative probe or any height advance. +const F26_CONSECUTIVE_TICKS_MIN = 3; + // v0.5.185 (P0-B) — max per-medium-tick (30s) SPV-height advance still counted // as "tracking the mainchain tip" rather than an initial bulk header download. // Normal tip-tracking moves ~tens of blocks per 30s; a fresh SPV catching up @@ -295,6 +314,25 @@ function detectF4(snap) { // yet). Don't treat that as a stall during the initial start grace. if (withinInitialStartGrace(snap)) return null; + // v0.5.228 audit — false-positive stall suppression. If our height is at + // (or within 1 block of) the network's best known height, the chain isn't + // stalled — the WHOLE network just hasn't produced new blocks recently. + // Elastos mainchain can go 10-20 min between blocks during quiet periods; + // pre-v0.5.228 F4 fired on these naturally quiet windows and prompted + // operators to restart a perfectly healthy chain (real-world repro: + // 2026-05-26 — node held at block 2221127 for 14 min, ALL peers also at + // 2221127, then resumed normally; ENM had already proposed a restart). + // + // networkHeight is populated by the adapters' primaryHeight() probe: + // - Class A (mainchain): max peer height from getnodestate.Neighbors + // - Class B (EVM): eth_syncing.highestBlock + // When it's a real number > 0 and we're at-or-near it, suppress the stall. + if (typeof snap.rpcSummary.networkHeight === 'number' + && snap.rpcSummary.networkHeight > 0 + && snap.rpcSummary.height >= snap.rpcSummary.networkHeight - 1) { + return null; + } + const firstStall = snap.ruleState && snap.ruleState.firstHeightStallAt; if (!firstStall) return null; if (Date.now() - firstStall < HEIGHT_STALL_GRACE_MS) return null; @@ -499,9 +537,16 @@ function detectF11(snap) { * CRITICAL_NOTIFY at >1300 (~10% slack from MAX_INACTIVE_ROUNDS=1440 before * permanent penalty). * - * Action: NEVER_AUTOMATIC. ActivateProducer requires the operator's owner - * key, which Node Manager intentionally does not hold (Rev 6 RNG findings). - * The summary points the operator at ela-cli. + * Action: NEVER_AUTOMATIC (operator-initiated; not auto-fired). v0.5.248 fix + * (validator-readiness audit P1-1): ActivateProducer is NODE-KEY signed + * (Elastos.ELA/core/transaction/activateproducertransaction.go:208-227) — it + * uses the keystore Node Manager already holds, so ENM CAN and DOES submit it + * via the in-app Activate control (POST /chains/:id/bpos/activate, + * EnmBposService). The summary points the operator THERE, not at ela-cli. It + * stays NEVER_AUTOMATIC because WHEN to reactivate is the operator's call — + * not because ENM is unable to. (The prior copy wrongly said "owner key … + * Node Manager cannot do this for you", steering validators to ela-cli at the + * exact moment they're losing their slot.) */ function detectF12(snap) { if (!snap || !snap.bpos || !snap.bpos.producer) return null; @@ -529,12 +574,12 @@ function detectF12(snap) { ruleId: 'F12', tier: HEALING_TIERS.NEVER_AUTOMATIC, severity: isCritical ? 'CRITICAL' : 'WARNING', - summaryAction: `Producer Inactive — run ActivateProducer (${inactiveRounds}/${MAX_INACTIVE_ROUNDS} rounds)`, + summaryAction: `Producer Inactive — Activate from the Main chain card (${inactiveRounds}/${MAX_INACTIVE_ROUNDS} rounds)`, summaryReason: `Your producer is in Inactive state. ${isCritical ? 'Critical: ' : ''}` + `${inactiveRounds} rounds elapsed (${MAX_INACTIVE_ROUNDS} = forced inactive). ` - + 'Sign and submit an ActivateProducer transaction via ela-cli within the ' - + '6-block window — Node Manager cannot do this for you.', + + 'Reactivate now from the Main chain dashboard card — Node Manager signs the ' + + 'ActivateProducer transaction with your node key (no ela-cli needed).', payload: { action: 'bpos-activate-producer', chainId: snap.chainId, @@ -886,6 +931,19 @@ function detectF24(snap) { * the operator-supplied address fails validation; F25 catches the * post-install case where the operator opened Settings and cleared * the address (or where the install never set one because miner. + * + * v0.5.229e (P11 audit note) — F-rule Council-mode safety review: + * every F-rule defined above null-guards on snap.bpos (or snap.bpos. + * producer) before reading producer.state etc. → a pure-Council + * operator with snap.bpos.producer === null never triggers any of + * F11 (rotation stuck), F12 (producer Inactive), F22 (DPoS state + * desync), so they don't fire wrongly. The remaining GAP is an + * unimplemented "F-rule for CR Committee MemberState=Inactive" + * (parallel to F12 but on crMember.state). It would consume the + * CrMembershipService output and warn when impeachmentVotes climbs + * or state flips to Inactive. Deferred — not a regression, just a + * missing feature; documented here so the next F-rule pass picks + * it up. * enabled was false at install time and is now true). */ function detectF25(snap) { @@ -913,29 +971,41 @@ function detectF25(snap) { } /** - * F26 — wedged EVM sidechain fork (Class B, v0.5.184). + * F26 — wedged EVM sidechain fork (Class B, v0.5.184; HARDENED v0.5.231). * - * Fires when ALL of: + * v0.5.231 — this rule's tier was DEMOTED from AUTOMATED_SAFE to OWNER_CONFIRMS + * after a false positive on 2026-05-27 wiped EID's chaindata while the chain + * was 99.96% synced (12k blocks from network tip). F26 now NEVER auto-executes + * a destructive resync — the operator MUST confirm. Three additional safety + * gates layer on top of the original detection: + * + * 1. Near-tip guard (F26_NEAR_TIP_BLOCKS_GUARD): if we know the peer tip and + * our local head is within ~100k blocks of it, do NOT propose a wipe — + * slow sync ≠ fork. If peer tip is unobservable, also refuse (fail safe). + * 2. Multi-tick consecutive gate (F26_CONSECUTIVE_TICKS_MIN): the fork log + * signature must persist across ≥3 consecutive medium ticks (~90s of + * unbroken evidence). HealthChecker maintains the counter and resets it + * on any negative probe OR any height advance. + * 3. Pre-execution sanity recheck (SelfHealingEngine._executeChainResync): + * after the operator confirms, the engine re-polls RPC and refuses to + * wipe if the chain has advanced past stuckHeight by ≥50 blocks. + * + * Original detection (still required, on top of the above): * - process alive * - RPC reachable + peers > 0 (so it's NOT a connectivity / peer-zero * problem — F3/F16 own that) * - height stalled past EVM_FORK_STALL_GRACE_MS (20 min) - * - HealthChecker's medium-tick log probe set snap.evmForkDetected (≥3 - * "retrieved hash chain is invalid" lines in the recent EVM node-log tail) + * - HealthChecker's medium-tick log probe set snap.evmForkDetected (≥10 + * "retrieved hash chain is invalid" lines timestamped within last 10 min + * in the recent EVM node-log tail — strengthened in v0.5.231) + * - snap.evmSpvReady === true (SPV catching up ≠ data fork) * - * What it means: the local chaindata has diverged onto a minority fork, so geth - * rejects every canonical peer's header chain ("retrieved hash chain is - * invalid") and can never advance. A restart does NOT help — geth comes back on - * the same forked head (and a mining node re-mines the fork). The only recovery - * is to wipe the EVM chaindata (mining keystore preserved) and re-sync clean - * from peers. - * - * Tier AUTOMATED_SAFE: the engine auto-resyncs, but gated by a dedicated - * once-per-EVM_RESYNC_MIN_INTERVAL_MS budget (SelfHealingEngine) — a chain that - * re-forks inside that window escalates to OWNER_CONFIRMS instead of wiping in a - * loop. Destructive but recoverable (the chain re-syncs; keystore is always - * preserved), and the alternative is an indefinite silent outage. Honours the - * master autoExecuteSafe toggle like every AUTOMATED_SAFE rule. + * What it means when ALL gates pass: the local chaindata has diverged onto a + * minority fork, so geth rejects every canonical peer's header chain + * ("retrieved hash chain is invalid") and can never advance. A restart does + * NOT help — geth comes back on the same forked head. The only recovery is to + * wipe the EVM chaindata (mining keystore + nodekey preserved as of v0.5.231) + * and re-sync clean from peers — but the operator confirms first, every time. * * Runs BEFORE F4 in the detector queue; detectF4 also yields on * snap.evmForkDetected so only F26 owns the fork case (no duplicate restart @@ -968,18 +1038,61 @@ function detectF26(snap) { if (!firstStall) return null; if (Date.now() - firstStall < EVM_FORK_STALL_GRACE_MS) return null; + // v0.5.231 — multi-tick consecutive gate. A single-tick fork signature can + // be a transient burst; require ≥3 consecutive medium ticks before we even + // PROPOSE (let alone execute) a destructive wipe. HealthChecker owns the + // counter — resets to 0 on any negative probe OR any height advance. + const consec = (snap.ruleState && snap.ruleState.evmForkDetectedConsecutive) || 0; + if (consec < F26_CONSECUTIVE_TICKS_MIN) return null; + + // v0.5.231 — near-tip safety guard. F26 fired on EID at 27,835,801 vs tip + // 27,847,941 on 2026-05-27 — only 12k blocks (~16h) behind, but ruled + // "forked" and wiped. A chain that's nearly caught up is almost certainly + // slow-syncing, not on a minority fork. Refuse to propose a wipe if we + // can see the peer tip and we're within F26_NEAR_TIP_BLOCKS_GUARD of it. + // Fail safe: if we can't see the peer tip at all, also refuse — better to + // stall the rule than risk another false-positive 16h-loss wipe. + const localHeight = snap.rpcSummary.height || 0; + const peerTip = snap.rpcSummary.peerMaxHeight + || snap.rpcSummary.networkHeight + || 0; + if (peerTip <= 0) return null; + if ((peerTip - localHeight) < F26_NEAR_TIP_BLOCKS_GUARD) return null; + + // v0.5.234 — branding pass: use the canonical display name in the + // operator-facing proposal copy instead of the raw lowercase chainId. + // Convention is "Main chain" for mainchain and all-caps for the EVM + // sidechains (ESC/EID/PG), per strings.js (~line 444) + session 28's + // wizard sub copy. PG is a PUBLIC EVM PBFT sidechain — never + // parenthesise it as "(Privacy)". + const chainDisplay = snap.chainId === 'mainchain' + ? 'Main chain' + : (snap.chainId || '').toUpperCase(); return { ruleId: 'F26', - tier: HEALING_TIERS.AUTOMATED_SAFE, - summaryAction: `Auto-resync ${snap.chainId} (forked off the network)`, + // v0.5.231 — NEVER auto-execute. Operator confirms every destructive + // wipe; the rate-limit/escalation logic in SelfHealingEngine stays in + // place to add context (e.g. "this chain was wiped in the last 24h"). + tier: HEALING_TIERS.OWNER_CONFIRMS, + summaryAction: `Confirm resync of ${chainDisplay} (suspected fork wedge)`, summaryReason: - `${snap.chainId} has been stuck at block ${snap.rpcSummary.height} for >20 min and its ` - + 'node log shows it rejecting every peer with "retrieved hash chain is invalid" — its ' - + 'local chain data forked off the network and cannot recover by restarting. Auto-resync ' - + 'wipes the chain data (mining keystore preserved) and re-syncs from peers.', + `${chainDisplay} has been stuck at block ${snap.rpcSummary.height} for >20 min, the network ` + + `tip is at block ${peerTip} (${peerTip - localHeight} blocks ahead), and its node log has ` + + 'shown "retrieved hash chain is invalid" persistently for the last several health checks — ' + + 'the local chain data appears to have forked off the network and cannot recover by ' + + 'restarting. Confirm to wipe the chain data (mining keystore AND network identity ' + + 'preserved) and re-sync clean from peers. If you suspect the chain is just slow-syncing ' + + 'or a peer-connectivity blip, dismiss this and check peers/bootnodes first.', // stuckHeight lets the engine's auto-resolve sweep tell "still forked" - // from "recovered" (height climbed past it), mirroring F4's payload. - payload: { action: 'evm-fork-resync', chainId: snap.chainId, stuckHeight: snap.rpcSummary.height }, + // from "recovered" (height climbed past it), AND drives the v0.5.231 + // pre-execution sanity recheck in _executeChainResync. + payload: { + action: 'evm-fork-resync', + chainId: snap.chainId, + stuckHeight: snap.rpcSummary.height, + peerTipAtDetection: peerTip, + consecutiveTicks: consec, + }, }; } @@ -1016,6 +1129,125 @@ function detectF27(snap) { }; } +/** + * F28 — CR Council MemberState degraded (Class A / mainchain only). + * + * v0.5.230 — parallel to F12 for the Council operator audience. F12 fires + * when the BPoS producer-registry record reads state='Inactive'; F28 + * fires when this node's CR Committee record (in `listcurrentcrs`'s + * `crmembersinfo[]`) reads MemberState != 'Elected'. Both rules surface + * the same kind of operator-facing risk (missed rotation rounds → lost + * rewards) for the two distinct roles a node can play in Elastos DPoS. + * + * Snap shape consumed: `snap.cr` populated by HealthChecker._fetchCrState + * (mirrors _fetchBposState), itself a thin wrapper over CrMembership + * Service.detectCrMembership. Null when: + * - chain is not class A (rule self-gates below) + * - operator has no node pubkey configured + * - mainchain RPC unreachable (CrMembershipService returns source='error') + * - the operator is not a CR Committee member (source='not-in-committee') + * In all those cases F28 stays quiet (returns null), same defensive + * pattern as F12's null-guard on snap.bpos.producer. + * + * State decision table (mirrors Elastos.ELA/cr/state/keyframe.go:24-42): + * Elected → no fire (steady state, healthy) + * Inactive → WARN if impeachmentVotes==0, CRITICAL if > 0 (close to + * impeachment threshold). Recoverable IN-APP (Validator card + * → Reactivate Council node; node-key-signed ActivateProducer, + * activateproducertransaction.go:113/212 — no owner key). + * Impeached → CRITICAL (impeachment threshold reached; seat lost for + * the rest of this term) + * Returned → CRITICAL (operator voluntarily withdrew; deposit + * returnable but seat gone) + * Terminated → CRITICAL (term ended without re-election; informational + * only post-term) + * Illegal → CRITICAL (caught misbehaving — deposit forfeited) + * + * Tier: NEVER_AUTOMATIC (alert-only — ENM never AUTO-submits an on-chain + * activation tx; activation is rate-limited on-chain to once per inactive + * window per ActivateDuration, so an auto-retry loop would be harmful). But + * the Inactive state IS recoverable on operator action: the Validator card's + * "Reactivate Council node" button submits a node-key-signed ActivateProducer + * (activateproducertransaction.go:113/212 — no owner key, just the keystore + * ENM already holds). The terminal states (Impeached/Returned/Terminated/ + * Illegal) are not recoverable this term; for those the summary points the + * operator at Essentials. + * + * Hard-gated to mainchain (snap.chainId === 'mainchain') because CR + * Committee membership is a Class-A-only concept; the rule runner would + * still skip non-A chains via _fetchCrState returning null, but the + * explicit chainId gate makes the intent clear in code-review. + */ +function detectF28(snap) { + if (!snap || snap.chainId !== 'mainchain') return null; + if (!snap.cr) return null; + const cr = snap.cr; + if (!cr.isCrMember) return null; + const state = String(cr.state || '').toLowerCase(); + if (state === 'elected') return null; // healthy steady state + + // Pre-fire severity decision. Inactive with no impeachment votes is + // WARN (still recoverable cheaply); Inactive with votes climbing, + // or any terminal state, is CRITICAL. + const impeachmentVotes = parseFloat(cr.impeachmentVotes || '0'); + const isCritical = (state !== 'inactive') || (impeachmentVotes > 0); + const severity = isCritical ? 'CRITICAL' : 'WARNING'; + + // Recovery copy is state-specific so the operator gets the right hint. + let summaryAction; + let summaryReason; + if (state === 'inactive') { + // v0.5.248 (validator-readiness audit P1) — corrected. Reactivating an + // Inactive CR member is a NODE-KEY-signed ActivateProducer tx + // (activateproducertransaction.go:113/212), so ENM CAN do it with the + // keystore it already holds — no owner/deposit wallet, no Essentials. + // The old copy ("sign from the wallet that holds your deposit, via + // Essentials. ENM cannot do this for you") was wrong on both counts. + summaryAction = 'CR Council member Inactive — reactivate in Node Manager'; + summaryReason = 'Your CR Committee member record reads MemberState=Inactive ' + + '(the chain skipped your DPoS slot for too many consecutive rounds). ' + + 'Node Manager can recover this: open the Validator card and click ' + + '“Reactivate Council node”. The activation is signed with this node’s ' + + 'key (no wallet needed); the node must be running and fully synced.' + + (impeachmentVotes > 0 ? ` Impeachment votes: ${cr.impeachmentVotes} — ` + + 'reactivate before votes pass the impeachment threshold.' : ''); + } else if (state === 'impeached') { + summaryAction = 'CR Council member Impeached — seat lost for this term'; + summaryReason = 'Your CR Committee member record reads MemberState=Impeached. ' + + 'The impeachment vote threshold was reached on-chain; your seat is gone ' + + 'for the rest of this Committee term. Your registration deposit is still ' + + 'yours but Activate is no longer an option. Re-register via Essentials ' + + 'in the next CR election cycle.'; + } else if (state === 'returned') { + summaryAction = 'CR Council member Returned — deposit refundable'; + summaryReason = 'Your CR Committee member record reads MemberState=Returned, ' + + 'which means you voluntarily withdrew from the seat (or the chain ' + + 'returned you after impeachment). Deposit is refundable via Essentials; ' + + 'your DPoS slot is no longer in the arbiter slate.'; + } else { + // Terminated / Illegal / any future MemberState value. + summaryAction = `CR Council member ${cr.state} — investigate via Essentials`; + summaryReason = `Your CR Committee member record reads MemberState=${cr.state}. ` + + 'This is a terminal state for the current term; check Elastos Essentials ' + + 'for the specific cause and next steps.'; + } + + return { + ruleId: 'F28', + tier: HEALING_TIERS.NEVER_AUTOMATIC, + severity, + summaryAction, + summaryReason, + payload: { + action: 'cr-council-investigate', + chainId: snap.chainId, + crState: cr.state, + impeachmentVotes: cr.impeachmentVotes || null, + nickname: cr.nickname || null, + }, + }; +} + /** * Per-rule enable defaults. Per Architectural Invariant #7, healing ships * with F1 (auto-restart on unexpected exit) only. F2-F19 are off until @@ -1037,6 +1269,43 @@ function detectF27(snap) { * actually controls. The tier here MUST match what each detect * function returns; the description is operator-facing copy. */ +/** + * F29 — Council EVM sidechain silently FOLLOWING because producer status was + * UNREADABLE (validator-readiness audit P1-2). When detectProducerRole can't + * read the main-chain arbiter slate (mainchain RPC down / creds undecryptable / + * error), the adapter fail-safes to FOLLOWER (no --mine). On a Council node + * that is genuinely on-duty, that means it quietly stops producing EVM blocks + * and earning — with only a log line. This surfaces it. When the source IS a + * real read (getarbitersinfo / empty-slate), following is correct → stay silent. + * + * Tier: OWNER_CONFIRMS (a notice; recovery = fix mainchain RPC + restart the + * chain, never an auto-action). + */ +function detectF29(snap) { + if (!snap || !snap.minerDecision) return null; + const d = snap.minerDecision; + if (d.setupRole !== 'council') return null; // BPoS sidechains follow by design + if (d.shouldMine !== false) return null; // only when demoted to follower + const CANT_READ = ['no-mainchain-rpc', 'rpc-password-undecryptable', 'error', 'unavailable', 'no-node-pubkey']; + if (!CANT_READ.includes(d.source)) return null; // genuine off-duty read → not this rule + const alive = !!(snap.processStatus && snap.processStatus.alive); + if (!alive) return null; // only meaningful while running + + return { + ruleId: 'F29', + tier: HEALING_TIERS.OWNER_CONFIRMS, + severity: 'WARNING', + summaryAction: `${snap.chainId}: running as follower — couldn’t read producer status`, + summaryReason: + `${snap.chainId} started as a FOLLOWER (not producing) because Node Manager couldn’t read ` + + `your on-chain producer status (${d.source}) — usually the Main chain RPC being unreachable ` + + 'or its credentials undecryptable. If your node is on-duty it is NOT producing this ' + + `sidechain’s blocks or earning. Fix Main chain RPC, then restart ${snap.chainId} so it ` + + 're-checks the arbiter slate and resumes producing.', + payload: { action: 'evm-follower-degraded', chainId: snap.chainId, source: d.source }, + }; +} + const RULE_METADATA = Object.freeze({ F1: { tier: 'AUTOMATED_SAFE', title: 'Auto-restart on crash', description: 'If the chain process exits unexpectedly (non-zero or SIGKILL) and the operator didn’t manually stop it, restart it.' }, @@ -1098,11 +1367,27 @@ const RULE_METADATA = Object.freeze({ F23: { tier: 'CRITICAL_NOTIFY', title: 'Arbiter cross-chain unreachable', description: 'The Arbiter signs multisig payloads across all 4 chains. If any cross-chain RPC becomes unreachable, the Arbiter cannot validate or produce cross-chain signatures for that chain. Operator must investigate the affected chain; alert auto-clears when all 4 RPCs respond.' }, // v0.5.184 — Class B-only. EVM sidechain wedged on a minority fork. - F26: { tier: 'AUTOMATED_SAFE', title: 'Auto-resync wedged EVM fork', - description: 'On an EVM sidechain (ESC/EID/PG) that has been stuck for >20 min while its node log rejects every peer with "retrieved hash chain is invalid", the local chain data has forked off the network and a restart cannot recover it. Auto-resync wipes the chain data (mining keystore preserved) and re-syncs from peers. Rate-limited to once per chain per 24h; a chain that re-forks inside that window escalates to operator confirmation instead of wiping again.' }, + // v0.5.231 — DEMOTED to OWNER_CONFIRMS after a false-positive wiped EID at + // 99.96% synced. Operator now confirms every destructive resync; three + // additional safety gates (near-tip / multi-tick / pre-exec recheck) make + // it close to impossible to propose a wipe on a chain that isn't actually + // forked. See detectF26 docstring + audit log 2026-05-27 17:32:45. + F26: { tier: 'OWNER_CONFIRMS', title: 'Confirm resync of wedged EVM fork', + description: 'On an EVM sidechain (ESC/EID/PG) that has been stuck for >20 min, is FAR from the network tip (>100k blocks behind), and whose node log persistently shows "retrieved hash chain is invalid" across multiple consecutive health checks, the local chain data appears to have forked off the network and a restart cannot recover it. Proposes a destructive resync that wipes the chain data (mining keystore AND network identity/nodekey preserved) and re-syncs from peers. ALWAYS requires operator confirmation — never auto-executes. Just before the wipe runs, conditions are re-verified and the action aborts if the chain has advanced since the proposal was raised. (v0.5.231 hardened after a 2026-05-27 false positive wiped a 99.96%-synced chain.)' }, // v0.5.185 (P1-A) — Class B-only, alert-only. PBFT consensus-recovery stall. F27: { tier: 'CRITICAL_NOTIFY', title: 'EVM consensus-recovery stall', description: 'On an EVM sidechain (ESC/EID/PG) that is stuck for >20 min with peers but a PBFT recovery-stall log signature ("wait for recoved states" / "can not find active peer"), surface a critical alert. This is a quorum / peer problem, not a data fork — an auto-resync cannot fix it, so F26 yields to this alert and the operator restores peers/bootnodes instead.' }, + // v0.5.230 — Class A (mainchain) — alert-only. CR Council MemberState drift. + F28: { tier: 'NEVER_AUTOMATIC', title: 'CR Council member state degraded', + description: 'Parallel to F12 for BPoS producers. Fires when this node\'s CR Committee MemberState is anything other than \'Elected\' — typically Inactive (skipped slots for too many consecutive rounds), Impeached, Returned, Terminated, or Illegal. Inactive is recoverable in-app: the Validator card\'s "Reactivate Council node" button submits a node-key-signed ActivateProducer (no owner key needed). The other states are terminal for the current term and need the operator to investigate via Essentials. Alert-only — ENM never auto-submits the activation (it is rate-limited on-chain), so recovery is always an explicit operator click.' }, + // v0.5.248 (validator-readiness audit P1-2) — Class B-only, Council-only, + // alert-only. EVM sidechain silently fell back to FOLLOWER because the + // adapter couldn't read on-chain producer status (mainchain RPC down / + // creds undecryptable / error). On an on-duty Council node that means it + // quietly stops producing this sidechain's blocks. Surfaces it so the + // operator fixes mainchain RPC + restarts; never auto-acts. + F29: { tier: 'OWNER_CONFIRMS', title: 'EVM sidechain following (producer status unreadable)', + description: 'On a Council node, an EVM sidechain (ESC/EID/PG) started as a FOLLOWER (not producing blocks) because Node Manager could not read your on-chain producer status — usually the Main chain RPC being unreachable or its credentials undecryptable. If your node is on-duty it is NOT producing this sidechain\'s blocks or earning. Recovery is operator-driven: fix Main chain RPC, then restart the sidechain so it re-checks the arbiter slate. ENM never auto-flips mining on (that would forge blocks an off-duty node has no right to).' }, }); // beta.3.22 — every rule is enabled by default. The operator-facing @@ -1139,6 +1424,8 @@ const DEFAULT_ENABLED = Object.freeze({ F23: true, // beta.0.3.14 — Class D arbiter cross-chain unreachable F26: true, // v0.5.184 — Class B wedged-fork auto-resync (rate-limited) F27: true, // v0.5.185 — Class B PBFT recovery-stall alert (alert-only) + F28: true, // v0.5.230 — Class A CR Council MemberState degraded (alert-only) + F29: true, // v0.5.248 — Class B Council EVM silently following (alert-only) }); // Global rule overrides (apply to all chains). Pre-3.87 this was the only @@ -1271,6 +1558,11 @@ function runAll(snap) { ['F24', detectF24], // beta.0.3.14 (Wave M6.5) — Class D arbiter cross-chain. ['F23', detectF23], + // v0.5.230 — Class A CR Council MemberState degraded (F12 sibling). + ['F28', detectF28], + // v0.5.248 (validator-readiness P1-2) — Class B Council EVM silently + // following because producer status was unreadable (alert-only). + ['F29', detectF29], ]; // beta.3.87 — Wave M1.3 — DPoS-only rules. F11 (rotation stuck), @@ -1299,7 +1591,10 @@ function runAll(snap) { // or oracles (Class C) etc., the rule is silently skipped. // v0.5.184/185 — F26 (wedged-fork auto-resync) + F27 (recovery-stall alert) // are EVM-sidechain-only. - const CLASS_B_ONLY_RULES = new Set(['F25', 'F26', 'F27']); + // v0.5.248 — F29 (Council EVM silently following, producer status unreadable) + // is also EVM-sidechain-only; the detector additionally self-gates on + // setupRole==='council' via snap.minerDecision. + const CLASS_B_ONLY_RULES = new Set(['F25', 'F26', 'F27', 'F29']); // beta.0.3.5 (Wave M4.5) — Class C-only rules. F24 fires only for // oracles (esc-oracle/eid-oracle/pg-oracle) where the parent- // chain abstraction exists. @@ -1367,6 +1662,8 @@ module.exports = { detectF23, // beta.0.3.14 (Wave M6.5) detectF26, // v0.5.184 — Class B wedged-fork auto-resync detectF27, // v0.5.185 (P1-A) — Class B PBFT recovery-stall alert + detectF28, // v0.5.230 — Class A CR Council MemberState degraded + detectF29, // v0.5.248 — Class B Council EVM silently following EVM_FORK_STALL_GRACE_MS, SPV_CAUGHTUP_MAX_DELTA, // v0.5.185 (P0-B) PEER_ZERO_GRACE_MS, diff --git a/enm-server/src/services/NativeProcessService.js b/enm-server/src/services/NativeProcessService.js index 826eaf70c5..7a646f5fb6 100644 --- a/enm-server/src/services/NativeProcessService.js +++ b/enm-server/src/services/NativeProcessService.js @@ -558,13 +558,39 @@ class NativeProcessService extends EventEmitter { // stdin, so the post-spawn keystore-password pipe (ela/arbiter) still // reaches the chain. argv is forwarded verbatim ($0=binary, "$@"=args). const NOFILE_SOFT_TARGET = 40960; + // v0.5.230 — stdio: keep stdin as a pipe (ela reads its keystore + // password from stdin per node.sh:878 + the BPoS arbiter mode + // password feed below), but route stdout/stderr to /dev/null + // ('ignore') instead of through ENM's runtime pipes. + // + // Why: every chain binary already writes its own logs via its + // own --log/--logdir flags (ela → chains/mainchain/elastos/logs/ + // node/*.log; geth forks → their own logdir; oracle scripts → + // their stdout was unread anyway). The pre-230 ['pipe', 'pipe', + // 'pipe'] only existed for the stdin password feed; the stdout/ + // stderr pipes back to ENM were never read, but they DID hold an + // FD attached to ENM's process lifecycle. + // + // The consequence pre-230: when ENM exited (deploy SIGTERM, + // crash, OOM, anything), Node closed those pipe FDs. The + // chain's NEXT write to stdout/stderr would deliver SIGPIPE → + // the chain process terminates by default. Net effect: every + // ENM restart killed all 8 child chains, even with detached: + // true. autoStart then respawned them ~60s later. Operator- + // visible as "all chains briefly down on every deploy." + // + // 'ignore' makes the kernel-level fd be /dev/null inside the + // child. The child can write to stdout/stderr forever without + // anyone closing on them — ENM exiting is invisible to the + // child's stdio. Combined with detached:true + child.unref(), + // children are now truly long-lived across ENM lifecycle events. const child = spawn( '/bin/sh', ['-c', `ulimit -n ${NOFILE_SOFT_TARGET} 2>/dev/null; exec "$0" "$@"`, binaryPath, ...spawnArgs], { cwd, env: childEnv, - stdio: ['pipe', 'pipe', 'pipe'], + stdio: ['pipe', 'ignore', 'ignore'], detached: true, }, ); diff --git a/enm-server/src/services/SelfHealingEngine.js b/enm-server/src/services/SelfHealingEngine.js index 287e9ebb75..c670cd4f3c 100644 --- a/enm-server/src/services/SelfHealingEngine.js +++ b/enm-server/src/services/SelfHealingEngine.js @@ -749,6 +749,83 @@ class SelfHealingEngine { return this.processService.restart(chainId, chainConfig); } + /** + * v0.5.231 — Final sanity check before an operator-confirmed evm-fork-resync + * actually destroys chaindata. The OWNER_CONFIRMS path can leave a proposal + * sitting in the dashboard for minutes-to-hours; by the time the operator + * clicks confirm, peers may have re-converged or a slow sync may have caught + * up. We re-poll the chain's RPC and abort the wipe if: + * + * - the local height has advanced ≥ ABORT_PROGRESS_BLOCKS past the + * stuckHeight recorded in the proposal payload (chain is recovering), OR + * - the RPC is unreachable (we cannot confirm the condition still exists, + * so we refuse to wipe; fail safe). + * + * On abort we write an AuditLog row so the dashboard shows why nothing + * happened. Returns `{abort:true, outcome:string}` to abort, or null to + * proceed with the wipe. + * + * @param {object} proposal + * @param {object} payload decoded proposal.payload + * @returns {Promise<{abort:boolean, outcome:string}|null>} + * @private + */ + async _preWipeRecheck(proposal, payload) { + const ABORT_PROGRESS_BLOCKS = 50; + const chainId = proposal.chain_id; + const stuckHeight = (payload && typeof payload.stuckHeight === 'number') + ? payload.stuckHeight : null; + if (stuckHeight === null) { + return null; // legacy proposal with no stuckHeight — proceed as before + } + let currentHeight = null; + try { + const cfg = await this._loadChainConfig(chainId); + const port = cfg && cfg.ports && cfg.ports.rpc; + if (!port) { + throw new Error('no RPC port configured'); + } + const { EthRpcClient } = require('./EthRpcClient'); + const client = new EthRpcClient({ host: '127.0.0.1', port, timeoutMs: 3000 }); + // getBlockNumber returns a parsed Number; throws on RPC error. + currentHeight = await client.getBlockNumber(); + if (!Number.isFinite(currentHeight)) { + throw new Error(`eth_blockNumber returned non-finite: ${currentHeight}`); + } + } catch (err) { + const outcome = `Aborted pre-wipe: RPC unreachable (${err.message}) — refusing to destroy chaindata without confirming the chain is still stuck`; + await this._appendPreWipeAbortAudit(proposal, payload, outcome); + return { abort: true, outcome }; + } + if (currentHeight > stuckHeight + ABORT_PROGRESS_BLOCKS) { + const outcome = `Aborted pre-wipe: chain advanced from stuck height ${stuckHeight} to ${currentHeight} (${currentHeight - stuckHeight} blocks) since the proposal was raised — chain is recovering, no wipe needed`; + await this._appendPreWipeAbortAudit(proposal, payload, outcome); + return { abort: true, outcome }; + } + return null; + } + + /** @private */ + async _appendPreWipeAbortAudit(proposal, payload, outcome) { + try { + const db = this.getDb(); + await AuditLog.append(db, { + walletAddress: proposal.wallet_address || this.ownerWallet, + chainId: proposal.chain_id, + ruleId: proposal.rule_id || 'F26', + tier: HEALING_TIERS.OWNER_CONFIRMS, + decision: AUDIT_DECISION.EXECUTED, // operator did confirm; we declined + executor: 'system', + outcome, + payload: payload || null, + }); + } catch (err) { + this.extensionHandle.log.warn( + `${ENM_LOG_PREFIX} pre-wipe-abort audit append failed (${err.message}) — abort itself succeeded`, + ); + } + } + /** * v0.5.184 — F26 executor. Wipe the forked EVM chaindata (mining keystore * preserved) and re-sync clean from peers, via EnmMaintenanceManager's @@ -1135,6 +1212,20 @@ class SelfHealingEngine { // v0.5.184 — F26 escalated to OWNER_CONFIRMS (the chain // re-forked inside the 24h auto-resync budget) and the // operator confirmed. Perform the wipe + resync now. + // v0.5.231 — pre-execution sanity recheck. The proposal may + // have sat in the dashboard for minutes-to-hours before the + // operator confirmed; the underlying condition can have + // resolved itself in that gap (slow sync caught up, peers + // re-converged, etc.). Before destroying chaindata, re-poll + // the chain's RPC: if the height has advanced past the + // stuckHeight captured at detection time, abort. Better to + // stall an old proposal than wipe a recovered chain. + { + const recheck = await this._preWipeRecheck(proposal, payload); + if (recheck && recheck.abort) { + return { success: false, outcome: recheck.outcome }; + } + } try { await this._executeChainResync(proposal.chain_id); return { success: true, outcome: 'chain-resync (operator-confirmed) complete — re-syncing from peers' }; diff --git a/src/backend/apps/elastos-node-manager/css/styles.css b/src/backend/apps/elastos-node-manager/css/styles.css index 454ee47402..618b451208 100644 --- a/src/backend/apps/elastos-node-manager/css/styles.css +++ b/src/backend/apps/elastos-node-manager/css/styles.css @@ -269,138 +269,70 @@ body { along with .enm-brand-env (was the MAINNET pill, removed 3.69). The PC2 window chrome already shows the app icon + name in its title bar; the duplicate inside the iframe was wasted header space. - See .enm-chain-selector below for the replacement — a smart chain - selector that lives in the same slot. - - Responsive overrides for .enm-brand-name (lines ~720, ~750) were - also dropped — empty selectors on a no-longer-rendered element. */ - -/* beta.3.70 — chain selector. Lives in the topbar slot vacated by - the old brand cluster. Operator's mental model: "what chain am I - looking at?" rather than "what app is this?" (the title bar - handles the latter). For a BPoS-only install (single chain - configured) the selector lists all 7 chain surfaces but only - Main chain is enabled; the rest are grayed with a hover tooltip - + a footer note explaining why. Once a council operator adds more - chains via config, the corresponding options light up. */ -.enm-chain-selector { - position: relative; + v0.5.237 — the chain-selector dropdown that replaced the brand cluster + was itself removed; a static .enm-node-mode label (defined below) now + occupies the same topbar slot, and navigation moved to the overview + + tabs. See .enm-node-mode / .enm-back-to-overview below. */ + +/* v0.5.237 — static node-mode label. Replaces the removed chain-selector + dropdown in the same topbar slot. Non-interactive; PaneRouter + (_detectNodeMode → _applyNodeModeLabel) sets its text from GET /config: + "Council node" (>=2 chains) or "BPoS node" (mainchain only). */ +.enm-node-mode { flex: 0 0 auto; -} -.enm-chain-selector-trigger { display: inline-flex; align-items: center; - gap: var(--sp-1); - padding: 4px 10px 4px 12px; - background: var(--bg-elevated); - color: var(--text-primary); - border: 1px solid var(--border-color, var(--border-subtle)); - border-radius: var(--r-md); - font: inherit; - font-size: var(--fs-caption); - font-weight: var(--fw-medium); - line-height: 1.2; - cursor: pointer; - user-select: none; - transition: background var(--motion-fast) var(--ease-out), - border-color var(--motion-fast) var(--ease-out); -} -.enm-chain-selector-trigger:hover { - background: var(--bg-surface); - border-color: var(--accent); -} -.enm-chain-selector-trigger[aria-expanded="true"] { - background: var(--bg-surface); - border-color: var(--accent); -} -.enm-chain-selector-label { + padding: 4px 10px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--text-secondary); + background: var(--bg-tint); + border: 1px solid var(--border-color); + border-radius: var(--r-pill); white-space: nowrap; } -.enm-chain-selector-caret { - font-size: 10px; - color: var(--text-tertiary); - transition: transform var(--motion-fast) var(--ease-out); -} -.enm-chain-selector-trigger[aria-expanded="true"] .enm-chain-selector-caret { - transform: rotate(180deg); -} -.enm-chain-selector-menu { - position: absolute; - top: calc(100% + var(--sp-1)); - left: 0; - min-width: 280px; - max-width: 360px; - padding: var(--sp-1); - background: var(--bg-elevated); - border: 1px solid var(--border-color, var(--border-subtle)); - border-radius: var(--r-md); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); - z-index: 100; - display: flex; - flex-direction: column; - gap: 1px; -} -.enm-chain-selector-menu[hidden] { display: none; } -.enm-chain-selector-option { - display: flex; +.enm-node-mode:empty { display: none; } +.enm-node-mode[data-mode="council"] { color: var(--accent); } +.enm-app[data-app-size="narrow"] .enm-node-mode, +body[data-app-size="narrow"] .enm-node-mode { padding: 3px 8px; font-size: 11px; } +.enm-app[data-app-size="compact"] .enm-node-mode, +body[data-app-size="compact"] .enm-node-mode { padding: 2px 6px; font-size: 10px; } + +/* v0.5.237 — "Back to overview" control. Rendered at the top of the + Dashboard pane when a Council node drills into a single chain + (PaneRouter._mountDashboardForActiveChain). Never shown for BPoS. */ +.enm-back-to-overview { + display: inline-flex; align-items: center; - gap: var(--sp-2); - padding: var(--sp-1) var(--sp-2); - border-radius: var(--r-xs); - color: var(--text-secondary); - font-size: var(--fs-caption); - cursor: pointer; - border: none; - background: transparent; - width: 100%; - text-align: left; + gap: 6px; + margin: 0 0 var(--sp-3) 0; + padding: 6px 12px; + font-size: 13px; font-family: inherit; -} -.enm-chain-selector-option:hover:not([disabled]) { - background: var(--bg-surface); - color: var(--text-primary); -} -.enm-chain-selector-option[aria-current="true"] { + line-height: 1; color: var(--accent); - font-weight: var(--fw-semibold); -} -.enm-chain-selector-option[disabled] { - opacity: 0.45; - cursor: not-allowed; -} -.enm-chain-selector-option-indicator { - width: 6px; - height: 6px; - border-radius: 50%; - flex: 0 0 auto; - background: var(--text-tertiary); -} -.enm-chain-selector-option[aria-current="true"] .enm-chain-selector-option-indicator { - background: var(--accent); - box-shadow: 0 0 6px var(--accent-soft); -} -.enm-chain-selector-option[disabled] .enm-chain-selector-option-indicator { - background: transparent; - border: 1px dashed var(--text-tertiary); + background: var(--accent-soft); + border: 1px solid var(--border-color); + border-radius: 6px; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease; } -.enm-chain-selector-option-label { - flex: 1 1 auto; +.enm-back-to-overview:hover { + background: var(--bg-elevated); + border-color: var(--accent); } -.enm-chain-selector-option-hint { - font-size: 10px; - color: var(--text-tertiary); - font-weight: var(--fw-regular); +.enm-back-to-overview:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--accent); } -.enm-chain-selector-footer { - margin-top: var(--sp-1); - padding: var(--sp-2); - background: var(--bg-tint); - border-radius: var(--r-xs); - color: var(--text-tertiary); - font-size: 11px; - line-height: 1.45; +.enm-app[data-app-size="compact"] .enm-back-to-overview, +body[data-app-size="compact"] .enm-back-to-overview { + font-size: 12px; + padding: 5px 10px; } + /* Tab strip — horizontally scrollable on overflow with a right-edge fade gradient so the operator sees there's more content past the visible edge. The fade is applied via a sibling overlay because @@ -851,22 +783,6 @@ body[data-app-size="narrow"] .enm-app .enm-topbar { height: auto; min-height: 48px; } -/* beta.3.71 — chain selector compact treatment at narrow. The trigger - was sized for wide-mode (padding 4px 10px 4px 12px, fs-caption ~12px). - At <700px it competes with 4 tabs for ~660px of horizontal space and - forces .enm-tabs to wrap. Shrink trigger footprint + cap the label - to keep tabs on one row whenever the viewport allows it. */ -.enm-app[data-app-size="narrow"] .enm-chain-selector-trigger, -body[data-app-size="narrow"] .enm-app .enm-chain-selector-trigger { - padding: 3px 8px 3px 10px; - font-size: 11px; -} -.enm-app[data-app-size="narrow"] .enm-chain-selector-label, -body[data-app-size="narrow"] .enm-app .enm-chain-selector-label { - max-width: 96px; - overflow: hidden; - text-overflow: ellipsis; -} /* beta.3.26 — Phase A. The wide-mode .enm-tabs / .enm-settings-pills strips horizontally-scroll any overflowing items behind a mask- fade. The whole shape relies on touch swiping past the fade to @@ -924,33 +840,6 @@ body[data-app-size="compact"] .enm-app .enm-topbar { height: auto; min-height: 44px; } -/* beta.3.71 — chain selector compact treatment. At <480px the - selector strips down to a minimal chip: tiny padding, 10px font, - label capped to ~60px. The dropdown menu opens with full chain - names so the trigger only needs to be a visible affordance, not - the canonical chain label. aria-label="Chain view selector" on - the trigger carries the meaning for screen readers. */ -.enm-app[data-app-size="compact"] .enm-chain-selector-trigger, -body[data-app-size="compact"] .enm-app .enm-chain-selector-trigger { - padding: 2px 6px 2px 8px; - font-size: 10px; - gap: 4px; -} -.enm-app[data-app-size="compact"] .enm-chain-selector-label, -body[data-app-size="compact"] .enm-app .enm-chain-selector-label { - max-width: 64px; - overflow: hidden; - text-overflow: ellipsis; -} -/* beta.3.71 — cap the dropdown menu width so it never escapes the - viewport on compact. Without this, the 280-360px menu would clip - off the right edge inside a 280px PC2 window. */ -.enm-app[data-app-size="compact"] .enm-chain-selector-menu, -body[data-app-size="compact"] .enm-app .enm-chain-selector-menu { - min-width: 0; - width: calc(100vw - 16px); - max-width: 320px; -} .enm-app[data-app-size="compact"] .enm-tab, body[data-app-size="compact"] .enm-app .enm-tab { @@ -1034,6 +923,16 @@ body[data-app-size="compact"] .enm-app .enm-main { padding: var(--sp-2); } gap: var(--sp-4); padding-bottom: var(--sp-4); /* breathing room below last card */ } +/* v0.5.242 — the dashboard pane is a flex column; its children default to + flex-shrink:1, so when the stacked cards are taller than the pane (common + on a drilled-in EVM chain — hero + Mining&rewards + system-status — and at + narrow widths where text wraps taller) flexbox SHRINKS the cards to fit + instead of letting the pane's overflow:auto scroll. .enm-section-card has + overflow:hidden, so the shrunk card CLIPS its content (the EVM account row + + footer vanished until the operator enlarged the window, which gave the pane + enough height that no shrink was needed). flex-shrink:0 makes every card keep + its natural height so the pane scrolls. The flex column (for `gap`) stays. */ +#enm-pane-dashboard > * { flex-shrink: 0; } /* ---- Chain card (hero) ---------------------------------------- */ .enm-chain-card { @@ -1882,6 +1781,18 @@ body[data-app-size="compact"] .enm-sys-cell:last-child, font-weight: var(--fw-semibold); padding: var(--sp-2) var(--sp-3); } +/* v0.5.245 (BL-2) — group subheaders inside the wide nav rail. Lighter than + the rail title; spacing (not a heavy border) separates each group so the + rail reads as a short outline. Decorative (aria-hidden in JS). */ +.enm-settings-nav-group { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-tertiary); + font-weight: var(--fw-semibold); + padding: 0 var(--sp-3) 4px; + margin-top: var(--sp-4); +} .enm-settings-nav-item { display: flex; align-items: center; @@ -2157,6 +2068,7 @@ body[data-app-size="compact"] .enm-sys-cell:last-child, min-width: 0; } .enm-detail-copy-slot { flex: 0 0 auto; } + /* Compact: stack the horizontal stat rows (label over value) so long values aren't crushed against the label on a 320px screen. Address rows are already stacked. */ @@ -3253,6 +3165,25 @@ body[data-app-size="compact"] .enm-section-card-foot .enm-btn, background: var(--success); } +/* v0.5.237 — in-pane chain picker (replaces the static pill). Lets Logs + scope to any installed chain now that the top chain-selector is gone. */ +.enm-log-chain-select { + flex: 0 0 auto; + max-width: 180px; + padding: 4px 8px; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--r-pill); + font-size: var(--fs-caption); + font-family: inherit; + color: var(--text-secondary); + cursor: pointer; +} +.enm-log-chain-select:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--accent); +} + .enm-log-search { position: relative; flex: 1; @@ -4567,6 +4498,60 @@ body[data-app-size="compact"] .enm-role-card-badge-short, line-height: 1.55; margin: 0; } + +/* ---------------------------------------------------------------- + * v0.5.229 — Council member info grid. + * + * Used by validator-registration-card's _renderCouncil() variant to + * show the CR Committee member's metadata (nickname, state, index, CID, + * impeachment votes) under the head. Mirrors the existing BPoS info + * surfaces in tone (subtle background tiles in a 2-column grid) but + * uses Council vocabulary. Hidden when crMember is null (operator is + * Council install but not currently bound to a Committee seat — the + * "unclaimed" / "no-term" sub-states don't have member metadata to + * surface). + * ---------------------------------------------------------------- */ +.enm-council-info-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--sp-2); + padding: var(--sp-3) 0; +} +.enm-council-info-cell { + background: var(--bg-elevated); + border: 1px solid var(--border-subtle); + border-radius: var(--r-sm); + padding: var(--sp-2) var(--sp-3); + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +.enm-council-info-cell-wide { + grid-column: 1 / -1; +} +.enm-council-info-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-tertiary); + font-weight: var(--fw-semibold); +} +.enm-council-info-value { + font-size: var(--fs-caption); + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.enm-council-info-value-mono { + font-family: var(--font-mono); + font-size: 11px; +} +.enm-app[data-app-size="narrow"] .enm-council-info-grid, +.enm-app[data-app-size="compact"] .enm-council-info-grid { + grid-template-columns: 1fr; +} .enm-bpos-cta-row { display: flex; gap: var(--sp-2); @@ -4791,6 +4776,152 @@ body[data-app-size="compact"] .enm-role-card-badge-short, .enm-identity-row-actionable { border-left: 3px solid var(--accent); } + +/* ---------------------------------------------------------------- + * v0.5.228 — Public key vs signing address visual hierarchy. + * + * Operator directive 2026-05-26: "we need the public address more + * than the wallet address". The public key (NodePublicKey, 66 hex + * compressed pubkey) is what stakers vote on, what gets pasted into + * Essentials, what appears on every explorer. The signing address + * is operationally internal — useful for debugging but never share- + * worthy. These modifier classes drive a ~3:1 visual weight ratio + * in favor of the pubkey across BOTH surfaces (the dashboard + * NodeIdentityCard and Settings → Identity → Current identity). + * ---------------------------------------------------------------- */ + +/* --- Dashboard NodeIdentityCard ---------------------------------- */ +.enm-identity-row--primary { + /* Stronger accent ring + slight tint so the eye lands here first. */ + border-left-width: 4px; + background: linear-gradient( + to right, + var(--accent-soft, var(--bg-overlay)) 0%, + var(--bg-elevated) 60% + ); + padding: var(--sp-4) var(--sp-4); +} +.enm-identity-row--primary .enm-identity-value { + /* Bigger, easier-to-read pubkey value. The 66-hex pubkey is the + * thing operators actually copy-paste; previously it rendered at + * the same 12px as the internal signing address. */ + font-size: 15px; + line-height: 1.6; + padding: var(--sp-3) var(--sp-3); + color: var(--text-primary); + background: var(--bg-input); + border-color: var(--accent-soft, var(--border-color)); +} +.enm-identity-row--primary .enm-identity-row-label { + /* Slightly bigger label too so the row reads as a unit. */ + font-size: 11px; + color: var(--accent, var(--text-secondary)); +} +.enm-identity-row--primary .enm-identity-copy-btn { + /* Make the primary copy button feel like a primary action. */ + font-size: 13px; + padding: 8px 14px; + background: var(--accent); + color: var(--accent-on, white); + border-color: var(--accent); +} +.enm-identity-row--primary .enm-identity-copy-btn:hover { + filter: brightness(1.08); +} + +.enm-identity-row--secondary { + /* Demote the signing address: compact, muted, smaller value font. */ + padding: var(--sp-2) var(--sp-3); + opacity: 0.92; +} +.enm-identity-row--secondary .enm-identity-row-label { + font-size: 9px; +} +.enm-identity-row--secondary .enm-identity-row-hint { + font-size: 11px; +} +.enm-identity-row--secondary .enm-identity-value { + /* ~73% of the primary value's font size. Still readable on its own + * if the operator looks at it, but obviously secondary. */ + font-size: 11px; + color: var(--text-secondary); + padding: 6px 10px; +} +.enm-identity-row--secondary .enm-identity-copy-btn { + font-size: 11px; + padding: 4px 10px; +} + +/* --- Settings → Identity → Current identity grid ----------------- */ +.enm-identity-grid-row--primary { + /* Same accent treatment as the dashboard pubkey row. The grid + * still uses the existing 160px / 1fr / auto template but the + * primary row gets a tint band + bigger value cell. */ + background: linear-gradient( + to right, + var(--accent-soft, var(--bg-overlay)) 0%, + transparent 50% + ); + border-left: 3px solid var(--accent); + border-radius: var(--r-sm); + padding: var(--sp-3) var(--sp-3); + margin-left: calc(-1 * var(--sp-3)); +} +.enm-identity-grid-row--primary .enm-identity-grid-label { + color: var(--accent, var(--text-secondary)); + font-size: 11px; +} +.enm-identity-grid-row--primary .enm-identity-grid-value { + font-size: 14px; + font-weight: var(--fw-medium); + padding: var(--sp-3) var(--sp-3); + color: var(--text-primary); + border-color: var(--accent-soft, var(--border-color)); +} + +.enm-identity-grid-row--secondary { + /* Compact + muted; sit closer to the producer row below to read + * as "technical details". */ + padding: var(--sp-1) 0; + opacity: 0.85; +} +.enm-identity-grid-row--secondary .enm-identity-grid-label { + font-size: 9px; +} +.enm-identity-grid-row--secondary .enm-identity-grid-value { + font-size: 11px; + color: var(--text-secondary); + padding: 6px 10px; +} + +/* Pill marker for the grid label cell. The primary "Share with + * Essentials" pill picks up the accent; the neutral "Internal" pill + * stays muted. Drops below the label text on narrow viewports so the + * grid template doesn't fight for inline space. */ +.enm-identity-grid-label { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; +} +.enm-identity-grid-pill { + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.04em; + font-weight: var(--fw-semibold); + padding: 1px 6px; + border-radius: var(--r-pill, 999px); + background: var(--bg-overlay); + color: var(--text-tertiary); + border: 1px solid var(--border-subtle); + line-height: 1.4; + white-space: nowrap; +} +.enm-identity-grid-pill-action { + background: var(--accent-soft); + color: var(--accent); + border-color: var(--accent-soft); +} .enm-identity-row-hint { font-size: var(--fs-caption); color: var(--text-secondary); @@ -5723,24 +5854,75 @@ body[data-app-size="compact"] .enm-bpos-head-chip, flex: 1 1 100%; } -/* beta.3.78 — Phase 7 snapshot CSS removed with the UI panel. - .enm-snapshot-host / -grid / -row / -label / -value / -help / - -actions all dropped. The compact-viewport overrides and inventory - list rules below are also gone — see the responsive override - block(s) at the bottom of this file. */ - -/* ==================================================================== - * beta.3.72 — compact + narrow viewport overhaul. - * - * 3.71 fixed the topbar regression from 3.70 but every pane still had - * piecemeal compact treatment that left layouts overflowing or jammed - * at iPhone-class widths (<480px). This block is a single, source- - * ordered-last override layer that owns small-screen behavior for: - * - * - shared chrome: section cards, form rows, inputs, chip inputs, - * secret fields, modals - * - Dashboard: stats grids, BPoS grid, identity grid, hero slot - * - Logs: line grid, toolbar, search, meta, chips +/* ---------------------------------------------------------------- + * v0.5.228 — Staged chain resume toggle inside Danger Zone. + * Native + label + subtitle, matching the + * spacing/typography of the existing typed-confirm rows so it sits + * naturally inside .enm-danger-card-body. Controls row below is + * hidden until the toggle is on; uses the same button gap as + * .enm-danger-card-foot. + * ---------------------------------------------------------------- */ +.enm-danger-toggle-row { + display: flex; + flex-direction: column; + gap: var(--sp-1); + padding: var(--sp-2) 0; +} +.enm-danger-toggle-label { + display: inline-flex; + align-items: center; + gap: var(--sp-2); + font-size: var(--fs-body); + color: var(--text-primary); + cursor: pointer; +} +.enm-danger-toggle-input { + width: 16px; + height: 16px; + margin: 0; + accent-color: var(--danger); + cursor: pointer; + flex: 0 0 auto; +} +.enm-danger-toggle-text { + font-weight: 500; + line-height: 1.3; +} +.enm-danger-toggle-sub { + font-size: var(--fs-caption); + color: var(--text-tertiary); + padding-left: calc(16px + var(--sp-2)); + line-height: 1.4; +} +.enm-danger-stage-controls { + display: flex; + flex-wrap: wrap; + gap: var(--sp-2); + margin-top: var(--sp-2); +} +.enm-app[data-app-size="narrow"] .enm-danger-stage-controls .enm-btn, +.enm-app[data-app-size="compact"] .enm-danger-stage-controls .enm-btn { + flex: 1 1 100%; +} + +/* beta.3.78 — Phase 7 snapshot CSS removed with the UI panel. + .enm-snapshot-host / -grid / -row / -label / -value / -help / + -actions all dropped. The compact-viewport overrides and inventory + list rules below are also gone — see the responsive override + block(s) at the bottom of this file. */ + +/* ==================================================================== + * beta.3.72 — compact + narrow viewport overhaul. + * + * 3.71 fixed the topbar regression from 3.70 but every pane still had + * piecemeal compact treatment that left layouts overflowing or jammed + * at iPhone-class widths (<480px). This block is a single, source- + * ordered-last override layer that owns small-screen behavior for: + * + * - shared chrome: section cards, form rows, inputs, chip inputs, + * secret fields, modals + * - Dashboard: stats grids, BPoS grid, identity grid, hero slot + * - Logs: line grid, toolbar, search, meta, chips * - Settings: pills nav, healing rules + activity, snapshot grid * - Activity: filters, table cells, drawer head/kv/payload/foot * @@ -6184,8 +6366,8 @@ body[data-app-size="compact"] .enm-app .enm-topbar { min-height: 44px; height: auto; } -.enm-app[data-app-size="compact"] .enm-chain-selector, -body[data-app-size="compact"] .enm-app .enm-chain-selector { +.enm-app[data-app-size="compact"] .enm-node-mode, +body[data-app-size="compact"] .enm-app .enm-node-mode { grid-area: selector; } .enm-app[data-app-size="compact"] .enm-tabs, @@ -6395,8 +6577,8 @@ body[data-app-size="compact"] .enm-app .enm-topbar { "selector" "tabs"; } -.enm-app[data-app-size="compact"] .enm-chain-selector, -body[data-app-size="compact"] .enm-app .enm-chain-selector { +.enm-app[data-app-size="compact"] .enm-node-mode, +body[data-app-size="compact"] .enm-app .enm-node-mode { justify-self: start; } @@ -6741,10 +6923,12 @@ body[data-app-size="compact"] .enm-app .enm-filter-chip { } /* Overview mode body hook (set by PaneRouter._enterOverviewMode / - _exitOverviewMode via document.body.dataset.enmOverview). */ -body[data-enm-overview="1"] .enm-app .enm-tabs { - display: none; -} + _exitOverviewMode via document.body.dataset.enmOverview). + v0.5.237 — the tab strip is NO LONGER hidden in overview mode. The + multi-chain overview is now the default Dashboard-pane content for a + Council node with the tabs always visible, so the old + `body[data-enm-overview="1"] .enm-app .enm-tabs { display:none }` rule + was removed. The body hook is kept for overview-specific styling. */ /* v0.5.186 (Council Node UX P1.4) — caption that marks the EVM-tab feature grid as roadmap (not live). On-palette tokens only. The feature grid itself @@ -7129,6 +7313,70 @@ body[data-enm-overview="1"] .enm-app .enm-tabs { font-size: 13px; color: var(--text-tertiary); } +/* v0.5.238 — "This node" identity card: DAO Council + BPoS status. */ +.enm-app .enm-overview-identity { + margin: 0 0 var(--sp-4, 16px) 0; + padding: var(--sp-3, 12px) var(--sp-4, 16px); + background: var(--bg-elevated); + border: 1px solid var(--border-color, var(--border-subtle)); + border-radius: 10px; +} +.enm-app .enm-identity-head { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-tertiary); + margin-bottom: var(--sp-2, 8px); +} +.enm-app .enm-identity-pills { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--sp-2, 8px) var(--sp-5, 24px); +} +.enm-app .enm-identity-pill { + display: flex; + align-items: baseline; + gap: var(--sp-2, 8px); + min-width: 0; +} +.enm-app .enm-identity-pill-label { + flex: 0 0 auto; + font-size: 12px; + color: var(--text-tertiary); +} +.enm-app .enm-identity-pill-val { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} +.enm-app .enm-identity-dot { + flex: 0 0 auto; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--text-tertiary); +} +.enm-app .enm-identity-dot.state-synced { background: var(--success); } +.enm-app .enm-identity-dot.state-syncing { background: var(--warning, #c08a00); } +.enm-app .enm-identity-dot.state-unconfigured { background: var(--text-tertiary); opacity: 0.5; } +.enm-app .enm-identity-keys { + margin-top: var(--sp-2, 8px); + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px var(--sp-3, 12px); + font-size: 12px; + color: var(--text-tertiary); +} +.enm-app .enm-identity-kv-k { margin-right: 2px; color: var(--text-tertiary); } +.enm-app .enm-identity-keys .enm-mono { color: var(--text-secondary); } +.enm-app .enm-identity-sep { opacity: 0.5; } .enm-app .enm-overview-body { display: flex; flex-direction: column; @@ -7162,8 +7410,14 @@ body[data-enm-overview="1"] .enm-app .enm-tabs { truth (height + sync badge for A/B; "relays for " for C) under its name; the actions column carries state-gated start/stop/restart. */ .enm-app .enm-overview-row { - display: grid; - grid-template-columns: 16px minmax(160px, 1fr) 90px 56px auto 16px; + /* v0.5.238 — flex (was a fixed 6-column grid). The grid's per-variant + column counts didn't match the rendered cells — the EVM variant + declared 5 columns for 6 cells, so the action buttons were crammed into + a 14px track and collided with the sparkline (the "dark squares" bug). + Flex lets the main column grow while the right-side cluster (sparkline · + uptime · actions · arrow) sizes to content and never collides, at any + width. The variant grid-template-columns rules below are now inert. */ + display: flex; align-items: center; gap: var(--sp-3, 12px); padding: var(--sp-2, 8px) var(--sp-3, 12px); @@ -7179,6 +7433,16 @@ body[data-enm-overview="1"] .enm-app .enm-tabs { flex-direction: column; gap: 2px; min-width: 0; + flex: 1 1 auto; +} +/* v0.5.238 — flex sizing for the row children: main grows/shrinks (its + min-width:0 lets long text ellipsize), while the right-side cluster holds + its natural size so the action buttons never get squashed. */ +.enm-app .enm-overview-dot, +.enm-app .enm-overview-spark, +.enm-app .enm-overview-uptime, +.enm-app .enm-overview-open { + flex: 0 0 auto; } .enm-app .enm-overview-line1 { display: flex; @@ -7314,49 +7578,121 @@ body[data-enm-overview="1"] .enm-app .enm-tabs { .enm-app .enm-overview-usage { display: grid; grid-template-columns: repeat(4, 1fr); - gap: var(--sp-3, 12px); - margin: 0 0 var(--sp-4, 16px) 0; + gap: var(--sp-2, 8px); + margin: 0 0 var(--sp-3, 12px) 0; } +/* v0.5.228 — usage cards were 22px values with 12px padding × 4 columns, + * which dominated the top of the overview and crowded out the chain + * cards below. Operator directive 2026-05-27 ("these cards need to be + * small or averaged for better view in smaller screens"): tighten the + * default density and stage responsive variants that compact further + * on each smaller breakpoint, culminating in a single horizontal pill + * bar on the narrowest viewports. */ .enm-app .enm-usage-card { background: var(--bg-elevated); border: 1px solid var(--border-color, var(--border-subtle)); - border-radius: var(--r-md, 8px); - padding: var(--sp-3, 12px) var(--sp-3, 12px); + border-radius: var(--r-sm, 6px); + padding: 8px 10px; display: flex; flex-direction: column; - gap: 2px; + gap: 1px; min-width: 0; } .enm-app .enm-usage-card-title { - font-size: 11px; + font-size: 10px; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.04em; font-weight: 600; + line-height: 1.3; } .enm-app .enm-usage-card-value { - font-size: 22px; - font-weight: 700; + font-size: 16px; + font-weight: 600; color: var(--text-primary); - line-height: 1.1; + line-height: 1.2; font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .enm-app .enm-usage-card-sub { - font-size: 11px; + font-size: 10px; color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + line-height: 1.4; } /* Tone each card with a faint left-rule in its category color so they're - scannable without reading labels. */ -.enm-app .enm-usage-card-chains { border-left: 3px solid var(--success); } -.enm-app .enm-usage-card-cpu { border-left: 3px solid var(--accent, #4cb8ff); } -.enm-app .enm-usage-card-mem { border-left: 3px solid var(--warning); } -.enm-app .enm-usage-card-disk { border-left: 3px solid var(--text-tertiary); } + scannable without reading labels. 2px instead of 3px to match the + tighter padding. */ +.enm-app .enm-usage-card-chains { border-left: 2px solid var(--success); } +.enm-app .enm-usage-card-cpu { border-left: 2px solid var(--accent, #4cb8ff); } +.enm-app .enm-usage-card-mem { border-left: 2px solid var(--warning); } +.enm-app .enm-usage-card-disk { border-left: 2px solid var(--text-tertiary); } + +/* --- Responsive: medium viewport (700-999) ------------------------- */ +/* Stay at 4 columns but drop one font tier so the card holds long + * values like "154.7 / 386.43 GB" without ellipsing. */ +.enm-app[data-app-size="medium"] .enm-overview-usage { + gap: 6px; +} +.enm-app[data-app-size="medium"] .enm-usage-card { + padding: 6px 8px; +} +.enm-app[data-app-size="medium"] .enm-usage-card-value { + font-size: 14px; +} + +/* --- Responsive: narrow viewport (<700) ---------------------------- */ +/* Drop to a 2x2 grid so each card has room for full subtitle on its + * own line, but the whole row is still 2 cards wide (scannable). */ +.enm-app[data-app-size="narrow"] .enm-overview-usage, +body[data-app-size="narrow"] .enm-app .enm-overview-usage { + grid-template-columns: repeat(2, 1fr); + gap: 6px; +} +.enm-app[data-app-size="narrow"] .enm-usage-card, +body[data-app-size="narrow"] .enm-app .enm-usage-card { + padding: 6px 8px; +} +.enm-app[data-app-size="narrow"] .enm-usage-card-value, +body[data-app-size="narrow"] .enm-app .enm-usage-card-value { + font-size: 14px; +} + +/* --- Responsive: compact viewport (<480, the smallest tier) -------- */ +/* Single-row stats bar — hide subtitle, drop card chrome to a flat + * horizontal pill row. Operator gets the at-a-glance numbers without + * the 4-card real-estate cost. */ +.enm-app[data-app-size="compact"] .enm-overview-usage, +body[data-app-size="compact"] .enm-app .enm-overview-usage { + grid-template-columns: repeat(4, 1fr); + gap: 4px; + margin-bottom: var(--sp-2, 8px); +} +.enm-app[data-app-size="compact"] .enm-usage-card, +body[data-app-size="compact"] .enm-app .enm-usage-card { + padding: 4px 6px; + border-radius: 4px; + /* Drop the bottom subtitle row by reducing internal gap and + * hiding the sub element below. */ + gap: 0; +} +.enm-app[data-app-size="compact"] .enm-usage-card-title, +body[data-app-size="compact"] .enm-app .enm-usage-card-title { + font-size: 9px; +} +.enm-app[data-app-size="compact"] .enm-usage-card-value, +body[data-app-size="compact"] .enm-app .enm-usage-card-value { + font-size: 12px; + font-weight: 600; +} +.enm-app[data-app-size="compact"] .enm-usage-card-sub, +body[data-app-size="compact"] .enm-app .enm-usage-card-sub { + display: none; /* hide the subtitle entirely at this size */ +} /* Per-row metrics line (3rd row under each chain). Inline chips, wrap on narrow. The chip background is intentionally subtle — the state pill is @@ -7548,54 +7884,12 @@ body[data-app-size="compact"] .enm-app .enm-usage-card-value, color: var(--error); background: color-mix(in srgb, var(--error) 16%, transparent); } -/* v0.5.186 (P2.2) — compact quick actions. Always visible (discoverable on - touch), muted by default, colorize on hover/focus per action. Buttons - intercept the row click in JS so acting never also navigates. Properties - follow the project's button rule: inline-flex, centered, line-height 1, - font-family inherit. */ -.enm-app .enm-overview-actions { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 4px; -} -.enm-app .enm-overview-action { - display: inline-flex; - align-items: center; - justify-content: center; - width: 26px; - height: 26px; - padding: 0; - font-size: 12px; - line-height: 1; - font-family: inherit; - border: 1px solid var(--border-color, var(--border-subtle)); - border-radius: 6px; - background: var(--bg-page); - color: var(--text-tertiary); - cursor: pointer; - transition: color 120ms, border-color 120ms, background-color 120ms, opacity 120ms; -} -.enm-app .enm-overview-action:hover, -.enm-app .enm-overview-action:focus-visible { - outline: none; - border-color: currentColor; -} -.enm-app .enm-overview-action:focus-visible { - box-shadow: 0 0 0 2px rgba(80, 160, 240, 0.4); -} -.enm-app .enm-overview-action.is-start:hover, -.enm-app .enm-overview-action.is-start:focus-visible { color: var(--success); } -.enm-app .enm-overview-action.is-stop:hover, -.enm-app .enm-overview-action.is-stop:focus-visible { color: var(--error); } -.enm-app .enm-overview-action.is-restart:hover, -.enm-app .enm-overview-action.is-restart:focus-visible { color: var(--accent); } -.enm-app .enm-overview-action.is-busy { - opacity: 0.5; - cursor: default; - animation: enm-overview-pulse 1.4s ease-in-out infinite; -} -.enm-app .enm-overview-action[disabled] { cursor: default; } +/* v0.5.242 — the legacy .enm-overview-actions / .enm-overview-action + icon-button rules (26×26 squares from the pre-v0.5.239 flat-row design) + were removed here. The overview's per-chain actions are now solely the + labelled .enm-ovx-act buttons (defined above); they no longer carry the + .enm-overview-action class, which closes the dual-class footgun whose + leaked width:26px caused the v0.5.241 Restart/Stop overlap. */ .enm-app .enm-overview-uptime { font-size: 12px; color: var(--text-tertiary); @@ -7663,6 +7957,513 @@ body[data-app-size="compact"] .enm-app .enm-usage-card-value, } .enm-app .enm-overview-empty p:last-child { margin-bottom: 0; } +/* ==================================================================== + * v0.5.228 — Validator status badge (per-chain Class B settings). + * + * Replaces the pre-228 writable "Mining on/off" toggle in the per-chain + * EVM settings form (_renderClassBForm). The backend already derives + * mining state from on-chain arbiter slate at every chain start + * (EvmSidechainAdapter.detectProducerRole + start, since v0.5.188); the + * old toggle was visible-but-ineffectual. This badge surfaces the + * derived state honestly with a state-keyed color, mirroring the chain- + * card state-dot palette so the operator's mental model carries over. + * ==================================================================== */ +.enm-classb-validator-row { + background: var(--bg-elevated); + border: 1px solid var(--border-subtle); + border-radius: var(--r-md); + padding: var(--sp-3) var(--sp-4); + margin-bottom: var(--sp-3); +} +.enm-classb-validator-head { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: var(--sp-2); +} +.enm-classb-validator-label { + font-size: 11px; + font-weight: var(--fw-semibold); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-tertiary); +} +.enm-classb-validator-help { + font-size: var(--fs-caption); + color: var(--text-secondary); + line-height: 1.5; +} +.enm-classb-validator-body { + display: flex; + flex-direction: column; + gap: var(--sp-1); +} +.enm-classb-validator-badge { + align-self: flex-start; + font-size: 12px; + font-weight: var(--fw-semibold); + line-height: 1; + padding: 6px 12px; + border-radius: var(--r-pill, 999px); + border: 1px solid var(--border-subtle); + background: var(--bg-overlay); + color: var(--text-tertiary); + text-transform: none; + letter-spacing: 0; +} +.enm-classb-validator-badge[data-state="on-duty"] { + background: rgba(76, 175, 80, 0.15); + color: var(--success); + border-color: rgba(76, 175, 80, 0.35); +} +.enm-classb-validator-badge[data-state="standby"] { + background: rgba(255, 167, 38, 0.15); + color: var(--warning, #c08a00); + border-color: rgba(255, 167, 38, 0.35); +} +.enm-classb-validator-badge[data-state="inactive"], +.enm-classb-validator-badge[data-state="follower"], +.enm-classb-validator-badge[data-state="unknown"], +.enm-classb-validator-badge[data-state="loading"] { + /* Default neutral palette set on the base rule covers these. */ +} +.enm-classb-validator-sub { + font-size: var(--fs-caption); + color: var(--text-secondary); + line-height: 1.5; + margin-top: 4px; +} + +/* ==================================================================== + * v0.5.228 — Settings → EVM chains (shared) section. + * Operator directive 2026-05-27 ("the multi EVM shared settings for + * all services isn't there either"). Mirrors the existing Danger Zone + * card layout so the rows look at home next to it. Mining-state grid + * is bespoke (per-chain pill triplet); reward + sync rows are the + * usual input + Apply + status pattern. + * ==================================================================== */ +.enm-evm-shared-input-row { + display: flex; + align-items: center; + gap: var(--sp-2); + width: 100%; +} +.enm-evm-shared-input { + flex: 1 1 auto; + min-width: 0; +} +.enm-evm-shared-select { + flex: 0 0 auto; + min-width: 140px; +} +.enm-evm-shared-status { + min-height: 1.2em; + font-size: var(--fs-caption); + color: var(--text-tertiary); + line-height: 1.5; + margin-top: var(--sp-2); +} +.enm-evm-shared-status.is-ok { color: var(--success); } +.enm-evm-shared-status.is-warn { color: var(--warning, #c08a00); } +.enm-evm-shared-status.is-err { color: var(--danger); } + +.enm-evm-shared-mining-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--sp-2); +} +.enm-evm-shared-mining-cell { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + padding: var(--sp-2) var(--sp-3); + background: var(--bg-input); + border: 1px solid var(--border-subtle); + border-radius: var(--r-sm); +} +.enm-evm-shared-mining-name { + font-family: var(--font-mono); + font-size: var(--fs-caption); + color: var(--text-secondary); +} +.enm-evm-shared-mining-pill { + font-size: 11px; + font-weight: var(--fw-semibold); + padding: 2px 8px; + border-radius: var(--r-pill, 999px); + background: var(--bg-overlay); + color: var(--text-tertiary); + border: 1px solid var(--border-subtle); + text-transform: uppercase; + letter-spacing: 0.04em; +} +/* v0.5.228d (audit follow-up) — extend pill palette to the 5-state + * validator taxonomy. Old [data-mining="on"|"off"] selectors stay so + * pre-228d on-disk renders don't lose color. New states: + * on-duty → success green (matches the [data-state="on-duty"] badge + * in the per-chain Validator-status row) + * standby → warning amber (in next rotation; not currently producing) + * ? → neutral muted (detecting / chain unreachable) + * "off" stays as the inactive/follower neutral color. */ +.enm-evm-shared-mining-pill[data-mining="on"], +.enm-evm-shared-mining-pill[data-mining="on-duty"] { + background: rgba(76, 175, 80, 0.15); + color: var(--success); + border-color: rgba(76, 175, 80, 0.35); +} +.enm-evm-shared-mining-pill[data-mining="standby"] { + background: rgba(255, 167, 38, 0.15); + color: var(--warning, #c08a00); + border-color: rgba(255, 167, 38, 0.35); +} +.enm-evm-shared-mining-pill[data-mining="off"], +.enm-evm-shared-mining-pill[data-mining="?"] { + background: var(--bg-overlay); + color: var(--text-tertiary); +} +.enm-evm-shared-footer-note { + margin-top: var(--sp-2); + font-size: var(--fs-caption); + color: var(--text-tertiary); + line-height: 1.5; +} +/* v0.5.237 — per-chain peers/bootnodes accordion inside the consolidated + Sidechain settings tab. One

per EVM (esc/eid/pg); the chain's + EnmPeersPanel is lazily mounted on first open. Full-width + vertical, so + it stays usable down to very small widths. */ +.enm-evm-shared-peers-accordion { + border: 1px solid var(--border-color); + border-radius: 8px; + margin-top: var(--sp-2); + background: var(--bg-overlay); + overflow: hidden; +} +.enm-evm-shared-peers-summary { + cursor: pointer; + padding: var(--sp-2) var(--sp-3); + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + list-style: none; + user-select: none; +} +.enm-evm-shared-peers-summary::-webkit-details-marker { display: none; } +.enm-evm-shared-peers-summary::before { + content: '▸'; + display: inline-block; + margin-right: 8px; + color: var(--text-tertiary); + transition: transform 0.15s ease; +} +.enm-evm-shared-peers-accordion[open] > .enm-evm-shared-peers-summary::before { + transform: rotate(90deg); +} +.enm-evm-shared-peers-summary:hover { color: var(--accent); } +.enm-evm-shared-peers-summary:focus-visible { + outline: none; + box-shadow: inset 0 0 0 2px var(--accent); +} +.enm-evm-shared-peers-mount { + padding: 0 var(--sp-3) var(--sp-2); +} +.enm-app[data-app-size="compact"] .enm-evm-shared-mining-grid, +.enm-app[data-app-size="narrow"] .enm-evm-shared-mining-grid, +body[data-app-size="compact"] .enm-app .enm-evm-shared-mining-grid, +body[data-app-size="narrow"] .enm-app .enm-evm-shared-mining-grid { + grid-template-columns: 1fr; +} +.enm-app[data-app-size="compact"] .enm-evm-shared-input-row, +.enm-app[data-app-size="narrow"] .enm-evm-shared-input-row { + flex-direction: column; + align-items: stretch; +} + +/* ==================================================================== + * v0.5.228 — Multi-chain overview visual hierarchy. + * + * Operator directive 2026-05-27 ("multi chain looks very ugly honestly"). + * Pre-228 every chain rendered as an identical flat row inside one of + * four class sections — mainchain looked the same as an oracle, and + * there was no visual link between an EVM and its companion oracle. + * + * Layers added below: + * 1. .enm-overview-section wrapper for each visual group + * 2. .enm-overview-section-heading smaller, calmer section label + * 3. .enm-overview-row--hero mainchain at the top, prominent + * 4. .enm-overview-evm-grid / + * .enm-overview-evm-card EVM card that contains its oracle + * 5. .enm-overview-row--evm EVM chain row inside its card + * 6. .enm-overview-row--oracle-nested oracle row indented inside parent + * 7. .enm-overview-row--arbiter arbiter footer card (compact) + * ==================================================================== */ + +.enm-app .enm-overview-section { + background: var(--bg-elevated); + border: 1px solid var(--border-color, var(--border-subtle)); + border-radius: 10px; + padding: var(--sp-3, 12px) var(--sp-4, 16px); +} +.enm-app .enm-overview-section-heading { + margin: 0 0 var(--sp-2, 8px) 0; + font-size: 12px; + font-weight: 600; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* --- 1. Main chain hero -------------------------------------------- */ +.enm-app .enm-overview-section-hero { + /* Subtle accent ring + tint so the eye lands on the node's heartbeat + before drilling into satellite services. */ + border-color: var(--accent-soft, var(--border-color)); + background: linear-gradient( + to right, + var(--accent-soft, var(--bg-overlay)) 0%, + var(--bg-elevated) 50% + ); + padding: var(--sp-4, 16px); +} +.enm-app .enm-overview-row--hero { + padding: var(--sp-3, 12px) var(--sp-3, 12px); + background: transparent; + border: none; + border-radius: 8px; +} +.enm-app .enm-overview-row--hero .enm-overview-name { + font-size: 18px; + font-weight: 600; +} +.enm-app .enm-overview-row--hero .enm-overview-dot { + width: 14px; + height: 14px; +} +.enm-app .enm-overview-row--hero .enm-overview-meta { + font-size: 13px; +} + +/* --- 2. EVM sidechains — paired cards ------------------------------ */ +.enm-app .enm-overview-section-evm { + /* The grid renders three EVM cards. On wide viewports they sit + side-by-side; on narrower viewports they stack via auto-fit. */ +} +.enm-app .enm-overview-evm-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); + gap: var(--sp-3, 12px); +} +.enm-app .enm-overview-evm-card { + background: var(--bg-overlay, var(--bg-elevated)); + border: 1px solid var(--border-subtle); + border-radius: 8px; + padding: var(--sp-2, 8px); + display: flex; + flex-direction: column; + gap: var(--sp-1, 4px); +} +.enm-app .enm-overview-row--evm { + /* The EVM row drops the open-arrow column (`auto` last) by collapsing + the trailing columns; sparkline shrinks because the card is narrower. */ + grid-template-columns: 14px minmax(120px, 1fr) auto auto 14px; + gap: var(--sp-2, 8px); + padding: var(--sp-2, 8px); +} +.enm-app .enm-overview-row--evm .enm-overview-name { + font-size: 14px; + font-weight: 600; +} +.enm-app .enm-overview-row--evm .enm-overview-spark { + width: 72px; +} + +/* --- 3. Oracle nested inside its parent's EVM card ----------------- */ +.enm-app .enm-overview-row--oracle-nested { + /* Indented + smaller dot + caption-sized name so it reads as a child + of the EVM above. Removes spark + uptime (compact variant in JS). */ + grid-template-columns: 10px 1fr auto 14px; + padding: 4px var(--sp-2, 8px) 4px calc(var(--sp-2, 8px) + 14px); + background: transparent; + border-left: 2px solid var(--border-subtle); + margin-left: var(--sp-3, 12px); + border-radius: 0 6px 6px 0; +} +.enm-app .enm-overview-row--oracle-nested .enm-overview-name { + font-size: 12px; + color: var(--text-secondary); +} +.enm-app .enm-overview-row--oracle-nested .enm-overview-state { + font-size: 9px; + padding: 2px 5px; +} +.enm-app .enm-overview-row--oracle-nested .enm-overview-meta { + font-size: 11px; +} +.enm-app .enm-overview-row--oracle-nested .enm-overview-dot { + width: 7px; + height: 7px; +} + +/* --- 4. Arbiter compact footer card -------------------------------- */ +.enm-app .enm-overview-section-arbiter { + padding: var(--sp-2, 8px) var(--sp-3, 12px); +} +.enm-app .enm-overview-row--arbiter { + grid-template-columns: 12px 1fr auto 14px; + padding: var(--sp-2, 8px); + gap: var(--sp-2, 8px); +} +.enm-app .enm-overview-row--arbiter .enm-overview-name { + font-size: 13px; + font-weight: 600; +} + +/* ==================================================================== + * v0.5.239 — overview redesign (.enm-ovx-*). Vertical chain cards, + * health headline + bulk actions, labelled action footers, collapsed + * oracle line. Supersedes the old flat horizontal-row look + sparklines. + * ==================================================================== */ +/* section heading → quiet uppercase label */ +.enm-app .enm-overview-section-heading { + font-size: 12px; font-weight: 600; letter-spacing: .05em; text-transform: uppercase; + color: var(--text-tertiary); margin: 0 2px 12px; +} +/* health headline + bulk actions */ +.enm-app .enm-ovx-health { + display: flex; align-items: center; justify-content: space-between; gap: 14px; flex-wrap: wrap; + background: var(--bg-surface); border: 1px solid var(--border); border-left: 3px solid var(--success); + border-radius: 12px; padding: 13px 18px; margin-bottom: 18px; +} +.enm-app .enm-ovx-health.warn { border-left-color: var(--warning); } +.enm-app .enm-ovx-health-l { display: flex; align-items: center; gap: 11px; min-width: 0; flex-wrap: wrap; } +.enm-app .enm-ovx-dot-lg { width: 11px; height: 11px; border-radius: 50%; flex: 0 0 auto; background: var(--text-tertiary); } +.enm-app .enm-ovx-dot-lg.ok { background: var(--success); box-shadow: 0 0 0 4px var(--success-bg); } +.enm-app .enm-ovx-dot-lg.warn { background: var(--warning); box-shadow: 0 0 0 4px var(--warning-bg); } +.enm-app .enm-ovx-health-verdict { font-size: 15px; font-weight: 600; color: var(--text-primary); } +.enm-app .enm-ovx-health-detail { font-size: 13px; color: var(--text-tertiary); } +.enm-app .enm-ovx-health-r { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +/* labelled action buttons */ +.enm-app .enm-ovx-act { + /* width:auto kept explicit (it's the inline-flex default). v0.5.241 added + it to defeat a width:26px that leaked from the legacy .enm-overview-action + class these buttons used to also carry; v0.5.242 removed that dual-class + entirely, so this is now just belt-and-suspenders. */ + display: inline-flex; align-items: center; justify-content: center; gap: 6px; width: auto; height: 32px; padding: 0 13px; + border-radius: 8px; border: 1px solid var(--border); background: var(--bg-elevated); color: var(--text-secondary); + font-size: 12px; font-weight: 600; font-family: inherit; cursor: pointer; line-height: 1; white-space: nowrap; + transition: color .12s, border-color .12s, background-color .12s, opacity .12s; +} +.enm-app .enm-ovx-act .enm-ovx-ico { font-size: 12px; line-height: 1; } +.enm-app .enm-ovx-act:hover { border-color: var(--border-strong); color: var(--text-primary); } +.enm-app .enm-ovx-act.is-start { color: var(--success); border-color: var(--success-soft); } +.enm-app .enm-ovx-act.is-start:hover { background: var(--success-bg); } +.enm-app .enm-ovx-act.is-stop:hover { color: var(--error); border-color: var(--error); background: var(--error-bg); } +.enm-app .enm-ovx-act.is-restart:hover { color: var(--accent); border-color: var(--accent); } +.enm-app .enm-ovx-act.is-update { color: var(--accent); border-color: var(--accent-soft); } +.enm-app .enm-ovx-act.is-update:hover { background: var(--accent-soft); } +.enm-app .enm-ovx-act.is-busy, .enm-app .enm-ovx-act[disabled] { opacity: .5; cursor: default; } +/* card action footer */ +.enm-app .enm-ovx-foot { + display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 14px; padding-top: 13px; + border-top: 1px solid var(--border-subtle); +} +.enm-app .enm-ovx-spacer { flex: 1 1 auto; } +.enm-app .enm-ovx-manage { + display: inline-flex; align-items: center; gap: 4px; font-size: 12px; color: var(--text-tertiary); + background: transparent; border: none; font-family: inherit; cursor: pointer; white-space: nowrap; +} +.enm-app .enm-ovx-manage:hover { color: var(--accent); } +/* vertical chain card (overrides the v0.5.238 flat-row flex) */ +.enm-app .enm-overview-row.enm-ovx-card { + display: flex; flex-direction: column; align-items: stretch; gap: 0; + background: var(--bg-surface); border: 1px solid var(--border); border-radius: 14px; padding: 16px 18px; + cursor: pointer; +} +.enm-app .enm-overview-row.enm-ovx-card:hover { border-color: var(--border-strong); } +.enm-app .enm-overview-row.enm-ovx-card.enm-ovx-attention { + border-color: rgba(251,191,36,.45); box-shadow: inset 3px 0 0 var(--warning); +} +.enm-app .enm-ovx-top { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +.enm-app .enm-ovx-meta { margin-top: 10px; font-size: 14px; color: var(--text-secondary); font-variant-numeric: tabular-nums; } +.enm-app .enm-ovx-metrics { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; } +.enm-app .enm-ovx-metrics .enm-overview-metric { + font-size: 11px; color: var(--text-secondary); background: var(--bg-overlay); border-radius: 6px; + padding: 3px 8px; font-variant-numeric: tabular-nums; +} +.enm-app .enm-ovx-metrics .enm-overview-metric.is-warn { color: var(--warning); } +.enm-app .enm-ovx-update-badge { + display: inline-flex; align-items: center; gap: 5px; font-size: 11px; font-weight: 600; + color: var(--accent); background: var(--accent-soft); border-radius: 999px; padding: 2px 9px; white-space: nowrap; +} +/* mainchain hero */ +.enm-app .enm-overview-row.enm-ovx-card.enm-ovx-hero { + background: linear-gradient(120deg, var(--accent-soft), var(--bg-surface) 62%); padding: 20px 22px; +} +.enm-app .enm-ovx-hero .enm-overview-name { font-size: 19px; font-weight: 650; } +/* oracle line folded into its EVM card — routes to the oracle dashboard on click (v0.5.240) */ +.enm-app .enm-ovx-oracle { + display: flex; align-items: center; gap: 8px; margin-top: 14px; padding-top: 12px; + border-top: 1px solid var(--border-subtle); flex-wrap: wrap; cursor: pointer; +} +.enm-app .enm-ovx-oracle-name { font-size: 12px; color: var(--text-secondary); font-weight: 500; } +.enm-app .enm-ovx-oracle-rel { font-size: 11px; color: var(--text-tertiary); } +.enm-app .enm-ovx-caret { margin-left: auto; color: var(--text-tertiary); font-size: 12px; } +/* EVM card is the visible card → the inner li goes "naked" (no card-in-card) */ +.enm-app .enm-overview-evm-card { + background: var(--bg-surface); border: 1px solid var(--border); border-radius: 14px; padding: 16px 18px; +} +.enm-app .enm-overview-evm-card .enm-overview-rows { list-style: none; margin: 0; padding: 0; gap: 0; } +.enm-app .enm-overview-evm-card .enm-overview-row.enm-ovx-card { + background: transparent; border: none; border-radius: 0; padding: 0; +} +.enm-app .enm-overview-evm-card .enm-overview-row.enm-ovx-card:hover { background: transparent; } +/* phone: footer + bulk buttons grow to share rows */ +@media (max-width: 430px) { + .enm-app .enm-ovx-health { flex-direction: column; align-items: stretch; } + .enm-app .enm-ovx-health-r { width: 100%; } + .enm-app .enm-ovx-health-r .enm-ovx-act, + .enm-app .enm-ovx-foot .enm-ovx-act { flex: 1 1 calc(50% - 4px); } + .enm-app .enm-ovx-manage { width: 100%; justify-content: flex-start; margin-top: 2px; } + .enm-app .enm-ovx-spacer { display: none; } + .enm-app .enm-ovx-hero .enm-overview-name { font-size: 17px; } +} +/* v0.5.239 — keep the stat strip a tidy 2-up on phone/narrow (the mock look) + rather than the old 4-up-tight / 1-up-stacked. */ +.enm-app[data-app-size="narrow"] .enm-overview-usage, +body[data-app-size="narrow"] .enm-app .enm-overview-usage, +.enm-app[data-app-size="compact"] .enm-overview-usage, +body[data-app-size="compact"] .enm-app .enm-overview-usage { + grid-template-columns: repeat(2, 1fr); +} + +/* --- Compact viewport overrides ------------------------------------ */ +.enm-app[data-app-size="compact"] .enm-overview-evm-grid, +body[data-app-size="compact"] .enm-app .enm-overview-evm-grid, +.enm-app[data-app-size="narrow"] .enm-overview-evm-grid, +body[data-app-size="narrow"] .enm-app .enm-overview-evm-grid { + grid-template-columns: 1fr; +} +.enm-app[data-app-size="compact"] .enm-overview-row--hero, +body[data-app-size="compact"] .enm-app .enm-overview-row--hero { + grid-template-columns: 14px 1fr auto 14px; +} +.enm-app[data-app-size="compact"] .enm-overview-row--hero .enm-overview-name, +body[data-app-size="compact"] .enm-app .enm-overview-row--hero .enm-overview-name { + font-size: 16px; +} +.enm-app[data-app-size="compact"] .enm-overview-row--evm, +body[data-app-size="compact"] .enm-app .enm-overview-row--evm, +.enm-app[data-app-size="compact"] .enm-overview-row--arbiter, +body[data-app-size="compact"] .enm-app .enm-overview-row--arbiter { + grid-template-columns: 12px 1fr auto 14px; +} +.enm-app[data-app-size="compact"] .enm-overview-row--evm .enm-overview-spark, +body[data-app-size="compact"] .enm-app .enm-overview-row--evm .enm-overview-spark, +.enm-app[data-app-size="compact"] .enm-overview-row--evm .enm-overview-uptime, +body[data-app-size="compact"] .enm-app .enm-overview-row--evm .enm-overview-uptime { + display: none; +} + /* Compact viewport: hide uptime + sparkline columns so the control-center main column (name + state + live meta line) keeps full width. Quick actions stay (operators need them on small screens too). Row still tappable, still diff --git a/src/backend/apps/elastos-node-manager/index.html b/src/backend/apps/elastos-node-manager/index.html index 9b9be34d61..915d2e61e5 100644 --- a/src/backend/apps/elastos-node-manager/index.html +++ b/src/backend/apps/elastos-node-manager/index.html @@ -10,7 +10,7 @@ Elastos Node Manager - + + + + + + + + + + + + + + + + + + + + + @@ -206,7 +204,8 @@

Elastos Node Manager

- + @@ -215,9 +214,9 @@

Elastos Node Manager

cards mounted BELOW the shared chain-card hero. Class B (EVM): mining on/off + geth/reward addresses. Class C (Oracle): parent reachable + height + last activity/error. Never mounted for the mainchain. --> - + - + - + - + - + - + @@ -271,6 +270,6 @@

Elastos Node Manager

(Dashboard / Logs / Settings / Audit) replaced its 4-sub-tab wrapper; the Dashboard pane now mounts SystemStatus + ChainCard + BPoS card + ToolsUpdate directly from app.js._showDashboard. --> - + diff --git a/src/backend/apps/elastos-node-manager/js/app.js b/src/backend/apps/elastos-node-manager/js/app.js index 8ba8194729..391ebd4aec 100644 --- a/src/backend/apps/elastos-node-manager/js/app.js +++ b/src/backend/apps/elastos-node-manager/js/app.js @@ -324,29 +324,13 @@ ? new root.EnmHeightSeriesClient(this.services.api, this.services.sse) : null; - // beta.3.70 — mount the chain selector in the topbar. It's a - // small dropdown that REPLACES the old static MAINNET pill + - // duplicate brand cluster. Loads /config asynchronously to - // detect node mode (BPoS-only vs council) and renders the - // option list accordingly (others grayed for BPoS-only). - // Mount is best-effort: if the element or component is - // missing for any reason, app continues without it. - try { - var selectorEl = document.getElementById('enm-chain-selector'); - if (selectorEl && root.EnmChainSelector) { - this._chainSelector = new root.EnmChainSelector({ - root: selectorEl, - api: this.services.api, - }); - this._chainSelector.mount(); - } - } catch (err) { - // Non-fatal — log to console so a real bug surfaces in - // dev tools, but never block the rest of init. - if (typeof console !== 'undefined') { - console.warn('ENM chain selector mount failed:', err && err.message); - } - } + // v0.5.237 — the chain-selector dropdown was removed. The topbar + // now shows a static node-mode label (#enm-node-mode) that + // PaneRouter populates from GET /config (Council vs BPoS). The + // council-vs-BPoS detection that used to live in chain-selector.js + // now lives in PaneRouter._detectNodeMode; navigation between the + // multi-chain overview and a per-chain dashboard is driven by the + // enm:chain-change event (overview row clicks + the Back control). // beta.3.89 (Wave M2.1) — install the PaneRouter listener so // selector key changes route to the right pane content + tab @@ -407,10 +391,45 @@ // first) so a backend-unreachable failure keeps its 'health' tag and the // dedicated offline/unreachable copy; setup-state just rides alongside // instead of waiting its turn, and is consumed once health resolves. - var healthP = this.services.api.get('/health', { skipCache: true }) - .catch(function (err) { - throw withTag(err, 'health'); - }); + // + // v0.5.232 — wrap the initial /health probe in a soft retry loop. The + // Settings → Reset ENM flow location.reloads() ~6s after the SIGKILL, + // which is usually enough for pc2-node to respawn ENM and bind :4180, + // but on a slow host the iframe can hit the reload before ENM is back + // (502 from pc2-node's proxy). Retry every 2s up to 15 times (~30s + // total) before surfacing the error pane — by then a real outage is + // more likely than a restart-window race. Each attempt updates the + // spinner text so the operator sees "ENM restarting…" instead of a + // blank page during the wait. + var self2 = this; + function probeHealthWithRetry() { + var attempt = 0; + var MAX_ATTEMPTS = 15; + var DELAY_MS = 2000; + function once() { + return self2.services.api.get('/health', { skipCache: true }) + .catch(function (err) { + // Only retry on connection-style failures (network + // error / 5xx / non-JSON response). A 4xx is a real + // error and fails fast. + var status = err && err.status; + var transient = !status || status >= 500 || status === 0; + attempt += 1; + if (!transient || attempt >= MAX_ATTEMPTS) { + throw withTag(err, 'health'); + } + if (self2.els && self2.els.spinnerText) { + self2.els.spinnerText.textContent = + 'ENM restarting… (' + attempt + '/' + MAX_ATTEMPTS + ')'; + } + return new Promise(function (resolve) { + setTimeout(resolve, DELAY_MS); + }).then(once); + }); + } + return once(); + } + var healthP = probeHealthWithRetry(); var setupP = this.services.api.get('/setup/state', { skipCache: true }); // Mark setupP as handled so a health-first failure (where we never reach // `return setupP`) doesn't trip an unhandledRejection warning. The real @@ -503,6 +522,20 @@ if (tabId === 'settings') { self._mountSettingsTabLazy(); } else if (tabId === 'logs') { self._mountLogViewerLazy(); } else if (tabId === 'audit') { self._mountAuditTabLazy(); } + // v0.5.240 audit fix — pause the overview pane's 3s /system/usage + // poll + council:overview SSE while the operator is on another tab, + // resume on return. enmUseVisibilityPause only covers browser-tab + // visibility (document.hidden), not an in-app tab switch, so + // without this the overview kept fetching + re-rendering into a + // hidden pane. Only relevant for the Council overview (BPoS / + // drilled-in dashboards have no _overviewPane → this no-ops). + if (self._overviewMode && self._overviewPane) { + if (tabId === 'dashboard') { + if (typeof self._overviewPane.resume === 'function') { self._overviewPane.resume(); } + } else if (typeof self._overviewPane.pause === 'function') { + self._overviewPane.pause(); + } + } } tabBtns.forEach(function (btn) { @@ -546,18 +579,17 @@ + 'Hard-refresh the page (Ctrl-Shift-R, or ⌘-Shift-R on Mac).

'; return; } - // beta.3.93 (M2.5) — pass chainId + chainClass so the settings - // tab dispatches to the right per-class mount entry point. - // Falls back to mainchain/A for the legacy single-chain path - // (PaneRouter init defaults _activeChainId to 'mainchain' - // when nothing's stored in localStorage). - var chainId = this._activeChainId || 'mainchain'; - var CHAIN_CLASS = root.enmChainClass; // P1.6 — single source (utils.js) + // v0.5.237 — Settings is GLOBAL now (no longer per-chain). The + // consolidated shell covers every chain — including all sidechains via + // the "Sidechain settings" section — so it always mounts with the + // mainchain (Class A) context regardless of which chain the operator + // drilled into. The SettingsTab constructor renders the global shell + // unconditionally; we pass A for clarity. this._settingsTab = new root.EnmSettingsTab({ api: this.services.api, notifications: this.services.notifications, - chainId: chainId, - chainClass: CHAIN_CLASS[chainId] || 'A', + chainId: 'mainchain', + chainClass: 'A', }); this._settingsTab.mount(this.els.paneSettings); }; @@ -814,14 +846,12 @@ this._revealContent(); this._clearPanes(); if (this.els.tabs) { this.els.tabs.hidden = true; } - // beta.0.4.2 — hide the chain selector during the setup wizard. - // Pre-0.4.2 the selector mounted in init() and stayed visible - // through every screen including welcome — confusing the - // operator who saw "Multi-chain overview" as a clickable option - // before they'd even installed mainchain. The selector belongs - // on the dashboard, not the welcome screen. - var selectorEl = document.getElementById('enm-chain-selector'); - if (selectorEl) { selectorEl.hidden = true; } + // v0.5.237 — hide the static node-mode label during the setup + // wizard (it only makes sense once chains are installed; the + // mode isn't known until /config has chains). _showDashboard + // re-shows + populates it via _detectNodeMode. + var modeLabel = document.getElementById('enm-node-mode'); + if (modeLabel) { modeLabel.hidden = true; } // alpha.28.1 batch 79 (Round-22 finding #3) — if init() already // fetched /setup/state and passed us the result, branch @@ -943,10 +973,8 @@ }; ENMApp.prototype._showDashboard = function () { - // beta.0.4.2 — restore the chain selector when leaving setup - // wizard for the dashboard. Pairs with _showSetupWizard's hide. - var selectorEl = document.getElementById('enm-chain-selector'); - if (selectorEl) { selectorEl.hidden = false; } + // v0.5.237 — the static node-mode label is re-shown + populated by + // _detectNodeMode below (it was hidden during the setup wizard). // beta.0.4.7 — the Council continuation banner has been deleted. // The redesigned 7-card wizard (setup-conversation.js) installs // everything (mainchain + ESC + EID + PG + 3 oracles + Arbiter) @@ -1009,31 +1037,27 @@ // re-mounts triggered by selector clicks. var self = this; this._dashboardMounted = true; - if (this._activeChainId === 'all') { - this._enterOverviewMode(); - } else { - this._mountDashboardForActiveChain(); - } - - // 0.5.8 audit Session 8 fix — re-detect council mode after install. - // Pre-0.5.8 the chain-selector's _refreshAvailability fired only at - // app init (boot), so chains installed during the wizard never - // triggered the v0.5.0 council-default 'all' switch. Operator - // landed on the mainchain pane with the selector still labeled - // "Main chain"; they had to click the dropdown manually to find - // the multi-chain overview. Calling refresh() here re-runs the - // /config GET; if the now-installed chain set is Council-shaped - // AND no stored selection exists, the selector flips to 'all' + - // dispatches enm:chain-change → PaneRouter (now in dashboard - // -mounted=true state) catches it via _handleChainChange → - // re-mounts the pane to the multi-chain overview. One brief - // flicker between initial pane mount and overview mount; way - // better than the stale "Main chain" label the operator hit - // in our session. - if (this._chainSelector && typeof this._chainSelector.refresh === 'function') { - try { this._chainSelector.refresh(); } - catch (_) { /* defensive: don't block dashboard render on selector refresh */ } - } + // v0.5.237 — route by node mode, re-detected on every dashboard + // entry. This subsumes the old 0.5.8 "re-detect Council after + // install" fix: when the Council wizard finishes, onComplete → + // _showDashboard → _detectNodeMode sees the freshly-installed chains + // and lands on the overview. Drill-in is session-only, so we always + // reset to the mode's default landing (overview for Council, + // mainchain for BPoS) rather than restoring a stored per-chain pick. + this._drilledIn = false; + this._detectNodeMode().then(function (mode) { + // Guard: if we transitioned back to the setup wizard while + // /config was in flight (e.g. a reinstall), don't mount. + if (!self._dashboardMounted) { return; } + if (mode === 'council') { + self._activeChainId = 'all'; + self._enterOverviewMode(); + } else { + self._activeChainId = 'mainchain'; + self._overviewMode = false; + self._mountDashboardForActiveChain(); + } + }); // Notifications pipeline — keep CRITICAL proposal cards popping // on top of the dashboard. @@ -1097,57 +1121,64 @@ }; /** - * beta.3.89 (Wave M2.1) — PaneRouter wiring. - * - * Wires the chain selector's enm:chain-change event into: - * 1. Tab strip visibility (hidden when key='all') - * 2. Pane mount (per-chain Dashboard for chain keys, multi-chain - * overview pane for 'all') + * PaneRouter wiring (v0.5.237 — selector removed). * - * The pre-M2.1 dashboard mounted unconditionally for mainchain and - * the selector event was silent (zero listeners). With PaneRouter, - * the selector becomes load-bearing: clicking "Multi-chain overview" - * actually swaps the dashboard for an aggregate view. + * Listens for the enm:chain-change event and routes the Dashboard pane: + * - key='all' → multi-chain overview (Council default; _enterOverviewMode) + * - chain key → drill into that chain's per-chain dashboard + * (_handleChainChange → _mountDashboardForActiveChain, + * with a "Back to overview" control) * - * Selector ↔ PaneRouter sync: - * - localStorage 'enm:chain-selection' is the shared key. Selector - * writes; PaneRouter reads at boot for the initial activeChainId. - * - chain-selector.js dispatches enm:chain-change on user click AND - * on availability auto-reset (selector's _refreshAvailability - * forces back to mainchain when stored selection is invalid for - * this install's mode). PaneRouter listens for both. + * Emitters of enm:chain-change are now the overview row click + * (multi-chain-overview.js#_routeToChain) and the "Back to overview" + * control — the old chain-selector dropdown that used to drive this is + * gone. The boot landing is decided by node mode (_detectNodeMode): + * Council → overview, BPoS → mainchain dashboard. The tab strip stays + * visible in every mode. Drill-in is session-only (no persistence). * - * Idempotent — _paneRouterInstalled gate stops re-wiring on Retry. + * Idempotent — the _paneRouterInstalled gate stops re-wiring on the + * init() re-run path (e.g. online-watcher reconnect), so there is + * exactly one document-level listener. * * @private */ ENMApp.prototype._initPaneRouter = function () { if (this._paneRouterInstalled) { return; } this._paneRouterInstalled = true; - // Initial activeChainId from the selector's storage key. Falls - // back to 'mainchain' for any unknown / missing value so the - // dashboard always has a definite chain to mount for. - this._activeChainId = this._loadStoredChainSelection(); - // overviewMode flag tracks whether we're rendering the multi- - // chain aggregate pane (true) or a per-chain dashboard (false). - // Default false; switched by _enterOverviewMode / _exitOverviewMode. - this._overviewMode = (this._activeChainId === 'all'); - // _dashboardMounted gates the listener so events fired during - // the setup wizard period (selector mounts at init, before the - // dashboard exists) don't try to manipulate panes that haven't - // been created. + // v0.5.237 — node mode ('council' | 'bpos-only') is resolved by + // _detectNodeMode (GET /config) and is the authoritative driver of + // the boot landing: Council → multi-chain overview; BPoS → mainchain + // dashboard. _showDashboard re-detects on every dashboard entry so a + // node that just became Council during the setup wizard flips to the + // overview. Provisional defaults until detection resolves: + this._nodeMode = null; + // 'all' = the multi-chain overview pane; a specific chainId = a + // per-chain dashboard. _showDashboard overrides this from _nodeMode, + // so the value here is only a provisional placeholder. + this._activeChainId = 'all'; + this._overviewMode = true; + // _drilledIn: true once the operator clicks a chain row in the + // overview (Council only). Gates the "Back to overview" control and + // is reset whenever we re-enter the overview. The drill-in is + // session-only — a reload always lands a Council node back on the + // overview (locked decision), so we never restore it from storage. + this._drilledIn = false; + // _dashboardMounted gates the listener so events fired during the + // setup wizard period don't manipulate panes that don't exist yet. if (typeof this._dashboardMounted !== 'boolean') { this._dashboardMounted = false; } var self = this; - // Listen at document level so the event bubbles from the - // selector root (which sits in the topbar, sibling of the - // pane container). bubbles:true is set by chain-selector.js. + // Listen at document level for enm:chain-change. Emitters: the + // overview row click (_routeToChain) and the "Back to overview" + // control. The idempotency gate above (_paneRouterInstalled) keeps + // this from double-wiring on the init() re-run path (online-watcher + // reconnect), so there's exactly one router listener. document.addEventListener('enm:chain-change', function (ev) { - var key = (ev && ev.detail && ev.detail.key) || 'mainchain'; + var key = (ev && ev.detail && ev.detail.key) || 'all'; if (!self._dashboardMounted) { - // Setup wizard is up — just remember the new selection - // so _showDashboard picks it up on transition. + // Setup wizard is up — just remember the selection so + // _showDashboard picks it up on transition. self._activeChainId = key; self._overviewMode = (key === 'all'); return; @@ -1157,18 +1188,61 @@ }; /** + * v0.5.237 — resolve node mode from GET /config. Council (>=2 configured + * chains) vs BPoS-only (mainchain only). Ported from the deleted + * chain-selector.js#_refreshAvailability so PaneRouter no longer depends + * on the selector component. Also populates the static topbar node-mode + * label. Returns a Promise<'council'|'bpos-only'> that resolves to + * 'bpos-only' on any error (safe default — a BPoS node never shows the + * overview, so a transient /config failure can't strand a Council + * operator in an empty overview). + * * @private - * @returns {string} one of: 'all', 'mainchain', 'esc', 'eid', 'pg', - * 'arbiter', 'spv'. Falls back to 'mainchain' on - * missing / unknown / non-string values. */ - ENMApp.prototype._loadStoredChainSelection = function () { - var VALID = { all: 1, mainchain: 1, esc: 1, eid: 1, pg: 1, arbiter: 1, spv: 1 }; - try { - var v = root.localStorage && root.localStorage.getItem('enm:chain-selection'); - if (typeof v === 'string' && VALID[v] === 1) { return v; } - } catch (_) { /* private-mode / storage disabled */ } - return 'mainchain'; + ENMApp.prototype._detectNodeMode = function () { + var self = this; + if (!this.services || !this.services.api || typeof this.services.api.get !== 'function') { + return Promise.resolve('bpos-only'); + } + return this.services.api.get('/config', { skipCache: true }).then(function (data) { + // api.js unwraps the envelope to parsed.result; resolve config + // across all three shapes (full envelope / unwrapped result / + // bare config) — the same triple-shape guard the selector used, + // which a prior bug got wrong and mis-detected every Council node. + var cfg = (data && data.result && data.result.config) + || (data && data.config) + || data || {}; + var chains = (cfg && cfg.chains) || {}; + var mode = (Object.keys(chains).length <= 1) ? 'bpos-only' : 'council'; + self._nodeMode = mode; + self._applyNodeModeLabel(mode); + return mode; + }).catch(function () { + self._nodeMode = self._nodeMode || 'bpos-only'; + self._applyNodeModeLabel(self._nodeMode); + return self._nodeMode; + }); + }; + + /** + * v0.5.237 — set the static topbar node-mode label text + data-mode. + * @private + * @param {'council'|'bpos-only'} mode + */ + ENMApp.prototype._applyNodeModeLabel = function (mode) { + var el = document.getElementById('enm-node-mode'); + if (!el) { return; } + el.hidden = false; + el.dataset.mode = mode; + var t = root.enmTOrFallback; + function _fb(key, fb) { + if (typeof t !== 'function') { return fb; } + var v = t(key); + return (!v || v === key || v === ('[' + key + ']')) ? fb : v; + } + el.textContent = (mode === 'council') + ? _fb('node_mode.council', 'Council node') + : _fb('node_mode.bpos', 'BPoS node'); }; /** @@ -1195,10 +1269,13 @@ this._enterOverviewMode(); return; } - // Specific chain. Exit overview mode if we were in it, then - // re-mount the Dashboard pane for the new chain. Only re-mounts + // Specific chain — drill in. Exit overview mode if we were in it, + // then mount the Dashboard pane for the new chain. Only re-mounts // when chainId actually changed — clicking the same chain twice // is a no-op (saves a full teardown + remount cycle). + // v0.5.237 — mark drilled-in so _mountDashboardForActiveChain renders + // the "Back to overview" control (Council only). + this._drilledIn = true; var prev = this._activeChainId; this._activeChainId = key; if (this._overviewMode) { @@ -1220,9 +1297,15 @@ */ ENMApp.prototype._enterOverviewMode = function () { this._overviewMode = true; - // Hide tabs (overview has no Dashboard/Logs/Settings/Audit split). - if (this.els.tabs) { this.els.tabs.hidden = true; } - // Mirror on body for CSS hooks (M2.3 styling reads this). + // v0.5.237 — entering the overview clears any drill-in state (the + // "Back to overview" control only exists for a drilled-in chain). + this._drilledIn = false; + // v0.5.237 — tabs STAY VISIBLE in overview mode. The overview is now + // the default Dashboard-pane content for a Council node, not a + // full-screen takeover, so Logs / Settings / Activity remain one + // click away. (The old `this.els.tabs.hidden = true` here — and the + // CSS rule keyed on body[data-enm-overview] — hid them; both removed.) + // The body flag is kept as a CSS hook for overview-specific styling. if (document.body) { document.body.dataset.enmOverview = '1'; } // Tear down per-chain mounts so their SSE subs + timers free. this._teardownHomeView(); @@ -1253,6 +1336,12 @@ try { this._overviewPane.destroy(); } catch (_) { /* idempotent */ } } this._overviewPane = null; + // v0.5.237 — tabs are visible in overview mode now, so the operator + // may have mounted Logs / Settings / Activity while on the overview. + // Drop those lazy handles before clearing the pane DOM; otherwise the + // next tab click returns early on a stale handle and shows a blank + // pane (the old code was safe only because overview hid the tabs). + this._teardownLazyPanes(); this._clearPanes(); }; @@ -1300,6 +1389,27 @@ var pane = this.els.paneDashboard; if (!pane) { return; } var chainId = this._activeChainId || 'mainchain'; + // v0.5.237 — "Back to overview" control. Rendered only when a Council + // node has drilled into a chain from the overview (never for BPoS, + // which has no overview). The pane is cleared by the caller before + // this runs, so we prepend it above the chain cards. Routes back via + // the same enm:chain-change('all') contract the overview rows use. + if (this._drilledIn && this._nodeMode === 'council') { + var selfBack = this; + var backBtn = document.createElement('button'); + backBtn.type = 'button'; + backBtn.className = 'enm-back-to-overview'; + var _bt = root.enmTOrFallback; + var _blabel = '← Back to overview'; + if (typeof _bt === 'function') { + var _bv = _bt('overview_pane.back_to_overview'); + if (_bv && _bv !== 'overview_pane.back_to_overview' + && _bv !== '[overview_pane.back_to_overview]') { _blabel = _bv; } + } + backBtn.textContent = _blabel; + backBtn.addEventListener('click', function () { selfBack._handleChainChange('all'); }); + pane.appendChild(backBtn); + } // beta.3.92 (M2.4) — chainClass static lookup mirrors the // server-side ChainAdapter.CHAIN_ID_TO_CLASS table. Passed // down to chain-card (and future per-class components) so @@ -1338,8 +1448,17 @@ // Defensive: the component script hasn't parsed yet (first // paint race). It's a deferred