Skip to content

Release 4.2.0: node_admin plugin, validator signing-key fix, HF16 no-contest fee freeze - #181

Open
On1x wants to merge 61 commits into
masterfrom
pm
Open

On1x wants to merge 61 commits into
masterfrom
pm

Conversation

@On1x

@On1x On1x commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

Summary

This branch adds an operator-facing admin surface to the node (node_admin plugin), fixes a
consensus-adjacent block production bug, and adds HF16 (freeze the no-contest fee at
announcement). The admin surface is optional and inert unless configured.

node_admin plugin

A separate HTTP listener (loopback by default, bearer tokens, append-only audit) exposing 31
admin.* JSON-RPC methods plus:

  • Streamable HTTP MCP on the same listener and tokens (initialize, tools/list,
    tools/call), one tool per admin method, for AI agents.
  • Built-in web UI at /ui/ — single static page, no external assets; the token stays in
    sessionStorage. Works behind a reverse-proxy prefix (/admin/ui/) after this branch.
  • P2P control: status (head/LIB, peer lifecycle, lag, bans), add_node, add_trusted,
    clear_banned; actions are serialized onto the P2P thread.
  • Snapshots: status, create, in-process restore, authenticated download.
  • Webserver policy: per-IP counters, fixed-window rate limits (minute/hour/day), whitelist
    and blacklist, hot-reloadable and persisted next to the data dir.
  • config.ini / plugin list edits through two-phase plan/apply with a SHA-256 guard and
    atomic replace, plus admin.restart for a supervised restart.
  • Validator operator actions: enable / disable / set signing key for validators, signed by
    validator_guard (no extra keys stored), with a persisted hold file so a disabled operator
    validator is not silently re-enabled by a restart.
  • Diagnostics: node info, plugin state, log tail with an allow-list, audit tail.

Scopes are per token: read (every method), p2p, snapshot, webserver, config,
plugins, chain, process. A token that has only a mutating scope can call nothing without
read; a missing scope is a separate error from a failed action. A non-loopback endpoint is
refused unless admin-allow-remote = true.

Bug fixed: a block producer signed with its own pending key

validate_block_header() checks the block signature against the validator's signing key from
pre-block (committed) state, but maybe_validate_block() read the session-visible
validator object and _generate_block() asserted against that same object. A pending
validator_update is already applied in the pending session, so rotating the producing
validator's own key made the node sign block N with the new key while the header was validated
against the old one: validate_signee failed, the pending pool was cleared and the update was
lost.

Fix:

  • database.cpp::_generate_block checks the key after _pending_tx_session.reset() and
    captures the committed key under the same write lock (otherwise a concurrent
    push_transaction() can re-apply the pending change before the read).
  • validator.cpp keeps a committed-key snapshot (_committed_signing_keys), seeded at startup
    and refreshed from the contents of applied blocks — applied_block fires after
    push_block() has restored the pending transactions, so the database there is not a
    committed-state source.

Regression: new section 10 in validator-e2e.sh rotates the producing validator's key and
back; restart/disable/enable paths keep their coverage.

HF16 — no-contest fee frozen at announcement (CHAIN_VERSION 4.2.0)

pm_no_contest opened a 12-hour dispute window but computed the penalty from the median
chain_properties_pm at settlement, so a parameter vote inside the window repriced an
announced void retroactively. HF16 records no_contest_fee on the market at announcement
(with a separate no_contest_fee_frozen flag: pre-fork rows keep the legacy median path, a
legitimate frozen zero does not fall back), charges exactly that at settlement, and adds a
monotone no_contest_slashed_total to the oracle in the same modify() as the existing
counter. Snapshot import is guarded with contains() for the new fields.

Activation timestamps are placeholders in this branch; the mainnet date is not announced.

Testing

  • plugins/node_admin/tests/acceptance.sh — one-shot node, full HTTP/MCP/UI/auth surface.
  • plugins/node_admin/tests/validator-e2e.sh — validator guard operator paths on a private
    single-slot chain, including the self-rotation regression (62 checks, live run green).
  • tests/node_admin/ — Boost.Test unit tests for the pure helpers (13 cases).
  • tests/consensus_sim/scenarios/test_pm_no_contest_fee.cpp — three markets around the
    activation block (legacy / frozen nonzero / frozen zero), median moved afterwards.
  • Local build + the two shell suites were run against this branch; CI builds the production
    image from this PR. The plugin includes fc/compat/asio_compat.hpp before its websocketpp
    headers: Boost >= 1.87 dropped io_service and the vendored websocketpp still spells it.

