From bf9dd2e4078eaa34dd40e918e7048a278bbe8031 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 1 Sep 2026 12:16:33 -0700 Subject: [PATCH 1/4] validator: stake and unstake state both clocks, read from the coin registry Unstake told the operator their XCHAIN was spendable again after the activation delay, when spendability is governed by the separate staking cooldown (1000 blocks on Bitcoin, roughly seven days). Both clocks now print in the plan and the post-broadcast summary for stake and unstake, sourced per chain from the vendored coins registry; the hardcoded 6 was also wrong for Litecoin (24) and Dogecoin (60). Stake now states the exit cost before the money moves. --- src/services/ValidatorStakeService.js | 109 ++++++++++++++++++++---- test/unit/ValidatorStakeService.test.js | 99 ++++++++++++++++++++- 2 files changed, 188 insertions(+), 20 deletions(-) diff --git a/src/services/ValidatorStakeService.js b/src/services/ValidatorStakeService.js index 012932a..0d38e60 100644 --- a/src/services/ValidatorStakeService.js +++ b/src/services/ValidatorStakeService.js @@ -33,18 +33,67 @@ const { getValidatorSettings, readWallets, publicWalletInfo, promptSecret, loadSdk, COIN_NETWORKS, WALLETS_FILE } = require('./ValidatorService') +const { getCoinConfigByFullName } = require('../coins') const STAKE_TICK = 'XCHAIN' // One stake that clears every capability floor at once (llm attestation // provider is the highest at 25000); see the indexer's STAKING.CAPABILITIES. const DEFAULT_STAKE_AMOUNT = 25000 -// Blocks between an action landing and the stake changing state, in either -// direction: a STAKE is not counted for this long, and an UNSTAKE keeps -// counting for this long (xchain-indexer/src/coins/BTC.js ACTIVATION_DELAY_BLOCKS, -// applied to the deactivation block in actions/unstake.js). -const ACTIVATION_DELAY_BLOCKS = 6 const PUBLIC_EXPLORER = 'https://explorer.xchain.io' +// Approximate target block spacing per chain, used ONLY to gloss a block count +// as wall-clock time an operator can plan around. Display-only: nothing branches +// on it, and every block count itself comes from the coin registry. The coin +// files carry no spacing field, so these mirror the arithmetic their own STAKING +// comments already state ("~7 days at ~10 min/block"). +const BLOCK_MINUTES = { bitcoin: 10, litecoin: 2.5, dogecoin: 1 } + +// A block count as a duration. Empty on regtest, where blocks are mined on +// demand and any wall-clock figure would be a fabrication, and empty for a chain +// with no spacing entry rather than a wrong one. +function roughDuration(blocks, fullName, network) { + const perBlock = BLOCK_MINUTES[fullName] + if (network === 'regtest' || !(perBlock > 0) || !(blocks > 0)) return '' + const minutes = blocks * perBlock + if (minutes < 90) return 'roughly ' + Math.round(minutes) + ' minutes' + const hours = minutes / 60 + if (hours < 36) return 'roughly ' + Math.round(hours) + ' hours' + return 'roughly ' + Math.round(hours / 24) + ' days' +} + +function paren(text) { + return text ? ' (' + text + ')' : '' +} + +/** + * The two clocks that govern a stake, read per-chain from the vendored canonical + * coin registry (src/coins/.js STAKING) rather than restated here. Both + * are per-chain (BTC 6/1000, LTC 24/4032, DOGE 60/10080), so a hardcoded pair + * would misreport two of the three chains. + * + * They are separate clocks and they differ by more than two orders of magnitude, + * which is exactly why both have to be printed. Both count from the same block, + * the one the action lands in (xchain-indexer/src/actions/unstake.js: + * deactivation_block = BLOCK_INDEX + ACTIVATION_DELAY_BLOCKS, COOLDOWN_END_BLOCK + * = BLOCK_INDEX + COOLDOWN_BLOCKS): + * activation: how long a STAKE waits before it counts, and how long an + * UNSTAKEd one keeps counting toward every capability before it drops out. + * cooldown: how long the escrowed XCHAIN stays locked afterwards, until the + * indexer's block-end sweep credits it back and it can be spent again. + * + * Reads the in-repo registry, so the dry-run path stays offline. + */ +function stakeTiming(coins, network) { + const fullName = String(coins.stake).split('-')[0] + const staking = getCoinConfigByFullName(fullName, network).STAKING || {} + return { + activationBlocks: staking.ACTIVATION_DELAY_BLOCKS, + cooldownBlocks: staking.COOLDOWN_BLOCKS, + activationFor: roughDuration(staking.ACTIVATION_DELAY_BLOCKS, fullName, network), + cooldownFor: roughDuration(staking.COOLDOWN_BLOCKS, fullName, network) + } +} + function fail(msg) { const e = new Error(msg) e.validatorStake = true @@ -229,7 +278,8 @@ function openValidatorSession(opts, deps) { */ async function unstakeValidator(opts = {}, deps = {}) { const log = deps.log || console.log - const { coins, pubkey, sdk, session, address } = openValidatorSession(opts, deps) + const { network, coins, pubkey, sdk, session, address } = openValidatorSession(opts, deps) + const timing = stakeTiming(coins, network) // Read the set rather than a per-pubkey lookup (see readChainState): the // SDK exposes no getValidator, and a lookup failure must not read as @@ -262,9 +312,17 @@ async function unstakeValidator(opts = {}, deps = {}) { log(' Steps:') log(' UNSTAKE v0: withdraw the full stake for this pubkey') log('') - log(' The stake keeps counting toward every capability for ' + ACTIVATION_DELAY_BLOCKS + - ' more blocks after this is') - log(' indexed, then drops out of the active set and your XCHAIN is spendable again.') + // Two clocks, printed together on purpose. Leaving the active set and getting + // the coins back are different events an order of magnitude or two apart, and + // an operator told only the first one plans an hour and waits a week. + log(' Two clocks start at the block this lands in, and they are far apart:') + log(' active set: ' + timing.activationBlocks + ' more blocks' + paren(timing.activationFor) + + '. Until then the stake keeps') + log(' counting toward every capability; then it drops out.') + log(' cooldown : ' + timing.cooldownBlocks + ' blocks' + paren(timing.cooldownFor) + + '. The ' + active.amount + ' ' + STAKE_TICK + ' stays locked') + log(' until the cooldown sweep credits it back, and is NOT') + log(' spendable before then.') if (!opts.broadcast) { log('') @@ -284,9 +342,14 @@ async function unstakeValidator(opts = {}, deps = {}) { { waitForIndexer: opts.wait !== false, timeout: timeoutMs, pollInterval: 15000 }) log(' txid ' + r.txid + (opts.wait !== false ? ' (indexed)' : ' (broadcast)')) log('') - log(' Unstaked. You leave the active set ' + ACTIVATION_DELAY_BLOCKS + - ' blocks after the block this landed in;') - log(' until then the federation still counts you, which is why standing down is not instant.') + log(' Unstaked. You leave the active set ' + timing.activationBlocks + ' blocks' + + paren(timing.activationFor) + ' after the block') + log(' this landed in; until then the federation still counts you, which is why standing') + log(' down is not instant.') + log(' Your ' + active.amount + ' ' + STAKE_TICK + ' stays locked for ' + timing.cooldownBlocks + + ' blocks' + paren(timing.cooldownFor) + ' from that same') + log(' block, then the cooldown sweep credits it back and it is spendable. Do not plan') + log(' around having it sooner.') log(' Watch it at ' + explorerUrl(coins, 'validator/' + pubkey)) log('') return { unstaked: true, txid: r.txid } @@ -300,6 +363,7 @@ async function stakeValidator(opts = {}, deps = {}) { const log = deps.log || console.log const { network, coins, pubkey, sdk, session, address } = openValidatorSession(opts, deps) const amount = parseInt(opts.amount) || DEFAULT_STAKE_AMOUNT + const timing = stakeTiming(coins, network) const state = await readChainState(sdk, address, pubkey) const plan = planMints(network, state.tokenBal, amount, state.mintMax, state.mintAddressMax) @@ -336,6 +400,17 @@ async function stakeValidator(opts = {}, deps = {}) { log(' Steps:') for (const s of steps) log(' ' + s) + // The exit cost, stated before the money moves rather than after. Getting the + // stake back is the cooldown clock, not the activation clock, and it is the + // one that decides whether this XCHAIN is reachable next week. + log('') + log(' The ' + amount + ' ' + STAKE_TICK + ' is escrowed for as long as you stay staked. Standing down') + log(' later frees it only after a cooldown of ' + timing.cooldownBlocks + ' blocks' + + paren(timing.cooldownFor) + ', on top of the') + log(' ' + timing.activationBlocks + ' blocks it takes to leave the active set. Do not stake ' + + STAKE_TICK + ' you may') + log(' need before then.') + const blockers = [] if (plan.reason) blockers.push(plan.reason) if (state.coinBal <= 0) blockers.push('no confirmed ' + coins.stakeCoin + ' at ' + address + ' to pay fees; fund it first.') @@ -432,8 +507,10 @@ async function stakeValidator(opts = {}, deps = {}) { if (opts.wait === false) { log(' Broadcast. Watch it land at ' + explorerUrl(coins, 'validator/' + pubkey)) } else { - log(' Staked. The stake activates 6 blocks after it is indexed; peers admit you on their next') - log(' signer-set refresh after that. Watch it at ' + explorerUrl(coins, 'validator/' + pubkey)) + log(' Staked. The stake activates ' + timing.activationBlocks + ' blocks' + paren(timing.activationFor) + + ' after it is indexed; peers') + log(' admit you on their next signer-set refresh after that.') + log(' Watch it at ' + explorerUrl(coins, 'validator/' + pubkey)) } log(' Next: xchain-node install master xchain-hub') log('') @@ -441,6 +518,6 @@ async function stakeValidator(opts = {}, deps = {}) { } module.exports = { - stakeValidator, unstakeValidator, planMints, readChainState, - DEFAULT_STAKE_AMOUNT, ACTIVATION_DELAY_BLOCKS, STAKE_TICK + stakeValidator, unstakeValidator, planMints, readChainState, stakeTiming, + DEFAULT_STAKE_AMOUNT, STAKE_TICK } diff --git a/test/unit/ValidatorStakeService.test.js b/test/unit/ValidatorStakeService.test.js index 294b386..fef7fed 100644 --- a/test/unit/ValidatorStakeService.test.js +++ b/test/unit/ValidatorStakeService.test.js @@ -13,7 +13,10 @@ const sinon = require('sinon') const { expect } = require('chai') -const { stakeValidator, unstakeValidator, planMints } = require('../../src/services/ValidatorStakeService') +const { stakeValidator, unstakeValidator, planMints, stakeTiming } = require('../../src/services/ValidatorStakeService') +// The authoritative source for both stake clocks. Read here too, so a test that +// asserts the CLI's numbers cannot itself become the place they are frozen. +const { getCoinConfig } = require('../../src/coins') const PUBKEY = 'ab'.repeat(32) const ADDRESS = 'mStakeAddress' @@ -129,8 +132,63 @@ describe('ValidatorStakeService', function () { }) }) + // Both clocks are per-chain (BTC 6/1000, LTC 24/4032, DOGE 60/10080). A CLI + // that hardcodes BTC's pair is wrong by 4x on LTC and 10x on DOGE, which is + // the drift these assertions exist to catch. + describe('stakeTiming()', function () { + + it('reads both clocks from the coin registry, not from the CLI', function () { + const btc = getCoinConfig('BTC', 'testnet').STAKING + const t = stakeTiming({ stake: 'bitcoin-testnet' }, 'testnet') + expect(t.activationBlocks).to.equal(btc.ACTIVATION_DELAY_BLOCKS) + expect(t.cooldownBlocks).to.equal(btc.COOLDOWN_BLOCKS) + expect(t.cooldownBlocks).to.be.above(t.activationBlocks) + }) + + it('tracks the per-chain values for litecoin and dogecoin', function () { + for (const [full, tick] of [['litecoin', 'LTC'], ['dogecoin', 'DOGE']]) { + const staking = getCoinConfig(tick, 'mainnet').STAKING + const t = stakeTiming({ stake: full + '-mainnet' }, 'mainnet') + expect(t.activationBlocks, tick).to.equal(staking.ACTIVATION_DELAY_BLOCKS) + expect(t.cooldownBlocks, tick).to.equal(staking.COOLDOWN_BLOCKS) + } + }) + + // Every chain sizes the same two intervals to the same wall-clock targets; + // only the block counts differ. + it('glosses both counts as the same durations on every chain', function () { + for (const full of ['bitcoin', 'litecoin', 'dogecoin']) { + const t = stakeTiming({ stake: full + '-mainnet' }, 'mainnet') + expect(t.activationFor, full).to.equal('roughly 60 minutes') + expect(t.cooldownFor, full).to.equal('roughly 7 days') + } + }) + + it('gives no duration on regtest, where blocks are mined on demand', function () { + const t = stakeTiming({ stake: 'bitcoin-regtest' }, 'regtest') + expect(t.activationFor).to.equal('') + expect(t.cooldownFor).to.equal('') + expect(t.cooldownBlocks).to.be.a('number') + }) + }) + describe('stakeValidator()', function () { + // The exit cost belongs before the money moves. An operator who reads only + // the activation delay budgets an hour for capital locked up for a week. + it('the plan states the escrow and the cooldown that frees it', async function () { + const { logged } = await run({}, { xchain: 30000, coin: '0.001' }) + const out = logged.join('\n') + expect(out).to.include('25000 XCHAIN is escrowed for as long as you stay staked') + expect(out).to.include('cooldown of 1000 blocks (roughly 7 days)') + expect(out).to.include('6 blocks it takes to leave the active set') + }) + + it('the post-broadcast summary takes the activation delay from the registry', async function () { + const { logged } = await run({ broadcast: true }, { xchain: 30000, coin: '0.001' }) + expect(logged.join('\n')).to.include('activates 6 blocks (roughly 60 minutes) after it is indexed') + }) + it('dry run prints the plan and sends nothing', async function () { const { result, calls, logged } = await run({}, { xchain: 0, coin: '0.001' }) expect(result.dryRun).to.be.true @@ -332,10 +390,43 @@ describe('ValidatorStakeService', function () { expect(result.unstaked).to.be.true }) - it('says the delay out loud: the stake keeps counting for 6 more blocks', async function () { + // Leaving the active set and getting the coins back are two clocks nearly + // three orders of magnitude apart. Printing only the first told operators + // their XCHAIN was spendable in an hour when it is locked for a week. + it('the plan prints BOTH clocks: 6 blocks to leave the set, 1000 to unlock', async function () { + const { logged } = await runUnstake({}, { existing: STAKED }) + const out = logged.join('\n') + expect(out).to.include('active set: 6 more blocks (roughly 60 minutes)') + expect(out).to.include('cooldown : 1000 blocks (roughly 7 days)') + expect(out).to.include('25000 XCHAIN stays locked') + expect(out).to.include('NOT') + expect(out).to.include('spendable before then') + }) + + it('the post-broadcast summary repeats both clocks, not just the delay', async function () { const { logged } = await runUnstake({ broadcast: true }, { existing: STAKED }) - expect(logged.join('\n')).to.include('for 6 more blocks') - expect(logged.join('\n')).to.include('leave the active set 6 blocks after') + const out = logged.join('\n') + expect(out).to.include('leave the active set 6 blocks (roughly 60 minutes) after the block') + expect(out).to.include('stays locked for 1000 blocks (roughly 7 days)') + expect(out).to.include('cooldown sweep credits it back') + }) + + // The old text promised spendability at the activation delay. Nothing in + // either message may say the coins come back on that clock again. + it('never claims the XCHAIN is spendable once the stake leaves the set', async function () { + const { logged } = await runUnstake({ broadcast: true }, { existing: STAKED }) + expect(logged.join('\n')).to.not.match(/6 (more )?blocks[^.]*spendable/) + expect(logged.join('\n')).to.not.include('XCHAIN is spendable again') + }) + + // Regtest mines on demand, so a wall-clock gloss there would be invented. + it('omits the duration on regtest, where block spacing is meaningless', async function () { + const { logged } = await runUnstake({}, { existing: STAKED }, + { network: 'regtest', P2P_PORT: undefined }) + const out = logged.join('\n') + expect(out).to.include('active set: 6 more blocks.') + expect(out).to.include('cooldown : 1000 blocks.') + expect(out).to.not.include('roughly') }) it('does nothing when the pubkey carries no valid stake', async function () { From a31e7eec17ae124cee3410a819ffc2c9db2df6f0 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 1 Sep 2026 12:16:33 -0700 Subject: [PATCH 2/4] readme: the version badge reads the released version --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5aa3cc5..12a394c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # XChain Platform Node

