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; + }, }; } 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); + }); +});