Upgrade notes

  • node_admin is inert unless admin-endpoint and at least one admin-token are configured;
    the plugin refuses to start with an endpoint and no token.
  • Config edits made through admin.config.apply are read at startup and need admin.restart.
  • HF16 adds fields to pm_market_object / pm_oracle_object, so the shared-memory layout
    changes: redeploy testnet from a snapshot (the node already runs with
    --replay-from-snapshot --snapshot-auto-latest).

On1x added 27 commits October 16, 2026 04:09
docker.md and validator-node.md already recommend it; getting-started
(en/ru/zh-CN) and README did not, so a node started from them stays
down after a host reboot.
pm_no_contest today opens a 12-hour dispute window and leaves the penalty to be
computed from the median chain_properties_pm of the settlement moment, so a
parameter vote inside the window silently repriced an announced void. The fork
records the fee on the market at announcement and charges exactly that.

Consensus:
* pm_market_object gains no_contest_fee + no_contest_fee_frozen. The flag is
  separate from the amount on purpose: a row announced before the fork has
  frozen == false and keeps the legacy median computation (no seed pass), while
  a legitimate frozen zero (penalty percent voted to 0) must not fall back to a
  later vote.
* pm_no_contest_evaluator freezes max(0, dispute_fee * pct / 10000) at
  announcement; settle_market(win < 0) charges the frozen value under HF16.
* pm_oracle_object gains no_contest_slashed_total, grown in the same modify()
  as total_insurance_slashed, for exact attribution without a virtual op.
* Registration in hardfork.d/16.hf + database_hardfork.cpp, CHAIN_VERSION 4.2.0
  in both configs, snapshot import guarded by contains() for the new fields.
  Activation timestamps are placeholders pending the owner's mainnet date.

API (no consensus):
* get_oracle / list_oracles: no_contest_pending_count / _stake / _fee_nominal /
  _fee_capped over by_oracle_finalized (open obligations only, per-block cache).
* market_card: settlement_charge — the price of a void before it is charged.
* get_pm_chain_properties: derived pm_no_contest_fee_per_market, additive.

Proof: tests/consensus_sim/scenarios/test_pm_no_contest_fee.cpp drives three
markets around the activation block (legacy, frozen 1u, frozen 0), moves the
median afterwards, and measures the insurance drop and the counter: 9u total
(8 + 1 + 0). pm_lifecycle_suite: 72 tests, no errors.

Docs: operator checklist in English and Russian, API reference updated.
…ly v1)

An administrative JSON-RPC endpoint inside the node process, for operator tooling
and AI agents. It opens its own HTTP listener rather than riding on webserver:
that one is published through nginx, so every request already arrives as
127.0.0.1 and a source-address check there protects nothing.

- Authentication: `label:scope[,scope]:sha256hex[:expires-unixtime]`, only the
  digest is stored, comparison is constant-time, and a token carrying no `read`
  scope gets -32002.
- Guards: loopback-only unless `admin-allow-remote`, no listener without tokens,
  POST only, 64 KB body cap, 20 req/s per token, log tails restricted to an
  explicit `admin-log-file` allow-list.
- Read-only methods: ping, node_info, diagnostics, plugins.list, config.get,
  logs.tail, audit.tail. Numbers are emitted as JSON numbers, not fc's default
  quoted form.
- Every outcome, including failed authentication and rate limiting, appends one
  JSON audit line with a label, never the token or its hash.
- p2p: add get_connections_count_sync() — the peer map belongs to the p2p thread,
  so diagnostics reads it through fc::thread::async().wait().
- Docs in English and Russian; live acceptance in
  plugins/node_admin/tests/acceptance.sh (35 checks against a throwaway node).

Mutating methods, plan/apply and restart are the next stage.
Add the Model Context Protocol (Streamable HTTP) to the same loopback
listener, with the same bearer tokens and audit trail (q#3052=B: no
sidecar). Methods: initialize, ping, tools/list, tools/call;
notifications are answered with 202 and an empty body. Tools map 1:1 to
the read-only admin.* methods and report execution errors via isError,
while protocol errors stay JSON-RPC errors. Sessionless, no SSE.