- Version + Version Tests Node License From 4023c71be7a3703995727beaebf5d609724b6b08 Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 1 Sep 2026 13:00:16 -0700 Subject: [PATCH 3/4] release: 0.12.3 --- CHANGELOG.md | 7 +++++++ README.md | 8 ++++---- package.json | 2 +- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03168e4..96c781e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.12.3] - 2026-09-01 + +### Fixed +- `validator stake` and `validator unstake` state both clocks: when the stake leaves the active set, and when the escrowed XCHAIN is spendable after the staking cooldown. The old text promised the money back at the activation delay, which is a week early on Bitcoin. +- Both delays are read per chain from the coin registry rather than hardcoded, so Litecoin and Dogecoin print their own values. +- Pinned hub 0.12.3, which tells a peer it is not in the signer set instead of reporting an invalid signature. + ## [0.12.2] - 2026-09-01 ### Added diff --git a/README.md b/README.md index 12a394c..885305a 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ # XChain Platform Node

- Version - Tests + Version + Tests Node License

@@ -156,7 +156,7 @@ Turn it off with any of: `--no-telemetry` on any command (sticks for future runs | Command | Description | |---|---| -| `npm test` | Unit tests (1,766 tests) | +| `npm test` | Unit tests (1,787 tests) | | `npm run test:integration` | Integration tests (103 tests) | | `npm run test:smoke` | Smoke tests (159 tests) | | `npm run test:boundary` | Boundary condition tests (57 tests) | @@ -168,7 +168,7 @@ Turn it off with any of: `--no-telemetry` on any command (sticks for future runs | `npm run test:regression:p0` | Regression P0: critical gate (33 tests) | | `npm run test:regression:p0p1` | Regression P0+P1: standard gate (51 tests) | | `npm run test:mutation` | Mutation testing (Stryker Mutator) | -| `npm run test:all` | All tests (~2,528 tests; excludes security/boundary) | +| `npm run test:all` | All tests (~2,549 tests; excludes security/boundary) | | `npm run benchmark` | Performance benchmarks (5 scenarios) | | `npm run benchmark:quick` | Quick benchmarks | diff --git a/package.json b/package.json index 6645910..8578195 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "xchain-node", - "version": "0.12.2", + "version": "0.12.3", "description": "xchain-node allows users to install, configure and run XChain platform nodes.", "license": "AGPL-3.0-or-later", "repository": { From b4e56c9c467274e5d84e02082691eabfb3049b0a Mon Sep 17 00:00:00 2001 From: J-Dog Date: Tue, 1 Sep 2026 15:32:51 -0700 Subject: [PATCH 4/4] release: pin the v0.12.3 component set --- src/release-manifest.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/release-manifest.json b/src/release-manifest.json index 210d000..c3c352e 100644 --- a/src/release-manifest.json +++ b/src/release-manifest.json @@ -1,17 +1,17 @@ { "_comment": [ - "Pinned component set for XChain Platform v0.12.2.", + "Pinned component set for XChain Platform v0.12.3.", "Written at ceremony step 6 from the ACTUAL tagged master merge commits.", "xchain-node is the carrier and is not listed: checking out its tag IS this manifest.", - "A patch train tags only the repos it touches. This one moves the CARRIER ONLY:", - "every component below is unchanged from v0.12.1 and keeps the tag it already", - "carries, which is what section 4 means by a version being the platform version", - "at which a component last changed. A gap is unchanged, not skipped.", + "A patch train tags only the repos it touches. This one moves the carrier and", + "xchain-hub; every other component is unchanged from v0.12.1 or earlier and keeps", + "the tag it already carries, which is what section 4 means by a version being the", + "platform version at which a component last changed. A gap is unchanged, not skipped.", "Each commit below is the master MERGE commit its tag names, and every tag is", "GPG-signed by the platform release key and reports verified against the", "tagger identity releases@xchain.io." ], - "platform_version": "0.12.2", + "platform_version": "0.12.3", "released": "2026-09-01", "components": { "xchain-vm": { @@ -27,8 +27,8 @@ "commit": "aee2bf21ecd6dc0fa90c26e7108dbedceb609bda" }, "xchain-hub": { - "tag": "v0.12.0", - "commit": "638f2e13b2d9b4b86f6dda9e5dc88455f27a6c55" + "tag": "v0.12.3", + "commit": "bb5b0d180b544ea08b5720b9f8416256fd07641c" }, "xchain-sync": { "tag": "v0.12.0",