From e52b6ae36958a8942c6b033b23687b39d16e6a8a Mon Sep 17 00:00:00 2001 From: pasta Date: Sun, 30 Aug 2026 06:34:39 +0200 Subject: [PATCH 1/2] fix(dashmate): replace dead mainnet tenderdash seeds All five hardcoded mainnet seeds are dead at the p2p layer (verified 2026-08-30): four fail the secret-connection handshake and one drops immediately after completing it, and none of their node IDs appears in the current evonode registry. They still accept TCP on 26656, so nodes stall silently instead of failing loudly. Replace the defaults with five currently-registered evonodes verified to carry a full platform sync, and migrate existing mainnet configs still holding exactly the stock list onto the new defaults, leaving custom seed lists alone. Co-Authored-By: Claude Fable 5 --- .../defaults/getMainnetConfigFactory.js | 24 +++++++----- .../configs/getConfigFileMigrationsFactory.js | 38 +++++++++++++++++++ 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/packages/dashmate/configs/defaults/getMainnetConfigFactory.js b/packages/dashmate/configs/defaults/getMainnetConfigFactory.js index 4a45c6f3b06..6a585f8665b 100644 --- a/packages/dashmate/configs/defaults/getMainnetConfigFactory.js +++ b/packages/dashmate/configs/defaults/getMainnetConfigFactory.js @@ -32,30 +32,34 @@ export default function getMainnetConfigFactory(homeDir, getBaseConfig) { drive: { tenderdash: { p2p: { + // Registered evonodes with open platform p2p, verified working + // against the live evonode registry on 2026-08-30. Seeds rotate + // with the registry, so this list rots; the long-term fix is to + // generate it from the registry instead of hardcoding it. seeds: [ { - id: '069639dfceec5f7c86257e6e9c46407c16ad1eab', - host: '34.211.174.194', + id: 'ee9ab93559e6e931d7dbcf269e1ea8446e7068e5', + host: '149.28.241.190', port: 26656, }, { - id: 'd46e2445642b2f94158ac3c2a6d90b88b83705b8', - host: '3.76.148.150', + id: '30918550e1f57eaff1b97f85adc8f4967065a16b', + host: '216.238.75.46', port: 26656, }, { - id: 'b08a650ecfac178939f21c0c12801eccaf18a5ea', - host: '3.0.60.103', + id: '6d9fe2b4f18b999521cf706e8c7b8559d4477e4c', + host: '89.125.209.110', port: 26656, }, { - id: '4cb4a8488eb1dbabda7fb79e47ac3c14eec73c4f', - host: '152.42.151.147', + id: 'dc812dc0e2e35a8a59491c5d20cba0390d045171', + host: '84.247.180.201', port: 26656, }, { - id: 'fdc2239c1e0e62f3a192823d6e068d012620a2d1', - host: 'seed-1.pshenmic.dev', + id: '3ed7bb4f1ed2f19cacd33f44a68b95d3f24cf85d', + host: '134.255.182.186', port: 26656, }, ], diff --git a/packages/dashmate/configs/getConfigFileMigrationsFactory.js b/packages/dashmate/configs/getConfigFileMigrationsFactory.js index 077ebae1225..d781fc62728 100644 --- a/packages/dashmate/configs/getConfigFileMigrationsFactory.js +++ b/packages/dashmate/configs/getConfigFileMigrationsFactory.js @@ -1764,6 +1764,44 @@ export default function getConfigFileMigrationsFactory(homeDir, defaultConfigs) return configFile; }, + '4.2.0-dev.7': (configFile) => { + // All five mainnet tenderdash seeds shipped until now are dead + // (verified at the p2p layer on 2026-08-30): four fail the + // secret-connection handshake and one drops right after completing it, + // and none of their node IDs is in the current evonode registry. They + // still accept TCP on 26656, so a mainnet node carrying them stalls + // instead of failing loudly. Move configs still holding exactly the + // stock list onto the new defaults; a custom seed list is left alone. + const deadSeeds = [ + '069639dfceec5f7c86257e6e9c46407c16ad1eab@34.211.174.194:26656', + 'd46e2445642b2f94158ac3c2a6d90b88b83705b8@3.76.148.150:26656', + 'b08a650ecfac178939f21c0c12801eccaf18a5ea@3.0.60.103:26656', + '4cb4a8488eb1dbabda7fb79e47ac3c14eec73c4f@152.42.151.147:26656', + 'fdc2239c1e0e62f3a192823d6e068d012620a2d1@seed-1.pshenmic.dev:26656', + ]; + + Object.entries(configFile.configs) + .forEach(([, options]) => { + if (options.network !== NETWORK_MAINNET) { + return; + } + + const seeds = options.platform?.drive?.tenderdash?.p2p?.seeds; + + if (!Array.isArray(seeds) || seeds.length !== deadSeeds.length) { + return; + } + + const isStockList = seeds + .every((seed) => deadSeeds.includes(`${seed.id}@${seed.host}:${seed.port}`)); + + if (isStockList) { + options.platform.drive.tenderdash.p2p.seeds = mainnet.getStored('platform.drive.tenderdash.p2p.seeds'); + } + }); + + return configFile; + }, }; } From 3bdceadc35dc2754a6907bfa069543ed6117bc35 Mon Sep 17 00:00:00 2001 From: pasta Date: Sun, 30 Aug 2026 06:34:51 +0200 Subject: [PATCH 2/2] fix(dashmate): generate missing tenderdash node key before rendering The interactive setup wizard is the only flow that collects a platform node key, so a config assembled any other way (dashmate config create, non-interactive setup, enabling platform on an existing node) reaches template rendering with platform.drive.tenderdash.node.{id,key} still null. node_key.json interpolates them literally, writing the string "null", and tenderdash panics at startup with a slice bounds error in ed25519.PrivKey.PubKey. Fill in a missing node identity right before service templates are rendered, and persist it into the stored config under the config file lock so restarts reuse the same identity. An existing key is never regenerated, and the base template config is skipped so configs created from it do not share an identity. Co-Authored-By: Claude Fable 5 --- packages/dashmate/src/createDIContainer.js | 2 + .../renderServiceTemplatesFactory.js | 9 +- .../ensureTenderdashNodeKeyFactory.js | 105 ++++++++++++++ .../unit/templates/dynamicCompose.spec.js | 6 +- .../test/unit/templates/envoyTemplate.spec.js | 6 +- .../ensureTenderdashNodeKeyFactory.spec.js | 136 ++++++++++++++++++ 6 files changed, 261 insertions(+), 3 deletions(-) create mode 100644 packages/dashmate/src/tenderdash/ensureTenderdashNodeKeyFactory.js create mode 100644 packages/dashmate/test/unit/tenderdash/ensureTenderdashNodeKeyFactory.spec.js diff --git a/packages/dashmate/src/createDIContainer.js b/packages/dashmate/src/createDIContainer.js index 0fca173c771..ab1dcc60a0a 100644 --- a/packages/dashmate/src/createDIContainer.js +++ b/packages/dashmate/src/createDIContainer.js @@ -33,6 +33,7 @@ import cancelCertificate from './ssl/zerossl/cancelCertificate.js'; import renderTemplateFactory from './templates/renderTemplateFactory.js'; import renderServiceTemplatesFactory from './templates/renderServiceTemplatesFactory.js'; +import ensureTenderdashNodeKeyFactory from './tenderdash/ensureTenderdashNodeKeyFactory.js'; import writeServiceConfigsFactory from './templates/writeServiceConfigsFactory.js'; import DockerCompose from './docker/DockerCompose.js'; @@ -217,6 +218,7 @@ export default async function createDIContainer(options = {}) { * Templates */ container.register({ + ensureTenderdashNodeKey: asFunction(ensureTenderdashNodeKeyFactory).singleton(), renderTemplate: asFunction(renderTemplateFactory).singleton(), renderServiceTemplates: asFunction(renderServiceTemplatesFactory).singleton(), writeServiceConfigs: asFunction(writeServiceConfigsFactory).singleton(), diff --git a/packages/dashmate/src/templates/renderServiceTemplatesFactory.js b/packages/dashmate/src/templates/renderServiceTemplatesFactory.js index 785e4775ae0..a886933c7c1 100644 --- a/packages/dashmate/src/templates/renderServiceTemplatesFactory.js +++ b/packages/dashmate/src/templates/renderServiceTemplatesFactory.js @@ -2,9 +2,11 @@ import * as glob from 'glob'; import { TEMPLATES_DIR } from '../constants.js'; /** + * @param {renderTemplate} renderTemplate + * @param {ensureTenderdashNodeKey} ensureTenderdashNodeKey * @return {renderServiceTemplates} */ -export default function renderServiceTemplatesFactory(renderTemplate) { +export default function renderServiceTemplatesFactory(renderTemplate, ensureTenderdashNodeKey) { /** * Render templates for services * @@ -14,6 +16,11 @@ export default function renderServiceTemplatesFactory(renderTemplate) { * @return {Object} */ function renderServiceTemplates(config) { + // node_key.json interpolates platform.drive.tenderdash.node.{id,key} + // literally, so a null key must be filled in before rendering or + // tenderdash panics at startup on the string "null". + ensureTenderdashNodeKey(config); + const templatePaths = glob.sync(`${TEMPLATES_DIR}/**/*.dot`, { ignore: { // Ignore manual rendered templates diff --git a/packages/dashmate/src/tenderdash/ensureTenderdashNodeKeyFactory.js b/packages/dashmate/src/tenderdash/ensureTenderdashNodeKeyFactory.js new file mode 100644 index 00000000000..641a1833782 --- /dev/null +++ b/packages/dashmate/src/tenderdash/ensureTenderdashNodeKeyFactory.js @@ -0,0 +1,105 @@ +import generateTenderdashNodeKey from './generateTenderdashNodeKey.js'; +import deriveTenderdashNodeId from './deriveTenderdashNodeId.js'; + +/** + * @param {ConfigFileJsonRepository} configFileRepository + * @return {ensureTenderdashNodeKey} + */ +export default function ensureTenderdashNodeKeyFactory(configFileRepository) { + /** + * Persist node identity values into the stored copy of the config, so a + * restart reuses the same identity instead of generating a new one. The + * config file is re-read under its lock, and a value that appeared there in + * the meantime wins over the one generated here. + * + * For a command holding the lock across its run this is an intermediate + * write: it persists the identity ahead of the command's own final save, + * without that command's other pending in-memory edits. Those still land + * with the final save; only if the command dies first does the identity + * outlive them - which is the point, since the rendered files already + * reference it. + * + * @param {Config} config + * @param {string} id + * @param {string} key + * @returns {void} + */ + function persistNodeIdentity(config, id, key) { + configFileRepository.update((configFile) => { + // A config not stored yet (a preset being set up) is persisted by the + // command that created it once it saves the config file it holds. + if (!configFile.isConfigExists(config.getName())) { + return; + } + + const storedConfig = configFile.getConfig(config.getName()); + const storedKey = storedConfig.get('platform.drive.tenderdash.node.key'); + + if (storedKey === null || storedKey === key) { + storedConfig.set('platform.drive.tenderdash.node.id', id); + storedConfig.set('platform.drive.tenderdash.node.key', key); + } else { + // Another process stored a different identity first; render with + // theirs, deriving the id when it is not stored either. + config.set( + 'platform.drive.tenderdash.node.id', + storedConfig.get('platform.drive.tenderdash.node.id') ?? deriveTenderdashNodeId(storedKey), + ); + config.set('platform.drive.tenderdash.node.key', storedKey); + } + }); + } + + /** + * Fill in a missing tenderdash node identity before service configs are + * rendered. + * + * The interactive setup wizard is the only flow that collects a node key, so + * a config assembled any other way (dashmate config create, non-interactive + * setup, enabling platform on an existing node) reaches template rendering + * with platform.drive.tenderdash.node.{id,key} still null, and node_key.json + * is written with the literal string "null" - tenderdash panics at startup. + * An existing key is never touched. + * + * @typedef {ensureTenderdashNodeKey} + * @param {Config} config + * @returns {void} + */ + function ensureTenderdashNodeKey(config) { + if (config.get('platform.enable') !== true) { + return; + } + + // The base config is a template: a key generated for it would be cloned + // into every config created from it, and those must not share an identity. + if (config.getName() === 'base') { + return; + } + + const existingKey = config.get('platform.drive.tenderdash.node.key'); + + if (existingKey !== null) { + // The id is derivable, so a config carrying a key without one is + // completed rather than rejected. + if (config.get('platform.drive.tenderdash.node.id') === null) { + const id = deriveTenderdashNodeId(existingKey); + + config.set('platform.drive.tenderdash.node.id', id); + + persistNodeIdentity(config, id, existingKey); + } + + return; + } + + const key = generateTenderdashNodeKey(); + const id = deriveTenderdashNodeId(key); + + config.set('platform.drive.tenderdash.node.id', id); + config.set('platform.drive.tenderdash.node.key', key); + + persistNodeIdentity(config, id, key); + } + + return ensureTenderdashNodeKey; +} diff --git a/packages/dashmate/test/unit/templates/dynamicCompose.spec.js b/packages/dashmate/test/unit/templates/dynamicCompose.spec.js index 4670c4470a5..23fdcd2caed 100644 --- a/packages/dashmate/test/unit/templates/dynamicCompose.spec.js +++ b/packages/dashmate/test/unit/templates/dynamicCompose.spec.js @@ -18,7 +18,11 @@ describe('dynamic compose template', () => { beforeEach(() => { getBaseConfig = getBaseConfigFactory(HomeDir.createTemp()); const renderTemplate = renderTemplateFactory(); - renderServiceTemplates = renderServiceTemplatesFactory(renderTemplate); + const ensureTenderdashNodeKey = () => {}; + renderServiceTemplates = renderServiceTemplatesFactory( + renderTemplate, + ensureTenderdashNodeKey, + ); }); it('should not publish metrics port when rs-dapi metrics are disabled', () => { diff --git a/packages/dashmate/test/unit/templates/envoyTemplate.spec.js b/packages/dashmate/test/unit/templates/envoyTemplate.spec.js index e654d62a6f7..83168253f1c 100644 --- a/packages/dashmate/test/unit/templates/envoyTemplate.spec.js +++ b/packages/dashmate/test/unit/templates/envoyTemplate.spec.js @@ -12,7 +12,11 @@ describe('envoy template', () => { config.set('platform.gateway.admin.enabled', false); const renderTemplate = renderTemplateFactory(); - const renderServiceTemplates = renderServiceTemplatesFactory(renderTemplate); + const ensureTenderdashNodeKey = () => {}; + const renderServiceTemplates = renderServiceTemplatesFactory( + renderTemplate, + ensureTenderdashNodeKey, + ); const renderedConfigs = renderServiceTemplates(config); const envoyConfig = renderedConfigs['platform/gateway/envoy.yaml']; diff --git a/packages/dashmate/test/unit/tenderdash/ensureTenderdashNodeKeyFactory.spec.js b/packages/dashmate/test/unit/tenderdash/ensureTenderdashNodeKeyFactory.spec.js new file mode 100644 index 00000000000..3b9592ee4b1 --- /dev/null +++ b/packages/dashmate/test/unit/tenderdash/ensureTenderdashNodeKeyFactory.spec.js @@ -0,0 +1,136 @@ +import ensureTenderdashNodeKeyFactory from '../../../src/tenderdash/ensureTenderdashNodeKeyFactory.js'; +import renderServiceTemplatesFactory from '../../../src/templates/renderServiceTemplatesFactory.js'; +import deriveTenderdashNodeId from '../../../src/tenderdash/deriveTenderdashNodeId.js'; +import generateTenderdashNodeKey from '../../../src/tenderdash/generateTenderdashNodeKey.js'; +import validateTenderdashNodeKey from '../../../src/listr/prompts/validators/validateTenderdashNodeKey.js'; +import Config from '../../../src/config/Config.js'; +import createDIContainer from '../../../src/createDIContainer.js'; + +describe('ensureTenderdashNodeKeyFactory', () => { + let container; + let config; + let storedConfig; + let configFileRepository; + let ensureTenderdashNodeKey; + + const NODE_ID_PATH = 'platform.drive.tenderdash.node.id'; + const NODE_KEY_PATH = 'platform.drive.tenderdash.node.key'; + + beforeEach(async function beforeEach() { + container = await createDIContainer(); + + const defaultConfigs = container.resolve('defaultConfigs'); + + config = new Config('testnet', defaultConfigs.get('testnet').getStoredOptions()); + config.set('platform.enable', true); + + // The stored copy the repository would read back from disk + storedConfig = new Config('testnet', config.getStoredOptions()); + + const configFile = { + isConfigExists: this.sinon.stub().returns(true), + getConfig: this.sinon.stub().returns(storedConfig), + }; + + configFileRepository = { + update: this.sinon.stub().callsFake((mutate) => mutate(configFile)), + }; + + ensureTenderdashNodeKey = ensureTenderdashNodeKeyFactory(configFileRepository); + }); + + it('should generate and persist a valid node key when the stored key is null', () => { + expect(config.get(NODE_KEY_PATH)).to.equal(null); + + ensureTenderdashNodeKey(config); + + const key = config.get(NODE_KEY_PATH); + + expect(key).to.be.a('string'); + expect(validateTenderdashNodeKey(key)).to.equal(true); + expect(config.get(NODE_ID_PATH)).to.equal(deriveTenderdashNodeId(key)); + + // Persisted into the stored copy so a restart reuses the same identity + expect(configFileRepository.update).to.have.been.calledOnce(); + expect(storedConfig.get(NODE_KEY_PATH)).to.equal(key); + expect(storedConfig.get(NODE_ID_PATH)).to.equal(config.get(NODE_ID_PATH)); + }); + + it('should never regenerate an existing node key', () => { + const existingKey = generateTenderdashNodeKey(); + const existingId = deriveTenderdashNodeId(existingKey); + + config.set(NODE_ID_PATH, existingId); + config.set(NODE_KEY_PATH, existingKey); + + ensureTenderdashNodeKey(config); + + expect(config.get(NODE_KEY_PATH)).to.equal(existingKey); + expect(config.get(NODE_ID_PATH)).to.equal(existingId); + expect(configFileRepository.update).to.have.not.been.called(); + }); + + it('should derive and persist a missing node id from an existing key', () => { + const existingKey = generateTenderdashNodeKey(); + + config.set(NODE_KEY_PATH, existingKey); + storedConfig.set(NODE_KEY_PATH, existingKey); + + ensureTenderdashNodeKey(config); + + expect(config.get(NODE_KEY_PATH)).to.equal(existingKey); + expect(config.get(NODE_ID_PATH)).to.equal(deriveTenderdashNodeId(existingKey)); + expect(storedConfig.get(NODE_ID_PATH)).to.equal(deriveTenderdashNodeId(existingKey)); + }); + + it('should adopt an identity another process stored first', () => { + const winningKey = generateTenderdashNodeKey(); + const winningId = deriveTenderdashNodeId(winningKey); + + storedConfig.set(NODE_ID_PATH, winningId); + storedConfig.set(NODE_KEY_PATH, winningKey); + + ensureTenderdashNodeKey(config); + + expect(config.get(NODE_KEY_PATH)).to.equal(winningKey); + expect(config.get(NODE_ID_PATH)).to.equal(winningId); + }); + + it('should not touch a config with platform disabled', () => { + config.set('platform.enable', false); + + ensureTenderdashNodeKey(config); + + expect(config.get(NODE_KEY_PATH)).to.equal(null); + expect(configFileRepository.update).to.have.not.been.called(); + }); + + it('should not generate a key for the base template config', () => { + const baseConfig = new Config('base', config.getStoredOptions()); + + ensureTenderdashNodeKey(baseConfig); + + expect(baseConfig.get(NODE_KEY_PATH)).to.equal(null); + expect(configFileRepository.update).to.have.not.been.called(); + }); + + it('should render node_key.json with a generated key instead of "null"', () => { + // Regression: a fullnode configured outside the interactive setup wizard + // reached template rendering with a null node key, and node_key.json was + // written with the literal string "null" - tenderdash panicked at startup. + const renderTemplate = container.resolve('renderTemplate'); + const renderServiceTemplates = renderServiceTemplatesFactory( + renderTemplate, + ensureTenderdashNodeKey, + ); + + const serviceConfigs = renderServiceTemplates(config); + + const nodeKeyFile = JSON.parse(serviceConfigs['platform/drive/tenderdash/node_key.json']); + + expect(nodeKeyFile.priv_key.value).to.equal(config.get(NODE_KEY_PATH)); + expect(nodeKeyFile.priv_key.value).to.not.equal('null'); + expect(nodeKeyFile.id).to.equal(config.get(NODE_ID_PATH)); + expect(validateTenderdashNodeKey(nodeKeyFile.priv_key.value)).to.equal(true); + }); +});