Extend acceptance with 16 MCP checks (51 total, all passing).
Expose the P2P picture the node already logs (head/LIB, DLT range, lag behind
the best peer, uptime, per-peer lifecycle and ban state) as admin.p2p.status.
Add the three operator actions the owner asked for: admin.p2p.add_node,
admin.p2p.add_trusted (never soft-banned, kept through known-peer GC) and
admin.p2p.clear_banned. Mutating methods need a token with the p2p scope on
top of read; all three are serialized onto the P2P thread.

Serve a minimal static page at GET /ui/ from the same listener: token in
sessionStorage, status cards, peers table, add/trusted/clear actions, five
second refresh. The page is unauthenticated by design and carries no data;
CSP restricts it to inline assets and same-origin fetch.

Expose the same four methods as MCP tools (11 total) and cover everything in
the acceptance suite (80 checks, including scope denial and the UI route).
Add plan/apply editing of config.ini behind the admin API:

- config.plan validates allow-listed keys (p2p, dlt, webserver, validator-guard),
  normalizes booleans and range-checks integers, and returns the exact diff
  without writing anything. plugins.plan validates the plugin list and refuses
  unknown names, duplicates and removal of chain/node_admin.
- Plans are single-use, bound to the token that created them, expire after
  admin-plan-ttl seconds, and are capped at 64 outstanding entries.
  plan.cancel discards one explicitly.
- apply recomputes the file hash and refuses when config.ini changed since the
  plan, then rewrites raw lines (comments, order, spacing and CRLF preserved)
  through a temp file, fsync and rename, after writing a .bak-<unixtime>
  backup. A config_write_mutex serializes concurrent applies.
- config.backups lists the backups under the plain read scope.
- admin.restart (process scope) asks appbase to quit after the reply flushes;
  the supervisor is what brings the node back, and the reply says so.

Acceptance covers scope and token binding, allow-list, expiry, stale hash,
cancel, replay, backups, plugin protection, a real restart and a
supervisor-style start that reads the edited config back. Unit tests cover the
line editors, normalization and plugin guards.
Add admin.validator.list / plan / apply (scope "chain") and the matching MCP
tools. Reads stay under "read"; plan and apply require a token with "chain"
AND a started validator_guard configured with active keys, so the scope alone
never authorises a signature.

validator_guard gains chain_actions_available(), validators_status() and
apply_validator_actions(). Actions are built and signed inside a weak read
lock, then accepted and broadcast after the lock is released (the same rule
disable_on_shutdown follows). Plans carry the observed signing key; apply
re-checks it and reports "chain state changed" instead of signing a stale
intent.

A successful disable writes an operator hold to
<data-dir>/validator-guard-holds.txt. The periodic auto-restore skips held
validators and startup reads the file back, so a redeploy cannot silently
re-enable block production. The hold follows the outcome, never the intent:
a failed disable leaves no latch, and enable/set_key clear it.

Docs (EN and RU) describe the methods, the plan/apply contract, the hold
semantics and the test coverage; acceptance.sh now covers the gate (guard
missing, guard loaded without an active-key validator) and the tool count.
The signing happy path was verified on a scratch chain with the validator
plugin and cli_wallet.
Covers admin.validator.list/plan/apply on a scratch testnet chain:
disable/enable/set_key, hold persistence across restart, stale plan
rejection, a two-action plan (one tx per action), and contract corners.

Staging notes in the script: register/vest/vote the producing validator
before HF9, run operator actions on a non-scheduled validator, and never
rotate the producing validator's own key (the producer reads the signing
key from session state, so the update is lost - tracked separately).

Docs (EN/RU) name the script and both load-bearing decisions.
A block header is validated against the validator signing key committed at the
previous block, but the producer read the key from the session-visible validator
object, where a pending validator_update is already applied. Rotating the
producing validator's own key made it sign the block with the new key:
validate_signee failed, the pending pool was cleared and the update was lost.

- database: check the signing key after _pending_tx_session.reset(), reading the
  committed object under the same write lock (a concurrent push_transaction after
  the lock is released would make the object session-visible again).
- validator plugin: keep a committed-key snapshot. Seed it at startup and refresh
  it from applied block contents - an applied_block callback runs after
  push_block() has re-applied the pending pool, so a DB read there sees
  uncommitted state. effective_signing_key() returns the committed key when the
  session-visible one differs and the snapshot is for the current head.
- validator-e2e: new section 10 rotates the producing validator's own key and
  rotates it back; the chain keeps producing and the key changes on chain
  (62 passed, 0 failed).
The paragraph still described the pre-fix behaviour (the producer read the
session-visible key, so rotating the producing validator's key lost the
update) and even pinned a source line number. That premise no longer holds:
the producer signs with the key committed at head, and section 10 covers
self-rotation. Reword to what cold is actually for and drop the line ref.
The page always called '/' for JSON-RPC and '/snapshot/…' for downloads, so it only
worked when the listener was reached at its root. Operators also publish it through
nginx under a path (e.g. /admin/ui/), where those absolute URLs leave the admin
listener entirely.

Derive the base from location.pathname (strip a trailing '/ui' or '/ui/'), exactly as
public API clients do for their own base. At the root the value is '/', so direct
access is unchanged. Documented in both locales.
Boost >= 1.87 dropped boost::asio::io_service, which the vendored
websocketpp 0.8.2 still spells ~100 times. webserver_plugin.cpp already
includes fc/compat/asio_compat.hpp for this; node_admin did not, so the
production image (Boost 1.91) failed to build the plugin while a local
build against an older Boost succeeded.
ctest found eight tests but the workflow built only the targets listed in
tests/pm and tests/consensus_sim, so tests/node_admin registered its
node_admin_util test and ctest reported it as "Not Run" (exit 8) while
every binary that was built passed. Take the list from every
tests/*/CMakeLists.txt under tests/.
…ve logs

The built-in /ui/ page could not tell a read-only token from an admin one:
Create snapshot, Restore latest and the peer actions were always shown, the
API refused them only after the click. It also knew nothing about emergency
consensus and could not read the node's own log.

Backend:
- admin.auth.me returns the token's label and scopes (never the token/hash).
- admin.snapshot.status takes offset/limit (default 20, max 200) and reports
  snapshot_total and has_more.
- admin.logs.list names the allow-listed appenders with size and existence.
- admin.validators.active returns the scheduled validator names, the schedule
  size and the emergency consensus state.
- admin.node_info gains emergency_consensus_active/start_block,
  emergency_key_configured and recovering.

UI:
- Status / Peers / Snapshots / Logs / Validators tabs.
- actions are hidden when the token lacks the scope they need.
- snapshots: newest 20 with Load more; the download button disables itself and
  shows a radial spinner, so repeated clicks cannot start parallel downloads.
- logs: tail an allow-listed file, paused by default, Play polls every 2s and
  appends at the bottom; auto-scroll yields when the operator scrolls up.
- status shows the emergency consensus state and whether an emergency key is
  configured; snapshot state says what idle/creating/restoring means.

Acceptance: auth.me, logs.list, validators.active, paging fields and the
emergency fields are now checked; tool count 37.
…the tabbed UI

Describe admin.auth.me, admin.logs.list, admin.validators.active and the new
offset/limit fields of admin.snapshot.status. Rewrite the built-in UI section
around the five tabs, scope-driven action hiding, Load more paging, the
disabled download button with its radial spinner, and the paused-by-default
live log tail. Both the English and the Russian document, same content.
…fset

A snapshot written between two pages shifted every offset below it, so the UI
repeated a row on Load more; the 24 h retention sweep shifts the other way and
made offsets skip a row. admin.snapshot.status now takes after=<last name the
client has> and continues after that exact row; a pruned anchor is reported as
after_found=false and offset applies. The UI anchors to the last shown row,
dedupes by name and restarts from page 1 when its anchor was pruned.
The stock file appender rotates hourly and keeps a day, so debug logging can
grow to gigabytes per hour. Add an opt-in capped_file appender that keeps one
file with only the last max_lines lines (default 10000, 0 = unlimited).

- trim walks the file backwards in 64 KiB chunks, so a startup on an oversized
  log costs a bounded tail read rather than a full scan; the rewrite goes to a
  temp file and is renamed into place
- compaction is amortised: it runs only after the file passes max_lines plus
  10% slack, and the appender counts lines as it writes, so no per-line stat
- nodes without a log.capped_file_appender.* section are unaffected; the
  appender is registered with its own type name and stays unknown to older
  binaries
- node_admin already reads any path, so admin.logs.list/tail pick the file up
  with no API change

Tests: tests/logging (8 cases) cover the tail contract and live compaction.
The file appender rotates hourly and keeps a day, which is not a size cap: at
debug level that is still gigabytes per hour. Say so, and point operators at
the optional capped_file appender for a hard line limit.
The file appender is configured to rotate hourly with one-day retention, but the
rotation chain runs on fc's fiber queue and vizd's main thread lives in
boost::asio after startup, so it never runs. Measured 2026-10-20: a testnet
node wrote one 37 MB file in nine hours at info, with no hourly files; the
mainnet node ran four days without a new one.

Correct the logging docs in en/ru/zh and the node-admin page: the file is
bounded by neither time nor size, and capped_file is the only real cap.
…he file

The line counter ran after a failed trim as well, i.e. exactly when the log is
largest (a full disk is a plausible reason the trim itself failed). Reading the
whole file into an ostringstream there would allocate a second copy of a log that
may be gigabytes — the appender would OOM while reporting that it cannot cap the
file. Count in 64 KiB chunks instead; a missing file still counts as zero lines.

Tests: three new cases (25k lines, missing file, a line spanning chunk borders).
Measured on real images: an older binary (4.1.2, no capped appender) starts fine with a
config that contains the new section, ignores it and keeps logging to stderr — so the
section can be deployed before the upgrade. A new node whose path cannot be opened also
starts, prints one 'cannot open log file' line to stderr, and leaves no file.
tests.yml now discovers every binary named by add_test, so it builds all
six pm_* targets (plus node_admin_util and capped_file_appender) with the
same configure flags and runs the whole ctest — a superset of the
`ctest -R '^pm_'` performed here. The only PRs it skips are docs-only
ones, where there is nothing to test.

So the `pm-tests` label no longer adds coverage; the gaps that remain are
`check-boost-range` (the only job building `vizd` itself on Boost 1.83)
and `testnet-config` (the only job running consensus_sim with testnet
constants, where the hardfork registry is validated). A skipped gate is
not a passing one: a PR with zero labels shows these as skipped next to
green checks.
@On1x On1x added check-boost-range Also run the distro Boost 1.83 range-floor build testnet-config Compile the testnet config and run consensus_sim (fork-gated PM regressions) labels Oct 20, 2026
Owner decision (Q#3081=A): the plugins panel shows the existing allow-list
grouped by plugin, plus log settings. Two gaps were in the way.

Sectioned keys. The config editor only touched top-level options, so
logger.* and log.* could not be edited at all. config_spec now names its
ini section, and plan/apply address the line through
set_config_value_in_section(). A missing section is an error rather than an
insert: inventing [logger.foo] would configure a logger the node never
declares and the next start would ignore it silently. CRLF and the spacing
around '=' are preserved exactly as in the top-level editor.

Logging keys. logger.default.level and logger.p2p.level accept
all/debug/info/warn/error/off; log.capped_file_appender.node.max_lines and
.flush bound the file that can fill the disk. Appender filenames stay
operator-only.

API for the UI. admin.config.get gains 'allow' -- key, group, section, type,
current value (null when the file relies on the code default), bounds and
allowed values -- so the panel renders without a second source of truth.
admin.plugins.list gains 'removable' (chain and node_admin render as
non-toggleable rather than merely off) and 'params' per plugin.

Verified by -fsyntax-only on both translation units; tests for the new
editor and normalizer are in node_admin_util_test.cpp.
On1x added 30 commits October 20, 2026 11:45
The verdict decides whether an operator is invited to wipe node state, so the
judge now lives in objects_check.hpp with no fc or chainbase dependency, and a
Boost.Test target drives every branch: healthy, crash lag (warn, not corrupted),
truncation with and without a block log, exhausted memory alone (no repair
offered) and beside another critical (replay), stale markers, schema mismatch,
core index counts above and at genesis, the 5% free-space boundary, a wedged
read lock (unreadable, no repair), and warn-only recovery/block-log cases.

The plugin keeps the collector and maps the judgement onto the JSON response.
…eptance

The acceptance script now drives the two features added this morning: a started
plugin with its own view (p2p detail), an unlinked type answering state: null
with a reason, a started plugin without a status API, and the missing-name
refusal; then the chainbase view (index count matches the array, largest first,
schema matches the binary, a clean verdict on a fresh node) and the repair
refusals that must not write a marker (read scope, missing confirm, unknown
mode, replay with an empty block log).

The repair block only exercises paths that return before the marker is written,
so the throwaway node survives the whole suite.
std::string::compare(pos, len, literal) compares a substring of length len
against the whole literal, so compare(0, 2, "p2p-") never matches. Use the
literal's own length, as the other branches already do. Caught by
node_admin_util/config_groups_map_to_plugins in CI.
A node started with --replay-from-snapshot rebuilds state from the snapshot
before it ever reaches the force_replay path, and comes back from the
snapshot on force_resync rather than from genesis. The repair reply kept
promising the generic block-log wording. Capture the startup options and say
what will happen; expose snapshot_replay so a client can tell the cases
apart. Docs updated in both locales.
Move repair_effect into the fc-free judge header so the snapshot-mode wording
is covered like the verdict it accompanies: four shapes (plain replay,
plain resync, snapshot-replay, snapshot-backed resync).
The suite proved the gate (read token, confirm word, empty block log) but never
performed a repair, so a handler that refused everything would have passed it.
Play the supervisor on the throwaway node: request a resync, wait for the clean
exit, restart, and check that the startup log shows the marker was honoured and
that the marker itself is gone.
config.ini writes '\''plugin = chain p2p'\'': appbase splits the value on whitespace and
loads every name. The list kept the whole line as one name, so on a real config
p2p had no lifecycle state, no status_method and no grouped parameters, and
plugins.plan rejected 'remove p2p' as not configured. Split the value in the
list, the diagnostics counter and the editor; removing one name from a
multi-name line now rewrites the line instead of dropping its neighbours.

The acceptance fixture had one name per line, which is why this never showed
up in CI: it now mirrors the live shape, looks rows up by name instead of
position, and checks that a multi-name line yields separate rows.
appbase reads config.ini through boost's config parser, which treats '#' as a
comment to the end of the line. The admin view kept the comment inside the
value, so `plugin = chain # why` listed "#" and "why" as plugin names, and a
rewrite could drop the operator's note. Cut the value at '#', keep the tail in
cfg_line::comment and re-append it when a line is rewritten.
Startup precedence is resync > replay: the chain plugin drops a force_replay
marker when force_resync is present. The repair handler wrote its own marker and
left a stale opposite one alone, so a confirmed "replay (keeps blocks)" under a
leftover force_resync (the p2p wedge watchdog writes those) would resync and
drop the block log — the response promising the opposite of what runs.

Clear the opposite marker before writing ours, refuse with -32000 if it cannot
be removed, and name it in removed_marker. Acceptance gains a second repair
cycle that plants a stale force_replay, checks it is reported and gone, and that
the next startup honours only the requested marker.
The chain plugin opens its database with <data-dir>/state as the data dir, so
block_log and schema_version live next to shared_memory.bin. The objects
collector read them from the application data dir: on every healthy node it
reported "no schema, no block log", the judge turned that into verdict
"corrupted", and the panel offered a resync. node_info read shared_memory.bin
from the same wrong place, so its size was silently missing.

Resolve schema_version and block_log under <data-dir>/state, and shared memory
through chain.get_state_dir() (shared-file-dir). A block log carrying only
chainbase's one-byte placeholder now counts as empty: replay_available is false
and a replay repair is refused instead of reindexing zero blocks.

objects.list always emits lock_error (null when the lock answered) so a client
can tell "no error" from "field missing". Acceptance gains a block-log presence
check; the unit suite drives the placeholder boundary.
The suite was written but never executed: it needs a built vizd, and host builds
are forbidden here. Run against the CI image it reported 30 failures, and 27 of
them were one environment defect — a Docker container without --user leaves the
data dir root-owned, so the suite's own fixtures (snapshots, config.ini edits,
repair markers) failed with permission denied and the cascades looked like
product failures.

Stop that class at the source: refuse to run when the node's state dir is not
writable by this user, and document the --user invocation in the header. Also
bring the suite in line with the code it tests: 41 MCP tools (the four objects
and plugins.status tools were added), plus existence checks for them, and an
explicit check that the block log is found in <data-dir>/state.
edit_plugin_lines() receives lines from split_lines(), which removes the
'\n' and keeps only a trailing '\r'; join_lines() puts the newline back.
The CRLF case passed "...\r\n" in, so the rewrite dropped the newline
the fixture had smuggled in and the unit test failed on CI. Feed the
fixtures the shape production actually passes, and assert the round trip
through join_lines. The inline-comment case had the same malformed
fixture and only passed because the comment carried the tail.
Validators asked for documentation links next to plugins. Plugins with a page of
their own link to it, the rest to their section of plugins/overview, unknown ones
to the overview itself. node_admin's own page ships with this branch and goes live
with the master merge.
Status output used to land in a box at the very bottom of the Plugins tab, far
from the button that produced it, and the parameters of all plugins were one
flat list. Each row now has Status and Parameters buttons (disabled when the
plugin has neither); below the table a tab per plugin with something to show
holds its state, documentation link, refreshable status and its own
parameters with Save. The buttons select the tab and scroll to the section,
the selected row is highlighted, the choice survives a reload, and switching
plugins asks before discarding unsaved parameter edits.
The per-plugin panel moved the answering method to a side note and dropped the
'state: ... (source)' line when a detail was available, so a copied status no
longer said what was running or which call answered. Restore both at the top of
the box. Caught by the live testnet UI acceptance harness.
The owner set the mainnet date. Both configs now carry 1793404800 instead of
the unannounced placeholders (2026-11-15 testnet, 2026-12-01 production); the
fee-freeze doc names the date in both locales.
transaction_id_type and block_id_type were declared as fc::ripemd160, a name
inherited from Graphene, but neither is a ripemd160 digest: the transaction id
is the first 20 bytes of sha256 over the unsigned transaction, the block id the
first 20 bytes of sha224 over the header with the block number in bytes 0-3.
Declare both through the alias id160_type (same type, same bytes, no consensus
or wire change) and say so in types.hpp. checksum_type stays as is: the merkle
root really is ripemd160 over the sha256 tree root.

New page docs/api/ids-and-hashes.md (en, ru, zh-CN) with the exact recipes for
clients and why the hash is not changed by a hardfork.
After a fork switch the post-validation signature for the dropped block has
already left the node, and nothing stopped the validator from signing the
replacement block at the same height. With enough validators doing that, both
blocks of one height can reach the 2/3 threshold and different nodes advance
LIB onto different blocks. Today forks are rare; with shorter slots they would
not be.

The validator plugin now records, per validator, the block id it signed at
each height above LIB, writes the record before the signature is broadcast,
persists it in <data-dir>/post-validation-signed.txt so a restart does not
forget it, and refuses a different id at a height already signed (logged once
per height). Re-signing the same id, which the 250 ms tick does by design,
stays allowed. Signer-side rule only: no consensus or wire change, ships with
4.2.0 (HF16). Documented in consensus/block-processing (en, ru, zh-CN).
The DLT network redesign reduced broadcast_block_post_validation to a
signature-less fork_status and dropped every apply_block_post_validation
call, so validator signatures never left the node and LIB fell back to
the producer rule (mainnet head-LIB = 11 blocks).

- dlt_block_vote_message (5117): block_num, block_id, round, validator,
  signature over block_vote_digest (domain tag + chain_id + num + round
  + id). The validator plugin now signs this digest (round 0).
- hello.capabilities (optional trailing field, ignored by v4.1.0): votes
  go only to peers advertising DLT_CAP_BLOCK_VOTE, so old peers never
  score an unknown packet against us.
- database::push_block_vote: scheduled validator, signing-key recovery,
  height/round bounds, one vote per (validator, height, round);
  a second id is logged as equivocation and not counted. A quorum
  (>2/3, not below the irreversibility threshold) forms a node-local QC
  and logs the time from first vote to quorum.

Consensus behaviour is unchanged: nothing reads the QC yet. LIB from
QC, parent_qc in the header, skip votes and rounds follow under HF16.
update_last_irreversible_block takes the highest QC height that
certifies the block on our main branch (capped at HEAD-1, skipped in
emergency mode) when it is above the producer-rule LIB. A QC for a
block we do not have, or for a sibling, is ignored.

Node-local like the pre-DLT post-validation LIB it replaces; no
evaluator reads LIB, so chain state stays deterministic.
New block header extension block_qc_certificate (variant index 3,
appended): block_num, block_id, round and a flat_map of validator
signatures over block_vote_digest.

Rule (validate_block_qc, run in _apply_block before current_validator
is overwritten): after HF16, a block whose parent was produced by a
different validator must carry a QC of that parent - quorum of
scheduled validators, each signature recovering to the voter's signing
key. Exempt: a validator's own series, emergency mode, and the first
block after an emergency (committee blocks never get a QC). The
extension is rejected before HF16.

Producer side: the validator plugin skips the slot while the head has
no QC instead of forking; _generate_block attaches the certificate,
re-verified against current state so it never carries a signature the
consensus check would refuse. Vote verification is shared
(verify_block_vote) between gossip intake and header validation.

Known gap, next step: votes are round 0 only, so a split vote at one
height can stall the handoff; rounds with growing timeouts and the
lock rule follow.
A certified block is final only when its child at the next height is
certified too, both on our main branch; LIB = that parent. A single QC
is safe only while voting has one round: with rounds, a validator that
voted for X need not see X's QC and may vote for Y later, so a second
QC at the same height becomes possible. The child's QC (whose header
carries X's QC) closes that window.

Parent <= head-1 by construction, so the HEAD-1 cap still holds.
Round = slot. The parent is committed only when it and its child are
certified on our branch AND the child sits in the very next slot. A
child after skipped slots was built on a timeout certificate; a parent
certified before that timeout is not yet safe to commit.
… step 3a)

Votes now carry the block's slot as their round (timestamp / interval)
instead of 0, end to end: validator signs block_vote_digest with it,
p2p broadcast passes it, push_block_vote accepts a window around the
head slot, and a parent QC must carry the parent's slot.

The validator plugin replaces the one-signature-per-height guard with
the Jolteon rule: vote for a block of round r only when r is above the
highest round already voted. Candidates are voted in ascending round
order; the round is persisted to <data-dir>/block-vote-round.txt
BEFORE the vote leaves, and a vote whose round cannot be persisted is
not sent. Unlike the height guard, a validator may vote for a different
block at the same height in a later round, so a split vote can resolve
instead of stalling the handoff.

Equivocation tracking is keyed by (validator, round).

Not yet complete: the lock half of the rule (vote only for a block whose
QC is from the previous round, or that carries a TC) needs QC in every
block and timeout certificates - the next steps. HF16 is not active on
any network.
Drop the own-series exemption from block_needs_parent_qc. The Jolteon
commit rule (two certified blocks in consecutive rounds) needs every
block certified; with the exemption a series could build past a parent
that never gathered a quorum, and LIB would stall on it. Emergency mode
and the first block after it stay exempt.
…tep 4a)

When a pushed block competes with head at the same or lower height, the
branch whose tip carries the certificate of the later round wins; equal
rounds fall through to the HF12 vote-weighted rule. The round is only
claimed at this point: switch_forks applies the block and
validate_block_qc rejects a forged certificate, which reverts the
switch. Skipped in emergency mode.

Groundwork for timeout certificates: a late block of a timed-out round
becomes head but never gets a QC, and the block built on the TC must be
able to replace it at the same height.
… parent QC

a27dff3 made the parent QC mandatory for every block; the single-node
simulator has no p2p, so pm_no_contest_fee_three_states (forces HF16)
stalled on 'No QC for head'. Tally the producer's vote directly.
4cc99be voted for the block just produced, but the scenario switches HF16
on after block #23, so #23 never got a vote. Vote for head when it lacks a QC.
The scenario schedules two validators (initiator + genesis), so the QC
quorum is two and the producer's vote alone never certified head #23.
Move the vote out of the generic harness into the scenario, which knows
both keys.
With a one-validator schedule (testnet, consensus_sim) the classic LIB
equals head, so push_block_vote dropped the vote for head as 'already
final' and HF16 could never certify head: the next producer stalled on
'No QC for head'. Settle only below head.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

check-boost-range Also run the distro Boost 1.83 range-floor build testnet-config Compile the testnet config and run consensus_sim (fork-gated PM regressions)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant