diff --git a/.github/workflows/docker-pr-build.yml b/.github/workflows/docker-pr-build.yml index 1a9c67bc49..567d2613de 100644 --- a/.github/workflows/docker-pr-build.yml +++ b/.github/workflows/docker-pr-build.yml @@ -89,6 +89,19 @@ jobs: cache-from: type=gha,scope=pr-${{ github.base_ref }} cache-to: type=gha,scope=pr-${{ github.base_ref }},mode=max + # HF16 QC stand (#6618): 21-validator testnet image for the tc-netem bench. Only built + # for PRs from `pm`, where the QC work lives; other PRs skip it. + - uses: docker/build-push-action@v7 + if: github.head_ref == 'pm' + with: + context: . + file: ./share/vizd/docker/Dockerfile-testnet + build-args: TESTNET_VALIDATORS=21 + push: true + tags: vizblockchain/vizd:${{ steps.tag.outputs.value }}-testnet21 + cache-from: type=gha,scope=pr-testnet21 + cache-to: type=gha,scope=pr-testnet21,mode=max + # The Docker build above proves the top of the supported Boost range (1.91, # from the vizblockchain/vizd:boost-base base image). The tree is also supposed to build # against the 1.83 that Ubuntu 24.04 ships, and nothing else covers that end. @@ -137,6 +150,16 @@ jobs: # touching PM consensus math, evaluators, crons, or chain properties. Builds only the # tests/pm targets (chain/protocol/fc compile as make-dependencies); the consensus_sim # harness is configured by BUILD_CONSENSUS_TESTS but never built here. + # + # 2026-10-20: this job is now a SUBSET of tests.yml and is kept only as a fast manual + # run. tests.yml discovers every binary named by add_test (so it builds all six pm_* + # targets plus node_admin_util and capped_file_appender), configures with the same + # flags, and runs the whole ctest — a superset of `ctest -R '^pm_'` here. Measured: + # the six binaries below are all in tests.yml's list. It skips only docs-only PRs, + # where there is nothing to test. Missing coverage is therefore NOT pm-tests but + # check-boost-range (the only job that builds `vizd` itself on Boost 1.83) and + # testnet-config (the only job that runs consensus_sim against testnet constants). + # A skipped gate is not a passing gate: a PR with 0 labels reports these as skipped. pm_tests: name: PM unit tests (ctest) runs-on: ubuntu-latest @@ -182,58 +205,3 @@ jobs: set -euo pipefail cd build ctest -R '^pm_' --output-on-failure - - # No other job compiles the testnet configuration, and no other job runs the - # fork-gated PM regressions. tests/consensus_sim/scenarios/test_pm_audit_fixes.cpp - # is appended by CMake only under -DBUILD_TESTNET=TRUE (the scenarios there need - # HF14 to activate, which the single-validator fixture can only reach with testnet - # constants), while the default Docker build never passes that flag and pm_tests - # builds only the PM unit targets. Compiling alone would not be enough either: the - # hardfork registry is validated by asserts that fire when a node opens the - # database (last_hardfork <= CHAIN_NUM_HARDFORKS, _hardfork_versions[last] <= - # CHAIN_VERSION), so the simulator has to actually run. Gated on the - # `testnet-config` label because the consensus_sim link is heavy. - testnet_config: - name: Testnet config + consensus_sim - runs-on: ubuntu-latest - if: contains(github.event.pull_request.labels.*.name, 'testnet-config') - timeout-minutes: 120 - - steps: - - uses: actions/checkout@v6 - with: - submodules: recursive - - - name: Install distro Boost and build deps - # Runners fetch packages from azure.archive.ubuntu.com; when that mirror goes quiet - # apt keeps retrying it and the job hangs for hours instead of failing (seen - # 2026-08-19). Pin the canonical mirror, bound the wait, and cap the step. - timeout-minutes: 15 - run: | - set -euo pipefail - sudo sed -i 's|azure.archive.ubuntu.com|archive.ubuntu.com|g' \ - /etc/apt/apt-mirrors.txt /etc/apt/sources.list 2>/dev/null || true - apt="sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=20 -o Acquire::https::Timeout=20" - $apt update - $apt install -y --no-install-recommends \ - build-essential cmake libboost-all-dev libssl-dev \ - libbz2-dev liblzma-dev libzstd-dev libreadline-dev \ - libtool ncurses-dev pkg-config zlib1g-dev - - - name: Configure and build the consensus simulator (testnet config) - run: | - set -euo pipefail - sed -i '/add_subdirectory(tests)/d' thirdparty/fc/CMakeLists.txt - mkdir -p build && cd build - cmake -DCMAKE_BUILD_TYPE=Release \ - -DBUILD_SHARED_LIBRARIES=FALSE \ - -DCHAINBASE_CHECK_LOCKING=FALSE \ - -DBUILD_TESTNET=TRUE \ - -DBUILD_CONSENSUS_TESTS=ON .. - make -j"$(nproc)" consensus_sim_tests - - - name: Run the simulator - run: | - set -euo pipefail - cd build - ctest -R '^consensus_sim$' --output-on-failure diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1a00a822a4..fac4083815 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -51,8 +51,10 @@ jobs: -DCHAINBASE_CHECK_LOCKING=FALSE \ -DBUILD_CONSENSUS_TESTS=ON .. # Build exactly the binaries ctest will run, so a new add_test never shows up as "Not Run". - TEST_BINS=$(grep -ho 'add_test(NAME [a-z_]* COMMAND [a-z_]*' \ - ../tests/pm/CMakeLists.txt ../tests/consensus_sim/CMakeLists.txt | awk '{print $NF}') + # Discover the suites instead of naming them: a hardcoded list silently skipped + # tests/node_admin and ctest reported its only test as "Not Run". + TEST_CMAKE=$(find ../tests -mindepth 2 -maxdepth 2 -name CMakeLists.txt) + TEST_BINS=$(grep -ho 'add_test(NAME [a-z_]* COMMAND [a-z_]*' $TEST_CMAKE | awk '{print $NF}') make -j"$(nproc)" $TEST_BINS - name: Run tests @@ -69,3 +71,61 @@ jobs: name: consensus-sim-failures path: tests/consensus_sim/failures/ if-no-files-found: ignore + + # Mandatory on every code PR and master push: real two-signer BFT coverage + # must not depend on a label. The N=1 arm also covers fork-gated PM scenarios. + testnet_config: + name: Testnet config + consensus_sim (${{ matrix.validators }} validators) + strategy: + fail-fast: false + matrix: + validators: [1, 2] + runs-on: ubuntu-latest + timeout-minutes: 120 + + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Install distro Boost and build deps + # Runners fetch packages from azure.archive.ubuntu.com; when that mirror goes quiet + # apt keeps retrying it and the job hangs for hours instead of failing (seen + # 2026-08-19). Pin the canonical mirror, bound the wait, and cap the step. + timeout-minutes: 15 + run: | + set -euo pipefail + sudo sed -i 's|azure.archive.ubuntu.com|archive.ubuntu.com|g' \ + /etc/apt/apt-mirrors.txt /etc/apt/sources.list 2>/dev/null || true + apt="sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=20 -o Acquire::https::Timeout=20" + $apt update + $apt install -y --no-install-recommends \ + build-essential cmake libboost-all-dev libssl-dev \ + libbz2-dev liblzma-dev libzstd-dev libreadline-dev \ + libtool ncurses-dev pkg-config zlib1g-dev + + - name: Configure and build the consensus simulator (testnet config) + run: | + set -euo pipefail + sed -i '/add_subdirectory(tests)/d' thirdparty/fc/CMakeLists.txt + mkdir -p build && cd build + multi=() + if [ "${{ matrix.validators }}" = "2" ]; then + multi=(-DCHAIN_TESTNET_VALIDATORS=2) + fi + cmake -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_SHARED_LIBRARIES=FALSE \ + -DCHAINBASE_CHECK_LOCKING=FALSE \ + -DBUILD_TESTNET=TRUE \ + -DBUILD_CONSENSUS_TESTS=ON "${multi[@]}" .. + make -j"$(nproc)" consensus_sim_tests + + - name: Run the simulator + run: | + set -euo pipefail + cd build + if [ "${{ matrix.validators }}" = "2" ]; then + tests/consensus_sim/consensus_sim_tests --run_test=hf16_boundary_suite --log_level=test_suite + else + ctest -R '^consensus_sim$' --output-on-failure + fi diff --git a/@l10n/ru/docs/api/client-libraries.md b/@l10n/ru/docs/api/client-libraries.md index 4cb9c418f9..6cc39d3526 100644 --- a/@l10n/ru/docs/api/client-libraries.md +++ b/@l10n/ru/docs/api/client-libraries.md @@ -225,3 +225,7 @@ let shares = VIZ.Asset(1000.0, .vests) // "1000.000000 VESTS" --- См. также: [JSON-RPC API](./json-rpc.md), [CLI Wallet](./cli-wallet.md). + +## Совместимость с HF16 + +JS 0.18.5 опубликован в npm и поддерживает заголовки QC/TC/двойного голосования. PHP сохраняет формат транзакций и передаёт новый JSON заголовков. Исходники Python 1.4.0 и зависимости прошли CI Python 3.10–3.14; публикация PyPI отдельная. Клиенты DAO не должны хранить фиксированный потолок 500 000 VIZ: лимит проверяет нода от текущей эмиссии. См. [HF16](../consensus/hf16.md). diff --git a/@l10n/ru/docs/api/ids-and-hashes.md b/@l10n/ru/docs/api/ids-and-hashes.md new file mode 100644 index 0000000000..d98d37c8bd --- /dev/null +++ b/@l10n/ru/docs/api/ids-and-hashes.md @@ -0,0 +1,29 @@ +# Идентификаторы транзакций и блоков + +Типы идентификаторов VIZ унаследовал от Graphene (BitShares, затем Steem). В исходниках они +объявлены как `fc::ripemd160`, и старая документация и библиотеки называют их «ripemd160 id». Это +только имя 20-байтового контейнера, в котором они хранятся: **ни один из этих id не является +ripemd160-хэшем.** Нода объявляет их через алиас `id160_type`, чтобы сказать это прямо. + +| Значение | Как вычисляется | Размер | +|---|---|---| +| `transaction_id` | первые 20 байт `sha256(сериализованная транзакция)`, подписи **не** входят | 20 байт | +| `block_id` | первые 20 байт `sha224(сериализованный заголовок блока)`, затем байты 0–3 заменяются номером блока (big-endian) | 20 байт | +| `transaction_merkle_root` | `ripemd160` от корня sha256-дерева по подписанным транзакциям | 20 байт | + +Merkle root — единственное значение, которое действительно ripemd160. + +## Что это значит для клиентов + +* Чтобы посчитать id транзакции, сериализуйте её **без** `signatures`, возьмите sha256 и оставьте + первые 20 байт. ripemd160 не применять — результат никогда не совпадёт с нодой. +* Номер блока читается прямо из block id: это первые 4 байта, big-endian. TaPoS + (`ref_block_num` / `ref_block_prefix`) берёт младшие 16 бит этого номера и байты 4–7 block id. +* Id имеют длину 20 байт во всех ответах API, в block log и в истории аккаунтов. Смена хэша (на + настоящий ripemd160 или на полный 32-байтовый sha256) была бы хардфорком, который делит историю на + два формата id, без выигрыша в безопасности: 160 бит для идентификатора достаточно, а подпись + ставится на полный sha256-дайджест, а не на id. + +Исходники: `transaction::id()` в `libraries/protocol/transaction.cpp`, `signed_block_header::id()` и +`signed_block::calculate_merkle_root()` в `libraries/protocol/block.cpp`, типы — в +`libraries/protocol/include/graphene/protocol/types.hpp`. diff --git a/@l10n/ru/docs/consensus/block-processing.md b/@l10n/ru/docs/consensus/block-processing.md index 570af1c07f..6194ed253a 100644 --- a/@l10n/ru/docs/consensus/block-processing.md +++ b/@l10n/ru/docs/consensus/block-processing.md @@ -1,5 +1,8 @@ # Обработка блоков +> **HF16 / 4.2.0:** слот 1 секунда, QC/TC и правило 2-chain описаны в [HF16](./hf16.md). Старые описания 3 секунд и 14 подтверждений ниже относятся к прежнему режиму, а не к кворуму QC HF16. + + Внутренняя механика применения блоков, управления ожидающими транзакциями и переключения форков. --- @@ -298,6 +301,7 @@ struct block_post_validation_message { ### Ограничения - В зачёт порога 2/3 идут только валидаторы из **текущего перетасованного расписания**; валидаторы вне расписания пропускаются. +- **Один голос на раунд (правило безопасности Jolteon).** Валидатор голосует за блок раунда *r* (раунд = слот блока, `timestamp / CHAIN_BLOCK_INTERVAL`), только если *r* выше самого старшего раунда, в котором он уже голосовал. Плагин валидатора хранит этот раунд для каждого валидатора в `/block-vote-round.txt`, записывает его **до** отправки голоса (голос, раунд которого не удалось сохранить, не отправляется) и пишет в лог `Not voting for … already voted for … in round …`, если в том же раунде появился другой блок. В отличие от правила «одна подпись на высоту», в более позднем раунде валидатор может проголосовать за другой блок той же высоты — именно это позволяет расколу голосов разрешиться, а не застрять. Два конфликтующих сертификата кворума в одном раунде требуют валидаторов, голосующих дважды; ноды пишут такие голоса в лог как `Equivocation: …`. Повторная рассылка того же голоса разрешена. - Во время **аварийного консенсуса** (`emergency_consensus_active = true`) `check_block_post_validation_chain()` сразу возвращается — LIB продвигается исключительно по классическому пути, чтобы не заблокировать восстановление. - Список подтверждений ограничен **20 записями** (`CHAIN_MAX_BLOCK_POST_VALIDATION_COUNT`). Записи ниже текущего LIB удаляются после каждого блока. diff --git a/@l10n/ru/docs/consensus/emergency-consensus.md b/@l10n/ru/docs/consensus/emergency-consensus.md index b1177250b9..4d0e424b6b 100644 --- a/@l10n/ru/docs/consensus/emergency-consensus.md +++ b/@l10n/ru/docs/consensus/emergency-consensus.md @@ -1,6 +1,6 @@ # Режим экстренного консенсуса -Режим экстренного консенсуса (введён в HF12) активируется автоматически, когда сеть простаивает в течение 1 часа. Специальный валидатор "committee" берёт на себя производство блоков для поддержания непрерывности цепочки до тех пор, пока реальные валидаторы не восстановят свои ключи подписи. +Режим экстренного консенсуса (введён в HF12) активируется автоматически, когда сеть простаивает в течение 20 минут. Специальный валидатор "committee" берёт на себя производство блоков для поддержания непрерывности цепочки до тех пор, пока реальные валидаторы не восстановят свои ключи подписи. --- @@ -8,7 +8,7 @@ | Константа | Значение | Значение | |-----------|---------|---------| -| `CHAIN_EMERGENCY_CONSENSUS_TIMEOUT_SEC` | 3600 с | Время простоя до активации | +| `CHAIN_EMERGENCY_CONSENSUS_TIMEOUT_SEC` | 1200 с | Время простоя до активации | | `CHAIN_EMERGENCY_VALIDATOR_ACCOUNT` | `"committee"` | Аккаунт экстренного производителя блоков | | `CHAIN_EMERGENCY_VALIDATOR_PUBLIC_KEY` | `VIZ75CR...` | Детерминированный ключ подписи для экстренного режима | | `CHAIN_EMERGENCY_EXIT_NORMAL_BLOCKS` | 21 | Последовательные блоки реальных валидаторов для выхода | @@ -31,7 +31,7 @@ 1. **Ворота HF12** — пропустить, если харфорк ещё не активирован. 2. **Уже активен** — пропустить, если экстренный режим уже включён. 3. **LIB-блок доступен в block log** — пропустить, если LIB-блок отсутствует в block log (DLT-узлы после восстановления из снимка имеют пустой block log; отсутствие LIB-блока вычислило бы миллионы устаревших секунд и запустило бы ложный дедлок активации). -4. **Таймаут** — вычислить `seconds_since_lib = current_block.timestamp − lib_block.timestamp`. Если `< 3600`, пропустить. +4. **Таймаут** — вычислить `seconds_since_lib = current_block.timestamp − lib_block.timestamp`. Если `< 1200`, пропустить. Все проверки используют только временны́е метки, встроенные в блоки — никаких системных часов, никаких флагов пропуска. Это гарантирует идентичную детерминированную активацию на каждом узле, включая воспроизведения. diff --git a/@l10n/ru/docs/consensus/fair-dpos.md b/@l10n/ru/docs/consensus/fair-dpos.md index 78e3840feb..d73dfc97c2 100644 --- a/@l10n/ru/docs/consensus/fair-dpos.md +++ b/@l10n/ru/docs/consensus/fair-dpos.md @@ -1,5 +1,8 @@ # Консенсус Fair-DPOS +> **HF16 / 4.2.0:** слот 1 секунда, QC/TC и правило 2-chain описаны в [HF16](./hf16.md). Старые описания 3 секунд и 14 подтверждений ниже относятся к прежнему режиму, а не к кворуму QC HF16. + + VIZ Ledger использует **Fair Delegated Proof of Stake (Fair-DPOS)** — расширение классического алгоритма DPoS, добавляющее контроль участия и штрафы за пропуски блоков для предотвращения получения вознаграждений без реального производства. --- @@ -9,7 +12,7 @@ VIZ Ledger использует **Fair Delegated Proof of Stake (Fair-DPOS)** В стандартном DPoS: - Держатели токенов голосуют за аккаунты-валидаторы (с весом, пропорциональным SHARES). - Валидаторы с наибольшим числом голосов планируются для производства блоков в порядке round-robin. -- Каждые 3 секунды срабатывает один слот; запланированный валидатор либо производит блок, либо пропускает слот. +- Каждые 3 секунды до HF16 (1 секунду после активации) срабатывает один слот; запланированный валидатор либо производит блок, либо пропускает слот. VIZ работает с **21 активным валидатором** в каждом раунде расписания. diff --git a/@l10n/ru/docs/consensus/fork-resolution.md b/@l10n/ru/docs/consensus/fork-resolution.md index 4e8121e44a..4f57c3297f 100644 --- a/@l10n/ru/docs/consensus/fork-resolution.md +++ b/@l10n/ru/docs/consensus/fork-resolution.md @@ -1,5 +1,8 @@ # Разрешение форков +> **HF16 / 4.2.0:** слот 1 секунда, QC/TC и правило 2-chain описаны в [HF16](./hf16.md). Старые описания 3 секунд и 14 подтверждений ниже относятся к прежнему режиму, а не к кворуму QC HF16. + + На этой странице описывается, как VIZ Ledger обнаруживает, выбирает и разрешает конкурирующие форки — от базовой базы данных форков до решателя коллизий HF12 с весом голосов. --- diff --git a/@l10n/ru/docs/consensus/hardforks.md b/@l10n/ru/docs/consensus/hardforks.md index 46d2c83c4b..85b9557d86 100644 --- a/@l10n/ru/docs/consensus/hardforks.md +++ b/@l10n/ru/docs/consensus/hardforks.md @@ -1,5 +1,8 @@ # Харфорки +> **HF16 / 4.2.0:** слот 1 секунда, QC/TC и правило 2-chain описаны в [HF16](./hf16.md). Старые описания 3 секунд и 14 подтверждений ниже относятся к прежнему режиму, а не к кворуму QC HF16. + + Харфорк — это обновление сети, изменяющее правила консенсуса. Все узлы должны обновиться до запланированной временной метки активации; узлы, работающие на старом программном обеспечении, разойдутся с сетью после активации. --- @@ -9,7 +12,7 @@ Каждый харфорк имеет: - Уникальный **номер** (N). - **Unix-временную метку** — самое раннее время по системным часам, когда харфорк может активироваться. -- **Суперквалифицированное большинство голосов валидаторов** — >80% текущего набора валидаторов должны подать сигнал новой версии харфорка через `validator_update_operation`. +- **Суперквалифицированное большинство голосов валидаторов** — >80% текущего набора валидаторов должны подать сигнал новой версии харфорка через `hardfork_version_vote` в заголовке блока (обычно автоматически плагином validator). Оба условия должны выполняться одновременно. Валидаторы могут заблокировать нежелательный харфорк, удерживая свой голос за версию даже после наступления запланированной временной метки. @@ -32,7 +35,7 @@ HF12 (версия 3.1.0) ввёл: 1. **Счётчик коллизий форков** — `fork_collision_count` и `last_fork_collision_block_num` добавлены в `dynamic_global_property_object`. Доступно через `get_dynamic_global_properties`. 2. **Сравнение форков с весом голосов** (`compare_fork_branches()`) — выбор форка использует общий делегированный SHARES по ветви валидатора + 10% бонус за более длинную цепочку. -3. **Режим экстренного консенсуса** — активируется автоматически через 1 час без блоков; аккаунт "committee" занимает все 21 слот. См. [Экстренный консенсус](./emergency-consensus.md). +3. **Режим экстренного консенсуса** — активируется автоматически через 20 минут без блоков; аккаунт "committee" занимает все 21 слот. См. [Экстренный консенсус](./emergency-consensus.md). 4. **Авто-ресинхронизация minority fork** — плагин validator обнаруживает изоляцию узла (21 последовательный собственный блок) и откатывается до LIB. 5. **Улучшения NTP** — выделенный NTP-клиент с настраиваемыми серверами, интервалом и порогом времени туда-обратно. @@ -46,7 +49,7 @@ HF13 (версия 3.2.0) ввёл: - Новое поле в `validator_object`: `reward_percent` — доля награды за блок, делимая с голосующими (0–10000 базисных пунктов). - Новая виртуальная операция: `validator_reward_virtual_operation` — срабатывает один раз при каждом распределении наград. -- Устанавливается через `validator_update_operation`. +- Устанавливается через `hardfork_version_vote` в заголовке блока (обычно автоматически плагином validator). --- @@ -169,3 +172,11 @@ case CHAIN_HARDFORK_N: { --- См. также: [Fair-DPOS](./fair-dpos.md), [Экстренный консенсус](./emergency-consensus.md), [Снимки](../node/snapshot.md). + +## Релизы 4.x + +| HF | Версия | Изменения | +|---|---|---| +| 14 | 4.0.0 | Рынки прогнозов и управление | +| 15 | 4.1.0 | Исправления PM, агенты и постоянная история ключей | +| 16 | 4.2.0 | QC/TC, слот 1 секунда, лимит DAO и заморозка no-contest; [активация](./hf16.md) | diff --git a/@l10n/ru/docs/consensus/hf16.md b/@l10n/ru/docs/consensus/hf16.md new file mode 100644 index 0000000000..820caea937 --- /dev/null +++ b/@l10n/ru/docs/consensus/hf16.md @@ -0,0 +1,48 @@ +# HF16 / релиз 4.2.0 + +## Активация и обновление + +HF16 назначен на **31 октября 2026, 00:00:00 UTC** (`1793404800`), версия **4.2.0**. Дополнительно нужен порог голосования валидаторов за хардфорк; установка бинарника не активирует его раньше времени. Обновите валидаторов заранее. Используйте проверенный **production**-образ, а не BUILD_TESTNET: у него другая идентичность цепи и кворум. + +После сборки master перед распространением проверьте ревизию/версию конкретного образа, запуск, синхронизацию и производство блоков. Сохраните настроенный ключ валидатора и резервную копию данных. Удаление каталога данных не является процедурой обновления. + +## Слоты, QC, TC и финальность + +- При HF16 рабочий слот меняется с **3 секунд на 1 секунду**. Читайте текущее значение `get_config` и время блоков: прежняя константа `CHAIN_BLOCK_INTERVAL` не задаёт слот после форка. +- QC собирает подписи валидаторов из расписания. Формула реализации: `max(floor(2N/3)+1, floor(0.75N))`, то есть **15 из 21**, а не старые 14 подтверждений поствалидации. +- При передаче хода другому производителю блок несёт QC родителя. Сертификаты таймаута (TC) позволяют продолжить работу после пропущенных раундов. LIB использует **2-chain**: сертифицированные родитель и ребёнок на локальной основной ветви, соседние высоты и последовательные раунды. Одного QC недостаточно. +- Валидатор голосует за один блок за раунд; максимальный проголосованный раунд сохраняется до рассылки. Повтор того же голоса разрешён. Доказательство конфликтующих голосов помещается в заголовок и создаёт виртуальную операцию `validator_equivocation` (ID **106**). +- Новые теги расширений заголовка: **3 QC**, **4 TC**, **5 доказательство двойного голоса**. Расширения транзакций и прежние ID операций не меняются. +- Параметры управления, измеряемые в блоках и сохраняющие смысл по времени, масштабируются при активации. Не заменяйте все таймеры в 3 секунды механически: независимые интервалы повторов и завершения процесса — отдельные настройки. + +## Восстановление и аварийный режим + +После временного нарушения связи обычное восстановление не требует перезапуска или аварийного ключа. На стенде из 21 валидатора проверены недоступность шести валидаторов, потери 20% с задержкой 150±50 мс, полный обрыв на 241 секунду и разделение 10/11. После восстановления связи все 21 узел финализировали один проверенный блок. Это проверенные сценарии, а не гарантия времени восстановления при любой атаке. + +Догонялка отложенной цепочки сохраняет предков LIB, пока fork_db опережает применённое состояние. Аварийный режим отдельный: его таймаут остаётся **1200 секунд (20 минут)**. Короткие тесты не меняют его на пять минут. См. [Аварийный консенсус](./emergency-consensus.md). + +## DAO и рынки + +При создании заявки максимум DAO равен `floor(current_supply.amount / 100)` в минимальных единицах: **1% текущего общего количества VIZ**, округлённый вниз до 0.001 VIZ. Лимит вычисляется для каждой заявки, не от начальной эмиссии и не от остатка фонда комитета. До HF16 при воспроизведении истории сохраняется старый потолок **500 000 VIZ**. Суммы существующих заявок не переписываются. См. [Операции комитета](../protocol/operations/committee.md). + +У no-contest, объявленного после HF16, номинальная цена фиксируется при объявлении. При расчёте применяется предел страхового остатка; старые объявления сохраняют прежний расчёт. Замороженный ноль отличается от отсутствия заморозки. См. [Заморозка цены no-contest](../prediction-markets/pm-no-contest-fee-freeze.md). + +## UI оператора и MCP + +Необязательный плагин `node_admin` предоставляет встроенный UI браузера `/ui/` и HTTP MCP на отдельном административном слушателе. Доступны здоровье, диагностика, плагины, логи, аудит, P2P, снапшоты, операции валидаторов и планы настроек. Bearer-токены разграничивают права; настройки и плагины меняются через проверяемые `plan` / `apply`. По умолчанию слушатель локальный; для удалённой работы используйте туннель. Не публикуйте его как публичный API цепи. См. [Управление нодой](../plugins/node-admin.md). + +## Клиентские библиотеки + +JS **0.18.5** поддерживает новые заголовки и опубликован в npm. PHP сохраняет формат транзакций и передаёт JSON заголовков без изменения. Исходники Python **1.4.0** и зависимости проверены CI на Python 3.10–3.14; публикация PyPI — отдельный шаг. Клиенты не должны ограничивать DAO старой фиксированной суммой. + +## Эмиссия и окно доказательств + +HF16 намеренно сохраняет **1.000 VIZ на произведённый блок** (решение владельца Q3105=C). При непрерывном выпуске блоков годовая валовая эмиссия растёт примерно с 10.5 до 31.5 млн VIZ. Пропущенный слот не выпускает награду. + +Окно хранения голосов и подачи доказательств двойного голосования сохраняет длительность один час: `3600 / block_interval()` раундов, то есть 3600 после HF16. Это не аварийный таймер и не обещание срока восстановления. Пороги обнуления ключа за пропуски увеличены втрое в блоках, сохраняя прежние примерно 10 минут в обычном режиме и 5 минут в аварийном. Минимальная длительность распределительного периода также сохраняется: новые голоса за параметры проверяются по увеличенной нижней границе; воспроизведение до HF16 сохраняет старую. + +## Повторное голосование параметров + +Параметры хранятся и голосуются в **реальном числе блоков**, согласно выбранной политике миграции HF16. Однократная миграция увеличивает существующие голоса и медиану втрое, но не распознаёт последующий голос из старого скрипта. Делегатам нужно увеличить зашитые `inflation_recalc_period`, `distribution_epoch_length`, `pm_batch_epoch_blocks`, `pm_reveal_window_blocks` втрое для сохранения длительности. Для голосования defaults используйте актуальный `get_config.CHAIN_DEFAULT_CHAIN_PROPERTIES`; при изменении другого поля сохраняйте остальные текущие on-chain значения. Старые defaults структуры повторно не отправляйте. + +`get_config` согласованно возвращает текущие блоки/час/сутки/год, ёмкость undo, пороги пропусков и defaults. Шаг изменения reserve ratio сохраняет 60 секунд. `CHAIN_EMERGENCY_EXIT_NORMAL_BLOCKS` — неиспользуемая историческая константа: выход из emergency определяется кворумом активных валидаторов, а не таймером 21 блок. diff --git a/@l10n/ru/docs/governance/committee.md b/@l10n/ru/docs/governance/committee.md index 071ca7f6a1..bf92ae5b9d 100644 --- a/@l10n/ru/docs/governance/committee.md +++ b/@l10n/ru/docs/governance/committee.md @@ -114,3 +114,5 @@ payment = min(committee_fund / count_approved_requests, remain_payout_amount) --- См. также: [Операции комитета](../protocol/operations/committee.md), [Стейкинг и DAO](./staking-and-dao.md), [Параметры цепочки](./chain-properties.md), [Виртуальные операции](../protocol/virtual-operations.md). + +**Лимит HF16:** при создании `required_amount_max.amount <= floor(current_supply.amount / 100)` в минимальных единицах (1% текущего общего количества VIZ с округлением вниз). До HF16 — 500 000 VIZ. Это не 1% фонда комитета; существующие заявки не переоцениваются. См. [HF16](../consensus/hf16.md). diff --git a/@l10n/ru/docs/node/configuration.md b/@l10n/ru/docs/node/configuration.md index 73fc1e5561..14691b9d6e 100644 --- a/@l10n/ru/docs/node/configuration.md +++ b/@l10n/ru/docs/node/configuration.md @@ -197,6 +197,28 @@ logger.p2p.level = warn logger.p2p.appenders = p2p ``` +Аппендер `file` НАСТРОЕН ротироваться по времени (новый файл каждый час, хранится сутки), +но ротация срабатывает только пока обслуживается очередь файберов fc, а `vizd` после старта +её не обслуживает (замер 20.10.2026: нода тестнета написала один файл на 37 МБ за девять +часов на уровне `info`, часовых файлов нет; нода мейннета проработала четыре дня без нового +файла). Не рассчитывайте, что аппендер ограничит размер, и помните: на `debug` нода может +писать гигабайты в час. Если нужен только свежий хвост, используйте опциональный +capped-аппендер: один файл, в котором остаются последние `max_lines` строк (по умолчанию +10000, `0` — без ограничения). Он считает строки при записи и сжимается на месте, поэтому +файл держится между `max_lines` и ~1.1 x `max_lines`, без всяких таймеров. Ноды, в конфиге +которых нет секции `log.capped_file_appender.*`, ведут себя ровно как раньше; саму секцию +безопасно завозить ДО обновления бинаря — старая сборка её игнорирует и продолжает писать +в stderr. Если указанный путь не открывается, нода всё равно стартует: печатает в stderr +одну строку `capped_file_appender: cannot open log file …` и просто не создаёт файл. + +```ini +log.capped_file_appender.node.filename = logs/node/node.log +log.capped_file_appender.node.max_lines = 10000 + +# Куда именно писать — решает логгер; сам аппендер ничего не пишет. +logger.default.appenders = stderr, node +``` + --- ## Полный справочник diff --git a/@l10n/ru/docs/node/getting-started.md b/@l10n/ru/docs/node/getting-started.md index a35c15c081..399c585d87 100644 --- a/@l10n/ru/docs/node/getting-started.md +++ b/@l10n/ru/docs/node/getting-started.md @@ -37,6 +37,7 @@ docker pull vizblockchain/vizd:latest ```bash docker run -d \ --name vizd \ + --restart unless-stopped \ -p 2001:2001 \ -p 8090:8090 \ -p 8091:8091 \ @@ -161,7 +162,7 @@ curl -s -X POST http://localhost:8090 \ | python3 -m json.tool ``` -Проверьте `head_block_number` — после синхронизации он должен увеличиваться каждые 3 секунды. +Проверьте `head_block_number` — после синхронизации он должен увеличиваться каждые 3 секунды до HF16 и каждую 1 секунду после HF16. --- diff --git a/@l10n/ru/docs/node/monitoring.md b/@l10n/ru/docs/node/monitoring.md index dacd1ff26a..b96c73bbcc 100644 --- a/@l10n/ru/docs/node/monitoring.md +++ b/@l10n/ru/docs/node/monitoring.md @@ -15,7 +15,7 @@ curl -s -X POST http://localhost:8090 \ | python3 -m json.tool ``` -Проверьте `head_block_number` — он должен увеличиваться каждые 3 секунды при синхронизации. Проверьте `time` — оно должно быть в пределах нескольких секунд от системных часов. +Проверьте `head_block_number` — он должен увеличиваться каждые 3 секунды до HF16 и каждую 1 секунду после HF16 при синхронизации. Проверьте `time` — оно должно быть в пределах нескольких секунд от системных часов. Простой скрипт проверки доступности: diff --git a/@l10n/ru/docs/node/snapshot.md b/@l10n/ru/docs/node/snapshot.md index c88a15d09c..76a0544829 100644 --- a/@l10n/ru/docs/node/snapshot.md +++ b/@l10n/ru/docs/node/snapshot.md @@ -82,7 +82,7 @@ snapshot-dir = /data/snapshots ```ini plugin = snapshot -snapshot-every-n-blocks = 28800 # ~24 часа при 3 с/блок +snapshot-every-n-blocks = 28800 # ~24 ч до HF16; ~8 ч после (1 с/блок) snapshot-dir = /data/snapshots snapshot-max-age-days = 90 ``` diff --git a/@l10n/ru/docs/node/validator-node.md b/@l10n/ru/docs/node/validator-node.md index 7925729ea9..3b1b254b2a 100644 --- a/@l10n/ru/docs/node/validator-node.md +++ b/@l10n/ru/docs/node/validator-node.md @@ -1,6 +1,6 @@ # Запуск узла-валидатора -Валидаторы (производители блоков) — аккаунты, которые алгоритм Fair-DPOS планирует для производства блоков каждые 3 секунды. Для запуска узла-валидатора необходим зарегистрированный аккаунт, ключ подписи и правильно настроенный узел. +Валидаторы (производители блоков) — аккаунты, которые алгоритм Fair-DPOS планирует для производства блоков каждые 3 секунды до HF16 и каждую 1 секунду после HF16. Для запуска узла-валидатора необходим зарегистрированный аккаунт, ключ подписи и правильно настроенный узел. --- diff --git a/@l10n/ru/docs/p2p/messages.md b/@l10n/ru/docs/p2p/messages.md index 4a816aaa6c..114f4fb56d 100644 --- a/@l10n/ru/docs/p2p/messages.md +++ b/@l10n/ru/docs/p2p/messages.md @@ -2,7 +2,7 @@ DLT P2P использует бинарный протокол через raw TCP. Каждое сообщение оформляется 4-байтовым заголовком с порядком байт little-endian, содержащим ID типа сообщения, за которым следует полезная нагрузка с префиксом длины, сериализованная с помощью FC reflection. -**Заголовочный файл:** [libraries/network/include/graphene/network/dlt_p2p_messages.hpp](../../libraries/network/include/graphene/network/dlt_p2p_messages.hpp) +**Заголовочный файл:** [libraries/network/include/graphene/network/dlt_p2p_messages.hpp](https://github.com/VIZ-Blockchain/viz-cpp-node/blob/pm/libraries/network/include/graphene/network/dlt_p2p_messages.hpp) --- diff --git a/@l10n/ru/docs/plugins/block-archive.md b/@l10n/ru/docs/plugins/block-archive.md index 7d6e98ecb3..aaa15a8edf 100644 --- a/@l10n/ru/docs/plugins/block-archive.md +++ b/@l10n/ru/docs/plugins/block-archive.md @@ -4,7 +4,7 @@ Плагин не меняет консенсус, не требует хардфорка и не влияет на обработку блоков: ноды с ним и без него полностью совместимы. -**Исходный код:** [plugins/block_archive/plugin.cpp](../../plugins/block_archive/plugin.cpp) +**Исходный код:** [plugins/block_archive/plugin.cpp](https://github.com/VIZ-Blockchain/viz-cpp-node/blob/pm/plugins/block_archive/plugin.cpp) --- diff --git a/@l10n/ru/docs/plugins/chain.md b/@l10n/ru/docs/plugins/chain.md index fa38048e55..308ac2441c 100644 --- a/@l10n/ru/docs/plugins/chain.md +++ b/@l10n/ru/docs/plugins/chain.md @@ -2,7 +2,7 @@ Плагин chain — это ключевой компонент каждого узла VIZ Ledger. Он управляет разделяемой памятью базы данных chainbase, принимает и проверяет блоки и транзакции, поддерживает состояние fork database и block log, координирует запуск с плагинами snapshot и P2P. Все остальные плагины зависят от него. -**Исходник:** [plugins/chain/plugin.cpp](../../plugins/chain/plugin.cpp) +**Исходник:** [plugins/chain/plugin.cpp](https://github.com/VIZ-Blockchain/viz-cpp-node/blob/pm/plugins/chain/plugin.cpp) --- diff --git a/@l10n/ru/docs/plugins/database-api.md b/@l10n/ru/docs/plugins/database-api.md index fd60e880f0..91def3da4f 100644 --- a/@l10n/ru/docs/plugins/database-api.md +++ b/@l10n/ru/docs/plugins/database-api.md @@ -2,7 +2,7 @@ Плагин `database_api` предоставляет JSON-RPC методы только для чтения, позволяющие запрашивать состояние блокчейна: блоки, аккаунты, свойства цепочки, делегирование, проверку авторизации и управление. -**Исходник:** [plugins/database_api/](../../plugins/database_api/) +**Исходник:** [plugins/database_api/](https://github.com/VIZ-Blockchain/viz-cpp-node/tree/pm/plugins/database_api/) --- diff --git a/@l10n/ru/docs/plugins/node-admin.md b/@l10n/ru/docs/plugins/node-admin.md new file mode 100644 index 0000000000..0f5b93e858 --- /dev/null +++ b/@l10n/ru/docs/plugins/node-admin.md @@ -0,0 +1,1188 @@ +# Административный API ноды (`node_admin`) + +Административный JSON-RPC-эндпоинт самого процесса ноды: здоровье, конфигурация, +состояние плагинов, логи, аудит, управление P2P-пирами и снапшотами. Предназначен для +инструментов оператора и AI-агентов, у которых есть bearer-токен; не для конечных +пользователей и не вместо публичного API. На том же слушателе работают эндпоинт **MCP** +(Model Context Protocol) поверх HTTP и небольшой встроенный веб-интерфейс по адресу `/ui/`. + +> **Изменяющая поверхность: P2P-пиры, снапшоты, ключи валидаторов, политика webserver, +> `config.ini` и процесс.** +> `admin.p2p.add_node`, `admin.p2p.add_trusted` и `admin.p2p.clear_banned` меняют, с кем нода +> устанавливает соединения; `admin.snapshot.create` и `admin.snapshot.restore` пишут файл +> снапшота или заменяют состояние ноды в памяти; `admin.validator.*` включает и выключает +> валидаторов и меняет их signing-ключи, подписывая транзакции в цепи; изменяющие методы +> `admin.webserver.*` меняют лимиты, переключатель статистики и списки +> разрешённых/запрещённых IP; `admin.config.*` и `admin.plugins.*` правят `config.ini` через +> двухфазные `plan`/`apply`; `admin.restart` просит процесс завершиться. Им нужен токен со +> скоупом `p2p`, `snapshot`, `chain`, `webserver`, `config`, `plugins` или `process` +> соответственно. Действия над пирами сериализуются на P2P-потоке; действия над +> валидаторами подписывает плагин `validator_guard` и рассылает их в сеть; изменения +> политики webserver применяются к следующему же запросу; правки конфигурации и плагинов +> читаются при старте, поэтому нужен перезапуск. + +--- + +## Почему отдельный слушатель + +Публичный API обычно опубликован через nginx. Внутри ноды такой запрос приходит +**с 127.0.0.1**, поэтому проверка адреса источника на публичном эндпоинте ничего не +защищает. Поэтому `node_admin` поднимает собственный HTTP-слушатель, по умолчанию +привязанный к loopback, и он никогда не проксируется. + +Адрес не на loopback отклоняется при старте, если явно не задан +`admin-allow-remote = true`. Bearer-токен на открытом порту — это другая модель угроз, +а не удобство. + +--- + +## Конфигурация + +```ini +plugin = node_admin + +admin-endpoint = 127.0.0.1:8099 +admin-token = agent:read:3f8c…(64 hex)…e1 +admin-audit-log = /var/lib/vizd/node-admin-audit.log +admin-log-file = node=/var/log/vizd/vizd.log +``` + +| Опция | По умолчанию | Описание | +|---|---|---| +| `admin-endpoint` | — | Адрес прослушивания. Пусто/нет — плагин неактивен. | +| `admin-token` | — | `label:scope[,scope]:sha256hex[:expires-unixtime]`. Можно повторять. | +| `admin-allow-remote` | `false` | Разрешить `admin-endpoint` не на loopback. | +| `admin-audit-log` | рядом с `config.ini` | Append-only журнал аудита (0600, ротация на 16 МБ). | +| `admin-log-file` | — | Записи `name=path`, которые разрешено читать `admin.logs.tail`. Можно повторять. | +| `admin-plan-ttl` | `120` | Сколько секунд `plan_id` остаётся действительным до отказа `apply`. | + +Плагин отказывается стартовать, если `admin-endpoint` задан, а токенов нет: админский +порт без аутентификации не бывает допустимым состоянием. + +Вкладка Logs читает **файл**, а не stdout контейнера. Ноде, которая внутри Docker пишет +только в stderr, нужен аппендер, кладущий те же строки на диск; именно этот файл и +называется в `admin-log-file`: + +```ini +[log.file_appender.node] +filename = /var/lib/vizd/logs/node.log + +[logger.default] +level = info +appenders = stderr,node + +admin-log-file = node=/var/lib/vizd/logs/node.log +``` + +Пиши секциями: плоские ключи `logger.default.*` сливаются первыми, а секция +`[logger.default]` ниже их перетирает — плоская строка поверх существующей секции молча +игнорируется. Файловый аппендер НАСТРОЕН на часовую ротацию с хранением суток, но в +`vizd` она фактически не происходит: цепочка живёт в очереди файберов fc, а главный поток +ноды после старта сидит в `boost/asio` и её не обслуживает (замер 20.10.2026 — один файл +на 37 МБ за девять часов, часовых файлов нет). То есть файл не ограничен ни по времени, ни +по размеру; на `debug` он доходит до гигабайт. Для жёсткого предела есть опциональный +аппендер `capped_file`: один файл, только последние `max_lines` строк (по умолчанию +10000, `0` — без ограничения). + +```ini +[log.capped_file_appender.node] +filename = /var/lib/vizd/logs/node.log +max_lines = 10000 +``` + +Укажи тот же путь в `admin-log-file` — таб Logs читает его без изменений. Аппендер +опционален: ноды без секции и старые бинари, не знающие типа, работают как раньше. + +### Токены + +Токен — 32 случайных байта, например `openssl rand -hex 32`. В конфигурации хранится +только его SHA-256; сравнение — constant-time. Скоупы: + +* `read` — все методы, включая `admin.p2p.status` и скачивание снапшотов. Нужен любому токену. +* `p2p` — изменяющие методы над пирами `admin.p2p.add_node`, `admin.p2p.add_trusted`, + `admin.p2p.clear_banned`. Токен только с `p2p` (без `read`) не может вызвать ничего. +* `snapshot` — `admin.snapshot.create` и `admin.snapshot.restore`. Список и скачивание + снапшотов требуют только `read`. +* `webserver` — изменяющие методы `admin.webserver.*` (лимиты, переключатель статистики, + списки IP, reset, reload). `admin.webserver.status` и `admin.webserver.ips` требуют + только `read`. +* `config` — `admin.config.plan` и `admin.config.apply` (правка разрешённых ключей + `config.ini`). `admin.config.backups` требует только `read`. +* `plugins` — `admin.plugins.plan` и `admin.plugins.apply` (правка списка `plugin =`). +* `process` — `admin.restart` и `admin.objects.repair`. Оба гасят процесс; поднять его + обратно обязан супервизор. Repair вдобавок оставляет маркер, который переписывает + состояние на следующем старте, так что это скоуп записи в состояние, а не только + доступности. +* `chain` — `admin.validator.plan` и `admin.validator.apply` (включение/выключение + валидаторов и смена их signing-ключей). Эти вызовы принимаются **только пока + `validator_guard` загружен, запущен и сконфигурирован с active-ключом** — именно этот + плагин хранит ключ, которым подписывается транзакция цепи, и через API он ключ не + отдаёт. Без живого guard токен с `chain` не может вызвать ничего: `admin.validator.list` + остаётся доступен со скоупом `read` и объясняет, почему изменяющие вызовы недоступны. + +Отзыв — правка строки и перезапуск. Необязательный `expires-unixtime` делает токен +недействительным после этого момента без перезапуска. + +--- + +## Протокол + +`POST` с `Content-Type: application/json` и `Authorization: Bearer `. +Тело ≤ 64 КБ. Ответы — JSON-RPC 2.0: + +```json +{"jsonrpc":"2.0","id":1,"result":{...}} +{"jsonrpc":"2.0","id":1,"error":{"code":-32002,"message":"forbidden", + "data":{"reason":"token 'agent' has no 'read' scope"}}} +``` + +| Код | Значение | +|---|---| +| `-32700` | Ошибка разбора | +| `-32600` | Некорректный запрос | +| `-32601` | Метод не найден | +| `-32602` | Некорректные параметры | +| `-32001` | Нет авторизации (HTTP 401) | +| `-32002` | Не хватает скоупа (HTTP 200) | +| `-32004` | Неизвестное/запрещённое имя (например, лог-файл) | +| `-32005` | Лимит: 20 запросов/с на токен (HTTP 429) | +| `-32099` | Внутренняя ошибка | + +Аутентификация проверяется **до** разбора тела. + +Маршруты не-`POST` — встроенный интерфейс и скачивание снапшотов: + +* `GET /ui/` (и `GET /ui`) отдаёт встроенную страницу, описанную ниже. В ней нет ни данных, + ни секретов; все настоящие вызовы страница делает обычными авторизованными `POST` на `/`. +* `GET /snapshot/latest` отдаёт самый свежий снапшот как файл-вложение. `GET /snapshot/` + отдаёт именованный файл из каталога снапшотов. Оба требуют `Authorization: Bearer …` и + скоуп `read` — в отличие от `/ui/`, снапшот это состояние ноды. `HEAD` работает на обоих и + несёт те же заголовки, включая `Content-Length`. Любой другой метод или путь отвечает `405`. + +Файл должен быть обычным файлом непосредственно в каталоге снапшотов (имена берутся +из `admin.snapshot.status`; traversal отклоняется). Скачивание идёт потоком по 64 КиБ +без ограничения размера файла и с учётом скорости получателя. Застрявшая запись в сокет +прерывается через 30 секунд; весь снапшот в память не загружается, остальные admin-запросы +обслуживаются во время скачивания. + +--- + +## Методы + +### `admin.ping` + +```json +{"ok": true, "server_time": "2026-10-19T14:40:00Z"} +``` + +Проверка живости; базу цепи не трогает. + +### `admin.auth.me` + +Без параметров. Возвращает личность, которую несёт предъявленный токен: `label`, `scopes` +и `expires` (unixtime, `null` — токен бессрочный). Сам токен и его хэш **не** возвращаются +никогда. Встроенный UI по этому ответу скрывает действия, которые токену недоступны; агент +может так же узнать свои права перед планированием вызова. + +```json +{"label":"ui-read","scopes":["read"],"expires":null} +``` + +### `admin.node_info` + +| Поле | Смысл | +|---|---| +| `version`, `hardfork_count` | Версия сборки и число известных бинарнику хардфорков | +| `chain_id` | Идентификатор цепи | +| `head_block_num`, `head_block_id`, `head_block_time` | Текущая голова | +| `head_age_sec` | Возраст головы по стенным часам; `null` на пустой цепи (там время головы 1970 — «возраст» ничего не значит) | +| `last_irreversible_block_num` | Последний необратимый блок | +| `emergency_consensus_active`, `emergency_consensus_start_block` | Состояние аварийного консенсуса цепи и блок, с которого он начался (`0`, когда не активен) | +| `emergency_key_configured` | В плагине validator загружен аварийный ключ | +| `recovering` | Chain-плагин восстанавливает shared memory | +| `uptime_sec` | Время с запуска админского слушателя | +| `server_time` | Текущее UTC | + +### `admin.diagnostics` + +Всё из `admin.node_info`, плюс: + +| Поле | Смысл | +|---|---| +| `data_dir`, `config_path` | Используемые пути | +| `p2p_loaded`, `peers` | P2P-плагин присутствует, число активных соединений (читается на p2p-потоке) | +| `plugins_configured`, `plugins_observed` | Записи `plugin =` в `config.ini` и сколько из них читаются напрямую (см. `admin.plugins.list`) | +| `shared_memory_bytes` | Размер `shared_memory.bin` | +| `disk_total_bytes`, `disk_free_bytes` | Файловая система `data_dir` (POSIX) | +| `rss_peak_kb` | Пиковый RSS (POSIX) | +| `loadavg` | Средняя нагрузка за 1/5/15 минут (POSIX) | + +Числа отдаются числами JSON, а не строками: дефолтный форматер fc берёт int64 выше 2^32-1 +и все double в кавычки, из-за чего размеры в байтах и load average пришлось бы парсить дважды. + +### `admin.plugins.list` + +Список плагинов, названных строками `plugin = …` в `config.ini`, без дублей. В строке может +стоять несколько имён (`plugin = chain p2p`) — AppBase читает её именно так, поэтому каждое имя +получает свою запись. Реестр плагинов AppBase закрыт +(поиск по имени — protected, перечисления нет), поэтому видимое население — сам конфиг; +плагины, включённые только командной строкой, здесь не видны. + +У каждой записи есть `loaded`, `state` и `state_source`: + +* `state_source: "observed"` — состояние прочитано у живого плагина (`chain`, `p2p`, + `snapshot`, `webserver`, `validator`, `validator_guard`, `node_admin`). `state` — + `registered`, `initialized`, `started` или `stopped`; `loaded` равен false только пока + плагин в состоянии `registered`. +* `state_source: "inferred"` — AppBase инициализирует каждую строку `plugin =` и падает на + старте при неизвестном имени, значит на работающей ноде сконфигурированный плагин загружен. + `state` при этом `null`, а не догадка. + +Для панели оператора есть ещё два поля: + +* `removable` — `false` у `chain` и `node_admin`: `admin.plugins.plan` всё равно откажет в + их удалении. Клиент обязан рисовать их как «непереключаемые», а не как «выключенные». +* `params` — allow-list-ключи этого плагина (`p2p`, `webserver`, `validator_guard`); пустой + массив значит «редактируемых параметров нет», и панель должна это сказать, а не показать + пустую форму. Ключи логов сгруппированы под `logging` — это не плагин, они в + `admin.config.get`. +* `status_method` — метод, отдающий **собственный** статус этого плагина + (`admin.p2p.status`, `admin.webserver.status`, `admin.snapshot.status`, + `admin.validator.list` для `validator_guard`), либо `null`, если своего статуса у плагина + нет. `null` — не ошибка: панель должна сказать «статуса нет», а не рисовать пустой ящик. + +В ответе также есть `known` — все имена плагинов, которые умеет загружать бинарь; ровно это +множество проверяет `admin.plugins.plan` для `add`. Сконфигурированные имена там тоже есть, +клиент вычитает их, собирая выбор «добавить плагин». + +### `admin.plugins.status` + +`{"name":"p2p"}` возвращает состояние жизненного цикла и то, чем плагин занят на самом деле: + +| Поле | Смысл | +|---|---| +| `configured` | В `config.ini` есть строка `plugin =` с этим именем | +| `state`, `state_source` | Как в `admin.plugins.list`; `state` = `null`, если тип плагина не слинкован в этот бинарь | +| `detail_available`, `detail_method` | Есть ли у плагина свой метод статуса и какой именно был вызван | +| `detail` | Ответ этого метода как есть (`admin.p2p.status`, `admin.webserver.status`, `admin.snapshot.status`, `admin.validator.list` для `validator_guard`) | +| `reason` | Почему детали нет: тип не наблюдается, плагин не запущен или своего статуса нет | + +Метод детали зовётся только у **запущенного** плагина: его обработчики читают состояние, +принадлежащее чужому потоку (карта пиров, счётчики соединений), и на остановленном плагине +бросят исключение. + +Плагин, тип которого не слинкован в этот бинарь (`snapshot` в сборке без него, скажем), +отвечает `state: null` и `reason` — не пустым объектом и не выдуманным «started». + +### `admin.objects.list` + +Граф объектов chainbase, как его видит живой процесс: по строке на индекс (тип объекта), +число объектов и ревизия индекса, плюс сколько отображённого файла занято. + +```json +{"state_dir":"/var/lib/vizd/state","shared_memory_path":"/var/lib/vizd/state/shared_memory.bin", + "shared_memory_bytes":34183577600,"mapped_bytes":34183577600,"free_bytes":12648009654, + "revision":1234567,"head_block_num":1234567,"recovering":false, + "schema":{"file_present":true,"stored":15,"compiled":15,"match":true}, + "block_log":{"path":"/var/lib/vizd/state/block_log","present":true,"bytes":2147483648}, + "markers":[],"index_count":36,"total_objects":9876543, + "indexes":[{"type":"graphene::chain::account_object","type_id":7,"objects":812345, + "revision":1234567}], + "generated_at":"2026-10-20T13:40:00Z"} +``` + +Счётчики читаются под тем же weak read lock, что и chain API; если поток блоков не уступил +вовремя, вызов вернёт файловые факты и `lock_error`, а не зависнет. + +Блок-лог показывается и файлом, и каталогом: chain-плагин стирает его через `remove_all`, +поэтому каталог там — законная форма. + +`schema_version` и `block_log` лежат в state-каталоге цепи (`/state` по умолчанию, +рядом с `shared_memory.bin`), а не в каталоге данных приложения: chain-плагин открывает БД, +передавая state-каталог как свой data dir. Лог, в котором только однобайтовая заглушка +chainbase, считается пустым — `replay_available` false, а `replay`-ремонт отвергается, потому +что проиндексировал бы ноль блоков. + +### `admin.objects.check` + +Тот же снимок, но с судом. `verdict` — `ok`, `warn`, `corrupted` или `unreadable`; в ответе +именованные `findings` (`severity`, `code`, `text`), счётчики, `replay_available` и +`recommended_repair` (`replay`, `resync` или `null`). + +| Код | Severity | Что значит | +|---|---|---| +| `read_lock_timeout` | critical | Shared memory не ответила на read lock; процесс завис | +| `state_dir_missing` / `shared_memory_missing` | critical | У ноды нет каталога состояния или образа | +| `shared_memory_truncated` | critical | Файл меньше отображения: обрезан или подменён под работающим процессом | +| `shared_memory_larger_than_mapping` | warn | Файл вырос под этим процессом — обычно вторая нода на том же каталоге | +| `stale_marker` | critical | `undo_all_in_progress`, `resize_in_progress`, `force_resync` или `force_replay` пережили прерванную операцию | +| `schema_mismatch` | critical | `state/schema_version` нет или он не равен `CHAIN_SCHEMA_VERSION` бинаря; следующий старт всё сотрёт и пересоберёт | +| `revision_behind_head` | warn | Ревизия индексов не равна head; следующий старт доиграет разрыв. После краша это норма | +| `shared_memory_exhausted` | critical | Свободно меньше 8 МиБ: нода не запишет следующий блок | +| `shared_memory_low` | warn | Свободно меньше 5% | +| `global_properties_count` / `validator_schedule_count` | critical | Ожидается ровно одна строка в каждом | +| `account_index_empty` | critical | Head выше нуля, а индекс аккаунтов пуст | +| `block_log_missing` | warn | Реплей состояние не пересоберёт, поможет только полный resync | +| `recovery_in_progress` | warn | Chain-плагин выполняет автовосстановление | + +`corrupted` — только жёсткие улики (critical выше); расхождение ревизии после краша — это +`warn`, а не порча. `recommended_repair` заполняется, только когда переписывание состояния +может починить находку; исчерпанная shared memory исключена — тот же размер забьётся снова. + +**Чего проверка не видит.** Порчу страниц, контрольные суммы и environment-штамп chainbase +можно проверить только с закрытым файлом, так что это структурная проверка живого +процесса, а не `fsck`. Чистый вердикт не обещает, что реплей пройдёт. + +### `admin.objects.repair` + +`{"mode":"replay"|"resync","confirm":"repair"}` (скоуп `process`) пишет маркер рядом с +`shared_memory.bin`, отвечает и через 300 мс завершает процесс — тот же жизненный цикл, что +у `admin.restart`. Маркер читает chain-плагин на следующем старте, до отображения shared +memory: + +* `force_replay` — стереть состояние, оставить блок-лог и переиндексировать его с блока 1. + Дешевле сетевого resync и достаточно, когда сам блок-лог цел. +* `force_resync` — стереть состояние **и** блок-лог, подняться пустым и синхронизироваться + с сетью. + +`confirm` обязан быть ровно `repair`, а режим ещё раз сверяется с файлами: `replay` +отклоняется с `-32000 no block log`, если лога нет или он пуст. Если уже лежит маркер +`force_resync` от wedge-сторожа, старт выполнил бы resync и отбросил просьбу реплея вместо +того чтобы реплеить пустой лог, — поэтому обработчик **сначала снимает противоположный +маркер** и называет его в `removed_marker`. Без этого подтверждённый «replay (сохраняет +блоки)» молча стирал бы блок-лог: приоритет на старте — `resync > replay`. Если маркер +снять не удалось, это отказ `-32000`, а не обещание, которое нода не выполнит. + +**Ноды в снапшот-режиме.** Описанные эффекты маркеров верны для ноды, стартующей обычным +путём. Нода с `--replay-from-snapshot` до него не доходит: chain-плагин восстанавливает +состояние из снапшота и сверху проигрывает блок-лог, поэтому `replay` на такой ноде +превращается в пересборку из снапшота, а `resync` возвращается из снапшота, а не с нуля. +Ремонт при этом остаётся полной пересборкой состояния — то есть тем, что оператору и +нужно, — но ответ так и говорит, а не повторяет общую формулировку. `effect` считается по +опциям, с которыми нода реально стартовала, и `snapshot_replay` на такой ноде равен `true`. + +В ответе есть `marker`, `removed_marker`, `effect` и `snapshot_replay`; вернуть ноду обратно +обязан супервизор (`--restart unless-stopped` / `Restart=always`) — ровно как при обычном +рестарте. + +### `admin.config.get` + +Отдаёт `config.ini` упорядоченными секциями с сохранением дублей ключей (нужно для +повторяющихся строк `plugin`). **Значения ключей, несущих секреты, заменяются на `***`**: +всё, что содержит `private-key`, `private_key`, `password`, `secret`, `token`, `wif` или +`emergency`. + +```json +{"path":"/etc/vizd/config.ini","exists":true,"sections":[ + {"name":"","entries":[{"key":"plugin","value":"chain"}, + {"key":"private-key","value":"***"}]}]} +``` + +Ещё он отдаёт `allow` — редактируемое подмножество, готовое к отрисовке. Элемент: +`{key, group, section, type, value, defaulted, min, max, values, needs_restart}`. + +* `key` — полный адрес, который принимает `admin.config.plan` (`logger.default.level` для + ключа внутри `[logger.default]`), поэтому клиенту не нужно знать, какие ключи секционные. +* `value` равен `null`, а `defaulted` — `true`, когда в файле ключа нет: нода берёт значение + из кода, и панель должна это показать, а не пустое поле. + Значение, прочитанное из файла, отдаётся **нормализованным** (`yes`/`on`/`1` → `true`), + чтобы совпадать со списком вариантов; `valid:false` значит, что строку нормализовать не + удалось и её надо подсветить. +* `type` — `boolean`, `unsigned_int` (с `min`/`max`) или `log_level` (с `values`). +* `group` — плагин, которому принадлежит ключ (`p2p`, `webserver`, `validator_guard`), либо + `logging` для настроек логгеров и аппендеров. + +### `admin.logs.list` + +Без параметров. Перечисляет файлы, объявленные в `admin-log-file`: `name`, `path`, `exists` +и `size` (`null`, если файла ещё нет). Настроенный, но пока пустой аппендер тоже виден: +«пусто» и «не настроено» — разные факты. + +```json +{"logs":[{"name":"node","path":"/var/lib/vizd/logs/node.log","exists":true,"size":1048576}]} +``` + +### `admin.logs.tail` + +Параметры: `{"name":"node","bytes":16384,"lines":200}`. `name` обязан совпасть с +записью `admin-log-file`; ничего вне этого allowlist прочитать нельзя. `bytes` +ограничивается 64 КБ, `lines` — 2000. Строки, похожие на WIF, и секреты вида +`ключ = значение` маскируются. + +### `admin.audit.tail` + +Параметры: `{"lines":100}`. Хвост журнала аудита. + +### `admin.p2p.status` + +Без параметров. Нужен только `read` (кто может смотреть — может смотреть и на пиров). +Отдаёт те же факты, что нода пишет в свой лог — `log_node_status()` раз в минуту и +`log_peer_stats()` раз в пять минут — но по запросу и в JSON: + +| Поле | Смысл | +|---|---| +| `as_of` | UTC-метка замера | +| `uptime_sec` | Аптайм ноды | +| `node_status`, `fork_status` | `SYNC`/`FORWARD`, `NORMAL`/`LOOKING_RESOLUTION`/`MINORITY` | +| `paused`, `isolated_peers`, `catching_up` | Обработка блоков на паузе, режим только-сиды, догон после паузы | +| `head_block_num`, `lib_block_num` | Наша голова и последний необратимый блок | +| `dlt_earliest`, `dlt_latest` | Диапазон блоков DLT, который держим локально (что эта нода может отдать) | +| `connections`, `active`, `max_connections` | Открытые соединения, из них полностью активных, и лимит | +| `known_peers`, `trusted_peers`, `banned_peers` | Множество известных пиров, доверенные оператором, сейчас забаненные | +| `best_peer_head` | Наибольшая голова среди отслеживаемых пиров | +| `best_peer_head_lag` | `наша голова − лучшая голова пира`; **отрицательное значит, что мы отстаём** | +| `peers_ahead` | Сколько пиров впереди нас | +| `peers[]` | Детали по каждому пиру, отсортированы по адресу | + +Поля пира: `endpoint`, `node_id` (hex, пусто до hello), `lifecycle` +(`CONNECTING`/`HANDSHAKING`/`SYNCING`/`ACTIVE`/`DISCONNECTED`/`BANNED`), `node_status`, +`fork_status`, `incoming`, `trusted`, `exchange_enabled`, `fork_alignment`, `connected_sec`, +`peer_head`, `peer_lib`, `peer_dlt_earliest`, `peer_dlt_latest`, `head_lag` (наш минус его), +`spam_strikes`, `ban_reason`, `ban_duration_sec`, `reconnect_backoff_sec`, +`next_reconnect_in_sec`. + +```json +{"jsonrpc":"2.0","id":40,"method":"admin.p2p.status"} +``` + +```json +{"jsonrpc":"2.0","id":40,"result":{ + "as_of":"2026-10-19T15:10:00Z","uptime_sec":86400, + "node_status":"FORWARD","fork_status":"NORMAL","paused":false, + "isolated_peers":false,"catching_up":false, + "head_block_num":22164,"lib_block_num":22150, + "dlt_earliest":1,"dlt_latest":22164, + "connections":3,"active":3,"max_connections":50, + "known_peers":12,"trusted_peers":1,"banned_peers":0, + "best_peer_head":22164,"best_peer_head_lag":0,"peers_ahead":0, + "peers":[{"endpoint":"1.2.3.4:1776","node_id":"02ab…","lifecycle":"ACTIVE", + "node_status":"FORWARD","fork_status":"NORMAL","incoming":false, + "trusted":true,"exchange_enabled":true,"fork_alignment":true, + "connected_sec":3600,"peer_head":22164,"peer_lib":22150, + "head_lag":0,"ban_reason":"","next_reconnect_in_sec":0}]}} +``` + +### `admin.p2p.add_node` + +Параметры: `{"endpoint":"1.2.3.4:1776"}` — IPv4 и порт. Нужны `read` **и** `p2p`. +Добавляет адрес в множество известных пиров и сразу звонит по нему (снимая старый бэкофф +или бан на этом адресе). Пир при этом не доверенный. + +```json +{"endpoint":"1.2.3.4:1776","trusted":false,"status":"added"} +``` + +### `admin.p2p.add_trusted` + +Те же параметры и форма ответа, `"trusted": true`. Доверенный пир: + +* **никогда не получает soft-ban** — `soft_ban_peer()` отключает его и повторяет попытку + через 30 с вместо часового бана; +* **не выбрасывается сборщиком известных пиров**, который иначе удаляет молчащих; +* звонится сразу и переподключается по мере надобности. + +Множество доверенных живёт в памяти: оно **не сохраняется между перезапусками**. Чтобы +закрепить пир навсегда, держите его в `p2p-seed-node` (или в списке сидов, который +используется вместе с `p2p-isolated-peers`) в `config.ini`. + +### `admin.p2p.clear_banned` + +Без параметров. Нужны `read` и `p2p`. Снимает все soft-баны (lifecycle `BANNED` или +непустой `ban_reason`), сбрасывает бэкофф переподключения и сразу повторяет попытки с +этими пирами. + +```json +{"cleared":3,"status":"ok"} +``` + +Типичный операторский сценарий: + +```bash +# сначала статус: что не так и с кем +curl -s http://127.0.0.1:8099 -H "Authorization: Bearer $T" \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"admin.p2p.status"}' | jq '.result | {head_block_num, best_peer_head_lag, peers}' + +# добавить пира и сделать его доверенным +curl -s http://127.0.0.1:8099 -H "Authorization: Bearer $T" \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":2,"method":"admin.p2p.add_trusted","params":{"endpoint":"1.2.3.4:1776"}}' + +# после форк-шторма или волны банов: повторить всех +curl -s http://127.0.0.1:8099 -H "Authorization: Bearer $T" \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":3,"method":"admin.p2p.clear_banned"}' +``` + +### `admin.snapshot.status` + +Параметры: `{"offset":0,"limit":20,"after":"snapshot-block-84360000.vizjson"}` — все +необязательны. Нужен `read` и загруженный плагин `snapshot`. Возвращает одну страницу файлов +снапшотов из настроенного `snapshot-dir` (сначала наибольший номер блока) и сообщает, что +делает плагин. `limit` ограничивается 200. + +Листая каталог, который меняется под читателем, передавай `after` (имя последней уже +полученной строки), а не `offset`: снапшот, записанный между двумя страницами, сдвигает все +offset'ы ниже себя, и страница повторяет строку; чистка по сроку (24 ч) сдвигает в другую +сторону и заставляет offset пропускать строку. С `after` страница продолжается ровно после +этой строки. Если якорь уже вычищен, ответ несёт `after_found: false` и работает по `offset`. + +| Поле | Смысл | +|---|---| +| `as_of` | UTC-метка замера | +| `dir` | Используемый каталог снапшотов | +| `head_block_num` | Текущая голова; `null`, пока восстановление перестраивает состояние | +| `snapshot_count` | Число файлов во всём каталоге | +| `snapshot_total` | Тот же итог, названный явно для клиентов с пагинацией | +| `offset`, `limit` | Выданная страница | +| `after`, `after_found` | Присланный курсор и остался ли он в каталоге | +| `has_more` | После этой страницы есть ещё файлы | +| `in_progress`, `restoring`, `recovering` | Пишется снапшот, идёт восстановление, chain-плагин восстанавливается | +| `latest_path`, `latest_name` | Самый свежий снапшот (наибольший номер блока); когда файлов нет — `latest_path` пустая строка, `latest_name` равен `null` | +| `snapshots[]` | `name`, `path`, `block_num` (0, если имя не несёт блок), `size_bytes`, `modified` (unixtime), `modified_iso`, `latest` | + +```json +{"jsonrpc":"2.0","id":50,"method":"admin.snapshot.status","params":{"offset":0,"limit":20}} +``` + +```json +{"jsonrpc":"2.0","id":50,"result":{ + "as_of":"2026-10-19T15:30:00Z","dir":"/var/lib/vizd/snapshots", + "head_block_num":22164,"snapshot_count":1,"snapshot_total":1, + "offset":0,"limit":20,"has_more":false,"in_progress":false, + "restoring":false,"recovering":false, + "latest_path":"/var/lib/vizd/snapshots/snapshot-block-22164.vizjson", + "latest_name":"snapshot-block-22164.vizjson", + "snapshots":[{"name":"snapshot-block-22164.vizjson", + "path":"/var/lib/vizd/snapshots/snapshot-block-22164.vizjson", + "block_num":22164,"size_bytes":4404019, + "modified":1792423800,"modified_iso":"2026-10-19T15:30:00Z","latest":true}]}} +``` + +### `admin.snapshot.create` + +Без параметров. Нужны `read` и `snapshot`. Пишет `snapshot-block-.vizjson` в +`snapshot-dir` **асинхронно** на потоке снапшотов плагина; обработка блоков P2P приостановлена +на время чтения базы и возобновляется до сжатия. Опрашивайте `admin.snapshot.status`, пока +`in_progress` не станет false. + +```json +{"jsonrpc":"2.0","id":51,"method":"admin.snapshot.create"} +``` + +```json +{"jsonrpc":"2.0","id":51,"result":{ + "status":"started","path":"/var/lib/vizd/snapshots/snapshot-block-22164.vizjson", + "note":"snapshot creation runs on the node's snapshot thread; poll admin.snapshot.status until in_progress is false"}} +``` + +`status` — `started`, `exists` (файл для этой головы уже на диске) или `busy` (идёт другое +создание/восстановление). На пустой цепи (голова `0`) вызов падает с `-32000` и ничего не пишет. + +### `admin.snapshot.restore` + +Параметры: `{}` (восстановить последний), `{"latest":true}`, +`{"name":"snapshot-block-22164.vizjson"}` или +`{"path":"/var/lib/vizd/snapshots/<файл>"}`. Нужны `read` и `snapshot`. + +Восстановление идёт **в процессе**, это не перезапуск процесса: обработка блоков P2P +приостанавливается, shared memory закрывается и очищается, снапшот импортируется, +`dlt_block_log` проигрывается заново, пиры сбрасываются и P2P возобновляется. ОС-процесс и +его аптайм не меняются, поэтому слушатель продолжает отвечать во время операции +(`restoring: true`, голова `null`). Если задан `path`, он обязан указывать на обычный файл +непосредственно в каталоге снапшотов. + +```json +{"jsonrpc":"2.0","id":52,"method":"admin.snapshot.restore","params":{"latest":true}} +``` + +```json +{"jsonrpc":"2.0","id":52,"result":{ + "status":"started","snapshot":"/var/lib/vizd/snapshots/snapshot-block-22164.vizjson", + "snapshot_name":"snapshot-block-22164.vizjson","block_num":22164, + "process_restarted":false, + "note":"in-process restore: P2P is paused, shared memory is wiped, the snapshot is imported, dlt_block_log is replayed and P2P resumes. The OS process and its uptime are unchanged. Progress: admin.snapshot.status (restoring)."}} +``` + +Вызов, который не может начаться, отвечает `-32000` (нет снапшотов, идёт создание, файл +нечитаем); некорректные имена, traversal и файлы вне каталога снапшотов отвечают `-32602` +до любого изменения состояния. Если сам импорт падает, `do_snapshot_load` уже очистил базу +и запросил завершение — смотрите лог ноды; админский API не делает вид, что восстановление +удалось. + +--- + +## Методы ключей валидаторов (скоуп `chain`) + +Решение q#3051=D: оператор управляет валидаторами через `node_admin`, но **active-ключ +никогда не попадает в админский API**. Транзакции собираются и подписываются внутри +плагина `validator_guard`, которому заданы signing- и active-WIF каждого валидатора: + +```ini +plugin = validator_guard +validator-guard-validator = ["val1", "5J…signing…", "5J…active…"] +validator-guard-validator = ["val2", "5J…signing…", "5J…active…"] +``` + +Guard должен быть загружен, запущен и содержать хотя бы одного настроенного валидатора; +иначе изменяющие методы отвечают `-32002 forbidden / validator-guard is not configured`, а +`admin.validator.list` показывает `guard.reason`. Настроенный валидатор обязан **существовать +в цепи** до планирования; создание нового валидатора здесь не предлагается (оно списывает +сбор за объявление валидатора и меняет набор производителей блоков). + +### `admin.validators.active` + +Без параметров, только `read`. Возвращает имена из текущего расписания валидаторов +(дубликаты от коэффициента повтора схлопнуты), число слотов расписания, состояние аварийного +консенсуса и запущен ли плагин validator в этом процессе. **Ключи не возвращаются** — это +публичные данные цепи. Для валидаторов, которыми управляет эта нода, есть +`admin.validator.list`. + +```json +{"as_of":"2026-10-19T15:40:00Z","head_block_num":22164,"count":21, + "num_scheduled":21,"validators":["val1","val2"], + "emergency_consensus_active":false,"emergency_consensus_start_block":0, + "validator_plugin_loaded":true} +``` + +### `admin.validator.list` + +Скоуп `read`. Параметров нет. Возвращает: + +```json +{"guard":{"loaded":true,"started":true,"available":true}, + "validators":[{"name":"val1","on_chain":true,"enabled":true,"hold":false, + "active_authority":true,"scheduled_in":2, + "signing_key":"VIZ6…","configured_key":"VIZ6…"}]} +``` + +* `enabled` — on-chain signing-ключ не null. +* `hold` — оператор выключил валидатор через этот API; авто-восстановление guard'а для него + подавлено до успешного `enable`/`set_key` (см. ниже). +* `active_authority` — active-ключ guard'а входит в active-власть аккаунта. `false` значит, + что транзакция, подписанная guard'ом, будет отвергнута цепью; то же предупреждение + пишется в лог при старте guard'а. +* `scheduled_in` — позиция (с 1) в ближайших 3 слотах расписания, которые производит этот + валидатор; `null`, если его там нет. + +### `admin.validator.plan` + +Скоуп `chain`. Параметры: `{"actions":[{"name":"val1","action":"disable"},…]}`. `action` — +`enable` (вернуть signing-ключ, настроенный в guard), `disable` (обнулить signing-ключ и +поставить operator hold) или `set_key` (требует `signing_key` — публичный ключ в base58). +Каждое имя должно быть настроено в guard и существовать в цепи; дубликаты отвергаются. + +Ответ содержит `plan_id`, `kind:"validators"`, `transactions` (сколько транзакций цепи +отправит план) и `diff` по каждому действию: ключи `from`/`to`, `hold_from`/`hold_to`, +`tx_needed`. Уже выполненное действие в план не попадает; план без фактических изменений +отвергается с `-32602 no changes`. **Ничего пока не подписано и не отправлено.** Планы +одноразовые, привязаны к метке токена и истекают через `admin-plan-ttl`, как и планы +конфигурации. Захват текущего ключа в план и делает устаревший план безопасным: если цепь +изменилась между plan и apply, apply отвергает это действие, а не затирает чужое обновление. + +### `admin.validator.apply` + +Скоуп `chain`. Параметры: `{"plan_id":"…"}`. Подписывает по одной `validator_update` на +каждое изменившееся действие active-ключом guard'а, кладёт её в локальный pending pool и +рассылает пирам. Ответ перечисляет по каждому действию `status`: `applied` (с `tx_id`), +`already` (цепь сама пришла в нужное состояние) или `failed` (с `error`). Также даны +счётчики `applied`, `unchanged`, `failed`. + +**Operator hold следует за результатом, а не за намерением.** Успешный `disable` записывает +валидатора в `/validator-guard-holds.txt`; периодическое авто-восстановление +guard'а пропускает удерживаемых, а файл перечитывается при старте — поэтому перезапуск тоже +не включит валидатор молча (disable-on-shutdown hold не создаёт — это штатное действие +выключения). Успешный `enable` или `set_key` снимает hold. Неудачный disable не оставляет +защёлки: валидатор остаётся как был. + +Транзакции собираются под read-lock цепи и рассылаются после его освобождения — то же +правило, что у любой другой записи в цепь из этого плагина. Перезапуск не нужен. + +--- + +## Методы политики webserver + +Эти методы управляют плагином публичного API: счётчиками запросов по IP, логом статистики, +лимитами и списками IP. Семантика, значения по умолчанию и формат лога описаны в +[Webserver — «Учёт запросов и лимиты»](./webserver.md#учёт-запросов-и-лимиты). + +**Изменения живые.** Плагин держит это состояние в памяти; изменение применяется к +следующему запросу webserver — без перезапуска и без перечитывания `config.ini`. Каждое +изменение также атомарно сохраняется в `webserver-admin-state` +(по умолчанию `/webserver-admin.json`), который загружается при старте с проверкой отпечатка `config_policy` (см. уточнения ниже), вместо безусловного приоритета над стартовое значение из `config.ini`. Метод `reload` перечитывает этот файл для +правок руками. + +| Метод | Скоуп | Параметры | Назначение | +|-------|-------|-----------|------------| +| `admin.webserver.status` | `read` | нет | Лимиты, итоги, списки IP и число отслеживаемых адресов. | +| `admin.webserver.ips` | `read` | `{"limit":200}` (макс. 5000) | Строки по IP: `total`, `minute`, `hour`, `day`, `blocked`, `limited`, `last_seen`, `whitelisted`, `blacklisted`; сортировка по total. | +| `admin.webserver.limits` | `webserver` | `{"enabled":bool,"per_minute":N,"per_hour":N,"per_day":N}` | Опущенные поля сохраняют значение. | +| `admin.webserver.stats` | `webserver` | `{"enabled":bool}` | Включить/выключить учёт запросов. Лимиты управляются отдельно. | +| `admin.webserver.whitelist_add` / `whitelist_remove` | `webserver` | `{"ip":"1.2.3.4"}` | Адреса из белого списка обходят лимиты, но всё равно считаются. | +| `admin.webserver.blacklist_add` / `blacklist_remove` | `webserver` | `{"ip":"1.2.3.4"}` | Адреса из чёрного списка получают `403`; действует даже при выключенной статистике. | +| `admin.webserver.reset` | `webserver` | нет | Обнулить счётчики; списки и лимиты сохранить. | +| `admin.webserver.reload` | `webserver` | нет | Перечитать файл состояния с диска; вернуть применённый статус. | + +Добавление IP в один список удаляет его из другого. Адрес должен быть одним литералом +IPv4 или IPv6; иначе `-32602`. + +```json +{"jsonrpc":"2.0","id":53,"method":"admin.webserver.limits","params":{"per_minute":600}} +``` + +```json +{"jsonrpc":"2.0","id":53,"result":{ + "status":"applied", + "limits":{"enabled":true,"per_minute":600,"per_hour":10000,"per_day":100000}, + "stats_enabled":true, + "note":"applied to the live webserver on the next request and persisted to its admin state file; config.ini is unchanged."}} +``` + +Если плагин `webserver` не загружен, ответ — `-32004 webserver unavailable`. Публичный +webserver отвечает клиентам из чёрного списка `403`, а превысившим лимит — `429` с +`Retry-After: 60`; эти ответы производит webserver, а не этот API. + +--- + +## Правка конфигурации и плагинов (две фазы) + +`config.ini` правится через **plan**, который возвращает точный дифф и ничего не пишет, и +**apply**, который перепроверяет файл и заменяет его атомарно. Так намерение агента можно +проверить до того, как оно коснётся боевой ноды, а падение посреди apply не оставит +обрезанный файл. + +### Семантика plan + +* `admin.config.plan {"set":{"key":value,…}}` проверяет каждый ключ по **allow-list** ниже, + нормализует значение (`yes/on/1` → `true`; целые проверяются по диапазону) и возвращает + `plan_id`, `kind:"config"`, `diff` (`[{key, from, to}]`), `config_path`, `needs_restart:true` + и `expires_at`. **Ничего не пишется.** План без фактических изменений отклоняется с + `-32602`. +* `admin.plugins.plan {"add":[…],"remove":[…]}` проверяет имена по набору плагинов, которые + умеет грузить бинарник, отклоняет дубли, отсутствующие имена и удаление `chain` и + `node_admin`, и возвращает `diff` из `[{op:"add"|"remove", name}]`. +* План **одноразовый** (сгорает на первом `apply`, успешном или нет), **привязан к label + токена**, который его создал (`-32002` для любого другого), и **истекает** через + `admin-plan-ttl` секунд (`-32004 plan expired`). Хранится до 64 планов; при переполнении + вытесняется самый старый. `admin.plan.cancel` отбрасывает план явно. +* `apply` заново считает SHA-256 файла `config.ini` и отказывает с `-32000 config changed … + nothing was written`, если файл изменился после создания плана. Затем он заново + нормализует и проверяет каждое значение — apply никогда не доверяет плану слепо. + +### Атомарная запись и бэкапы + +Оба apply правят файл как **сырые строки**, поэтому комментарии, порядок, отступы и CRLF +сохраняются. Отсутствующий ключ верхнего уровня вставляется перед первым `[section]`; +существующий ключ верхнего уровня меняется на месте. Ключ, у которого в спецификации +указана секция (см. строку логов в таблице ниже), правится внутри этой секции и +вставляется сразу под её заголовок, если секция есть, а ключа в ней нет. + +Каждый успешный apply пишет один бэкап рядом с конфигом — `.bak-` +(при коллизии с суффиксом `-n`), — затем временный файл в том же каталоге, `fsync`, копию +прав исходного файла, `rename` поверх `config.ini` и `fsync` каталога. Неудачная запись +оставляет исходный файл нетронутым. Старые бэкапы нода не чистит; в них есть секреты +(токены, ключи), поэтому обращаться с ними нужно как с самим конфигом. + +`admin.config.backups` (скоуп `read`) перечисляет `.bak-*`, новые сверху, с +размером и mtime. Откат вручную — скопировать бэкап обратно и перезапустить ноду. + +### Allow-list + +Админский API правит только эти ключи. Всё остальное — идентичность, ключи, data-dir, +эндпоинты, `plugin`, `shared-file-size` — намеренно вне досягаемости bearer-токена. + +| Ключи | Тип | Диапазон | +|---|---|---| +| `p2p-max-connections` | uint | 1–10000 | +| `dlt-peer-max-disconnect-hours` | uint | 1–8760 | +| `dlt-mempool-max-tx` | uint | 1–1000000 | +| `dlt-mempool-max-bytes` | uint | 1024–1073741824 | +| `dlt-mempool-max-tx-size` | uint | 256–67108864 | +| `dlt-mempool-max-expiration-hours` | uint | 1–720 | +| `dlt-peer-exchange-max-per-reply` | uint | 0–1000 | +| `dlt-peer-exchange-max-per-subnet` | uint | 0–1000 | +| `dlt-peer-exchange-min-uptime-sec` | uint | 0–86400 | +| `dlt-stats-interval-sec` | uint | 10–86400 | +| `p2p-isolated-peers`, `auto-resync-on-wedge` | bool | — | +| `webserver-thread-pool-size` | uint | 1–4096 | +| `webserver-cache-enabled` | bool | — | +| `webserver-cache-size` | uint | 0–10000000 | +| `webserver-stats-enabled`, `webserver-limits-enabled` | bool | — | +| `webserver-limit-m`, `webserver-limit-h`, `webserver-limit-d` | uint | 1–100000000 | +| `webserver-stats-log-max-size` | uint | 1024–1073741824 | +| `webserver-stats-log-keep` | uint | 1–1000 | +| `webserver-stats-interval` | uint | 1–86400 | +| `validator-guard-enabled`, `validator-guard-disable`, `validator-guard-disable-on-shutdown` | bool | — | +| `validator-guard-interval`, `validator-guard-shutdown-grace` | uint | 1–3600 | +| `validator-guard-disable-max` | uint | 0–1000 | +| `logger.default.level`, `logger.p2p.level` | уровень лога | `all`, `debug`, `info`, `warn`, `error`, `off` | +| `log.capped_file_appender.node.max_lines` | uint | 0–10000000 | +| `log.capped_file_appender.node.flush` | bool | — | + +Ключи логов адресуются вместе с секцией: `logger.default.level` правит `level` внутри +`[logger.default]`, а не одноимённую опцию верхнего уровня. Отсутствующая в `config.ini` +секция — ошибка, а не повод её создать: нода такую секцию не объявляет, и значение молча +игнорировалось бы на следующем старте. Имена файлов аппендеров и окна ротации остаются за +оператором: опечатка в пути — это потерянный лог, а не настройка. + +### Пример + +```json +{"jsonrpc":"2.0","id":80,"method":"admin.config.plan", + "params":{"set":{"p2p-max-connections":200,"webserver-limit-m":600}}} + +{"jsonrpc":"2.0","id":80,"result":{ + "plan_id":"9f2c41a0d3b54e8770c5f1a2b3c4d5e6","kind":"config", + "config_path":"/var/lib/vizd/config.ini","needs_restart":true, + "diff":[{"key":"p2p-max-connections","from":"64","to":"200"}, + {"key":"webserver-limit-m","from":null,"to":"600"}], + "expires_at":"2026-10-19T14:42:00Z", + "hint":"nothing was written yet; apply with admin.config.apply {plan_id}…"}} +``` + +```json +{"jsonrpc":"2.0","id":81,"method":"admin.config.apply", + "params":{"plan_id":"9f2c41a0d3b54e8770c5f1a2b3c4d5e6"}} + +{"jsonrpc":"2.0","id":81,"result":{ + "applied":true,"kind":"config", + "config_path":"/var/lib/vizd/config.ini", + "backup":"/var/lib/vizd/config.ini.bak-1760884860", + "needs_restart":true, + "restart_hint":"config.ini is read at startup: call admin.restart {confirm:\"restart\"}…"}} +``` + +Пример с плагинами: + +```json +{"jsonrpc":"2.0","method":"admin.plugins.plan", + "params":{"add":["block_info"],"remove":[]}} +{"jsonrpc":"2.0","method":"admin.plugins.apply","params":{"plan_id":"…"}} +``` + +Оба apply отвечают `-32004 config unavailable`, если `config.ini` не читается, и +`-32000 write failed`, если атомарная замена не удалась (исходный файл остаётся нетронутым). + +--- + +## Перезапуск и супервизия + +`admin.restart {"confirm":"restart"}` (скоуп `process`) просит `appbase` завершиться: +сначала уходит ответ, затем отдельный поток через 300 мс вызывает `quit()`, чтобы HTTP-ответ +успел дойти до клиента. Процесс завершается с кодом 0. + +**Сам плагин ноду обратно не поднимает.** Это работа супервизора — `--restart +unless-stopped` у контейнера или `Restart=always` у systemd. Без них нода просто останется +лежать; ответ говорит об этом в `hint`, и это намеренно: админский эндпоинт, который +форкает новый процесс ноды, был бы путём эскалации привилегий за пределы жизненного цикла +процесса. + +Поскольку `config.ini` и список плагинов читаются при старте, `admin.config.apply` и +`admin.plugins.apply` вступают в силу только после этого перезапуска. `confirm` обязан быть +ровно словом `restart`; иначе `-32602`. Повторный вызов, пока завершение ещё не началось, +отвечает `-32000 restart already requested`. + +--- + +## Эндпоинт MCP (Streamable HTTP) + +Решение q#3052=B: MCP-сервер живёт **внутри этого плагина**, а не в sidecar. Тот же +loopback-слушатель, те же bearer-токены и тот же журнал аудита обслуживают Model Context +Protocol поверх HTTP — на хосте ничего дополнительно ставить не нужно. + +Запросы MCP — обычные JSON-RPC 2.0 `POST` на админский эндпоинт: + +| Метод | Назначение | +|---|---| +| `initialize` | Рукопожатие. Поддерживаемые версии: `2024-11-05`, `2025-03-26`, `2025-06-18` (запрошенная возвращается как есть, иначе `2025-06-18`). Объявляется только возможность `tools`. | +| `notifications/initialized` | Уведомление (без `id`); ответ — HTTP 202 с пустым телом. | +| `ping` | Keepalive MCP; возвращает `{}`. | +| `tools/list` | Инструменты ниже с JSON-схемой `inputSchema`. | +| `tools/call` | `{"name":"admin.node_info","arguments":{…}}` — выполняет соответствующий метод `admin.*`. | + +Инструменты 1:1 с методами admin: `admin.ping`, `admin.auth.me`, `admin.node_info`, +`admin.diagnostics`, `admin.plugins.list`, `admin.plugins.status`, `admin.objects.list`, +`admin.objects.check`, `admin.objects.repair`, `admin.config.get`, `admin.logs.list`, +`admin.logs.tail`, `admin.audit.tail`, `admin.validators.active`, +`admin.p2p.status`, `admin.p2p.add_node`, `admin.p2p.add_trusted`, `admin.p2p.clear_banned`, +`admin.snapshot.status`, `admin.snapshot.create`, `admin.snapshot.restore`, +`admin.webserver.status`, `admin.webserver.ips`, `admin.webserver.limits`, +`admin.webserver.stats`, `admin.webserver.whitelist_add`, `admin.webserver.whitelist_remove`, +`admin.webserver.blacklist_add`, `admin.webserver.blacklist_remove`, `admin.webserver.reset`, +`admin.webserver.reload`, `admin.validator.list`, `admin.validator.plan`, +`admin.validator.apply`, `admin.config.plan`, `admin.config.apply`, `admin.config.backups`, +`admin.plugins.plan`, `admin.plugins.apply`, `admin.plan.cancel`, `admin.restart` +(сорок один). +Действия над пирами дополнительно требуют скоупа `p2p`, действия над снапшотами — `snapshot`, +действия над валидаторами — `chain` (и живого `validator_guard` с active-ключом), действия +над webserver — `webserver`, правки конфига и плагинов — `config` или `plugins`, +перезапуск и починка состояния — `process`, ровно как и по обычному JSON-RPC. Ошибки исполнения инструмента возвращаются **внутри** +результата (`"isError": true`, текст в `content[0].text`), как требует MCP; протокольные +ошибки (неизвестный инструмент, плохие параметры, нет скоупа) остаются ошибками JSON-RPC. + +```json +{"jsonrpc":"2.0","id":7,"result":{"tools":[ + {"name":"admin.node_info","description":"Chain id, head block, hardfork, uptime and head age.", + "inputSchema":{"type":"object","properties":{},"additionalProperties":false}}]}} +``` + +```json +{"jsonrpc":"2.0","id":8,"result":{ + "content":[{"type":"text","text":"{\"head_block_num\":1234,…}"}],"isError":false}} +``` + +Эндпоинт без сессий: `Mcp-Session-Id` не выдаётся, `GET` на всё, кроме `/ui/` и маршрутов +`/snapshot/…` (включая необязательный SSE-поток), отвечает `405`. Клиенты, которым обязателен +SSE, не поддерживаются; ответы — JSON. + +Конфигурация клиента (транспорт Streamable HTTP) указывает на тот же loopback-адрес: + +```json +{"mcpServers":{"viz-node":{"type":"http","url":"http://127.0.0.1:8099", + "headers":{"Authorization":"Bearer "}}}} +``` + +--- + +## Встроенный веб-интерфейс (`/ui/`) + +Одна статическая страница, вкомпилированная в бинарник и отдаваемая тем же слушателем: + +```text +http://127.0.0.1:8099/ui/ +``` + +Чистые HTML + vanilla JS — без сборки, без внешних ресурсов, без фреймворков. CSP разрешает +только собственные inline-стиль/скрипт страницы и same-origin `fetch`, всё остальное — +`'none'`. + +Базу JSON-RPC страница выводит из собственного URL, поэтому работает и когда оператор +публикует слушатель за префиксом обратного прокси, а не в корне: при nginx +`location /admin/ { proxy_pass http://127.0.0.1:9099/; }` страница живёт на `/admin/ui/` +и вызывает `/admin/`. В корне (`/ui/`) база — `/`, как и раньше. + +**Аутентификация.** У страницы нет собственного секрета. При первом использовании оператор +вставляет bearer-токен; он хранится в `sessionStorage` (на вкладку, очищается при её +закрытии) и отправляется как `Authorization: Bearer …` в каждом JSON-RPC-вызове обратно на +тот же слушатель. Сама страница отдаётся без аутентификации намеренно: она одинакова для +всех, данных в ней нет, а браузер всё равно не умеет приложить bearer-заголовок к переходу +по ссылке. Токены никогда не кладутся в URL (иначе они попадут в историю и access-логи). + +**Что показывает** + +Страница разбита на семь вкладок; последняя выбранная запоминается на сессию вкладки. + +* **Status** — состояние ноды, аварийный консенсус (`ACTIVE` с блоком начала или `normal`), + настроен ли аварийный ключ, голова/LIB, возраст головы, версия, аптайм, счётчики пиров, + лучшая голова пира и наше отставание, баны и флаги. Карточка состояния снапшотов прямо + расшифровывает `idle` («никакой задачи со снапшотом не выполняется»), `creating` и + `restoring`. +* **Peers** — таблица всех отслеживаемых пиров: направление, lifecycle, голова, отставание, + статусы ноды/форка, флаги (`trusted`, `exchange`, `aligned`), возраст соединения, + примечание о бане/повторе. При скоупе `p2p` появляется панель действий: добавить пира по + `IP:port`, добавить как доверенного, очистить баны. +* **Snapshots** — состояние, каталог, общее число и самый свежий файл; **20** новейших + файлов с кнопкой **Load more**, которая листает через `offset`/`limit`; блок, размер, + возраст и кнопки **Download** / **Restore** в строке, плюс **Create snapshot** и + **Restore latest**. На время скачивания кнопка блокируется и показывает радиальный + индикатор до прихода тела ответа, поэтому повторные клики не запускают параллельные + скачивания. Скачивание идёт через авторизованный маршрут `/snapshot/` и + сохраняется браузером под именем файла. +* **Logs** — выбор файла из записей `admin-log-file`, кнопка **Play/Pause** и прокручиваемая + панель. По умолчанию **пауза**: Play опрашивает `admin.logs.tail` каждые две секунды и + дописывает только новые строки внизу, раскрашивая их по важности. Если оператор прокрутил + вверх, автопрокрутка прекращается; возврат вниз её возобновляет. +* **Plugins** — плагины из `config.ini` с наблюдаемым состоянием и числом редактируемых + параметров, кнопка **Disable** в каждой строке (`chain` и `node_admin` показывают + недоступную кнопку **protected**) и выбор плагина, который умеет загружать бинарь. + Любое изменение идёт через `plugins.plan` → подтверждение с последствием → `plugins.apply`. + Ниже таблицы **Parameters** рисует allow-list-ключи по группам плагинов, а **Logging** — + ключи логгеров и аппендеров; каждая группа сохраняется через `config.plan`/`config.apply`. + Значения, взятые из кода (ключа нет в `config.ini`), помечены `code default`, а не пустым + полем. После любого apply появляется баннер «файл изменён» с кнопкой **Restart node**: + она зовёт `admin.restart` после двух подтверждений и работает только со скоупом `process`. + Кнопка **Status** в каждой строке, у плагина которой есть свой статус, зовёт + `admin.plugins.status` и показывает ответ как есть над таблицей; у строк без статуса + кнопки нет — отсутствие видно, а не превращается в пустую панель. +* **Objects** — взгляд chainbase: путь и размер shared memory, отображено/свободно/ + зарезервировано, ревизия индексов против head, версии схемы, размер блок-лога, маркеры + починки и таблица типов объектов с числом строк и ревизией индекса, от большего к + меньшему. **Check** зовёт `admin.objects.check` и красит вердикт (`ok` / `warn` / + `corrupted` / `unreadable`), каждая находка — строкой + `SEVERITY [code] text`; рекомендованный режим починки подставляет селектор. + **Restart with repair** шлёт `admin.objects.repair` после двух подтверждений, где прямо + сказана разница между replay (блоки остаются) и resync (блоки стираются), и требует скоуп + `process`. Как и Plugins, вкладка вне пятисекундного таймера, чтобы ожидающий вердикт не + перерисовывался под оператором. +* **Validators** — имена из текущего расписания валидаторов (публичные данные цепи, без + ключей), размер расписания и аварийное состояние; ниже — валидаторы, которыми управляет + эта нода через `validator_guard`, если плагин жив. + +**Страницей управляют скоупы.** При загрузке страница вызывает `admin.auth.me` и скрывает +каждое действие, которое токену недоступно: без `snapshot` пропадают кнопки Create/Restore +и Restore в строках, без `p2p` — панель действий над пирами, без `plugins` — кнопки +Disable/Add, без `config` поля параметров заблокированы, без `process` недоступны кнопки +Restart и Repair. Поэтому read-only токен видит +дашборд только со списком и скачиванием, и страница говорит об этом прямо, вместо кнопок, +на которые API ответил бы `-32002`. + +Статус обновляется каждые пять секунд, пока вкладка видима. + +**Чего оно не делает.** Нет управления токенами и удаления файлов снапшотов. Записи — три +действия над пирами, создание снапшота, восстановление в процессе, правка `config.ini` +парой plan/apply, запрос перезапуска и запрос починки состояния (это маркер плюс рестарт: +сам пересбор делает chain-плагин на следующем старте, а не админский плагин). Это тонкий вид поверх того же JSON-RPC-контракта, описанного +выше — всё, что умеет страница, умеют и `curl`, и MCP-клиент, а каждый вызов попадает в +журнал аудита под меткой токена (собственный `GET /ui/` пишется в аудит с исходом `ui`, +скачивание — с `snapshot_download`). + +**Токен для страницы.** Заведите отдельный токен с нужными странице скоупами: токен только +с `read` даёт живой read-only дашборд со списком и скачиванием снапшотов, добавление `p2p` +включает кнопки действий над пирами, а `snapshot` — кнопки **Create** и **Restore**: + +```ini +admin-token = ui:read,snapshot,p2p: +``` + +--- + +## Аудит + +Каждый запрос в пределах общего входного бюджета 100 запросов/с — включая неудачную аутентификацию и ошибки разбора — +добавляет одну JSON-строку: + +```json +{"ts":"2026-10-19T14:40:00Z","rid":"17","token":"agent","method":"admin.node_info", + "params":{},"outcome":"ok","code":0,"ms":2,"remote":"127.0.0.1:51234"} +``` + +`token` — метка, никогда не сам токен и не его хеш. `remote` — настоящий адрес пира +соединения (не `X-Forwarded-For`: слушатель по построению не стоит за прокси, иначе клиент +выбирал бы себе атрибуцию в аудите). `params` маскируются теми же правилами, что и хвост +лога. Файл создаётся с правами `0600` и ротируется на 16 МБ с сохранением восьми поколений `.1`–`.8`. Превышение входного бюджета отбрасывается без записи в аудит; MCP-вызовы дополнительно содержат имя инструмента `tool`. + +--- + +## Замечания по безопасности + +* Слушатель по умолчанию только на loopback; не ставьте его за nginx. +* Никогда не передавайте токен в query string — он попадёт в логи прокси и access-логи. +* Токен — это доступ к процессу ноды. Один токен на агента, чтобы аудит атрибутировал + вызовы, и минимальный набор скоупов. +* `admin.config.get` маскирует секреты по имени ключа, а не по форме значения. Секрет под + безобидным именем будет напечатан — не заводите таких ключей. +* Админский API никогда не отдаёт WIF-ключи. Кроме журнала аудита, на диск пишут + `admin.snapshot.create` (в настроенный каталог снапшотов) и apply у + `admin.config.*`/`admin.plugins.*` (конфиг плюс один `.bak-*` на каждый apply). +* `admin.snapshot.restore` **заменяет всё состояние цепи** выбранным файлом. Он + приостанавливает обработку блоков и очищает shared memory; неверный или устаревший снапшот + разрушителен. Считайте скоуп `snapshot` эквивалентом права записи в состояние ноды, а при + ожидаемом догоне с пирами предпочитайте снапшот на уровне LIB пиров или ниже. +* Снапшоты отдаёт только админский слушатель, никогда публичный webserver. Не копируйте их + в общедоступный путь; снапшот содержит полное состояние цепи и, в зависимости от сборки, + метаданные аккаунтов. +* `GET /ui/` без аутентификации — так задумано, и это одни и те же байты для всех. Доступа + это не даёт: без токена каждый её вызов получает `401`. `OPTIONS` он не отвечает и + CORS-заголовков не шлёт, поэтому чужая страница не сможет дёргать его из браузера. +* Действия над пирами живут в памяти. `add_trusted` и `add_node` действуют до перезапуска + процесса; постоянные закрепления — в конфигурации сидов P2P, а не в админском API. +* Скоуп `webserver` может влиять на доступность публичного API (слишком строгий лимит или + ошибочная запись в чёрном списке отклоняют добросовестных клиентов). Токен с ним — это + политический credential, а не только читающий; изменения к тому же переживают перезапуск + через `webserver-admin-state`. +* Скоупы `config` и `plugins` правят `config.ini` на диске. Allow-list держит вне + досягаемости идентичность, ключи, эндпоинты и `plugin`, но редактируемые ключи всё равно + меняют поведение ноды при следующем перезапуске. `apply` сверяет хеш файла, поэтому правка, + сделанная кем-то другим после плана, никогда не будет молча перезаписана; бэкап — это путь + отката. +* `admin.config.backups` перечисляет файлы, в которых есть секреты (токены, строки + `private-key`). Они создаются с правами исходного файла; не копируйте их в общедоступный + путь. +* Скоуп `process` останавливает ноду. Без супервизора нода не вернётся; админский API + намеренно не умеет перезапускать себя в обход жизненного цикла процесса. +* `admin.objects.repair` — это право записи, замаскированное под обслуживание. + `mode=replay` выбрасывает всё состояние и собирает его заново из блок-лога; + `mode=resync` **вдобавок удаляет блок-лог**, и нода сможет подняться только синхронизацией + с пирами. Сначала гоняйте `admin.objects.check`, а скоуп считайте равным удалению + каталога состояния руками. + +--- + +## См. также + +* [Обзор плагинов](./overview.md) +* [Webserver](./webserver.md) — публичный API, который этот плагин намеренно не использует + +--- + +## Тесты + +`plugins/node_admin/tests/acceptance.sh` поднимает одноразовую ноду, дёргает слушатель +curl'ом и проверяет этот контракт: аутентификацию и скоупы, маскирование конфига/логов, +allowlist логов, коды ответов, аудит, лимит, действия над P2P-пирами (включая отказ без +скоупа `p2p`), статус/список/скачивание снапшотов (включая `HEAD`, traversal и отсутствующий +файл, а также отказ без скоупа `snapshot` на create/restore), статус каждого плагина +(запущенный со своим представлением, неслинкованный тип отвечает `state: null` с причиной, +запущенный плагин без своего статуса, отказ без имени), объекты chainbase (`index_count` +совпадает с длиной массива, порядок от большего, схема совпадает с бинарём, чистый вердикт на +свежей ноде и отказы repair — скоуп read, отсутствующий `confirm`, неизвестный режим и +`replay` при пустом блок-логе, причём отказ не оставляет маркера), статистику/лимиты webserver +(всплеск → `429`, обход белым списком, `403` чёрным, смена лимитов на ходу, сохранение и +reload), двухфазную правку конфига и плагинов (allow-list, скоупы и привязка к токену, +одноразовость, истечение, отказ по устаревшему хешу, бэкапы, защита плагинов), операции с +ключами валидаторов (список остаётся читаемым и называет недостающий плагин, `plan` отвергается +без `chain`, а затем без живого guard'а; после перезапуска guard загружен и стартовал, но +валидатора с active-ключом нет — `plan` по-прежнему отвергается), перезапуск +(гард `confirm`, гард скоупа, чистый выход с кодом 0 и старт «как у супервизора», который +читает отредактированный конфиг обратно), MCP `initialize`/`tools/list`/`tools/call` и +страницу `/ui/`. Поднимает плагин +webserver на третьем порту, поэтому нужны собранный `vizd` и три свободных порта; в CTest +не входит. + +Путь подписи этим скриптом не проверить: у свежей цепи нет второго валидатора, а у +genesis-аккаунта `committee` active-авторитет пуст. Его проверяет +`plugins/node_admin/tests/validator-e2e.sh` — он поднимает собственную черновую цепь (временный +каталог и три порта; `--keep` оставляет её для разбора). Скрипт проводит тестовую однослотовую +цепь через HF9/HF12: `viz` регистрируется, вестится и получает голос в первых блоках, потому что +голоса весят доли вестинга, HF9 удаляет валидаторов, не подписавших ни одного блока, а HF12 +исключает `committee` из выбора; затем создаются два валидатора: `backup` несёт операторские +действия, а `cold` нужен, чтобы план-батч мог назвать двух разных валидаторов. Проверки: список +(`on_chain`, `enabled`, `active_authority`), disable/enable/set-key с id транзакции, файл удержаний +переживает перезапуск ноды, устаревший план отвергается с `chain state changed` и ничего не +подписывает, план из двух действий применяет оба, производящий валидатор ротирует собственный +ключ, и углы контракта (дубль, неизвестное имя, плохой ключ, no-op → `-32602`). + +Два решения в подготовке цепи принципиальны. Операторские действия никогда не целятся в +единственного *запланированного* валидатора: расписание пересобирается только в конце +применённого блока, поэтому обнуление его ключа оставляет устаревшее расписание, указывающее на +валидатора без ключа, и однослотовая цепь встаёт намертво. Секция саморотации — регрессия на +исправленный баг ноды: заголовок блока проверяется по ключу, зафиксированному в head, поэтому +блок, несущий обновление, обязан быть подписан СТАРЫМ ключом, а новый вступает в силу со +следующего блока. Раньше продюсер брал ключ подписи из session-visible объекта валидатора +(`validator.cpp`), поэтому ожидающий `validator_update`, меняющий ключ производящего валидатора, +заставлял подписать блок НОВЫМ ключом; `validate_block_header` отвергал блок, pending-пул +очищался, обновление молча терялось. Теперь плагин держит снапшот committed-ключей (засевается на +старте, обновляется по содержимому применённых блоков) и подписывает им. + +`tests/node_admin/` — две цели CTest (Boost.Test). `node_admin_util` покрывает чистые +хелперы: маскирование секретов, разбор токенов, парсер конфига, allow-list и нормализацию, +а также редакторы сырых строк (отступы, CRLF, секции, гарды добавления/удаления плагинов). + +`node_admin_objects` покрывает ветки вердикта `admin.objects.check` в `objects_check.hpp` — +заголовок намеренно без fc и chainbase, поэтому каждое плечо гоняется на вручную собранном +состоянии: здоровое, отставание ревизии после краша (warn, не порча), обрезанный файл +(replay), нет блок-лога (resync), исчерпанная память в одиночку (починка не предлагается — +тот же размер забьётся снова), исчерпание рядом с другой критичной находкой (replay), +пережившие маркеры, правила схемы, кардинальность ключевых индексов выше и на генезисе, +граница 5% свободного места, зависший read lock (unreadable, и починка не предлагается по +замку, на который так и не ответили) и предупреждения о восстановлении/блок-логе. Именно +судья приглашает оператора стереть состояние, поэтому ложное `corrupted` и ложное `ok` — +оба дефекты, и на каждое есть тест. + +## Уточнения безопасности 4.2.0 + +Глобальный входной бюджет — 100 запросов/с: превышение отбрасывается до аудита, чтобы поток без авторизации не вытеснял историю. Аудит хранит восемь поколений ротации `.1`–`.8`, MCP-запись содержит поле `tool` с именем вызванного инструмента. HTTP handshake ограничен 5 секундами, слушатель — 64 одновременными соединениями. Snapshot скачивается потоком по 64 КиБ без ограничения размера; застрявшая запись в сокет прерывается через 30 секунд. + +Сохранённая runtime-политика webserver применяется при совпадении отпечатка `config_policy` с исходными stats/limits из config.ini. После изменения этих параметров config.ini имеет приоритет; IP-списки сохраняются. Старые файлы без отпечатка восстанавливают только IP-списки. Отключение статистики не отключает отдельно включённые rate limits. + +В памяти хранятся счётчики не более 200 000 источников. При заполнении вытесняется давно не использовавшаяся запись: новые клиенты проходят обычную проверку лимитов вместо общего отказа 429. Вернувшийся после вытеснения источник начинает новые счётчики; активные источники сохраняют свои окна. Blacklist и whitelist хранятся отдельно и не вытесняются. diff --git a/@l10n/ru/docs/plugins/overview.md b/@l10n/ru/docs/plugins/overview.md index 6d3ac89c8d..260c1783a7 100644 --- a/@l10n/ru/docs/plugins/overview.md +++ b/@l10n/ru/docs/plugins/overview.md @@ -35,6 +35,7 @@ VIZ Ledger использует фреймворк плагинов **AppBase**. | `snapshot` | Рекомендуется | `chain` | — | | `block_archive` | Опционально | `chain` | — | | `validator_guard` | Рекомендуется для валидаторов | `chain`, `p2p` | — | +| `node_admin` | Опционально (оператор/AI-агент) | `chain` | Свой loopback-слушатель | ### API diff --git a/@l10n/ru/docs/plugins/prediction-market-api.md b/@l10n/ru/docs/plugins/prediction-market-api.md index 7d4d84c1bf..060955736d 100644 --- a/@l10n/ru/docs/plugins/prediction-market-api.md +++ b/@l10n/ru/docs/plugins/prediction-market-api.md @@ -56,7 +56,7 @@ Read-only JSON-RPC доступ к состоянию прогнозных ры | `get_account_leverage_positions` | `account, from, limit` | `pm_leverage_position_object[]` | | `get_market_leverage_positions` | `market_id, from, limit` | `pm_leverage_position_object[]` | | `get_creator_ban` | `account` | `pm_creator_ban_object` (или ошибка, если нет) | -| `get_oracle` | `owner` | `pm_oracle` (объект + `reliability_score`) | +| `get_oracle` | `owner` | `pm_oracle` (объект + `reliability_score` + чтения no-contest) | | `list_oracles` | `from, limit` | `pm_oracle_object[]` | ### Превью плеча (Boost) @@ -85,10 +85,12 @@ Read-only котировки, вызывающие **ту же внутриуз | `get_lazy_deposit` | `account` | `pm_lazy_deposit_object` | | `get_lazy_allocations` | `from, limit` | `pm_lazy_allocation_object[]` | | `get_market_lazy_allocation` | `market_id` | `pm_lazy_allocation_object` (или ошибка, если нет) | -| `get_pm_chain_properties` | — | `chain_properties_pm` (медиана, v5) | +| `get_pm_chain_properties` | — | `chain_properties_pm` (медиана, v5) + `pm_no_contest_fee_per_market` (производное) | `get_lazy_allocations` перечисляет записи аллокаций lazy-пула по рынкам (для дашборда пула); `get_market_lazy_allocation` берёт запись для конкретного рынка. Штрафные штампы оракула отдельного метода не требуют — они идут на `pm_oracle_object` (`penalty_stamps`, `last_penalty_stamp_time`) через `get_oracle`. +Каждая карточка рынка (`pm_market_card` — в списках, `get_market` и `get_market_full`) несёт **`settlement_charge`**: штраф за no-contest, который расчёт спишет прямо сейчас — замороженная при объявлении цена (HF16) или текущая медиана для дофорковых строк, с кэпом по страховке оракула — и `0`, если рынок не объявлен void (`status 3`, выплата не произведена, спора нет) или на нём нет ставок. `get_oracle` агрегирует тот же расчёт по всему оракулу: `no_contest_pending_count` / `_stake` / `_fee_nominal` / `_fee_capped` (обход `by_oracle_finalized` по открытым обязательствам, история оракула не читается). Оба поля — только чтение для отображения; само списание делает консенсус при расчёте. + ### Графики — kline / история весов Тайм-серия для построения графика изменения веса каждого исхода. Плагин добавляет точку **каждый раз, когда веса исходов рынка меняются** — ставка, отмена, ликвидация, batch-settle, открытие плеча или расчёт плеча — как таймстампированный снимок паримутюэль-веса (сумма ставок) по каждому исходу. Это **неконсенсусное** состояние плагина (хранится в chainbase, undo/redo-безопасно, не входит в хеш состояния); история копится с момента первого включения плагина на узле. diff --git a/@l10n/ru/docs/plugins/snapshot.md b/@l10n/ru/docs/plugins/snapshot.md index df1831e7d2..4bdcbd037e 100644 --- a/@l10n/ru/docs/plugins/snapshot.md +++ b/@l10n/ru/docs/plugins/snapshot.md @@ -2,7 +2,7 @@ Плагин snapshot обеспечивает почти мгновенный запуск узла путём сериализации и восстановления полного состояния блокчейна в виде JSON-файла. Вместо воспроизведения миллионов блоков из block log, узел загружает предварительно подготовленный снимок и начинает синхронизацию с высоты блока снимка через P2P. -**Исходник:** [plugins/snapshot/snapshot.cpp](../../plugins/snapshot/snapshot.cpp) +**Исходник:** [plugins/snapshot/snapshot.cpp](https://github.com/VIZ-Blockchain/viz-cpp-node/blob/pm/plugins/snapshot/plugin.cpp) --- diff --git a/@l10n/ru/docs/plugins/validator.md b/@l10n/ru/docs/plugins/validator.md index 566b700431..5f2e6cf2f8 100644 --- a/@l10n/ru/docs/plugins/validator.md +++ b/@l10n/ru/docs/plugins/validator.md @@ -2,7 +2,7 @@ Плагин валидатора отвечает за подписание и производство блоков. Он запускает выделенный цикл таймера с интервалом 250 мс в собственном потоке ОС, выполняет ряд проверок безопасности при каждом тике и вызывает `database::generate_block()` при выполнении всех условий. -**Источник:** [plugins/validator/validator.cpp](../../plugins/validator/validator.cpp) +**Источник:** [plugins/validator/validator.cpp](https://github.com/VIZ-Blockchain/viz-cpp-node/blob/pm/plugins/validator/validator.cpp) --- diff --git a/@l10n/ru/docs/plugins/webserver.md b/@l10n/ru/docs/plugins/webserver.md index ce7ffaee1e..8643987dd7 100644 --- a/@l10n/ru/docs/plugins/webserver.md +++ b/@l10n/ru/docs/plugins/webserver.md @@ -2,7 +2,7 @@ Плагин webserver предоставляет HTTP и WebSocket конечные точки, перенаправляющие JSON-RPC запросы в плагин `json_rpc`. Включает кеш ответов, ключом которого служит `method + params` (не `id`), инвалидируемый при каждом применённом блоке, и пул потоков для параллельной обработки запросов. -**Исходник:** [plugins/webserver/webserver_plugin.cpp](../../plugins/webserver/webserver_plugin.cpp) +**Исходник:** [plugins/webserver/webserver_plugin.cpp](https://github.com/VIZ-Blockchain/viz-cpp-node/blob/pm/plugins/webserver/webserver_plugin.cpp) --- @@ -23,9 +23,21 @@ json_rpc::plugin | `webserver-thread-pool-size` | `256` | Рабочие потоки для обработки HTTP и WebSocket запросов. | | `webserver-cache-enabled` | `true` | Включить кеш ответов. | | `webserver-cache-size` | `10000` | Максимальное количество кешированных ответов. Кеш полностью вытесняется при достижении этого лимита. | +| `webserver-stats-enabled` | `false` | Включить учёт запросов по IP, лог статистики и (если не переопределено) лимиты. | +| `webserver-limits-enabled` | значение `webserver-stats-enabled` | Применять лимиты по IP. Лимитам нужны счётчики из `webserver-stats-enabled`. | +| `webserver-limit-m` | `1000` | Максимум запросов с одного IP в минуту. | +| `webserver-limit-h` | `10000` | Максимум запросов с одного IP в час. | +| `webserver-limit-d` | `100000` | Максимум запросов с одного IP в сутки. | +| `webserver-stats-log` | `/webserver-stats.log` | Путь лога статистики; ротация по размеру. | +| `webserver-stats-log-max-size` | `10485760` (10 MiB) | Ротировать лог при превышении размера. | +| `webserver-stats-log-keep` | `5` | Сколько ротированных файлов (`.1` … `.N`) хранить. | +| `webserver-stats-interval` | `60` | Интервал в секундах между снимками в лог статистики. | +| `webserver-admin-state` | `/webserver-admin.json` | Живое состояние (лимиты, списки IP, флаг статистики), сохраняемое `node_admin`. | Для работы плагина должно быть задано хотя бы одно из `webserver-http-endpoint` или `webserver-ws-endpoint`. Оба могут быть включены одновременно. +Опции статистики и лимитов — только **стартовое значение**. После запуска живые значения принадлежат плагину и меняются через `node_admin` (см. [Управление статистикой и лимитами на ходу](#управление-статистикой-и-лимитами-на-ходу)); каждое изменение применяется к следующему запросу и сохраняется в `webserver-admin-state`. + --- ## Минимальный config.ini @@ -79,6 +91,70 @@ webserver-cache-enabled = false --- +## Учёт запросов и лимиты + +По умолчанию выключено. `webserver-stats-enabled = true` включает подсчёт запросов по IP-адресу клиента и лимиты (или задайте `webserver-limits-enabled` явно, чтобы разделить эти две вещи). + +### Лимиты + +| Окно | По умолчанию | +|------|--------------| +| минута | 1000 запросов | +| час | 10000 запросов | +| сутки | 100000 запросов | + +Окна фиксированные (минутная корзина сбрасывается на границе минуты, часовая — на границе часа, суточная — в полночь UTC), и каждый запрос проверяется по всем трём. Запрос сверх любого лимита отклоняется: + +- HTTP: `429 Too Many Requests`, `Retry-After: 60`, тело `rate limit exceeded`. +- WebSocket: ошибка JSON-RPC `-32006 rate limit exceeded`. + +Это осознанная защита от скрапинга и парсерного трафика: тем, кому нужно больше, следует поднимать свою ноду. + +### Списки + +- **Белый список** — адреса, обходящие лимиты. Они всё равно считаются и видны в статистике. +- **Чёрный список** — адреса, получающие `403 Forbidden` (HTTP) или `-32005 forbidden` (WebSocket). Чёрный список важнее белого. Он действует даже при выключенной статистике. +- Добавление адреса в один список удаляет его из другого. + +### Что содержит лог статистики + +Каждые `webserver-stats-interval` секунд плагин дописывает строку на каждый отслеживаемый IP плюс итог: + +```text +2026-10-19T12:00:00Z ip=203.0.113.7 total=4210 minute=320 hour=4210 day=4210 blocked=0 limited=3 whitelisted=0 blacklisted=0 last_seen=1760875200 +2026-10-19T12:00:00Z summary total_requests=4210 blocked_requests=0 limited_requests=3 tracked_ips=1 +``` + +Файл ротируется по размеру: `<имя>` становится `<имя>.1`, старые файлы сдвигаются в `.2` … `.webserver-stats-log-keep`, самый старый удаляется. + +### Адрес клиента + +Плагин использует TCP-адрес пира соединения. За nginx все запросы выглядят приходящими от прокси (`127.0.0.1`), поэтому лимиты по IP становятся глобальными, а отчёт показывает одну строку. Варианты: публиковать API напрямую, внести прокси в белый список и ограничивать на уровне прокси (`limit_req`), либо включить поддержку PROXY protocol на уровне прокси/сети. + +### Управление статистикой и лимитами на ходу + +Изменяющие методы требуют скоупа `webserver` у токена `node_admin`; чтению достаточно `read`: + +| Метод | Назначение | +|-------|------------| +| `admin.webserver.status` | Лимиты, итоги, списки IP, число отслеживаемых адресов | +| `admin.webserver.ips` | Счётчики по IP (`limit` до 5000), сортировка по числу запросов | +| `admin.webserver.limits` | Изменить `enabled`, `per_minute`, `per_hour`, `per_day` (опущенные поля сохраняют значение) | +| `admin.webserver.stats` | Включить/выключить учёт (`{"enabled": true|false}`) | +| `admin.webserver.whitelist_add` / `whitelist_remove` | Правка белого списка (`{"ip": "..."}`) | +| `admin.webserver.blacklist_add` / `blacklist_remove` | Правка чёрного списка (`{"ip": "..."}`) | +| `admin.webserver.reset` | Обнулить счётчики, сохранив списки и лимиты | +| `admin.webserver.reload` | Перечитать `webserver-admin-state` с диска | + +Каждый из них также доступен как MCP-инструмент с тем же именем. **Изменения применяются к живому плагину сразу — без перезапуска и без перечитывания `config.ini`.** Они записываются в `webserver-admin-state` (атомарно: временный файл + rename), который восстанавливает политику при совпадении отпечатка `config_policy` со стартовыми stats/limits. Изменённые параметры config.ini имеют приоритет; IP-списки восстанавливаются независимо. Старые файлы без отпечатка восстанавливают только IP-списки. Сам `config.ini` не перезаписывается. `reload` нужен оператору, правившему JSON руками; обычным изменениям он не требуется. + +```json +{"jsonrpc":"2.0","id":1,"method":"admin.webserver.limits", + "params":{"per_minute":600,"per_hour":8000}} +``` + +--- + ## WebSocket-подписки WebSocket-клиенты могут регистрировать колбеки: @@ -117,7 +193,7 @@ WebSocket-клиенты могут регистрировать колбеки: ## Безопасность - **Привяжите к localhost** (`127.0.0.1`) и используйте обратный прокси (nginx/Caddy) для публичного доступа. Привязка к `0.0.0.0` открывает RPC напрямую в сеть. -- Плагин не имеет встроенной аутентификации или ограничения скорости. Применяйте их на уровне обратного прокси. +- Плагин не имеет встроенной аутентификации. Есть необязательные лимиты по IP и списки адресов (см. [Учёт запросов и лимиты](#учёт-запросов-и-лимиты)), но это грубые инструменты — аутентификацию и общую фильтрацию держите на уровне обратного прокси. - Мутирующие методы (`network_broadcast_api`) защищены от отравления кеша по замыслу, но они остаются доступными для вызова с любого подключённого клиента — при необходимости ограничьте доступ на сетевом уровне. --- @@ -200,3 +276,9 @@ location /ws { --- См. также: [Обзор плагинов](./overview.md), [Database API](./database-api.md), [JSON-RPC API](../api/json-rpc.md). + +### Доверенные reverse proxy и сохранение политики + +По умолчанию адрес берётся из TCP-соединения. За nginx явно задайте `webserver-trusted-proxy = 127.0.0.1` (повторите для каждого IP своего прокси) и передавайте `X-Forwarded-For`. Цепочка разбирается справа налево до первого недоверенного узла; произвольные клиентские IP в доверенные не добавляйте. + +При достижении 200000 IP удаляются записи старше суток; пока места нет, новые источники отклоняются, а действующие окна лимитов не сбрасываются. Включённые лимиты работают и без статистического логирования. Лог записывает изменённые строки, форматирование и дисковая запись вынесены из mutex приёма запросов. Отпечаток `config_policy` позволяет новым параметрам config.ini перекрыть старую runtime-политику, сохранив IP-списки. diff --git a/@l10n/ru/docs/prediction-markets/pm-audit-fix-upgrade.md b/@l10n/ru/docs/prediction-markets/pm-audit-fix-upgrade.md index 6c96ccd167..a0ba324b31 100644 --- a/@l10n/ru/docs/prediction-markets/pm-audit-fix-upgrade.md +++ b/@l10n/ru/docs/prediction-markets/pm-audit-fix-upgrade.md @@ -1,5 +1,8 @@ # HF15 — фиксы PM-аудита: чеклист активации и миграция устаревшего состояния +> **Исторический чеклист HF15:** ниже описано то обновление, а не текущий статус релиза. В 4.2.0 зарегистрированы форки до 16; см. [HF16](../consensus/hf16.md). + + Статус: оба фикса **реализованы, но на mainnet не запланированы**. Форк зарегистрирован в обеих конфигурациях (`CHAIN_NUM_HARDFORKS` = 15 везде) и гейтится временем активации плюс кворумом валидаторов, так что production-бинарь применит его, как только это время наступит; до тех пор ничего diff --git a/@l10n/ru/docs/prediction-markets/pm-no-contest-fee-freeze.md b/@l10n/ru/docs/prediction-markets/pm-no-contest-fee-freeze.md new file mode 100644 index 0000000000..46b76eb33e --- /dev/null +++ b/@l10n/ru/docs/prediction-markets/pm-no-contest-fee-freeze.md @@ -0,0 +1,153 @@ +# HF16 — заморозка цены no-contest: чеклист активации и семантика бэкфилла + +Статус: **реализовано; активация назначена на 31.10.2026 00:00:00 UTC.** Форк зарегистрирован в обеих конфигурациях +(`CHAIN_NUM_HARDFORKS = 16`, `CHAIN_VERSION = 4.2.0`) и гейтится временем активации плюс кворумом +валидаторов: production-бинарь применит его, как только это время наступит, а до тех пор ничего не +выполняется. Эта заметка — операторский чеклист: что именно замораживается, как на рынке +различаются два состояния, что меняется в layout объектов и снапшоте, как проверить активацию и +почему seed-проход для строк, уже находящихся в окне спора на момент форка, не нужен. + +## 1. Что меняет форк + +**Проблема.** `pm_no_contest` лишь объявляет void и открывает окно `pm_dispute_grace_sec`, а штраф +считается на **сеттлменте**. До HF16 сумма бралась как +`floor(pm_dispute_fee × pm_no_contest_penalty_percent / 10000)` из медианного `chain_properties_pm` +**на момент сеттлмента**. Оба входа — медианно голосуемые параметры, поэтому голосование внутри окна +спора меняло счёт после того, как оракул уже объявил void: оракул объявлял одно число, а платил +другое, и узнать итоговую цену при объявлении было невозможно. + +**Фикс: число фиксируется при объявлении.** `pm_no_contest_evaluator` считает ту же формулу от +медианы **блока объявления** и сохраняет её на рынке в `pm_market_object::no_contest_fee`. +`settle_market(win < 0)` списывает ровно эту сумму, а не перечитывает медиану. Кап по страховке не +меняется и по-прежнему применяется на каждом сеттлменте: +`penalty = min(frozen_fee, oracle.insurance)`. + +**Два состояния, один флаг.** `pm_market_object::no_contest_fee_frozen` говорит, была ли сумма вообще +заморожена: + +* `true` — void объявлен **под HF16**; сеттлмент списывает `no_contest_fee` как есть. +* `false` — строка объявлена **до HF16** (или импортирована из до-HF16-снапшота); сеттлмент сохраняет + legacy-вычисление от медианы — ровно поведение до форка. + +Флаг отделён от суммы намеренно. Замороженный штраф **0** (процент штрафа был проголосован в 0 на +момент объявления) обязан остаться нулём, даже если позже процент поднимут: «замороженный ноль» и +«никогда не замораживался» — разные состояния, и вывод legacy-ности из `fee == 0` молча переоценил бы +первое. Рынок, объявленный под HF16 без ставок, тоже записывает `frozen = true, fee = 0`; это +безвредно, потому что сеттлмент списывает только при `settlement.stake_total > 0`. + +**Счётчик атрибуции.** `pm_oracle_object::no_contest_slashed_total` — монотонный итог штрафов +no-contest, списанных с оракула; он инкрементируется в **том же** `modify()`, что растит +`total_insurance_slashed`. Это даёт классу no-contest точную атрибуцию без virtual operation, и +счётчик растёт на обоих путях (legacy-строка, рассчитавшаяся после активации, — всё равно +no-contest-списание). Он накапливается вперёд от HF16: штрафы, списанные до форка, **не** +бэкфиллятся — агрегатная дельта осталась единственной записью, и вычитать её задним числом было бы +догадкой. Только отображение/атрибуция; консенсус его никогда не читает. + +**Layout объектов.** Оба поля **дописаны в конец** своих структур и в соответствующий `FC_REFLECT`: + +* `pm_market_object`: `share_type no_contest_fee; bool no_contest_fee_frozen = false;` +* `pm_oracle_object`: `share_type no_contest_slashed_total;` + +Импорт снапшота защищён `contains()` для всех трёх, поэтому до-HF16-снапшот импортируется с +дефолтами (`0` / `false`) — а это ровно состояние «legacy-строка», которого ждёт сеттлмент. Экспорт +снапшота общий (`EXPORT_INDEX`) и правки не требует. В `apply_hardfork` случая для HF16 нет +(`default: break` здесь корректен): нет синглтона для создания и нечего пересчитывать при активации. + +**Что едет без форка.** Эти read-only добавки полезны и до активации и не гейтятся — они выводятся из +того же состояния, которое прочитает сеттлмент: + +* `get_oracle` (и блок оракула в `get_market_full`) получает `no_contest_pending_count`, + `no_contest_pending_stake`, `no_contest_fee_nominal`, `no_contest_fee_capped` — цена объявленных этим + оракулом void'ов, которые ещё в окне спора (status 3, payout pending, спор не подан) и несут ставки. + Показываются и номинальная, и урезанная страховкой суммы: иначе срабатывание капа скрыло бы + номинальную цену. Считается на чтении, кэш на блок. +* Карточка рынка получает `settlement_charge`: сколько спишется, если этот рынок рассчитается void + **сейчас** (0 для всего, что не является ожидающим no-contest со ставками). +* `get_pm_chain_properties` сохраняет все прежние поля и добавляет + `pm_no_contest_fee_per_market = dispute_fee × penalty_percent / 10000` — номинальную единицу, + которую клиенты и скрипты иначе пересчитывают руками. RPC теперь возвращает variant; JSON-клиенты + видят тот же объект плюс один ключ. + +## 2. Как форк регистрируется и активируется + +* **Регистрация безусловна в обеих конфигурациях.** `0-preamble.hf` ставит `CHAIN_NUM_HARDFORKS` = 16; + `hardfork.d/16.hf` определяет `CHAIN_HARDFORK_16` / `CHAIN_PM_NO_CONTEST_FREEZE_HARDFORK` и + `CHAIN_HARDFORK_16_VERSION`; `database_hardfork.cpp` регистрирует `_hardfork_times[16]` / + `_hardfork_versions[16]` для любой сборки. Массивы размера `[CHAIN_NUM_HARDFORKS + 1]`. Гейт — + таймстемп плюс кворум, а не flavour сборки — то же правило, что у HF15, и по той же причине: + shelter-тестнет — **production-config**-деплой, так что testnet-only форк там активироваться не + смог бы никогда. Полное обоснование — `pm-audit-fix-upgrade.md` §2. +* **Активация: 2026-10-31 00:00:00 UTC** (`CHAIN_HARDFORK_16_TIME` = 1793404800 в обеих + конфигурациях, задано владельцем 20.10.2026). Объявление валидаторам должно называть именно этот + момент, и до него 17 из 21 валидатора должны стоять на бинаре 4.2.0. Держать значение в **будущем**: с прошедшим + таймстемпом форк активируется в том блоке, где случился кворум, и объявляемого момента нет. + Менять его вместе с текстом объявления и никогда — после публикации. +* **Версия, а не ревизия.** `CHAIN_VERSION` переезжает на `4.2.0` в **обеих** конфигурациях, + `config.hpp` и `config_testnet.hpp`; средняя компонента — это hardfork-версия, за которую голосуют + валидаторы, и `database_hardfork.cpp` проверяет её равенство + `_hardfork_versions[CHAIN_NUM_HARDFORKS]`. (В двух конфигах раньше стояли 4.1.2 и 4.1.0 — обе + переезжают на один и тот же 4.2.0.) +* **Чеклист активации.** (1) Подтвердить или заменить провизорную дату и объявить её. (2) Замерить + популяцию из §4 на свежем снапшоте и закрыть там вопрос (ожидаемый ответ: действий не нужно). + (3) Выкатить образ; голоса валидаторов автоматические. (4) Подтвердить активацию по логу ноды и + прогнать проверки из §3. (5) Помнить ловушку emergency consensus из `pm-audit-fix-upgrade.md` §3: + на цепи в emergency consensus форк запланировать нельзя вообще, и вкомпилированное время — мёртвая + ручка. `testnet_plugin --testnet-hardfork 16` форсит форк на старте production-config-тестнета для + проверки; плагин ничего не делает, если не загружен и не получил цель. +* **Откат.** До таймстемпа передеплой прошлого образа безопасен — форк остаётся pending. После + активации маркер — состояние цепи: откатываться нельзя (до-HF16-бинарь рассчитал бы ожидающие + no-contest'ы от медианы, пока цепь считает иначе). Только вперёд. + +## 3. Проверка + +**До активации** (read-only добавки работают на живой цепи): + +* в `get_pm_chain_properties` есть `pm_no_contest_fee_per_market`, численно равный + `pm_dispute_fee × pm_no_contest_penalty_percent / 10000`; +* у оракула с void'ом в окне спора и ставками на нём `get_oracle` показывает + `no_contest_pending_count ≥ 1` и `no_contest_fee_nominal = count × unit`; карточка + соответствующего рынка показывает `settlement_charge`; +* ни один рынок ещё не несёт `no_contest_fee_frozen = true` — флаг пишется только под HF16. + +**После активации:** + +* рынок, объявленный void через `pm_no_contest`, сразу показывает `no_contest_fee_frozen = true` и + `no_contest_fee`, равный единице блока объявления; +* проголосовать `pm_dispute_fee` / `pm_no_contest_penalty_percent`, пока рынок в окне спора: итоговое + списание равно замороженной сумме, а не новой медиане; +* рынок, объявленный **до** активации с `frozen = false`, рассчитывается от текущей на тот момент + медианы, т.е. дофорковое поведение для него сохраняется; +* `oracle.no_contest_slashed_total` растёт ровно на списанную сумму, синхронно с + `total_insurance_slashed`; +* обычные post-redeploy-инварианты: SHARES delta 0, TOKEN delta равен legacy-якорю цепи, + `restarts 0`, листинги непустые. + +**Консенсус-регрессия.** `tests/consensus_sim/scenarios/test_pm_no_contest_fee.cpp` (BUILD_TESTNET) +гоняет одну цепь с тремя рынками, объявленными вокруг точки форка — legacy до форка, замороженный +после форка, замороженный в ноль, — затем двигает медиану и проверяет, что падение страховки и +`no_contest_slashed_total` равны `8u + 1u + 0` для legacy/замороженной/нулевой ноги соответственно. +Замер 19.10.2026 (BUILD_TESTNET, `consensus_sim_tests --run_test=pm_no_contest_fee_three_states`): +страховка `5 000 000 → 4 100 000` raw, то есть ровно `9u`; тест прошёл без ошибок. + +## 4. Строки, уже находящиеся в окне спора на момент активации + +**Seed-проход не нужен, и он был бы неверен.** У строки, объявленной до форка, замороженной суммы +нет; она обязана сохранить дофорковое правило (медиана на сеттлменте), потому что именно по нему её +объявляли. Запись замороженной суммы при активации изменила бы счёт оракула задним числом — ровно тот +дефект, который HF16 убирает. Флаг `false` — маркер, который делает это автоматическим: ни кода +миграции, ни переписывания снапшота, а старый снапшот восстанавливается ровно в то состояние, которого +ждёт сеттлмент. + +Остаточная экспозиция поэтому ограничена и названа: строки, объявленные до форка **и** всё ещё +находящиеся в окне спора на момент активации, могут быть переоценены голосованием между активацией и +их сеттлментом. Их число измеримо из цепи; если оно не ноль и сеть не хочет никакой ретроактивной +переоценки, единственный чистый ответ — дождаться окончания этих окон до любого голосования по +параметрам, а не миграция. + +**Замер перед планированием.** Пройти по ожидающим no-contest-строкам оракулов (status 3, payout +pending, `resolved_outcome = -1`) и посчитать те, у которых `bets_sum > 0`, плюс их стейк: +`get_oracle` отдаёт ровно это по каждому оракулу (`no_contest_pending_count` / `_stake`), так что новый +прибор не нужен. Замер на живом production-config-тестнете **2026-10-19**: 112 рынков в окне спора, +**0** из них со ставками — популяция, на которую могло бы повлиять голосование внутри окна, на тот +момент была пуста. Перемерить на свежем снапшоте непосредственно перед планированием: число ходит +вместе с популяцией оракулов и о будущем не говорит ничего. diff --git a/@l10n/ru/docs/protocol/operations/committee.md b/@l10n/ru/docs/protocol/operations/committee.md index 37a5968919..6e484ab2ac 100644 --- a/@l10n/ru/docs/protocol/operations/committee.md +++ b/@l10n/ru/docs/protocol/operations/committee.md @@ -102,3 +102,5 @@ --- См. также: [Типы данных](../data-types.md), [Обзор операций](./overview.md), [Виртуальные операции](../virtual-operations.md), [Управление комитетом](../../governance/committee.md). + +**Лимит HF16:** при создании `required_amount_max.amount <= floor(current_supply.amount / 100)` в минимальных единицах (1% текущего общего количества VIZ с округлением вниз). До HF16 — 500 000 VIZ. Это не 1% фонда комитета; существующие заявки не переоцениваются. См. [HF16](../../consensus/hf16.md). diff --git a/@l10n/ru/docs/protocol/operations/overview.md b/@l10n/ru/docs/protocol/operations/overview.md index c6ea56c1dc..9e08a17e76 100644 --- a/@l10n/ru/docs/protocol/operations/overview.md +++ b/@l10n/ru/docs/protocol/operations/overview.md @@ -127,6 +127,7 @@ | 103 | `pm_early_exit_claim_paid_operation` | Отложенная выплата досрочного выхода оплачена при расчёте | [Прогнозные рынки](./prediction-markets.md) | | 104 | `pm_lp_payout_operation` | Доход LP выплачен при расчёте | [Прогнозные рынки](./prediction-markets.md) | | 105 | `set_agent_permission_operation` | Выдать, заменить или отозвать ключ агента (HF15) | [Агент-доступ](./agent-access.md) | +| 106 | `validator_equivocation_operation` | Доказана двойная подпись валидатора (HF16 QC) | [Виртуальные операции](../virtual-operations.md) | --- diff --git a/@l10n/ru/docs/protocol/virtual-operations.md b/@l10n/ru/docs/protocol/virtual-operations.md index 909bbf4a06..f99c0bf696 100644 --- a/@l10n/ru/docs/protocol/virtual-operations.md +++ b/@l10n/ru/docs/protocol/virtual-operations.md @@ -377,6 +377,13 @@ Всё движение средств PM строго zero-sum (без эмиссии); расчёт сохраняет `Σ out == Σ ставок + принципал LP + forfeit_pool`. + +### Консенсус (HF16 QC) + +| ID | Операция | Триггер | +|----|----------|---------| +| 106 | `validator_equivocation_operation` | Блок принёс доказательство, что `validator` подписал голоса за два разных блока (`first_block_id`, `second_block_id`) в одном `round`. Наказание как за пропуск блока (`total_missed++`, `penalty_percent += validator_miss_penalty_percent` на `validator_miss_penalty_duration`); не чаще раза за раунд (`last_equivocation_round`) | + --- См. также: [Обзор операций](./operations/overview.md), [Награды](./operations/awards.md), [Комитет](./operations/committee.md). diff --git a/@l10n/zh-CN/docs/api/client-libraries.md b/@l10n/zh-CN/docs/api/client-libraries.md index 6d4b641551..951f6a0705 100644 --- a/@l10n/zh-CN/docs/api/client-libraries.md +++ b/@l10n/zh-CN/docs/api/client-libraries.md @@ -225,3 +225,7 @@ let shares = VIZ.Asset(1000.0, .vests) // "1000.000000 VESTS" --- 参见:[JSON-RPC API](./json-rpc.md)、[CLI Wallet](./cli-wallet.md)。 + +## HF16 兼容性 + +JS 0.18.5 已发布至 npm,支持 QC/TC/双重投票证据区块头。PHP 保持原交易格式并透传新头部 JSON。Python 1.4.0 源码和依赖已通过 Python 3.10–3.14 CI;PyPI 发布独立进行。DAO 客户端不能固定限制为 500,000 VIZ:节点按当前总供应量检查上限。参见 [HF16](../consensus/hf16.md)。 diff --git a/@l10n/zh-CN/docs/api/ids-and-hashes.md b/@l10n/zh-CN/docs/api/ids-and-hashes.md new file mode 100644 index 0000000000..1f374a71e0 --- /dev/null +++ b/@l10n/zh-CN/docs/api/ids-and-hashes.md @@ -0,0 +1,19 @@ +# 交易与区块 ID + +VIZ 的 ID 类型继承自 Graphene(BitShares,随后是 Steem)。源码中它们声明为 `fc::ripemd160`,旧文档和库也称之为“ripemd160 ID”。这只是存放它们的 20 字节容器的名字:**这两个 ID 都不是 ripemd160 哈希。** 节点通过别名 `id160_type` 明确说明这一点。 + +| 值 | 计算方式 | 大小 | +|---|---|---| +| `transaction_id` | `sha256(序列化的交易)` 的前 20 字节,**不**包含签名 | 20 字节 | +| `block_id` | `sha224(序列化的区块头)` 的前 20 字节,再把第 0–3 字节替换为区块号(大端序) | 20 字节 | +| `transaction_merkle_root` | 对已签名交易构建的 sha256 树的根做 `ripemd160` | 20 字节 | + +Merkle 根是唯一真正的 ripemd160 值。 + +## 对客户端的影响 + +* 计算交易 ID:序列化交易时**不含** `signatures`,取 sha256 的前 20 字节。不要使用 ripemd160,否则结果永远与节点不符。 +* 区块号可直接从 block id 读出:前 4 字节,大端序。TaPoS(`ref_block_num` / `ref_block_prefix`)取该编号的低 16 位和 block id 的第 4–7 字节。 +* 在所有 API 响应、block log 和账户历史中,ID 都是 20 字节。更换哈希(改为真正的 ripemd160 或完整的 32 字节 sha256)将是一次硬分叉,会把历史分成两种 ID 格式,却没有安全收益:160 位对标识符已经足够,签名针对的是完整的 sha256 摘要而非 ID。 + +源码:`libraries/protocol/transaction.cpp` 中的 `transaction::id()`,`libraries/protocol/block.cpp` 中的 `signed_block_header::id()` 与 `signed_block::calculate_merkle_root()`,类型定义见 `libraries/protocol/include/graphene/protocol/types.hpp`。 diff --git a/@l10n/zh-CN/docs/consensus/block-processing.md b/@l10n/zh-CN/docs/consensus/block-processing.md index 52c6864ce4..d01088fa1d 100644 --- a/@l10n/zh-CN/docs/consensus/block-processing.md +++ b/@l10n/zh-CN/docs/consensus/block-processing.md @@ -1,5 +1,8 @@ # 区块处理 +> **HF16 / 4.2.0:** 1 秒时隙、QC/TC 和 2-chain 规则参见 [HF16](./hf16.md)。下文旧的 3 秒和 14 个确认描述适用于激活前,不能作为 HF16 QC 门槛。 + + 区块应用、待处理交易管理和 fork 切换的内部机制。 --- @@ -298,6 +301,7 @@ struct block_post_validation_message { ### 约束条件 - 只有**当前打乱排列的调度**中的验证者才计入 2/3 阈值;调度外的验证者被跳过。 +- **每轮只投一票(Jolteon 安全规则)。** 验证者只在 *r* 高于其已投票的最高轮次时,才为第 *r* 轮的区块投票(轮次 = 区块的时隙,`timestamp / CHAIN_BLOCK_INTERVAL`)。验证者插件把每个验证者的该轮次保存在 `/block-vote-round.txt`,在投票发出**之前**写入(无法持久化轮次的投票不会发送),并在同一轮出现另一个区块时记录日志 `Not voting for … already voted for … in round …`。与“每个高度只签一次”不同,验证者可以在更晚的轮次为同一高度的另一个区块投票——正是这一点让分裂的投票得以解决而不是卡住。同一轮中两个冲突的法定人数证书需要有验证者投票两次;节点会把这类投票记录为 `Equivocation: …`。重复广播同一投票仍然允许。 - 在**紧急共识**期间(`emergency_consensus_active = true`),`check_block_post_validation_chain()` 立即返回——LIB 仅通过经典路径推进,以避免恢复过程死锁。 - 确认列表上限为 **20 条**(`CHAIN_MAX_BLOCK_POST_VALIDATION_COUNT`)。低于当前 LIB 的条目在每个区块后删除。 diff --git a/@l10n/zh-CN/docs/consensus/emergency-consensus.md b/@l10n/zh-CN/docs/consensus/emergency-consensus.md index 572c62204d..71478c2981 100644 --- a/@l10n/zh-CN/docs/consensus/emergency-consensus.md +++ b/@l10n/zh-CN/docs/consensus/emergency-consensus.md @@ -1,6 +1,6 @@ # 紧急共识模式 -紧急共识模式(在 HF12 中引入)在网络停滞 1 小时后自动激活。特殊的"committee"验证者接管区块生产,以在真实验证者恢复其签名密钥之前维持链的连续性。 +紧急共识模式(在 HF12 中引入)在网络停滞 20 分钟后自动激活。特殊的"committee"验证者接管区块生产,以在真实验证者恢复其签名密钥之前维持链的连续性。 --- @@ -8,7 +8,7 @@ | 常量 | 值 | 含义 | |------|---|------| -| `CHAIN_EMERGENCY_CONSENSUS_TIMEOUT_SEC` | 3600 秒 | 激活前的不活动时间 | +| `CHAIN_EMERGENCY_CONSENSUS_TIMEOUT_SEC` | 1200 秒 | 激活前的不活动时间 | | `CHAIN_EMERGENCY_VALIDATOR_ACCOUNT` | `"committee"` | 紧急区块生产者账户 | | `CHAIN_EMERGENCY_VALIDATOR_PUBLIC_KEY` | `VIZ75CR...` | 确定性紧急签名密钥 | | `CHAIN_EMERGENCY_EXIT_NORMAL_BLOCKS` | 21 | 退出所需的连续真实验证者区块数 | @@ -31,7 +31,7 @@ 1. **HF12 门控** — 如果硬分叉尚未激活则跳过。 2. **已激活** — 如果紧急模式已经开启则跳过。 3. **block log 中有 LIB 区块** — 如果 LIB 区块不在 block log 中则跳过(快照恢复后的 DLT 节点有空的 block log;缺少 LIB 区块将计算数百万过时秒数并触发错误的激活死锁)。 -4. **超时** — 计算 `seconds_since_lib = current_block.timestamp − lib_block.timestamp`。如果 `< 3600` 则跳过。 +4. **超时** — 计算 `seconds_since_lib = current_block.timestamp − lib_block.timestamp`。如果 `< 1200` 则跳过。 所有检查仅使用区块内嵌的时间戳——没有系统时钟,没有跳过标志。这保证了在每个节点上,包括重放,具有相同的确定性激活。 diff --git a/@l10n/zh-CN/docs/consensus/fair-dpos.md b/@l10n/zh-CN/docs/consensus/fair-dpos.md index 601a2e9270..59d28ef1fb 100644 --- a/@l10n/zh-CN/docs/consensus/fair-dpos.md +++ b/@l10n/zh-CN/docs/consensus/fair-dpos.md @@ -1,5 +1,8 @@ # Fair-DPOS 共识 +> **HF16 / 4.2.0:** 1 秒时隙、QC/TC 和 2-chain 规则参见 [HF16](./hf16.md)。下文旧的 3 秒和 14 个确认描述适用于激活前,不能作为 HF16 QC 门槛。 + + VIZ Ledger 使用**公平委托权益证明(Fair-DPOS)**——经典 DPoS 算法的扩展,增加了参与度执行和错过惩罚机制,以防止验证者在不实际生产区块的情况下收取奖励。 --- @@ -9,7 +12,7 @@ VIZ Ledger 使用**公平委托权益证明(Fair-DPOS)**——经典 DPoS 在标准 DPoS 中: - 代币持有者为验证者账户投票(权重与其 SHARES 成比例)。 - 票数最高的验证者按轮询顺序被安排生产区块。 -- 每 3 秒触发一个槽位;被安排的验证者要么生产区块,要么错过该槽位。 +- HF16 前每 3 秒、激活后每 1 秒触发一个槽位;被安排的验证者要么生产区块,要么错过该槽位。 VIZ 每个调度轮次运行 **21 个活跃验证者**。 diff --git a/@l10n/zh-CN/docs/consensus/fork-resolution.md b/@l10n/zh-CN/docs/consensus/fork-resolution.md index 3476b4e436..d97c655c08 100644 --- a/@l10n/zh-CN/docs/consensus/fork-resolution.md +++ b/@l10n/zh-CN/docs/consensus/fork-resolution.md @@ -1,5 +1,8 @@ # Fork 解决 +> **HF16 / 4.2.0:** 1 秒时隙、QC/TC 和 2-chain 规则参见 [HF16](./hf16.md)。下文旧的 3 秒和 14 个确认描述适用于激活前,不能作为 HF16 QC 门槛。 + + 本页介绍 VIZ Ledger 如何检测、选择和解决竞争性 fork——从基本的 fork 数据库到 HF12 投票加权碰撞解析器。 --- diff --git a/@l10n/zh-CN/docs/consensus/hardforks.md b/@l10n/zh-CN/docs/consensus/hardforks.md index 30b30cf1e8..d6bff02e8f 100644 --- a/@l10n/zh-CN/docs/consensus/hardforks.md +++ b/@l10n/zh-CN/docs/consensus/hardforks.md @@ -1,5 +1,8 @@ # 硬分叉 +> **HF16 / 4.2.0:** 1 秒时隙、QC/TC 和 2-chain 规则参见 [HF16](./hf16.md)。下文旧的 3 秒和 14 个确认描述适用于激活前,不能作为 HF16 QC 门槛。 + + 硬分叉是改变共识规则的网络升级。所有节点必须在计划的激活时间戳之前升级;运行旧版软件的节点将在激活后与网络分叉。 --- @@ -9,7 +12,7 @@ 每个硬分叉包含: - 唯一**编号**(N)。 - **Unix 时间戳** — 硬分叉可以激活的最早系统时间。 -- **验证者投票超多数** — 当前验证者集合中 >80% 必须通过 `validator_update_operation` 发出新硬分叉版本信号。 +- **验证者投票超多数** — 当前验证者集合中 >80% 必须通过区块头 `hardfork_version_vote`(通常由 validator 插件自动发出) 发出新硬分叉版本信号。 两个条件必须同时满足。验证者可以通过即使在计划时间戳后仍不提交版本投票来阻止不需要的硬分叉。 @@ -32,7 +35,7 @@ HF12(版本 3.1.0)引入了: 1. **Fork 碰撞计数器** — `fork_collision_count` 和 `last_fork_collision_block_num` 添加到 `dynamic_global_property_object`。可通过 `get_dynamic_global_properties` 观察。 2. **投票加权 fork 比较**(`compare_fork_branches()`)— fork 选择使用每个验证者分支的总委托 SHARES + 较长链 10% 奖励。 -3. **紧急共识模式** — 1 小时无区块后自动激活;"committee"账户接管所有 21 个槽位。参见[紧急共识](./emergency-consensus.md)。 +3. **紧急共识模式** — 20 分钟无区块后自动激活;"committee"账户接管所有 21 个槽位。参见[紧急共识](./emergency-consensus.md)。 4. **少数派 fork 自动重新同步** — 验证者插件检测节点隔离(连续 21 个自己的区块)并回滚到 LIB。 5. **NTP 改进** — 专用 NTP 客户端,可配置服务器、间隔和往返时间阈值。 @@ -46,7 +49,7 @@ HF13(版本 3.2.0)引入了: - `validator_object` 上的新字段:`reward_percent` — 与投票者共享的区块奖励比例(0–10000 基点)。 - 新虚拟操作:`validator_reward_virtual_operation` — 每次奖励分配触发一次。 -- 通过 `validator_update_operation` 设置。 +- 通过区块头 `hardfork_version_vote`(通常由 validator 插件自动发出) 设置。 --- @@ -169,3 +172,11 @@ case CHAIN_HARDFORK_N: { --- 参见:[Fair-DPOS](./fair-dpos.md)、[紧急共识](./emergency-consensus.md)、[快照](../node/snapshot.md)。 + +## 4.x 版本 + +| HF | 版本 | 内容 | +|---|---|---| +| 14 | 4.0.0 | 预测市场与治理 | +| 15 | 4.1.0 | PM 修复、代理授权与永久密钥历史 | +| 16 | 4.2.0 | QC/TC、1 秒时隙、DAO 上限和 no-contest 冻结;[激活说明](./hf16.md) | diff --git a/@l10n/zh-CN/docs/consensus/hf16.md b/@l10n/zh-CN/docs/consensus/hf16.md new file mode 100644 index 0000000000..59d3462456 --- /dev/null +++ b/@l10n/zh-CN/docs/consensus/hf16.md @@ -0,0 +1,48 @@ +# HF16 / 4.2.0 版本 + +## 激活与升级 + +HF16 计划在 **2026 年 10 月 31 日 00:00:00 UTC**(`1793404800`)激活,版本为 **4.2.0**。生产网络还需要达到硬分叉投票门槛;安装新程序不会提前激活。请验证者提前升级。使用经过验证的 **production** 镜像,不能使用 BUILD_TESTNET 镜像:测试配置的链标识和法定人数不同。 + +master 镜像构建后,分发前应核对具体镜像的版本、提交、启动、同步和出块。保留验证者密钥配置及数据备份。升级不要求删除数据目录。 + +## 时隙、QC、TC 与最终性 + +- HF16 激活时,运行时时隙从 **3 秒改为 1 秒**。读取节点当前 `get_config` 和区块时间;旧编译常量 `CHAIN_BLOCK_INTERVAL` 不能代表分叉后的时隙。 +- QC 收集当前调度验证者的签名。实现公式为 `max(floor(2N/3)+1, floor(0.75N))`,即 **21 人中需要 15 人**,不能沿用旧后验证流程的 14 个确认。 +- 将出块权交给其他生产者时,区块携带父区块 QC。超时证书 TC 支持在跳过轮次后继续推进。LIB 使用 **2-chain** 规则:本地主链上相邻高度、连续轮次的父子区块均已认证。单个 QC 不足以提交最终性。 +- 每个验证者每轮只为一个区块投票,最高投票轮次在广播之前持久化;允许重复广播同一票。冲突投票证据可放入区块头,并产生虚拟操作 `validator_equivocation`(ID **106**)。 +- 新区块头扩展标签按顺序追加:**3 QC**、**4 TC**、**5 双重投票证据**。交易扩展和现有操作 ID 不变。 +- 激活时会缩放需要保持原有实际时间含义的按区块计量治理参数。不能机械修改所有 3 秒定时器;重试和停机定时器属于独立配置。 + +## 自动恢复与紧急模式 + +临时网络故障恢复后,普通恢复不需要重启或紧急密钥。21 个验证者的测试覆盖:6 个验证者暂时不可达、20% 丢包和 150±50 毫秒延迟、241 秒全网断连,以及 10/11 分区。网络恢复后,全部 21 个节点对同一被检查区块达成最终性。这是测试范围,不是对任意攻击下恢复时间的保证。 + +延迟链追赶现在保留 fork_db 超前于已应用状态时所需的 LIB 祖先。紧急共识是独立路径,其超时仍为 **1200 秒(20 分钟)**;短时故障测试没有把这个阈值改为五分钟。参见[紧急共识](./emergency-consensus.md)。 + +## DAO 与市场 + +创建申请时,DAO 上限为最小单位中的 `floor(current_supply.amount / 100)`:**当前 VIZ 总供应量的 1%**,向下取整到 0.001 VIZ。每次申请重新计算,不依据初始发行量或委员会资金余额。HF16 之前的历史重放保留 **500,000 VIZ** 旧上限,不重写已存在申请的金额。参见[委员会操作](../protocol/operations/committee.md)。 + +HF16 激活后声明的 no-contest 在声明时冻结名义费用,结算时仍以保险余额封顶;旧声明沿用旧算法。冻结为零与从未冻结是不同状态。参见[no-contest 费用冻结](../prediction-markets/pm-no-contest-fee-freeze.md)。 + +## 操作员 UI 与 MCP + +可选 `node_admin` 插件在独立管理监听器上提供内置浏览器 UI `/ui/` 和 HTTP MCP。支持健康、诊断、插件、日志、审计、P2P、快照、验证者操作及配置计划。Bearer 令牌按 scope 限制权限,配置和插件变更使用可检查的 `plan` / `apply`。默认仅监听回环地址;远程操作使用隧道,不要把管理端口当作公共链 API。参见[节点管理](../plugins/node-admin.md)。 + +## 客户端库 + +JS **0.18.5** 支持新头部格式,已发布到 npm。PHP 保持交易格式并透传区块头 JSON。Python **1.4.0** 源码和依赖已通过 Python 3.10–3.14 CI;PyPI 发布是独立步骤。客户端不能保留旧的固定 DAO 金额上限。 + +## 增发与证据窗口 + +HF16 有意保留每个实际产生的区块 **1.000 VIZ**(所有者决定 Q3105=C)。连续出块时,每年总增发量约从 1050 万增至 3150 万 VIZ;空缺时隙没有区块奖励。 + +投票及双重投票证据窗口保留一小时:`3600 / block_interval()` 轮,HF16 后为 3600 轮。这与紧急计时器及恢复 SLA 不同。漏块导致清空签名密钥的区块阈值在 HF16 增加三倍,保持普通模式约 10 分钟、紧急模式约 5 分钟。新参数投票的最小分配周期也保持原有时长,HF16 前重放保留旧下限。 + +## 重新投票区块参数 + +按已选 HF16 策略,存储和投票单位仍为**实际区块数**。一次性迁移将现有投票及中位数乘三,但无法识别旧脚本随后提交的旧值。验证者须将硬编码的 `inflation_recalc_period`、`distribution_epoch_length`、`pm_batch_epoch_blocks`、`pm_reveal_window_blocks` 乘三以保留时长。投默认值时使用实时 `get_config.CHAIN_DEFAULT_CHAIN_PROPERTIES`;修改无关字段时保留其他当前链上值,勿重发旧结构默认值。 + +`get_config` 返回一致的实时每小时/天/年区块数、undo 容量、漏块阈值及默认参数。reserve ratio 调整仍每 60 秒执行。`CHAIN_EMERGENCY_EXIT_NORMAL_BLOCKS` 为未使用的旧常量;实际紧急退出依据活跃验证者法定人数,而不是 21 区块计时器。 diff --git a/@l10n/zh-CN/docs/governance/committee.md b/@l10n/zh-CN/docs/governance/committee.md index 2e0d45683e..a4481d866c 100644 --- a/@l10n/zh-CN/docs/governance/committee.md +++ b/@l10n/zh-CN/docs/governance/committee.md @@ -114,3 +114,5 @@ payment = min(committee_fund / count_approved_requests, remain_payout_amount) --- 参见:[委员会操作](../protocol/operations/committee.md)、[质押和 DAO](./staking-and-dao.md)、[链属性](./chain-properties.md)、[虚拟操作](../protocol/virtual-operations.md)。 + +**HF16 上限:** 创建时 `required_amount_max.amount <= floor(current_supply.amount / 100)`,按最小单位向下取整,即当前 VIZ 总量的 1%。激活前上限为 500,000 VIZ。不是委员会资金的 1%,也不修改现有申请。参见 [HF16](../consensus/hf16.md)。 diff --git a/@l10n/zh-CN/docs/node/configuration.md b/@l10n/zh-CN/docs/node/configuration.md index d7d8e1bc21..f6a5f8a558 100644 --- a/@l10n/zh-CN/docs/node/configuration.md +++ b/@l10n/zh-CN/docs/node/configuration.md @@ -197,6 +197,25 @@ logger.p2p.level = warn logger.p2p.appenders = p2p ``` +`file` appender 已配置为按时间轮转(每小时一个新文件,保留一天),但只有在 fc +纤程队列被处理时轮转才会触发,而 `vizd` 启动后不再处理它(2026-10-20 实测:测试网节点在 +`info` 级别下九小时写了一个 37 MB 的文件,没有任何按小时的文件;主网节点运行四天也没有 +产生新文件)。不要指望它来限制文件大小,并注意在 `debug` 下节点每小时可能写出数 GB 日志。 +如果只需要最近的尾部,请使用可选的 capped appender:单个文件,只保留最后 `max_lines` 行 +(默认 10000,`0` 表示不限制)。它在写入时统计行数并在原文件上就地压缩,因此文件保持在 +`max_lines` 与约 1.1 x `max_lines` 之间,不依赖任何定时器。配置中没有 +`log.capped_file_appender.*` 节的节点行为与之前完全一致;该节也可以安全地先于二进制升级 +下发——旧版本会忽略它并继续写入 stderr。若配置的路径无法打开,节点仍会启动:向 stderr +输出一行 `capped_file_appender: cannot open log file …`,只是不会创建文件。 + +```ini +log.capped_file_appender.node.filename = logs/node/node.log +log.capped_file_appender.node.max_lines = 10000 + +# 写哪些日志由 logger 决定;appender 本身不写任何东西。 +logger.default.appenders = stderr, node +``` + --- ## 完整参考 diff --git a/@l10n/zh-CN/docs/node/getting-started.md b/@l10n/zh-CN/docs/node/getting-started.md index 483001422f..41d8ae46f1 100644 --- a/@l10n/zh-CN/docs/node/getting-started.md +++ b/@l10n/zh-CN/docs/node/getting-started.md @@ -37,6 +37,7 @@ docker pull vizblockchain/vizd:latest ```bash docker run -d \ --name vizd \ + --restart unless-stopped \ -p 2001:2001 \ -p 8090:8090 \ -p 8091:8091 \ @@ -161,7 +162,7 @@ curl -s -X POST http://localhost:8090 \ | python3 -m json.tool ``` -检查 `head_block_number`——同步后应每 3 秒增加一次。 +检查 `head_block_number`——同步后应HF16 前每 3 秒、激活后每 1 秒增加一次。 --- diff --git a/@l10n/zh-CN/docs/node/monitoring.md b/@l10n/zh-CN/docs/node/monitoring.md index fa28366af5..8a2869ebbb 100644 --- a/@l10n/zh-CN/docs/node/monitoring.md +++ b/@l10n/zh-CN/docs/node/monitoring.md @@ -15,7 +15,7 @@ curl -s -X POST http://localhost:8090 \ | python3 -m json.tool ``` -检查 `head_block_number` — 同步时应每 3 秒增加一次。检查 `time` — 应与系统时钟相差几秒以内。 +检查 `head_block_number` — 同步时应HF16 前每 3 秒、激活后每 1 秒增加一次。检查 `time` — 应与系统时钟相差几秒以内。 简单的存活探测脚本: @@ -184,7 +184,7 @@ while True: | 面板 | 指标 / 来源 | |------|-----------| -| 头块 | `viz_head_block_number`(同步时每 3 秒增加) | +| 头块 | `viz_head_block_number`(同步时HF16 前每 3 秒、激活后每 1 秒增加) | | 区块延迟 | `time() - viz_head_block_time`(落后系统时钟的秒数) | | 节点数 | 从 P2P 统计日志解析 | | 节点延迟 | P2P 统计日志,按节点 IP | diff --git a/@l10n/zh-CN/docs/node/snapshot.md b/@l10n/zh-CN/docs/node/snapshot.md index b830aebef2..bcdb68f9fc 100644 --- a/@l10n/zh-CN/docs/node/snapshot.md +++ b/@l10n/zh-CN/docs/node/snapshot.md @@ -82,7 +82,7 @@ snapshot-dir = /data/snapshots ```ini plugin = snapshot -snapshot-every-n-blocks = 28800 # 按 3 秒/区块计约 24 小时 +snapshot-every-n-blocks = 28800 # HF16 前约 24 小时,激活后约 8 小时(1 秒/区块) snapshot-dir = /data/snapshots snapshot-max-age-days = 90 ``` diff --git a/@l10n/zh-CN/docs/node/validator-node.md b/@l10n/zh-CN/docs/node/validator-node.md index a95ea3579b..624301663e 100644 --- a/@l10n/zh-CN/docs/node/validator-node.md +++ b/@l10n/zh-CN/docs/node/validator-node.md @@ -1,6 +1,6 @@ # 运行验证者节点 -验证者(区块生产者)是由 Fair-DPOS 算法计划每 3 秒生产一个区块的账户。运行验证者节点需要已注册的验证者账户、签名密钥和正确配置的节点。 +验证者(区块生产者)是由 Fair-DPOS 算法计划HF16 前每 3 秒、激活后每 1 秒生产一个区块的账户。运行验证者节点需要已注册的验证者账户、签名密钥和正确配置的节点。 --- diff --git a/@l10n/zh-CN/docs/p2p/messages.md b/@l10n/zh-CN/docs/p2p/messages.md index c7454ce69f..0734fb75b7 100644 --- a/@l10n/zh-CN/docs/p2p/messages.md +++ b/@l10n/zh-CN/docs/p2p/messages.md @@ -2,7 +2,7 @@ DLT P2P 使用原始 TCP 上的二进制协议。每条消息以 4 字节小端头帧定,包含消息类型 ID,后跟通过 FC 反射序列化的长度前缀数据载荷。 -**头文件:** [libraries/network/include/graphene/network/dlt_p2p_messages.hpp](../../libraries/network/include/graphene/network/dlt_p2p_messages.hpp) +**头文件:** [libraries/network/include/graphene/network/dlt_p2p_messages.hpp](https://github.com/VIZ-Blockchain/viz-cpp-node/blob/pm/libraries/network/include/graphene/network/dlt_p2p_messages.hpp) --- diff --git a/@l10n/zh-CN/docs/plugins/block-archive.md b/@l10n/zh-CN/docs/plugins/block-archive.md index 3709f861e3..0370d8b964 100644 --- a/@l10n/zh-CN/docs/plugins/block-archive.md +++ b/@l10n/zh-CN/docs/plugins/block-archive.md @@ -4,7 +4,7 @@ 该插件不改变共识,不需要硬分叉,也不影响区块处理:启用与未启用它的节点完全兼容。 -**源码:** [plugins/block_archive/plugin.cpp](../../plugins/block_archive/plugin.cpp) +**源码:** [plugins/block_archive/plugin.cpp](https://github.com/VIZ-Blockchain/viz-cpp-node/blob/pm/plugins/block_archive/plugin.cpp) --- diff --git a/@l10n/zh-CN/docs/plugins/chain.md b/@l10n/zh-CN/docs/plugins/chain.md index bdb5139a45..aef46f15a0 100644 --- a/@l10n/zh-CN/docs/plugins/chain.md +++ b/@l10n/zh-CN/docs/plugins/chain.md @@ -2,7 +2,7 @@ 链插件是每个 VIZ Ledger 节点的核心组件。它管理 chainbase 共享内存数据库,接受并验证区块和交易,维护 fork 数据库和区块日志状态,并协调与快照和 P2P 插件的启动。所有其他插件都依赖它。 -**源码:** [plugins/chain/plugin.cpp](../../plugins/chain/plugin.cpp) +**源码:** [plugins/chain/plugin.cpp](https://github.com/VIZ-Blockchain/viz-cpp-node/blob/pm/plugins/chain/plugin.cpp) --- diff --git a/@l10n/zh-CN/docs/plugins/database-api.md b/@l10n/zh-CN/docs/plugins/database-api.md index ca6d1cfde8..c7dd20f338 100644 --- a/@l10n/zh-CN/docs/plugins/database-api.md +++ b/@l10n/zh-CN/docs/plugins/database-api.md @@ -2,7 +2,7 @@ `database_api` 插件提供只读 JSON-RPC 方法,用于查询区块链状态:区块、账户、链属性、委托、权限验证和治理。 -**源码:** [plugins/database_api/](../../plugins/database_api/) +**源码:** [plugins/database_api/](https://github.com/VIZ-Blockchain/viz-cpp-node/tree/pm/plugins/database_api/) --- diff --git a/@l10n/zh-CN/docs/plugins/node-admin.md b/@l10n/zh-CN/docs/plugins/node-admin.md new file mode 100644 index 0000000000..1142916a4b --- /dev/null +++ b/@l10n/zh-CN/docs/plugins/node-admin.md @@ -0,0 +1,107 @@ +# 节点管理 API、UI 与 MCP + +`node_admin` 是节点进程内部的管理插件,面向操作员和持有管理令牌的 AI 工具。它提供健康检查、配置、插件状态、日志、审计、P2P、快照和验证者管理;不是面向普通用户的公共链 API。 + +## 独立监听器与配置 + +公共 API 通常经 nginx 转发,节点看到的来源可能都是回环地址,因此管理权限不能依赖公共端口的来源 IP。管理插件使用独立监听器,默认只允许回环地址。非回环地址必须显式设置 `admin-allow-remote = true`。远程使用优先选择 SSH 隧道。 + +```ini +plugin = node_admin +admin-endpoint = 127.0.0.1:8099 +admin-token = operator:read:REPLACE_WITH_SHA256_OF_TOKEN +admin-audit-log = /var/lib/vizd/node-admin-audit.log +admin-log-file = node=/var/lib/vizd/logs/node.log +admin-plan-ttl = 120 +``` + +`admin-endpoint` 缺省或为空时插件不监听。设置了端口却没有令牌时拒绝启动。`admin-token` 可重复,格式为 `label:scope[,scope]:sha256hex[:expires-unixtime]`。用 `openssl rand -hex 32` 生成随机令牌,只把它的 SHA-256 放入配置;请求携带原令牌 `Authorization: Bearer ...`。比较使用恒定时间。令牌到期后无需重启即可失效;修改配置撤销令牌需要重启。 + +## 权限 + +所有令牌都必须包含 `read`。额外权限不替代 `read`。 + +| Scope | 功能 | +|---|---| +| `read` | 查询、诊断、日志、快照列表和下载 | +| `p2p` | 添加节点、可信节点、清除封禁 | +| `snapshot` | 创建、恢复快照 | +| `webserver` | 限流、统计、IP 名单及重置/重载 | +| `config` | 配置 plan/apply | +| `plugins` | 插件 plan/apply | +| `chain` | 验证者启停、签名密钥变更,需要已启动且配置 active key 的 `validator_guard` | +| `process` | 重启和状态修复;修复会在下次启动改写状态,不只是停止进程 | + +## JSON-RPC 与方法 + +向管理端点发送 JSON-RPC 2.0 POST,`Content-Type: application/json`。所有调用均检查令牌和 scope。省略必需参数、未知方法及越权请求会返回错误,不应把 HTTP 成功等同于操作成功。 + +```json +{"jsonrpc":"2.0","id":1,"method":"admin.node_info","params":{}} +``` + +| 方法组 | 方法与用途 | +|---|---| +| 健康 | `admin.ping`、`admin.auth.me`(当前令牌权限/期限)、`admin.node_info`(头块/LIB/版本)、`admin.diagnostics` | +| 插件 | `admin.plugins.list`、`admin.plugins.status`;配置的插件与实际观测到的生命周期状态有区别 | +| 状态对象 | `admin.objects.list`、`admin.objects.check`、`admin.objects.repair`;修复需要 `process` 并重启,不把未能读取当作健康 | +| 配置/日志 | `admin.config.get`、`admin.logs.list`、`admin.logs.tail`、`admin.audit.tail`;只可读取显式允许的日志路径,敏感配置不返回明文 | +| P2P | `admin.p2p.status`、`admin.p2p.add_node`、`admin.p2p.add_trusted`、`admin.p2p.clear_banned`;变更在 P2P 线程执行 | +| 快照 | `admin.snapshot.status`、`admin.snapshot.create`、`admin.snapshot.restore`;恢复会替换节点状态,不能当作只读操作 | +| 验证者 | `admin.validators.active`、`admin.validator.list`、`admin.validator.plan`、`admin.validator.apply` | +| Webserver | `admin.webserver.status`、`admin.webserver.ips`、`admin.webserver.limits`、`admin.webserver.stats`、`admin.webserver.whitelist_add`、`admin.webserver.whitelist_remove`、`admin.webserver.blacklist_add`、`admin.webserver.blacklist_remove`、`admin.webserver.reset`、`admin.webserver.reload` | +| 配置计划 | `admin.config.plan`、`admin.config.apply`、`admin.config.backups` | +| 插件计划 | `admin.plugins.plan`、`admin.plugins.apply` | +| 计划/进程 | `admin.plan.cancel`、`admin.restart` | + +Webserver 统计和限流是独立开关。白名单绕过限制但仍计数;黑名单即使关闭统计也返回 403。重置计数不清除名单或限制。配置的插件不一定已经加载,UI 不把配置列表等同于实际运行状态。 + +## 两阶段变更和备份 + +`admin.config.plan {"set":{"key":value}}` 只验证允许的键并返回 `plan_id`、精确 `diff`、`config_path`、`needs_restart` 和 `expires_at`,不写文件。`admin.plugins.plan` 使用 `add`/`remove` 列表,拒绝重复、未知插件及移除 `chain`/`node_admin`。无实际变化的计划被拒绝。 + +计划与创建它的令牌 label 绑定、一次性使用,并在 `admin-plan-ttl`(默认 120 秒)后过期;最多保留 64 个。apply 会重新验证参数以及配置 SHA-256;文件在 plan 后被别人改动则不写入。`admin.plan.cancel` 可丢弃计划。 + +成功写入会保留原注释、顺序、CRLF 和权限,创建同目录 `config.ini.bak-`,通过临时文件、fsync、rename 原子替换。备份不会自动清理,可能含密钥,须像原配置一样保护。配置/插件变更需要重启后生效,不是热加载。`admin.restart` 只请求退出,由 systemd/Docker 等监督器重新启动;不会自行启动第二个进程。 + +验证者变更通过 `validator_guard` 的 active key 签署链上交易,API 不返回密钥。没有可用 guard 时查询仍可说明原因,变更不可用。 + +## 内置 UI + +打开 `http://127.0.0.1:8099/ui/`。页面编译进二进制,无外部资源;首次使用输入 bearer 令牌。令牌保存在本标签页的 `sessionStorage`,不是服务器内置秘密。UI 根据 `admin.auth.me` 隐藏无权限操作,实际服务端仍独立检查权限。 + +UI 提供总览、诊断、插件、对象检查、日志、P2P、快照、Webserver 策略、验证者和配置计划。危险变更先展示计划/差异,再执行 apply。页面自身 GET 不需要认证,但不携带节点数据,也不授予任何权限。反向代理前缀下页面根据自己的 URL 计算 RPC 基址。 + +快照下载 `GET /snapshot/latest` 或 `GET /snapshot/` 及对应 HEAD 需要 bearer/read;只接受列表中实际文件名,拒绝目录穿越。文件按 64 KiB 分块流式传输,无文件大小上限,并按接收方速度施加背压;写入停滞 30 秒后断开,不将整个快照载入内存,也不阻塞其他管理请求。 + +## MCP(Streamable HTTP) + +MCP 在同一插件和监听器内运行,无需 sidecar;使用相同令牌和审计。 + +| 方法 | 行为 | +|---|---| +| `initialize` | 支持 `2024-11-05`、`2025-03-26`、`2025-06-18`,默认后者;只声明 tools 能力 | +| `notifications/initialized` | 无 id 通知,HTTP 202 空正文 | +| `ping` | 返回空对象 | +| `tools/list` | 返回管理工具及 JSON Schema | +| `tools/call` | `{"name":"admin.node_info","arguments":{}}` 调用对应管理方法 | + +MCP 工具与上表管理方法一一对应,共 41 个。无会话,不签发 `Mcp-Session-Id`,不提供持续 SSE 订阅。工具执行错误通过结果中的 `isError:true` 和 `content` 返回;协议错误(未知工具、参数错误、缺少权限)仍是 JSON-RPC 错误。AI 工具不会因为通过 MCP 调用就得到额外权限。 + +## 日志、审计与安全 + +审计记录令牌 label、方法、脱敏参数、结果、错误码、耗时和来源,不记录原令牌或私钥。审计文件权限 0600,16 MB 轮转。仅允许列出的日志文件,路径和配置中的秘密字段被过滤。 + +普通 fc file_appender 的定时轮转在当前节点主线程模型下不能可靠执行。需要硬上限时使用可选 `capped_file` appender(默认保留 10000 行,0 为不限),并把 `admin-log-file` 指到同一路径。不要把仅配置了小时轮转当作已限制磁盘增长。 + +管理端口包含状态恢复、链上密钥变更及进程控制能力,不应接入公共 RPC 反向代理。通过隧道访问时保留令牌权限分离和审计。详尽参数、配置允许列表和各方法返回字段可对照[英文完整参考](/plugins/node-admin)。 + +测试入口:`plugins/node_admin/tests/acceptance.sh`、`plugins/node_admin/tests/validator-e2e.sh`;包含 token/scope 拒绝、plan/apply、快照、对象修复、MCP/UI 与审计检查。 + +## 4.2.0 安全补充 + +入口总预算为每秒 100 个请求,超出部分在写审计前拒绝;审计轮换保留 `.1`–`.8` 八代,MCP 审计增加实际工具名称 `tool`。HTTP 握手超时为 5 秒,并发连接上限为 64。快照以 64 KiB 分块流式下载,无文件大小上限;写入停滞 30 秒后断开。 + +持久化的 webserver 运行策略仅在 `config_policy` 与启动时 config.ini 的统计/限流配置一致时恢复。修改这些配置后采用新配置,IP 名单仍保留;无指纹的旧状态文件仅恢复 IP 名单。关闭统计日志不会关闭已启用的限流。 + +内存中最多保留 200,000 个来源的计数器。达到容量时淘汰最久未使用的记录,新客户端仍按正常限额判断,不会统一返回 429。被淘汰的来源再次访问时重新计数,活跃来源保留已有窗口。黑名单和白名单独立保存,不参与淘汰。 diff --git a/@l10n/zh-CN/docs/plugins/snapshot.md b/@l10n/zh-CN/docs/plugins/snapshot.md index 1f050f34aa..543306f204 100644 --- a/@l10n/zh-CN/docs/plugins/snapshot.md +++ b/@l10n/zh-CN/docs/plugins/snapshot.md @@ -2,7 +2,7 @@ 快照插件通过将完整的区块链状态序列化并恢复为 JSON 文件,实现节点的近乎即时启动。节点不需要从区块日志重放数百万个区块,而是加载预构建的快照,并通过 P2P 从快照的区块高度开始同步。 -**源码:** [plugins/snapshot/snapshot.cpp](../../plugins/snapshot/snapshot.cpp) +**源码:** [plugins/snapshot/snapshot.cpp](https://github.com/VIZ-Blockchain/viz-cpp-node/blob/pm/plugins/snapshot/plugin.cpp) --- diff --git a/@l10n/zh-CN/docs/plugins/validator.md b/@l10n/zh-CN/docs/plugins/validator.md index d6215fe8a2..5223b97a6c 100644 --- a/@l10n/zh-CN/docs/plugins/validator.md +++ b/@l10n/zh-CN/docs/plugins/validator.md @@ -2,7 +2,7 @@ 验证者插件负责区块签名和生产。它在自己的 OS 线程上运行专用的 250ms 定时器循环,在每次触发时执行一系列安全检查,并在满足所有条件时调用 `database::generate_block()`。 -**源码:** [plugins/validator/validator.cpp](../../plugins/validator/validator.cpp) +**源码:** [plugins/validator/validator.cpp](https://github.com/VIZ-Blockchain/viz-cpp-node/blob/pm/plugins/validator/validator.cpp) --- diff --git a/@l10n/zh-CN/docs/plugins/webserver.md b/@l10n/zh-CN/docs/plugins/webserver.md index 105d433a16..35357cec6c 100644 --- a/@l10n/zh-CN/docs/plugins/webserver.md +++ b/@l10n/zh-CN/docs/plugins/webserver.md @@ -2,7 +2,7 @@ 网络服务器插件提供 HTTP 和 WebSocket 端点,将 JSON-RPC 请求转发到 `json_rpc` 插件。它包含以 `method + params`(不含 `id`)为键的响应缓存(每个应用的区块使其失效),以及用于并发请求处理的线程池。 -**源码:** [plugins/webserver/webserver_plugin.cpp](../../plugins/webserver/webserver_plugin.cpp) +**源码:** [plugins/webserver/webserver_plugin.cpp](https://github.com/VIZ-Blockchain/viz-cpp-node/blob/pm/plugins/webserver/webserver_plugin.cpp) --- @@ -201,3 +201,9 @@ location /ws { --- 参见:[插件概述](./overview.md)、[Database API](./database-api.md)、[JSON-RPC API](../api/json-rpc.md)。 + +### 可信反向代理与策略持久化 + +默认按 TCP 对端限流,忽略转发头。nginx 后方请显式配置 `webserver-trusted-proxy = 127.0.0.1`(每个真实代理 IP 一项)并转发 `X-Forwarded-For`。从右向左检查代理链,遇到第一个不可信节点即停止;勿信任任意客户端 IP。 + +达到 200000 个 IP 时清理超过一天的旧记录,容量仍满时拒绝未跟踪的新来源,不清空现有客户端窗口。关闭统计日志不关闭已启用的限流。定期只记录变更行,格式化和磁盘 I/O 在 admission mutex 外执行。`config_policy` 指纹确保修改 config.ini 后采用新策略,同时保留 IP 名单。 diff --git a/@l10n/zh-CN/docs/prediction-markets/pm-audit-fix-upgrade.md b/@l10n/zh-CN/docs/prediction-markets/pm-audit-fix-upgrade.md index 3741b140a9..b0be755e25 100644 --- a/@l10n/zh-CN/docs/prediction-markets/pm-audit-fix-upgrade.md +++ b/@l10n/zh-CN/docs/prediction-markets/pm-audit-fix-upgrade.md @@ -1,5 +1,8 @@ # HF15 — PM 审计修复:激活清单与陈旧状态迁移 +> **HF15 历史检查表:** 下文描述当时升级,不代表当前发布状态。4.2.0 编译至硬分叉 16;参见 [HF16](../consensus/hf16.md)。 + + 状态:下述两项修复**已实现,但未在 mainnet 排期**。分叉在两种配置中都会注册(`CHAIN_NUM_HARDFORKS` 均为 15),并由激活时间加验证者法定人数共同门控,因此 production 二进制在该时间到达后即会应用它; 在此之前不会执行任何内容,而无论哪种情况都不需要迁移。两个编译期时间戳见 §2。本文是面向运维的 diff --git a/@l10n/zh-CN/docs/prediction-markets/pm-no-contest-fee-freeze.md b/@l10n/zh-CN/docs/prediction-markets/pm-no-contest-fee-freeze.md new file mode 100644 index 0000000000..576c632cb1 --- /dev/null +++ b/@l10n/zh-CN/docs/prediction-markets/pm-no-contest-fee-freeze.md @@ -0,0 +1,31 @@ +# HF16:no-contest 费用冻结 + +状态:已实现,计划在 **2026-10-31 00:00:00 UTC** 随 4.2.0 激活,还需硬分叉投票门槛。参见 [HF16](../consensus/hf16.md)。 + +## 费用何时确定 + +HF16 之前,`pm_no_contest` 只声明无结果并开启争议宽限期,费用在结算时使用当时的中位数参数计算。参数在窗口内改变会追溯改变账单。 + +HF16 下,声明区块的中位数决定 `floor(pm_dispute_fee × pm_no_contest_penalty_percent / 10000)`,保存到市场的 `no_contest_fee`。结算不重新读取当前中位数;实际罚款仍为 `min(frozen_fee, oracle.insurance)`,不会超过保险余额。有无下注的处理不变;没有 stake 的市场记录冻结零费用且不收费。 + +## 两种状态 + +- `no_contest_fee_frozen=true`:HF16 下声明,使用保存的金额,包括合法的零值。 +- `false`:激活前声明,或来自旧快照;保留旧的结算时中位数算法。 + +不能用 `fee == 0` 代替标志。冻结为零必须保持零,不能因后来的投票涨价。激活时不扫描或回填宽限期中的旧市场,也不猜测历史费用。 + +## 统计与快照 + +`no_contest_slashed_total` 是预言机累计已收取的 no-contest 罚款,在增加 `total_insurance_slashed` 的同一次 modify 中累加。HF16 后结算的旧声明也计入;HF16 之前已经收取的罚款不回填。它仅用于展示/归因,不参与共识判定,不增加虚拟操作。 + +市场金额及冻结标志、预言机累计值必须由当前版本快照保存/恢复;旧数据默认未冻结。对象布局变化要求使用当前 schema 和匹配二进制,不手工修改 shared_memory。更新前保存数据备份。 + +## 验证要点 + +1. 确认实际生效的 hardfork 为 16,而不只是二进制版本 4.2.0。 +2. 新声明记录 `no_contest_fee_frozen=true`;在宽限期改变参数后结算仍使用冻结金额。 +3. 验证冻结零费用、无下注、保险不足和激活前声明的旧路径。 +4. 快照往返后字段保持一致;累计值与实际保险扣除对应,不把冻结的名义费用当作已经收取的罚款。 + +回归入口:`tests/consensus_sim/scenarios/test_pm_no_contest_fee.cpp`。[英文完整检查表](/prediction-markets/pm-no-contest-fee-freeze)包含底层字段和快照验证步骤。 diff --git a/@l10n/zh-CN/docs/protocol/key-history.md b/@l10n/zh-CN/docs/protocol/key-history.md new file mode 100644 index 0000000000..40607d3294 --- /dev/null +++ b/@l10n/zh-CN/docs/protocol/key-history.md @@ -0,0 +1,29 @@ +# 密钥历史(HF15) + +DLT 节点从快照启动,未必保留旧区块。HF15 起,共识状态永久记录账户已停止使用的旧权限成员,使历史签名可以用当前状态证明。 + +## 记录内容 + +某角色的权限实际改变时,为旧权限的每个成员记录一行: + +| 字段 | 含义 | +|---|---| +| `account` | 账户 | +| `role` | `master`、`active`、`regular` 或 `memo` | +| `key` | 旧公钥;账户成员使用空公钥 | +| `auth_account` | 旧 `account_auths` 成员;公钥成员为空 | +| `weight` | 成员权重,memo 为 0 | +| `weight_threshold` | 角色阈值,memo 为 0 | +| `valid_until_block` | 旧权限最后有效的区块(含),下一区块发生改变 | +| `valid_until_time` | 该区块时间 | + +空旧权限也保存标记行:空公钥、空账户、权重 0,用于空→非空的冷却限制;空公钥查询也能返回标记。起始时间是同一角色上一行结束后的区块;HF15 激活前已存在的密钥起点可能未知。当前权限仍在账户对象中。 + +`account_update`、恢复、直接出售和拍卖成交均记录实际改变的旧权限。未改变的角色不新增行;历史行永久保留。它独立于[账户恢复](operations/recovery.md)使用的仅保留 30 天的 master 历史。 + +## 修改频率与 API + +HF15 起通过 `account_update` 实际修改 active、regular 或 memo 每个角色最多每小时一次,与已有 master 限制一致。不同角色分别计时;账户恢复和出售不受此限制。 + +- `database_api.get_key_history(account, from, limit)`:账户时间线。 +- `database_api.get_key_history_by_key(key, limit)`:谁曾持有密钥、何时结束。 diff --git a/@l10n/zh-CN/docs/protocol/operations/agent-access.md b/@l10n/zh-CN/docs/protocol/operations/agent-access.md new file mode 100644 index 0000000000..8d297d486a --- /dev/null +++ b/@l10n/zh-CN/docs/protocol/operations/agent-access.md @@ -0,0 +1,41 @@ +# 代理权限(HF15) + +主账户可以登记命名代理公钥,允许其代表账户签署明确列出的操作。代理是账户上的记录,不是独立账户;无需交出主账户私钥,可随时撤销。 + +## `set_agent_permission_operation`(ID 105) + +需要 `account` 的 **active** 权限;HF15 前拒绝。 + +| 字段 | 类型 | 含义 | +|---|---|---| +| `account` | account_name_type | 授权主账户 | +| `agent_name` | string | 同账户内唯一,1–32 字节,字符 `[a-z0-9_-]`;超长拒绝,不截断 | +| `agent_key` | public_key_type | 授权时必填,撤销时忽略 | +| `operations` | `flat_set` | 允许操作的 wire 名称,如 transfer、pm_place_bet | +| `expiration` | time_point_sec | 1970-01-01T00:00:00 为永久;过去时间撤销 | +| `addons` | `flat_set` | 链外服务 scope,最多 10 个,每个小于 64 字节且不含逗号;共识不解释、不授予链上权限 | +| `extensions` | extensions_type | 始终为 [] | + +```json +["set_agent_permission", {"account":"alice","agent_name":"trade-bot","agent_key":"VIZ6MyX5QiXAXRZk7SYCiqpi6Mtm8UbHWDFSV8HPpt7FJyahCnc2T","operations":["pm_place_bet","transfer"],"expiration":"2027-01-01T00:00:00","addons":["vizhub"],"extensions":[]}] +``` + +operations 为空、addons 非空可创建只供链外服务登录的代理。两个列表都为空即撤销;撤销也可使用空公钥。相同 agent_name 更新记录;同一主账户不能把同一代理公钥分给不同名称。最多 16 个代理,授权前清除已过期代理。 + +## 权限边界 + +明确授权可满足主账户的直接顶层 active/regular 要求;每个操作、每个主账户分别判断。普通权限优先,含 master 回退;多余未使用签名仍非法。旧有禁止混合 regular 与 active/master 操作的规则不变。 + +代理不能满足 master、任意 other 权限或嵌套 account_auths,代理签名不产生账户批准,也不能授权同交易中的另一个账户。proposal_create 的明确授权只允许保存提案,不自动批准或执行内部操作;proposal_delete 仍须满足原 evaluator。proposal_update 禁止用于代理,不能形成持久账户/公钥批准。 + +每个操作执行前重新检查依赖代理的要求;前序撤销、限制、换代理密钥或清空权限会立刻使后续代理证明失效。普通进入交易时的权限证明行为不变。 + +master 或 active 字段出现在 account_update 时,即使密钥值相同,也会清空所有代理;恢复、出售、拍卖成交同样清空。仅改 regular 不清空。只更新 regular/memo 的客户端必须省略 master/active;PHP build_account_update 传 null 表示省略。 + +## 查询与钱包 + +`database_api.get_agent_permissions(account)` 返回代理列表。get_potential_signatures 提供候选密钥;get_required_signatures 提供当前可用贡献,不代表所有主账户权限已满足。verify_authority 和实际应用交易仍检查完整权限、操作范围和过期状态。 + +WebVIZWallet 的 Agents 页面可生成密钥(私钥仅展示一次)、配置操作/到期/addons、查看和撤销代理,授权由主账户 active 签署。Vizonator 向网站提供同类操作并显示确认窗口。代理登录使用主账户名和代理私钥,客户端核对链上授权,显示 🤖、允许操作及有效期,禁用越权动作。 + +网站 `domain:auth:account:regular:time:nonce` 登录签名是独立链外流程,不是链上操作;代理会话不代答普通 regular 登录请求。PHP VIZ Auth / JS viz.auth 只有在代理持有所需网站 addon 时才接受代理链外签名;链外授权来自 addon,不是 operations 列表。master/active 更新或恢复/出售后须重新授权代理。 diff --git a/@l10n/zh-CN/docs/protocol/operations/committee.md b/@l10n/zh-CN/docs/protocol/operations/committee.md index 545ee0ecd1..0ebbc7fd7b 100644 --- a/@l10n/zh-CN/docs/protocol/operations/committee.md +++ b/@l10n/zh-CN/docs/protocol/operations/committee.md @@ -102,3 +102,5 @@ --- 参见:[数据类型](../data-types.md)、[操作概述](./overview.md)、[虚拟操作](../virtual-operations.md)、[委员会治理](../../governance/committee.md)。 + +**HF16 上限:** 创建时 `required_amount_max.amount <= floor(current_supply.amount / 100)`,按最小单位向下取整,即当前 VIZ 总量的 1%。激活前上限为 500,000 VIZ。不是委员会资金的 1%,也不修改现有申请。参见 [HF16](../../consensus/hf16.md)。 diff --git a/@l10n/zh-CN/docs/protocol/operations/overview.md b/@l10n/zh-CN/docs/protocol/operations/overview.md index 65543cc4da..d7313afab2 100644 --- a/@l10n/zh-CN/docs/protocol/operations/overview.md +++ b/@l10n/zh-CN/docs/protocol/operations/overview.md @@ -127,6 +127,7 @@ VIZ Ledger 操作是包含在交易中的原子状态变更动作。每个操作 | 103 | `pm_early_exit_claim_paid_operation` | 提前退出的延迟索赔在结算时支付 | [预测市场](./prediction-markets.md) | | 104 | `pm_lp_payout_operation` | LP 收益在结算时支付 | [预测市场](./prediction-markets.md) | | 105 | `set_agent_permission_operation` | 授予、替换或撤销代理密钥(HF15) | [Agent access](./agent-access.md) | +| 106 | `validator_equivocation_operation` | 验证者双重投票已被证明(HF16 QC) | [虚拟操作](../virtual-operations.md) | --- diff --git a/@l10n/zh-CN/docs/protocol/virtual-operations.md b/@l10n/zh-CN/docs/protocol/virtual-operations.md index 9c22fd7ff3..1b32e86e7e 100644 --- a/@l10n/zh-CN/docs/protocol/virtual-operations.md +++ b/@l10n/zh-CN/docs/protocol/virtual-operations.md @@ -377,6 +377,13 @@ 所有 PM 资金流动严格零和(无增发);结算守恒 `Σ out == Σ 下注 + LP 本金 + forfeit_pool`。 + +### 共识(HF16 QC) + +| ID | 操作 | 触发条件 | +|----|------|----------| +| 106 | `validator_equivocation_operation` | 某区块携带证据,证明 `validator` 在同一 `round` 中为两个不同区块(`first_block_id`、`second_block_id`)签署了投票。处罚等同于漏块(`total_missed++`,`penalty_percent += validator_miss_penalty_percent`,持续 `validator_miss_penalty_duration`);每轮最多一次(`last_equivocation_round`) | + --- 参见:[操作概述](./operations/overview.md)、[奖励](./operations/awards.md)、[委员会](./operations/committee.md)。 diff --git a/CMakeLists.txt b/CMakeLists.txt index 3ced54bbfa..92ca35366c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -85,6 +85,12 @@ if(BUILD_TESTNET) message(STATUS " ") set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -DBUILD_TESTNET") set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -DBUILD_TESTNET") + # Multi-validator testnet (see config_testnet.hpp); empty = one validator. + set(CHAIN_TESTNET_VALIDATORS "" CACHE STRING "Number of genesis validators on the test network") + if(CHAIN_TESTNET_VALIDATORS) + message(STATUS "CHAIN_TESTNET_VALIDATORS: ${CHAIN_TESTNET_VALIDATORS}") + add_definitions(-DCHAIN_TESTNET_VALIDATORS=${CHAIN_TESTNET_VALIDATORS}) + endif() endif() @@ -280,6 +286,9 @@ if(BUILD_CONSENSUS_TESTS) enable_testing() add_subdirectory(tests/consensus_sim) add_subdirectory(tests/pm) + add_subdirectory(tests/node_admin) + add_subdirectory(tests/logging) + add_subdirectory(tests/webserver) endif() if(ENABLE_INSTALLER) diff --git a/README.md b/README.md index edcf96eae4..8c65adeb61 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Example run: ``` docker run \ - -d -p 2001:2001 -p 8090:8090 -p 8091:8091 --name vizd \ + -d --restart unless-stopped -p 2001:2001 -p 8090:8090 -p 8091:8091 --name vizd \ vizblockchain/vizd:latest docker logs -f vizd diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 95e4a83d80..378196e3fa 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -78,6 +78,7 @@ interface SidebarLabels { jsonRpc: string; cliWallet: string; clientLibraries: string; + idsAndHashes: string; development: string; buildingDev: string; testing: string; @@ -166,6 +167,7 @@ const en: SidebarLabels = { jsonRpc: 'JSON-RPC', cliWallet: 'CLI Wallet', clientLibraries: 'Client Libraries', + idsAndHashes: 'Transaction and Block IDs', development: 'Development', buildingDev: 'Building', testing: 'Testing', @@ -254,6 +256,7 @@ const ru: SidebarLabels = { jsonRpc: 'JSON-RPC', cliWallet: 'CLI-кошелёк', clientLibraries: 'Клиентские библиотеки', + idsAndHashes: 'Идентификаторы транзакций и блоков', development: 'Разработка', buildingDev: 'Сборка', testing: 'Тестирование', @@ -342,6 +345,7 @@ const zhCN: SidebarLabels = { jsonRpc: 'JSON-RPC', cliWallet: 'CLI 钱包', clientLibraries: '客户端库', + idsAndHashes: '交易与区块 ID', development: '开发', buildingDev: '构建', testing: '测试', @@ -401,6 +405,7 @@ function buildSidebar(t: SidebarLabels, prefix: string): DefaultTheme.SidebarIte { text: t.consensus, items: [ + { text: 'HF16 / 4.2.0', link: p('/consensus/hf16') }, { text: t.fairDpos, link: p('/consensus/fair-dpos') }, { text: t.blockProcessing, link: p('/consensus/block-processing') }, { text: t.forkResolution, link: p('/consensus/fork-resolution') }, @@ -429,6 +434,7 @@ function buildSidebar(t: SidebarLabels, prefix: string): DefaultTheme.SidebarIte { text: t.databaseApi, link: p('/plugins/database-api') }, { text: t.predictionMarketApi, link: p('/plugins/prediction-market-api') }, { text: t.webserver, link: p('/plugins/webserver') }, + { text: 'Node Admin · UI · MCP', link: p('/plugins/node-admin') }, ], }, { @@ -482,6 +488,7 @@ function buildSidebar(t: SidebarLabels, prefix: string): DefaultTheme.SidebarIte { text: t.jsonRpc, link: p('/api/json-rpc') }, { text: t.cliWallet, link: p('/api/cli-wallet') }, { text: t.clientLibraries, link: p('/api/client-libraries') }, + { text: t.idsAndHashes, link: p('/api/ids-and-hashes') }, ], }, { diff --git a/docs/api/client-libraries.md b/docs/api/client-libraries.md index 33d5673cae..28c5491792 100644 --- a/docs/api/client-libraries.md +++ b/docs/api/client-libraries.md @@ -225,3 +225,7 @@ let shares = VIZ.Asset(1000.0, .vests) // "1000.000000 VESTS" --- See also: [JSON-RPC API](./json-rpc.md), [CLI Wallet](./cli-wallet.md). + +## HF16 compatibility + +JS 0.18.5 is published to npm and supports QC/TC/equivocation block headers. PHP supports the unchanged transaction format and passes new header JSON through. Python 1.4.0 source and dependencies passed Python 3.10–3.14 CI; PyPI publication is separate. DAO clients must use the node’s current-supply ceiling, not a hard-coded 500,000 VIZ. See [HF16](../consensus/hf16.md). diff --git a/docs/api/ids-and-hashes.md b/docs/api/ids-and-hashes.md new file mode 100644 index 0000000000..918f31515c --- /dev/null +++ b/docs/api/ids-and-hashes.md @@ -0,0 +1,30 @@ +# Transaction and block ids + +VIZ inherited its id types from Graphene (BitShares, then Steem). In the source they are declared +as `fc::ripemd160`, and older documentation and libraries call them "ripemd160 ids". That is only +the name of the 20-byte container they are stored in: **neither id is a ripemd160 digest.** +The node declares them through the alias `id160_type` to say so. + +| Value | How it is computed | Size | +|---|---|---| +| `transaction_id` | first 20 bytes of `sha256(packed transaction)`, signatures **not** included | 20 bytes | +| `block_id` | first 20 bytes of `sha224(packed block header)`, then bytes 0–3 replaced by the block number (big-endian) | 20 bytes | +| `transaction_merkle_root` | `ripemd160` of the root of a sha256 tree over the signed transactions | 20 bytes | + +The merkle root is the one value that really is a ripemd160. + +## Consequences for clients + +* To compute a transaction id, serialize the transaction **without** `signatures`, take sha256 and + keep the first 20 bytes. Do not run ripemd160 — the result would never match the node. +* The block number can be read straight from a block id: it is the first 4 bytes, big-endian. + `ref_block_num` / `ref_block_prefix` (TaPoS) use the low 16 bits of that number and bytes 4–7 of + the block id. +* The ids are 20 bytes in every API answer, in the block log and in account history. Changing the + hash (to a real ripemd160 or to a full 32-byte sha256) would be a hardfork that splits history in + two id formats, for no security gain: 160 bits is plenty for an identifier, and signatures are + made over the full sha256 digest, not over the id. + +Source: `transaction::id()` in `libraries/protocol/transaction.cpp`, `signed_block_header::id()` +and `signed_block::calculate_merkle_root()` in `libraries/protocol/block.cpp`, the types in +`libraries/protocol/include/graphene/protocol/types.hpp`. diff --git a/docs/consensus/block-processing.md b/docs/consensus/block-processing.md index e1065b77f9..0dac245cc6 100644 --- a/docs/consensus/block-processing.md +++ b/docs/consensus/block-processing.md @@ -1,5 +1,8 @@ # Block Processing +> **HF16 / 4.2.0:** the 1-second slot, QC/TC and 2-chain rules are described in [HF16](./hf16.md). Legacy 3-second/14-confirmation descriptions below apply before activation, not as the HF16 QC quorum. + + Internal mechanics of block application, pending transaction management, and fork switching. --- @@ -298,6 +301,7 @@ Classic DPOS path (~63 s) remains active as a fallback if confirmation messages ### Constraints - Only validators in the **current shuffled schedule** count toward the 2/3 threshold; off-schedule validators are skipped. +- **One vote per round (Jolteon safety rule).** A validator votes for a block of round *r* (round = the block's slot, `timestamp / CHAIN_BLOCK_INTERVAL`) only when *r* is above the highest round it has voted in. The validator plugin keeps that round per validator in `/block-vote-round.txt`, writes it **before** the vote leaves (a vote whose round cannot be persisted is not sent) and logs `Not voting for … already voted for … in round …` when a different block of the same round shows up. Unlike a one-signature-per-height rule, a validator may vote for a different block at the same height in a later round — that is what lets a split vote resolve instead of stalling. Two conflicting quorum certificates in one round need validators that vote twice; nodes log such votes as `Equivocation: …`. Re-broadcasting the same vote stays allowed. - During **emergency consensus** (`emergency_consensus_active = true`), `check_block_post_validation_chain()` returns immediately — LIB is advanced solely by the classic path to avoid deadlocking recovery. - The confirmation list is capped at **20 entries** (`CHAIN_MAX_BLOCK_POST_VALIDATION_COUNT`). Entries below the current LIB are pruned after every block. @@ -311,7 +315,7 @@ Classic DPOS path (~63 s) remains active as a fallback if confirmation messages | `CHAIN_BLOCK_GENERATION_POSTPONED_TX_LIMIT` | 5 | Max consecutive oversized transactions skipped during generation | | `CHAIN_BLOCK_SIZE` | 65536 bytes | Hard block size limit | | `maximum_block_size` | Dynamic (validator median) | Soft block size limit | -| `CHAIN_BLOCK_INTERVAL` | 3 s | Block production interval | +| `CHAIN_BLOCK_INTERVAL` | 3 s before HF16; runtime 1 s after | Block production interval | --- diff --git a/docs/consensus/emergency-consensus.md b/docs/consensus/emergency-consensus.md index 91d99ee370..c9e80fa371 100644 --- a/docs/consensus/emergency-consensus.md +++ b/docs/consensus/emergency-consensus.md @@ -1,6 +1,6 @@ # Emergency Consensus Mode -Emergency consensus mode (introduced in HF12) activates automatically when the network stalls for 1 hour. A special "committee" validator takes over block production to maintain chain continuity until real validators restore their signing keys. +Emergency consensus mode (introduced in HF12) activates automatically when the network stalls for 20 minutes. A special "committee" validator takes over block production to maintain chain continuity until real validators restore their signing keys. --- @@ -8,7 +8,7 @@ Emergency consensus mode (introduced in HF12) activates automatically when the n | Constant | Value | Meaning | |----------|-------|---------| -| `CHAIN_EMERGENCY_CONSENSUS_TIMEOUT_SEC` | 3600 s | Inactivity time before activation | +| `CHAIN_EMERGENCY_CONSENSUS_TIMEOUT_SEC` | 1200 s | Inactivity time before activation | | `CHAIN_EMERGENCY_VALIDATOR_ACCOUNT` | `"committee"` | Emergency block producer account | | `CHAIN_EMERGENCY_VALIDATOR_PUBLIC_KEY` | `VIZ75CR...` | Deterministic emergency signing key | | `CHAIN_EMERGENCY_EXIT_NORMAL_BLOCKS` | 21 | Consecutive real-validator blocks for exit | @@ -31,7 +31,7 @@ Emergency consensus mode (introduced in HF12) activates automatically when the n 1. **HF12 gate** — skip if the hardfork has not activated. 2. **Already active** — skip if emergency mode is already on. 3. **LIB block available in block log** — skip if the LIB block is not present in the block log (DLT nodes after snapshot restore have an empty block log; a missing LIB block would compute millions of stale seconds and trigger a false activation deadlock). -4. **Timeout** — compute `seconds_since_lib = current_block.timestamp − lib_block.timestamp`. If `< 3600`, skip. +4. **Timeout** — compute `seconds_since_lib = current_block.timestamp − lib_block.timestamp`. If `< 1200`, skip. All checks use only block-embedded timestamps — no wall clock, no skip flags. This guarantees identical deterministic activation on every node, including replays. diff --git a/docs/consensus/fair-dpos.md b/docs/consensus/fair-dpos.md index fd68413d2d..e799ddfd76 100644 --- a/docs/consensus/fair-dpos.md +++ b/docs/consensus/fair-dpos.md @@ -1,5 +1,8 @@ # Fair-DPOS Consensus +> **HF16 / 4.2.0:** the 1-second slot, QC/TC and 2-chain rules are described in [HF16](./hf16.md). Legacy 3-second/14-confirmation descriptions below apply before activation, not as the HF16 QC quorum. + + VIZ Ledger uses **Fair Delegated Proof of Stake (Fair-DPOS)**, an extension of the classic DPoS algorithm that adds participation enforcement and miss penalties to prevent validators from collecting rewards without actually producing blocks. --- @@ -9,7 +12,7 @@ VIZ Ledger uses **Fair Delegated Proof of Stake (Fair-DPOS)**, an extension of t In standard DPoS: - Token holders vote for validator accounts (weighted by their SHARES). - The top-voted validators are scheduled to produce blocks in a round-robin schedule. -- Every 3 seconds one slot fires; the scheduled validator either produces a block or misses the slot. +- Every 3 seconds before HF16 (1 second after activation) one slot fires; the scheduled validator either produces a block or misses the slot. VIZ runs with **21 active validators** per schedule round. @@ -167,7 +170,7 @@ See [Fork Resolution](./fork-resolution.md) for the full fork collision algorith | `enable-stale-production` | `false` | Bypass participation check (testnet only) | | `emergency-private-key` | — | Optional emergency consensus signing key | | Active validators | 21 | Hardcoded in `CHAIN_MAX_VALIDATORS` | -| Block interval | 3 s | `CHAIN_BLOCK_INTERVAL` | +| Block interval | 3 s before HF16; 1 s after | runtime `get_config` | | LIB threshold | ⌈21 × 2/3⌉ = 14 | Blocks confirming irreversibility | | Emergency timeout | 3600 s | `CHAIN_EMERGENCY_CONSENSUS_TIMEOUT_SEC` | diff --git a/docs/consensus/fork-resolution.md b/docs/consensus/fork-resolution.md index 779710fbd7..4dcdbffe60 100644 --- a/docs/consensus/fork-resolution.md +++ b/docs/consensus/fork-resolution.md @@ -1,5 +1,8 @@ # Fork Resolution +> **HF16 / 4.2.0:** the 1-second slot, QC/TC and 2-chain rules are described in [HF16](./hf16.md). Legacy 3-second/14-confirmation descriptions below apply before activation, not as the HF16 QC quorum. + + This page covers how VIZ Ledger detects, selects, and resolves competing forks — from the basic fork database to the HF12 vote-weighted collision resolver. --- diff --git a/docs/consensus/hardforks.md b/docs/consensus/hardforks.md index e56d7c35f3..c0ecc132c5 100644 --- a/docs/consensus/hardforks.md +++ b/docs/consensus/hardforks.md @@ -1,5 +1,8 @@ # Hardforks +> **HF16 / 4.2.0:** the 1-second slot, QC/TC and 2-chain rules are described in [HF16](./hf16.md). Legacy 3-second/14-confirmation descriptions below apply before activation, not as the HF16 QC quorum. + + A hardfork is a network upgrade that changes consensus rules. All nodes must upgrade before the scheduled activation timestamp; nodes running old software will diverge from the network after activation. --- @@ -9,7 +12,7 @@ A hardfork is a network upgrade that changes consensus rules. All nodes must upg Each hardfork has: - A **unique number** (N). - A **Unix timestamp** — the earliest wall-clock time at which the hardfork can activate. -- A **validator vote supermajority** — >80% of the current validator set must signal the new hardfork version via `validator_update_operation`. +- A **validator vote supermajority** — >80% of the current validator set must signal the new hardfork version via block-header `hardfork_version_vote` (normally emitted automatically by the validator plugin). Both conditions must be satisfied simultaneously. Validators can block an unwanted hardfork by withholding their version vote even after the scheduled timestamp. @@ -32,7 +35,7 @@ HF12 (version 3.1.0) introduced: 1. **Fork collision counter** — `fork_collision_count` and `last_fork_collision_block_num` added to `dynamic_global_property_object`. Observable via `get_dynamic_global_properties`. 2. **Vote-weighted fork comparison** (`compare_fork_branches()`) — fork selection uses total delegated SHARES per validator branch + 10% bonus for longer chain. -3. **Emergency consensus mode** — activates automatically after 1 hour with no blocks; "committee" account takes all 21 slots. See [Emergency Consensus](./emergency-consensus.md). +3. **Emergency consensus mode** — activates automatically after 20 minutes with no blocks; "committee" account takes all 21 slots. See [Emergency Consensus](./emergency-consensus.md). 4. **Minority fork auto-resync** — validator plugin detects node isolation (21 consecutive own-blocks) and rolls back to LIB. 5. **NTP improvements** — dedicated NTP client with configurable servers, interval, and round-trip threshold. @@ -46,7 +49,7 @@ HF13 (version 3.2.0) introduced: - New field on `validator_object`: `reward_percent` — fraction of block reward shared with voters (0–10000 basis points). - New virtual operation: `validator_reward_virtual_operation` — fired once per reward distribution. -- Set via `validator_update_operation`. +- Set via block-header `hardfork_version_vote` (normally emitted automatically by the validator plugin). --- @@ -169,3 +172,11 @@ Normal restart: --- See also: [Fair-DPOS](./fair-dpos.md), [Emergency Consensus](./emergency-consensus.md), [Snapshots](../node/snapshot.md). + +## Release 4.x + +| HF | Version | Scope | +|---|---|---| +| 14 | 4.0.0 | Prediction markets and governance | +| 15 | 4.1.0 | PM audit fixes, agent access and permanent key history | +| 16 | 4.2.0 | QC/TC, 1-second slot, DAO ceiling and no-contest fee freeze; [activation details](./hf16.md) | diff --git a/docs/consensus/hf16.md b/docs/consensus/hf16.md new file mode 100644 index 0000000000..f88d4dbbff --- /dev/null +++ b/docs/consensus/hf16.md @@ -0,0 +1,48 @@ +# HF16 / release 4.2.0 + +## Activation and rollout + +HF16 is scheduled for **2026-10-31 00:00:00 UTC** (`1793404800`), version **4.2.0**. Activation also requires the production hardfork voting threshold; merely installing the binary does not activate the fork early. Update validators before the activation date. Use a verified **production** image, not a BUILD_TESTNET image (its chain identity and quorum differ). + +After the master image is built, check its revision/version, startup, synchronization and block production before distributing that exact image/digest. Keep the configured validator key and data-directory backup. Do not delete the data directory as an update procedure. + +## Slots, QC, TC and finality + +- The runtime slot changes from **3 seconds to 1 second** at HF16. Use the node's current `get_config` value and block timestamps; the compile-time legacy `CHAIN_BLOCK_INTERVAL` is not the post-fork slot. +- A QC collects signatures from scheduled validators. The implementation uses `max(floor(2N/3)+1, floor(0.75N))`: **15 of 21**, not the old 14-confirmation post-validation description. +- A producer handover carries a parent QC. Timeout certificates (TCs) allow progress after skipped rounds. LIB uses the **2-chain** rule: certified parent and child on the local main branch, adjacent heights and consecutive rounds. One isolated QC is insufficient. +- Votes are limited to one block per validator per round; the highest voted round is persisted before broadcasting. Repeating the same vote is allowed. Conflicting votes can be proved by header evidence and produce virtual operation `validator_equivocation` (ID **106**). +- Header extension tags are append-only: **3 QC**, **4 TC**, **5 equivocation proof**. Transaction extensions and existing operation IDs are unchanged. +- Block-denominated governance values whose wall-time meaning must survive the slot change are scaled at activation. Do not mechanically replace every 3-second timer: independent retry/shutdown timers remain separate. + +## Recovery and emergency mode + +Normal recovery after transient connectivity loss does not require a restart or an emergency key. Acceptance on 21 validators covered six temporarily unreachable validators, 20% loss with 150±50 ms delay, a full outage lasting 241 seconds, and a 10/11 partition. After healing, all 21 nodes finalized the same checked block. These are tested scenarios, not a guarantee of bounded recovery under every attack. + +Deferred-chain catch-up now retains the LIB ancestors needed when fork_db is ahead of the applied state. Emergency consensus is separate: its configured timeout remains **1200 seconds (20 minutes)**. The short-outage tests do not reduce that threshold to five minutes. See [Emergency consensus](./emergency-consensus.md). + +## DAO and markets + +At request creation, the DAO maximum is `floor(current_supply.amount / 100)` in raw units: **1% of current total VIZ supply**, rounded down to 0.001 VIZ. It is recomputed for each request, not based on initial supply or the committee fund balance. Before HF16, replay retains the old **500,000 VIZ** ceiling. Existing request amounts are not rewritten. See [Committee operations](../protocol/operations/committee.md). + +For no-contest declared under HF16, the nominal penalty is frozen at announcement. Settlement applies the insurance cap; legacy declarations retain the old calculation. A frozen zero is distinct from a fee never frozen. See [No-contest fee freeze](../prediction-markets/pm-no-contest-fee-freeze.md). + +## Operator UI and MCP + +The optional `node_admin` plugin provides a built-in browser UI at `/ui/` and an HTTP MCP endpoint on its separate admin listener. It exposes health, diagnostics, plugins, logs, audit, P2P, snapshots, validator operations and config plans. Bearer tokens restrict actions by scope; configuration/plugin changes use reviewable `plan` / `apply`. Listen on loopback by default and use a tunnel for remote operation. Do not expose the admin listener as the public chain API. See [Node administration](../plugins/node-admin.md). + +## Client libraries + +JS **0.18.5** supports the new block-header formats and is published to npm. PHP keeps the existing transaction format and passes header JSON through. Python **1.4.0** source/dependencies passed CI on Python 3.10–3.14; PyPI publication is a separate step. Clients must not enforce the obsolete fixed DAO ceiling. + +## Emission and bounded vote history + +HF16 deliberately retains **1.000 VIZ minted per produced block** (owner decision Q3105=C). At uninterrupted production, 1-second slots therefore increase gross annual issuance from about 10.5 million to 31.5 million VIZ. This is a monetary-policy change, not an unscaled constant to divide by three. Missed slots do not mint a block reward. + +Vote/equivocation history retains its one-hour wall-time window: `3600 / block_interval()` rounds (3600 after HF16). This is distinct from the emergency timeout and from any recovery SLA. Key-blanking block thresholds scale by three at HF16, preserving the previous roughly 10-minute normal and 5-minute emergency durations. The minimum distribution epoch duration is also preserved: new property votes must meet the HF16-scaled lower bound, while pre-fork replay retains the old bound. + +## Re-voting block parameters + +Stored and voted properties remain **native block counts**, per the selected HF16 migration policy. The one-time migration scales existing votes and the median by three; it cannot identify a later vote copied from an old script. Validators must update hard-coded `inflation_recalc_period`, `distribution_epoch_length`, `pm_batch_epoch_blocks` and `pm_reveal_window_blocks` by three to preserve time. Alternatively, use live `get_config.CHAIN_DEFAULT_CHAIN_PROPERTIES` when intentionally voting defaults, or preserve the current on-chain properties when editing unrelated fields. Do not re-vote stale struct defaults. + +`get_config` reports consistent live blocks/hour/day/year, undo capacity, missed-block thresholds and default properties. The reserve-ratio adjustment retains its 60-second cadence. `CHAIN_EMERGENCY_EXIT_NORMAL_BLOCKS` is an unused legacy constant; actual emergency exit depends on the active-validator schedule quorum, not a 21-block timer. diff --git a/docs/governance/committee.md b/docs/governance/committee.md index fcc743ddc1..a8c564a56f 100644 --- a/docs/governance/committee.md +++ b/docs/governance/committee.md @@ -114,3 +114,5 @@ Virtual operations fired during lifecycle: --- See also: [Committee Operations](../protocol/operations/committee.md), [Staking and DAO](./staking-and-dao.md), [Chain Properties](./chain-properties.md), [Virtual Operations](../protocol/virtual-operations.md). + +**HF16 limit:** at creation, `required_amount_max.amount <= floor(current_supply.amount / 100)` in raw units (1% of current total VIZ, rounded down). Before HF16 the limit is 500,000 VIZ. This is not 1% of the committee fund; existing requests are not repriced. See [HF16](../consensus/hf16.md). diff --git a/docs/node/configuration.md b/docs/node/configuration.md index 7a892d090e..98ff0d0ef5 100644 --- a/docs/node/configuration.md +++ b/docs/node/configuration.md @@ -197,6 +197,28 @@ logger.p2p.level = warn logger.p2p.appenders = p2p ``` +The `file` appender is configured to rotate by time (a new file every hour, kept for a +day), but the rotation only fires while the fc fiber queue is serviced, and `vizd` does not +service it after startup (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). +Do not count on it to bound a file, and be aware that at `debug` a node can write +gigabytes per hour. If all you need is the recent tail, use the optional capped appender: +a single file that keeps only the last `max_lines` lines (default 10000, `0` = unlimited). +It counts lines as it writes and compacts itself in place, so the file stays between +`max_lines` and about 1.1 x `max_lines`, with no timer involved. Nodes whose config has no +`log.capped_file_appender.*` section behave exactly as before, and the section itself is +safe to roll out ahead of the upgrade: an older binary ignores it and keeps logging to +stderr. If the configured path cannot be opened, the node still starts, prints one +`capped_file_appender: cannot open log file …` line to stderr, and simply leaves no file. + +```ini +log.capped_file_appender.node.filename = logs/node/node.log +log.capped_file_appender.node.max_lines = 10000 + +# Route whatever you want into it; the appender alone writes nothing. +logger.default.appenders = stderr, node +``` + --- ## Complete Reference diff --git a/docs/node/getting-started.md b/docs/node/getting-started.md index 50245fbc80..89aecbd52b 100644 --- a/docs/node/getting-started.md +++ b/docs/node/getting-started.md @@ -37,6 +37,7 @@ docker pull vizblockchain/vizd:latest ```bash docker run -d \ --name vizd \ + --restart unless-stopped \ -p 2001:2001 \ -p 8090:8090 \ -p 8091:8091 \ @@ -160,7 +161,7 @@ curl -s -X POST http://localhost:8090 \ | python3 -m json.tool ``` -Check `head_block_number` — it should increase every 3 seconds once synced. +Check `head_block_number` — it should increase every 3 seconds before HF16 and every 1 second after HF16 once synced. --- diff --git a/docs/node/monitoring.md b/docs/node/monitoring.md index 1da6daca8d..7583dbe9c0 100644 --- a/docs/node/monitoring.md +++ b/docs/node/monitoring.md @@ -15,7 +15,7 @@ curl -s -X POST http://localhost:8090 \ | python3 -m json.tool ``` -Check `head_block_number` — it must increase every 3 seconds while synced. Check `time` — it must be within a few seconds of wall clock. +Check `head_block_number` — it must increase every 3 seconds before HF16 and every 1 second after HF16 while synced. Check `time` — it must be within a few seconds of wall clock. A simple liveness probe script: @@ -184,7 +184,7 @@ while True: | Panel | Metric / Source | |-------|----------------| -| Head block | `viz_head_block_number` (increases every 3 s when synced) | +| Head block | `viz_head_block_number` (3 s before HF16, 1 s after; when synced) | | Block lag | `time() - viz_head_block_time` (seconds behind wall clock) | | Peer count | Parsed from P2P stats log | | Peer latency | P2P stats log, by peer IP | diff --git a/docs/node/snapshot.md b/docs/node/snapshot.md index 679189c2cd..44ca0fbebd 100644 --- a/docs/node/snapshot.md +++ b/docs/node/snapshot.md @@ -82,7 +82,7 @@ When block 5,000,000 is applied, the snapshot is written to `/data/snapshots/sna ```ini plugin = snapshot -snapshot-every-n-blocks = 28800 # ~24 hours at 3 s/block +snapshot-every-n-blocks = 28800 # ~24 h before HF16; ~8 h after (1 s/block) snapshot-dir = /data/snapshots snapshot-max-age-days = 90 ``` diff --git a/docs/node/validator-node.md b/docs/node/validator-node.md index 6aab9535e6..0deb207b39 100644 --- a/docs/node/validator-node.md +++ b/docs/node/validator-node.md @@ -1,6 +1,6 @@ # Running a Validator Node -Validators (block producers) are accounts scheduled by the Fair-DPOS algorithm to produce blocks every 3 seconds. Running a validator node requires a registered validator account, a signing key, and a properly configured node. +Validators (block producers) are accounts scheduled by the Fair-DPOS algorithm to produce blocks every 3 seconds before HF16 and every 1 second after HF16. Running a validator node requires a registered validator account, a signing key, and a properly configured node. --- diff --git a/docs/plugins/node-admin.md b/docs/plugins/node-admin.md new file mode 100644 index 0000000000..847c36840c --- /dev/null +++ b/docs/plugins/node-admin.md @@ -0,0 +1,1179 @@ +# Node Admin API (`node_admin`) + +Administrative JSON-RPC endpoint for the node process itself: health, configuration, +plugin state, logs, the audit trail and P2P peer control. It is meant for operator tooling +and AI agents holding a bearer token — not for end users, and not as a replacement for the +public API. The same listener also serves an **MCP** (Model Context Protocol) endpoint over +HTTP and a small built-in web UI at `/ui/`. + +> **Mutating surface: P2P peers, snapshots, validator keys, webserver policy, `config.ini` +> and the process.** `admin.p2p.add_node`, `admin.p2p.add_trusted` and +> `admin.p2p.clear_banned` change whom the node dials; `admin.snapshot.create` and +> `admin.snapshot.restore` write a snapshot file or replace the node's in-memory state; +> `admin.validator.*` enable/disable validators and change their signing keys by signing +> on-chain transactions; `admin.webserver.*` mutating methods change rate limits, the stats +> switch and the IP allow/deny lists; `admin.config.*` and `admin.plugins.*` edit `config.ini` +> through a two-phase `plan`/`apply`; `admin.restart` asks the process to exit. They require a +> token with the `p2p`, `snapshot`, `chain`, `webserver`, `config`, `plugins` or `process` +> scope respectively. Peer actions are serialized onto the P2P thread; validator actions are +> signed by the `validator_guard` plugin and broadcast to the network; webserver policy +> changes apply to the very next request; config/plugin edits are read at startup and +> therefore need a restart. + +--- + +## Why a separate listener + +The public webserver API is normally published through nginx. Inside the node every such +request arrives **from 127.0.0.1**, so checking the source address on the public endpoint +protects nothing. `node_admin` therefore opens its own HTTP listener, bound to loopback by +default, that is never proxied. + +An endpoint on a non-loopback address is refused unless `admin-allow-remote = true` is set +explicitly. A bearer token on an open port is a different threat model, not a convenience. + +--- + +## Configuration + +```ini +plugin = node_admin + +admin-endpoint = 127.0.0.1:8099 +admin-token = agent:read:3f8c…(64 hex)…e1 +admin-audit-log = /var/lib/vizd/node-admin-audit.log +admin-log-file = node=/var/log/vizd/vizd.log +``` + +| Option | Default | Description | +|---|---|---| +| `admin-endpoint` | — | Listen address. Empty/absent: the plugin is inert. | +| `admin-token` | — | `label:scope[,scope]:sha256hex[:expires-unixtime]`. Repeatable. | +| `admin-allow-remote` | `false` | Permit a non-loopback `admin-endpoint`. | +| `admin-audit-log` | next to `config.ini` | Append-only audit log (0600, rotated at 16 MB). | +| `admin-log-file` | — | `name=path` entries that `admin.logs.tail` may read. Repeatable. | +| `admin-plan-ttl` | `120` | Seconds a `plan_id` stays valid before `apply` refuses it. | + +The plugin refuses to start if `admin-endpoint` is set but no token is configured: an +unauthenticated admin port is never a valid state. + +The Logs tab reads a **file**, not the container's stdout. Nodes that log only to stderr +inside Docker need an appender that writes the same lines to disk, and that file is what +`admin-log-file` names: + +```ini +[log.file_appender.node] +filename = /var/lib/vizd/logs/node.log + +[logger.default] +level = info +appenders = stderr,node + +admin-log-file = node=/var/lib/vizd/logs/node.log +``` + +Use the section form: flat `logger.default.*` keys are merged first and a later +`[logger.default]` section wins, so a flat line added on top of an existing section is +silently ignored. The file appender is configured to rotate hourly with one-day retention, +but that rotation does not actually happen in vizd: the chain runs on the fc fiber queue, +and the node's main thread lives in `boost/asio` after startup and never services it +(measured 2026-10-20 — one 37 MB file in nine hours, no hourly files). So the file is +bounded by neither time nor size; on `debug` it can reach gigabytes. For a hard limit use +the optional `capped_file` appender: one file holding only the last `max_lines` lines +(default 10000, `0` = unlimited). + +```ini +[log.capped_file_appender.node] +filename = /var/lib/vizd/logs/node.log +max_lines = 10000 +``` + +Point `admin-log-file` at the same path and the Logs tab reads it unchanged. The appender is +optional: nodes without the section, and older binaries that do not know the type, keep the +stock behaviour. + +### Tokens + +A token is 32 random bytes, for example `openssl rand -hex 32`. Only its SHA-256 is stored +in the configuration; comparison is constant-time. Scopes: + +* `read` — every method, including `admin.p2p.status` and the snapshot download route. Every token needs it. +* `p2p` — the mutating peer methods `admin.p2p.add_node`, `admin.p2p.add_trusted`, + `admin.p2p.clear_banned`. A token with only `p2p` (without `read`) cannot call anything. +* `snapshot` — `admin.snapshot.create` and `admin.snapshot.restore`. Listing and downloading + snapshots need only `read`. +* `webserver` — the mutating `admin.webserver.*` methods (limits, stats switch, IP lists, + reset, reload). `admin.webserver.status` and `admin.webserver.ips` need only `read`. +* `config` — `admin.config.plan` and `admin.config.apply` (editing allow-listed `config.ini` + keys). `admin.config.backups` needs only `read`. +* `plugins` — `admin.plugins.plan` and `admin.plugins.apply` (editing the `plugin =` list). +* `process` — `admin.restart` and `admin.objects.repair`. Both shut the process down; a + supervisor must start it again. Repair additionally leaves a marker that rewrites state at + the next startup, so the scope is state-write, not just availability. +* `chain` — `admin.validator.plan` and `admin.validator.apply` (enabling/disabling validators + and changing their signing keys). These calls are **only accepted while `validator_guard` + is loaded, started and configured with an active key** — that plugin holds the key that + signs the chain transaction, and it never exposes the key through this API. Without a live + guard, a `chain` token can call nothing: `admin.validator.list` stays readable with `read` + and reports why the mutating calls are unavailable. + +Revocation is editing the line and restarting. `expires-unixtime` (optional) makes the +token invalid after that moment without a restart. + +--- + +## Protocol + +`POST` with `Content-Type: application/json` and `Authorization: Bearer `. +Body ≤ 64 KB. Responses are JSON-RPC 2.0: + +```json +{"jsonrpc":"2.0","id":1,"result":{...}} +{"jsonrpc":"2.0","id":1,"error":{"code":-32002,"message":"forbidden", + "data":{"reason":"token 'agent' has no 'read' scope"}}} +``` + +| Code | Meaning | +|---|---| +| `-32700` | Parse error | +| `-32600` | Invalid request | +| `-32601` | Method not found | +| `-32602` | Invalid params | +| `-32001` | Unauthorized (HTTP 401) | +| `-32002` | Missing scope (HTTP 200) | +| `-32004` | Unknown/not allowed name (e.g. log file) | +| `-32005` | Rate limit: 20 requests/second per token (HTTP 429) | +| `-32099` | Internal error | + +Authentication is checked **before** the body is parsed. + +The non-`POST` routes are the built-in UI and snapshot downloads: + +* `GET /ui/` (and `GET /ui`) serves the built-in page described below. It carries no data and + no secret; every actual call the page makes is a normal authenticated `POST` back to `/`. +* `GET /snapshot/latest` returns the newest snapshot as a file attachment. `GET /snapshot/` + returns a named file from the snapshot directory. Both require `Authorization: Bearer …` + and the `read` scope — unlike `/ui/`, a snapshot is node state. `HEAD` works on both and + carries the same headers, including `Content-Length`. Any other method or path answers `405`. + +Downloads require a regular file directly in the snapshot directory (names come from +`admin.snapshot.status`; traversal is rejected). Files stream in 64 KiB chunks with +backpressure and no file-size ceiling. A stalled socket write times out after 30 seconds; +large downloads do not buffer the entire snapshot or block other admin requests. + +--- + +## Methods + +### `admin.ping` + +```json +{"ok": true, "server_time": "2026-10-19T14:40:00Z"} +``` + +Liveness check; does not touch the chain database. + +### `admin.auth.me` + +No params. Returns the identity the presented token carries — its `label`, its `scopes` and +its `expires` (unixtime, `null` when the token does not expire). The token and its hash are +**never** returned. The built-in UI uses this to hide actions the token cannot perform; an +agent can use it to discover its own permissions before planning a call. + +```json +{"label":"ui-read","scopes":["read"],"expires":null} +``` + +### `admin.node_info` + +| Field | Meaning | +|---|---| +| `version`, `hardfork_count` | Build version and number of hardforks known to the binary | +| `chain_id` | Chain identifier | +| `head_block_num`, `head_block_id`, `head_block_time` | Current head | +| `head_age_sec` | Wall-clock age of the head block; `null` on an empty chain (head time there is 1970, so an "age" would be meaningless) | +| `last_irreversible_block_num` | Last irreversible block | +| `emergency_consensus_active`, `emergency_consensus_start_block` | Chain-wide emergency consensus state and the block that started it (`0` when inactive) | +| `emergency_key_configured` | The validator plugin has an emergency key loaded | +| `recovering` | Chain plugin is recovering shared memory | +| `uptime_sec` | Time since the admin listener started | +| `server_time` | UTC now | + +### `admin.diagnostics` + +Everything from `admin.node_info`, plus: + +| Field | Meaning | +|---|---| +| `data_dir`, `config_path` | Paths in use | +| `p2p_loaded`, `peers` | P2P plugin present, active connection count (read on the p2p thread) | +| `plugins_configured`, `plugins_observed` | `plugin =` entries in `config.ini`, and how many of those have their lifecycle state read directly (see `admin.plugins.list`) | +| `shared_memory_bytes` | Size of `shared_memory.bin` | +| `disk_total_bytes`, `disk_free_bytes` | Filesystem of `data_dir` (POSIX) | +| `rss_peak_kb` | Peak RSS (POSIX) | +| `loadavg` | 1/5/15-minute load average (POSIX) | + +Numbers are emitted as JSON numbers, not strings: fc's default formatter quotes int64 values +above 2^32-1 and every double, which would turn byte counts and load averages into text. + +### `admin.plugins.list` + +Lists the deduplicated plugins named by `plugin = …` lines in `config.ini`. A line may carry +several names (`plugin = chain p2p`), exactly as AppBase reads it, so each name gets its own +row. AppBase keeps its plugin +registry private (name-based lookup is protected and there is no enumeration API), so the +config file is the population this method can see; plugins enabled only on the command line +are not listed. + +Each entry carries `loaded`, `state` and `state_source`: + +* `state_source: "observed"` — the lifecycle state was read from the running plugin + (`chain`, `p2p`, `snapshot`, `webserver`, `validator`, `validator_guard`, `node_admin`). + `state` is one of `registered`, `initialized`, `started`, `stopped`; `loaded` is false only + while the plugin is still merely `registered`. +* `state_source: "inferred"` — AppBase initializes every `plugin =` line and aborts startup + when a name is unknown, so a configured plugin on a running node is loaded. `state` is + `null` rather than a guess. + +Two further fields exist for the operator panel: + +* `removable` — `false` for `chain` and `node_admin`, which `admin.plugins.plan` refuses to + remove anyway. A client must render those as non-toggleable, not as merely "off". +* `params` — the allow-listed keys that belong to this plugin (`p2p`, `webserver`, + `validator_guard`); an empty array means the plugin has no editable parameters and the + panel should say so instead of showing an empty form. Logging keys are grouped under + `logging`, which is not a plugin: they live in `admin.config.get`. +* `status_method` — the method that returns this plugin's **own** status (`admin.p2p.status`, + `admin.webserver.status`, `admin.snapshot.status`, `admin.validator.list` for + `validator_guard`), or `null` when the plugin exposes none. `null` is not an error: the + panel should say "no status" instead of drawing an empty drawer. + +The result also carries `known`: every plugin name the binary can load, which is the set +`admin.plugins.plan` validates `add` against. Configured names appear there too; a client +subtracts them to build the "add a plugin" selector. + +### `admin.plugins.status` + +`{"name":"p2p"}` returns the lifecycle state plus what the plugin is actually doing: + +| Field | Meaning | +|---|---| +| `configured` | A `plugin =` line for this name exists in `config.ini` | +| `state`, `state_source` | As in `admin.plugins.list`; `state` is `null` when the plugin type is not linked into this binary | +| `detail_available`, `detail_method` | Whether a dedicated status method exists and which one was called | +| `detail` | The verbatim result of that method (`admin.p2p.status`, `admin.webserver.status`, `admin.snapshot.status`, `admin.validator.list` for `validator_guard`) | +| `reason` | Why there is no detail: unobserved type, plugin not running, or no status API of its own | + +The detail method is called only for a **started** plugin: those handlers read +thread-owned state (peer map, connection counters) and would throw on a stopped one. + +A plugin whose type is not linked into this binary (`snapshot` on a build without it, say) +answers `state: null` with `reason` — not an empty object and not a fabricated "started". + +### `admin.objects.list` + +The chainbase object graph as the running process sees it: one row per index (object type), +its row count and the index revision, plus how much of the mapped file is used. + +```json +{"state_dir":"/var/lib/vizd/state","shared_memory_path":"/var/lib/vizd/state/shared_memory.bin", + "shared_memory_bytes":34183577600,"mapped_bytes":34183577600,"free_bytes":12648009654, + "revision":1234567,"head_block_num":1234567,"recovering":false, + "schema":{"file_present":true,"stored":15,"compiled":15,"match":true}, + "block_log":{"path":"/var/lib/vizd/state/block_log","present":true,"bytes":2147483648}, + "markers":[],"index_count":36,"total_objects":9876543, + "indexes":[{"type":"graphene::chain::account_object","type_id":7,"objects":812345, + "revision":1234567}], + "generated_at":"2026-10-20T13:40:00Z"} +``` + +Counts are read under the same weak read lock the chain API uses; if the block thread does +not yield in time the call returns the file facts plus `lock_error` instead of stalling. + +The block log is reported as a file or a directory — the chain plugin wipes it with +`remove_all`, so a directory is a legal shape there. + +`schema_version` and `block_log` live in the chain's state directory (`/state` by +default, next to `shared_memory.bin`), not in the application data dir: the chain plugin opens +the database with the state directory as its data dir. A log holding only chainbase's one-byte +placeholder counts as empty — `replay_available` is false and a `replay` repair is refused, +because it would reindex zero blocks. + +### `admin.objects.check` + +The same view, judged. `verdict` is `ok`, `warn`, `corrupted` or `unreadable`; the result +carries named `findings` (`severity`, `code`, `text`), counts, `replay_available` and +`recommended_repair` (`replay`, `resync` or `null`). + +| Code | Severity | What it means | +|---|---|---| +| `read_lock_timeout` | critical | Shared memory did not answer a read lock; the process is wedged | +| `state_dir_missing` / `shared_memory_missing` | critical | The node has no state directory or no image | +| `shared_memory_truncated` | critical | The file is smaller than the mapping: truncated or replaced under a running process | +| `shared_memory_larger_than_mapping` | warn | Something grew the file under this process — usually a second node on the same state dir | +| `stale_marker` | critical | `undo_all_in_progress`, `resize_in_progress`, `force_resync` or `force_replay` survived an interrupted operation | +| `schema_mismatch` | critical | `state/schema_version` is absent or differs from the binary's `CHAIN_SCHEMA_VERSION`; the next startup wipes and rebuilds | +| `revision_behind_head` | warn | Index revision differs from head; the next startup replays the gap. Normal after a crash | +| `shared_memory_exhausted` | critical | Less than 8 MiB free: the node cannot write another block | +| `shared_memory_low` | warn | Less than 5% free | +| `global_properties_count` / `validator_schedule_count` | critical | Exactly one row expected in each | +| `account_index_empty` | critical | Head is above zero but the account index is empty | +| `block_log_missing` | warn | A replay cannot rebuild state; only a full resync can | +| `recovery_in_progress` | warn | The chain plugin is running an automatic recovery | + +`corrupted` means hard evidence only (the criticals above); a revision mismatch after a +crash is `warn`, not corruption. `recommended_repair` is filled only when rewriting state can +fix the finding — exhausted shared memory is excluded, because the same size would fill again. + +**What the check cannot see.** Page-level corruption, checksums and chainbase's own +environment stamp can only be verified with the file closed, so this is a structural check of +a live process, not `fsck`. A clean verdict does not promise that a replay will succeed. + +### `admin.objects.repair` + +`{"mode":"replay"|"resync","confirm":"repair"}` (scope `process`) writes a marker next to +`shared_memory.bin`, replies, and quits after 300 ms — the same lifecycle as `admin.restart`. +The marker is honoured by the chain plugin at the next startup, before it maps shared memory: + +* `force_replay` — wipe state, keep the block log, reindex it from block 1. Cheaper than a + network resync and enough whenever the block log itself is intact. +* `force_resync` — wipe state **and** block log, come up empty and sync from peers. + +`confirm` must be exactly `repair` and the mode is re-validated against the files: `replay` +is refused with `-32000 no block log` when the log is absent or empty. If a `force_resync` +marker from the wedge watchdog is already present, the startup would perform the resync and +drop the replay request instead of replaying an empty log — so the handler clears the opposite +marker before writing its own, and names it in `removed_marker`. Without that step a confirmed +"replay (keeps blocks)" would silently drop the block log, because startup precedence is +`resync > replay`. A marker that cannot be cleared is a `-32000` refusal, not a promise the +node cannot keep. + +**Snapshot-mode nodes.** The two marker effects above describe a node that starts on the +normal path. A node started with `--replay-from-snapshot` never reaches it: the chain plugin +rebuilds state from the snapshot and replays the block log on top, so a `replay` request ends +up as a snapshot rebuild, and a `resync` request comes back from the snapshot instead of +genesis. The repair is still a full state rebuild — which is what an operator wants — but +the response says so instead of repeating the generic wording. `effect` is computed from the +options the node was actually started with, and `snapshot_replay` is `true` on such a node. + +The response carries `marker`, `removed_marker`, `effect` and `snapshot_replay`; the supervisor +must bring the node back (`--restart unless-stopped` / `Restart=always`), exactly as for a plain +restart. + +### `admin.config.get` + +Returns `config.ini` as ordered sections, preserving duplicate keys (needed for the +repeated `plugin` lines). **Values of secret-bearing keys are replaced with `***`**: +anything containing `private-key`, `private_key`, `password`, `secret`, `token`, `wif` or +`emergency`. + +```json +{"path":"/etc/vizd/config.ini","exists":true,"sections":[ + {"name":"","entries":[{"key":"plugin","value":"chain"}, + {"key":"private-key","value":"***"}]}]} +``` + +It also returns `allow`: the editable subset, ready to render. Each item is +`{key, group, section, type, value, defaulted, min, max, values, needs_restart}`. + +* `key` is the full address `admin.config.plan` accepts — `logger.default.level` for a key + inside `[logger.default]` — so a client never has to know which keys are sectioned. +* `value` is `null` and `defaulted` is `true` when the file does not set the key: the node + uses its code default, and the panel should show that instead of an empty field. + A value read from the file is returned **normalized** (`yes`/`on`/`1` → `true`), so it + matches the option list; `valid:false` means the line could not be normalized and should + be flagged. +* `type` is `boolean`, `unsigned_int` (with `min`/`max`) or `log_level` (with `values`). +* `group` is the plugin the key belongs to (`p2p`, `webserver`, `validator_guard`) or + `logging` for logger and appender settings. + +### `admin.logs.list` + +No params. Lists the files declared by `admin-log-file`, each with `name`, `path`, `exists` +and `size` (`null` when the file is missing). A configured appender that has not written yet +is still listed: "empty" and "not configured" are different facts. + +```json +{"logs":[{"name":"node","path":"/var/lib/vizd/logs/node.log","exists":true,"size":1048576}]} +``` + +### `admin.logs.tail` + +Params: `{"name":"node","bytes":16384,"lines":200}`. `name` must match an +`admin-log-file` entry; nothing outside that allow-list can be read. `bytes` is clamped to +64 KB and `lines` to 2000. WIF-looking strings and `key = value` secrets are masked. + +### `admin.audit.tail` + +Params: `{"lines":100}`. Returns the tail of the audit log. + +### `admin.p2p.status` + +No params. Requires only `read` (a token that may look can also look at peers). +Returns the same facts the node writes to its own log — `log_node_status()` every minute and +`log_peer_stats()` every five minutes — but on demand and in JSON: + +| Field | Meaning | +|---|---| +| `as_of` | UTC timestamp of the reading | +| `uptime_sec` | Node uptime | +| `node_status`, `fork_status` | `SYNC`/`FORWARD`, `NORMAL`/`LOOKING_RESOLUTION`/`MINORITY` | +| `paused`, `isolated_peers`, `catching_up` | Block processing paused, seed-only peer mode, catch-up after pause | +| `head_block_num`, `lib_block_num` | Our head and last irreversible block | +| `dlt_earliest`, `dlt_latest` | The DLT block range held locally (what this node can serve) | +| `connections`, `active`, `max_connections` | Open connections, of which fully active, and the limit | +| `known_peers`, `trusted_peers`, `banned_peers` | Known-peer set, operator-trusted endpoints, currently banned | +| `best_peer_head` | Highest head reported by any tracked peer | +| `best_peer_head_lag` | `our head − best peer head`; **negative means we are behind** | +| `peers_ahead` | Number of peers whose head is above ours | +| `peers[]` | Per-peer detail, sorted by endpoint | + +Each peer entry: `endpoint`, `node_id` (hex, empty before the hello), `lifecycle` +(`CONNECTING`/`HANDSHAKING`/`SYNCING`/`ACTIVE`/`DISCONNECTED`/`BANNED`), `node_status`, +`fork_status`, `incoming`, `trusted`, `exchange_enabled`, `fork_alignment`, `connected_sec`, +`peer_head`, `peer_lib`, `peer_dlt_earliest`, `peer_dlt_latest`, `head_lag` (ours − theirs), +`spam_strikes`, `ban_reason`, `ban_duration_sec`, `reconnect_backoff_sec`, +`next_reconnect_in_sec`. + +```json +{"jsonrpc":"2.0","id":40,"method":"admin.p2p.status"} +``` + +```json +{"jsonrpc":"2.0","id":40,"result":{ + "as_of":"2026-10-19T15:10:00Z","uptime_sec":86400, + "node_status":"FORWARD","fork_status":"NORMAL","paused":false, + "isolated_peers":false,"catching_up":false, + "head_block_num":22164,"lib_block_num":22150, + "dlt_earliest":1,"dlt_latest":22164, + "connections":3,"active":3,"max_connections":50, + "known_peers":12,"trusted_peers":1,"banned_peers":0, + "best_peer_head":22164,"best_peer_head_lag":0,"peers_ahead":0, + "peers":[{"endpoint":"1.2.3.4:1776","node_id":"02ab…","lifecycle":"ACTIVE", + "node_status":"FORWARD","fork_status":"NORMAL","incoming":false, + "trusted":true,"exchange_enabled":true,"fork_alignment":true, + "connected_sec":3600,"peer_head":22164,"peer_lib":22150, + "head_lag":0,"ban_reason":"","next_reconnect_in_sec":0}]}} +``` + +### `admin.p2p.add_node` + +Params: `{"endpoint":"1.2.3.4:1776"}` — IPv4 and port. Requires `read` **and** `p2p`. +Adds the endpoint to the known-peer set and dials it now (clearing any old backoff or ban on +that endpoint). The peer is not trusted. + +```json +{"endpoint":"1.2.3.4:1776","trusted":false,"status":"added"} +``` + +### `admin.p2p.add_trusted` + +Same params and response shape, `"trusted": true`. A trusted peer: + +* is **never soft-banned** — `soft_ban_peer()` disconnects it and retries after 30 s instead + of parking it for an hour; +* is **kept through the known-peer garbage collector**, which otherwise drops silent peers; +* is dialed immediately and re-dialed as needed. + +The trusted set lives in memory: it is **not persisted across restarts**. To pin a peer +permanently, keep it in `p2p-seed-node` (or in the seed list used together with +`p2p-isolated-peers`) in `config.ini`. + +### `admin.p2p.clear_banned` + +No params. Requires `read` and `p2p`. Clears every soft ban (lifecycle `BANNED` or a +non-empty `ban_reason`), resets the reconnect backoff and retries those peers immediately. + +```json +{"cleared":3,"status":"ok"} +``` + +Typical operator flow: + +```bash +# status first: what is wrong and with whom +curl -s http://127.0.0.1:8099 -H "Authorization: Bearer $T" \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"admin.p2p.status"}' | jq '.result | {head_block_num, best_peer_head_lag, peers}' + +# add a peer and trust it +curl -s http://127.0.0.1:8099 -H "Authorization: Bearer $T" \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":2,"method":"admin.p2p.add_trusted","params":{"endpoint":"1.2.3.4:1776"}}' + +# after a fork/ban storm: retry everyone +curl -s http://127.0.0.1:8099 -H "Authorization: Bearer $T" \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":3,"method":"admin.p2p.clear_banned"}' +``` + +### `admin.snapshot.status` + +Params: `{"offset":0,"limit":20,"after":"snapshot-block-84360000.vizjson"}` — all optional. +Requires `read` and the `snapshot` plugin to be loaded. Returns one page of the snapshot files +in the configured `snapshot-dir`, newest block first, and reports what the plugin is doing. +`limit` is clamped to 200. + +Use `after` (the name of the last row you already have) instead of `offset` when paging a +directory that changes under the reader: a snapshot written between two pages shifts every +offset below it, so offsets repeat a row, and the retention sweep (24 h) shifts the other way +and makes offsets skip one. With `after` the page continues after that exact row. If the anchor +has already been pruned the reply carries `after_found: false` and falls back to `offset`. + +| Field | Meaning | +|---|---| +| `as_of` | UTC timestamp of the reading | +| `dir` | Snapshot directory in use | +| `head_block_num` | Current head; `null` while a restore is rebuilding state | +| `snapshot_count` | Number of files in the whole directory | +| `snapshot_total` | Same total, named explicitly for paging clients | +| `offset`, `limit` | The page that was returned | +| `after`, `after_found` | The cursor that was sent and whether it still exists | +| `has_more` | More files exist after this page | +| `in_progress`, `restoring`, `recovering` | A snapshot is being written, a restore is running, the chain plugin is recovering | +| `latest_path`, `latest_name` | Newest snapshot (highest block number); `latest_path` is an empty string and `latest_name` is `null` when there are none | +| `snapshots[]` | `name`, `path`, `block_num` (0 when the name does not carry a block), `size_bytes`, `modified` (unixtime), `modified_iso`, `latest` | + +```json +{"jsonrpc":"2.0","id":50,"method":"admin.snapshot.status","params":{"offset":0,"limit":20}} +``` + +```json +{"jsonrpc":"2.0","id":50,"result":{ + "as_of":"2026-10-19T15:30:00Z","dir":"/var/lib/vizd/snapshots", + "head_block_num":22164,"snapshot_count":1,"snapshot_total":1, + "offset":0,"limit":20,"has_more":false,"in_progress":false, + "restoring":false,"recovering":false, + "latest_path":"/var/lib/vizd/snapshots/snapshot-block-22164.vizjson", + "latest_name":"snapshot-block-22164.vizjson", + "snapshots":[{"name":"snapshot-block-22164.vizjson", + "path":"/var/lib/vizd/snapshots/snapshot-block-22164.vizjson", + "block_num":22164,"size_bytes":4404019, + "modified":1792423800,"modified_iso":"2026-10-19T15:30:00Z","latest":true}]}} +``` + +### `admin.snapshot.create` + +No params. Requires `read` and `snapshot`. Writes `snapshot-block-.vizjson` into +`snapshot-dir` **asynchronously** on the plugin's snapshot thread; P2P block processing is +paused for the duration of the database read and resumed before compression. Poll +`admin.snapshot.status` until `in_progress` is false. + +```json +{"jsonrpc":"2.0","id":51,"method":"admin.snapshot.create"} +``` + +```json +{"jsonrpc":"2.0","id":51,"result":{ + "status":"started","path":"/var/lib/vizd/snapshots/snapshot-block-22164.vizjson", + "note":"snapshot creation runs on the node's snapshot thread; poll admin.snapshot.status until in_progress is false"}} +``` + +`status` is `started`, `exists` (a file for this head is already on disk), or `busy` +(another creation/restore is running). On an empty chain (head `0`) the call fails with +`-32000` and writes nothing. + +### `admin.snapshot.restore` + +Params: `{}` (restore the latest), `{"latest":true}`, `{"name":"snapshot-block-22164.vizjson"}` +or `{"path":"/var/lib/vizd/snapshots/"}`. Requires `read` and `snapshot`. + +The restore is **in-process**, not a process restart: P2P block processing is paused, shared +memory is closed and wiped, the snapshot is imported, `dlt_block_log` is replayed, peers are +reset and P2P resumes. The OS process and its uptime are unchanged, so the listener keeps +answering during the operation (with `restoring: true` and a `null` head). `path`, if given, +must resolve to a regular file directly inside the snapshot directory. + +```json +{"jsonrpc":"2.0","id":52,"method":"admin.snapshot.restore","params":{"latest":true}} +``` + +```json +{"jsonrpc":"2.0","id":52,"result":{ + "status":"started","snapshot":"/var/lib/vizd/snapshots/snapshot-block-22164.vizjson", + "snapshot_name":"snapshot-block-22164.vizjson","block_num":22164, + "process_restarted":false, + "note":"in-process restore: P2P is paused, shared memory is wiped, the snapshot is imported, dlt_block_log is replayed and P2P resumes. The OS process and its uptime are unchanged. Progress: admin.snapshot.status (restoring)."}} +``` + +A call that cannot start answers `-32000` (no snapshots, a creation is in progress, the file +is not readable); invalid names, traversal and files outside the snapshot directory answer +`-32602` before any state is touched. If the import itself fails, `do_snapshot_load` has +already wiped the database and requested shutdown — inspect the node log; the admin API does +not pretend the restore succeeded. + +--- + +## Validator key methods (`chain` scope) + +Decision q#3051=D: the operator manages validators through `node_admin`, but the **active +key never enters the admin API**. Transactions are built and signed inside the +`validator_guard` plugin, which is configured with the signing and active WIFs of each +validator: + +```ini +plugin = validator_guard +validator-guard-validator = ["val1", "5J…signing…", "5J…active…"] +validator-guard-validator = ["val2", "5J…signing…", "5J…active…"] +``` + +The guard must be loaded, started and hold at least one configured validator; otherwise the +mutating methods answer `-32002 forbidden / validator-guard is not configured`, and +`admin.validator.list` reports `guard.reason`. A configured validator must exist **on chain** +before it can be planned; creating a new validator is not offered here (it charges the +validator declaration fee and changes the set of block producers). + +### `admin.validators.active` + +No params, `read` only. Returns the names in the current validator schedule (duplicates from +the repeat factor are collapsed), the number of scheduled slots, the emergency consensus +state and whether the validator plugin is running in this process. **No keys are returned** — +this is public chain data. Use `admin.validator.list` for the validators this node actually +operates. + +```json +{"as_of":"2026-10-19T15:40:00Z","head_block_num":22164,"count":21, + "num_scheduled":21,"validators":["val1","val2"], + "emergency_consensus_active":false,"emergency_consensus_start_block":0, + "validator_plugin_loaded":true} +``` + +### `admin.validator.list` + +Scope `read`. No params. Returns: + +```json +{"guard":{"loaded":true,"started":true,"available":true}, + "validators":[{"name":"val1","on_chain":true,"enabled":true,"hold":false, + "active_authority":true,"scheduled_in":2, + "signing_key":"VIZ6…","configured_key":"VIZ6…"}]} +``` + +* `enabled` — the on-chain signing key is not null. +* `hold` — the operator disabled this validator through this API; the guard's auto-restore + is suppressed for it until an `enable`/`set_key` succeeds (see below). +* `active_authority` — the guard's active key is in the account's active authority. `false` + means a transaction signed by the guard will be rejected by the chain; the same warning is + logged at guard startup. +* `scheduled_in` — position (1-based) of the next 3 scheduled slots this validator produces, + `null` when it is not in them. + +### `admin.validator.plan` + +Scope `chain`. Params: `{"actions":[{"name":"val1","action":"disable"},…]}`. `action` is +`enable` (restore the guard's configured signing key), `disable` (null the signing key and +set the operator hold) or `set_key` (requires `signing_key`, a base58 public key). Every name +must be configured in the guard and exist on chain; duplicates are refused. + +The reply carries `plan_id`, `kind:"validators"`, `transactions` (how many chain +transactions the plan would send) and a `diff` per action: `from`/`to` signing keys, +`hold_from`/`hold_to`, `tx_needed`. An action that is already satisfied is omitted; a plan +with no effective change is refused with `-32602 no changes`. **Nothing is signed or +broadcast yet.** Plans are single-use, bound to the token label and expire after +`admin-plan-ttl`, exactly like config plans. Capturing the current key in the plan is what +makes a stale plan safe: if the chain moved in between, `apply` refuses that action instead +of overwriting somebody else's update. + +### `admin.validator.apply` + +Scope `chain`. Params: `{"plan_id":"…"}`. Signs one `validator_update` per changed action with +the guard's active key, puts it into the local pending pool and broadcasts it to peers. The +reply lists per-action `status`: `applied` (with `tx_id`), `already` (the chain reached the +desired state on its own) or `failed` (with `error`). `applied`, `unchanged` and `failed` +counts are given too. + +**The operator hold follows the outcome, never the intent.** A successful `disable` writes +the validator into `/validator-guard-holds.txt`; the guard's periodic auto-restore +skips held validators, and the file is re-read at startup, so a restart does not silently +re-enable it either (disable-on-shutdown does not create a hold — it is a normal shutdown +action). A successful `enable` or `set_key` removes the hold. A failed disable leaves no +latch: the validator stays as it was. + +Transactions are built under the chain's read lock and broadcast after it is released, the +same rule as every other chain write from this plugin. No restart is needed. + +--- + +## Webserver policy methods + +These methods manage the public API plugin: per-IP request counters, the stats log, rate +limits and the IP lists. Their semantics, defaults and log format are documented in +[Webserver — Request Accounting and Rate Limiting](./webserver.md#request-accounting-and-rate-limiting). + +**Changes are live.** The plugin keeps this state in memory; a change applies to the next +request the webserver receives, with no restart and no re-reading of `config.ini`. Every +change is also persisted atomically to `webserver-admin-state` +(`/webserver-admin.json` by default), which is loaded at startup. Persisted runtime policy is applied only when its +`config_policy` fingerprint matches the startup stats/limits configuration. Changing those +config.ini settings invalidates the old policy snapshot; IP lists remain intact. Legacy state +files without a fingerprint restore IP lists only. The `reload` method re-reads that file for manual edits. + +| Method | Scope | Params | Purpose | +|--------|-------|--------|---------| +| `admin.webserver.status` | `read` | none | Limits, totals, IP lists and tracked-IP count. | +| `admin.webserver.ips` | `read` | `{"limit":200}` (max 5000) | Per-IP rows: `total`, `minute`, `hour`, `day`, `blocked`, `limited`, `last_seen`, `whitelisted`, `blacklisted`, sorted by total. | +| `admin.webserver.limits` | `webserver` | `{"enabled":bool,"per_minute":N,"per_hour":N,"per_day":N}` | Omitted fields keep their value. | +| `admin.webserver.stats` | `webserver` | `{"enabled":bool}` | Turn request accounting on/off. Limits are controlled separately. | +| `admin.webserver.whitelist_add` / `whitelist_remove` | `webserver` | `{"ip":"1.2.3.4"}` | Whitelisted addresses bypass limits but are still counted. | +| `admin.webserver.blacklist_add` / `blacklist_remove` | `webserver` | `{"ip":"1.2.3.4"}` | Blacklisted addresses get `403`; enforced even with stats off. | +| `admin.webserver.reset` | `webserver` | none | Zero the counters; keep lists and limits. | +| `admin.webserver.reload` | `webserver` | none | Re-read the state file from disk; returns the applied status. | + +Adding an IP to one list removes it from the other. An address must be a single IPv4 or IPv6 +literal; anything else answers `-32602`. + +```json +{"jsonrpc":"2.0","id":53,"method":"admin.webserver.limits","params":{"per_minute":600}} +``` + +```json +{"jsonrpc":"2.0","id":53,"result":{ + "status":"applied", + "limits":{"enabled":true,"per_minute":600,"per_hour":10000,"per_day":100000}, + "stats_enabled":true, + "note":"applied to the live webserver on the next request and persisted to its admin state file; config.ini is unchanged."}} +``` + +A node without the `webserver` plugin loaded answers `-32004 webserver unavailable`. The +public webserver rejects blacklisted clients with `403` and rate-limited clients with +`429` + `Retry-After: 60`; those responses are produced by the webserver, not by this API. + +--- + +## Config and plugin editing (two-phase) + +`config.ini` is edited through a **plan** that returns the exact diff and writes nothing, and +an **apply** that re-validates the file and replaces it atomically. This makes an agent's +intent reviewable before it touches a production node and keeps a crash mid-apply from +leaving a truncated file. + +### Plan semantics + +* `admin.config.plan {"set":{"key":value,…}}` validates every key against the **allow-list** + below, normalizes values (`yes/on/1` → `true`; integers are range-checked) and returns + `plan_id`, `kind:"config"`, `diff` (`[{key, from, to}]`), `config_path`, `needs_restart:true` + and `expires_at`. **Nothing is written.** A plan with no effective change is refused with + `-32602`. +* `admin.plugins.plan {"add":[…],"remove":[…]}` validates plugin names against the set the + binary can load, refuses duplicates, absent names and removals of `chain` and `node_admin`, + and returns `diff` of `[{op:"add"|"remove", name}]`. +* A plan is **single-use** (consumed on the first `apply`, successful or not), + **bound to the token label** that created it (`-32002` for any other token) and **expires** + after `admin-plan-ttl` seconds (`-32004 plan expired`). Up to 64 plans are kept; the + oldest is dropped when a new one arrives. `admin.plan.cancel` discards a plan explicitly. +* `apply` recomputes the SHA-256 of `config.ini` and refuses with `-32000 config changed … + nothing was written` if the file moved after the plan was made. It then normalizes and + re-validates every value again — an apply never trusts the plan blindly. + +### Atomic write and backups + +Both appliers edit the file as **raw lines**, so comments, ordering, spacing and CRLF are +preserved. A top-level key that is absent is inserted before the first `[section]`; an +existing top-level key is changed in place. An allow-listed key whose spec names a section +(see the logging row in the table below) is edited inside that section, and inserted +directly under its header when the section exists but the key does not. + +Each successful apply writes one backup next to the config — `.bak-` +(with a `-n` suffix on collision) — then a temp file in the same directory, `fsync`s it, +applies the original file's permissions, `rename`s it over `config.ini` and `fsync`s the +directory. A failed write leaves the original file untouched. Old backups are never pruned +by the node; they contain secrets (tokens, keys), so manage them like the config itself. + +`admin.config.backups` (scope `read`) lists `.bak-*`, newest first, with size and +mtime. Rolling back by hand is copying a backup back and restarting. + +### Allow-list + +The admin API edits only these keys. Everything else — identity, keys, data dir, endpoints, +`plugin`, `shared-file-size` — is deliberately out of reach of a bearer token. + +| Keys | Type | Range | +|---|---|---| +| `p2p-max-connections` | uint | 1–10000 | +| `dlt-peer-max-disconnect-hours` | uint | 1–8760 | +| `dlt-mempool-max-tx` | uint | 1–1000000 | +| `dlt-mempool-max-bytes` | uint | 1024–1073741824 | +| `dlt-mempool-max-tx-size` | uint | 256–67108864 | +| `dlt-mempool-max-expiration-hours` | uint | 1–720 | +| `dlt-peer-exchange-max-per-reply` | uint | 0–1000 | +| `dlt-peer-exchange-max-per-subnet` | uint | 0–1000 | +| `dlt-peer-exchange-min-uptime-sec` | uint | 0–86400 | +| `dlt-stats-interval-sec` | uint | 10–86400 | +| `p2p-isolated-peers`, `auto-resync-on-wedge` | bool | — | +| `webserver-thread-pool-size` | uint | 1–4096 | +| `webserver-cache-enabled` | bool | — | +| `webserver-cache-size` | uint | 0–10000000 | +| `webserver-stats-enabled`, `webserver-limits-enabled` | bool | — | +| `webserver-limit-m`, `webserver-limit-h`, `webserver-limit-d` | uint | 1–100000000 | +| `webserver-stats-log-max-size` | uint | 1024–1073741824 | +| `webserver-stats-log-keep` | uint | 1–1000 | +| `webserver-stats-interval` | uint | 1–86400 | +| `validator-guard-enabled`, `validator-guard-disable`, `validator-guard-disable-on-shutdown` | bool | — | +| `validator-guard-interval`, `validator-guard-shutdown-grace` | uint | 1–3600 | +| `validator-guard-disable-max` | uint | 0–1000 | +| `logger.default.level`, `logger.p2p.level` | log level | `all`, `debug`, `info`, `warn`, `error`, `off` | +| `log.capped_file_appender.node.max_lines` | uint | 0–10000000 | +| `log.capped_file_appender.node.flush` | bool | — | + +The logging keys are addressed with their section: `logger.default.level` edits `level` +inside `[logger.default]`, not a top-level option. A section that is absent from +`config.ini` is an error rather than an insert — the node never declares it, so the value +would be silently ignored at the next start. Appender filenames and rotation windows stay +operator-only: a typo'd path is a lost log, not a tunable. + +### Example + +```json +{"jsonrpc":"2.0","id":80,"method":"admin.config.plan", + "params":{"set":{"p2p-max-connections":200,"webserver-limit-m":600}}} + +{"jsonrpc":"2.0","id":80,"result":{ + "plan_id":"9f2c41a0d3b54e8770c5f1a2b3c4d5e6","kind":"config", + "config_path":"/var/lib/vizd/config.ini","needs_restart":true, + "diff":[{"key":"p2p-max-connections","from":"64","to":"200"}, + {"key":"webserver-limit-m","from":null,"to":"600"}], + "expires_at":"2026-10-19T14:42:00Z", + "hint":"nothing was written yet; apply with admin.config.apply {plan_id}…"}} +``` + +```json +{"jsonrpc":"2.0","id":81,"method":"admin.config.apply", + "params":{"plan_id":"9f2c41a0d3b54e8770c5f1a2b3c4d5e6"}} + +{"jsonrpc":"2.0","id":81,"result":{ + "applied":true,"kind":"config", + "config_path":"/var/lib/vizd/config.ini", + "backup":"/var/lib/vizd/config.ini.bak-1760884860", + "needs_restart":true, + "restart_hint":"config.ini is read at startup: call admin.restart {confirm:\"restart\"}…"}} +``` + +Plugin example: + +```json +{"jsonrpc":"2.0","method":"admin.plugins.plan", + "params":{"add":["block_info"],"remove":[]}} +{"jsonrpc":"2.0","method":"admin.plugins.apply","params":{"plan_id":"…"}} +``` + +Both appliers answer `-32004 config unavailable` when `config.ini` cannot be read, and +`-32000 write failed` when the atomic replace fails (the original file is left untouched). + +--- + +## Restart and supervision + +`admin.restart {"confirm":"restart"}` (scope `process`) asks `appbase` to quit: the handler +replies first, then a detached thread calls `quit()` after 300 ms so the HTTP response +reaches the client. The process exits with code 0. + +**The plugin cannot start the node again.** That is the supervisor's job — the container's +`--restart unless-stopped` or systemd `Restart=always`. With neither, the node simply stays +down; the reply says so in `hint`, and that is deliberate: an admin endpoint that forks a new +node process would be a privilege-escalation path out of the process's own lifecycle. + +Because `config.ini` and the plugin list are read at startup, `admin.config.apply` and +`admin.plugins.apply` take effect only after this restart. `confirm` must be exactly the word +`restart`; anything else is `-32602`. A second call while shutdown is pending answers +`-32000 restart already requested`. + +--- + +## MCP endpoint (Streamable HTTP) + +Decision q#3052=B: the MCP server lives **inside this plugin**, not in a sidecar. The same +loopback listener, the same bearer tokens and the same audit log serve the Model Context +Protocol over HTTP; there is nothing extra to install on the host. + +MCP requests are ordinary JSON-RPC 2.0 `POST`s to the admin endpoint: + +| Method | Purpose | +|---|---| +| `initialize` | Protocol handshake. Supported versions: `2024-11-05`, `2025-03-26`, `2025-06-18` (echoed when requested, otherwise `2025-06-18`). Advertises only the `tools` capability. | +| `notifications/initialized` | Notification (no `id`); answered with HTTP 202 and an empty body. | +| `ping` | MCP keepalive; returns `{}`. | +| `tools/list` | The tools below with JSON-Schema `inputSchema`. | +| `tools/call` | `{"name":"admin.node_info","arguments":{…}}` — executes the matching `admin.*` method. | + +Tools are 1:1 with the admin methods: `admin.ping`, `admin.auth.me`, `admin.node_info`, +`admin.diagnostics`, `admin.plugins.list`, `admin.plugins.status`, `admin.objects.list`, +`admin.objects.check`, `admin.objects.repair`, `admin.config.get`, `admin.logs.list`, +`admin.logs.tail`, `admin.audit.tail`, `admin.validators.active`, +`admin.p2p.status`, `admin.p2p.add_node`, `admin.p2p.add_trusted`, `admin.p2p.clear_banned`, +`admin.snapshot.status`, `admin.snapshot.create`, `admin.snapshot.restore`, +`admin.webserver.status`, `admin.webserver.ips`, `admin.webserver.limits`, +`admin.webserver.stats`, `admin.webserver.whitelist_add`, `admin.webserver.whitelist_remove`, +`admin.webserver.blacklist_add`, `admin.webserver.blacklist_remove`, `admin.webserver.reset`, +`admin.webserver.reload`, `admin.validator.list`, `admin.validator.plan`, +`admin.validator.apply`, `admin.config.plan`, `admin.config.apply`, `admin.config.backups`, +`admin.plugins.plan`, `admin.plugins.apply`, `admin.plan.cancel`, `admin.restart` +(forty-one). +The peer actions additionally require the `p2p` scope, the snapshot actions the `snapshot` +scope, validator operations the `chain` scope (and a live `validator_guard` with an active +key), the webserver actions the `webserver` scope, config/plugin edits the `config` or +`plugins` scope, and restart and state repair the `process` scope, exactly as over plain +JSON-RPC. Tool execution errors are returned **inside** the +result (`"isError": true` with the message in `content[0].text`), as MCP expects; protocol +errors (unknown tool, bad params, missing scope) remain JSON-RPC errors. + +```json +{"jsonrpc":"2.0","id":7,"result":{"tools":[ + {"name":"admin.node_info","description":"Chain id, head block, hardfork, uptime and head age.", + "inputSchema":{"type":"object","properties":{},"additionalProperties":false}}]}} +``` + +```json +{"jsonrpc":"2.0","id":8,"result":{ + "content":[{"type":"text","text":"{\"head_block_num\":1234,…}"}],"isError":false}} +``` + +The endpoint is sessionless: no `Mcp-Session-Id` is issued, and `GET` on anything except +`/ui/` and the `/snapshot/…` routes (the optional SSE stream included) answers `405`. Clients +that require SSE are not supported; JSON responses are. + +Client configuration (Streamable HTTP transport) points at the same loopback address: + +```json +{"mcpServers":{"viz-node":{"type":"http","url":"http://127.0.0.1:8099", + "headers":{"Authorization":"Bearer "}}}} +``` + +--- + +## Built-in UI (`/ui/`) + +A single static page, embedded in the binary, served by the same listener: + +```text +http://127.0.0.1:8099/ui/ +``` + +It is plain HTML + vanilla JS — no build step, no external assets, no frameworks. The CSP +allows only the page's own inline style/script and same-origin `fetch`; everything else is +`'none'`. + +The page derives its JSON-RPC base from its own URL, so it also works when an operator +publishes the listener behind a reverse-proxy prefix instead of the root: with nginx +`location /admin/ { proxy_pass http://127.0.0.1:9099/; }` the page is at `/admin/ui/` and +calls `/admin/`. At the root (`/ui/`) the base is `/`, as before. + +**Authentication.** The page holds no secret of its own. On first use the operator pastes a +bearer token; it is kept in `sessionStorage` (per tab, cleared when the tab closes) and sent +as `Authorization: Bearer …` on every JSON-RPC call back to the same listener. The page +itself is served unauthenticated on purpose: it is identical for everybody, contains no data, +and a browser cannot attach a bearer header to a navigation anyway. Tokens are never put in +the URL (that would land in history and access logs). + +**What it shows** + +The page is organised as seven tabs; the last one is remembered per tab session. + +* **Status** — node state, emergency consensus (`ACTIVE` with the starting block, or `normal`), + whether an emergency key is configured, head/LIB, head age, version, uptime, peer counts, + best peer head and our lag behind it, bans and flags. The snapshot tab's own state card + spells out `idle` ("no snapshot job is running") versus `creating` and `restoring`. +* **Peers** — a table of every tracked peer: direction, lifecycle, head, lag, node/fork + status, flags (`trusted`, `exchange`, `aligned`), connection age, ban/retry note. With the + `p2p` scope an action panel adds a peer by `IP:port`, adds it as trusted, or clears bans. +* **Snapshots** — state, directory, total count and the newest file; the newest **20** files + with **Load more** paging through `offset`/`limit`, block, size, age and per-row + **Download** / **Restore**, plus **Create snapshot** and **Restore latest**. A download + request disables its button and shows a radial progress spinner until the response body + arrives, so repeated clicks cannot start parallel downloads. Downloads use the + authenticated `/snapshot/` route and are saved by the browser under the file's own + name. +* **Logs** — a selector over `admin-log-file` entries, a **Play/Pause** control and a + scrolling pane. It starts **paused**: pressing Play polls `admin.logs.tail` every two + seconds and appends only the new lines at the bottom, colouring them by severity. When the + operator scrolls up, the pane stops auto-scrolling; scrolling back to the bottom resumes + it. +* **Plugins** — the plugins listed in `config.ini` with their observed state and how many + editable parameters each one has, a **Disable** button per row (`chain` and `node_admin` + show a disabled **protected** button instead) and a selector that adds a plugin the binary + can load. Every change goes through `plugins.plan` → a confirmation that spells out the + consequence → `plugins.apply`. Below the table, **Parameters** renders the allow-listed + keys grouped by plugin and **Logging** renders the logger and appender keys; each group + saves through `config.plan`/`config.apply`. Values that come from code (the key is absent + from `config.ini`) are marked `code default` rather than shown as blank. After any apply a + banner says the file changed and offers **Restart node**, which calls `admin.restart` after + two confirmations and works only with the `process` scope. A **Status** button on every row + whose plugin has a status API calls `admin.plugins.status` and shows the result verbatim + above the table; rows without one have no button, so the absence is visible rather than an + empty panel. +* **Objects** — the chainbase view: shared-memory path and size, mapped/free/reserved bytes, + index revision versus head, schema versions, block-log size, repair markers, and a table of + object types with row counts and per-index revision, largest first. **Check** runs + `admin.objects.check` and paints the verdict (`ok` / `warn` / `corrupted` / `unreadable`) + with each finding as `SEVERITY [code] text`; a recommended repair mode + preselects the selector. **Restart with repair** sends `admin.objects.repair` after two + confirmations that spell out the difference between replay (keeps blocks) and resync + (drops them), and needs the `process` scope. Like Plugins, this tab is outside the 5-second + timer so a pending verdict is not repainted under the operator. +* **Validators** — the names in the current validator schedule (public chain data, no keys), + the schedule size and the emergency state; below it, the validators this node operates via + `validator_guard`, when the plugin is live. + +**Scopes drive the page.** On load the page calls `admin.auth.me` and hides every action the +token cannot perform: without `snapshot` the Create/Restore buttons and the per-row Restore +disappear, without `p2p` the peer action panel disappears, without `plugins` the Disable/Add +controls disappear, without `config` the parameter inputs are locked, and without `process` +the Restart and Repair buttons are disabled. A read-only token therefore sees a +dashboard with listing and downloads only, and the page says so instead of offering buttons +that would be refused with `-32002`. + +The status view refreshes every five seconds while the tab is visible. + +**What it does not do.** No token management and no deletion of snapshot files. Writes are +the three peer actions, snapshot creation, the in-process restore, `config.ini` edits through +the plan/apply pair, a restart request and a state-repair request (which is a marker plus a +restart — the actual rebuild happens at the next startup, inside the chain plugin, not in the +admin plugin). It is a thin view over the same JSON-RPC contract documented above — anything it can +do, `curl` and an MCP client can do as well, and every call lands in the audit log under the +token's label (the page's own `GET /ui/` is audited with outcome `ui`, downloads as +`snapshot_download`). + +**Token for the page.** Use a dedicated token with the scopes you want the page to have; a +`read`-only token gives a live, read-only dashboard with snapshot download and listing, +adding `p2p` enables the peer action buttons, and adding `snapshot` enables **Create** and +**Restore**: + +```ini +admin-token = ui:read,snapshot,p2p: +``` + + +--- + +## Audit + +Requests admitted by the global 100 requests/second ingress budget — including failed +authentication and parse errors — append one JSON line. Excess ingress is rejected before +auditing so unauthenticated floods cannot continuously rotate the audit trail: + +```json +{"ts":"2026-10-19T14:40:00Z","rid":"17","token":"agent","method":"admin.node_info", + "params":{},"outcome":"ok","code":0,"ms":2,"remote":"127.0.0.1:51234"} +``` + +`token` is the label, never the token or its hash. `remote` is the real peer address of the +connection (not `X-Forwarded-For`: this listener is never behind a proxy, so that header +would let a client pick its own attribution). `params` are masked with the same redaction +rules as the log tail. The file is created `0600` and rotated at 16 MB with eight retained generations (`.1`–`.8`). MCP calls also record the +actual tool name in `tool`. HTTP handshakes time out after 5 seconds and the listener admits +at most 64 concurrent connections. Snapshot downloads stream in 64 KiB chunks without a file-size ceiling; stalled writes time out after 30 seconds. + +--- + +## Security notes + +* The listener is loopback-only by default; do not put it behind nginx. +* Never send the token in a query string — it would land in proxy/access logs. +* Treat the token as a credential to the node process. Use one token per agent so the + audit log attributes calls correctly, and give each the smallest scope it needs. +* `admin.config.get` masks secrets by key name, not by value shape. A secret stored under + an innocuous key would be printed; do not invent such keys. +* The admin API never returns WIF keys. Besides the audit log, the methods that write to disk + are `admin.snapshot.create` (into the configured snapshot directory) and + `admin.config.*`/`admin.plugins.*` apply (the config plus one `.bak-*` per apply). +* `admin.snapshot.restore` **replaces all chain state** with the chosen file. It pauses block + processing and wipes shared memory; a wrong or stale snapshot is destructive. Treat the + `snapshot` scope as equivalent to write access to the node's state, and prefer a snapshot + taken at or below the peers' LIB when the node is expected to re-sync. +* Snapshot downloads are served by the admin listener only, never by the public webserver. + Do not copy them to a world-readable path; a snapshot contains full chain state and, + depending on the build, account metadata. +* `GET /ui/` is unauthenticated by design and is the same bytes for every caller. It grants + no access: without a token every call it makes gets `401`. It does not answer `OPTIONS` + and sends no CORS headers, so another origin cannot drive it through a browser. +* Peer actions are in-memory. `add_trusted` and `add_node` survive until the process + restarts; permanent pins belong in the P2P seed configuration, not in the admin API. +* The `webserver` scope can change the availability of the public API (a too-strict limit or + a wrong blacklist entry rejects legitimate clients). A token with it is a policy + credential, not just a read credential; changes also persist across restarts through + `webserver-admin-state`. +* The `config` and `plugins` scopes edit `config.ini` on disk. The allow-list keeps identity, + keys, endpoints and `plugin` out of reach, but the keys that are editable still change node + behaviour at the next restart. `apply` re-checks the file hash, so an edit made by someone + else after the plan is never silently overwritten; the backup is the rollback path. +* `admin.config.backups` lists files that contain secrets (tokens, `private-key` lines). They + are created with the original file's permissions; do not copy them to a world-readable path. +* The `process` scope stops the node. Without a supervisor the node does not come back; the + admin API deliberately cannot restart itself outside the process lifecycle. +* `admin.objects.repair` is a write credential disguised as a maintenance action. + `mode=replay` discards all state and rebuilds it from the block log; + `mode=resync` **also deletes the block log**, and the node can then only resync from peers. + Run `admin.objects.check` first, and treat the scope as equivalent to deleting the state + directory by hand. + +--- + +## Related + +* [Plugin overview](./overview.md) +* [Webserver](./webserver.md) — the public API this plugin deliberately does not use + +--- + +## Tests + +`plugins/node_admin/tests/acceptance.sh` starts a throwaway node, drives the listener with +curl and checks this contract: authentication and scopes, config/log masking, the log +allow-list, status codes, audit, rate limit, P2P peer actions (including the `p2p`-scope +denial), snapshot status/listing/download (including `HEAD`, traversal and missing-file +cases, and the `snapshot`-scope denial on create/restore), per-plugin status (a started +plugin with its own view, an unlinked type answering `state: null` with a reason, a started +plugin without a status API, and the missing-name refusal), chainbase objects (index count +matches the array, largest first, schema matches the binary, a clean verdict on a fresh node, +and the repair refusals — read scope, missing `confirm`, unknown mode, and `replay` with an +empty block log, which must leave no marker behind), MCP +`initialize`/`tools/list`/`tools/call`, webserver stats/limits (burst → `429`, whitelist +bypass, blacklist `403`, hot limits change, persistence and reload), config/plugin +plan-and-apply (allow-list, scope and token binding, single use, expiry, stale-hash refusal, +backups, plugin protection), validator key operations (the list stays readable and names the +missing plugin, `plan` is refused without `chain` and again without a live guard; after the +restart the guard is loaded and started but has no active-key validator, so `plan` stays +refused), restart (`confirm` guard, scope guard, clean exit 0 and a +supervisor-style start that reads the edited config back), and the `/ui/` page. It +starts the webserver plugin on a third port and needs a built `vizd` and three free ports, +so it is not part of CTest. + +The signing path itself cannot run in that script: a fresh chain has no second validator, and +the genesis `committee` account has an empty active authority. It is exercised by +`plugins/node_admin/tests/validator-e2e.sh`, which builds its own scratch chain (a temp dir and +three ports; `--keep` leaves it behind for inspection). It stages a testnet one-slot chain +through HF9/HF12 — `viz` is registered, vested and voted in the first blocks, because votes weigh +vesting shares, HF9 deletes validators that never signed a block and HF12 excludes `committee` +from selection — then creates two more validators: `backup` carries the operator actions and +`cold` lets a batch plan name two different validators. Checks: list (`on_chain`, `enabled`, +`active_authority`), disable/enable/set-key with a transaction id, the hold file surviving a node +restart, a stale plan refused with `chain state changed` and nothing signed, a two-action plan +applying both actions, the producing validator rotating its own signing key, and the contract +corners (duplicate, unknown, bad key, no-op → `-32602`). + +Two staging decisions are load-bearing. Operator actions never target the only *scheduled* +validator: the schedule is rebuilt only at the end of an applied block, so nulling its key leaves +a stale schedule pointing at a keyless validator and bricks a one-slot chain. The self-rotation +section is a regression check for a fixed node bug: a block header is validated against the key +committed at head, so the block that carries the update must be signed with the old key and the +new key takes effect for the next block. The producer used to read the signing key from the +session-visible validator object, so a pending `validator_update` that rotated the producing +validator's key made it sign with the new key, `validate_block_header` rejected the block, the +pending pool was cleared and the update was silently lost. The plugin now keeps a committed-key +snapshot (seeded at startup, refreshed from applied block contents) and production signs with +that key. + +`tests/node_admin/` holds two CTest targets (Boost.Test). `node_admin_util` covers the pure +helpers: secret masking, token parsing, the config parser, the allow-list and normalization, and +the raw-line editors (spacing, CRLF, sections, plugin add/remove guards). + +`node_admin_objects` covers the verdict branches of `admin.objects.check` in +`objects_check.hpp`, which is deliberately free of fc and chainbase so every arm can be driven +by a hand-built state: healthy, crash-lag (warn, not corrupted), truncation (replay), no block +log (resync), exhaustion alone (no repair offered — the same size fills again), exhaustion next +to another critical (replay), stale markers, schema rules, core index cardinality above and at +genesis, the 5% free-space boundary, a wedged read lock (unreadable, and no repair on the +strength of a lock that was never answered), and recovery/block-log warnings. The judge is what +invites an operator to wipe state, so a false `corrupted` and a false `ok` are both defects and +both have a test. + +Per-IP counters retain at most 200,000 sources in memory. At capacity, the least recently used counter is evicted so new clients are evaluated normally instead of all receiving 429. A returning evicted source starts fresh counters; active sources retain their existing windows. Blacklists and whitelists are stored separately and are not evicted. diff --git a/docs/plugins/overview.md b/docs/plugins/overview.md index 4cf910b15f..fd669caece 100644 --- a/docs/plugins/overview.md +++ b/docs/plugins/overview.md @@ -35,6 +35,7 @@ VIZ Ledger uses the **AppBase** plugin framework. Each plugin has a lifecycle (` | `snapshot` | Recommended | `chain` | — | | `block_archive` | Optional | `chain` | — | | `validator_guard` | Recommended for validators | `chain`, `p2p` | — | +| `node_admin` | Optional (operator/AI agent) | `chain` | Its own loopback listener | ### API diff --git a/docs/plugins/prediction-market-api.md b/docs/plugins/prediction-market-api.md index 6942017088..c208fe5b50 100644 --- a/docs/plugins/prediction-market-api.md +++ b/docs/plugins/prediction-market-api.md @@ -75,13 +75,15 @@ build its filter chips without hard-coding a taxonomy. The meta object: | `get_account_leverage_positions` | `account, from, limit` | `pm_leverage_position_object[]` | | `get_market_leverage_positions` | `market_id, from, limit` | `pm_leverage_position_object[]` | | `get_creator_ban` | `account` | `pm_creator_ban_object` (or error if none) | -| `get_oracle` | `owner` | `pm_oracle` (object + `reliability_score` + workload/latency reads) | +| `get_oracle` | `owner` | `pm_oracle` (object + `reliability_score` + workload/latency/no-contest reads) | | `list_oracles` | `from, limit` | `pm_oracle_object[]` | | `list_markets_in_dispute_window` | `oracle, from, limit` | `pm_market_card[]` | | `list_oracle_disputes` | `oracle, from, limit` | `pm_oracle_dispute[]` (computed) | `list_markets_in_dispute_window` drills into the `markets_in_dispute_window` gauge: this oracle's resolved (`status 3`, `payout_status 1`) markets that still carry **no dispute row** — i.e. the ones a challenger could still dispute within `pm_dispute_grace_sec` of the announcement. `list_oracle_disputes` drills into the two dispute gauges: this oracle's currently **open** disputes (`status 0`), each tagged with a `stage` — `awaiting_response` (the oracle has not answered yet) or `awaiting_decision` (answered, now with the resolver/committee) — derived from `oracle_response_time`, plus a `market_card` for rendering. Both walk only the oracle's own set (`by_oracle_status` / `by_auto_close`), so they stay O(this oracle), not a global scan. They pair with the stored gauges on `get_oracle` (below): the gauge is the count, these methods are the list. +Every `pm_market_card` (here and in the market list/detail methods) carries **`settlement_charge`**: the no-contest penalty settlement would charge right now — the fee frozen at announcement under HF16 (`no_contest_fee_frozen`) or the current median for pre-fork rows, capped by the oracle's insurance — and `0` unless the market is void-announced (`status 3`, payout pending, no dispute filed) with stake on it. `get_oracle` aggregates the same computation for the whole oracle: `no_contest_pending_count` / `_stake` / `_fee_nominal` / `_fee_capped`. Both are display-only reads; the charge itself happens in consensus at settlement. + ### Leverage previews (Boost) Read-only quotes that call the **same in-node margin math** the evaluators use, so a preview matches what the corresponding `pm_leverage_*` op would compute at the head block. They are non-consensus estimates (reserves move between the read and the broadcast — always send the on-chain slippage guards). @@ -109,7 +111,7 @@ Read-only quotes that call the **same in-node margin math** the evaluators use, | `get_lazy_deposit` | `account` | `pm_lazy_deposit_object` | | `get_lazy_allocations` | `from, limit` | `pm_lazy_allocation_object[]` | | `get_market_lazy_allocation` | `market_id` | `pm_lazy_allocation_object` (or error if none) | -| `get_pm_chain_properties` | — | `chain_properties_pm` (median, v5) | +| `get_pm_chain_properties` | — | `chain_properties_pm` (median, v5) + `pm_no_contest_fee_per_market` (derived) | `get_lazy_allocations` lists the lazy pool's per-market allocation records (for a pool dashboard); `get_market_lazy_allocation` fetches the one for a given market. Oracle penalty stamps need no separate method — they ship on `pm_oracle_object` (`penalty_stamps`, `last_penalty_stamp_time`) via `get_oracle`. @@ -159,7 +161,7 @@ These wrap raw objects with values derived at read time (non-consensus). ``` Each ratio defaults to a full `10000` until the oracle has the relevant history (optimistic when unproven). `timely` is the P5 lateness signal: a late-but-delivered resolve still counts as `markets_resolved` (so it earns full `accuracy`), and `timely` is what separates a chronically-late oracle from a punctual one. `avg_resolution_time` is deliberately **not** scored — it measures latency from betting close, not deadline overrun, and can't be normalized without the market length. Weights are non-consensus; tune freely. - **Workload gauges** (stored on `pm_oracle_object`, O(1)-maintained across the op/cron transitions, seeded once on upgrade): `markets_in_dispute_window`, `disputes_awaiting_response`, `disputes_awaiting_decision` — the live counts an Oracle Console badges; drill into the lists with `list_markets_in_dispute_window` / `list_oracle_disputes`. -- **Computed-on-read** (time-dependent, so not stored): `markets_awaiting_resolution` + `oldest_unresolved_age` (this oracle's `status 1` markets past betting close, and the age of the oldest — one `by_oracle_status` walk), and `resolution_time_p50` / `resolution_time_p95` (percentiles read off the oracle's 8-bucket latency histogram `resolution_time_hist`, upper bucket edge where the cumulative count first crosses the percentile). +- **Computed-on-read** (time-dependent, so not stored): `markets_awaiting_resolution` + `oldest_unresolved_age` (this oracle's `status 1` markets past betting close, and the age of the oldest — one `by_oracle_status` walk), `resolution_time_p50` / `resolution_time_p95` (percentiles read off the oracle's 8-bucket latency histogram `resolution_time_hist`, upper bucket edge where the cumulative count first crosses the percentile), and `no_contest_pending_count` / `no_contest_pending_stake` / `no_contest_fee_nominal` / `no_contest_fee_capped` — this oracle's void-announced markets still inside the dispute grace with stake (one `by_oracle_finalized` walk over the open obligations; the oracle's resolved history is not read). The price per market is the HF16 frozen fee when the row has one, otherwise the legacy median computation — the same branch `settle_market(win < 0)` takes; `_capped` is `min(nominal, insurance)` because settlement caps each market. **`pm_market_weight_sums`** — per side/outcome `bets_sum` and `weight_sum` (weight sums are computed by scanning bets, since they are not stored): ``` diff --git a/docs/plugins/snapshot.md b/docs/plugins/snapshot.md index f2d78011b1..67ab8629d5 100644 --- a/docs/plugins/snapshot.md +++ b/docs/plugins/snapshot.md @@ -2,7 +2,7 @@ The snapshot plugin enables near-instant node startup by serializing and restoring the full blockchain state as a JSON file. Instead of replaying millions of blocks from the block log, a node loads a pre-built snapshot and begins syncing from the snapshot's block height via P2P. -**Source:** [plugins/snapshot/snapshot.cpp](../../plugins/snapshot/snapshot.cpp) +**Source:** [plugins/snapshot/snapshot.cpp](https://github.com/VIZ-Blockchain/viz-cpp-node/blob/pm/plugins/snapshot/plugin.cpp) --- diff --git a/docs/plugins/webserver.md b/docs/plugins/webserver.md index 06dc5d4f99..7711e982b2 100644 --- a/docs/plugins/webserver.md +++ b/docs/plugins/webserver.md @@ -23,9 +23,21 @@ json_rpc::plugin | `webserver-thread-pool-size` | `256` | Worker threads for handling HTTP and WebSocket requests. | | `webserver-cache-enabled` | `true` | Enable the response cache. | | `webserver-cache-size` | `10000` | Maximum number of cached responses. Cache is evicted entirely when this limit is reached. | +| `webserver-stats-enabled` | `false` | Enable per-IP request accounting, the stats log, and (unless overridden) the rate limits. | +| `webserver-limits-enabled` | value of `webserver-stats-enabled` | Enforce the per-IP rate limits. Limits need the counters from `webserver-stats-enabled`. | +| `webserver-limit-m` | `1000` | Maximum requests per IP per minute. | +| `webserver-limit-h` | `10000` | Maximum requests per IP per hour. | +| `webserver-limit-d` | `100000` | Maximum requests per IP per day. | +| `webserver-stats-log` | `/webserver-stats.log` | Stats log path; rotated by size. | +| `webserver-stats-log-max-size` | `10485760` (10 MiB) | Rotate the stats log when it exceeds this size. | +| `webserver-stats-log-keep` | `5` | Number of rotated files (`.1` … `.N`) to keep. | +| `webserver-stats-interval` | `60` | Seconds between snapshot lines written to the stats log. | +| `webserver-admin-state` | `/webserver-admin.json` | Live state (limits, IP lists, stats switch) persisted by `node_admin`. | At least one of `webserver-http-endpoint` or `webserver-ws-endpoint` must be set for the plugin to do anything. Both can be enabled simultaneously. +The stats and limit options are the **startup seed** only. Once the node is running, the live values are owned by the plugin and can be changed through `node_admin` (see [Managing stats and limits at runtime](#managing-stats-and-limits-at-runtime)); every change is applied to the next request and persisted to `webserver-admin-state`. + --- ## Minimal config.ini @@ -79,6 +91,70 @@ HTTP and WebSocket servers each run on a dedicated `io_service` instance. Incomi --- +## Request Accounting and Rate Limiting + +Off by default. Set `webserver-stats-enabled = true` to count requests per client IP and enable the limits (or set `webserver-limits-enabled` explicitly to decouple the two). + +### Limits + +| Window | Default | +|--------|---------| +| minute | 1000 requests | +| hour | 10000 requests | +| day | 100000 requests | + +The windows are fixed (the minute bucket resets at the minute boundary, the hour at the hour, the day at midnight UTC), and every request is checked against all three. A request over any limit is rejected: + +- HTTP: `429 Too Many Requests`, `Retry-After: 60`, body `rate limit exceeded`. +- WebSocket: JSON-RPC error `-32006 rate limit exceeded`. + +This is deliberate protection against scraping and parser traffic: consumers that need more throughput should run their own node. + +### Lists + +- **Whitelist** — addresses that bypass the limits. They are still counted and still appear in the stats. +- **Blacklist** — addresses that are rejected with `403 Forbidden` (HTTP) or `-32005 forbidden` (WebSocket). The blacklist wins over the whitelist. It is enforced even when stats are disabled. +- Adding an address to one list removes it from the other. + +### What the stats log contains + +Every `webserver-stats-interval` seconds the plugin appends one line per tracked IP plus a summary: + +```text +2026-10-19T12:00:00Z ip=203.0.113.7 total=4210 minute=320 hour=4210 day=4210 blocked=0 limited=3 whitelisted=0 blacklisted=0 last_seen=1760875200 +2026-10-19T12:00:00Z summary total_requests=4210 blocked_requests=0 limited_requests=3 tracked_ips=1 +``` + +The file is rotated by size: `` becomes `.1`, older files shift to `.2` … `.webserver-stats-log-keep`, and the oldest is deleted. + +### Client address + +The plugin uses the TCP peer address of the connection. Put behind nginx, every request appears to come from the proxy (`127.0.0.1`), so per-IP limits become global limits and the per-IP report shows a single row. Either expose the API directly, whitelist the proxy and rate-limit at the proxy layer (`limit_req`), or enable PROXY protocol support at the proxy/network layer. + +### Managing stats and limits at runtime + +The `webserver` scope on a `node_admin` token controls the mutating methods; reads only need `read`: + +| Method | Purpose | +|--------|---------| +| `admin.webserver.status` | Limits, totals, IP lists, tracked IP count | +| `admin.webserver.ips` | Per-IP counters (`limit` up to 5000), sorted by request count | +| `admin.webserver.limits` | Change `enabled`, `per_minute`, `per_hour`, `per_day` (omitted fields keep their value) | +| `admin.webserver.stats` | Turn stats logging on/off; enabled limits retain counters (`{"enabled": true|false}`) | +| `admin.webserver.whitelist_add` / `whitelist_remove` | Edit the whitelist (`{"ip": "..."}`) | +| `admin.webserver.blacklist_add` / `blacklist_remove` | Edit the blacklist (`{"ip": "..."}`) | +| `admin.webserver.reset` | Zero the counters, keep the lists and limits | +| `admin.webserver.reload` | Re-read `webserver-admin-state` from disk | + +Each of these is also exposed as an MCP tool of the same name. **Changes are applied to the live plugin immediately — no restart and no re-reading of `config.ini`.** They are written to `webserver-admin-state` (atomic temp-file + rename), which is loaded at the next startup only when its `config_policy` fingerprint matches the startup stats/limits settings. Changed config.ini policy wins; IP lists are restored independently. Legacy state files restore IP lists only. `config.ini` is never rewritten. `reload` exists for operators who edited the JSON file by hand; normal changes do not need it. + +```json +{"jsonrpc":"2.0","id":1,"method":"admin.webserver.limits", + "params":{"per_minute":600,"per_hour":8000}} +``` + +--- + ## WebSocket Subscriptions WebSocket clients can register callbacks: @@ -117,7 +193,7 @@ This allows browser-based wallets and dApps to call the JSON-RPC endpoint direct ## Security - **Bind to localhost** (`127.0.0.1`) and use a reverse proxy (nginx/Caddy) for public exposure. Binding to `0.0.0.0` exposes the RPC directly to the network. -- The plugin has no built-in authentication or rate limiting. Apply those at the reverse proxy layer. +- The plugin has no built-in authentication. It has optional per-IP rate limiting and IP lists (see [Request Accounting and Rate Limiting](#request-accounting-and-rate-limiting)), but those are blunt controls — keep authentication and coarse filtering at the reverse proxy layer. - Mutating methods (`network_broadcast_api`) are blocked from cache poisoning by design, but they remain callable from any connected client — restrict access at the network level if needed. --- @@ -200,3 +276,9 @@ location /ws { --- See also: [Plugin Overview](./overview.md), [Database API](./database-api.md), [JSON-RPC API](../api/json-rpc.md). + +### Trusted reverse proxies and persistence + +By default limits use the TCP peer address and ignore forwarding headers. Behind nginx, explicitly configure `webserver-trusted-proxy = 127.0.0.1` (repeat the option for each actual proxy IP) and forward `X-Forwarded-For`. The node walks the chain from right to left and stops at the first untrusted hop. Never trust arbitrary client addresses as proxies. + +The 200,000-IP capacity guard expires entries older than one day and rejects new untracked sources while full; it never clears live clients' rate windows. Enabled limits continue counting when stats logging is disabled. Periodic stats write changed rows only, with formatting and disk I/O outside the admission mutex. Persisted policy is bound to a `config_policy` startup fingerprint; changed config.ini stats/limits settings supersede old runtime policy while preserving IP lists. diff --git a/docs/prediction-markets/pm-audit-fix-upgrade.md b/docs/prediction-markets/pm-audit-fix-upgrade.md index 0bc816aeb9..b16da1d42f 100644 --- a/docs/prediction-markets/pm-audit-fix-upgrade.md +++ b/docs/prediction-markets/pm-audit-fix-upgrade.md @@ -1,5 +1,8 @@ # HF15 — PM audit fixes: activation checklist and stale-state migration +> **Historical HF15 checklist:** the statements below describe that upgrade, not the current release status. Release 4.2.0 compiles hardforks through 16; see [HF16](../consensus/hf16.md). + + Status: the two fixes below are **implemented but not scheduled on mainnet**. The fork is registered in both configs (`CHAIN_NUM_HARDFORKS = 15` everywhere) and gated by its activation time plus the validator quorum, so a production binary applies it once that time is reached — nothing runs before diff --git a/docs/prediction-markets/pm-no-contest-fee-freeze.md b/docs/prediction-markets/pm-no-contest-fee-freeze.md new file mode 100644 index 0000000000..d7607185cb --- /dev/null +++ b/docs/prediction-markets/pm-no-contest-fee-freeze.md @@ -0,0 +1,153 @@ +# HF16 — no-contest fee freeze: activation checklist and backfill semantics + +Status: **implemented; activation scheduled for 2026-10-31 00:00:00 UTC.** The fork is registered in both configs +(`CHAIN_NUM_HARDFORKS = 16`, `CHAIN_VERSION = 4.2.0`) and gated by its activation time plus the +validator quorum: a production binary applies it once that time is reached, and nothing runs before +then. This note is the operator-facing checklist — what is frozen, how the two states on a market are +distinguished, what changes in the object layout and the snapshot, how to verify the activation, and +why no seed pass is needed for rows that are already inside the dispute window when the fork lands. + +## 1. What the fork changes + +**The problem.** `pm_no_contest` only declares the void and opens the `pm_dispute_grace_sec` window; +the penalty is computed at **settlement**. Before HF16 the amount was +`floor(pm_dispute_fee × pm_no_contest_penalty_percent / 10000)` read from the median +`chain_properties_pm` **at settlement time**. Both inputs are median-voted, so a parameter vote +inside the dispute window changed the bill after the oracle had already announced the void: the +oracle announced one number and paid another, with no way to know the final price at announcement. + +**The fix: freeze the number at announcement.** `pm_no_contest_evaluator` computes the same formula +from the median of the **announcing block** and stores it on the market as +`pm_market_object::no_contest_fee`. `settle_market(win < 0)` charges exactly that amount instead of +re-reading the median. The insurance cap is unchanged and still applies per settlement: +`penalty = min(frozen_fee, oracle.insurance)`. + +**Two states, one flag.** `pm_market_object::no_contest_fee_frozen` says whether the amount was +frozen at all: + +* `true` — the void was announced **under HF16**; settlement charges `no_contest_fee` verbatim. +* `false` — the row was announced **before HF16** (or imported from a pre-HF16 snapshot); settlement + keeps the legacy median computation, exactly the pre-fork behaviour. + +The flag is separate from the amount on purpose. A frozen fee of **0** (the penalty percent was voted +to 0 at announcement) must stay 0 even if a later vote raises the percent — "frozen zero" and "never +frozen" are different states, and inferring legacy-ness from `fee == 0` would silently re-price the +former. A market announced under HF16 with no bets also records `frozen = true, fee = 0`; that is +inert, because settlement only charges when `settlement.stake_total > 0`. + +**Attribution counter.** `pm_oracle_object::no_contest_slashed_total` is a monotone total of +no-contest penalties charged to the oracle, incremented in the **same** `modify()` that grows +`total_insurance_slashed`. It gives the no-contest class exact attribution without a virtual +operation, and it accumulates on both paths (a legacy row that settles after activation is still a +no-contest slash). It is forward-accumulating from HF16: penalties charged before the fork are **not** +backfilled — the aggregate delta is the only surviving record, and subtracting it later would be a +guess. Display/attribution only; consensus never reads it. + +**Object layout.** Both fields are **appended at the end** of their structs and to the corresponding +`FC_REFLECT`: + +* `pm_market_object`: `share_type no_contest_fee; bool no_contest_fee_frozen = false;` +* `pm_oracle_object`: `share_type no_contest_slashed_total;` + +Snapshot import is `contains()`-guarded for all three, so a pre-HF16 snapshot imports with defaults +(`0` / `false`) — which is precisely the "legacy row" state the settlement expects. Snapshot export is +generic (`EXPORT_INDEX`) and needs no change. `apply_hardfork` has no case for HF16 (`default: break` +is correct): there is no singleton to create and nothing to recompute on activation. + +**What ships without the fork.** These read-only additions are useful before activation and are not +gated — they derive from the same state the settlement will read: + +* `get_oracle` (and the oracle block in `get_market_full`) gains `no_contest_pending_count`, + `no_contest_pending_stake`, `no_contest_fee_nominal`, `no_contest_fee_capped` — the price of the + voids this oracle has announced and that are still inside the dispute window (status 3, payout + pending, no dispute filed) and carry stake. Nominal and insurance-capped figures are both reported, + because a cap hit would otherwise hide the nominal cost. Computed on read, per-block cached. +* The market card gains `settlement_charge`: the amount that would be slashed if this market settled + as a void **now** (0 for anything that is not a pending no-contest with stake). +* `get_pm_chain_properties` keeps every existing field and adds + `pm_no_contest_fee_per_market = dispute_fee × penalty_percent / 10000` — the nominal unit clients + and scripts otherwise recompute by hand. The RPC now returns a variant; JSON clients see the same + object plus one key. + +## 2. How the fork is registered and activated + +* **Registration is unconditional in both configs.** `0-preamble.hf` sets `CHAIN_NUM_HARDFORKS` to 16; + `hardfork.d/16.hf` defines `CHAIN_HARDFORK_16` / `CHAIN_PM_NO_CONTEST_FREEZE_HARDFORK` and + `CHAIN_HARDFORK_16_VERSION`; `database_hardfork.cpp` registers `_hardfork_times[16]` / + `_hardfork_versions[16]` for every build. The arrays are sized `[CHAIN_NUM_HARDFORKS + 1]`. What gates + the fork is the timestamp plus quorum, not the build flavour — the same rule as HF15, and for the + same reason: the shelter testnet is a **production-config** deployment, so a testnet-only fork could + never activate there. See `pm-audit-fix-upgrade.md` §2 for the full argument. +* **Activation: 2026-10-31 00:00:00 UTC** (`CHAIN_HARDFORK_16_TIME` = 1793404800 in both configs, set + by the owner on 2026-10-20). The validator announcement must name this moment, and 17 of 21 + validators must run the 4.2.0 binary before it. Keep the value in the **future**: + a past timestamp activates the fork in whatever block quorum is reached, with no announceable + moment. Change it together with the announcement text, never after it is published. +* **Version, not revision.** `CHAIN_VERSION` moves to `4.2.0` in **both** `config.hpp` and + `config_testnet.hpp`; the middle component is the hardfork version the validators vote, and + `database_hardfork.cpp` asserts it equals `_hardfork_versions[CHAIN_NUM_HARDFORKS]`. (The two configs + previously carried 4.1.2 and 4.1.0 — both move to the same 4.2.0.) +* **Activation checklist.** (1) Confirm or replace the provisional date and announce it. (2) Measure + the population in §4 on a fresh snapshot and settle the question there (expected answer: no action). + (3) Ship the image; validator votes are automatic. (4) Confirm activation from the node log and run + the checks in §3. (5) Note the emergency-consensus trap from `pm-audit-fix-upgrade.md` §3: on a chain + in emergency consensus no fork can be scheduled at all, and the compiled time is a dead knob. + `testnet_plugin --testnet-hardfork 16` forces the fork at startup on a production-config testnet for + verification; the plugin does nothing unless both loaded and given a target. +* **Rollback.** Before the timestamp, redeploying the previous image is safe — the fork stays pending. + After activation the marker is chain state: do not roll back (a pre-HF16 binary would settle pending + no-contests from the median while the chain says otherwise). Roll forward. + +## 3. Verification + +**Before activation** (the read-only additions work on the running chain): + +* `get_pm_chain_properties` contains `pm_no_contest_fee_per_market`, numerically equal to + `pm_dispute_fee × pm_no_contest_penalty_percent / 10000`. +* For an oracle with a void in its dispute window and stake on it, `get_oracle` reports + `no_contest_pending_count ≥ 1` and `no_contest_fee_nominal = count × unit`; the matching market + card reports the per-market `settlement_charge`. +* No market carries `no_contest_fee_frozen = true` yet — the flag is written only under HF16. + +**After activation:** + +* a market voided by `pm_no_contest` immediately shows `no_contest_fee_frozen = true` and + `no_contest_fee` equal to the announcing block's unit; +* vote `pm_dispute_fee` / `pm_no_contest_penalty_percent` while that market sits in its dispute window: + the eventual slash equals the frozen amount, not the new median; +* a market announced **before** activation with `frozen = false` settles from the then-current median, + i.e. pre-fork behaviour is preserved for it; +* `oracle.no_contest_slashed_total` grows by exactly the charged amount, in step with + `total_insurance_slashed`; +* the usual post-redeploy invariants: SHARES delta 0, TOKEN delta equal to the chain's legacy anchor, + `restarts 0`, listings non-empty. + +**Consensus regression.** `tests/consensus_sim/scenarios/test_pm_no_contest_fee.cpp` (BUILD_TESTNET) +runs one chain with three markets announced around the fork point — pre-fork legacy, post-fork frozen, +post-fork frozen at zero — moves the median afterwards, and checks that the insurance drop and +`no_contest_slashed_total` equal `8u + 1u + 0` for the legacy/frozen/zero legs respectively. +Measured 2026-10-19 (BUILD_TESTNET, `consensus_sim_tests --run_test=pm_no_contest_fee_three_states`): +insurance `5 000 000 → 4 100 000` raw, i.e. exactly `9u`; the test passed with no errors. + +## 4. Rows already in the dispute window at activation + +**No seed pass, and a seed pass would be wrong.** A row that was announced before the fork has no +frozen amount; it must keep the pre-fork rule (median at settlement), because that is the rule under +which it was announced. Writing a frozen amount into it at activation would change the oracle's bill +retroactively — the very defect HF16 removes. The `false` flag is the marker that makes this +automatic: no migration code, no snapshot rewriting, and an old snapshot restores into exactly the +state the settlement expects. + +The residual exposure is therefore bounded and explicit: rows announced pre-fork **and** still inside +the dispute window at activation can still be re-priced by a vote that lands between activation and +their settlement. Their number is measurable from the chain; if it is non-zero and the network wants +no retroactive repricing at all, the only clean response is to wait out those windows before any +parameter vote — not a migration. + +**Measurement before scheduling.** Walk the oracles' pending no-contest rows (status 3, payout +pending, `resolved_outcome = -1`) and count those with `bets_sum > 0`, plus the stake they carry; +`get_oracle` reports exactly this per oracle (`no_contest_pending_count` / `_stake`), so no new +instrument is needed. Measured on the live production-config testnet on **2026-10-19**: 112 markets in +the dispute window, **0** of them carrying stake — the population that could be affected by a vote +inside the window was empty at that moment. Re-measure on a fresh snapshot right before scheduling; +the number moves with the oracle population and says nothing about the future. diff --git a/docs/protocol/operations/committee.md b/docs/protocol/operations/committee.md index 072a9dbf74..07aa433493 100644 --- a/docs/protocol/operations/committee.md +++ b/docs/protocol/operations/committee.md @@ -102,3 +102,5 @@ Votes on a funding request. Voting power is proportional to the voter's SHARES s --- See also: [Data Types](../data-types.md), [Operations Overview](./overview.md), [Virtual Operations](../virtual-operations.md), [Committee Governance](../../governance/committee.md). + +**HF16 limit:** at creation, `required_amount_max.amount <= floor(current_supply.amount / 100)` in raw units (1% of current total VIZ, rounded down). Before HF16 the limit is 500,000 VIZ. This is not 1% of the committee fund; existing requests are not repriced. See [HF16](../../consensus/hf16.md). diff --git a/docs/protocol/operations/overview.md b/docs/protocol/operations/overview.md index 9b6d7d201a..e35fc2d21f 100644 --- a/docs/protocol/operations/overview.md +++ b/docs/protocol/operations/overview.md @@ -127,6 +127,7 @@ Virtual operations are generated by the blockchain itself during block processin | 103 | `pm_early_exit_claim_paid_operation` | Early-exit deferred claim paid at settlement | [Prediction Markets](./prediction-markets.md) | | 104 | `pm_lp_payout_operation` | LP income paid at settlement | [Prediction Markets](./prediction-markets.md) | | 105 | `set_agent_permission_operation` | Grant, replace or revoke an agent key (HF15) | [Agent access](./agent-access.md) | +| 106 | `validator_equivocation_operation` | Validator double-vote proven (HF16 QC) | [Virtual Ops](../virtual-operations.md) | --- diff --git a/docs/protocol/virtual-operations.md b/docs/protocol/virtual-operations.md index b0ac23d5b7..8adbf44f59 100644 --- a/docs/protocol/virtual-operations.md +++ b/docs/protocol/virtual-operations.md @@ -377,6 +377,13 @@ See [Prediction Market Operations](./operations/prediction-markets.md). (IDs 91 All PM money movement is strictly zero-sum (no emission); settlement conserves `Σ out == Σ bets + LP principal + forfeit_pool`. + +### Consensus (HF16 QC) + +| ID | Operation | Trigger | +|----|-----------|---------| +| 106 | `validator_equivocation_operation` | A block carried proof that `validator` signed votes for two different blocks (`first_block_id`, `second_block_id`) in the same `round`. Penalised like a missed block (`total_missed++`, `penalty_percent += validator_miss_penalty_percent` for `validator_miss_penalty_duration`); at most once per round (`last_equivocation_round`) | + --- See also: [Operations Overview](./operations/overview.md), [Awards](./operations/awards.md), [Committee](./operations/committee.md). diff --git a/libraries/chain/chain_properties_evaluators.cpp b/libraries/chain/chain_properties_evaluators.cpp index 0e3e130ff5..9aa89f97f7 100644 --- a/libraries/chain/chain_properties_evaluators.cpp +++ b/libraries/chain/chain_properties_evaluators.cpp @@ -99,17 +99,42 @@ namespace graphene { namespace chain { FC_ASSERT( _db.has_hardfork(CHAIN_HARDFORK_4), "versioned_chain_properties_update_evaluator not enabled until HF 4" ); _db.get_account(o.owner); // verify owner exists + // #6737: protocol validate() allows up to 3 years of 1 s blocks; until HF16 the old + // one-year-of-3-s-blocks bound stays consensus (the stored props are checked after the + // visit, so a rejected op leaves nothing behind - the whole op is rolled back). + const auto check_year_bound = [&](const chain_properties& p) { + const bool hf16 = _db.has_hardfork(CHAIN_HARDFORK_16); + const uint64_t reveal_blocks = hf16 + ? uint64_t(p.pm_batch_epoch_blocks) + p.pm_reveal_window_blocks + : uint64_t(uint32_t(p.pm_batch_epoch_blocks + p.pm_reveal_window_blocks)); + FC_ASSERT(p.pm_closed_market_retention_sec > reveal_blocks * _db.block_interval(), + "retention must exceed the live worst-case commit reveal deadline"); + if (_db.has_hardfork(CHAIN_HARDFORK_16)) { + FC_ASSERT(p.distribution_epoch_length >= + CHAIN_MIN_DISTRIBUTION_EPOCH_LENGTH * (CHAIN_BLOCK_INTERVAL / _db.block_interval()), + "distribution_epoch_length must preserve the minimum epoch duration after HF16"); + return; + } + FC_ASSERT(p.inflation_recalc_period <= CHAIN_BLOCKS_PER_YEAR, + "inflation_recalc_period must not exceed one year in blocks before HF16"); + FC_ASSERT(p.distribution_epoch_length <= uint32_t(CHAIN_BLOCKS_PER_YEAR), + "distribution_epoch_length must not exceed one year in blocks before HF16"); + }; + const auto &idx = _db.get_index().indices().get(); auto itr = idx.find(o.owner); + // Validate a detached candidate. A throwing multi_index modifier may erase + // the live object before transaction undo can restore it. + chain_properties candidate = itr != idx.end() ? itr->props : chain_properties(); + o.props.visit(chain_properties_update(_db, candidate)); + check_year_bound(candidate); if (itr != idx.end()) { - _db.modify(*itr, [&](validator_object& w) { - o.props.visit(chain_properties_update(_db, w.props)); - }); + _db.modify(*itr, [&](validator_object& w) { w.props = candidate; }); } else { _db.create([&](validator_object& w) { w.owner = o.owner; w.created = _db.head_block_time(); - o.props.visit(chain_properties_update(_db, w.props)); + w.props = candidate; }); } } diff --git a/libraries/chain/committee_evaluator.cpp b/libraries/chain/committee_evaluator.cpp index e057e44802..0da8396c34 100644 --- a/libraries/chain/committee_evaluator.cpp +++ b/libraries/chain/committee_evaluator.cpp @@ -6,6 +6,15 @@ namespace graphene { namespace chain { void committee_worker_create_request_evaluator::do_apply(const committee_worker_create_request_operation& o) { + // Before HF16 preserve the historical fixed ceiling for replay. From HF16, + // use one percent of the current total supply, rounded down to raw units. + const auto& supply = _db.get_dynamic_global_properties().current_supply; + const share_type maximum = _db.has_hardfork(CHAIN_HARDFORK_16) + ? share_type(supply.amount.value / 100) + : share_type(COMMITTEE_MAX_REQUIRED_AMOUNT); + FC_ASSERT(o.required_amount_max.amount <= maximum, + "Committee request exceeds maximum ${maximum}", + ("maximum", asset(maximum, TOKEN_SYMBOL))); const auto& median_props = _db.get_validator_schedule_object().median_props; const auto &creator = _db.get_account(o.creator); _db.get_account(o.worker); diff --git a/libraries/chain/database.cpp b/libraries/chain/database.cpp index d94caabedd..3fb6d9492f 100644 --- a/libraries/chain/database.cpp +++ b/libraries/chain/database.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include @@ -1939,9 +1940,8 @@ namespace graphene { namespace chain { // have the full signed_block from the peer — fixes that. // _repair_child_prev_links reconnects the start_block root // so fetch_branch_from can walk the full slave chain. - if (!_fork_db.is_known_block(new_block.id())) { - _fork_db.insert_as_base(new_block); - } + // insert_as_base also fills an existing data-less anchor. + _fork_db.insert_as_base(new_block); return false; } // Block is at or before head but on a different fork. @@ -2104,6 +2104,38 @@ namespace graphene { namespace chain { } _maybe_warn_multiple_production(new_head->num); + // Signature admission controls certificate priority only. A legitimate + // competing branch can have a different key/schedule from current state; + // if cheap verification cannot establish its certificate, retain the + // historical longer-chain path and let full branch replay validate it. + const auto admitted_round = [&](const signed_block &b) -> uint32_t { + try { return verified_fork_cert_round(b); } + catch (const fc::exception &) { return 0; } + }; + + // HF16: fork_db keeps the first block at a height as its head, so a + // same-height sibling never reached the certificate comparison below: + // a TC replacement of a QC-less head (Jolteon) was stored and never + // applied, every next producer built one more sibling and the chain + // stalled for good (21-validator stand, netem 300 ms: 5 blocks #194). + // Offer the pushed block as the candidate when it proves a later + // round; signatures and quorum are checked before changing the fork head. + // It may also sit LOWER than head: after a TC the proposal extends the + // highest certified block, below a late-QC head and its parent. + if (has_hardfork(CHAIN_HARDFORK_16) && + !get_dynamic_global_properties().emergency_consensus_active && + new_head && !new_head->anchor_only && + new_head->id != new_block.id() && + new_block.block_num() <= new_head->num && // equal, or below a late-QC head + new_block.block_num() > get_dynamic_global_properties().last_irreversible_block_num && + admitted_round(new_block) > block_cert_round(new_head->data)) { + auto pushed = _fork_db.fetch_block(new_block.id()); + if (pushed && !pushed->invalid) { + _fork_db.set_head(pushed); + new_head = pushed; + } + } + // If the block we just pushed directly extends our database head, // it is a simple linear extension regardless of what fork_db thinks // is the longest chain. This handles the case where fork_db's _head @@ -2126,7 +2158,24 @@ namespace graphene { namespace chain { //Only switch forks if new_head is actually higher than head bool should_switch = false; try { - if (has_hardfork(CHAIN_HARDFORK_12)) { + // HF16: the branch whose tip carries the certificate of the + // later round wins, even at the same height. The certificate + // has passed signature/quorum admission before rollback. Full + // branch-dependent validation still runs during apply_block. + // Equal rounds fall through to the HF12 rule. + int cert_cmp = 0; + if (has_hardfork(CHAIN_HARDFORK_16) && + !get_dynamic_global_properties().emergency_consensus_active) { + const auto head_blk = fetch_block_by_id(head_block_id()); + if (head_blk) { + const uint32_t new_r = admitted_round(new_head->data); + const uint32_t head_r = block_cert_round(*head_blk); + if (new_r) cert_cmp = new_r > head_r ? 1 : (new_r < head_r ? -1 : 0); + } + } + if (cert_cmp != 0) { + should_switch = cert_cmp > 0; + } else if (has_hardfork(CHAIN_HARDFORK_12)) { // HF12: Vote-weighted chain comparison with +10% longer-chain bonus if (new_head->data.block_num() >= head_block_num() && _fork_db.is_known_block(head_block_id())) { @@ -2695,11 +2744,13 @@ namespace graphene { namespace chain { fc::time_point_sec when, const account_name_type &validator_owner, const fc::ecc::private_key &block_signing_private_key, - uint32_t skip /* = 0 */ + uint32_t skip /* = 0 */, + const fc::optional &tc, + const fc::optional &qc_override ) { signed_block result; try { - result = _generate_block(when, validator_owner, block_signing_private_key, skip); + result = _generate_block(when, validator_owner, block_signing_private_key, skip, tc, qc_override); } FC_CAPTURE_AND_RETHROW((validator_owner)) return result; @@ -2710,7 +2761,9 @@ namespace graphene { namespace chain { fc::time_point_sec when, const account_name_type &validator_owner, const fc::ecc::private_key &block_signing_private_key, - uint32_t skip + uint32_t skip, + const fc::optional &tc, + const fc::optional &qc_override ) { // Apply any deferred resize BEFORE the lockless reads below. // This is critical: get_slot_at_time(), get_validator(), find_account() @@ -2742,9 +2795,12 @@ namespace graphene { namespace chain { const auto &validator_obj = get_validator(validator_owner); - if (!(skip & skip_validator_signature)) - FC_ASSERT(validator_obj.signing_key == - block_signing_private_key.get_public_key()); + // NOTE: the signing-key check is deliberately NOT done here. At this point + // the session-visible validator object may already contain a pending + // validator_update that changes our own signing key, while the block header + // is validated against the pre-block (committed) key by + // validate_block_header(). The check is done below, after + // _pending_tx_session.reset() has restored committed head state. // Lockless hint: does the validator account exist? // op_guard coordinates with resize only — it does NOT exclude writers. @@ -2797,6 +2853,9 @@ namespace graphene { namespace chain { size_t total_block_size = max_block_header_size; signed_block pending_block; + // Signing key of our validator as committed at the head block, captured + // while no pending session is active (see the assert below). + public_key_type committed_signing_key; with_strong_write_lock([&]() { // @@ -2857,6 +2916,12 @@ namespace graphene { namespace chain { } _pending_tx_session.reset(); + + // Committed head state is visible again. Capture the key here, under the + // same write lock: a concurrent push_transaction() after the lock is + // released could otherwise re-apply a pending key change and make the + // post-lock read see uncommitted state again. + committed_signing_key = get_validator(validator_owner).signing_key; }); // We have temporarily broken the invariant that @@ -2872,10 +2937,61 @@ namespace graphene { namespace chain { const auto &validator = get_validator(validator_owner); + // The header of THIS block is validated against the validator signing key + // committed at the previous (head) block — a pending validator_update that + // changes our own key takes effect only for the NEXT block. Verify against + // the committed object (visible again after the session reset above), not + // against the session-visible one used at the top of this function. + if (!(skip & skip_validator_signature)) + FC_ASSERT(committed_signing_key == block_signing_private_key.get_public_key(), + "Block signing key does not match the signing key committed at head " + "(a pending validator_update must not change the key used to sign " + "the current block)", + ("validator", validator_owner) + ("committed_key", committed_signing_key) + ("signing_key", block_signing_private_key.get_public_key())); + if (validator.running_version != CHAIN_VERSION) { pending_block.extensions.insert(block_header_extensions(CHAIN_VERSION)); } + // HF16: hand the turn over only on a certified parent. The validator + // plugin waits for the QC before calling us; reaching here without + // one is a bug, and the block would be rejected anyway. + if (block_needs_parent_qc(validator_owner)) { + fc::optional cert = qc_override; + if (!cert) cert = parent_qc_for_next_block(validator_owner); + FC_ASSERT(cert.valid(), "No QC for head #${n} yet: cannot hand over the turn", + ("n", head_block_num())); + FC_ASSERT(!(qc_override && tc), "The late-QC fallback carries no TC"); + pending_block.extensions.insert(block_header_extensions(*cert)); + if (tc) pending_block.extensions.insert(block_header_extensions(*tc)); + + // Double-vote proofs: newest round per validator that the chain has + // not punished yet; a few per block keeps headers small. + std::lock_guard guard(_block_votes_mutex); + const uint32_t own_round = round_of(when); + std::map pick; + for (auto it = _pending_equivocations.begin(); it != _pending_equivocations.end(); ) { + const auto &p = it->second; + const auto *v = find_validator(p.validator); + if (!v || p.round <= v->last_equivocation_round || uint64_t(p.round) + 3600 / block_interval() < own_round) { + it = _pending_equivocations.erase(it); // punished already or too old + continue; + } + auto &slot = pick[p.validator]; + if (!slot || slot->round < p.round) slot = &p; + ++it; + } + size_t n = 0; + for (const auto &kv : pick) { + if (n++ >= 5) break; + pending_block.extensions.insert(block_header_extensions(*kv.second)); + } + } + FC_ASSERT(!tc || block_needs_parent_qc(validator_owner), + "A TC goes only next to a parent QC"); + const auto &hfp = get_hardfork_property_object(); // Emergency committee validator should NOT vote for future hardforks @@ -3059,7 +3175,7 @@ namespace graphene { namespace chain { return fc::time_point_sec(); } - auto interval = CHAIN_BLOCK_INTERVAL; + auto interval = block_interval(); const dynamic_global_property_object &dpo = get_dynamic_global_properties(); if (head_block_num() == 0) { @@ -3084,7 +3200,26 @@ namespace graphene { namespace chain { return 0; } return (when - first_slot_time).to_seconds() / - CHAIN_BLOCK_INTERVAL + 1; + block_interval() + 1; + } + + uint32_t database::block_interval() const { + return has_hardfork(CHAIN_HARDFORK_16) ? CHAIN_BLOCK_INTERVAL_HF16 : CHAIN_BLOCK_INTERVAL; + } + + uint32_t database::round_of(fc::time_point_sec t) const { + const auto& dgp = get_dynamic_global_properties(); + const auto a = dgp.round_anchor_time; + if (a == fc::time_point_sec()) { + return t.sec_since_epoch() / CHAIN_BLOCK_INTERVAL; + } + if (t <= a) { + // Parents/QCs just before the anchor. Only one re-anchor exists (HF16), so the + // segment before it is 3 s; a later re-anchoring HF must keep its own previous + // interval here instead of CHAIN_BLOCK_INTERVAL. + return dgp.round_anchor_round - (a.sec_since_epoch() - t.sec_since_epoch()) / CHAIN_BLOCK_INTERVAL; + } + return dgp.round_anchor_round + (t.sec_since_epoch() - a.sec_since_epoch()) / block_interval(); } void database::shares_sender_recalc_energy(const account_object &sender, asset tokens) { @@ -3143,7 +3278,8 @@ namespace graphene { namespace chain { } void database::update_bandwidth_reserve_candidates() { - if ((head_block_num() % CHAIN_BLOCKS_PER_HOUR ) != 0) return; + // HF16: keep the hourly cadence in wall time (3x more blocks per hour at 1 s). + if ((head_block_num() % (CHAIN_BLOCKS_PER_HOUR * (CHAIN_BLOCK_INTERVAL / block_interval()))) != 0) return; uint32_t bandwidth_reserve_candidates = 1; const auto &gprops = get_dynamic_global_properties(); const validator_schedule_object &consensus = get_validator_schedule_object(); @@ -3329,7 +3465,8 @@ namespace graphene { namespace chain { } } } - if ((head_block_num() % COMMITTEE_REQUEST_PROCESSING ) != 0) return; + // HF16: same wall-time cadence as with 3 s slots. + if ((head_block_num() % (COMMITTEE_REQUEST_PROCESSING * (CHAIN_BLOCK_INTERVAL / block_interval()))) != 0) return; uint32_t committee_payment_request_count = 1; const auto &idx4_count = get_index().indices().get(); auto itr3 = idx4_count.lower_bound(4); @@ -5917,6 +6054,14 @@ namespace graphene { namespace chain { const validator_object &signing_validator = validate_block_header(skip, next_block); + // HF16: must run before current_validator is set to this block's + // producer below — the handoff rule compares against the parent's. + validate_block_qc(next_block, skip); + apply_equivocation_proofs(next_block, skip); + for (const auto &e : next_block.extensions) { + if (e.which() == 3) remember_block_qc(e.get()); + } + _current_block_num = next_block_num; _current_trx_in_block = 0; _current_virtual_op = 0; @@ -6069,6 +6214,10 @@ namespace graphene { namespace chain { break; } + case 3: // block_qc_certificate — verified in validate_block_qc() + case 4: // block_timeout_certificate — verified in validate_block_qc() + case 5: // block_equivocation_proof — verified in apply_equivocation_proofs() + break; default: FC_ASSERT(false, "Unknown extension in block header"); } @@ -6258,7 +6407,14 @@ namespace graphene { namespace chain { uint32_t missed_blocks = 0; if (head_block_time() != fc::time_point_sec()) { missed_blocks = get_slot_at_time(b.timestamp); - assert(missed_blocks != 0); + // assert() is compiled out in release builds: a slot of 0 (block time + // before the first slot, e.g. post-HF16 1 s blocks replayed while the + // node still applies the 3 s interval) wrapped missed_blocks to 2^32-1 + // and the miss loop below ran for hours (21-validator stand, replay with + // the fork forced only after replay: block numbers past #20M, 7 GB log). + FC_ASSERT(missed_blocks != 0, + "Block #${n} time ${t} is before the first slot ${s} (interval ${i} s)", + ("n", b.block_num())("t", b.timestamp)("s", get_slot_time(1))("i", block_interval())); missed_blocks--; for (uint32_t i = 0; i < missed_blocks; ++i) { const auto &validator_missed = get_validator(get_scheduled_validator(i + 1)); @@ -6320,11 +6476,11 @@ namespace graphene { namespace chain { if (w.signing_key != public_key_type() && head_block_num() - w.last_confirmed_block_num > - CHAIN_EMERGENCY_MAX_VALIDATOR_MISSED_BLOCKS) { + CHAIN_EMERGENCY_MAX_VALIDATOR_MISSED_BLOCKS * (CHAIN_BLOCK_INTERVAL / block_interval())) { elog("Emergency consensus: Validator ${w} missed ${missed} blocks since last confirmed ${lc} " "(threshold=${t}), blanking signing_key", ("w", w.owner)("missed", head_block_num() - w.last_confirmed_block_num) - ("lc", w.last_confirmed_block_num)("t", CHAIN_EMERGENCY_MAX_VALIDATOR_MISSED_BLOCKS)); + ("lc", w.last_confirmed_block_num)("t", CHAIN_EMERGENCY_MAX_VALIDATOR_MISSED_BLOCKS * (CHAIN_BLOCK_INTERVAL / block_interval()))); w.signing_key = public_key_type(); push_virtual_operation(shutdown_validator_operation(w.owner)); } @@ -6350,7 +6506,7 @@ namespace graphene { namespace chain { if (w.signing_key != public_key_type() && head_block_num() - w.last_confirmed_block_num > - CHAIN_MAX_VALIDATOR_MISSED_BLOCKS) { + CHAIN_MAX_VALIDATOR_MISSED_BLOCKS * (CHAIN_BLOCK_INTERVAL / block_interval())) { elog("Validator ${w} missed too many blocks (${missed} since last confirmed ${lc}), blanking signing_key (was ${k})", ("w", w.owner)("missed", head_block_num() - w.last_confirmed_block_num) ("lc", w.last_confirmed_block_num)("k", w.signing_key)); @@ -6394,7 +6550,7 @@ namespace graphene { namespace chain { * If the network reserve ratio falls under 100 then it is probably time to * increase the capacity of the network. */ - if (dgp.head_block_number % 20 == 0) { + if (dgp.head_block_number % (60 / block_interval()) == 0) { if (dgp.average_block_size > dgp.maximum_block_size / 4) { dgp.current_reserve_ratio /= 2; /// exponential back up } else { /// linear growth... not much fine grain control near full capacity @@ -6416,18 +6572,23 @@ namespace graphene { namespace chain { dgp.current_reserve_ratio * CHAIN_BANDWIDTH_PRECISION * CHAIN_BANDWIDTH_AVERAGE_WINDOW_SECONDS) / - CHAIN_BLOCK_INTERVAL; + block_interval(); }); if (!(skip & skip_undo_history_check)) { + // HF16 (1 s blocks, Q#3108=A): the undo window is counted in blocks, so keep it equal in TIME — + // 3× the blocks, otherwise a LIB stall halts the chain after 2.8 h instead of 8.3 h. + const uint32_t max_undo = has_hardfork(CHAIN_HARDFORK_16) + ? CHAIN_MAX_UNDO_HISTORY * (CHAIN_BLOCK_INTERVAL / CHAIN_BLOCK_INTERVAL_HF16) + : CHAIN_MAX_UNDO_HISTORY; CHAIN_ASSERT( - _dgp.head_block_number - _dgp.last_irreversible_block_num < CHAIN_MAX_UNDO_HISTORY, + _dgp.head_block_number - _dgp.last_irreversible_block_num < max_undo, undo_database_exception, "The database does not have enough undo history to support a blockchain with so many missed blocks. " "Please add a checkpoint if you would like to continue applying blocks beyond this point.", ("last_irreversible_block_num", _dgp.last_irreversible_block_num) ("head", _dgp.head_block_number) - ("max_undo", CHAIN_MAX_UNDO_HISTORY)); + ("max_undo", max_undo)); } // === HARDFORK 12: EMERGENCY CONSENSUS MODE ACTIVATION === @@ -6452,7 +6613,7 @@ namespace graphene { namespace chain { // dgp.time is advanced to b.timestamp), so it is identical on // every node and during every replay. No block_log fetch, // config flag, wall-clock time, or skip-flag gates it. - uint32_t gap_sec = (missed_blocks + 1) * CHAIN_BLOCK_INTERVAL; + uint32_t gap_sec = (missed_blocks + 1) * block_interval(); { if (gap_sec >= CHAIN_EMERGENCY_CONSENSUS_TIMEOUT_SEC) { @@ -6723,8 +6884,13 @@ namespace graphene { namespace chain { }); } - _fork_db.set_max_size(dpo.head_block_number - - dpo.last_irreversible_block_num + 1); + // fork_db may be ahead of applied state after linking a deferred batch. + // Its pruning window is measured from ITS head, so retain through + // our LIB rather than pruning still-needed ancestors during catchup. + _fork_db.set_max_size( + std::max(dpo.head_block_number, + _fork_db.head() ? _fork_db.head()->num : uint32_t(0)) - + dpo.last_irreversible_block_num + 1); remove_validated=true; } } @@ -6931,8 +7097,13 @@ namespace graphene { namespace chain { }); } - _fork_db.set_max_size(dpo.head_block_number - - dpo.last_irreversible_block_num + 1); + // fork_db may be ahead of applied state after linking a deferred batch. + // Its pruning window is measured from ITS head, so retain through + // our LIB rather than pruning still-needed ancestors during catchup. + _fork_db.set_max_size( + std::max(dpo.head_block_number, + _fork_db.head() ? _fork_db.head()->num : uint32_t(0)) - + dpo.last_irreversible_block_num + 1); } } } @@ -6974,6 +7145,548 @@ namespace graphene { namespace chain { //create block post validation object with current shuffled validators //remove old block post validation objects //remove old blocks from post validation list if it is full (CHAIN_MAX_BLOCK_POST_VALIDATION_COUNT) + uint32_t database::block_qc_quorum(uint32_t num_scheduled) { + const uint32_t two_thirds_plus = num_scheduled * 2 / 3 + 1; + const uint32_t threshold = uint32_t(uint64_t(num_scheduled) * CHAIN_IRREVERSIBLE_THRESHOLD / CHAIN_100_PERCENT); + return std::max(two_thirds_plus, threshold); + } + + bool database::verify_block_vote(uint32_t block_num, const block_id_type &block_id, uint32_t round, + const account_name_type &validator, const signature_type &signature) const { + const auto &wso = get_validator_schedule_object(); + bool scheduled = false; + for (int i = 0; i < wso.num_scheduled_validators; i += CHAIN_BLOCK_VALIDATOR_REPEAT) { + if (wso.current_shuffled_validators[i] == validator) { scheduled = true; break; } + } + if (!scheduled) return false; + + const auto *v = find_validator(validator); + if (!v || v->signing_key == public_key_type()) return false; + + const auto digest = protocol::block_vote_digest(get_chain_id(), block_num, block_id, round); + try { + return public_key_type(fc::ecc::public_key(signature, digest)) == v->signing_key; + } catch (const fc::exception &) { + return false; // unrecoverable signature + } + } + + bool database::verify_timeout_vote(uint32_t round, uint32_t high_qc_round, + const account_name_type &validator, const signature_type &signature) const { + // A QC seen while timing out round r is from an earlier round. + if (high_qc_round >= round) return false; + const auto &wso = get_validator_schedule_object(); + bool scheduled = false; + for (int i = 0; i < wso.num_scheduled_validators; i += CHAIN_BLOCK_VALIDATOR_REPEAT) { + if (wso.current_shuffled_validators[i] == validator) { scheduled = true; break; } + } + if (!scheduled) return false; + + const auto *v = find_validator(validator); + if (!v || v->signing_key == public_key_type()) return false; + + const auto digest = protocol::block_timeout_digest(get_chain_id(), round, high_qc_round); + try { + return public_key_type(fc::ecc::public_key(signature, digest)) == v->signing_key; + } catch (const fc::exception &) { + return false; // unrecoverable signature + } + } + + bool database::push_timeout_vote(uint32_t round, uint32_t high_qc_round, + const account_name_type &validator, const signature_type &signature) { + // Same window as block votes: bounded memory for valid-but-absurd rounds. + static constexpr uint32_t MAX_VOTE_AHEAD_ROUNDS = 64; + const uint32_t MAX_VOTE_ROUND_BEHIND = 3600 / block_interval(); // preserve the one-hour evidence window + const uint32_t head_round = round_of(head_block_time()); + if (uint64_t(round) + MAX_VOTE_ROUND_BEHIND < head_round) return false; + // Bound "ahead" by the wall-clock round, not head's: timeout votes exist + // precisely while head is stuck. Measured from head, every vote is refused + // ~64 s into a stall, no TC can form any more and the chain never recovers, + // even after the network heals (21-validator stand, netem 300 ms, 22.10). + // The vote pool is off-chain state, so a local clock bound is fine here. + const uint32_t now_round = std::max(head_round, + round_of(fc::time_point_sec(fc::time_point::now()))); + if (round > now_round + MAX_VOTE_AHEAD_ROUNDS) return false; + + if (!verify_timeout_vote(round, high_qc_round, validator, signature)) return false; + + const auto &wso = get_validator_schedule_object(); + const uint32_t quorum = block_qc_quorum(uint32_t(wso.num_scheduled_validators) / CHAIN_BLOCK_VALIDATOR_REPEAT); + + std::lock_guard guard(_block_votes_mutex); + + const uint32_t floor_round = head_round > MAX_VOTE_ROUND_BEHIND ? head_round - MAX_VOTE_ROUND_BEHIND : 0; + for (auto it = _timeout_votes.begin(); it != _timeout_votes.end() && it->first < floor_round; ) + it = _timeout_votes.erase(it); + for (auto it = _timeout_certs.begin(); it != _timeout_certs.end() && it->first < floor_round; ) + it = _timeout_certs.erase(it); + + // One timeout vote per (validator, round); a repeat (even with another + // high_qc_round) is ignored, the first one stands. + auto &votes = _timeout_votes[round]; + if (votes.count(validator)) return false; + protocol::block_timeout_vote tv; + tv.high_qc_round = high_qc_round; + tv.signature = signature; + votes.emplace(validator, tv); + + if (votes.size() >= quorum && _timeout_certs.find(round) == _timeout_certs.end()) { + protocol::block_timeout_certificate tc; + tc.round = round; + tc.votes.insert(votes.begin(), votes.end()); + ilog("TC formed for round ${r}: ${k}/${q} timeout votes", ("r", round)("k", votes.size())("q", quorum)); + _timeout_certs.emplace(round, std::move(tc)); + } + return true; + } + + void database::apply_equivocation_proofs(const signed_block &b, uint32_t skip) { + std::vector proofs; + for (const auto &e : b.extensions) + if (e.which() == 5) proofs.push_back(&e.get()); + if (proofs.empty()) return; + FC_ASSERT(has_hardfork(CHAIN_HARDFORK_16), "Double-vote proof in block #${n} before HF16", ("n", b.block_num())); + FC_ASSERT(proofs.size() <= 5, "Block #${n} carries ${k} double-vote proofs, at most 5", + ("n", b.block_num())("k", proofs.size())); + + const uint32_t own_round = round_of(b.timestamp); + std::set seen; + const auto &props = get_validator_schedule_object().median_props; + for (const auto *p : proofs) { + FC_ASSERT(seen.insert(p->validator).second, "Two double-vote proofs for ${v} in one block", ("v", p->validator)); + FC_ASSERT(p->first.block_id != p->second.block_id, "Double-vote proof for ${v}: same block twice", ("v", p->validator)); + FC_ASSERT(p->round < own_round && uint64_t(p->round) + 3600 / block_interval() >= own_round, + "Double-vote proof for ${v}: round ${r} outside the window", ("v", p->validator)("r", p->round)); + const auto &w = get_validator(p->validator); + FC_ASSERT(p->round > w.last_equivocation_round, + "Double vote of ${v} in round ${r} is already punished", ("v", p->validator)("r", p->round)); + if (!(skip & skip_validator_signature)) { + FC_ASSERT(verify_block_vote(p->first.block_num, p->first.block_id, p->round, p->validator, p->first.signature) && + verify_block_vote(p->second.block_num, p->second.block_id, p->round, p->validator, p->second.signature), + "Double-vote proof for ${v} does not verify", ("v", p->validator)); + } + + // Same punishment as a missed block (owner 20.10): penalty for the + // configured duration and one more total_missed. + modify(w, [&](validator_object &v) { + v.last_equivocation_round = p->round; + v.total_missed++; + v.penalty_percent += props.validator_miss_penalty_percent; + v.counted_votes = (fc::uint128_t(v.votes) - (fc::uint128_t(v.votes) * + std::min(v.penalty_percent, uint32_t(CHAIN_100_PERCENT)) / CHAIN_100_PERCENT)).to_uint64(); + }); + create([&](validator_penalty_expire_object &wpe) { + wpe.validator = p->validator; + wpe.penalty_percent = props.validator_miss_penalty_percent; + wpe.expires = b.timestamp + fc::seconds(props.validator_miss_penalty_duration); + }); + push_virtual_operation(protocol::validator_equivocation_operation( + p->validator, p->round, p->first.block_id, p->second.block_id)); + wlog("Validator ${v} punished for a double vote in round ${r} (block #${n})", + ("v", p->validator)("r", p->round)("n", b.block_num())); + } + } + + void database::remember_block_qc(const protocol::block_qc_certificate &cert) { + std::lock_guard guard(_block_votes_mutex); + // Same rule as the tally path: per height keep the QC with the HIGHEST round. A re-proposal after a + // TC carries a newer-round QC; keeping the first (possibly for an equivocating twin) pinned the height. + auto held = _block_qcs.find(cert.block_num); + if (held != _block_qcs.end()) { + if (held->second.round >= cert.round) return; + _block_qcs.erase(held); + } + block_qc qc; + qc.block_num = cert.block_num; + qc.block_id = cert.block_id; + qc.round = cert.round; + qc.signatures.assign(cert.signatures.begin(), cert.signatures.end()); + _block_qcs.emplace(cert.block_num, std::move(qc)); + } + + fc::optional database::get_timeout_cert(uint32_t round) const { + std::lock_guard guard(_block_votes_mutex); + auto it = _timeout_certs.find(round); + if (it == _timeout_certs.end()) return {}; + return it->second; + } + + bool database::block_needs_parent_qc(const account_name_type &producer) const { + if (!has_hardfork(CHAIN_HARDFORK_16)) return false; + const auto &dgp = get_dynamic_global_properties(); + if (dgp.emergency_consensus_active) return false; // committee alone cannot form a QC + if (dgp.head_block_number < 1) return false; + const auto &parent_producer = dgp.current_validator; // producer of head + if (parent_producer == account_name_type()) return false; + // No exemption for a validator's own series: the Jolteon commit rule + // needs every block certified, or a series could build past a parent + // that never gathered a quorum and LIB would stall on it. + (void)producer; + // First block after an emergency: committee blocks never get a QC + // (validators re-enter with fresh keys), so their child is exempt. + if (parent_producer == CHAIN_EMERGENCY_VALIDATOR_ACCOUNT) return false; + // Child of the HF16 activation block (the round anchor, the last 3 s slot): + // nothing below it carries a QC, so if its own QC does not form (votes lost to + // a network-wide restart, a hiccup at the switch) a TC cannot rescue it either + // (a TC needs the head's parent certified) and the chain stalls for good. + // 21-validator stand, 22.10: head #65 waited for a QC over 100+ slots. + if (dgp.round_anchor_time != fc::time_point_sec() && dgp.time <= dgp.round_anchor_time) return false; + return true; + } + + fc::optional database::parent_qc_for_next_block(const account_name_type &producer) const { + fc::optional result; + if (!block_needs_parent_qc(producer)) return result; + const auto qc = get_block_qc(head_block_num()); + if (!qc || qc->block_id != head_block_id()) return result; + + protocol::block_qc_certificate cert; + cert.block_num = qc->block_num; + cert.block_id = qc->block_id; + cert.round = qc->round; + // Re-verify against current state: the schedule or a signing key may + // have changed since the votes were tallied (round boundary), and the + // block must carry only signatures the consensus check will accept. + for (const auto &sv : qc->signatures) { + if (verify_block_vote(cert.block_num, cert.block_id, cert.round, sv.first, sv.second)) + cert.signatures.emplace(sv.first, sv.second); + } + const auto &wso = get_validator_schedule_object(); + if (cert.signatures.size() < block_qc_quorum(uint32_t(wso.num_scheduled_validators) / CHAIN_BLOCK_VALIDATOR_REPEAT)) + return result; + result = std::move(cert); + return result; + } + + fc::optional database::grandparent_qc_for_next_block( + const account_name_type &producer, uint32_t own_round) const { + fc::optional result; + if (!block_needs_parent_qc(producer) || head_block_num() < 2) return result; + const uint32_t head_round = round_of(head_block_time()); + if (head_round + 1 != own_round) return result; // rounds must run on, no gap + const auto head = fetch_block_by_number(head_block_num()); + if (!head) return result; + const protocol::block_qc_certificate *gp = nullptr; + for (const auto &e : head->extensions) + if (e.which() == 3) gp = &e.get(); + if (!gp || gp->block_id != head->previous || gp->round + 1 != head_round) return result; + + protocol::block_qc_certificate cert; + cert.block_num = gp->block_num; + cert.block_id = gp->block_id; + cert.round = gp->round; + for (const auto &sv : gp->signatures) { + if (verify_block_vote(cert.block_num, cert.block_id, cert.round, sv.first, sv.second)) + cert.signatures.emplace(sv.first, sv.second); + } + const auto &wso = get_validator_schedule_object(); + if (cert.signatures.size() < block_qc_quorum(uint32_t(wso.num_scheduled_validators) / CHAIN_BLOCK_VALIDATOR_REPEAT)) + return result; + result = std::move(cert); + return result; + } + + uint32_t database::block_cert_round(const signed_block &b) { + // Highest round a block proves: its parent QC, or a TC it carries + // (a TC for round r outranks any QC below r). Validity is checked + // when the block is applied. + uint32_t r = 0; + for (const auto &e : b.extensions) { + if (e.which() == 3) // block_qc_certificate + r = std::max(r, e.get().round); + else if (e.which() == 4) // block_timeout_certificate + r = std::max(r, e.get().round); + } + return r; + } + + // Admission check only: branch-dependent parent/schedule checks still run in apply_block. + // An unauthenticated peer must not buy a rollback with a made-up certificate round. + uint32_t database::verified_fork_cert_round(const signed_block &b) const { + const uint32_t round = block_cert_round(b); + if (!round) return 0; + const auto *producer = find_validator(b.validator); + FC_ASSERT(producer && b.validate_signee(producer->signing_key), "Invalid fork producer signature"); + const auto &wso = get_validator_schedule_object(); + const uint32_t scheduled = uint32_t(wso.num_scheduled_validators) / CHAIN_BLOCK_VALIDATOR_REPEAT; + bool producer_scheduled = false; + for (int i = 0; i < wso.num_scheduled_validators; i += CHAIN_BLOCK_VALIDATOR_REPEAT) + if (wso.current_shuffled_validators[i] == b.validator) producer_scheduled = true; + FC_ASSERT(producer_scheduled, "Fork producer is not in the current schedule"); + const uint32_t quorum = block_qc_quorum(scheduled); + unsigned qcs = 0, tcs = 0; + for (const auto &e : b.extensions) { + if (e.which() == 3) { + FC_ASSERT(++qcs == 1, "Duplicate fork QC"); + const auto &qc = e.get(); + FC_ASSERT(qc.signatures.size() >= quorum && qc.signatures.size() <= scheduled, + "Fork QC does not have a scheduled quorum"); + FC_ASSERT(protocol::block_header::num_from_id(qc.block_id) == qc.block_num && + qc.block_num < b.block_num() && qc.round < round_of(b.timestamp), + "Invalid fork QC height/round"); + for (const auto &v : qc.signatures) + FC_ASSERT(verify_block_vote(qc.block_num, qc.block_id, qc.round, v.first, v.second), + "Invalid fork QC vote"); + } else if (e.which() == 4) { + FC_ASSERT(++tcs == 1, "Duplicate fork TC"); + const auto &tc = e.get(); + FC_ASSERT(tc.votes.size() >= quorum && tc.votes.size() <= scheduled && + tc.round < round_of(b.timestamp), "Invalid fork TC quorum/round"); + for (const auto &v : tc.votes) + FC_ASSERT(verify_timeout_vote(tc.round, v.second.high_qc_round, v.first, v.second.signature), + "Invalid fork timeout vote"); + } + } + FC_ASSERT(!tcs || qcs, "Fork TC without QC"); + return round; + } + + void database::validate_block_qc(const signed_block &next_block, uint32_t skip) const { + const protocol::block_qc_certificate *cert = nullptr; + const protocol::block_timeout_certificate *tc = nullptr; + for (const auto &e : next_block.extensions) { + if (e.which() == 3) { // block_qc_certificate (index in block_header_extensions) + FC_ASSERT(cert == nullptr, "Block #${n} carries two parent QCs", ("n", next_block.block_num())); + cert = &e.get(); + } else if (e.which() == 4) { // block_timeout_certificate + FC_ASSERT(tc == nullptr, "Block #${n} carries two TCs", ("n", next_block.block_num())); + tc = &e.get(); + } + } + if (!has_hardfork(CHAIN_HARDFORK_16)) { + FC_ASSERT(cert == nullptr, "Parent QC in block #${n} before HF16", ("n", next_block.block_num())); + FC_ASSERT(tc == nullptr, "TC in block #${n} before HF16", ("n", next_block.block_num())); + return; + } + if (block_needs_parent_qc(next_block.validator)) { + FC_ASSERT(cert != nullptr, + "Block #${n} by ${w} carries no QC of its parent (produced by ${p})", + ("n", next_block.block_num())("w", next_block.validator) + ("p", get_dynamic_global_properties().current_validator)); + } + // A TC only makes sense next to the parent QC it is checked against. + FC_ASSERT(cert != nullptr || tc == nullptr, "Block #${n} carries a TC without a parent QC", + ("n", next_block.block_num())); + if (cert == nullptr) return; + + const uint32_t parent_round = round_of(head_block_time()); + if (cert->block_id == next_block.previous) { + FC_ASSERT(cert->block_num + 1 == next_block.block_num(), + "Parent QC certifies #${qn}, not the parent", ("qn", cert->block_num)); + // Round = slot of the certified block. The parent is head here. + FC_ASSERT(cert->round == parent_round, + "Parent QC round ${r} is not the parent's slot", ("r", cert->round)); + } else { + // Late-QC fallback: QC of the grandparent, three consecutive rounds + // (grandparent r-2, parent r-1, this block r). The parent must itself + // carry this very QC, so nothing certified is skipped. Safety rests on + // one QC per round and the commit rule needing consecutive rounds. + const uint32_t own_round = round_of(next_block.timestamp); + FC_ASSERT(tc == nullptr, "The late-QC fallback carries no TC"); + FC_ASSERT(parent_round + 1 == own_round && cert->round + 1 == parent_round && + cert->block_num + 2 == next_block.block_num(), + "QC #${qn} round ${r} is neither the parent's nor a consecutive grandparent's", + ("qn", cert->block_num)("r", cert->round)); + const auto parent = fetch_block_by_id(next_block.previous); + FC_ASSERT(parent.valid(), "Parent of block #${n} not found", ("n", next_block.block_num())); + bool parent_carries = false; + for (const auto &e : parent->extensions) + if (e.which() == 3) { + const auto &pc = e.get(); + parent_carries = pc.block_id == cert->block_id && pc.round == cert->round; + } + FC_ASSERT(parent_carries && parent->previous == cert->block_id, + "Grandparent QC #${qn} is not the one the parent carries", ("qn", cert->block_num)); + } + const auto &wso = get_validator_schedule_object(); + const uint32_t scheduled = uint32_t(wso.num_scheduled_validators) / CHAIN_BLOCK_VALIDATOR_REPEAT; + FC_ASSERT(cert->signatures.size() <= scheduled, "Parent QC has more signatures than scheduled validators"); + FC_ASSERT(cert->signatures.size() >= block_qc_quorum(scheduled), + "Parent QC has ${k} signatures, quorum is ${q}", + ("k", cert->signatures.size())("q", block_qc_quorum(scheduled))); + + // TC(r): a quorum gave up on round r, i.e. on a QC-less block that was + // built on our parent. Jolteon: r lies strictly between the parent's + // round and ours, and our parent QC is at least as high as every QC a + // timeout voter reported (nobody saw a newer certified block we skip). + if (tc != nullptr) { + const uint32_t own_round = round_of(next_block.timestamp); + FC_ASSERT(tc->round > cert->round && tc->round < own_round, + "TC round ${t} not between parent round ${p} and block round ${o}", + ("t", tc->round)("p", cert->round)("o", own_round)); + FC_ASSERT(tc->votes.size() <= scheduled, "TC has more votes than scheduled validators"); + FC_ASSERT(tc->votes.size() >= block_qc_quorum(scheduled), + "TC has ${k} votes, quorum is ${q}", + ("k", tc->votes.size())("q", block_qc_quorum(scheduled))); + for (const auto &tv : tc->votes) { + FC_ASSERT(tv.second.high_qc_round <= cert->round, + "TC vote by ${v} saw QC round ${h}, above the parent QC round ${p}", + ("v", tv.first)("h", tv.second.high_qc_round)("p", cert->round)); + } + } + + if (skip & skip_validator_signature) return; + for (const auto &sv : cert->signatures) { + FC_ASSERT(verify_block_vote(cert->block_num, cert->block_id, cert->round, sv.first, sv.second), + "Parent QC carries an invalid vote by ${v}", ("v", sv.first)); + } + if (tc != nullptr) { + for (const auto &tv : tc->votes) { + FC_ASSERT(verify_timeout_vote(tc->round, tv.second.high_qc_round, tv.first, tv.second.signature), + "TC carries an invalid timeout vote by ${v}", ("v", tv.first)); + } + } + } + + bool database::push_block_vote(uint32_t block_num, const block_id_type &block_id, uint32_t round, + const account_name_type &validator, const signature_type &signature) { + // Bounds first: a scheduled validator's valid signature over an + // absurd (height, round) must still not buy unbounded memory. + static constexpr uint32_t MAX_VOTE_AHEAD_BLOCKS = 64; + // Round = slot (block timestamp / interval). Accept a window around + // head: far-past rounds are below LIB anyway, far-future ones are junk. + const uint32_t MAX_VOTE_ROUND_BEHIND = 3600 / block_interval(); // preserve the one-hour evidence window + if (block_num == 0 || block_id == block_id_type()) return false; + if (protocol::block_header::num_from_id(block_id) != block_num) return false; + + const auto &dpo = get_dynamic_global_properties(); + const uint32_t head_round = round_of(head_block_time()); + if (uint64_t(round) + MAX_VOTE_ROUND_BEHIND < head_round) return false; + if (round > head_round + MAX_VOTE_AHEAD_BLOCKS) return false; + // With a tiny schedule (one validator: testnet, consensus_sim) the + // classic LIB reaches head itself, yet head still needs a QC before + // the next block can be produced. Settle only what lies below head. + const uint32_t lib = std::min(dpo.last_irreversible_block_num, + dpo.head_block_number > 0 ? dpo.head_block_number - 1 : 0); + // HF16 diagnostics (21-validator stand): a vote for head that is turned away + // is logged once per block, so a QC that never forms names its cause. + const auto reject_head = [&](const char *why) { + if (block_num == dpo.head_block_number && _head_reject_logged.exchange(block_num) != block_num) { + wlog("Vote for head #${n} from ${w} round ${r} rejected: ${why} (LIB ${l}, head round ${h})", + ("n", block_num)("w", validator)("r", round)("why", why)("l", lib)("h", head_round)); + } + return false; + }; + if (block_num <= lib) return reject_head("at or below LIB"); // already final + if (block_num > dpo.head_block_number + MAX_VOTE_AHEAD_BLOCKS) return false; + + if (!verify_block_vote(block_num, block_id, round, validator, signature)) return reject_head("signature/schedule check failed"); + + const auto &wso = get_validator_schedule_object(); + const uint32_t quorum = block_qc_quorum(uint32_t(wso.num_scheduled_validators) / CHAIN_BLOCK_VALIDATOR_REPEAT); + + std::lock_guard guard(_block_votes_mutex); + + // Forget everything at or below LIB: those heights are settled. + for (auto it = _block_vote_tallies.begin(); it != _block_vote_tallies.end() && std::get<0>(it->first) <= lib; ) + it = _block_vote_tallies.erase(it); + for (auto it = _block_vote_by_validator.begin(); it != _block_vote_by_validator.end(); ) { + if (it->second.first <= lib) { _block_vote_sigs.erase(it->first); it = _block_vote_by_validator.erase(it); } + else ++it; + } + // Keep certificates a little below LIB: the next block header (and + // a lagging peer) may still need the parent's QC. + for (auto it = _block_qcs.begin(); it != _block_qcs.end() && it->first + MAX_VOTE_AHEAD_BLOCKS <= lib; ) + it = _block_qcs.erase(it); + + // One vote per (validator, round). A second, different id in the same + // round is equivocation: never counted, logged as evidence (both + // signatures verify against the same signing key). + const auto vkey = std::make_pair(validator, round); + auto prev = _block_vote_by_validator.find(vkey); + if (prev != _block_vote_by_validator.end()) { + if (prev->second.second != block_id) { + wlog("Equivocation: validator ${v} voted for two blocks in round ${r}: #${an} ${a} and #${n} ${b}", + ("v", validator)("r", round)("an", prev->second.first)("a", prev->second.second) + ("n", block_num)("b", block_id)); + // Both signatures verified against the same key: keep the pair as + // a proof for the next block we produce. + auto sig = _block_vote_sigs.find(vkey); + if (sig != _block_vote_sigs.end() && _pending_equivocations.size() < 256 && + !_pending_equivocations.count(vkey)) { + protocol::block_equivocation_proof proof; + proof.validator = validator; + proof.round = round; + proof.first = protocol::block_vote_evidence{prev->second.first, prev->second.second, sig->second}; + proof.second = protocol::block_vote_evidence{block_num, block_id, signature}; + _pending_equivocations.emplace(vkey, std::move(proof)); + } + } + return false; + } + _block_vote_by_validator.emplace(vkey, std::make_pair(block_num, block_id)); + _block_vote_sigs.emplace(vkey, signature); + + auto &tally = _block_vote_tallies[std::make_tuple(block_num, round, block_id)]; + if (tally.votes.empty()) tally.first_seen = fc::time_point::now(); + tally.votes.emplace(validator, signature); + + // One QC per height, but the HIGHEST round wins: after a TC the height is + // rebuilt in a later round, and keeping the first QC forever made every + // rebuild uncertifiable (21-validator stand, double signer: QC for one twin + // of #546 in round 649, fork choice took another, then 40+ rebuilds of #546 + // with 15/15 votes each and no QC; chain stuck for 20+ min). + auto held_qc = _block_qcs.find(block_num); + if (tally.votes.size() >= quorum && (held_qc == _block_qcs.end() || held_qc->second.round < round)) { + if (held_qc != _block_qcs.end()) _block_qcs.erase(held_qc); + block_qc qc; + qc.block_num = block_num; + qc.block_id = block_id; + qc.round = round; + qc.signatures.assign(tally.votes.begin(), tally.votes.end()); + qc.formed_after = fc::time_point::now() - tally.first_seen; + ilog("QC formed for #${n} ${id} round ${r}: ${k}/${q} votes, ${ms} ms after first vote", + ("n", block_num)("id", block_id)("r", round)("k", qc.signatures.size())("q", quorum) + ("ms", qc.formed_after.count() / 1000)); + _block_qcs.emplace(block_num, std::move(qc)); + } + return true; + } + + uint32_t database::highest_qc_on_main_branch(uint32_t max_num) const { + std::vector> certified; + { + std::lock_guard guard(_block_votes_mutex); + for (auto it = _block_qcs.rbegin(); it != _block_qcs.rend(); ++it) { + if (it->first <= max_num) certified.emplace_back(it->first, it->second.block_id); + } + } + // 2-chain rule: a certified block is final only when its child at the + // next height is certified too, both on our branch. One QC is not + // enough once voting has rounds: a validator that voted for X need + // not see X's QC, may vote for Y in a later round, and a second QC + // at the same height becomes possible. Requiring a certified child + // (whose header carries X's QC) closes that window. Highest first. + for (size_t i = 0; i + 1 < certified.size(); ++i) { + const auto &child = certified[i]; + const auto &parent = certified[i + 1]; + if (child.first != parent.first + 1) continue; + if (find_block_id_for_num(child.first) != child.second || + find_block_id_for_num(parent.first) != parent.second) continue; + // Jolteon commit rule: the two certified blocks must also be in + // consecutive ROUNDS (round = slot). A child after skipped slots + // was built on a timeout certificate, and a parent certified + // before that timeout is not yet safe to commit. + const auto pb = fetch_block_by_id(parent.second); + const auto cb = fetch_block_by_id(child.second); + if (!pb || !cb) continue; + // Compare rounds, not timestamps: the slot is 1 s after HF16, and a + // compile-time 3 s step never matched there, so QC-LIB was always 0 + // and LIB fell back to post-validation (stand: lag exactly 14 blocks). + if (round_of(cb->timestamp) != round_of(pb->timestamp) + 1) continue; + return parent.first; + } + return 0; + } + + fc::optional database::get_block_qc(uint32_t block_num) const { + std::lock_guard guard(_block_votes_mutex); + auto it = _block_qcs.find(block_num); + if (it == _block_qcs.end()) return fc::optional(); + return it->second; + } + void database::create_block_post_validation(uint32_t block_num, block_id_type block_id, const account_name_type& validator_account){ const dynamic_global_property_object &dpo = get_dynamic_global_properties(); //remove blocks if they height is less than last irreversible block @@ -7097,6 +7810,26 @@ namespace graphene { namespace chain { : 0; } + // === HARDFORK 16: LIB FROM QUORUM CERTIFICATES === + // A QC is >2/3 of scheduled validators signing one block id at one + // height; the one-vote-per-height guard makes a conflicting QC at + // the same height impossible without equivocation, so the certified + // block and its ancestors are final. Take the highest certified + // height that is on OUR main branch (a QC for a block we do not + // have, or for a sibling, proves nothing about our chain). + // Capped at HEAD-1, same reason as the emergency cap: committing + // HEAD would drop the undo session of the block being applied. + // Node-local, like the pre-DLT post-validation LIB it replaces: + // no evaluator reads LIB, so state stays deterministic. + if (has_hardfork(CHAIN_HARDFORK_16) && !dpo.emergency_consensus_active && + dpo.head_block_number > 1) { + const uint32_t qc_lib = highest_qc_on_main_branch(dpo.head_block_number); + if (qc_lib > new_last_irreversible_block_num) { + new_last_irreversible_block_num = qc_lib; + } + } + // === END HARDFORK 16 === + if (_debug_block_production) ilog("DEBUG_CRASH: update_last_irreversible_block: new_lib=${lib} old_lib=${old} head=${h}", ("lib", new_last_irreversible_block_num)("old", dpo.last_irreversible_block_num)("h", dpo.head_block_number)); @@ -7245,8 +7978,13 @@ namespace graphene { namespace chain { } if (_debug_block_production) ilog("DEBUG_CRASH: fork_db set_max_size=${s}", ("s", dpo.head_block_number - dpo.last_irreversible_block_num + 1)); - _fork_db.set_max_size(dpo.head_block_number - - dpo.last_irreversible_block_num + 1); + // fork_db may be ahead of applied state after linking a deferred batch. + // Its pruning window is measured from ITS head, so retain through + // our LIB rather than pruning still-needed ancestors during catchup. + _fork_db.set_max_size( + std::max(dpo.head_block_number, + _fork_db.head() ? _fork_db.head()->num : uint32_t(0)) - + dpo.last_irreversible_block_num + 1); if (_debug_block_production) ilog("DEBUG_CRASH: update_last_irreversible_block done"); } FC_CAPTURE_AND_RETHROW() } diff --git a/libraries/chain/database_hardfork.cpp b/libraries/chain/database_hardfork.cpp index 54fa0fd116..1b858e806e 100644 --- a/libraries/chain/database_hardfork.cpp +++ b/libraries/chain/database_hardfork.cpp @@ -105,11 +105,16 @@ inline u256 to256(const fc::uint128_t &t) { // HF15 (PM audit fixes) is registered in BOTH configs — production included, because the // shelter testnet runs the production config and a testnet-only fork could never // activate there. The gate is the activation time (15.hf) plus the validator quorum, - // not the build flavour; CHAIN_NUM_HARDFORKS is 15 everywhere so the arrays are sized - // [CHAIN_NUM_HARDFORKS + 1] = [16] and these indices are in bounds. + // not the build flavour; CHAIN_NUM_HARDFORKS is 16 everywhere so the arrays are sized + // [CHAIN_NUM_HARDFORKS + 1] = [17] and these indices are in bounds. _hardfork_times[CHAIN_HARDFORK_15] = fc::time_point_sec(CHAIN_HARDFORK_15_TIME); _hardfork_versions[CHAIN_HARDFORK_15] = CHAIN_HARDFORK_15_VERSION; + // HF16 (no-contest fee freeze) — same registration rule as HF15: unconditional in both + // configs, gated by the time in 16.hf plus validator quorum. + _hardfork_times[CHAIN_HARDFORK_16] = fc::time_point_sec(CHAIN_HARDFORK_16_TIME); + _hardfork_versions[CHAIN_HARDFORK_16] = CHAIN_HARDFORK_16_VERSION; + const auto &hardforks = get_hardfork_property_object(); FC_ASSERT( hardforks.last_hardfork <= CHAIN_NUM_HARDFORKS, @@ -1041,6 +1046,41 @@ inline u256 to256(const fc::uint128_t &t) { // on get. All balances start at zero (empty pool). create([](pm_lazy_pool_object&) {}); break; + case CHAIN_HARDFORK_16: + // #6737: this block is the last 3 s slot (dgpo.time is already this block's + // timestamp: update_global_dynamic_data runs before process_hardforks), every + // later slot is CHAIN_BLOCK_INTERVAL_HF16. Re-anchor rounds here, using the + // pre-switch formula for this block's own round. See database::round_of(). + { + const uint32_t r = round_of(get_dynamic_global_properties().time); + modify(get_dynamic_global_properties(), [&](dynamic_global_property_object& p) { + p.round_anchor_time = p.time; + p.round_anchor_round = r; + }); + } + { + // #6737, Q#3108=A: block-denominated chain props keep their wall-time + // meaning at 1 s slots. The median is recomputed from every validator's + // own props each schedule round, so scaling only median_props would be + // undone within one round: scale every stored vote, then the median. + const auto scale = [](chain_properties& c) { + const auto x3 = [](uint32_t v) { + return uint32_t(std::min(uint64_t(v) * 3, std::numeric_limits::max())); + }; + c.inflation_recalc_period = x3(c.inflation_recalc_period); + c.distribution_epoch_length = x3(c.distribution_epoch_length); + c.pm_batch_epoch_blocks = x3(c.pm_batch_epoch_blocks); + c.pm_reveal_window_blocks = x3(c.pm_reveal_window_blocks); + }; + const auto& vidx = get_index().indices(); + for (auto it = vidx.begin(); it != vidx.end(); ++it) { + modify(*it, [&](validator_object& v) { scale(v.props); }); + } + modify(get_validator_schedule_object(), [&](validator_schedule_object& w) { + scale(w.median_props); + }); + } + break; default: break; } diff --git a/libraries/chain/fork_database.cpp b/libraries/chain/fork_database.cpp index c54c8f1fef..fc8aa09240 100644 --- a/libraries/chain/fork_database.cpp +++ b/libraries/chain/fork_database.cpp @@ -31,6 +31,17 @@ namespace graphene { auto &id_index = _index.get(); auto existing = id_index.find(b.id()); if (existing != id_index.end()) { + // An anchor (insert_anchor_block) holds only id/num. Now that the + // real block is here, fill it in: otherwise the LIB writer of a + // non-DLT node finds no data for this block and asserts on + // block_log append (21-validator QC stand, #6741: a node that + // joined at head #1 anchored #1, then rejected every #2). + if ((*existing)->anchor_only) { + id_index.modify(existing, [&](item_ptr &it) { + it->data = std::move(b); + it->anchor_only = false; + }); + } // Already in linked index — still repair any child blocks whose // prev pointer is null (inserted via start_block before we knew // this ancestor). diff --git a/libraries/chain/hardfork.d/0-preamble.hf b/libraries/chain/hardfork.d/0-preamble.hf index a7dfc85d97..69cd549d83 100644 --- a/libraries/chain/hardfork.d/0-preamble.hf +++ b/libraries/chain/hardfork.d/0-preamble.hf @@ -52,10 +52,11 @@ FC_REFLECT((graphene::chain::hardfork_property_object), CHAINBASE_SET_INDEX_TYPE( graphene::chain::hardfork_property_object, graphene::chain::hardfork_property_index) #define CHAIN_STARTUP_HARDFORKS 0 -// HF15 (the PM audit fixes) is registered in BOTH configs — production included, because the -// shelter testnet runs the production config (config_testnet.hpp would change CHAIN_ID/CHAIN_NAME -// and drop the validator quorum to 1) and could not activate a testnet-only fork. What gates it is -// the activation time in 15.hf plus the 17/21 validator quorum, not the build flavour. -// Keep this in sync with 15.hf and with CHAIN_VERSION: database_hardfork.cpp asserts -// CHAIN_HARDFORK_VERSION == _hardfork_versions[CHAIN_NUM_HARDFORKS]. -#define CHAIN_NUM_HARDFORKS 15 +// HF15 (the PM audit fixes) and HF16 (BFT finality, 1 s slots, DAO cap and fee freeze) are registered in BOTH configs — +// production included, because the shelter testnet runs the production config (config_testnet.hpp +// would change CHAIN_ID/CHAIN_NAME and drop the validator quorum to 1) and could not activate a +// testnet-only fork. What gates them is the activation time in 15.hf/16.hf plus the 17/21 +// validator quorum, not the build flavour. +// Keep this in sync with the highest .hf file and with CHAIN_VERSION: database_hardfork.cpp +// asserts CHAIN_HARDFORK_VERSION == _hardfork_versions[CHAIN_NUM_HARDFORKS]. +#define CHAIN_NUM_HARDFORKS 16 diff --git a/libraries/chain/hardfork.d/15.hf b/libraries/chain/hardfork.d/15.hf index 66029c8017..8649c8a8a8 100644 --- a/libraries/chain/hardfork.d/15.hf +++ b/libraries/chain/hardfork.d/15.hf @@ -37,7 +37,8 @@ #define CHAIN_HARDFORK_15_TIME 1791158400 // 2026-10-05 00:00:00 UTC #define CHAIN_HARDFORK_15_VERSION hardfork_version( version(4, 1, 0) ) #endif -// Registration itself is unconditional in both configs (CHAIN_NUM_HARDFORKS is 15 everywhere), so -// the fork can never be "unknown" to a binary that carries it; what gates it is the time above plus -// the 17/21 validator quorum. The stale-state migration runs separately — see the checklist. +// Registration itself is unconditional in both configs (the hardfork arrays are sized +// [CHAIN_NUM_HARDFORKS + 1]), so the fork can never be "unknown" to a binary that carries it; what +// gates it is the time above plus the 17/21 validator quorum. The stale-state migration runs +// separately — see the checklist. #endif diff --git a/libraries/chain/hardfork.d/16.hf b/libraries/chain/hardfork.d/16.hf new file mode 100644 index 0000000000..84a2eb35e0 --- /dev/null +++ b/libraries/chain/hardfork.d/16.hf @@ -0,0 +1,57 @@ +// HF16 / VIZ 4.2.0: BFT QC/TC block votes, 2-chain finality, certificate fork choice, +// 1-second slots (1 VIZ/block intentionally retained: 3x annual emission, owner Q3105=C), +// equivocation virtual operation 106, P2P vote messages 5117/5118, and DAO request cap +// floor(current_supply / 100). Block-denominated periods are migrated at activation. +// All nodes, including API/seed nodes, must upgrade: old nodes cannot decode extensions 3/4/5. +// Shared-memory layout changes require replay or a compatible 4.2.0 snapshot. +// Activation: 2026-10-31 00:00 UTC plus the production 17/21 version-vote quorum. +// See docs/consensus/hf16.md for the full upgrade and consensus contract. +// +// 16 Hardfork — freeze the no-contest price at announcement. Today pm_no_contest only declares the +// void and opens the pm_dispute_grace_sec window; the penalty is computed at settlement from the +// median chain_properties_pm of THAT moment. A parameter vote inside the window therefore changes +// the price retroactively: the oracle announces one number and pays another. This fork records the +// nominal fee on the market (pm_market_object::no_contest_fee) in pm_no_contest_evaluator and makes +// settle_market(win < 0) charge exactly that; the oracle gets a monotone no_contest_slashed_total +// counter in the same modify() that grows total_insurance_slashed (exact attribution of the class +// without a virtual operation). The insurance cap stays where it was — per settlement. +// See docs/prediction-markets/pm-no-contest-fee-freeze.md. +#ifndef CHAIN_HARDFORK_16 +#define CHAIN_HARDFORK_16 16 +// The same fork under the name the fix itself uses (the gates read this one, so the pair is +// deliberately not two independent facts: they are defined together and must stay equal). +#ifndef CHAIN_PM_NO_CONTEST_FREEZE_HARDFORK +#define CHAIN_PM_NO_CONTEST_FREEZE_HARDFORK 16 +#endif +#ifdef BUILD_TESTNET +// Testnet-config activation (CHAIN_NAME "VIZTEST"). Testnet has a single validator +// (CHAIN_HARDFORK_REQUIRED_VALIDATORS=1), so this timestamp IS the whole coordination: the +// validator's auto-vote in _generate_block reaches quorum by itself and the fork applies on the +// first block at or after this time. +#define CHAIN_HARDFORK_16_TIME 1793404800 // 2026-10-31 00:00:00 UTC (same as production) +#define CHAIN_HARDFORK_16_VERSION hardfork_version( version(4, 2, 0) ) +#else +// Production-config activation. This branch is what the shelter testnet runs too — that node is a +// PRODUCTION-config deployment (config_testnet.hpp would change CHAIN_ID/CHAIN_NAME and 17/21 +// validator quorum to 1), so the only lever for a production-config testnet is this timestamp. +// 17 of 21 validators must still vote the version above, which the node does on its own +// (database.cpp _generate_block injects hardfork_version_vote while current_hardfork_version < +// CHAIN_HARDFORK_VERSION); the fork then applies on the first block at or after this time. +// +// SET by the owner (2026-10-20): 2026-10-31 00:00:00 UTC is the mainnet date, and the validator +// announcement must name this value. It MUST stay in the FUTURE for the announced moment to exist at +// all — with a past date the fork activates in whatever block the 17th validator happens to upgrade +// in, with no announceable moment (same warning as 14.hf/15.hf). Change this value together with +// the announcement text, never after one is published. +#define CHAIN_HARDFORK_16_TIME 1793404800 // 2026-10-31 00:00:00 UTC +#define CHAIN_HARDFORK_16_VERSION hardfork_version( version(4, 2, 0) ) +#endif +// Registration itself is unconditional in both configs (the hardfork arrays are sized +// [CHAIN_NUM_HARDFORKS + 1]), so the fork can never be "unknown" to a binary that carries it; what +// gates it is the time above plus +// the 17/21 validator quorum. Rows that were already inside the dispute window at activation carry +// no_contest_fee_frozen == false, which the settlement treats as "legacy row" and prices from the +// current median — exactly the pre-fork behaviour, so no seed pass is needed. The flag is separate +// from the amount on purpose: a frozen fee of 0 (penalty percent voted to 0) must not fall back to +// the median and pick up a later vote; 0-amount and never-frozen are different states. +#endif diff --git a/libraries/chain/include/graphene/chain/database.hpp b/libraries/chain/include/graphene/chain/database.hpp index 58da045c5c..cfd8ce483d 100644 --- a/libraries/chain/include/graphene/chain/database.hpp +++ b/libraries/chain/include/graphene/chain/database.hpp @@ -17,6 +17,7 @@ #include #include #include +#include #include namespace graphene { namespace chain { @@ -310,14 +311,26 @@ namespace graphene { namespace chain { const fc::time_point_sec when, const account_name_type &validator_owner, const fc::ecc::private_key &block_signing_private_key, - uint32_t skip + uint32_t skip, + // HF16: TC of the skipped round when building past a QC-less head + const fc::optional &tc = + fc::optional(), + // HF16 late-QC fallback: certificate to carry instead of QC(head) + const fc::optional &qc_override = + fc::optional() ); signed_block _generate_block( const fc::time_point_sec when, const account_name_type &validator_owner, const fc::ecc::private_key &block_signing_private_key, - uint32_t skip + uint32_t skip, + // HF16: TC of the skipped round when building past a QC-less head + const fc::optional &tc = + fc::optional(), + // HF16 late-QC fallback: certificate to carry instead of QC(head) + const fc::optional &qc_override = + fc::optional() ); void pop_block(); @@ -426,6 +439,16 @@ namespace graphene { namespace chain { */ uint32_t get_slot_at_time(fc::time_point_sec when) const; + /** Slot length in seconds for slots after the head (HF16: 3 -> 1). */ + uint32_t block_interval() const; + + /** + * Consensus round of a slot time, contiguous across the HF16 switch: + * t/3 up to the switch block S, then S/3 + (t - S). Rounds of QC/TC must use this, + * never t / CHAIN_BLOCK_INTERVAL, or round r-1 does not exist on the first 1 s slot. + */ + uint32_t round_of(fc::time_point_sec t) const; + void shares_sender_recalc_energy(const account_object &receiver, asset tokens); asset create_vesting(const account_object &to_account, asset tokens); @@ -648,6 +671,87 @@ namespace graphene { namespace chain { void check_block_post_validation_chain(); + // ── Block votes and quorum certificates (QC) ───────────────── + // In-memory, node-local: votes are gossiped (dlt_block_vote_message), + // not stored in chainbase, so they never enter undo sessions. + // Step 0 of the QC plan: verify, tally, detect a quorum and + // equivocation, and expose the certificate. Under HF16 the + // highest QC on our branch also advances LIB (node-local, like the + // pre-DLT post-validation LIB); parent_qc in the header is next. + struct block_qc { + uint32_t block_num = 0; + block_id_type block_id; + uint32_t round = 0; + std::vector> signatures; + fc::microseconds formed_after; // first vote -> quorum, for measurement + }; + + /// Verify and tally a vote. Caller holds at least a weak read lock. + /// Returns true when the vote is valid and new (worth relaying). + bool push_block_vote(uint32_t block_num, const block_id_type &block_id, uint32_t round, + const account_name_type &validator, const signature_type &signature); + + /// The certificate formed for a height, if any (node-local view). + fc::optional get_block_qc(uint32_t block_num) const; + + /// Highest height H such that H and H+1 (both <= max_num) are certified + /// on our main branch (2-chain finality); 0 if none. Feeds LIB under HF16. + uint32_t highest_qc_on_main_branch(uint32_t max_num) const; + + /// HF16 header rule: a handoff block carries a valid QC of its parent. + void validate_block_qc(const signed_block &next_block, uint32_t skip) const; + + /// Round of the certificate a block claims to carry (0 = none). Used by + /// HF16 fork choice; validity is checked when the block is applied. + static uint32_t block_cert_round(const signed_block &b); + uint32_t verified_fork_cert_round(const signed_block &b) const; + + /// Votes needed for a QC with the given number of scheduled validators: + /// strictly more than 2/3 and not below the irreversibility threshold. + static uint32_t block_qc_quorum(uint32_t num_scheduled); + + /// Consensus check of one vote against current state: validator is + /// scheduled, has a signing key, and the signature recovers to it. + bool verify_block_vote(uint32_t block_num, const block_id_type &block_id, uint32_t round, + const account_name_type &validator, const signature_type &signature) const; + + /// Consensus check of one timeout vote: validator is scheduled, has a + /// signing key, and the signature over block_timeout_digest recovers to it. + bool verify_timeout_vote(uint32_t round, uint32_t high_qc_round, + const account_name_type &validator, const signature_type &signature) const; + + /// Verify and tally a timeout vote (validator gave up on `round`). + /// Returns true when the vote is valid and new (worth relaying). + bool push_timeout_vote(uint32_t round, uint32_t high_qc_round, + const account_name_type &validator, const signature_type &signature); + + /// HF16 late-QC fallback. QC(head) has not formed in time, but head is of + /// round own_round-1 and certifies its own parent of round own_round-2: + /// then a block of own_round may carry that grandparent QC instead, so a + /// late quorum costs one slot of finality rather than the slot itself. + fc::optional grandparent_qc_for_next_block( + const account_name_type &producer, uint32_t own_round) const; + + /// Keep a parent QC that arrived inside a validated block header, so a + /// node that missed the votes still knows the height is certified + /// (LIB, and a producer building past a QC-less child of it). + void remember_block_qc(const protocol::block_qc_certificate &cert); + + /// HF16: check double-vote proofs a block carries and punish each validator + /// like for a missed block (once per validator and round, newest first). + void apply_equivocation_proofs(const signed_block &b, uint32_t skip); + + /// The timeout certificate formed for a round, if any (node-local view). + fc::optional get_timeout_cert(uint32_t round) const; + + /// HF16: does a block by `producer` on top of head need the parent's QC? + bool block_needs_parent_qc(const account_name_type &producer) const; + + /// HF16: the parent certificate `producer` would put into its next + /// block, built from the node-local QC of head and re-verified against + /// state; empty when not needed or not formed yet. + fc::optional parent_qc_for_next_block(const account_name_type &producer) const; + /** * Compare two fork branches by vote weight (HF12 logic). * Sums wit_obj.votes (on-chain stake) for all unique validators in each branch, @@ -725,6 +829,26 @@ namespace graphene { namespace chain { std::unique_ptr _my; fork_database _fork_db; + + struct block_vote_tally { + fc::time_point first_seen; + std::map votes; + }; + // (block_num, round, block_id) -> votes for exactly that triple + std::map, block_vote_tally> _block_vote_tallies; + // (validator, round) -> (height, id) it voted for. Round = slot: one vote + // per round, so a second id in the same round is equivocation. + std::map, std::pair> _block_vote_by_validator; + // Signature of each counted vote (same key), kept to build double-vote proofs + std::map, signature_type> _block_vote_sigs; + // Double votes seen and not yet carried in a block (one per validator, round) + std::map, protocol::block_equivocation_proof> _pending_equivocations; + std::map _block_qcs; + // HF16 timeouts: round -> (validator -> vote); one vote per (validator, round) + std::map> _timeout_votes; + std::map _timeout_certs; + mutable std::mutex _block_votes_mutex; + std::atomic _head_reject_logged{0}; fc::time_point_sec _hardfork_times[CHAIN_NUM_HARDFORKS + 1]; protocol::hardfork_version _hardfork_versions[CHAIN_NUM_HARDFORKS + 1]; diff --git a/libraries/chain/include/graphene/chain/global_property_object.hpp b/libraries/chain/include/graphene/chain/global_property_object.hpp index 6bca532ab5..38f265645f 100644 --- a/libraries/chain/include/graphene/chain/global_property_object.hpp +++ b/libraries/chain/include/graphene/chain/global_property_object.hpp @@ -195,6 +195,17 @@ namespace graphene { * by epoch) would become unreachable. */ uint64_t pm_batch_settle_bet_cursor = 0; + + /** + * Round anchor (#6737, Q#3109=A): the consensus round of time t is + * round_anchor_round + (t - round_anchor_time) / block_interval(). + * Rounds are stored in QC/TC, so they must never be renumbered: every hardfork that + * changes the slot length re-anchors at the block that applies it (its actual time, + * not the scheduled one: the fork applies once 17/21 voted, possibly later). + * Zero time = never re-anchored, round = t / CHAIN_BLOCK_INTERVAL (pre-HF16). + */ + fc::time_point_sec round_anchor_time; + uint32_t round_anchor_round = 0; }; typedef multi_index_container < @@ -248,5 +259,7 @@ FC_REFLECT((graphene::chain::dynamic_global_property_object), (pm_oracle_gauges_seeded) (pm_batch_settle_cursor) (pm_batch_settle_bet_cursor) + (round_anchor_time) + (round_anchor_round) ) CHAINBASE_SET_INDEX_TYPE(graphene::chain::dynamic_global_property_object, graphene::chain::dynamic_global_property_index) diff --git a/libraries/chain/include/graphene/chain/pm_objects.hpp b/libraries/chain/include/graphene/chain/pm_objects.hpp index 4b01f68eb1..67bacf5dda 100644 --- a/libraries/chain/include/graphene/chain/pm_objects.hpp +++ b/libraries/chain/include/graphene/chain/pm_objects.hpp @@ -95,6 +95,13 @@ namespace graphene { namespace chain { // guaranteed zero-initialized — the generic fc::array ctor leaves data // indeterminate, which would be non-deterministic. p50/p95 are derived on read in the API. fc::array resolution_time_hist; + + // HF16: monotone total of no-contest penalties charged to this oracle — incremented in the + // same modify() that grows total_insurance_slashed, so the no-contest class has exact + // attribution without a virtual operation. Forward-accumulating from activation; rows + // settled before HF16 are NOT backfilled (the aggregate delta is the only record, and + // subtracting it now would be a guess). Display/attribution only — never gates consensus. + share_type no_contest_slashed_total; }; struct by_owner; @@ -199,6 +206,17 @@ namespace graphene { namespace chain { // no-reveal penalty, refunded 80% otherwise). GC never sees status-0 rows by construction // (retention strictly exceeds the worst-case reveal deadline), so no GC adjustment needed. uint32_t open_commits = 0; + // HF16 (no-contest fee freeze): the nominal penalty fixed in pm_no_contest_evaluator at + // the moment the oracle announces the void, from the THEN-current median chain_properties_pm. + // settle_market(win < 0) charges exactly this instead of re-reading the median, so a + // parameter vote inside the pm_dispute_grace_sec window can no longer change the price + // retroactively. The flag is separate from the amount on purpose: a frozen fee of 0 + // (penalty percent voted to 0) must not fall back to the median and pick up a later vote; + // 0-amount and never-frozen are different states. `false` = row announced before HF16 — + // settlement keeps the legacy median computation for it (no seed pass needed; see 16.hf). + // Not display-only: consensus reads both at settlement. + share_type no_contest_fee; + bool no_contest_fee_frozen = false; }; struct by_creator; @@ -931,7 +949,7 @@ FC_REFLECT((graphene::chain::pm_oracle_object), (avg_resolution_time)(penalty_stamps)(bans_received)(last_penalty_stamp_time) (auto_accept_creator)(auto_accept_resolver)(auto_accept)(banned_by)(active_markets) (markets_in_dispute_window)(disputes_awaiting_response)(disputes_awaiting_decision) - (resolved_late_count)(resolution_time_hist)) + (resolved_late_count)(resolution_time_hist)(no_contest_slashed_total)) CHAINBASE_SET_INDEX_TYPE(graphene::chain::pm_oracle_object, graphene::chain::pm_oracle_index) FC_REFLECT((graphene::chain::pm_market_object), @@ -941,7 +959,7 @@ FC_REFLECT((graphene::chain::pm_market_object), (liquidity_fee_earned)(forfeit_pool)(time_penalty_type)(time_penalty_value)(penalty_curve_type) (allow_early_resolution)(allow_cancellation)(allow_batch)(allow_instant_bet)(endogeneity_tier)(current_epoch) (dispute_mode)(dispute_resolver)(dispute_penalty_percent)(decision_url)(decision_reason) - (deferred_claim_count)(open_commits)) + (deferred_claim_count)(open_commits)(no_contest_fee)(no_contest_fee_frozen)) CHAINBASE_SET_INDEX_TYPE(graphene::chain::pm_market_object, graphene::chain::pm_market_index) FC_REFLECT((graphene::chain::pm_outcome_object), diff --git a/libraries/chain/include/graphene/chain/validator_objects.hpp b/libraries/chain/include/graphene/chain/validator_objects.hpp index 44b2a7dbb1..67632fe230 100644 --- a/libraries/chain/include/graphene/chain/validator_objects.hpp +++ b/libraries/chain/include/graphene/chain/validator_objects.hpp @@ -105,6 +105,10 @@ namespace graphene { namespace chain { // HF13: Validator reward sharing /// Fraction of block reward shared with stakeholders; basis points (0-CHAIN_100_PERCENT). uint16_t sharing_rate = 0; + + /// HF16: round of the last double vote punished (a proof must be newer, + /// so each (validator, round) is punished once and proofs cannot repeat). + uint32_t last_equivocation_round = 0; /// Accumulated TOKEN (VIZ) pending distribution to stakeholders at epoch end. /// Stored in TOKEN atomic units; converted to SHARES via create_vesting() at epoch end. /// validator_reward_operation for the validator carries only the validator's own share. @@ -310,7 +314,7 @@ FC_REFLECT( (id)(owner)(created)(url)(votes)(penalty_percent)(counted_votes)(schedule)(virtual_last_update)(virtual_position)(virtual_scheduled_time)(total_missed) (last_aslot)(last_confirmed_block_num)(current_run)(last_supported_block_num)(signing_key)(props) (last_work)(running_version)(hardfork_version_vote)(hardfork_time_vote) - (sharing_rate)(pending_stakeholder_reward)) + (sharing_rate)(pending_stakeholder_reward)(last_equivocation_round)) CHAINBASE_SET_INDEX_TYPE(graphene::chain::validator_object, graphene::chain::validator_index) diff --git a/libraries/chain/pm_evaluator.cpp b/libraries/chain/pm_evaluator.cpp index d5eec112da..6629fad0e6 100644 --- a/libraries/chain/pm_evaluator.cpp +++ b/libraries/chain/pm_evaluator.cpp @@ -557,7 +557,7 @@ void pm_commit_bet_evaluator::do_apply(const pm_commit_bet_operation& o) { uint32_t reveal_window = mp.pm_reveal_window_blocks; uint32_t cur_block = db.head_block_num(); uint32_t epoch_end = ((cur_block / epoch_blocks) + 1) * epoch_blocks; - time_point_sec reveal_dl = now + fc::seconds((int64_t)(epoch_end - cur_block + reveal_window) * CHAIN_BLOCK_INTERVAL); + time_point_sec reveal_dl = now + fc::seconds((int64_t)(epoch_end - cur_block + reveal_window) * db.block_interval()); db.create([&](pm_commit_object& c) { c.market = mkt.id; @@ -1040,12 +1040,29 @@ void pm_no_contest_evaluator::do_apply(const pm_no_contest_operation& o) { ora.markets_in_dispute_window++; // enters disputable window (status3, payout1, no dispute) ora.last_active_time = now; }); + // HF16: fix the price of the void HERE, from the median of the announcing block. Settlement then + // charges this number instead of re-reading the median — otherwise a parameter vote inside the + // dispute window changes the bill after the oracle announced it. Correct base: bets require + // status == 1 (pm_place_bet), and the modify below moves the market to 3, so bets_sum is final. + const bool freeze_fee = db.has_hardfork(CHAIN_PM_NO_CONTEST_FREEZE_HARDFORK); + share_type frozen_fee(0); // every path that writes no_contest_fee goes through the freeze branch + if (freeze_fee && mkt.bets_sum.value > 0) { + const auto& mp = median(db); + frozen_fee = share_type((int64_t)(fc::uint128_t((uint64_t)mp.pm_dispute_fee.amount.value) + * (uint64_t)mp.pm_no_contest_penalty_percent / 10000).lo); + } db.modify(mkt, [&](pm_market_object& m) { m.status = 3; m.payout_status = 1; m.resolved_outcome = -1; m.result_expiration = now; // start the dispute/settle grace from here from_string(m.decision_reason, o.reason); // NO-CONTEST rationale, readable via get_market + if (freeze_fee) { + // The flag marks "announced under HF16" even when the amount is 0: a frozen 0 must not + // fall back to the median later and pick up a vote that happened inside the window. + m.no_contest_fee_frozen = true; + m.no_contest_fee = frozen_fee; + } }); } diff --git a/libraries/chain/pm_process_markets.cpp b/libraries/chain/pm_process_markets.cpp index 5113a86c24..b47e63d59d 100644 --- a/libraries/chain/pm_process_markets.cpp +++ b/libraries/chain/pm_process_markets.cpp @@ -766,19 +766,29 @@ namespace pm_detail { // carries the oracle's no-contest penalty and `uncovered` the forfeit pool (dual use, // documented on the object) — kept as two separate floor divisions so the amounts // match the one-shot implementation exactly. - const auto& mp = median(db); share_type penalty(0); if (st.stake_total.value > 0) { const auto& oidx = db.get_index().indices().get(); auto oit = oidx.find(mkt.oracle); if (oit != oidx.end()) { - int64_t want = (int64_t)(fc::uint128_t((uint64_t)mp.pm_dispute_fee.amount.value) - * fc::uint128_t((uint64_t)mp.pm_no_contest_penalty_percent) / fc::uint128_t(10000)).lo; + // HF16: the price was fixed by pm_no_contest_evaluator in the announcing + // block; charge exactly it. Rows announced before the fork carry + // no_contest_fee_frozen == false and keep the legacy median computation — + // that is why no seed pass is needed at activation. + int64_t want; + if (db.has_hardfork(CHAIN_PM_NO_CONTEST_FREEZE_HARDFORK) && mkt.no_contest_fee_frozen) { + want = mkt.no_contest_fee.value; + } else { + const auto& mp = median(db); + want = (int64_t)(fc::uint128_t((uint64_t)mp.pm_dispute_fee.amount.value) + * fc::uint128_t((uint64_t)mp.pm_no_contest_penalty_percent) / fc::uint128_t(10000)).lo; + } penalty = share_type(want > oit->insurance.value ? oit->insurance.value : want); if (penalty.value > 0) db.modify(*oit, [&](pm_oracle_object& o) { o.insurance -= penalty; o.total_insurance_slashed += penalty; + o.no_contest_slashed_total += penalty; // HF16: exact class attribution }); } } diff --git a/libraries/network/dlt_p2p_messages.cpp b/libraries/network/dlt_p2p_messages.cpp index 559f74ece3..4997110713 100644 --- a/libraries/network/dlt_p2p_messages.cpp +++ b/libraries/network/dlt_p2p_messages.cpp @@ -21,6 +21,8 @@ const dlt_message_type_enum dlt_transaction_message::type = dlt_transact const dlt_message_type_enum dlt_soft_ban_message::type = dlt_soft_ban_message_type; const dlt_message_type_enum dlt_gap_fill_request::type = dlt_gap_fill_request_type; const dlt_message_type_enum dlt_gap_fill_reply::type = dlt_gap_fill_reply_type; +const dlt_message_type_enum dlt_block_vote_message::type = dlt_block_vote_message_type; +const dlt_message_type_enum dlt_timeout_vote_message::type = dlt_timeout_vote_message_type; } // namespace network } // namespace graphene diff --git a/libraries/network/dlt_p2p_node.cpp b/libraries/network/dlt_p2p_node.cpp index bb59ee25f1..9dd59e3f02 100644 --- a/libraries/network/dlt_p2p_node.cpp +++ b/libraries/network/dlt_p2p_node.cpp @@ -309,6 +309,10 @@ static dlt_hello_message unpack_hello_compat(const message& msg) { if (ds.remaining() >= sizeof(node_id_t)) { fc::raw::unpack(ds, hello.node_id); } + // capabilities (uint32) follows node_id; absent on peers that predate it. + if (ds.remaining() >= sizeof(uint32_t)) { + fc::raw::unpack(ds, hello.capabilities); + } // Any remaining bytes are future protocol fields — ignored for forward compat. return hello; } @@ -641,7 +645,9 @@ inline uint32_t frame_msg_type(const std::vector& frame) { // stall a peer's sync or leave a request unanswered. inline bool is_droppable_when_full(uint32_t msg_type) { return msg_type == dlt_fork_status_message_type - || msg_type == dlt_transaction_message_type; + || msg_type == dlt_transaction_message_type + || msg_type == dlt_block_vote_message_type // superseded by the QC / next round + || msg_type == dlt_timeout_vote_message_type; // superseded by the TC / next round } } // anonymous namespace @@ -1018,6 +1024,12 @@ bool dlt_p2p_node::on_message(peer_id peer, const message& msg) { case dlt_gap_fill_reply_type: on_dlt_gap_fill_reply(peer, msg.as()); break; + case dlt_block_vote_message_type: + on_dlt_block_vote(peer, msg.as()); + break; + case dlt_timeout_vote_message_type: + on_dlt_timeout_vote(peer, msg.as()); + break; default: auto ep_it_unk = _peer_states.find(peer); auto ep_unk = (ep_it_unk != _peer_states.end()) ? std::string(ep_it_unk->second.endpoint) : std::to_string(peer); @@ -1056,6 +1068,7 @@ dlt_hello_message dlt_p2p_node::build_hello_message() const { hello.fork_status = _fork_status; hello.node_status = _node_status; hello.node_id = _node_id; // identify ourselves so NAT peers can dedup by node, not IP + hello.capabilities = DLT_CAP_BLOCK_VOTE | DLT_CAP_TIMEOUT_VOTE; return hello; } @@ -1088,6 +1101,21 @@ bool dlt_p2p_node::check_fork_alignment(const dlt_hello_message& hello, return true; } + // ── We are empty, peer serves the chain from genesis ──────── + // Mirror of the rule above. A node started from genesis (fresh testnet, no snapshot) + // has no block to recognise the peer by, so every peer was "not fork-aligned": exchange + // stayed off, blocks #1..#2 were never requested and every later block was unlinkable. + // Only nodes that were connected while #1 was being produced ever got the chain (6 of 21 + // on the HF16 QC stand, #6741). Every block will still be fully validated from genesis on + // apply against the locally configured genesis/chain id and checkpoints. This hello + // grants transport eligibility, not trust in the advertised head or canonical history. + // Operators needing a pinned bootstrap history must configure known checkpoints or + // a trusted snapshot; a peer that cannot serve from #1 (a DLT node + // with a pruned range) still needs a snapshot and is not accepted here. + if (_delegate->get_head_block_num() == 0 && hello.dlt_earliest_block <= 1) { + return true; + } + uint32_t our_earliest = _delegate->get_dlt_earliest_block(); uint32_t our_latest = _delegate->get_dlt_latest_block(); @@ -1138,6 +1166,7 @@ void dlt_p2p_node::on_dlt_hello(peer_id peer, const dlt_hello_message& hello) { // Persist node_id — used for dedup and peer-exchange identity. state.node_id = hello.node_id; + state.peer_capabilities = hello.capabilities; // ── Self-connection guard ─────────────────────────────────────────────── // The hello carries OUR node_id ⇒ this socket ends at our own listener. @@ -2502,20 +2531,112 @@ void dlt_p2p_node::broadcast_block(const signed_block& block) { void dlt_p2p_node::broadcast_block_post_validation( const block_id_type& block_id, const std::string& witness_account, - const signature_type& validator_signature) { - // For now, send as fork_status message with block_id - dlt_fork_status_message msg; - msg.fork_status = _fork_status; - msg.head_block_id = block_id; - msg.head_block_num = graphene::protocol::block_header::num_from_id(block_id); - if (_delegate) { - msg.lib_block_id = _delegate->get_lib_block_id(); - msg.lib_block_num = _delegate->get_lib_block_num(); - msg.dlt_earliest_block = _delegate->get_dlt_earliest_block(); - msg.dlt_latest_block = _delegate->get_dlt_latest_block(); + const signature_type& validator_signature, + uint32_t round) { + // The signature is a block vote (block_vote_digest; round = slot of the + // block) made by the validator plugin. Until the DLT redesign is fully QC-aware it travels as + // dlt_block_vote_message; before this the call sent a fork_status and the + // signature never left the node, so LIB fell back to the producer rule. + dlt_block_vote_message msg; + msg.block_num = graphene::protocol::block_header::num_from_id(block_id); + msg.block_id = block_id; + msg.round = round; + msg.validator = witness_account; + msg.signature = validator_signature; + // A re-send of our own vote (the validator retries until a QC forms) would + // die in the relay dedup and in the pool's one-vote-per-round check, so it + // goes straight to the peers. Peers that already have it drop it there. + const auto key = fc::sha256::hash(fc::raw::pack(msg)); + if (_block_votes_seen.count(key)) { + _block_votes_seen[key] = fc::time_point::now(); + gossip_to_capable(message(msg), DLT_CAP_BLOCK_VOTE, INVALID_PEER_ID); + return; } - msg.node_status = _node_status; - send_to_all_our_fork_peers(message(msg)); + process_block_vote(msg, INVALID_PEER_ID); +} + +void dlt_p2p_node::on_dlt_block_vote(peer_id peer, const dlt_block_vote_message& msg) { + process_block_vote(msg, peer); +} + +void dlt_p2p_node::process_block_vote(const dlt_block_vote_message& msg, peer_id from) { + static constexpr int64_t DLT_BLOCK_VOTE_SEEN_SEC = 120; + const auto now = fc::time_point::now(); + + // Prune the relay-dedup map. Votes older than two minutes are far below LIB + // at any block interval we run, so re-accepting one is harmless (the pool + // already counted it or dropped it as stale). + for (auto it = _block_votes_seen.begin(); it != _block_votes_seen.end(); ) { + if (now - it->second > fc::seconds(DLT_BLOCK_VOTE_SEEN_SEC)) it = _block_votes_seen.erase(it); + else ++it; + } + const auto key = fc::sha256::hash(fc::raw::pack(msg)); + if (!_block_votes_seen.emplace(key, now).second) return; // already handled + + // Verification lives in the chain: scheduled validator + signature over + // block_vote_digest. An invalid vote is neither counted nor relayed, so a + // peer cannot use us to amplify garbage. + if (!_delegate || !_delegate->accept_block_vote(msg)) { + if (from != INVALID_PEER_ID) { + dlog(DLT_LOG_DGRAY "Block vote #${n} by ${v} from peer ${p} not accepted" DLT_LOG_RESET, + ("n", msg.block_num)("v", msg.validator)("p", from)); + } + return; + } + + // Gossip to capable fork peers only: a peer without DLT_CAP_BLOCK_VOTE + // would score the message as an unknown packet against us. + gossip_to_capable(message(msg), DLT_CAP_BLOCK_VOTE, from); +} + +void dlt_p2p_node::gossip_to_capable(const message& out, uint32_t cap, peer_id from) { + std::set sent_to_node_ids; + static const node_id_t zero_id; + std::vector targets; + for (const auto& item : _peer_states) { + const auto& st = item.second; + if (item.first == from) continue; + if (st.lifecycle_state != DLT_PEER_LIFECYCLE_ACTIVE || !st.exchange_enabled) continue; + if (!(st.peer_capabilities & cap)) continue; + if (st.node_id != zero_id && !sent_to_node_ids.insert(st.node_id).second) continue; + targets.push_back(item.first); + } + for (auto id : targets) send_message(id, out); +} + +void dlt_p2p_node::broadcast_timeout_vote(uint32_t round, uint32_t high_qc_round, + const std::string& validator, const signature_type& signature) { + dlt_timeout_vote_message msg; + msg.round = round; + msg.high_qc_round = high_qc_round; + msg.validator = validator; + msg.signature = signature; + process_timeout_vote(msg, INVALID_PEER_ID); +} + +void dlt_p2p_node::on_dlt_timeout_vote(peer_id peer, const dlt_timeout_vote_message& msg) { + process_timeout_vote(msg, peer); +} + +void dlt_p2p_node::process_timeout_vote(const dlt_timeout_vote_message& msg, peer_id from) { + static constexpr int64_t DLT_TIMEOUT_VOTE_SEEN_SEC = 120; + const auto now = fc::time_point::now(); + for (auto it = _timeout_votes_seen.begin(); it != _timeout_votes_seen.end(); ) { + if (now - it->second > fc::seconds(DLT_TIMEOUT_VOTE_SEEN_SEC)) it = _timeout_votes_seen.erase(it); + else ++it; + } + const auto key = fc::sha256::hash(fc::raw::pack(msg)); + if (!_timeout_votes_seen.emplace(key, now).second) return; // already handled + + // Invalid votes are neither counted nor relayed (no amplification). + if (!_delegate || !_delegate->accept_timeout_vote(msg)) { + if (from != INVALID_PEER_ID) { + dlog(DLT_LOG_DGRAY "Timeout vote r${r} by ${v} from peer ${p} not accepted" DLT_LOG_RESET, + ("r", msg.round)("v", msg.validator)("p", from)); + } + return; + } + gossip_to_capable(message(msg), DLT_CAP_TIMEOUT_VOTE, from); } void dlt_p2p_node::broadcast_transaction(const signed_transaction& trx) { @@ -3439,6 +3560,26 @@ void dlt_p2p_node::soft_ban_peer(peer_id peer, const std::string& reason) { auto it = _peer_states.find(peer); if (it == _peer_states.end()) return; + // A trusted peer is never soft-banned: we drop the connection and retry shortly instead. + // Trusted nodes are declared by the operator (admin.p2p.add_trusted), and turning one + // bad packet into an hour of silence defeats the purpose of trusting it. + if (_trusted_peers.count(it->second.endpoint)) { + wlog(DLT_LOG_ORANGE "Trusted peer ${ep}: not banning (${r}); disconnecting and retrying" DLT_LOG_RESET, + ("ep", it->second.endpoint)("r", reason)); + it->second.lifecycle_state = DLT_PEER_LIFECYCLE_DISCONNECTED; + it->second.state_entered_time = fc::time_point::now(); + it->second.ban_reason.clear(); + it->second.ban_duration_sec = 0; + it->second.reconnect_backoff_sec = dlt_peer_state::INITIAL_RECONNECT_BACKOFF_SEC; + it->second.next_reconnect_attempt = fc::time_point::now() + fc::seconds(30); + auto conn_it = _connections.find(peer); + if (conn_it != _connections.end()) { + try { if (conn_it->second) conn_it->second->close(); } catch (...) {} + _connections.erase(conn_it); + } + return; + } + wlog(DLT_LOG_RED "Soft-banning peer ${ep} for ${d}s (reason: ${r})" DLT_LOG_RESET, ("ep", it->second.endpoint)("d", BAN_DURATION_SEC)("r", reason)); @@ -3466,6 +3607,179 @@ void dlt_p2p_node::soft_ban_peer(peer_id peer, const std::string& reason) { } } +// ── Operator control (admin API) ───────────────────────────────────── + +namespace { + +const char* lifecycle_name(dlt_peer_lifecycle_state s) { + switch (s) { + case DLT_PEER_LIFECYCLE_CONNECTING: return "CONNECTING"; + case DLT_PEER_LIFECYCLE_HANDSHAKING: return "HANDSHAKING"; + case DLT_PEER_LIFECYCLE_SYNCING: return "SYNCING"; + case DLT_PEER_LIFECYCLE_ACTIVE: return "ACTIVE"; + case DLT_PEER_LIFECYCLE_DISCONNECTED: return "DISCONNECTED"; + case DLT_PEER_LIFECYCLE_BANNED: return "BANNED"; + } + return "?"; +} + +const char* node_status_name(uint8_t s) { + return s == DLT_NODE_STATUS_FORWARD ? "FORWARD" : "SYNC"; +} + +const char* fork_status_name(uint8_t s) { + switch (s) { + case DLT_FORK_STATUS_NORMAL: return "NORMAL"; + case DLT_FORK_STATUS_LOOKING_RESOLUTION: return "LOOKING_RESOLUTION"; + case DLT_FORK_STATUS_MINORITY: return "MINORITY"; + } + return "?"; +} + +std::string node_id_hex(const node_id_t &id) { + static const char digits[] = "0123456789abcdef"; + bool zero = true; + for (size_t i = 0; i < sizeof(id.data); ++i) { + if (id.data[i] != 0) { zero = false; break; } + } + if (zero) return std::string(); + std::string out; + out.reserve(sizeof(id.data) * 2); + for (size_t i = 0; i < sizeof(id.data); ++i) { + const unsigned char b = static_cast(id.data[i]); + out.push_back(digits[b >> 4]); + out.push_back(digits[b & 0x0f]); + } + return out; +} + +} // namespace + +void dlt_p2p_node::add_peer(const fc::ip::endpoint& ep, bool trusted) { + if (trusted) _trusted_peers.insert(ep); + + dlt_known_peer kp; + kp.endpoint = ep; + kp.last_seen = fc::time_point::now(); + _known_peers.insert(kp); + + // An endpoint we already track may be parked in DISCONNECTED/BANNED with a long + // backoff; clear that so add_peer dials now rather than in an hour. + for (auto& item : _peer_states) { + auto& s = item.second; + if (s.endpoint != ep) continue; + s.ban_reason.clear(); + s.ban_duration_sec = 0; + s.reconnect_backoff_sec = dlt_peer_state::INITIAL_RECONNECT_BACKOFF_SEC; + s.next_reconnect_attempt = fc::time_point(); + break; + } + + connect_to_peer(ep); + ilog(DLT_LOG_GREEN "DLT P2P: operator added peer ${ep}${t}" DLT_LOG_RESET, + ("ep", ep)("t", trusted ? " as trusted" : "")); +} + +uint32_t dlt_p2p_node::clear_banned_peers() { + uint32_t cleared = 0; + for (auto& item : _peer_states) { + auto& s = item.second; + const bool banned = s.lifecycle_state == DLT_PEER_LIFECYCLE_BANNED || !s.ban_reason.empty(); + if (!banned) continue; + + ilog(DLT_LOG_GREEN "DLT P2P: clearing ban for ${ep} (was: ${r})" DLT_LOG_RESET, + ("ep", s.endpoint)("r", s.ban_reason.empty() ? std::string("remote") : s.ban_reason)); + ++cleared; + s.ban_reason.clear(); + s.ban_duration_sec = 0; + s.reconnect_backoff_sec = dlt_peer_state::INITIAL_RECONNECT_BACKOFF_SEC; + s.next_reconnect_attempt = fc::time_point(); + + dlt_known_peer kp; + kp.endpoint = s.endpoint; + kp.last_seen = fc::time_point::now(); + _known_peers.insert(kp); + + // connect_to_peer() reuses this peer_id and resets the per-session fields. + connect_to_peer(s.endpoint); + } + ilog(DLT_LOG_GREEN "DLT P2P: clearing banned peer list — ${n} entries reset" DLT_LOG_RESET, + ("n", cleared)); + return cleared; +} + +dlt_peer_report dlt_p2p_node::get_peer_report() const { + dlt_peer_report r; + const auto now = fc::time_point::now(); + + r.uptime_sec = (_node_start_time.sec_since_epoch() > 0) + ? static_cast((now - _node_start_time).count() / 1000000) + : 0; + r.node_status = node_status_name(_node_status); + r.fork_status = fork_status_name(_fork_status); + r.paused = _block_processing_paused; + r.isolated_peers = _isolated_peers; + r.catching_up = is_catching_up_after_pause(); + r.our_head = _delegate ? _delegate->get_head_block_num() : 0; + r.our_lib = _delegate ? _delegate->get_lib_block_num() : 0; + r.dlt_earliest = _delegate ? _delegate->get_dlt_earliest_block() : 0; + r.dlt_latest = _delegate ? _delegate->get_dlt_latest_block() : 0; + r.max_connections = _max_connections; + r.known_peers = static_cast(_known_peers.size()); + r.trusted_peers = static_cast(_trusted_peers.size()); + + for (const auto& item : _peer_states) { + const auto& s = item.second; + dlt_peer_report_entry e; + e.endpoint = std::string(s.endpoint); + e.node_id = node_id_hex(s.node_id); + e.lifecycle = lifecycle_name(s.lifecycle_state); + e.node_status = node_status_name(s.peer_node_status); + e.fork_status = fork_status_name(s.peer_fork_status); + e.incoming = s.is_incoming; + e.trusted = _trusted_peers.count(s.endpoint) > 0; + e.exchange_enabled = s.exchange_enabled; + e.fork_alignment = s.fork_alignment; + if (s.connected_since.sec_since_epoch() > 0) { + const int64_t sec = (now - s.connected_since).count() / 1000000; + e.connected_sec = sec > 0 ? static_cast(sec) : 0; + } + e.peer_head = s.peer_head_num; + e.peer_lib = s.peer_lib_num; + e.peer_dlt_earliest = s.peer_dlt_earliest; + e.peer_dlt_latest = s.peer_dlt_latest; + e.head_lag = static_cast(r.our_head) - static_cast(s.peer_head_num); + e.spam_strikes = s.spam_strikes; + e.ban_reason = s.ban_reason; + e.ban_duration_sec = s.ban_duration_sec; + e.reconnect_backoff_sec = s.reconnect_backoff_sec; + if (s.next_reconnect_attempt.sec_since_epoch() > 0 && s.next_reconnect_attempt > now) { + e.next_reconnect_in_sec = static_cast( + (s.next_reconnect_attempt - now).count() / 1000000); + } + + if (s.lifecycle_state == DLT_PEER_LIFECYCLE_ACTIVE || + s.lifecycle_state == DLT_PEER_LIFECYCLE_SYNCING) { + ++r.active_count; + } + if (s.lifecycle_state == DLT_PEER_LIFECYCLE_BANNED || !s.ban_reason.empty()) { + ++r.banned_peers; + } + if (s.peer_head_num > r.best_peer_head) r.best_peer_head = s.peer_head_num; + if (s.peer_head_num > r.our_head) ++r.peers_ahead; + + r.peers.push_back(std::move(e)); + } + + r.connection_count = static_cast(_connections.size()); + r.best_peer_head_lag = static_cast(r.our_head) - static_cast(r.best_peer_head); + std::sort(r.peers.begin(), r.peers.end(), + [](const dlt_peer_report_entry& a, const dlt_peer_report_entry& b) { + return a.endpoint < b.endpoint; + }); + return r; +} + // ── Diagnostics ────────────────────────────────────────────────────── void dlt_p2p_node::log_node_status() { @@ -3772,8 +4086,8 @@ void dlt_p2p_node::periodic_known_peers_cleanup() { continue; } - // Skip seeds (always retry-worthy) - bool is_seed = false; + // Skip seeds and operator-trusted peers (always retry-worthy) + bool is_seed = _trusted_peers.count(it->endpoint) > 0; for (const auto& sep : _seed_nodes) { if (sep == it->endpoint) { is_seed = true; break; } } diff --git a/libraries/network/include/graphene/network/dlt_p2p_messages.hpp b/libraries/network/include/graphene/network/dlt_p2p_messages.hpp index 4a5ddc2964..c475feb6c2 100644 --- a/libraries/network/include/graphene/network/dlt_p2p_messages.hpp +++ b/libraries/network/include/graphene/network/dlt_p2p_messages.hpp @@ -41,8 +41,17 @@ enum dlt_message_type_enum { dlt_soft_ban_message_type = 5114, dlt_gap_fill_request_type = 5115, dlt_gap_fill_reply_type = 5116, + dlt_block_vote_message_type = 5117, + dlt_timeout_vote_message_type = 5118, }; +// ── Capability bits advertised in hello.capabilities ──────────────── +// Sent as an optional trailing hello field: peers that predate it ignore the +// extra bytes (unpack_hello_compat), and we never send them a message type they +// would score as an unknown packet. +static const uint32_t DLT_CAP_BLOCK_VOTE = 0x1; // understands dlt_block_vote_message +static const uint32_t DLT_CAP_TIMEOUT_VOTE = 0x2; // understands dlt_timeout_vote_message + // ── DLT node status ───────────────────────────────────────────────── enum dlt_node_status { DLT_NODE_STATUS_SYNC = 0, // catching up @@ -85,6 +94,7 @@ struct dlt_hello_message { // sharing the same NAT IP (different ports). Each node generates a random // keypair at startup. Zero-value means "unknown" (old protocol peer). node_id_t node_id; + uint32_t capabilities = 0; // DLT_CAP_* bits; optional trailing field (absent = 0) }; // ── DLT Hello Reply ───────────────────────────────────────────────── @@ -235,6 +245,34 @@ struct dlt_gap_fill_reply { std::vector blocks; // requested blocks (may be partial) }; +// ── Block vote (QC building block) ────────────────────────────────── +// A scheduled validator's signature over block_vote_digest(chain_id, +// block_num, block_id, round). Gossiped to capable fork peers; a quorum of +// votes for one (block_num, round, block_id) forms a quorum certificate. +// Replaces the pre-HF16 post-validation broadcast, which the DLT network +// redesign reduced to a signature-less fork_status. +struct dlt_block_vote_message { + static const dlt_message_type_enum type; + + uint32_t block_num = 0; + block_id_type block_id; + uint32_t round = 0; + std::string validator; + signature_type signature; +}; + +// HF16 timeout vote: `validator` gave up on `round` without a QC. Signs +// block_timeout_digest(round, high_qc_round); a quorum for one round forms a +// timeout certificate (TC) that lets the next producer skip a QC-less head. +struct dlt_timeout_vote_message { + static const dlt_message_type_enum type; + + uint32_t round = 0; + uint32_t high_qc_round = 0; + std::string validator; + signature_type signature; +}; + } // namespace network } // namespace graphene @@ -257,7 +295,9 @@ FC_REFLECT_ENUM(graphene::network::dlt_message_type_enum, (dlt_transaction_message_type) (dlt_soft_ban_message_type) (dlt_gap_fill_request_type) - (dlt_gap_fill_reply_type)) + (dlt_gap_fill_reply_type) + (dlt_block_vote_message_type) + (dlt_timeout_vote_message_type)) FC_REFLECT_ENUM(graphene::network::dlt_node_status, (DLT_NODE_STATUS_SYNC)(DLT_NODE_STATUS_FORWARD)) @@ -273,7 +313,7 @@ FC_REFLECT_ENUM(graphene::network::dlt_peer_lifecycle_state, FC_REFLECT((graphene::network::dlt_hello_message), (protocol_version)(head_block_id)(head_block_num)(lib_block_id)(lib_block_num) (dlt_earliest_block)(dlt_latest_block)(emergency_active)(has_emergency_key) - (fork_status)(node_status)(node_id)) + (fork_status)(node_status)(node_id)(capabilities)) FC_REFLECT((graphene::network::dlt_hello_reply_message), (exchange_enabled)(fork_alignment)(initiator_head_seen)(initiator_lib_seen) @@ -325,3 +365,9 @@ FC_REFLECT((graphene::network::dlt_gap_fill_request), FC_REFLECT((graphene::network::dlt_gap_fill_reply), (blocks)) + +FC_REFLECT((graphene::network::dlt_block_vote_message), + (block_num)(block_id)(round)(validator)(signature)) + +FC_REFLECT((graphene::network::dlt_timeout_vote_message), + (round)(high_qc_round)(validator)(signature)) diff --git a/libraries/network/include/graphene/network/dlt_p2p_node.hpp b/libraries/network/include/graphene/network/dlt_p2p_node.hpp index 1c2a2d1e14..8930ce01c0 100644 --- a/libraries/network/include/graphene/network/dlt_p2p_node.hpp +++ b/libraries/network/include/graphene/network/dlt_p2p_node.hpp @@ -38,6 +38,57 @@ enum class dlt_block_accept_result { REJECTED ///< Block failed validation entirely (bad signature, etc.) }; +// ── Operator-facing snapshots ───────────────────────────────────────── +// Plain data copies of the P2P state, returned on demand by the admin API. They must be +// read on the P2P thread (the maps they copy belong to it); everything here is already +// formatted (no fc types) so the HTTP layer stays free of network internals. + +struct dlt_peer_report_entry { + std::string endpoint; + std::string node_id; ///< hex of public_key_data, empty before hello + std::string lifecycle; ///< CONNECTING / HANDSHAKING / SYNCING / ACTIVE / DISCONNECTED / BANNED + std::string node_status; ///< SYNC / FORWARD, as last seen from the peer + std::string fork_status; ///< NORMAL / LOOKING_RESOLUTION / MINORITY + bool incoming = false; + bool trusted = false; + bool exchange_enabled = false; + bool fork_alignment = false; + uint32_t connected_sec = 0; + uint32_t peer_head = 0; + uint32_t peer_lib = 0; + uint32_t peer_dlt_earliest = 0; + uint32_t peer_dlt_latest = 0; + int64_t head_lag = 0; ///< our head - peer head; negative = the peer is ahead + uint32_t spam_strikes = 0; + std::string ban_reason; + uint32_t ban_duration_sec = 0; + uint32_t reconnect_backoff_sec = 0; + uint32_t next_reconnect_in_sec = 0; +}; + +struct dlt_peer_report { + uint64_t uptime_sec = 0; + std::string node_status; + std::string fork_status; + bool paused = false; + bool isolated_peers = false; + bool catching_up = false; + uint32_t our_head = 0; + uint32_t our_lib = 0; + uint32_t dlt_earliest = 0; + uint32_t dlt_latest = 0; + uint32_t connection_count = 0; + uint32_t active_count = 0; + uint32_t max_connections = 0; + uint32_t known_peers = 0; + uint32_t trusted_peers = 0; + uint32_t banned_peers = 0; + uint32_t best_peer_head = 0; + int64_t best_peer_head_lag = 0; ///< our head - best peer head; negative = behind + uint32_t peers_ahead = 0; + std::vector peers; +}; + /** * @class dlt_p2p_delegate * @brief Callback interface between dlt_p2p_node and the chain/plugin layer. @@ -68,6 +119,13 @@ class dlt_p2p_delegate { // ── Block/transaction handling ─────────────────────────────── virtual dlt_block_accept_result accept_block(const signed_block& block, bool sync_mode) = 0; virtual bool accept_transaction(const signed_transaction& trx) = 0; + // Verify a block vote (scheduled validator, signature over block_vote_digest) + // and record it. True = valid and not seen before, so it is worth relaying. + // Non-pure: test delegates that never see votes need not implement it. + virtual bool accept_block_vote(const dlt_block_vote_message& vote) { return false; } + // Same contract for a timeout vote (scheduled validator, signature over + // block_timeout_digest). True = valid and new, worth relaying. + virtual bool accept_timeout_vote(const dlt_timeout_vote_message& vote) { return false; } // ── Fork resolution ────────────────────────────────────────── virtual int compare_fork_branches(const block_id_type& a, const block_id_type& b) const = 0; @@ -138,7 +196,10 @@ class dlt_p2p_node { void broadcast_block_post_validation( const block_id_type& block_id, const std::string& witness_account, - const signature_type& validator_signature); + const signature_type& validator_signature, + uint32_t round); + void broadcast_timeout_vote(uint32_t round, uint32_t high_qc_round, + const std::string& validator, const signature_type& signature); void broadcast_transaction(const signed_transaction& trx); void broadcast_chain_status(); @@ -158,6 +219,20 @@ class dlt_p2p_node { dlt_fork_status get_fork_status() const { return _fork_status; } bool is_on_majority_fork() const; + // ── Operator control (admin API) ───────────────────────────── + // All three must be called on the P2P thread: they touch the peer maps. + // + // add_peer() starts dialing immediately and remembers the endpoint so the periodic + // reconnect loop keeps retrying it. trusted=true additionally makes the peer + // exempt from soft-bans: a trusted node that sends bad data is disconnected and + // retried instead of being banned for an hour. + void add_peer(const fc::ip::endpoint& ep, bool trusted); + // Clears local and remote soft-bans, resets backoff and re-dials. Returns the number + // of peer entries that carried a ban. + uint32_t clear_banned_peers(); + // Read-only copy of the state served by admin.p2p.status / the /ui/ page. + dlt_peer_report get_peer_report() const; + // True when block production should be deferred: either during // a block-processing pause (snapshot creation holding DB read lock) // or while catching up after the pause ends (draining queued @@ -233,6 +308,13 @@ class dlt_p2p_node { // ── Gap fill handlers (exchange-only) ──────────────────────── void on_dlt_gap_fill_request(peer_id peer, const dlt_gap_fill_request& req); void on_dlt_gap_fill_reply(peer_id peer, const dlt_gap_fill_reply& reply); + void on_dlt_block_vote(peer_id peer, const dlt_block_vote_message& msg); + // Accept a vote (local or relayed) and gossip it to capable fork peers. + void process_block_vote(const dlt_block_vote_message& msg, peer_id from); + void on_dlt_timeout_vote(peer_id peer, const dlt_timeout_vote_message& msg); + void process_timeout_vote(const dlt_timeout_vote_message& msg, peer_id from); + // Send to active fork peers advertising `cap`, except `from`, once per node id. + void gossip_to_capable(const message& out, uint32_t cap, peer_id from); void request_gap_fill(); // ── Hello construction ─────────────────────────────────────── @@ -391,6 +473,10 @@ class dlt_p2p_node { std::map _peer_states; std::set _known_peers; std::vector _seed_nodes; + // Endpoints the operator marked trusted at runtime (admin.p2p.add_trusted). They are + // kept out of the known-peer GC and never soft-banned. Not persisted: a restart drops + // them unless the operator re-adds them (or lists them in p2p-seed-node). + std::set _trusted_peers; // ── Active connections ─────────────────────────────────────── std::map _connections; @@ -420,6 +506,10 @@ class dlt_p2p_node { // ── Mempool ────────────────────────────────────────────────── std::map _mempool_by_id; + // Relay dedup for block votes: digest of the vote message -> seen time. + // Bounded by pruning entries older than DLT_BLOCK_VOTE_SEEN_SEC. + std::map _block_votes_seen; + std::map _timeout_votes_seen; std::multimap _mempool_by_expiry; uint32_t _mempool_total_bytes = 0; diff --git a/libraries/network/include/graphene/network/dlt_p2p_peer_state.hpp b/libraries/network/include/graphene/network/dlt_p2p_peer_state.hpp index 92255cb4e9..7aacce1333 100644 --- a/libraries/network/include/graphene/network/dlt_p2p_peer_state.hpp +++ b/libraries/network/include/graphene/network/dlt_p2p_peer_state.hpp @@ -42,6 +42,7 @@ struct dlt_peer_state { bool peer_has_emergency_key = false; uint8_t peer_fork_status = DLT_FORK_STATUS_NORMAL; uint8_t peer_node_status = DLT_NODE_STATUS_SYNC; + uint32_t peer_capabilities = 0; // DLT_CAP_* from hello (0 = pre-capability peer) // Our view of this peer bool exchange_enabled = false; // "our fork" diff --git a/libraries/protocol/get_config.cpp b/libraries/protocol/get_config.cpp index db9ff351b9..a3addffef4 100644 --- a/libraries/protocol/get_config.cpp +++ b/libraries/protocol/get_config.cpp @@ -5,12 +5,16 @@ #include #endif #include +#include #include namespace graphene { namespace protocol { - fc::variant_object get_config() { + fc::variant_object get_config(uint32_t live_block_interval) { + const uint32_t interval = live_block_interval ? live_block_interval : CHAIN_BLOCK_INTERVAL; + FC_ASSERT(interval == CHAIN_BLOCK_INTERVAL || interval == CHAIN_BLOCK_INTERVAL_HF16); + const uint32_t scale = CHAIN_BLOCK_INTERVAL / interval; fc::mutable_variant_object result; result["CHAIN_100_PERCENT"] = CHAIN_100_PERCENT; @@ -22,9 +26,11 @@ namespace graphene { result["CONSENSUS_BANDWIDTH_RESERVE_BELOW"] = CONSENSUS_BANDWIDTH_RESERVE_BELOW; result["CHAIN_HARDFORK_VERSION"] = CHAIN_HARDFORK_VERSION; result["CHAIN_VERSION"] = CHAIN_VERSION; - result["CHAIN_BLOCK_INTERVAL"] = CHAIN_BLOCK_INTERVAL; - result["CHAIN_BLOCKS_PER_DAY"] = CHAIN_BLOCKS_PER_DAY; - result["CHAIN_BLOCKS_PER_YEAR"] = CHAIN_BLOCKS_PER_YEAR; + result["CHAIN_BLOCK_INTERVAL"] = interval; + result["CHAIN_BLOCK_INTERVAL_HF16"] = CHAIN_BLOCK_INTERVAL_HF16; + result["CHAIN_BLOCKS_PER_HOUR"] = uint32_t(3600 / interval); + result["CHAIN_BLOCKS_PER_DAY"] = uint32_t(86400 / interval); + result["CHAIN_BLOCKS_PER_YEAR"] = uint32_t(CHAIN_SECONDS_PER_YEAR / interval); result["CHAIN_ID"] = CHAIN_ID; result["CHAIN_HARDFORK_REQUIRED_VALIDATORS"] = CHAIN_HARDFORK_REQUIRED_VALIDATORS; result["CHAIN_INITIATOR_NAME"] = CHAIN_INITIATOR_NAME; @@ -47,7 +53,7 @@ namespace graphene { result["CHAIN_MAX_SIG_CHECK_DEPTH"] = CHAIN_MAX_SIG_CHECK_DEPTH; result["CHAIN_MAX_TIME_UNTIL_EXPIRATION"] = CHAIN_MAX_TIME_UNTIL_EXPIRATION; result["CHAIN_MAX_TRANSACTION_SIZE"] = CHAIN_MAX_TRANSACTION_SIZE; - result["CHAIN_MAX_UNDO_HISTORY"] = CHAIN_MAX_UNDO_HISTORY; + result["CHAIN_MAX_UNDO_HISTORY"] = uint32_t(CHAIN_MAX_UNDO_HISTORY * scale); result["CHAIN_MAX_VOTE_CHANGES"] = CHAIN_MAX_VOTE_CHANGES; result["CHAIN_MAX_TOP_VALIDATORS"] = CHAIN_MAX_TOP_VALIDATORS; result["CHAIN_MAX_WITHDRAW_ROUTES"] = CHAIN_MAX_WITHDRAW_ROUTES; @@ -70,6 +76,19 @@ namespace graphene { result["CHAIN_PENDING_TRANSACTION_EXECUTION_LIMIT"] = CHAIN_PENDING_TRANSACTION_EXECUTION_LIMIT; result["CHAIN_DIGITAL_ASSET_ISSUED_PER_BLOCK"] = CHAIN_DIGITAL_ASSET_ISSUED_PER_BLOCK; + // Stored/voted properties are native block counts (owner Q3108=A), not + // hidden 3-second units. Expose live defaults so clients need not re-vote + // stale compile-time values after HF16. + chain_properties_pm defaults; + defaults.inflation_recalc_period *= scale; + defaults.distribution_epoch_length *= scale; + defaults.pm_batch_epoch_blocks *= scale; + defaults.pm_reveal_window_blocks *= scale; + result["CHAIN_DEFAULT_CHAIN_PROPERTIES"] = fc::variant(defaults); + result["CHAIN_PROPERTY_BLOCK_UNITS"] = "native-blocks"; + result["CHAIN_MIN_DISTRIBUTION_EPOCH_LENGTH"] = uint32_t(CHAIN_MIN_DISTRIBUTION_EPOCH_LENGTH * scale); + result["CHAIN_MAX_VALIDATOR_MISSED_BLOCKS"] = uint32_t(CHAIN_MAX_VALIDATOR_MISSED_BLOCKS * scale); + result["CHAIN_EMERGENCY_MAX_VALIDATOR_MISSED_BLOCKS"] = uint32_t(CHAIN_EMERGENCY_MAX_VALIDATOR_MISSED_BLOCKS * scale); return result; } diff --git a/libraries/protocol/include/graphene/protocol/base.hpp b/libraries/protocol/include/graphene/protocol/base.hpp index 027a15eaab..bbede92bc4 100644 --- a/libraries/protocol/include/graphene/protocol/base.hpp +++ b/libraries/protocol/include/graphene/protocol/base.hpp @@ -40,10 +40,57 @@ namespace graphene { } }; + /// Quorum certificate of the parent block (HF16). Required when the + /// block hands the turn over (parent produced by another validator): + /// >2/3 of scheduled validators signed block_vote_digest for the parent. + /// Two conflicting certified blocks at one height need a validator to + /// vote twice at that height, which the validator plugin refuses. + struct block_qc_certificate { + uint32_t block_num = 0; + block_id_type block_id; + uint32_t round = 0; + flat_map signatures; + }; + + /// One validator's timeout vote inside a TC: the round of the highest QC + /// it had seen and its signature over block_timeout_digest(round, high_qc_round). + struct block_timeout_vote { + uint32_t high_qc_round = 0; + signature_type signature; + }; + + /// HF16 timeout certificate (Jolteon): a quorum of validators gave up on + /// `round`. A block that skips a QC-less head carries TC(round of that head); + /// its parent QC must be at least the max high_qc_round inside the TC. + struct block_timeout_certificate { + uint32_t round = 0; + flat_map votes; + }; + + /// One signed block vote, as gossiped (see block_vote_digest). + struct block_vote_evidence { + uint32_t block_num = 0; + block_id_type block_id; + signature_type signature; + }; + + /// HF16 double signing: `validator` voted for two different blocks in one + /// round. Carried in a block header; punished like a missed block, once per + /// (validator, round), with the validator_equivocation virtual operation. + struct block_equivocation_proof { + account_name_type validator; + uint32_t round = 0; + block_vote_evidence first; + block_vote_evidence second; + }; + typedef static_variant< void_t, version, // Normal witness version reporting, for diagnostics and voting - hardfork_version_vote // Voting for the next hardfork to trigger + hardfork_version_vote, // Voting for the next hardfork to trigger + block_qc_certificate, // HF16: QC of the parent block (append only: index is wire format) + block_timeout_certificate, // HF16: TC of the skipped round + block_equivocation_proof // HF16: double vote by a validator > block_header_extensions; typedef static_variant< @@ -57,5 +104,10 @@ namespace graphene { } } // graphene::protocol +FC_REFLECT((graphene::protocol::block_qc_certificate), (block_num)(block_id)(round)(signatures)) +FC_REFLECT((graphene::protocol::block_timeout_vote), (high_qc_round)(signature)) +FC_REFLECT((graphene::protocol::block_timeout_certificate), (round)(votes)) +FC_REFLECT((graphene::protocol::block_vote_evidence), (block_num)(block_id)(signature)) +FC_REFLECT((graphene::protocol::block_equivocation_proof), (validator)(round)(first)(second)) FC_REFLECT_TYPENAME((graphene::protocol::block_header_extensions)) FC_REFLECT_TYPENAME((graphene::protocol::future_extensions)) diff --git a/libraries/protocol/include/graphene/protocol/block_vote.hpp b/libraries/protocol/include/graphene/protocol/block_vote.hpp new file mode 100644 index 0000000000..b3717c0b32 --- /dev/null +++ b/libraries/protocol/include/graphene/protocol/block_vote.hpp @@ -0,0 +1,45 @@ +#pragma once + +#include +#include + +namespace graphene { namespace protocol { + + /// Domain tag of a block vote. Keeps a vote signature from ever being valid + /// as a signature over anything else (transaction, block header, the + /// pre-HF16 post-validation digest of chain_id+block_id). + static const char *const CHAIN_BLOCK_VOTE_DOMAIN = "viz-block-vote-v1"; + + /// Digest a validator signs to vote for block_id at (block_num, round). + /// A quorum of these votes for one (block_num, round, block_id) is a quorum + /// certificate (QC). One signature per (validator, block_num, round) is the + /// safety rule the validator plugin enforces before signing. + inline digest_type block_vote_digest(const chain_id_type &chain_id, uint32_t block_num, + const block_id_type &block_id, uint32_t round) { + digest_type::encoder enc; + fc::raw::pack(enc, std::string(CHAIN_BLOCK_VOTE_DOMAIN)); + fc::raw::pack(enc, chain_id); + fc::raw::pack(enc, block_num); + fc::raw::pack(enc, round); + fc::raw::pack(enc, block_id); + return enc.result(); + } + + /// Domain tag of a timeout vote; distinct from block votes so neither + /// signature can be replayed as the other. + static const char *const CHAIN_BLOCK_TIMEOUT_DOMAIN = "viz-block-timeout-v1"; + + /// Digest a validator signs when it gives up on `round` without a QC. + /// high_qc_round is the round of the highest QC it has seen; signing it + /// binds the claim a TC uses to lower-bound the next block's parent QC. + inline digest_type block_timeout_digest(const chain_id_type &chain_id, uint32_t round, + uint32_t high_qc_round) { + digest_type::encoder enc; + fc::raw::pack(enc, std::string(CHAIN_BLOCK_TIMEOUT_DOMAIN)); + fc::raw::pack(enc, chain_id); + fc::raw::pack(enc, round); + fc::raw::pack(enc, high_qc_round); + return enc.result(); + } + +} } // graphene::protocol diff --git a/libraries/protocol/include/graphene/protocol/chain_operations.hpp b/libraries/protocol/include/graphene/protocol/chain_operations.hpp index 49b99aadca..a69e4298b0 100644 --- a/libraries/protocol/include/graphene/protocol/chain_operations.hpp +++ b/libraries/protocol/include/graphene/protocol/chain_operations.hpp @@ -465,7 +465,8 @@ namespace graphene { namespace protocol { FC_ASSERT(inflation_ratio_committee_vs_reward_fund >= 0); FC_ASSERT(inflation_ratio_committee_vs_reward_fund <= CHAIN_100_PERCENT); FC_ASSERT(inflation_recalc_period >= 0); - FC_ASSERT(inflation_recalc_period <= CHAIN_BLOCKS_PER_YEAR); + // HF16 (#6737): one year is 3x more blocks at 1 s; the pre-HF16 bound is kept by the evaluator. + FC_ASSERT(inflation_recalc_period <= CHAIN_BLOCKS_PER_YEAR * 3); } chain_properties_hf4& operator=(const chain_properties_init& src) { @@ -599,7 +600,7 @@ namespace graphene { namespace protocol { FC_ASSERT(distribution_epoch_length >= CHAIN_MIN_DISTRIBUTION_EPOCH_LENGTH, "distribution_epoch_length must be at least ${min} blocks", ("min", CHAIN_MIN_DISTRIBUTION_EPOCH_LENGTH)); - FC_ASSERT(distribution_epoch_length <= uint32_t(CHAIN_BLOCKS_PER_YEAR), + FC_ASSERT(distribution_epoch_length <= uint32_t(CHAIN_BLOCKS_PER_YEAR * 3), // HF16: see evaluator "distribution_epoch_length must not exceed one year in blocks"); } @@ -789,10 +790,13 @@ namespace graphene { namespace protocol { // commit can never be garbage-collected before its escrow is returned — makes the // "commit is always cleared before GC" invariant hold by construction, not by the // default parameter magnitudes (5 d vs ~11 min) alone. + // Stateless validation must accept both pre-fork and native HF16 counts. + // The evaluator retains the historical 3 s check before HF16 and checks + // the live cadence (with a widened sum) afterwards. Keeping the legacy + // uint32 sum here avoids tightening validation of historical replay. FC_ASSERT(pm_closed_market_retention_sec - > (uint64_t)(pm_batch_epoch_blocks + pm_reveal_window_blocks) * CHAIN_BLOCK_INTERVAL, - "pm_closed_market_retention_sec must exceed the worst-case commit reveal deadline " - "((pm_batch_epoch_blocks + pm_reveal_window_blocks) * CHAIN_BLOCK_INTERVAL)"); + > (uint64_t)(pm_batch_epoch_blocks + pm_reveal_window_blocks) * CHAIN_BLOCK_INTERVAL_HF16, + "pm_closed_market_retention_sec must exceed the minimum possible commit reveal deadline"); FC_ASSERT(pm_processing_cap_per_block > 0, "pm_processing_cap_per_block must be positive"); // #432 D: the settle budget must guarantee PROGRESS (a market with N rows finishes in // ~N/budget blocks) and still bound the work of a single block. Zero would wedge every @@ -1171,7 +1175,7 @@ namespace graphene { namespace protocol { FC_ASSERT(required_amount_max.symbol == TOKEN_SYMBOL); FC_ASSERT(duration >= COMMITTEE_MIN_DURATION); FC_ASSERT(duration <= COMMITTEE_MAX_DURATION); - FC_ASSERT(required_amount_max.amount <= COMMITTEE_MAX_REQUIRED_AMOUNT); + // Supply-dependent ceiling is checked by the evaluator (HF16). } void get_required_regular_authorities(flat_set &a) const { diff --git a/libraries/protocol/include/graphene/protocol/chain_virtual_operations.hpp b/libraries/protocol/include/graphene/protocol/chain_virtual_operations.hpp index 8d69ce63ee..a0605706fb 100644 --- a/libraries/protocol/include/graphene/protocol/chain_virtual_operations.hpp +++ b/libraries/protocol/include/graphene/protocol/chain_virtual_operations.hpp @@ -318,8 +318,24 @@ namespace graphene { namespace protocol { account_name_type stakeholder; asset shares; ///< SHARES credited to stakeholder }; + + // HF16: a block carried proof that `validator` voted for two blocks in + // `round`; the validator is penalised like for a missed block. + struct validator_equivocation_operation : public virtual_operation { + validator_equivocation_operation() {} + + validator_equivocation_operation(const account_name_type& v, uint32_t r, + const block_id_type& a, const block_id_type& b) + : validator(v), round(r), first_block_id(a), second_block_id(b) {} + + account_name_type validator; + uint32_t round = 0; + block_id_type first_block_id; + block_id_type second_block_id; + }; } } //graphene::protocol +FC_REFLECT((graphene::protocol::validator_equivocation_operation), (validator)(round)(first_block_id)(second_block_id)) FC_REFLECT((graphene::protocol::author_reward_operation), (author)(permlink)(token_payout)(vesting_payout)) FC_REFLECT((graphene::protocol::curation_reward_operation), (curator)(reward)(content_author)(content_permlink)) FC_REFLECT((graphene::protocol::content_reward_operation), (author)(permlink)(payout)) diff --git a/libraries/protocol/include/graphene/protocol/config.hpp b/libraries/protocol/include/graphene/protocol/config.hpp index 6d54a0eeb3..5d50a8208f 100644 --- a/libraries/protocol/include/graphene/protocol/config.hpp +++ b/libraries/protocol/include/graphene/protocol/config.hpp @@ -2,12 +2,13 @@ #define CHAIN_STARTUP_VERSION (version(1, 0, 0)) #define CHAIN_HARDFORK_STARTUP_VERSION (hardfork_version(CHAIN_STARTUP_VERSION)) -// HF15 (PM audit fixes) — the middle component IS the hardfork version: CHAIN_HARDFORK_VERSION is -// version's hardfork field, and the auto-vote in database.cpp _generate_block fires only while +// HF16 (BFT finality, 1 s slots, DAO cap and fee freeze) — the middle component IS the hardfork version: CHAIN_HARDFORK_VERSION +// is version's hardfork field, and the auto-vote in database.cpp _generate_block fires only while // current_hardfork_version < CHAIN_HARDFORK_VERSION. A new fork therefore has to move THAT field, -// not the revision; 4.1.0 here must stay byte-equal to CHAIN_HARDFORK_15_VERSION in hardfork.d/15.hf, -// which database_hardfork.cpp asserts. See docs/prediction-markets/pm-audit-fix-upgrade.md. -#define CHAIN_VERSION (version(4, 1, 2)) +// not the revision; 4.2.0 here must stay byte-equal to CHAIN_HARDFORK_16_VERSION in +// hardfork.d/16.hf, which database_hardfork.cpp asserts. See +// docs/prediction-markets/pm-no-contest-fee-freeze.md. +#define CHAIN_VERSION (version(4, 2, 0)) #define CHAIN_HARDFORK_VERSION (hardfork_version(CHAIN_VERSION)) #define CHAIN_NAME "VIZ" @@ -30,12 +31,13 @@ #define CHAIN_CLEAR_CLOSED_COMMITTEE_REQUEST_DELAY fc::days(7) #define CHAIN_BLOCK_INTERVAL 3 +#define CHAIN_BLOCK_INTERVAL_HF16 1 // HF16 (#6737, Q#3103=A): slot after the fork block; database::block_interval() #define CHAIN_BLOCK_VALIDATOR_REPEAT 1 #define CHAIN_BLOCKS_PER_YEAR (365*24*60*60/CHAIN_BLOCK_INTERVAL) #define CHAIN_BLOCKS_PER_DAY (24*60*60/CHAIN_BLOCK_INTERVAL) #define CHAIN_BLOCKS_PER_HOUR (60*60/CHAIN_BLOCK_INTERVAL) -#define CHAIN_MAX_VALIDATOR_MISSED_BLOCKS 200 // ~10 min after first missed block for top witness -#define CHAIN_EMERGENCY_MAX_VALIDATOR_MISSED_BLOCKS (5 * CHAIN_MAX_VALIDATORS) // in emergency mode, blank key after 5 full rounds of missed scheduled slots (~5 min) +#define CHAIN_MAX_VALIDATOR_MISSED_BLOCKS 200 // ~10 min; runtime multiplies block threshold by 3 after HF16 +#define CHAIN_EMERGENCY_MAX_VALIDATOR_MISSED_BLOCKS (5 * CHAIN_MAX_VALIDATORS) // in emergency mode, blank key after 5 full rounds of missed scheduled slots (~5 min; runtime threshold scales at HF16) #define CHAIN_INITIATOR_NAME "viz" // Private key: 5JabcrvaLnBTCkCVFX5r4rmeGGfuJuVp4NAKRNLTey6pxhRQmf4 @@ -126,7 +128,7 @@ /// Number of consecutive blocks produced by the emergency witness that /// triggers automatic exit from emergency mode (validators have rejoined). -#define CHAIN_EMERGENCY_EXIT_NORMAL_BLOCKS 21 // 1 full round of 21 validators +#define CHAIN_EMERGENCY_EXIT_NORMAL_BLOCKS 21 // Legacy unused constant: emergency exits by active-validator quorum, not a timer. #define CHAIN_IRREVERSIBLE_THRESHOLD (75 * CHAIN_1_PERCENT) /** Irreversibility only counts blocks produced if wit.current_run >= CHAIN_IRREVERSIBLE_SUPPORT_MIN_RUN */ diff --git a/libraries/protocol/include/graphene/protocol/config_testnet.hpp b/libraries/protocol/include/graphene/protocol/config_testnet.hpp index cd5ea5ca75..b7d2b1cf5d 100644 --- a/libraries/protocol/include/graphene/protocol/config_testnet.hpp +++ b/libraries/protocol/include/graphene/protocol/config_testnet.hpp @@ -2,14 +2,14 @@ #define CHAIN_STARTUP_VERSION (version(1, 0, 0)) #define CHAIN_HARDFORK_STARTUP_VERSION (hardfork_version(CHAIN_STARTUP_VERSION)) -// HF15 (PM audit fixes) — the middle component IS the hardfork version: CHAIN_HARDFORK_VERSION is -// version's hardfork field, and the auto-vote in database.cpp _generate_block fires only while +// HF16 (no-contest fee freeze) — the middle component IS the hardfork version: CHAIN_HARDFORK_VERSION +// is version's hardfork field, and the auto-vote in database.cpp _generate_block fires only while // current_hardfork_version < CHAIN_HARDFORK_VERSION. So a new fork has to move THAT field, not the -// revision; 4.1.0 here must stay byte-equal to CHAIN_HARDFORK_15_VERSION in hardfork.d/15.hf, +// revision; 4.2.0 here must stay byte-equal to CHAIN_HARDFORK_16_VERSION in hardfork.d/16.hf, // which database_hardfork.cpp asserts. config.hpp carries the same value for the production -// config — the two builds differ in chain identity and fork TIMES (15.hf), not in which forks +// config — the two builds differ in chain identity and fork TIMES (16.hf), not in which forks // are registered. -#define CHAIN_VERSION (version(4, 1, 0)) +#define CHAIN_VERSION (version(4, 2, 0)) #define CHAIN_HARDFORK_VERSION (hardfork_version(CHAIN_VERSION)) #define CHAIN_NAME "VIZTEST" @@ -32,6 +32,7 @@ #define CHAIN_CLEAR_CLOSED_COMMITTEE_REQUEST_DELAY fc::minutes(10) #define CHAIN_BLOCK_INTERVAL 3 +#define CHAIN_BLOCK_INTERVAL_HF16 1 // HF16 (#6737, Q#3103=A): slot after the fork block; database::block_interval() #define CHAIN_BLOCK_VALIDATOR_REPEAT 1 #define CHAIN_BLOCKS_PER_YEAR (365*24*60*60/CHAIN_BLOCK_INTERVAL) #define CHAIN_BLOCKS_PER_DAY (24*60*60/CHAIN_BLOCK_INTERVAL) @@ -43,8 +44,17 @@ // Private key: 5JabcrvaLnBTCkCVFX5r4rmeGGfuJuVp4NAKRNLTey6pxhRQmf4 #define CHAIN_INITIATOR_PUBLIC_KEY_STR "VIZ6MyX5QiXAXRZk7SYCiqpi6Mtm8UbHWDFSV8HPpt7FJyahCnc2T" #define CHAIN_INITIATOR_PUBLIC_KEY (graphene::protocol::public_key_type(CHAIN_INITIATOR_PUBLIC_KEY_STR)) +// Multi-validator test network (QC/TC stands): build with +// -DCHAIN_TESTNET_VALIDATORS=N and genesis creates viz, viz1 .. viz(N-1), all +// in the top schedule (one shared initiator key; rotate per validator with +// validator_update after start). Unset = the classic one-validator testnet. +#ifdef CHAIN_TESTNET_VALIDATORS +#define CHAIN_NUM_INITIATORS CHAIN_TESTNET_VALIDATORS +#define CHAIN_MAX_TOP_VALIDATORS CHAIN_TESTNET_VALIDATORS +#else #define CHAIN_NUM_INITIATORS 0 #define CHAIN_MAX_TOP_VALIDATORS 1 +#endif #define CHAIN_MAX_SUPPORT_VALIDATORS 0 #define CHAIN_MAX_VALIDATORS (CHAIN_MAX_TOP_VALIDATORS+CHAIN_MAX_SUPPORT_VALIDATORS) #define CHAIN_HARDFORK_REQUIRED_VALIDATORS 1 @@ -128,7 +138,7 @@ /// Number of consecutive blocks produced by the emergency witness that /// triggers automatic exit from emergency mode (validators have rejoined). -#define CHAIN_EMERGENCY_EXIT_NORMAL_BLOCKS 21 // 1 full round of 21 validators +#define CHAIN_EMERGENCY_EXIT_NORMAL_BLOCKS 21 // Legacy unused constant: emergency exits by active-validator quorum, not a timer. #define CHAIN_IRREVERSIBLE_THRESHOLD (75 * CHAIN_1_PERCENT) /** Irreversibility only counts blocks produced if wit.current_run >= CHAIN_IRREVERSIBLE_SUPPORT_MIN_RUN */ diff --git a/libraries/protocol/include/graphene/protocol/get_config.hpp b/libraries/protocol/include/graphene/protocol/get_config.hpp index 2bd3b7632e..19f837eafb 100644 --- a/libraries/protocol/include/graphene/protocol/get_config.hpp +++ b/libraries/protocol/include/graphene/protocol/get_config.hpp @@ -1,11 +1,13 @@ #pragma once #include +#include namespace graphene { namespace protocol { - fc::variant_object get_config(); + // 0 selects legacy compile-time cadence; APIs pass the live chain interval. + fc::variant_object get_config(uint32_t live_block_interval = 0); } } // graphene::protocol diff --git a/libraries/protocol/include/graphene/protocol/operations.hpp b/libraries/protocol/include/graphene/protocol/operations.hpp index 33719140c6..2cf231abee 100644 --- a/libraries/protocol/include/graphene/protocol/operations.hpp +++ b/libraries/protocol/include/graphene/protocol/operations.hpp @@ -157,7 +157,9 @@ namespace graphene { namespace protocol { // #442/#681=D: LP income paid at settlement -> LP's own history (virtual) pm_lp_payout_operation, // HF15 agent access (Onix): account-level delegation of an explicit operation list - set_agent_permission_operation + set_agent_permission_operation, + // HF16 QC: double vote proven in a block header (virtual) + validator_equivocation_operation > operation; /*void operation_get_required_authorities( const operation& op, diff --git a/libraries/protocol/include/graphene/protocol/types.hpp b/libraries/protocol/include/graphene/protocol/types.hpp index 6603c46ea4..512a6c5521 100644 --- a/libraries/protocol/include/graphene/protocol/types.hpp +++ b/libraries/protocol/include/graphene/protocol/types.hpp @@ -102,9 +102,21 @@ namespace graphene { } }; - typedef fc::ripemd160 block_id_type; + // 20-byte identifier. Inherited from Graphene (BitShares -> Steem): the ids are stored in + // fc::ripemd160 because it is a 20-byte container, NOT because they are ripemd160 digests. + // transaction_id_type = first 20 bytes of sha256(packed transaction, without signatures) + // (transaction::id(), transaction.cpp) + // block_id_type = first 20 bytes of sha224(packed header), whose first 4 bytes are + // then replaced by the big-endian block number + // (signed_block_header::id(), block.cpp) + // The alias is pure naming: same type, same bytes, same wire format and reflection, so it + // changes nothing in consensus. See docs/api/ids-and-hashes.md. + typedef fc::ripemd160 id160_type; + typedef id160_type block_id_type; + typedef id160_type transaction_id_type; + // This one IS a real ripemd160: the block merkle root is ripemd160 over the sha256 root of + // the transaction tree (signed_block::calculate_merkle_root(), block.cpp). typedef fc::ripemd160 checksum_type; - typedef fc::ripemd160 transaction_id_type; typedef fc::sha256 digest_type; typedef fc::ecc::compact_signature signature_type; typedef safe share_type; diff --git a/libraries/utilities/CMakeLists.txt b/libraries/utilities/CMakeLists.txt index 89d0e21531..66057c6a0c 100644 --- a/libraries/utilities/CMakeLists.txt +++ b/libraries/utilities/CMakeLists.txt @@ -17,6 +17,7 @@ endif(NOT GRAPHENE_GIT_REVISION_DESCRIPTION) file(GLOB HEADERS "include/graphene/utilities/*.hpp") set(sources + capped_file_appender.cpp key_conversion.cpp string_escape.cpp tempdir.cpp diff --git a/libraries/utilities/capped_file_appender.cpp b/libraries/utilities/capped_file_appender.cpp new file mode 100644 index 0000000000..c1d20a30fe --- /dev/null +++ b/libraries/utilities/capped_file_appender.cpp @@ -0,0 +1,318 @@ +#include + +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#define get_pid _getpid +#else +#include +#define get_pid getpid +#endif + +namespace graphene { +namespace utilities { + +namespace { + +const uint32_t kDefaultMaxLines = 10000; + +// The file is compacted only after it grows past the cap by this many lines. Without +// slack every single line past the cap would trigger a full rewrite (O(n) per line); +// with it the rewrite cost is amortised over ~10% of the file. A 10k-line file is a +// couple of megabytes, so the file oscillates between max_lines and 1.1 * max_lines. +uint64_t compaction_trigger(uint32_t max_lines) { + return static_cast(max_lines) + std::max(16, max_lines / 10); +} + +// Scan in fixed 64 KiB chunks: the configured line cap has no byte-size assumption. + +/** + * Offset right after the newline that starts the last `keep` lines, or 0 when the + * whole file is that short. `have_boundary` distinguishes "cut at 0" from "cut at 0 + * because everything fits", which matters for skipping the rewrite. + */ +bool find_tail_offset(const std::string &path, uint32_t keep, uint64_t &cut, bool &found, std::string &error) { + cut = 0; + found = false; + + std::ifstream in(path.c_str(), std::ios::binary); + if (!in) { + error = "cannot open " + path + ": " + std::strerror(errno); + return false; + } + in.seekg(0, std::ios::end); + const std::streamoff size = in.tellg(); + if (size <= 0) return true; + + // A trailing newline terminates the last line; it does not open a new one. So a + // file that ends with '\n' needs keep+1 newline boundaries to cut before line N. + char last = 0; + in.seekg(size - 1); + in.read(&last, 1); + const uint64_t newlines_wanted = (last == '\n') ? static_cast(keep) + 1 : keep; + + const std::size_t chunk = 64 * 1024; + std::streamoff pos = size; + uint64_t seen = 0; + + while (pos > 0) { + const std::size_t take = static_cast(std::min(chunk, pos)); + pos -= take; + std::string piece(take, '\0'); + in.seekg(pos); + in.read(&piece[0], static_cast(take)); + if (in.gcount() != static_cast(take)) { + error = "short read from " + path; + return false; + } + for (std::streamoff i = static_cast(take) - 1; i >= 0; --i) { + if (piece[static_cast(i)] == '\n' && ++seen == newlines_wanted) { + cut = static_cast(pos + i + 1); + found = true; + return true; + } + } + } + return true; +} + +bool replace_with_tail(const std::string &path, uint64_t cut, std::string &error) { + std::ifstream in(path.c_str(), std::ios::binary); + if (!in) { + error = "cannot open " + path + ": " + std::strerror(errno); + return false; + } + in.seekg(static_cast(cut)); + + const std::string tmp = path + ".trim." + std::to_string(static_cast(get_pid())); + { + std::ofstream out(tmp.c_str(), std::ios::binary | std::ios::trunc); + if (!out) { + error = "cannot create " + tmp + ": " + std::strerror(errno); + return false; + } + out << in.rdbuf(); + out.flush(); + if (!out) { + error = "cannot write " + tmp + ": " + std::strerror(errno); + out.close(); + std::remove(tmp.c_str()); + return false; + } + } + in.close(); + +#ifdef _WIN32 + // std::rename does not replace an existing file on Windows. The appender closes its + // stream before compaction and reopens afterwards, so deleting first is safe here. + std::remove(path.c_str()); +#endif + if (std::rename(tmp.c_str(), path.c_str()) != 0) { + error = "cannot replace " + path + ": " + std::strerror(errno); + std::remove(tmp.c_str()); + return false; + } + return true; +} + +std::string format_line(const fc::log_message &m) { + std::stringstream line; + { + int64_t us = m.get_context().get_timestamp().time_since_epoch().count(); + int ms = static_cast((us / 1000) % 1000); + int64_t s = us / 1000000; + line << std::setfill('0') + << std::setw(2) << static_cast((s / 3600) % 24) << ':' + << std::setw(2) << static_cast((s / 60) % 60) << ':' + << std::setw(2) << static_cast(s % 60) << '.' + << std::setw(3) << ms + << std::setfill(' ') << ' '; + } + line << std::setw(21) + << (m.get_context().get_thread_name().substr(0, 9) + std::string(":") + m.get_context().get_task_name()).c_str() + << " "; + + std::string method_name = m.get_context().get_method(); + if (!method_name.empty()) { + uint32_t p = 0; + for (uint32_t i = 0; i < method_name.size(); ++i) { + if (method_name[i] == ':') p = i; + } + if (method_name[p] == ':') ++p; + line << std::setw(20) << method_name.substr(p, 20).c_str() << " "; + } + + line << "] " << fc::format_string(m.get_format(), m.get_data()).c_str(); + return line.str(); +} + +} // namespace + +class capped_file_appender::impl : public fc::retainable { +public: + config cfg; + std::ofstream out; + boost::mutex slock; + uint64_t lines = 0; + bool warned = false; + + explicit impl(const config &c) : cfg(c) { + try { + if (!cfg.filename.parent_path().string().empty()) fc::create_directories(cfg.filename.parent_path()); + } catch (...) { + // Reported below by the failed open; a bare filename needs no directory. + } + + if (cfg.max_lines > 0) { + std::string error; + if (!capped_file_appender::trim_to_last_lines(cfg.filename, cfg.max_lines, error)) { + warn(error); + } + lines = count_lines_of_file(); + } + open_append(); + } + + void open_append() { + out.open(cfg.filename.string().c_str(), std::ios::out | std::ios::app | std::ios::binary); + if (!out.is_open()) { + error("cannot open log file " + cfg.filename.string() + ": " + std::strerror(errno)); + } + } + + void error(const std::string &text) const { + std::cerr << "capped_file_appender: " << text << "\n"; + } + + void warn(const std::string &text) { + if (warned) return; + warned = true; + std::cerr << "capped_file_appender: " << text << " (log cap is not enforced)\n"; + } + + uint64_t count_lines_of_file() const { + uint64_t n = 0; + std::string error_text; + if (!capped_file_appender::count_lines_in_file(cfg.filename, n, error_text)) { + // Counting is best-effort here: a wrong count only delays or advances the next + // compaction, so report it and keep logging rather than dropping the appender. + error(error_text); + return 0; + } + return n; + } + + // Caller holds slock. Rewrites the file to its last max_lines lines and reopens. + void compact() { + out.flush(); + out.close(); + + std::string error_text; + if (capped_file_appender::trim_to_last_lines(cfg.filename, cfg.max_lines, error_text)) { + lines = count_lines_of_file(); + } else { + // Do not retry on every following line: wait for the next trigger window. + warn(error_text); + lines = 0; + } + open_append(); + } +}; + +capped_file_appender::config::config(const fc::path &p) : + filename(p), + max_lines(kDefaultMaxLines), + flush(true) { +} + +capped_file_appender::capped_file_appender(const fc::variant &args) : + my(new impl(args.as())) { +} + +capped_file_appender::~capped_file_appender() = default; + +bool capped_file_appender::count_lines_in_file(const fc::path &path, uint64_t &lines, std::string &error) { + lines = 0; + error.clear(); + + const std::string name = path.string(); + std::ifstream in(name.c_str(), std::ios::binary); + if (!in) { + if (errno == ENOENT) return true; // nothing written yet + error = "cannot open " + name + ": " + std::strerror(errno); + return false; + } + + std::vector buf(64 * 1024); + char last = 0; + bool any = false; + while (in) { + in.read(&buf[0], static_cast(buf.size())); + const std::streamsize got = in.gcount(); + if (got <= 0) break; + lines += static_cast( + std::count(buf.begin(), buf.begin() + static_cast(got), '\n')); + last = buf[static_cast(got) - 1]; + any = true; + } + if (in.bad()) { + error = "read error on " + name; + lines = 0; + return false; + } + // A file that does not end in '\n' still has that last partial line. + if (any && last != '\n') ++lines; + return true; +} + +bool capped_file_appender::trim_to_last_lines(const fc::path &path, uint32_t keep, std::string &error) { + error.clear(); + if (keep == 0) return true; + + const std::string name = path.string(); + { + std::ifstream probe(name.c_str(), std::ios::binary); + if (!probe) return true; // nothing to trim yet + } + + uint64_t cut = 0; + bool found = false; + if (!find_tail_offset(name, keep, cut, found, error)) return false; + if (!found || cut == 0) return true; // the whole file fits into the cap + + return replace_with_tail(name, cut, error); +} + +void capped_file_appender::log(const fc::log_message &m) { + const std::string line = format_line(m); + + boost::mutex::scoped_lock lock(my->slock); + if (!my->out.is_open()) return; + + my->out << line << "\t\t\t" << m.get_context().get_file() << ":" << m.get_context().get_line_number() << "\n"; + if (my->cfg.flush) my->out.flush(); + my->lines += 1 + static_cast(std::count(line.begin(), line.end(), '\n')); + + if (my->cfg.max_lines > 0 && my->lines >= compaction_trigger(my->cfg.max_lines)) { + my->compact(); + } +} + +} // namespace utilities +} // namespace graphene diff --git a/libraries/utilities/include/graphene/utilities/capped_file_appender.hpp b/libraries/utilities/include/graphene/utilities/capped_file_appender.hpp new file mode 100644 index 0000000000..c81de6028d --- /dev/null +++ b/libraries/utilities/include/graphene/utilities/capped_file_appender.hpp @@ -0,0 +1,88 @@ +#pragma once + +#include +#include + +#include +#include + +namespace graphene { +namespace utilities { + +/** + * File appender that keeps only the last `max_lines` lines of one file. + * + * Why it exists: the stock fc::file_appender rotates by TIME (new file every hour, + * kept for a day). In debug mode an hour of logs can be gigabytes, and 24 hourly + * files of that size overflow a small disk. This appender keeps a single file and + * compacts it in place once it grows past the cap, so the file always holds the + * most recent lines and nothing older. + * + * The appender is OPTIONAL by construction: + * - it is registered as its own type ("capped_file"), so configs that do not use + * it behave exactly as before; + * - an older binary ignores the whole [log.capped_file_appender.*] section (its + * loader does not know the prefix) and an unknown appender type resolves to a + * null appender instead of failing; + * - `max_lines = 0` disables compaction explicitly. + * + * File format is byte-identical to fc::file_appender's text format, so the Logs tab + * of the node_admin plugin (admin.logs.list/tail) needs no changes: it reads a path. + */ +class capped_file_appender : public fc::appender { +public: + struct config { + config(const fc::path &p = "log.txt"); + + fc::path filename; + /// Keep at most this many lines; 0 = unlimited (no compaction). + uint32_t max_lines = 10000; + bool flush = true; + }; + + explicit capped_file_appender(const fc::variant &args); + + ~capped_file_appender() override; + + void log(const fc::log_message &m) override; + + /** + * Rewrite `path` so that it holds only its last `keep` lines. + * + * Reads the file backwards in chunks, so a file that is already far over the cap + * costs one bounded tail read rather than a full scan of 10 GB. The rewrite goes + * through a temporary file in the same directory and an atomic rename, so a reader + * (admin.logs.tail) never sees a half-written file. + * + * @return true when the file is already short enough or was trimmed; false with a + * reason in `error` when it could not be read or replaced. + */ + static bool trim_to_last_lines(const fc::path &path, uint32_t keep, std::string &error); + + /** + * Count the lines of `path`, streaming it in fixed chunks. + * + * The appender needs the current line count to know when to compact. Counting by + * reading the whole file into memory is exactly wrong for this appender: it exists + * for files that may be gigabytes, and the count is also taken when a trim FAILED + * (e.g. disk full, so the temporary file cannot be written) — the worst moment to + * allocate a second copy of a huge log. + * + * A missing file counts as zero lines and is not an error (nothing was written yet). + * + * @return true when the file was read; false with a reason in `error` otherwise. + */ + static bool count_lines_in_file(const fc::path &path, uint64_t &lines, std::string &error); + +private: + class impl; + + fc::shared_ptr my; +}; + +} // namespace utilities +} // namespace graphene + +#include + +FC_REFLECT((graphene::utilities::capped_file_appender::config), (filename)(max_lines)(flush)) diff --git a/plugins/account_history/plugin.cpp b/plugins/account_history/plugin.cpp index 229046bea3..09614dc9a4 100644 --- a/plugins/account_history/plugin.cpp +++ b/plugins/account_history/plugin.cpp @@ -532,6 +532,10 @@ if( options.count(name) ) { \ impacted.insert(op.stakeholder); } + void operator()(const validator_equivocation_operation& op) { + impacted.insert(op.validator); + } + // --- Prediction markets (HF14, Onix) --- // Signed ops: the generic handler already records the signer through the // required authorities, so these overloads add the *other* material diff --git a/plugins/chain/include/graphene/plugins/chain/plugin.hpp b/plugins/chain/include/graphene/plugins/chain/plugin.hpp index 56950ba122..44c8111b40 100644 --- a/plugins/chain/include/graphene/plugins/chain/plugin.hpp +++ b/plugins/chain/include/graphene/plugins/chain/plugin.hpp @@ -1,4 +1,5 @@ #pragma once +#include #include @@ -137,6 +138,17 @@ namespace graphene { /// Can be called from any plugin that detects corruption at runtime. void attempt_auto_recovery(); + /// Restore chain state from a specific snapshot file, in-process: + /// pause P2P, close and wipe shared memory, import the snapshot, + /// replay dlt_block_log and resume P2P. This is the same sequence as + /// attempt_auto_recovery(), but with an operator-chosen file. + /// Blocks for the whole import, so call it on an fc thread (not on a + /// plain std::thread and not on the p2p thread itself). + /// Returns false and fills `error` if the restore cannot start or fails; + /// a failed import has already wiped the state and requested shutdown. + bool restore_from_snapshot(const std::string& snapshot_path, std::string& error); + std::unique_lock try_lock_recovery() const; + /// Apply the chain's runtime corruption policy: recover when enabled, /// otherwise request shutdown. Callers must stop processing the failed item. void handle_shared_memory_corruption(); diff --git a/plugins/chain/plugin.cpp b/plugins/chain/plugin.cpp index 919acc9bb1..68c136bfad 100644 --- a/plugins/chain/plugin.cpp +++ b/plugins/chain/plugin.cpp @@ -71,6 +71,7 @@ namespace chain { bool sync_start_logged = false; // guard to log sync start only once std::atomic currently_syncing{false}; // true while processing P2P sync blocks + std::mutex recovery_read_mutex; std::atomic recovery_in_progress{false}; // true while auto-recovery is running bool pending_snapshot_load = false; // set when snapshot args present but callback not yet registered @@ -331,6 +332,10 @@ namespace chain { return my->currently_syncing.load(std::memory_order_acquire); } + std::unique_lock plugin::try_lock_recovery() const { + return std::unique_lock(my->recovery_read_mutex, std::try_to_lock); + } + bool plugin::is_recovering() const { return my->recovery_in_progress.load(std::memory_order_acquire); } @@ -568,9 +573,11 @@ namespace chain { // state so head becomes 0, then come up on empty state. This is the exact // recovery a manual `docker compose up -d --force-recreate` performs, minus // the operator. + bool resync_marker_seen = false; { auto force_resync_marker = my->shared_memory_dir / "force_resync"; if (boost::filesystem::exists(force_resync_marker)) { + resync_marker_seen = true; wlog("Detected force_resync marker (P2P wedge watchdog). Wiping state for a clean " "re-bootstrap (network resync or fresh operator-supplied snapshot)."); std::cerr << " force_resync marker found — wiping state for clean re-bootstrap.\n"; @@ -616,6 +623,37 @@ namespace chain { } } + // ========== force_replay marker (admin repair) ========== + // node_admin's Objects tab writes this marker and exits when an operator asks to + // rebuild state while keeping the block log: replay reindexes every block, which is + // far cheaper than a network resync and is enough whenever the block log itself is + // intact. The marker is removed before the work starts; a crash mid-replay leaves an + // empty state plus an intact block log, and the normal "revision != head" replay picks + // it up on the next boot. + { + auto force_replay_marker = my->shared_memory_dir / "force_replay"; + if (boost::filesystem::exists(force_replay_marker)) { + boost::system::error_code _fr_ec; + if (resync_marker_seen) { + // Both markers asked for a rebuild. The resync above has already wiped state + // AND the block log; replaying an empty log would fail, so the resync wins + // and the stale request is dropped rather than left to crash the next boot. + boost::filesystem::remove(force_replay_marker, _fr_ec); + wlog("force_replay ignored and removed: force_resync already wiped state and " + "the block log in this same startup."); + } else { + wlog("Detected force_replay marker (admin repair). Wiping state and replaying the block log."); + std::cerr << " force_replay marker found — rebuilding state from the block log.\n"; + my->force_replay = true; + my->replay = true; + boost::filesystem::remove(force_replay_marker, _fr_ec); + if (_fr_ec) + wlog("force_replay: failed to remove marker (${e}); may re-trigger next boot.", + ("e", _fr_ec.message())); + } + } + } + my->db.set_flush_interval(my->flush_interval); my->db.add_checkpoints(my->loaded_checkpoints); my->db.set_require_locking(my->check_locks); @@ -1013,6 +1051,8 @@ namespace chain { return; } + std::unique_lock readers(my->recovery_read_mutex); + // Guard against infinite recovery loops: if the same block keeps // failing after recovery, the snapshot or block log may be corrupted. // Reset the counter after a cooldown period to allow eventual retry. @@ -1134,6 +1174,111 @@ namespace chain { } } + bool plugin::restore_from_snapshot(const std::string& snapshot_path, std::string& error) { + bool expected = false; + if (!my->recovery_in_progress.compare_exchange_strong(expected, true)) { + error = "another recovery or restore is already in progress"; + return false; + } + struct flag_reset { + std::atomic& flag; + ~flag_reset() { flag.store(false, std::memory_order_release); } + } reset_guard{my->recovery_in_progress}; + std::unique_lock readers(my->recovery_read_mutex); + + if (snapshot_path.empty()) { + error = "empty snapshot path"; + return false; + } + if (!snapshot_load_callback) { + error = "snapshot plugin is not loaded (add 'plugin = snapshot' to config.ini)"; + return false; + } + if (!bfs::exists(snapshot_path) || !bfs::is_regular_file(snapshot_path)) { + error = "snapshot file not found: " + snapshot_path; + return false; + } + + wlog("=== RESTORE FROM SNAPSHOT: ${p} ===", ("p", snapshot_path)); + + // 1. Stop P2P from pushing blocks into a database that is about to be rebuilt. + // pause_block_processing() itself must run on the p2p thread; this method is + // called from an fc thread, so use the sync wrapper. + try { + auto* p2p_plug = appbase::app().find_plugin(); + if (p2p_plug && p2p_plug->get_state() == appbase::abstract_plugin::started) { + p2p_plug->pause_block_processing_sync(); + wlog("Restore: P2P block processing paused"); + } + } catch (const fc::exception& e) { + wlog("Restore: failed to pause P2P: ${e}", ("e", e.to_detail_string())); + } catch (...) { + wlog("Restore: failed to pause P2P"); + } + + // 2. Gate block production (validator reads is_syncing()). + my->currently_syncing.store(true, std::memory_order_release); + + // 3. Close the current database. open_from_snapshot() in do_snapshot_load() + // reopens it and wipes shared memory for a recovery load. + try { + my->db.close(false); + } catch (...) { + wlog("Restore: ignoring error while closing the database"); + } + + my->snapshot_path = snapshot_path; + auto data_dir = appbase::app().data_dir() / "state"; + + try { + do_snapshot_load(data_dir, true); + } catch (const fc::exception& e) { + error = e.to_detail_string(); + elog("Restore FAILED during snapshot load: ${e}", ("e", error)); + } catch (const std::exception& e) { + error = e.what(); + elog("Restore FAILED during snapshot load: ${e}", ("e", error)); + } catch (...) { + error = "unknown exception during snapshot load"; + elog("Restore FAILED during snapshot load: unknown exception"); + } + + uint32_t head = 0; + try { + head = my->db.head_block_num(); + } catch (...) { + } + + // do_snapshot_load() handles a failed import by wiping the state and requesting + // shutdown, then returns normally. A zero head is the observable trace of that. + if (!error.empty() || head == 0) { + if (error.empty()) error = "snapshot import did not produce state"; + elog("Restore FAILED: ${e}", ("e", error)); + // P2P stays paused and the syncing flag stays set: the state on disk is not + // safe to consume. do_snapshot_load() has already asked the process to quit; + // if it did not, the node must not resume as if nothing happened. + return false; + } + + my->currently_syncing.store(false, std::memory_order_release); + + // 4. Resume P2P. reset_peers_after_recovery() clears soft bans and stale sync + // state, so peers can serve blocks immediately after the import. + try { + auto* p2p_plug = appbase::app().find_plugin(); + if (p2p_plug && p2p_plug->get_state() == appbase::abstract_plugin::started) { + p2p_plug->reset_peers_after_recovery(); + p2p_plug->resume_block_processing(); + wlog("Restore: P2P block processing resumed"); + } + } catch (...) { + wlog("Restore: failed to resume P2P"); + } + + wlog("=== RESTORE COMPLETE: node resumed at block ${n} ===", ("n", head)); + return true; + } + void plugin::wipe_state() { auto data_dir = appbase::app().data_dir() / "state"; my->wipe_db(data_dir, false); diff --git a/plugins/database_api/api.cpp b/plugins/database_api/api.cpp index 04146ad5aa..4ecf448d07 100755 --- a/plugins/database_api/api.cpp +++ b/plugins/database_api/api.cpp @@ -347,7 +347,11 @@ DEFINE_API(plugin, get_config) { } fc::variant_object plugin::api_impl::get_config() const { - return graphene::protocol::get_config(); + // HF16 (#6737): the slot length is chain state, not a compile constant; clients that + // derive times from block counts must see the live value. + return database().with_weak_read_lock([&]() { + return graphene::protocol::get_config(database().block_interval()); + }); } DEFINE_API(plugin, get_dynamic_global_properties) { diff --git a/plugins/node_admin/CMakeLists.txt b/plugins/node_admin/CMakeLists.txt new file mode 100644 index 0000000000..5ab986b6d4 --- /dev/null +++ b/plugins/node_admin/CMakeLists.txt @@ -0,0 +1,41 @@ +set(CURRENT_TARGET node_admin) + +list(APPEND CURRENT_TARGET_HEADERS + include/graphene/plugins/node_admin/node_admin_plugin.hpp + ) + +list(APPEND CURRENT_TARGET_SOURCES + node_admin_plugin.cpp + ) + +add_library(graphene_${CURRENT_TARGET} ${VIZ_LIBRARY_TYPE} + ${CURRENT_TARGET_HEADERS} + ${CURRENT_TARGET_SOURCES} + ) + +add_library(graphene::${CURRENT_TARGET} ALIAS graphene_${CURRENT_TARGET}) +set_property(TARGET graphene_${CURRENT_TARGET} PROPERTY EXPORT_NAME ${CURRENT_TARGET}) + +target_link_libraries( + graphene_${CURRENT_TARGET} + graphene_chain + graphene::chain_plugin + graphene::p2p + graphene::snapshot + graphene::validator_guard + graphene::webserver_plugin + graphene::protocol + graphene_utilities + appbase + fc +) + +target_include_directories(graphene_${CURRENT_TARGET} + PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/include") + +install(TARGETS + graphene_${CURRENT_TARGET} + RUNTIME DESTINATION bin + LIBRARY DESTINATION lib + ARCHIVE DESTINATION lib + ) diff --git a/plugins/node_admin/include/graphene/plugins/node_admin/node_admin_plugin.hpp b/plugins/node_admin/include/graphene/plugins/node_admin/node_admin_plugin.hpp new file mode 100644 index 0000000000..6064b212d4 --- /dev/null +++ b/plugins/node_admin/include/graphene/plugins/node_admin/node_admin_plugin.hpp @@ -0,0 +1,59 @@ +#pragma once + +#include +#include + +#include + +#include + +namespace graphene { +namespace plugins { +namespace node_admin { + +/** + * Administrative API for the node itself. + * + * Listens on a SEPARATE HTTP endpoint (loopback by default) and speaks JSON-RPC 2.0 with + * bearer-token authentication. It is deliberately not part of the public webserver API: + * that endpoint is proxied by nginx, so every remote request arrives inside the node + * looking like 127.0.0.1 — a source-IP check there protects nothing. + * + * The plugin is inert unless `admin-endpoint` is configured. With an endpoint but without + * tokens it refuses to start: an unauthenticated admin port is never a valid configuration. + * + * v1 is READ-ONLY: node_info, diagnostics, plugins.list, config.get, logs.tail, audit.tail. + * Mutating methods (config/plugin edits, restart, validator updates) are later stages and + * will be built on the same listener with plan/apply and an audit trail. + */ +class node_admin_plugin final : public appbase::plugin { +public: + APPBASE_PLUGIN_REQUIRES((graphene::plugins::chain::plugin)) + + constexpr static const char *plugin_name = "node_admin"; + + static const std::string &name() { + static std::string name = plugin_name; + return name; + } + + node_admin_plugin(); + ~node_admin_plugin(); + + void set_program_options( + boost::program_options::options_description &command_line_options, + boost::program_options::options_description &config_file_options + ) override; + + void plugin_initialize(const boost::program_options::variables_map &options) override; + void plugin_startup() override; + void plugin_shutdown() override; + +private: + struct impl; + std::unique_ptr pimpl; +}; + +} // namespace node_admin +} // namespace plugins +} // namespace graphene diff --git a/plugins/node_admin/include/graphene/plugins/node_admin/node_admin_util.hpp b/plugins/node_admin/include/graphene/plugins/node_admin/node_admin_util.hpp new file mode 100644 index 0000000000..aabf240df2 --- /dev/null +++ b/plugins/node_admin/include/graphene/plugins/node_admin/node_admin_util.hpp @@ -0,0 +1,693 @@ +#pragma once + +// Pure helpers of the node_admin plugin: secret masking, config.ini parsing and admin-token +// parsing. They live in a header (not the plugin TU) so CTest can exercise them without +// starting a node. Behavior here is the contract the plugin relies on; keep it side-effect free. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace graphene { +namespace plugins { +namespace node_admin { +namespace util { + +// ── small string helpers ──────────────────────────────────────────────────────────────────── +inline std::string trim(const std::string &s) { + std::size_t b = 0; + std::size_t e = s.size(); + while (b < e && std::isspace(static_cast(s[b]))) ++b; + while (e > b && std::isspace(static_cast(s[e - 1]))) --e; + return s.substr(b, e - b); +} + +inline std::string to_lower(std::string s) { + for (char &c : s) c = static_cast(std::tolower(static_cast(c))); + return s; +} + +inline std::vector split(const std::string &s, char sep) { + std::vector out; + std::size_t pos = 0; + while (true) { + const std::size_t next = s.find(sep, pos); + if (next == std::string::npos) { + out.push_back(s.substr(pos)); + return out; + } + out.push_back(s.substr(pos, next - pos)); + pos = next + 1; + } +} + +/// Names carried by one `plugin = ...` line. appbase splits that value on whitespace and +/// loads every name, so `plugin = chain p2p` is two plugins; everything that reads the file +/// (the list, the editor, the group filter) has to see the same set or it disagrees with the +/// loader — a live config.ini is written that way. +inline std::vector plugin_names_in_value(const std::string &value) { + std::vector out; + std::size_t i = 0; + while (i < value.size()) { + while (i < value.size() && std::isspace(static_cast(value[i]))) ++i; + const std::size_t start = i; + while (i < value.size() && !std::isspace(static_cast(value[i]))) ++i; + if (i > start) out.push_back(value.substr(start, i - start)); + } + return out; +} + +/// A snapshot file name as accepted from HTTP paths and JSON params: one path component, +/// no traversal, and a snapshot extension. Names come from list_snapshots(), so anything +/// else is a client mistake (or an attempt to reach outside the snapshot directory). +inline bool is_plain_snapshot_name(const std::string &name) { + if (name.empty() || name.size() > 255) return false; + if (name.find('/') != std::string::npos || name.find('\\') != std::string::npos) return false; + if (name == "." || name == "..") return false; + const std::string lower = to_lower(name); + auto ends_with = [&](const char *suffix) { + const std::size_t n = std::strlen(suffix); + return lower.size() > n && lower.compare(lower.size() - n, n, suffix) == 0; + }; + return ends_with(".vizjson") || ends_with(".json"); +} + +// ── secret masking ────────────────────────────────────────────────────────────────────────── +inline bool sensitive_key(const std::string &key) { + const std::string k = to_lower(key); + static const char *needles[] = { + "private-key", "private_key", "password", "secret", "token", "wif", "emergency", + "validator-guard-validator", "witness-guard-witness" + }; + for (const char *n : needles) { + if (k.find(n) != std::string::npos) return true; + } + return false; +} + +/// Redact key material from free text (log tail). Deliberately coarse: a false positive +/// (masking an innocent word) is harmless; a false negative is a leaked WIF. +inline std::string mask_text(const std::string &text) { + static const std::regex wif( + "(^|[^A-Za-z0-9])(5[HJK][1-9A-HJ-NP-Za-km-z]{49}|[KL][1-9A-HJ-NP-Za-km-z]{51})(?=[^A-Za-z0-9]|$)", + std::regex::ECMAScript); + static const std::regex inline_secret( + "((private-key|emergency-private-key|admin-token|password|secret)[[:space:]]*[=:][[:space:]]*)([^[:space:]\\\"]+)", + std::regex::ECMAScript | std::regex::icase); + std::string out = std::regex_replace(text, wif, "$1***"); + out = std::regex_replace(out, inline_secret, "$1***"); + return out; +} + +// ── config file ──────────────────────────────────────────────────────────────────────────── +struct cfg_entry { + std::string section; + std::string key; + std::string value; +}; + +inline std::vector parse_config_file(const std::string &path, bool *exists = nullptr) { + std::vector entries; + std::ifstream in(path.c_str()); + if (!in) { + if (exists) *exists = false; + return entries; + } + if (exists) *exists = true; + + std::string section; + std::string line; + while (std::getline(in, line)) { + if (!line.empty() && line.back() == '\r') line.pop_back(); + // Boost's config reader — the one appbase loads config.ini through — treats '#' as a + // comment to the end of the line, so the value must be cut at the same place. Otherwise + // `plugin = chain # why` reaches the plugin list as names "#" and "why", which the + // loader never sees. + const std::size_t hash = line.find('#'); + if (hash != std::string::npos) line.erase(hash); + const std::string t = trim(line); + if (t.empty() || t[0] == ';') continue; + if (t[0] == '[' && t.back() == ']') { + section = trim(t.substr(1, t.size() - 2)); + continue; + } + const std::size_t eq = t.find('='); + if (eq == std::string::npos) { + entries.push_back(cfg_entry{section, trim(t), ""}); + } else { + entries.push_back(cfg_entry{section, trim(t.substr(0, eq)), trim(t.substr(eq + 1))}); + } + } + return entries; +} + +// ── config editor: allow-list ───────────────────────────────────────────────────────────── +// config.ini is the file the next start depends on, so the admin API may rewrite only keys +// from this table. Identity, keys, data locations, listener endpoints and startup modes are +// deliberately absent: those are operator decisions made by hand (design §4.6). Everything +// here is read in plugin_initialize(), so each change needs a restart; the flag stays explicit +// because live-applied keys may be added later. +enum class config_type { boolean, unsigned_int, log_level }; + +struct config_spec { + const char *name; + config_type type; + uint64_t min_value; + uint64_t max_value; + bool needs_restart; + /// Empty for a top-level `key = value`; otherwise the key lives inside `[section]` + /// (`logger.default.level` -> section "logger.default", key "level"). + const char *section = ""; +}; + +/// Split an allow-list name into its ini section and leaf key. A name without a section is a +/// top-level option: section empty, key unchanged. `section` must match config_spec::section, +/// which is what the editor uses to address the line. +inline void config_name_parts(const config_spec &spec, std::string §ion, std::string &key) { + section = spec.section; + key = spec.name; + if (!section.empty() && key.size() > section.size() + 1 && + key.compare(0, section.size(), section) == 0 && key[section.size()] == '.') { + key = key.substr(section.size() + 1); + } +} + +/// Accepted logger levels, in the order the UI shows them (quiet last is deliberate: an operator +/// raising verbosity should not be one stray click from `off`, which silences the log). +inline const std::vector &log_level_values() { + static const std::vector levels = {"all", "debug", "info", "warn", "error", "off"}; + return levels; +} + +/// Which plugin a parameter belongs to, for grouping in the UI and in admin.plugins.list. +/// `logging` is not a plugin: it is the runtime logging section of the same panel. +inline std::string config_key_group(const std::string &name) { + if (name.compare(0, 4, "p2p-") == 0 || name.compare(0, 4, "dlt-") == 0 || + name == "auto-resync-on-wedge") return "p2p"; + if (name.compare(0, 10, "webserver-") == 0) return "webserver"; + if (name.compare(0, 16, "validator-guard-") == 0) return "validator_guard"; + if (name.compare(0, 7, "logger.") == 0 || name.compare(0, 4, "log.") == 0) return "logging"; + return "other"; +} + +inline const std::vector &config_allowlist() { + static const std::vector specs = { + {"p2p-max-connections", config_type::unsigned_int, 1, 10000, true}, + {"dlt-peer-max-disconnect-hours", config_type::unsigned_int, 1, 8760, true}, + {"dlt-mempool-max-tx", config_type::unsigned_int, 1, 1000000, true}, + {"dlt-mempool-max-bytes", config_type::unsigned_int, 1024, 1073741824ULL, true}, + {"dlt-mempool-max-tx-size", config_type::unsigned_int, 256, 67108864, true}, + {"dlt-mempool-max-expiration-hours", config_type::unsigned_int, 1, 720, true}, + {"dlt-peer-exchange-max-per-reply", config_type::unsigned_int, 0, 1000, true}, + {"dlt-peer-exchange-max-per-subnet", config_type::unsigned_int, 0, 1000, true}, + {"dlt-peer-exchange-min-uptime-sec", config_type::unsigned_int, 0, 86400, true}, + {"dlt-stats-interval-sec", config_type::unsigned_int, 10, 86400, true}, + {"p2p-isolated-peers", config_type::boolean, 0, 0, true}, + {"auto-resync-on-wedge", config_type::boolean, 0, 0, true}, + {"webserver-thread-pool-size", config_type::unsigned_int, 1, 4096, true}, + {"webserver-cache-enabled", config_type::boolean, 0, 0, true}, + {"webserver-cache-size", config_type::unsigned_int, 0, 10000000, true}, + {"webserver-stats-enabled", config_type::boolean, 0, 0, true}, + {"webserver-limits-enabled", config_type::boolean, 0, 0, true}, + {"webserver-limit-m", config_type::unsigned_int, 1, 100000000, true}, + {"webserver-limit-h", config_type::unsigned_int, 1, 100000000, true}, + {"webserver-limit-d", config_type::unsigned_int, 1, 100000000, true}, + {"webserver-stats-log-max-size", config_type::unsigned_int, 1024, 1073741824ULL, true}, + {"webserver-stats-log-keep", config_type::unsigned_int, 1, 1000, true}, + {"webserver-stats-interval", config_type::unsigned_int, 1, 86400, true}, + {"validator-guard-enabled", config_type::boolean, 0, 0, true}, + {"validator-guard-interval", config_type::unsigned_int, 1, 3600, true}, + {"validator-guard-disable", config_type::boolean, 0, 0, true}, + {"validator-guard-disable-max", config_type::unsigned_int, 0, 1000, true}, + {"validator-guard-disable-on-shutdown", config_type::boolean, 0, 0, true}, + {"validator-guard-shutdown-grace", config_type::unsigned_int, 1, 3600, true}, + // Logging (owner decision 20.10.2026, Q#3081=A: the parameters panel plus log settings). + // Levels only: appender filenames and rotation windows stay operator decisions, while the + // capped appender's bounds are exactly the knobs against a log that fills the disk. + {"logger.default.level", config_type::log_level, 0, 0, true, "logger.default"}, + {"logger.p2p.level", config_type::log_level, 0, 0, true, "logger.p2p"}, + {"log.capped_file_appender.node.max_lines", config_type::unsigned_int, 0, 10000000, true, + "log.capped_file_appender.node"}, + {"log.capped_file_appender.node.flush", config_type::boolean, 0, 0, true, + "log.capped_file_appender.node"} + }; + return specs; +} + +inline const config_spec *config_spec_for(const std::string &key) { + for (const config_spec &s : config_allowlist()) { + if (key == s.name) return &s; + } + return nullptr; +} + +/// Validate and canonicalize a value. Unknown keys are refused here, so a caller cannot bypass +/// the allow-list by skipping config_spec_for(). +inline bool normalize_config_value(const std::string &key, const std::string &raw, + std::string &normalized, std::string &why) { + const config_spec *spec = config_spec_for(key); + if (!spec) { + why = "key '" + key + "' is not in the admin allow-list"; + return false; + } + const std::string value = trim(raw); + if (spec->type == config_type::log_level) { + const std::string v = to_lower(value); + for (const std::string &level : log_level_values()) { + if (v == level) { + normalized = v; + return true; + } + } + why = "'" + key + "' expects one of: all, debug, info, warn, error, off"; + return false; + } + if (spec->type == config_type::boolean) { + const std::string v = to_lower(value); + if (v == "true" || v == "yes" || v == "on" || v == "1") { + normalized = "true"; + return true; + } + if (v == "false" || v == "no" || v == "off" || v == "0") { + normalized = "false"; + return true; + } + why = "'" + key + "' expects a boolean (true/false)"; + return false; + } + if (value.empty() || value.size() > 19 || + value.find_first_not_of("0123456789") != std::string::npos) { + why = "'" + key + "' expects a non-negative integer"; + return false; + } + const uint64_t parsed = std::strtoull(value.c_str(), nullptr, 10); + if (parsed < spec->min_value || parsed > spec->max_value) { + why = "'" + key + "' must be between " + std::to_string(spec->min_value) + " and " + + std::to_string(spec->max_value); + return false; + } + normalized = std::to_string(parsed); + return true; +} + +// ── config editor: plugin list ───────────────────────────────────────────────────────────── +/// Plugin names registered by programs/vizd/main.cpp (including dependencies that +/// register_dependencies() pulls in, such as json_rpc). A name absent here is refused even when +/// it looks plausible: appbase would abort startup on an unknown plugin, and that is exactly the +/// failure the plan/apply pair exists to prevent. +inline const std::set &known_plugins() { + static const std::set names = { + "chain", "json_rpc", "database_api", "p2p", "webserver", "validator", + "validator_api", "validator_guard", "network_broadcast_api", "account_history", + "account_by_key", "raw_block", "block_archive", "block_info", "committee_api", + "invite_api", "paid_subscription_api", "prediction_market_api", + "custom_protocol_api", "snapshot", "testnet_plugin", "node_admin" + }; + return names; +} + +/// Plugins whose removal would either break the node's reason to exist or cut the admin API +/// itself off; the API refuses to remove them. +inline bool plugin_removal_forbidden(const std::string &name) { + return name == "chain" || name == "node_admin"; +} + +// ── config editor: raw-line transforms ──────────────────────────────────────────────────── +// Lines are edited in place so comments, ordering and unknown keys survive. The functions +// below are pure: they take the current lines and return the new ones. + +struct cfg_line { + enum kind_t { other, header, entry } kind = other; + std::string section; // section the entry belongs to (or the new section for a header) + std::string key; + std::string value; + /// Everything from the first '#' to the end of the line, kept so a rewritten line does not + /// swallow the operator's note. The value itself ends where the loader would end it. + std::string comment; +}; + +/// Classify one raw line (without its '\n', possibly with a trailing '\r'). `current_section` +/// is the section the line starts in; for a header, `section` is the section it opens. +inline cfg_line classify_config_line(const std::string &raw, const std::string ¤t_section) { + cfg_line out; + out.section = current_section; + std::string line = raw; + if (!line.empty() && line.back() == '\r') line.pop_back(); + const std::size_t hash = line.find('#'); + if (hash != std::string::npos) { + out.comment = line.substr(hash); + line.erase(hash); + } + const std::string t = trim(line); + if (t.empty() || t[0] == ';') return out; + if (t[0] == '[' && t.back() == ']') { + out.kind = cfg_line::header; + out.section = trim(t.substr(1, t.size() - 2)); + return out; + } + out.kind = cfg_line::entry; + const std::size_t eq = line.find('='); + if (eq == std::string::npos) { + out.key = t; + } else { + out.key = trim(line.substr(0, eq)); + out.value = trim(line.substr(eq + 1)); + } + return out; +} + +inline std::vector split_lines(const std::string &text) { + std::vector out; + std::size_t pos = 0; + while (pos < text.size()) { + const std::size_t nl = text.find('\n', pos); + if (nl == std::string::npos) { + out.push_back(text.substr(pos)); + break; + } + out.push_back(text.substr(pos, nl - pos)); + pos = nl + 1; + } + return out; +} + +inline std::string join_lines(const std::vector &lines, bool trailing_newline = true) { + std::string out; + for (std::size_t i = 0; i < lines.size(); ++i) { + if (i != 0) out += '\n'; + out += lines[i]; + } + if (trailing_newline && !lines.empty()) out += '\n'; + return out; +} + +inline bool text_uses_crlf(const std::vector &lines) { + for (const std::string &line : lines) { + if (!line.empty() && line.back() == '\r') return true; + } + return false; +} + +struct line_edit { + std::vector lines; + std::size_t changed = 0; + std::string error; +}; + +/// Replace the value of a top-level `key = value` line, or insert the key before the first +/// [section] (a key after a section header would belong to that section, not to the top level). +inline line_edit set_config_value(std::vector lines, const std::string &key, + const std::string &value) { + line_edit out{std::move(lines), 0, ""}; + const bool crlf = text_uses_crlf(out.lines); + std::string section; + std::size_t first_header = std::string::npos; + for (std::size_t i = 0; i < out.lines.size(); ++i) { + const cfg_line l = classify_config_line(out.lines[i], section); + if (l.kind == cfg_line::header) { + section = l.section; + if (first_header == std::string::npos) first_header = i; + continue; + } + if (l.kind != cfg_line::entry || !l.section.empty() || l.key != key) continue; + + std::string raw = out.lines[i]; + const bool cr = !raw.empty() && raw.back() == '\r'; + if (cr) raw.pop_back(); + const std::size_t eq = raw.find('='); + if (eq == std::string::npos) { + std::size_t b = 0; + while (b < raw.size() && std::isspace(static_cast(raw[b]))) ++b; + out.lines[i] = raw.substr(0, b) + key + " = " + value + + (l.comment.empty() ? "" : " " + l.comment) + (cr ? "\r" : ""); + } else { + // Keep the original spacing around '=' so the diff is exactly the value. + std::size_t ws = eq + 1; + while (ws < raw.size() && (raw[ws] == ' ' || raw[ws] == '\t')) ++ws; + out.lines[i] = raw.substr(0, eq + 1) + raw.substr(eq + 1, ws - (eq + 1)) + value + + (l.comment.empty() ? "" : " " + l.comment) + (cr ? "\r" : ""); + } + ++out.changed; + } + if (out.changed == 0) { + const std::string row = key + " = " + value + (crlf ? "\r" : ""); + if (first_header == std::string::npos) { + out.lines.push_back(row); + } else { + out.lines.insert(out.lines.begin() + static_cast(first_header), row); + } + out.changed = 1; + } + return out; +} + +/// Replace the value of `key = value` INSIDE `[section]`. A missing section is an error, not an +/// insert: inventing `[logger.foo]` would configure a logger the node never declares, and the +/// next start would silently ignore it. A missing key inside an existing section is inserted +/// directly under its header, which is where the shipped configs keep single-line options. +inline line_edit set_config_value_in_section(std::vector lines, + const std::string §ion, const std::string &key, + const std::string &value) { + line_edit out{std::move(lines), 0, ""}; + if (section.empty()) { + out.error = "internal: empty section"; + return out; + } + const bool crlf = text_uses_crlf(out.lines); + std::string current; + std::size_t header_at = std::string::npos; + for (std::size_t i = 0; i < out.lines.size(); ++i) { + const cfg_line l = classify_config_line(out.lines[i], current); + if (l.kind == cfg_line::header) { + current = l.section; + if (current == section) header_at = i; + continue; + } + if (current != section || l.kind != cfg_line::entry || l.key != key) continue; + + std::string raw = out.lines[i]; + const bool cr = !raw.empty() && raw.back() == '\r'; + if (cr) raw.pop_back(); + const std::size_t eq = raw.find('='); + if (eq == std::string::npos) { + std::size_t b = 0; + while (b < raw.size() && std::isspace(static_cast(raw[b]))) ++b; + out.lines[i] = raw.substr(0, b) + key + " = " + value + + (l.comment.empty() ? "" : " " + l.comment) + (cr ? "\r" : ""); + } else { + std::size_t ws = eq + 1; + while (ws < raw.size() && (raw[ws] == ' ' || raw[ws] == '\t')) ++ws; + out.lines[i] = raw.substr(0, eq + 1) + raw.substr(eq + 1, ws - (eq + 1)) + value + + (l.comment.empty() ? "" : " " + l.comment) + (cr ? "\r" : ""); + } + ++out.changed; + return out; + } + if (header_at == std::string::npos) { + out.error = "section [" + section + "] is not present in config.ini"; + return out; + } + out.lines.insert(out.lines.begin() + static_cast(header_at) + 1, + key + " = " + value + (crlf ? "\r" : "")); + out.changed = 1; + return out; +} + +/// Current value of `key` inside `[section]`, for the plan diff. +inline bool config_value_in_section(const std::vector &lines, + const std::string §ion, const std::string &key, + std::string &value) { + std::string current; + bool found = false; + for (const std::string &raw : lines) { + const cfg_line l = classify_config_line(raw, current); + if (l.kind == cfg_line::header) { + current = l.section; + continue; + } + if (current == section && l.kind == cfg_line::entry && l.key == key) { + value = l.value; + found = true; + } + } + return found; +} + +/// Add and remove `plugin = ` lines. Validation is part of the transform: an unknown, +/// duplicated, protected, absent or already-configured name yields `error` and the lines are +/// not touched. +inline line_edit edit_plugin_lines(std::vector lines, + const std::vector &add, + const std::vector &remove) { + line_edit out{std::move(lines), 0, ""}; + std::set configured; + std::string section; + for (const std::string &raw : out.lines) { + const cfg_line l = classify_config_line(raw, section); + if (l.kind == cfg_line::header) { + section = l.section; + } else if (l.kind == cfg_line::entry && l.section.empty() && l.key == "plugin" && + !l.value.empty()) { + for (const std::string &name : plugin_names_in_value(l.value)) configured.insert(name); + } + } + + std::set add_set; + std::set remove_set; + for (const std::string &raw : add) { + const std::string name = trim(raw); + if (!known_plugins().count(name)) { + out.error = "unknown plugin '" + name + "'"; + return out; + } + if (!add_set.insert(name).second) { + out.error = "plugin '" + name + "' requested twice"; + return out; + } + if (configured.count(name)) { + out.error = "plugin '" + name + "' is already configured"; + return out; + } + } + for (const std::string &raw : remove) { + const std::string name = trim(raw); + if (!configured.count(name)) { + out.error = "plugin '" + name + "' is not configured"; + return out; + } + if (!remove_set.insert(name).second) { + out.error = "plugin '" + name + "' requested for removal twice"; + return out; + } + if (plugin_removal_forbidden(name)) { + out.error = "plugin '" + name + "' cannot be disabled through the admin API"; + return out; + } + if (add_set.count(name)) { + out.error = "plugin '" + name + "' is both added and removed"; + return out; + } + } + if (add_set.empty() && remove_set.empty()) { + out.error = "nothing to do"; + return out; + } + + std::vector kept; + kept.reserve(out.lines.size() + add_set.size()); + section.clear(); + for (const std::string &raw : out.lines) { + const cfg_line l = classify_config_line(raw, section); + if (l.kind == cfg_line::header) section = l.section; + if (l.kind == cfg_line::entry && l.section.empty() && l.key == "plugin" && + !l.value.empty()) { + // One line can carry several names (`plugin = chain p2p`), so removal is a rewrite + // when some names survive: dropping the whole line would disable a plugin the + // operator never asked to touch. + const std::vector names = plugin_names_in_value(l.value); + std::vector keep_names; + for (const std::string &name : names) { + if (!remove_set.count(name)) keep_names.push_back(name); + } + if (keep_names.size() != names.size()) { + ++out.changed; + if (keep_names.empty()) continue; + std::string line = "plugin = "; + for (std::size_t n = 0; n < keep_names.size(); ++n) { + if (n) line += " "; + line += keep_names[n]; + } + if (!l.comment.empty()) line += " " + l.comment; + if (!raw.empty() && raw.back() == '\r') line += "\r"; + kept.push_back(line); + continue; + } + } + kept.push_back(raw); + } + out.lines = std::move(kept); + + if (!add_set.empty()) { + const bool crlf = text_uses_crlf(out.lines); + std::size_t at = out.lines.size(); + std::size_t first_header = std::string::npos; + bool have_plugin_line = false; + section.clear(); + for (std::size_t i = 0; i < out.lines.size(); ++i) { + const cfg_line l = classify_config_line(out.lines[i], section); + if (l.kind == cfg_line::header) { + section = l.section; + if (first_header == std::string::npos) first_header = i; + } else if (l.kind == cfg_line::entry && l.section.empty() && l.key == "plugin") { + at = i + 1; + have_plugin_line = true; + } + } + if (!have_plugin_line && first_header != std::string::npos) at = first_header; + std::vector rows; + rows.reserve(add_set.size()); + for (const std::string &name : add_set) { + rows.push_back("plugin = " + name + (crlf ? "\r" : "")); + } + out.lines.insert(out.lines.begin() + static_cast(at), rows.begin(), + rows.end()); + out.changed += rows.size(); + } + return out; +} + +// ── tokens ───────────────────────────────────────────────────────────────────────────────── +struct token_info { + std::string label; + std::set scopes; + std::string hash_hex; + uint64_t expires = 0; +}; + +inline token_info parse_token_spec(const std::string &spec) { + // label:scope[,scope]:sha256hex[:expires-unixtime] + const std::vector parts = split(spec, ':'); + FC_ASSERT(parts.size() >= 3 && parts.size() <= 4, + "admin-token: expected 'label:scope[,scope]:sha256hex[:expires]', got '${s}'", + ("s", spec)); + token_info t; + t.label = trim(parts[0]); + FC_ASSERT(!t.label.empty(), "admin-token: empty label"); + + for (const std::string &raw : split(parts[1], ',')) { + const std::string scope = trim(raw); + FC_ASSERT(scope == "read" || scope == "plugins" || scope == "config" || + scope == "process" || scope == "chain" || scope == "p2p" || + scope == "snapshot" || scope == "webserver", + "admin-token: unknown scope '${s}' (allowed: read, plugins, config, process, chain, p2p, snapshot, webserver)", + ("s", scope)); + t.scopes.insert(scope); + } + FC_ASSERT(!t.scopes.empty(), "admin-token: token '${l}' has no scopes", ("l", t.label)); + + t.hash_hex = to_lower(trim(parts[2])); + FC_ASSERT(t.hash_hex.size() == 64 && + t.hash_hex.find_first_not_of("0123456789abcdef") == std::string::npos, + "admin-token: token '${l}' hash is not 64 hex chars", ("l", t.label)); + + if (parts.size() == 4) { + const std::string exp = trim(parts[3]); + FC_ASSERT(!exp.empty() && exp.find_first_not_of("0123456789") == std::string::npos, + "admin-token: token '${l}' has a non-numeric expiry", ("l", t.label)); + t.expires = std::stoull(exp); + } + return t; +} + +} // namespace util +} // namespace node_admin +} // namespace plugins +} // namespace graphene diff --git a/plugins/node_admin/include/graphene/plugins/node_admin/objects_check.hpp b/plugins/node_admin/include/graphene/plugins/node_admin/objects_check.hpp new file mode 100644 index 0000000000..7ee5288190 --- /dev/null +++ b/plugins/node_admin/include/graphene/plugins/node_admin/objects_check.hpp @@ -0,0 +1,213 @@ +#pragma once + +// Judging of the chainbase state view shown by the Objects tab. +// +// The plugin collects facts about the running node (file sizes, index counts, markers) and +// this header turns them into a verdict. It is deliberately free of fc/chainbase/appbase: +// the whole point of separating it is that every branch can be driven by a unit test with a +// hand-built state, including the ones an operator acts on (wipe + replay / resync), where a +// false "corrupted" would be expensive and a false "ok" would hide real damage. + +#include +#include +#include + +namespace graphene { +namespace plugins { +namespace node_admin { +namespace objects_check { + +/// One row per chainbase index (one per object type). +struct index_row { + std::string type; + int64_t type_id = 0; + int64_t objects = 0; + int64_t revision = 0; +}; + +/// Everything the admin plugin can see about the state of a running node. Defaults describe +/// a node whose state directory is missing; the collector fills what it can read. +struct state { + std::string state_dir; + bool state_dir_exists = false; + bool shm_exists = false; + uint64_t shm_bytes = 0; + uint64_t mapped_bytes = 0; + uint64_t free_bytes = 0; + uint64_t reserved_bytes = 0; + int64_t revision = 0; + uint32_t head_block_num = 0; + uint32_t stored_schema = 0; + bool schema_file_present = false; + bool block_log_present = false; + uint64_t block_log_bytes = 0; + bool recovering = false; + std::vector markers; + std::vector indexes; + int64_t total_objects = 0; + int64_t dgp_objects = -1; + int64_t schedule_objects = -1; + int64_t account_objects = -1; + std::string lock_error; +}; + +struct finding { + std::string severity; // "critical" | "warn" + std::string code; + std::string text; +}; + +struct judgement { + std::string verdict; // "ok" | "warn" | "corrupted" | "unreadable" + int critical = 0; + int warnings = 0; + std::vector findings; + std::string recommended_repair; // "" | "replay" | "resync" + bool replay_available = false; +}; + +/// Facts only: the node's schema version differs from the binary that is running it. +inline bool schema_mismatch(const state &s, uint32_t compiled_schema) { + return s.shm_exists && (!s.schema_file_present || s.stored_schema != compiled_schema); +} + +/// What the next startup will really do with a repair marker. The chain plugin takes the +/// snapshot path before it looks at force_replay, so on a --replay-from-snapshot node neither +/// marker does what its name suggests: the state is rebuilt from the snapshot either way. +inline std::string repair_effect(const std::string &mode, bool snapshot_replay_from, + bool snapshot_configured) { + if (mode == "replay" && snapshot_replay_from) { + return "next startup rebuilds state from the configured snapshot and replays the " + "block log on top of it: this node runs with --replay-from-snapshot, which the " + "chain plugin takes before the marker's replay-from-block-1 path"; + } + if (mode == "replay") { + return "next startup wipes shared memory and reindexes the existing block log from block 1"; + } + if (snapshot_configured) { + return "next startup wipes shared memory and the block log, then rebuilds state from the " + "configured snapshot and continues from the network (not from genesis)"; + } + return "next startup wipes shared memory and the block log, then resyncs from peers"; +} + +/// A block log that only carries chainbase's one-byte placeholder has nothing to replay. +/// block_log::open() writes that byte when it creates the files, so on a fresh node (and after +/// a resync wipe) "the file exists" is true while it holds zero blocks. Treating it as a +/// replay source would promise a rebuild that would reindex nothing. +inline bool has_replayable_blocks(const state &s) { + return s.block_log_present && s.block_log_bytes > 1; +} + +inline judgement judge(const state &s, uint32_t compiled_schema) { + judgement j; + auto add = [&](const char *severity, const char *code, const std::string &text) { + finding f; + f.severity = severity; + f.code = code; + f.text = text; + j.findings.push_back(f); + if (std::string(severity) == "critical") ++j.critical; + else if (std::string(severity) == "warn") ++j.warnings; + }; + auto num = [](uint64_t v) { return std::to_string(v); }; + + if (!s.lock_error.empty()) { + add("critical", "read_lock_timeout", + "shared memory did not answer a read lock: " + s.lock_error); + } + if (!s.state_dir_exists) { + add("critical", "state_dir_missing", "shared-memory directory does not exist: " + s.state_dir); + } else if (!s.shm_exists) { + add("critical", "shared_memory_missing", + "shared_memory.bin is absent in " + s.state_dir + "; the node has no state"); + } else { + if (s.mapped_bytes > 0 && s.shm_bytes < s.mapped_bytes) { + add("critical", "shared_memory_truncated", + "shared_memory.bin is " + num(s.shm_bytes) + " bytes but the node maps " + + num(s.mapped_bytes) + "; the file was truncated or replaced under a running process"); + } else if (s.mapped_bytes > 0 && s.shm_bytes > s.mapped_bytes) { + add("warn", "shared_memory_larger_than_mapping", + "shared_memory.bin is " + num(s.shm_bytes) + " bytes, larger than the mapping this " + "process opened (" + num(s.mapped_bytes) + "); something else grew the file under " + "the running node, usually a second process pointed at the same state directory"); + } + } + if (!s.markers.empty()) { + std::string list; + for (const std::string &x : s.markers) { + if (!list.empty()) list += ", "; + list += x; + } + add("critical", "stale_marker", + "marker file(s) present from an interrupted operation: " + list + + "; the next startup will act on them, and until then the state is mid-operation"); + } + if (schema_mismatch(s, compiled_schema)) { + add("critical", "schema_mismatch", + "schema_version " + std::string(s.schema_file_present ? num(s.stored_schema) : "missing") + + " does not match this binary (" + num(compiled_schema) + + "); the next startup wipes shared memory and rebuilds it from a snapshot or block log"); + } + if (s.shm_exists && s.revision != static_cast(s.head_block_num)) { + add("warn", "revision_behind_head", + "index revision " + std::to_string(s.revision) + " differs from head block " + + num(s.head_block_num) + "; the next startup replays the gap from the block log. " + "Normal after a crash, not corruption by itself"); + } + if (s.mapped_bytes > 0 && s.free_bytes < 8ull * 1024 * 1024) { + add("critical", "shared_memory_exhausted", + "only " + num(s.free_bytes) + " bytes are free in the mapped file; the node cannot " + "write another block. Repair does not help: raise shared-file-size or the resize " + "limits and restart"); + } else if (s.mapped_bytes > 0 && s.free_bytes * 20 < s.mapped_bytes) { + add("warn", "shared_memory_low", + "less than 5% of the mapped file is free (" + num(s.free_bytes) + " of " + + num(s.mapped_bytes) + " bytes)"); + } + if (s.head_block_num > 0) { + if (s.dgp_objects != 1) { + add("critical", "global_properties_count", + "dynamic_global_property_object rows = " + std::to_string(s.dgp_objects) + + ", expected exactly 1"); + } + if (s.schedule_objects != 1) { + add("critical", "validator_schedule_count", + "validator_schedule_object rows = " + std::to_string(s.schedule_objects) + + ", expected exactly 1"); + } + if (s.account_objects == 0) { + add("critical", "account_index_empty", + "account_object rows = 0 while head is " + num(s.head_block_num)); + } + } + if (!s.block_log_present && s.head_block_num > 0) { + add("warn", "block_log_missing", + "block_log is absent while head is " + num(s.head_block_num) + + "; a replay cannot rebuild state, only a full resync can"); + } + if (s.recovering) { + add("warn", "recovery_in_progress", + "the chain plugin reports an automatic recovery in progress"); + } + + if (!s.lock_error.empty()) j.verdict = "unreadable"; + else if (j.critical > 0) j.verdict = "corrupted"; + else if (j.warnings > 0) j.verdict = "warn"; + else j.verdict = "ok"; + + // A repair rewrites state, so it is recommended only when rewriting can fix the finding. + // Exhausted shared memory is the exception: the same size would fill up again, so wiping + // alone is not a fix unless something else is also wrong. + const bool repair_helps = + j.verdict == "corrupted" && + !(j.critical == 1 && s.free_bytes < 8ull * 1024 * 1024 && s.mapped_bytes > 0); + if (repair_helps) j.recommended_repair = has_replayable_blocks(s) ? "replay" : "resync"; + j.replay_available = has_replayable_blocks(s); + return j; +} + +} // namespace objects_check +} // namespace node_admin +} // namespace plugins +} // namespace graphene diff --git a/plugins/node_admin/node_admin_plugin.cpp b/plugins/node_admin/node_admin_plugin.cpp new file mode 100644 index 0000000000..a233b3d135 --- /dev/null +++ b/plugins/node_admin/node_admin_plugin.cpp @@ -0,0 +1,5400 @@ +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +#include +#include + +// Must precede any websocketpp header: restores the `io_service` alias that +// Boost >= 1.87 dropped but the vendored websocketpp still spells throughout. +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef _WIN32 +#include +#include +#include +#include +#include +#endif + +namespace graphene { +namespace plugins { +namespace node_admin { + +namespace bpo = boost::program_options; +namespace bfs = boost::filesystem; +namespace asio = boost::asio; +using boost::asio::ip::tcp; +using websocketpp::connection_hdl; +// CHAIN_VERSION expands to an unqualified `version(4, 2, 0)`; same import as vizd's main.cpp. +using graphene::protocol::version; + +namespace { + +using util::cfg_entry; +using util::classify_config_line; +using util::edit_plugin_lines; +using util::is_plain_snapshot_name; +using util::join_lines; +using util::line_edit; +using util::mask_text; +using util::normalize_config_value; +using util::parse_config_file; +using util::parse_token_spec; +using util::plugin_removal_forbidden; +using util::sensitive_key; +using util::set_config_value; +using util::split; +using util::split_lines; +using util::to_lower; +using util::token_info; +using util::trim; + +// appbase exposes only a typed find_plugin(); the name-based lookup is protected and the +// plugin registry has no public enumeration. So the config list is the visible population, and +// the state below is genuinely observed only for the plugins whose type this TU links. +const appbase::abstract_plugin *observe_plugin(const std::string &name) { + if (name == "chain") return appbase::app().find_plugin(); + if (name == "p2p") return appbase::app().find_plugin(); + if (name == "snapshot") return appbase::app().find_plugin(); + if (name == "webserver") return appbase::app().find_plugin(); + if (name == "validator_guard") return appbase::app().find_plugin(); + if (name == "validator") return appbase::app().find_plugin(); + if (name == node_admin_plugin::name()) return appbase::app().find_plugin(); + return nullptr; +} + +// ── chainbase introspection ───────────────────────────────────────────────────────────────── +// The shared-memory image is the node's whole state. Its file size and the index list are the +// two facts an operator can see from inside a running process; anything deeper (page checks, +// checksum) needs the file closed, so "repair" is a marker honoured at the next startup. +bool ends_with_text(const std::string &s, const char *suffix) { + const std::size_t n = std::strlen(suffix); + return s.size() >= n && s.compare(s.size() - n, n, suffix) == 0; +} + +bool read_uint_file(const bfs::path &p, uint32_t &out) { + std::ifstream f(p.string()); + if (!f) return false; + uint32_t v = 0; + f >> v; + if (f.fail()) return false; + out = v; + return true; +} + +// The facts live in objects_check.hpp together with the judge, so the verdict branches can be +// driven by unit tests without chainbase: see tests/node_admin/node_admin_objects_test.cpp. +using objects_state = objects_check::state; + +// ── limits ────────────────────────────────────────────────────────────────────────────────── +const std::size_t kMaxBodyBytes = 64 * 1024; +const std::size_t kMaxLogBytes = 64 * 1024; +const std::size_t kMaxLines = 2000; +const std::size_t kMaxSnapshotPage = 200; +const std::size_t kAuditRotateBytes = 16 * 1024 * 1024; +const uint32_t kRatePerSecond = 20; +// Snapshot files are served in one HTTP response, so the body has to fit in memory. +// VIZ mainnet snapshots are ~4 MB compressed; the cap is for a pathological file, not +// the normal case. Larger files stay downloadable with scp from the node. + +/// Constant-time comparison for equal-length strings. Token hashes are always 64 hex chars, +/// so an early size check leaks nothing beyond "this is not a sha256 hex digest". +bool ct_equal(const std::string &a, const std::string &b) { + if (a.size() != b.size()) return false; + unsigned char diff = 0; + for (std::size_t i = 0; i < a.size(); ++i) { + diff |= static_cast(a[i] ^ b[i]); + } + return diff == 0; +} + +std::string iso_utc(const fc::time_point_sec &t) { + return t.to_iso_string() + "Z"; +} + +std::string now_iso() { + return iso_utc(fc::time_point_sec(static_cast(fc::time_point::now().sec_since_epoch()))); +} + +std::string unixtime_iso(uint32_t t) { + return iso_utc(fc::time_point_sec(t)); +} + +uint32_t now_unixtime() { + return static_cast(fc::time_point::now().sec_since_epoch()); +} + +// ── config file IO ──────────────────────────────────────────────────────────────────────── +bool read_text_file(const std::string &path, std::string &out, std::string &why) { + std::ifstream in(path.c_str(), std::ios::binary); + if (!in) { + why = "cannot open " + path; + return false; + } + std::ostringstream ss; + ss << in.rdbuf(); + out = ss.str(); + return true; +} + +std::string text_sha256(const std::string &text) { + return fc::sha256::hash(text).str(); +} + +/// Current value of a top-level key, for the plan diff. Keys inside [sections] are a different +/// namespace and are not touched by the editor. +bool top_level_value(const std::vector &lines, const std::string &key, + std::string &value) { + std::string section; + bool found = false; + for (const std::string &raw : lines) { + const util::cfg_line l = util::classify_config_line(raw, section); + if (l.kind == util::cfg_line::header) { + section = l.section; + continue; + } + if (l.kind == util::cfg_line::entry && l.section.empty() && l.key == key) { + value = l.value; + found = true; + } + } + return found; +} + +/// Apply one allow-listed key to the candidate lines, choosing the namespace by the spec: the 29 +/// top-level options keep their old behaviour, and the logging keys (`logger.*`, `log.*`) are +/// edited inside their ini section. Both plan and apply go through here, so the diff shown to the +/// operator and the file that is actually written cannot disagree. +line_edit set_allowed_config_value(std::vector lines, const std::string &key, + const std::string &value) { + const util::config_spec *spec = util::config_spec_for(key); + if (!spec) { + line_edit out{std::move(lines), 0, "key '" + key + "' is not in the admin allow-list"}; + return out; + } + std::string section; + std::string leaf; + util::config_name_parts(*spec, section, leaf); + if (section.empty()) return util::set_config_value(std::move(lines), leaf, value); + return util::set_config_value_in_section(std::move(lines), section, leaf, value); +} + +/// Current value of an allow-listed key in whichever namespace its spec names. +bool allowed_config_value(const std::vector &lines, const std::string &key, + std::string &value) { + const util::config_spec *spec = util::config_spec_for(key); + if (!spec) return false; + std::string section; + std::string leaf; + util::config_name_parts(*spec, section, leaf); + if (section.empty()) return top_level_value(lines, leaf, value); + return util::config_value_in_section(lines, section, leaf, value); +} + +/// Replace config.ini atomically: copy the current file to `.bak-`, write the +/// candidate to a sibling temp file, fsync it, rename over the original and fsync the directory. +/// A failure before the rename leaves the original untouched; a crash after it leaves either +/// the old or the new file, never a half-written one. +bool write_config_atomic(const std::string &path, const std::string &body, + std::string &backup_path, std::string &why) { + boost::system::error_code ec; + const bfs::path target = bfs::exists(path, ec) ? bfs::canonical(path, ec) : bfs::path(path); + if (ec) { why = "cannot resolve config path: " + ec.message(); return false; } + const bfs::path dir = target.parent_path(); + + if (bfs::exists(target, ec)) { + const std::string stamp = std::to_string(static_cast(now_unixtime())); + backup_path = path + ".bak-" + stamp; + for (int n = 1; bfs::exists(backup_path, ec); ++n) { + backup_path = path + ".bak-" + stamp + "-" + std::to_string(n); + } + bfs::copy_file(target, bfs::path(backup_path), bfs::copy_options::none, ec); + if (ec) { + why = "cannot back up " + path + ": " + ec.message(); + return false; + } + } + +#ifdef _WIN32 + const std::string tmp = target.string() + ".tmp"; + { + std::ofstream out(tmp.c_str(), std::ios::binary | std::ios::trunc); + out.write(body.data(), static_cast(body.size())); + out.flush(); + if (!out) { why = "cannot write " + tmp; return false; } + } +#else + std::string pattern = target.string() + ".tmp.XXXXXX"; + std::vector name(pattern.begin(), pattern.end()); + name.push_back('\0'); + const int fd = ::mkstemp(name.data()); // O_EXCL and 0600 before any secret bytes are written + if (fd < 0) { why = "cannot create config temp file"; return false; } + const std::string tmp(name.data()); + size_t written = 0; + while (written < body.size()) { + const ssize_t n = ::write(fd, body.data() + written, body.size() - written); + if (n < 0 && errno == EINTR) continue; + if (n <= 0) { ::close(fd); ::unlink(tmp.c_str()); why = "cannot write config"; return false; } + written += static_cast(n); + } + struct stat st; + if (::stat(target.string().c_str(), &st) == 0) ::fchmod(fd, st.st_mode & 0777); + if (::fsync(fd) != 0) { + ::close(fd); ::unlink(tmp.c_str()); why = "cannot fsync config"; return false; + } + ::close(fd); +#endif + bfs::rename(bfs::path(tmp), target, ec); + if (ec) { + why = "cannot replace " + path + ": " + ec.message(); + return false; + } +#ifndef _WIN32 + const int dfd = ::open(dir.string().c_str(), O_RDONLY | O_DIRECTORY); + if (dfd >= 0) { + ::fsync(dfd); + ::close(dfd); + } +#endif + return true; +} + +/// Plan ids are bearer capabilities for one single use, so they come from random_device rather +/// than from a counter: an id that can be guessed is an id somebody else can spend. +std::string random_plan_id() { + std::random_device rd; + std::mt19937_64 gen((static_cast(rd()) << 32) ^ rd()); + std::uniform_int_distribution dist; + const uint64_t a = dist(gen); + const uint64_t b = dist(gen); + char buf[33]; + std::snprintf(buf, sizeof(buf), "%016llx%016llx", + static_cast(a), static_cast(b)); + return std::string(buf, 32); +} + +// ── tail a file ──────────────────────────────────────────────────────────────────────────── +struct tail_result { + bool ok = false; + std::string error; + uint64_t size = 0; + uint64_t bytes_returned = 0; + std::vector lines; +}; + +int64_t elapsed_ms(const fc::time_point &started) { + return (fc::time_point::now() - started).count() / 1000; +} + +// The admin API is machine-facing: emit real JSON numbers. fc's default formatting quotes every +// int64 above 2^32-1 and every double, which turns byte counts and load averages into strings +// and forces the client to parse them twice. +std::string to_json(const fc::variant &v) { + return fc::json::to_string(v, fc::json::legacy_generator); +} + +int64_t param_int(const fc::variant_object &o, const std::string &key, int64_t def) { + auto it = o.find(key); + if (it == o.end()) return def; + try { + return it->value().as_int64(); + } catch (...) { + return def; + } +} + +std::string param_str(const fc::variant_object &o, const std::string &key, const std::string &def) { + auto it = o.find(key); + if (it == o.end() || !it->value().is_string()) return def; + return it->value().as_string(); +} + +tail_result tail_file(const std::string &path, uint64_t bytes, std::size_t max_lines) { + tail_result r; + std::ifstream in(path.c_str(), std::ios::binary); + if (!in) { + r.error = "cannot open file"; + return r; + } + in.seekg(0, std::ios::end); + const std::streamoff end = in.tellg(); + if (end < 0) { + r.error = "cannot stat file"; + return r; + } + r.size = static_cast(end); + const uint64_t take = std::min(bytes, r.size); + in.seekg(static_cast(r.size - take), std::ios::beg); + std::string buf(static_cast(take), '\0'); + in.read(&buf[0], static_cast(take)); + buf.resize(static_cast(in.gcount())); + r.bytes_returned = buf.size(); + + std::vector all = split(buf, '\n'); + // A tail that starts mid-line produces a truncated first line; drop it unless we read + // the whole file (then the first line is genuine). + if (take < r.size && !all.empty()) all.erase(all.begin()); + // Files end with a newline, so the final split element is an empty string — not a line. + if (!all.empty() && all.back().empty()) all.pop_back(); + if (all.size() > max_lines) { + all.erase(all.begin(), all.end() - static_cast(max_lines)); + } + r.lines = std::move(all); + r.ok = true; + return r; +} + +// ── built-in UI ────────────────────────────────────────────────────────────────────────── +// A single static page served at /ui/. It holds no secrets and no state: the operator +// pastes a bearer token, the page keeps it in sessionStorage and calls the same JSON-RPC +// endpoint. Vanilla JS, no build step, no external assets (CSP forbids them anyway). +const char *ui_page() { + return R"HTML( + + + + +VIZ node admin + + + +
+

VIZ node admin

+ + + no token + — +
+ + +
+
+

Node

+
+
+ + + + + + + + + + + + +
+ + + +)HTML"; +} + +// ── websocketpp server config (same stub-log shape as webserver_plugin) ───────────────────── +struct asio_with_stub_log : public websocketpp::config::asio { + typedef asio_with_stub_log type; + typedef asio base; + + typedef base::concurrency_type concurrency_type; + typedef base::request_type request_type; + typedef base::response_type response_type; + typedef base::message_type message_type; + typedef base::con_msg_manager_type con_msg_manager_type; + typedef base::endpoint_msg_manager_type endpoint_msg_manager_type; + typedef base::alog_type alog_type; + typedef base::elog_type elog_type; + typedef base::rng_type rng_type; + + struct transport_config : public base::transport_config { + typedef type::concurrency_type concurrency_type; + typedef type::alog_type alog_type; + typedef type::elog_type elog_type; + typedef type::request_type request_type; + typedef type::response_type response_type; + typedef websocketpp::transport::asio::basic_socket::endpoint socket_type; + }; + + typedef websocketpp::transport::asio::endpoint transport_type; + + static const long timeout_open_handshake = 5000; +}; + +typedef websocketpp::server websocket_server_type; + +// A raw HTTP response uses one bounded buffer and one outstanding socket write. +// Defer websocketpp's buffered response, then use its normal HTTP completion hook +// after the last chunk (or terminate on an incomplete transfer). +class snapshot_http_stream : public std::enable_shared_from_this { +public: + snapshot_http_stream(asio::io_context &ios, websocket_server_type::connection_ptr con, + std::unique_ptr input, uint64_t size, + std::string headers) + : con_(std::move(con)), input_(std::move(input)), remaining_(size), + headers_(std::move(headers)), timer_(ios) {} + + void start() { + const auto ec = con_->defer_http_response(); + if (ec) { finish(false); return; } + arm_timeout(); + auto self = shared_from_this(); + asio::async_write(con_->get_socket(), asio::buffer(headers_), + [self](const boost::system::error_code &ec, std::size_t) { + if (ec) self->finish(false); else self->next(); + }); + } + +private: + void arm_timeout() { + timer_.expires_after(std::chrono::seconds(30)); + auto self = shared_from_this(); + timer_.async_wait([self](const boost::system::error_code &ec) { + if (!ec) self->finish(false); + }); + } + + void next() { + if (finished_) return; + if (!remaining_) { finish(true); return; } + const auto n = static_cast(std::min(remaining_, buffer_.size())); + input_->read(buffer_.data(), static_cast(n)); + if (input_->gcount() != static_cast(n)) { finish(false); return; } + remaining_ -= n; + arm_timeout(); // A stalled receiver cannot retain a descriptor indefinitely. + auto self = shared_from_this(); + asio::async_write(con_->get_socket(), asio::buffer(buffer_.data(), n), + [self](const boost::system::error_code &ec, std::size_t) { + if (ec) self->finish(false); else self->next(); + }); + } + + void finish(bool complete) { + if (finished_) return; + finished_ = true; + boost::system::error_code ignored; + try { timer_.cancel(); } catch (const boost::system::system_error &) {} + input_.reset(); + if (complete) + con_->handle_write_http_response(websocketpp::lib::error_code()); + else { + // Cancel our raw Asio write as well as websocketpp's transport state. + // Otherwise its completion handler can keep a stalled socket alive. + con_->get_socket().set_option(asio::socket_base::linger(true, 0), ignored); + con_->get_socket().cancel(ignored); + con_->get_socket().close(ignored); + con_->terminate(websocketpp::error::make_error_code(websocketpp::error::general)); + } + } + + websocket_server_type::connection_ptr con_; + std::unique_ptr input_; + uint64_t remaining_; + std::string headers_; + std::array buffer_; + asio::steady_timer timer_; + bool finished_ = false; +}; + +// ── JSON-RPC error ───────────────────────────────────────────────────────────────────────── +struct rpc_error { + int code; + std::string message; + std::string reason; + std::string hint; +}; + +} // namespace + +// ── impl ─────────────────────────────────────────────────────────────────────────────────── +struct node_admin_plugin::impl { + explicit impl(chain::plugin &c) : chain(c) {} + + chain::plugin &chain; + + // listener + asio::io_context ios; + websocket_server_type server; + std::thread thread; + std::set> connections; + std::unique_ptr endpoint; + bool listening = false; + fc::time_point started_at = fc::time_point::now(); + + // auth / policy + std::vector tokens; + std::string audit_path; + std::map log_files; // name -> path + bool allow_remote = false; + + // rate limit: label -> (window start sec, count in window) + std::mutex rate_mutex; + std::map> rate; + + std::atomic request_seq{0}; + std::mutex audit_mutex; + + // plan/apply for config.ini (design §0.3). A plan is a single-use, token-bound snapshot of + // "what will be written": the file's hash at plan time, the exact key/value set or plugin + // edit, and an expiry. apply re-checks the hash, so a hand edit between plan and apply is a + // refusal, not an overwrite. + struct pending_plan { + std::string id; + std::string label; + std::string kind; // "config" | "plugins" + uint32_t created = 0; + uint32_t expires = 0; + std::string base_sha256; + std::map set; // config + std::vector add; // plugins + std::vector remove; // plugins + // validators: [{name, action, signing_key, expected_key, hold_from}] + fc::variants actions; + fc::variant diff; + }; + std::mutex plans_mutex; + std::map plans; + // Serializes the read-hash-write of an apply: without it two concurrent applies each + // pass their own hash check and the later rename silently drops the other's edit. + std::mutex config_write_mutex; + uint32_t plan_ttl_sec = 120; + std::atomic restart_requested{false}; + // Startup mode of the chain plugin, captured while node_admin still sees the option + // map. It decides what a repair marker really does: with --replay-from-snapshot the + // chain plugin takes the snapshot path before it ever looks at force_replay, so the + // marker's "replay from block 1" is superseded, and a force_resync comes back from the + // snapshot rather than from genesis. Reporting the generic effect there would be a lie. + bool snapshot_replay_from = false; + bool snapshot_configured = false; + + void store_plan(const pending_plan &p); + pending_plan take_plan(const std::string &id, const token_info &token, const std::string &kind); + + // ── listener ─────────────────────────────────────────────────────────────────────────── + void start(); + void stop(); + void handle_http(connection_hdl hdl); + + // ── auth ─────────────────────────────────────────────────────────────────────────────── + const token_info *authenticate(const std::string &header, std::string &why) const; + bool rate_ok(const std::string &label); + + // ── dispatch ─────────────────────────────────────────────────────────────────────────── + fc::variant dispatch(const std::string &method, const fc::variant ¶ms, + const token_info &token); + fc::variant rpc_ok(const fc::variant &id, const fc::variant &result) const; + fc::variant rpc_err(const fc::variant &id, const rpc_error &e) const; + + // ── methods ──────────────────────────────────────────────────────────────────────────── + fc::mutable_variant_object make_node_info() const; + fc::variant h_auth_me(const token_info &token); + fc::variant h_ping(const fc::variant ¶ms); + fc::variant h_node_info(const fc::variant ¶ms); + fc::variant h_diagnostics(const fc::variant ¶ms); + fc::variant h_plugins_list(const fc::variant ¶ms); + fc::variant h_plugins_status(const fc::variant ¶ms); + fc::variant h_config_get(const fc::variant ¶ms); + fc::variant h_objects_list(const fc::variant ¶ms); + fc::variant h_objects_check(const fc::variant ¶ms); + fc::variant h_objects_repair(const fc::variant ¶ms); + objects_state collect_objects() const; + fc::variant h_logs_list(const fc::variant ¶ms); + fc::variant h_logs_tail(const fc::variant ¶ms); + fc::variant h_audit_tail(const fc::variant ¶ms); + fc::variant h_validators_active(const fc::variant ¶ms); + + // ── P2P operator methods ────────────────────────────────────────────────────────────── + graphene::plugins::p2p::p2p_plugin &p2p_started() const; + fc::variant h_p2p_status(const fc::variant ¶ms); + fc::variant h_p2p_add_node(const fc::variant ¶ms, bool trusted); + fc::variant h_p2p_clear_banned(const fc::variant ¶ms); + + // ── snapshot operator methods ───────────────────────────────────────────────────────── + graphene::plugins::snapshot::snapshot_plugin &snapshot_started() const; + fc::variant snapshot_list_variant() const; + fc::variant h_snapshot_status(const fc::variant ¶ms); + fc::variant h_snapshot_create(const fc::variant ¶ms); + fc::variant h_snapshot_restore(const fc::variant ¶ms); + + // ── webserver operator methods ──────────────────────────────────────────────────────── + graphene::plugins::webserver::webserver_plugin &webserver_started() const; + fc::variant h_webserver_status(const fc::variant ¶ms); + fc::variant h_webserver_ips(const fc::variant ¶ms); + fc::variant h_webserver_limits(const fc::variant ¶ms); + fc::variant h_webserver_stats(const fc::variant ¶ms); + fc::variant h_webserver_list_add(const fc::variant ¶ms, bool whitelist); + fc::variant h_webserver_list_remove(const fc::variant ¶ms, bool whitelist); + fc::variant h_webserver_reset(const fc::variant ¶ms); + fc::variant h_webserver_reload(const fc::variant ¶ms); + + // ── validator chain operations (delegate signed through validator_guard) ────────────── + // The plugin keeps both keys and never exposes them; node_admin only drives it and + // reports the outcome. The `chain` scope additionally requires a live guard. + graphene::plugins::validator_guard::validator_guard_plugin *guard_plugin() const; + bool guard_ready() const; + fc::variant h_validator_list(const fc::variant ¶ms); + fc::variant h_validator_plan(const fc::variant ¶ms, const token_info &token); + fc::variant h_validator_apply(const fc::variant ¶ms, const token_info &token); + + // ── config/plugin editor and restart (plan/apply) ───────────────────────────────────── + fc::variant h_config_plan(const fc::variant ¶ms, const token_info &token); + fc::variant h_config_apply(const fc::variant ¶ms, const token_info &token); + fc::variant h_config_backups(const fc::variant ¶ms); + fc::variant h_plugins_plan(const fc::variant ¶ms, const token_info &token); + fc::variant h_plugins_apply(const fc::variant ¶ms, const token_info &token); + fc::variant h_plan_cancel(const fc::variant ¶ms, const token_info &token); + fc::variant h_restart(const fc::variant ¶ms); + + // ── MCP (Streamable HTTP) ────────────────────────────────────────────────────────────── + fc::variant mcp_tools() const; + fc::variant h_mcp_initialize(const fc::variant ¶ms); + fc::variant h_mcp_ping(const fc::variant ¶ms); + fc::variant h_mcp_tools_list(const fc::variant ¶ms); + fc::variant h_mcp_tools_call(const fc::variant ¶ms, const token_info &token); + + // ── audit ────────────────────────────────────────────────────────────────────────────── + void audit(const std::string &rid, const std::string &label, const std::string &method, + const fc::variant ¶ms, const std::string &outcome, int code, + int64_t duration_ms, const std::string &remote); + void audit_line(const std::string &line); + + std::string data_dir() const { return appbase::app().data_dir().string(); } + std::string config_path() const { return appbase::app().config_path().string(); } + + void respond(websocket_server_type::connection_ptr con, + websocketpp::http::status_code::value status, const std::string &body, + const std::string &content_type = "application/json", + int64_t content_length_override = -1) { + try { + con->append_header("Content-Type", content_type); + con->append_header("Cache-Control", "no-store"); + con->set_body(body); + // set_body rewrites Content-Length (0 for a HEAD reply), so an explicit + // length must be applied after it, not before. + if (content_length_override >= 0) { + con->replace_header("Content-Length", + std::to_string(content_length_override)); + } + con->set_status(status); + con->send_http_response(); + } catch (const std::exception &e) { + wlog("node_admin: failed to send response: ${e}", ("e", e.what())); + } catch (...) { + } + } +}; + +// ── option registration ──────────────────────────────────────────────────────────────────── +void node_admin_plugin::set_program_options( + bpo::options_description &command_line_options, + bpo::options_description &config_file_options +) { + // Config-file description only, like testnet_plugin: appbase also accepts these on the + // command line, and adding the same long name to both descriptions makes boost refuse + // every start with "option is ambiguous". + bpo::options_description opts("Node administration API"); + opts.add_options() + ("admin-endpoint", bpo::value(), + "HTTP endpoint of the admin API, e.g. 127.0.0.1:8099. Empty (default) keeps the " + "plugin inert.") + ("admin-token", bpo::value>()->composing()->multitoken(), + "Access token: label:scope[,scope]:sha256hex[:expires-unixtime]. Repeatable.") + ("admin-allow-remote", bpo::value()->default_value(false), + "Allow admin-endpoint on a non-loopback address. Off by default; a remote admin " + "port is a security decision, not a convenience.") + ("admin-plan-ttl", bpo::value()->default_value(120), + "Seconds a config/plugin plan stays applicable. Plans are single-use and bound to the " + "token that created them. Default: 120.") + ("admin-audit-log", bpo::value()->default_value(""), + "Append-only audit log path. Default: node-admin-audit.log next to config.ini.") + ("admin-log-file", bpo::value>()->composing()->multitoken(), + "File the admin API may tail, as name=path or path. Repeatable. Nothing outside " + "this allow-list can be read through admin.logs.tail."); + config_file_options.add(opts); + (void) command_line_options; +} + +node_admin_plugin::node_admin_plugin() {} +node_admin_plugin::~node_admin_plugin() = default; + +// ── lifecycle ───────────────────────────────────────────────────────────────────────────── +void node_admin_plugin::plugin_initialize(const bpo::variables_map &options) { + pimpl.reset(new impl(appbase::app().get_plugin())); + + if (!options.count("admin-endpoint")) { + ilog("node_admin: admin-endpoint is not set; the admin API is disabled"); + return; + } + + const std::string ep = options.at("admin-endpoint").as(); + if (trim(ep).empty()) { + ilog("node_admin: admin-endpoint is empty; the admin API is disabled"); + return; + } + + pimpl->allow_remote = options.at("admin-allow-remote").as(); + // Same option map the chain plugin reads. Guarded with count(): node_admin must not make + // a snapshot option mandatory for nodes that start without one. + pimpl->snapshot_replay_from = + options.count("replay-from-snapshot") && options.at("replay-from-snapshot").as(); + pimpl->snapshot_configured = + (options.count("snapshot-auto-latest") && options.at("snapshot-auto-latest").as()) || + (options.count("snapshot") && !trim(options.at("snapshot").as()).empty()); + // A zero TTL would make every plan unusable before it is returned; clamp like the + // webserver limits do. + pimpl->plan_ttl_sec = std::max(1, options.at("admin-plan-ttl").as()); + + // Tokens are mandatory: an admin port without authentication is never a valid state. + FC_ASSERT(options.count("admin-token"), "node_admin: admin-endpoint is set but no admin-token is configured"); + for (const std::string &spec : options.at("admin-token").as>()) { + pimpl->tokens.push_back(parse_token_spec(spec)); + } + FC_ASSERT(!pimpl->tokens.empty(), "node_admin: admin-endpoint is set but no admin-token is configured"); + + const std::string audit = trim(options.at("admin-audit-log").as()); + if (audit.empty()) { + bfs::path p = appbase::app().config_path(); + p = p.parent_path() / "node-admin-audit.log"; + pimpl->audit_path = p.string(); + } else { + pimpl->audit_path = audit; + } + + if (options.count("admin-log-file")) { + for (const std::string &spec : options.at("admin-log-file").as>()) { + const std::size_t eq = spec.find('='); + std::string name; + std::string path; + if (eq == std::string::npos) { + path = trim(spec); + name = bfs::path(path).filename().string(); + } else { + name = trim(spec.substr(0, eq)); + path = trim(spec.substr(eq + 1)); + } + FC_ASSERT(!name.empty() && !path.empty(), + "admin-log-file: expected 'name=path' or 'path', got '${s}'", ("s", spec)); + pimpl->log_files[name] = path; + } + } + + // Resolve and validate the endpoint. The listener is loopback-only unless the operator + // says otherwise: a bearer token on an open port is a different threat model. + std::vector eps = appbase::app().resolve_string_to_ip_endpoints(ep); + FC_ASSERT(!eps.empty(), "admin-endpoint '${e}' did not resolve", ("e", ep)); + const tcp::endpoint chosen = eps.front(); + if (!chosen.address().is_loopback() && !pimpl->allow_remote) { + FC_ASSERT(false, + "admin-endpoint '${e}' is not a loopback address; set admin-allow-remote = true " + "if this is deliberate", ("e", ep)); + } + pimpl->endpoint.reset(new tcp::endpoint(chosen)); + + ilog("node_admin: configured on ${host}:${port}, tokens=${n}, audit=${audit}", + ("host", chosen.address().to_string())("port", chosen.port()) + ("n", pimpl->tokens.size())("audit", pimpl->audit_path)); + if (!chosen.address().is_loopback()) { + wlog("node_admin: listening on a NON-loopback address ${host} — the admin API is exposed to the network", + ("host", chosen.address().to_string())); + } +} + +void node_admin_plugin::plugin_startup() { + if (!pimpl || !pimpl->endpoint) return; + pimpl->start(); +} + +void node_admin_plugin::plugin_shutdown() { + if (pimpl) pimpl->stop(); +} + +void node_admin_plugin::impl::start() { + server.clear_access_channels(websocketpp::log::alevel::all); + server.clear_error_channels(websocketpp::log::elevel::all); + server.init_asio(&ios); + server.set_reuse_addr(true); + server.set_max_http_body_size(kMaxBodyBytes); + + server.set_tcp_pre_init_handler([this](connection_hdl hdl) { + for (auto it = connections.begin(); it != connections.end(); ) { + if (it->expired()) it = connections.erase(it); else ++it; + } + if (connections.size() >= 64) { + boost::system::error_code ec; + server.get_con_from_hdl(hdl)->get_socket().close(ec); + return; + } + connections.insert(hdl); + }); + server.set_http_handler([this](connection_hdl hdl) { this->handle_http(hdl); }); + + server.listen(*endpoint); + server.start_accept(); + listening = true; + started_at = fc::time_point::now(); + + thread = std::thread([this]() { + try { + ios.run(); + } catch (const std::exception &e) { + elog("node_admin: listener stopped with an error: ${e}", ("e", e.what())); + } catch (...) { + elog("node_admin: listener stopped with an unknown error"); + } + }); + ilog("node_admin: listening on ${host}:${port}", + ("host", endpoint->address().to_string())("port", endpoint->port())); +} + +void node_admin_plugin::impl::stop() { + if (!listening) return; + listening = false; + try { + server.stop_listening(); + } catch (...) { + } + ios.stop(); + if (thread.joinable()) thread.join(); + ilog("node_admin: listener stopped"); +} + +// ── auth / rate limit ───────────────────────────────────────────────────────────────────── +const token_info *node_admin_plugin::impl::authenticate(const std::string &header, + std::string &why) const { + static const std::string prefix = "bearer "; + const std::string h = trim(header); + if (h.size() <= prefix.size() || to_lower(h.substr(0, prefix.size())) != prefix) { + why = "missing or malformed Authorization header (expected 'Bearer ')"; + return nullptr; + } + const std::string token = trim(h.substr(prefix.size())); + if (token.empty()) { + why = "empty bearer token"; + return nullptr; + } + const std::string hash = fc::sha256::hash(token).str(); + const uint64_t now = static_cast(fc::time_point::now().sec_since_epoch()); + for (const token_info &t : tokens) { + if (!ct_equal(hash, t.hash_hex)) continue; + if (t.expires != 0 && now > t.expires) { + why = "token expired"; + return nullptr; + } + return &t; + } + why = "unknown token"; + return nullptr; +} + +bool node_admin_plugin::impl::rate_ok(const std::string &label) { + const uint32_t now = static_cast(fc::time_point::now().sec_since_epoch()); + std::lock_guard lock(rate_mutex); + auto &slot = rate[label]; + if (slot.first != now) { + slot.first = now; + slot.second = 0; + } + if (++slot.second > (label == "__ingress__" ? 100u : kRatePerSecond)) return false; + return true; +} + +// ── HTTP entry ──────────────────────────────────────────────────────────────────────────── +void node_admin_plugin::impl::handle_http(connection_hdl hdl) { + websocket_server_type::connection_ptr con; + try { + con = server.get_con_from_hdl(hdl); + } catch (...) { + return; + } + if (!con) return; + con->defer_http_response(); + + if (!rate_ok("__ingress__")) { + respond(con, websocketpp::http::status_code::too_many_requests, "rate limit exceeded"); + return; // Do not let rejected traffic rotate authenticated audit history away. + } + const fc::time_point started = fc::time_point::now(); + const std::string rid = std::to_string(static_cast(++request_seq)); + // The real peer, not X-Forwarded-For: this listener is never behind a proxy by design, so + // that header would only let a client choose its own audit attribution. + std::string remote; + try { + remote = con->get_remote_endpoint(); + } catch (...) { + } + + auto finish = [&](websocketpp::http::status_code::value status, const std::string &body) { + respond(con, status, body); + }; + + // ── built-in UI: one static page, no data and no secrets in it ──────────────────────── + // The page cannot carry a bearer token on navigation, and it does not need one: it is + // the same bytes for everybody and all real calls go back to this listener as POSTs + // with the operator-supplied token. Serving it unauthenticated therefore leaks nothing. + const std::string req_method = con->get_request().get_method(); + std::string path = con->get_request().get_uri(); + { + const std::size_t qpos = path.find('?'); + if (qpos != std::string::npos) path = path.substr(0, qpos); + } + if (req_method == "GET" || req_method == "HEAD") { + if (path == "/ui" || path == "/ui/") { + try { + con->append_header("X-Content-Type-Options", "nosniff"); + con->append_header("X-Frame-Options", "DENY"); + con->append_header("Referrer-Policy", "no-referrer"); + con->append_header("Content-Security-Policy", + "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; " + "connect-src 'self'; base-uri 'none'; form-action 'none'; " + "frame-ancestors 'none'"); + } catch (const std::exception &e) { + wlog("node_admin: failed to set UI headers: ${e}", ("e", e.what())); + } + respond(con, websocketpp::http::status_code::ok, + req_method == "HEAD" ? std::string() : std::string(ui_page()), + "text/html; charset=utf-8"); + audit(rid, "-", req_method + " " + path, fc::variant(), "ui", 0, + elapsed_ms(started), remote); + return; + } + + // Snapshot download: the newest file by default, or a named file from list_snapshots(). + // Unlike /ui/ this carries node state, so it is authenticated like every JSON-RPC call. + static const std::string snap_prefix = "/snapshot/"; + if (path == "/snapshot" || path.compare(0, snap_prefix.size(), snap_prefix) == 0) { + std::string why; + const token_info *token = + authenticate(con->get_request().get_header("Authorization"), why); + if (!token) { + respond(con, websocketpp::http::status_code::unauthorized, + to_json(rpc_err(fc::variant(), rpc_error{-32001, "unauthorized", why, ""}))); + audit(rid, "-", req_method + " " + path, fc::variant(), "unauthorized", + -32001, elapsed_ms(started), remote); + return; + } + if (!rate_ok(token->label)) { + respond(con, websocketpp::http::status_code::too_many_requests, + to_json(rpc_err(fc::variant(), + rpc_error{-32005, "rate limit exceeded", "", ""}))); + audit(rid, token->label, req_method + " " + path, fc::variant(), "rate_limited", + -32005, elapsed_ms(started), remote); + return; + } + if (token->scopes.find("read") == token->scopes.end()) { + respond(con, websocketpp::http::status_code::forbidden, + to_json(rpc_err(fc::variant(), + rpc_error{-32002, "forbidden", + "token '" + token->label + "' has no 'read' scope", + ""}))); + audit(rid, token->label, req_method + " " + path, fc::variant(), "forbidden", + -32002, elapsed_ms(started), remote); + return; + } + + std::string file; + std::string filename; + try { + auto &snap = snapshot_started(); + if (path == "/snapshot" || path == "/snapshot/") { + respond(con, websocketpp::http::status_code::bad_request, + to_json(rpc_err(fc::variant(), + rpc_error{-32602, "invalid params", + "use /snapshot/latest or /snapshot/", + "names come from admin.snapshot.status"}))); + audit(rid, token->label, req_method + " " + path, fc::variant(), + "bad_request", -32602, elapsed_ms(started), remote); + return; + } + const std::string name = path.substr(snap_prefix.size()); + if (name == "latest") { + file = snap.latest_snapshot_path(); + } else if (is_plain_snapshot_name(name)) { + file = (bfs::path(snap.snapshot_directory()) / name).string(); + } else { + respond(con, websocketpp::http::status_code::bad_request, + to_json(rpc_err(fc::variant(), + rpc_error{-32602, "invalid snapshot name", name, + "expected a file name ending in .vizjson"}))); + audit(rid, token->label, req_method + " " + path, fc::variant(), + "bad_request", -32602, elapsed_ms(started), remote); + return; + } + if (!file.empty()) filename = bfs::path(file).filename().string(); + } catch (const rpc_error &e) { + respond(con, websocketpp::http::status_code::service_unavailable, + to_json(rpc_err(fc::variant(), e))); + audit(rid, token->label, req_method + " " + path, fc::variant(), "error", + e.code, elapsed_ms(started), remote); + return; + } catch (const std::exception &e) { + respond(con, websocketpp::http::status_code::internal_server_error, + to_json(rpc_err(fc::variant(), + rpc_error{-32099, "internal error", e.what(), ""}))); + audit(rid, token->label, req_method + " " + path, fc::variant(), + "internal_error", -32099, elapsed_ms(started), remote); + return; + } + + boost::system::error_code ec; + if (file.empty() || !bfs::exists(file, ec) || ec || + !bfs::is_regular_file(file, ec) || ec) { + respond(con, websocketpp::http::status_code::not_found, + to_json(rpc_err(fc::variant(), + rpc_error{-32602, "snapshot not found", + file.empty() ? "no snapshots in the snapshot directory" : file, + "see admin.snapshot.status"}))); + audit(rid, token->label, req_method + " " + path, fc::variant(), "not_found", + -32602, elapsed_ms(started), remote); + return; + } + // Open once and measure that same descriptor: a concurrent pathname + // replacement must not mix one file's Content-Length with another body. + auto input = std::unique_ptr(new std::ifstream(file, std::ios::binary)); + input->seekg(0, std::ios::end); + const auto end = input->tellg(); + if (!*input || end < std::streampos(0)) { + respond(con, websocketpp::http::status_code::internal_server_error, + to_json(rpc_err(fc::variant(), rpc_error{-32099, "cannot open snapshot file", file, ""}))); + audit(rid, token->label, req_method + " " + path, fc::variant(), + "open_failed", -32099, elapsed_ms(started), remote); + return; + } + const uint64_t size = static_cast(end); + input->seekg(0, std::ios::beg); + + try { + con->append_header("X-Content-Type-Options", "nosniff"); + con->append_header("Content-Disposition", + "attachment; filename=\"" + filename + "\""); + } catch (const std::exception &e) { + wlog("node_admin: failed to set snapshot headers: ${e}", ("e", e.what())); + } + if (req_method == "HEAD") { + respond(con, websocketpp::http::status_code::ok, "", + "application/octet-stream", static_cast(size)); + } else { + con->set_status(websocketpp::http::status_code::ok); + auto response = con->get_response(); + response.set_version("HTTP/1.1"); + response.set_body(""); + response.replace_header("Content-Type", "application/octet-stream"); + response.replace_header("Content-Length", std::to_string(size)); + response.replace_header("Cache-Control", "no-store"); + response.replace_header("Connection", "close"); + auto transfer = std::make_shared( + ios, con, std::move(input), size, response.raw()); + transfer->start(); + } + audit(rid, token->label, req_method + " " + path, + fc::mutable_variant_object("file", filename)("bytes", static_cast(size)), + "snapshot_download", 0, elapsed_ms(started), remote); + return; + } + } + + if (con->get_request().get_method() != "POST") { + const fc::variant id; + finish(websocketpp::http::status_code::method_not_allowed, + to_json(rpc_err(id, rpc_error{-32600, "only POST is supported", "", ""}))); + audit(rid, "-", con->get_request().get_method(), fc::variant(), "method_not_allowed", + -32600, elapsed_ms(started), remote); + return; + } + + const std::string body = con->get_request_body(); + if (body.empty()) { + finish(websocketpp::http::status_code::bad_request, + to_json(rpc_err(fc::variant(), rpc_error{-32700, "empty request body", "", ""}))); + audit(rid, "-", "-", fc::variant(), "empty_body", -32700, elapsed_ms(started), remote); + return; + } + if (body.size() > kMaxBodyBytes) { + finish(websocketpp::http::status_code::request_entity_too_large, + to_json(rpc_err(fc::variant(), rpc_error{-32600, "request body too large", "", ""}))); + audit(rid, "-", "-", fc::variant(), "body_too_large", -32600, elapsed_ms(started), remote); + return; + } + + // ── auth first: parsing untrusted JSON before authentication is needless attack surface + std::string why; + const token_info *token = authenticate(con->get_request().get_header("Authorization"), why); + if (!token) { + finish(websocketpp::http::status_code::unauthorized, + to_json(rpc_err(fc::variant(), + rpc_error{-32001, "unauthorized", why, ""}))); + audit(rid, "-", "-", fc::variant(), "unauthorized", -32001, elapsed_ms(started), remote); + return; + } + if (!rate_ok(token->label)) { + finish(websocketpp::http::status_code::too_many_requests, + to_json(rpc_err(fc::variant(), + rpc_error{-32005, "rate limit exceeded", "", ""}))); + audit(rid, token->label, "-", fc::variant(), "rate_limited", -32005, elapsed_ms(started), remote); + return; + } + + // ── parse + fc::variant request; + try { + request = fc::json::from_string(body); + } catch (const fc::exception &e) { + finish(websocketpp::http::status_code::bad_request, + to_json(rpc_err(fc::variant(), + rpc_error{-32700, "parse error", e.to_string(), ""}))); + audit(rid, token->label, "-", fc::variant(), "parse_error", -32700, elapsed_ms(started), remote); + return; + } + if (!request.is_object()) { + finish(websocketpp::http::status_code::bad_request, + to_json(rpc_err(fc::variant(), + rpc_error{-32600, "invalid request", "expected a JSON object", ""}))); + audit(rid, token->label, "-", fc::variant(), "invalid_request", -32600, elapsed_ms(started), remote); + return; + } + + const fc::variant_object &obj = request.get_object(); + fc::variant id; + auto id_it = obj.find("id"); + if (id_it != obj.end()) id = id_it->value(); + + if (!obj.contains("method") || !obj["method"].is_string()) { + finish(websocketpp::http::status_code::bad_request, + to_json(rpc_err(id, rpc_error{-32600, "invalid request", "missing 'method'", ""}))); + audit(rid, token->label, "-", fc::variant(), "invalid_request", -32600, elapsed_ms(started), remote); + return; + } + const std::string method = obj["method"].as_string(); + fc::variant params; + auto p_it = obj.find("params"); + if (p_it != obj.end()) params = p_it->value(); + + // JSON-RPC notification (no id): MCP clients send `notifications/initialized` this way and + // expect 202 with no body. Answering a notification with a response is a protocol error. + if (method.compare(0, 14, "notifications/") == 0) { + finish(websocketpp::http::status_code::accepted, ""); + audit(rid, token->label, method, params, "notification", 0, elapsed_ms(started), remote); + return; + } + + try { + const fc::variant result = dispatch(method, params, *token); + finish(websocketpp::http::status_code::ok, to_json(rpc_ok(id, result))); + audit(rid, token->label, method, params, "ok", 0, elapsed_ms(started), remote); + } catch (const rpc_error &e) { + finish(websocketpp::http::status_code::ok, to_json(rpc_err(id, e))); + audit(rid, token->label, method, params, "error", e.code, elapsed_ms(started), remote); + } catch (const fc::exception &e) { + finish(websocketpp::http::status_code::ok, + to_json(rpc_err(id, rpc_error{-32099, "internal error", e.to_string(), ""}))); + audit(rid, token->label, method, params, "internal_error", -32099, elapsed_ms(started), remote); + } catch (const std::exception &e) { + finish(websocketpp::http::status_code::ok, + to_json(rpc_err(id, rpc_error{-32099, "internal error", e.what(), ""}))); + audit(rid, token->label, method, params, "internal_error", -32099, elapsed_ms(started), remote); + } +} + +// ── dispatch ───────────────────────────────────────────────────────────────────────────── +fc::variant node_admin_plugin::impl::rpc_ok(const fc::variant &id, const fc::variant &result) const { + fc::mutable_variant_object r; + r("jsonrpc", "2.0"); + r("id", id); + r("result", result); + return fc::variant(r); +} + +fc::variant node_admin_plugin::impl::rpc_err(const fc::variant &id, const rpc_error &e) const { + fc::mutable_variant_object err; + err("code", static_cast(e.code)); + err("message", e.message); + if (!e.reason.empty() || !e.hint.empty()) { + fc::mutable_variant_object data; + if (!e.reason.empty()) data("reason", e.reason); + if (!e.hint.empty()) data("hint", e.hint); + err("data", data); + } + fc::mutable_variant_object r; + r("jsonrpc", "2.0"); + r("id", id); + r("error", err); + return fc::variant(r); +} + +fc::variant node_admin_plugin::impl::dispatch(const std::string &method, const fc::variant ¶ms, + const token_info &token) { + // v1 is read-only; every method needs the `read` scope. Later stages add their own + // scopes (plugins/config/process/chain) on the same check. + if (token.scopes.find("read") == token.scopes.end()) { + throw rpc_error{-32002, "forbidden", "token '" + token.label + "' has no 'read' scope", ""}; + } + + // The UI asks who it is so it can hide actions the token cannot perform. Answer with the + // token's own label and scopes — never with the token or its hash. + if (method == "admin.auth.me") return h_auth_me(token); + if (method == "admin.ping") return h_ping(params); + if (method == "admin.node_info") return h_node_info(params); + if (method == "admin.diagnostics") return h_diagnostics(params); + if (method == "admin.plugins.list") return h_plugins_list(params); + if (method == "admin.plugins.status") return h_plugins_status(params); + if (method == "admin.config.get") return h_config_get(params); + // Chainbase introspection: which object types exist, how many rows each holds, and how + // much of the mapped file is used. Read-only; the check is the same view plus a verdict. + if (method == "admin.objects.list") return h_objects_list(params); + if (method == "admin.objects.check") return h_objects_check(params); + if (method == "admin.logs.list") return h_logs_list(params); + if (method == "admin.logs.tail") return h_logs_tail(params); + if (method == "admin.audit.tail") return h_audit_tail(params); + if (method == "admin.validators.active") return h_validators_active(params); + if (method == "admin.p2p.status") return h_p2p_status(params); + if (method == "admin.snapshot.status") return h_snapshot_status(params); + if (method == "admin.webserver.status") return h_webserver_status(params); + if (method == "admin.webserver.ips") return h_webserver_ips(params); + + // Mutating peer operations need `p2p` on top of `read`: a read-only token must not be able + // to change whom this node talks to. Both required scopes are named in the error. + if (method == "admin.p2p.add_node" || method == "admin.p2p.add_trusted" || + method == "admin.p2p.clear_banned") { + if (token.scopes.find("p2p") == token.scopes.end()) { + throw rpc_error{-32002, "forbidden", + "token '" + token.label + "' has no 'p2p' scope", + "peer operations need a token with scopes 'read,p2p'"}; + } + if (method == "admin.p2p.add_node") return h_p2p_add_node(params, false); + if (method == "admin.p2p.add_trusted") return h_p2p_add_node(params, true); + return h_p2p_clear_banned(params); + } + + // Snapshot creation and restore rewrite node state / disk, so they need `snapshot` + // on top of `read`. Listing and downloading stay under `read`. + if (method == "admin.snapshot.create" || method == "admin.snapshot.restore") { + if (token.scopes.find("snapshot") == token.scopes.end()) { + throw rpc_error{-32002, "forbidden", + "token '" + token.label + "' has no 'snapshot' scope", + "snapshot operations need a token with scopes 'read,snapshot'"}; + } + if (method == "admin.snapshot.create") return h_snapshot_create(params); + return h_snapshot_restore(params); + } + + // The webserver knobs (limits, IP lists, stats switch, reset/reload) change how the + // public API treats its clients, so they need the `webserver` scope. Reads stay under + // `read`. Changes are applied to the live plugin immediately and persisted to its + // admin state file; config.ini is only the startup seed. + if (method == "admin.webserver.limits" || method == "admin.webserver.stats" || + method == "admin.webserver.whitelist_add" || method == "admin.webserver.whitelist_remove" || + method == "admin.webserver.blacklist_add" || method == "admin.webserver.blacklist_remove" || + method == "admin.webserver.reset" || method == "admin.webserver.reload") { + if (token.scopes.find("webserver") == token.scopes.end()) { + throw rpc_error{-32002, "forbidden", + "token '" + token.label + "' has no 'webserver' scope", + "webserver operations need a token with scopes 'read,webserver'"}; + } + if (method == "admin.webserver.limits") return h_webserver_limits(params); + if (method == "admin.webserver.stats") return h_webserver_stats(params); + if (method == "admin.webserver.whitelist_add") return h_webserver_list_add(params, true); + if (method == "admin.webserver.whitelist_remove") return h_webserver_list_remove(params, true); + if (method == "admin.webserver.blacklist_add") return h_webserver_list_add(params, false); + if (method == "admin.webserver.blacklist_remove") return h_webserver_list_remove(params, false); + if (method == "admin.webserver.reset") return h_webserver_reset(params); + return h_webserver_reload(params); + } + + // Config.ini edits are two-phase: plan returns the exact diff plus a single-use, + // token-bound plan_id; apply re-validates against the current file and writes atomically + // with a backup. Reads (config.backups) stay under `read`. + if (method == "admin.config.plan" || method == "admin.config.apply" || + method == "admin.config.backups" || method == "admin.plugins.plan" || + method == "admin.plugins.apply") { + const bool cfg = method == "admin.config.plan" || method == "admin.config.apply"; + const bool plg = method == "admin.plugins.plan" || method == "admin.plugins.apply"; + if (cfg && token.scopes.find("config") == token.scopes.end()) { + throw rpc_error{-32002, "forbidden", + "token '" + token.label + "' has no 'config' scope", + "config edits need a token with scopes 'read,config'"}; + } + if (plg && token.scopes.find("plugins") == token.scopes.end()) { + throw rpc_error{-32002, "forbidden", + "token '" + token.label + "' has no 'plugins' scope", + "plugin edits need a token with scopes 'read,plugins'"}; + } + if (method == "admin.config.plan") return h_config_plan(params, token); + if (method == "admin.config.apply") return h_config_apply(params, token); + if (method == "admin.config.backups") return h_config_backups(params); + if (method == "admin.plugins.plan") return h_plugins_plan(params, token); + return h_plugins_apply(params, token); + } + + if (method == "admin.plan.cancel") return h_plan_cancel(params, token); + + if (method == "admin.restart" || method == "admin.objects.repair") { + if (token.scopes.find("process") == token.scopes.end()) { + throw rpc_error{-32002, "forbidden", + "token '" + token.label + "' has no 'process' scope", + "restart and state repair need a token with scopes 'read,process'"}; + } + if (method == "admin.objects.repair") return h_objects_repair(params); + return h_restart(params); + } + + // Validator chain operations are signed INSIDE validator_guard with its active keys + // (decision q#3051=D): node_admin never sees a key. Reads stay under `read`; plan/apply + // need `chain`, and that scope is usable only while a live guard holds an active key. + if (method == "admin.validator.list" || method == "admin.validator.status") { + return h_validator_list(params); + } + if (method == "admin.validator.plan" || method == "admin.validator.apply") { + if (token.scopes.find("chain") == token.scopes.end()) { + throw rpc_error{-32002, "forbidden", + "token '" + token.label + "' has no 'chain' scope", + "validator operations need a token with scopes 'read,chain'"}; + } + if (!guard_ready()) { + throw rpc_error{-32002, "forbidden", "validator-guard is not configured", + "chain operations are signed by validator_guard; configure the " + "plugin with validator-guard-validator = [name,signing_wif,active_wif]"}; + } + if (method == "admin.validator.plan") return h_validator_plan(params, token); + return h_validator_apply(params, token); + } + + // MCP Streamable HTTP on the same authenticated listener (decision q#3052=B: the MCP + // endpoint lives inside the plugin, not in a sidecar). Tools are 1:1 with the admin.* + // methods above, so the audit trail and scopes are shared. + if (method == "initialize") return h_mcp_initialize(params); + if (method == "ping") return h_mcp_ping(params); + if (method == "tools/list") return h_mcp_tools_list(params); + if (method == "tools/call") return h_mcp_tools_call(params, token); + + throw rpc_error{-32601, "method not found", method, ""}; +} + +// ── methods ────────────────────────────────────────────────────────────────────────────── +fc::mutable_variant_object node_admin_plugin::impl::make_node_info() const { + auto recovery_guard = chain.try_lock_recovery(); + if (!recovery_guard.owns_lock() || chain.is_recovering()) + throw rpc_error{-32004, "chain is recovering", "state is temporarily unavailable", "retry after restore"}; + graphene::chain::database &db = chain.db(); + return db.with_weak_read_lock([&]() { + const auto head_time = db.head_block_time(); + const auto now = fc::time_point::now().sec_since_epoch(); + const int64_t head_age = static_cast(now) - static_cast(head_time.sec_since_epoch()); + + fc::mutable_variant_object m; + m("server_time", now_iso()); + m("version", std::string(CHAIN_VERSION)); + m("hardfork_count", static_cast(CHAIN_NUM_HARDFORKS)); + m("chain_id", db.get_chain_id().str()); + m("head_block_num", static_cast(db.head_block_num())); + m("head_block_id", db.head_block_id().str()); + m("head_block_time", iso_utc(head_time)); + // An empty chain reports head time 1970, so the "age" would be 56 years and mean nothing. + m("head_age_sec", db.head_block_num() == 0 ? fc::variant() : fc::variant(head_age < 0 ? 0 : head_age)); + const auto &dgp = db.get_dynamic_global_properties(); + m("last_irreversible_block_num", static_cast(dgp.last_irreversible_block_num)); + // Emergency consensus is a chain-wide state voted in by validators. It used to be visible + // only in the p2p hello and in the log, never on the operator page. + m("emergency_consensus_active", dgp.emergency_consensus_active); + m("emergency_consensus_start_block", + static_cast(dgp.emergency_consensus_active ? dgp.emergency_consensus_start_block : 0)); + bool emergency_key = false; + if (auto *vp = appbase::app().find_plugin()) { + emergency_key = vp->is_emergency_key_configured(); + } + m("emergency_key_configured", emergency_key); + m("recovering", chain.is_recovering()); + m("uptime_sec", static_cast((fc::time_point::now() - started_at).count() / 1000000)); + return m; + }); +} + +fc::variant node_admin_plugin::impl::h_ping(const fc::variant &) { + fc::mutable_variant_object m; + m("ok", true); + m("server_time", now_iso()); + return fc::variant(m); +} + +// The UI cannot guess what a token may do: the same page is opened with a read-only token and +// with a full one. It asks here and hides the actions the token cannot perform. Only the label, +// the scopes and the expiry leave this method — never the token or its hash. +fc::variant node_admin_plugin::impl::h_auth_me(const token_info &token) { + fc::variants scopes; + for (const std::string &s : token.scopes) scopes.push_back(fc::variant(s)); + + fc::mutable_variant_object m; + m("label", token.label); + m("scopes", scopes); + m("expires", token.expires == 0 ? fc::variant() + : fc::variant(static_cast(token.expires))); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_node_info(const fc::variant &) { + return fc::variant(make_node_info()); +} + +fc::variant node_admin_plugin::impl::h_diagnostics(const fc::variant &) { + fc::mutable_variant_object m = make_node_info(); + + m("data_dir", data_dir()); + m("config_path", config_path()); + + // peers: read through the p2p thread, because the peer map is not thread-safe + bool p2p_loaded = false; + uint32_t peers = 0; + if (auto *p2p = appbase::app().find_plugin()) { + p2p_loaded = true; + if (p2p->get_state() == appbase::abstract_plugin::started) { + peers = p2p->get_connections_count_sync(); + } + } + m("p2p_loaded", p2p_loaded); + m("peers", static_cast(peers)); + + // configured plugins: appbase keeps its registry private, so config.ini is what we can see. + // `plugins_observed` counts the names whose lifecycle state is read through a typed + // find_plugin(); the rest are known to be loaded by construction (appbase aborts startup + // on an unknown plugin name) but their state cannot be read from here. + bool cfg_exists = false; + const std::vector cfg = parse_config_file(config_path(), &cfg_exists); + std::set configured; + for (const auto &e : cfg) { + if (e.key != "plugin" || e.value.empty()) continue; + for (const std::string &name : util::plugin_names_in_value(e.value)) configured.insert(name); + } + std::size_t observed_count = 0; + for (const auto &name : configured) { + if (observe_plugin(name) != nullptr) ++observed_count; + } + m("plugins_configured", static_cast(configured.size())); + m("plugins_observed", static_cast(observed_count)); + + // shared memory file + boost::system::error_code ec; + // shared_memory.bin follows shared-file-dir, which the chain plugin resolves at startup. + const bfs::path shm = bfs::path(chain.get_state_dir()) / "shared_memory.bin"; + const uintmax_t shm_size = bfs::file_size(shm, ec); + if (!ec) m("shared_memory_bytes", static_cast(shm_size)); + +#ifndef _WIN32 + struct statvfs vfs; + if (::statvfs(data_dir().c_str(), &vfs) == 0) { + m("disk_total_bytes", + static_cast(static_cast(vfs.f_blocks) * vfs.f_frsize)); + m("disk_free_bytes", + static_cast(static_cast(vfs.f_bavail) * vfs.f_frsize)); + } + struct rusage usage; + if (::getrusage(RUSAGE_SELF, &usage) == 0) { + m("rss_peak_kb", static_cast(usage.ru_maxrss)); + } + double load[3] = {0, 0, 0}; + if (::getloadavg(load, 3) == 3) { + m("loadavg", fc::variants{fc::variant(load[0]), fc::variant(load[1]), fc::variant(load[2])}); + } +#endif + + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_plugins_list(const fc::variant &) { + bool exists = false; + const std::vector cfg = parse_config_file(config_path(), &exists); + + fc::variants plugins; + std::set seen; + for (const auto &e : cfg) { + if (e.key != "plugin" || e.value.empty()) continue; + // A line may name several plugins (`plugin = chain p2p`); appbase loads each of them, + // so each gets its own row. Keeping the line as one name made state, status_method and + // parameter grouping unreachable for every plugin on such a line. + for (const std::string &name : util::plugin_names_in_value(e.value)) { + if (!seen.insert(name).second) continue; + + const appbase::abstract_plugin *p = observe_plugin(name); + const char *state = "unknown"; + const char *source = "inferred"; + if (p) { + source = "observed"; + switch (p->get_state()) { + case appbase::abstract_plugin::registered: state = "registered"; break; + case appbase::abstract_plugin::initialized: state = "initialized"; break; + case appbase::abstract_plugin::started: state = "started"; break; + case appbase::abstract_plugin::stopped: state = "stopped"; break; + default: state = "unknown"; break; + } + } + fc::mutable_variant_object item; + item("name", name); + // A configured plugin is always loaded: appbase calls get_plugin(name) for every + // `plugin =` line and aborts startup when the name is unknown. + item("loaded", p ? p->get_state() != appbase::abstract_plugin::registered : true); + item("state", p ? fc::variant(std::string(state)) : fc::variant()); + item("state_source", std::string(source)); + // The UI needs both facts before it can draw a toggle: a plugin that cannot be disabled + // must not render as merely "off", and a plugin with no allow-listed keys must say so + // instead of showing an empty panel. + item("removable", !util::plugin_removal_forbidden(name)); + // Plugins that answer a status question of their own. Null means the plugin exposes + // no operator status: the panel should say so instead of drawing an empty drawer. + const char *status_method = nullptr; + if (name == "p2p") status_method = "admin.p2p.status"; + else if (name == "webserver") status_method = "admin.webserver.status"; + else if (name == "snapshot") status_method = "admin.snapshot.status"; + else if (name == "validator_guard") status_method = "admin.validator.list"; + item("status_method", status_method ? fc::variant(std::string(status_method)) : fc::variant()); + fc::variants params; + for (const util::config_spec &spec : util::config_allowlist()) { + if (util::config_key_group(spec.name) != name) continue; + fc::mutable_variant_object pitem; + pitem("key", spec.name); + params.push_back(fc::variant(pitem)); + } + item("params", params); + plugins.push_back(fc::variant(item)); + } + } + + // The catalogue the binary can load, so the panel can offer an enable action. It is the + // same set admin.plugins.plan validates `add` against; a name that is already configured + // still appears here, and the client subtracts the configured ones itself. + fc::variants known; + for (const std::string &name : util::known_plugins()) known.push_back(fc::variant(name)); + + fc::mutable_variant_object m; + m("config_path", config_path()); + m("config_exists", exists); + m("known", known); + m("note", "Entries come from config.ini. Appbase keeps its plugin registry private, so " + "lifecycle state is observed only for the plugin types this binary's node_admin " + "links (chain, p2p, snapshot, webserver, validator, validator_guard, node_admin); " + "for the others loaded=true is implied by startup (an unknown plugin name aborts " + "the node). status_method names the method that returns that plugin's own status."); + m("plugins", plugins); + return fc::variant(m); +} + +// A plugin's lifecycle state says whether it is running; it does not say what it is doing. +// This method answers the second question for every plugin that has a status API of its own, +// and says so plainly when there is none instead of returning an empty object. +fc::variant node_admin_plugin::impl::h_plugins_status(const fc::variant ¶ms) { + if (!params.is_object()) { + throw rpc_error{-32602, "invalid params", "expected {name: \"p2p\"}", ""}; + } + const std::string name = trim(param_str(params.get_object(), "name", "")); + if (name.empty()) { + throw rpc_error{-32602, "invalid params", "name is required", ""}; + } + + bool exists = false; + const std::vector cfg = parse_config_file(config_path(), &exists); + bool configured = false; + for (const cfg_entry &e : cfg) { + if (e.key == "plugin") { + const auto names = util::plugin_names_in_value(e.value); + if (std::find(names.begin(), names.end(), name) != names.end()) { configured = true; break; } + } + } + + const appbase::abstract_plugin *p = observe_plugin(name); + std::string state = "unobserved"; + if (p) { + switch (p->get_state()) { + case appbase::abstract_plugin::registered: state = "registered"; break; + case appbase::abstract_plugin::initialized: state = "initialized"; break; + case appbase::abstract_plugin::started: state = "started"; break; + case appbase::abstract_plugin::stopped: state = "stopped"; break; + default: state = "unknown"; break; + } + } + const bool started = p && p->get_state() == appbase::abstract_plugin::started; + + fc::mutable_variant_object m; + m("name", name); + m("configured", configured); + m("state", p ? fc::variant(state) : fc::variant()); + m("state_source", p ? std::string("observed") : std::string("unobserved")); + + // Only call into a plugin that is actually started: these handlers touch thread-owned + // state (peer map, connection counters) and would throw on a stopped plugin. + fc::variant detail; + std::string detail_method; + if (started) { + if (name == "p2p") { detail = h_p2p_status(params); detail_method = "admin.p2p.status"; } + else if (name == "webserver") { detail = h_webserver_status(params); detail_method = "admin.webserver.status"; } + else if (name == "snapshot") { detail = h_snapshot_status(params); detail_method = "admin.snapshot.status"; } + else if (name == "validator_guard") { detail = h_validator_list(params); detail_method = "admin.validator.list"; } + } + m("detail_available", !detail_method.empty()); + m("detail_method", detail_method.empty() ? fc::variant() : fc::variant(detail_method)); + if (!detail_method.empty()) m("detail", detail); + else if (!p) m("reason", "this binary does not link this plugin, so its lifecycle cannot be " + "observed; a configured plugin is loaded or startup would have aborted"); + else if (!started) m("reason", "the plugin is not running, so it has no status to report"); + else m("reason", "this plugin exposes no status of its own; only its lifecycle state is known"); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_config_get(const fc::variant &) { + bool exists = false; + const std::vector cfg = parse_config_file(config_path(), &exists); + + // Preserve order and duplicate keys (config.ini repeats `plugin = ...`), so this is an + // array of sections rather than a map. + fc::variants sections; + std::string current; + fc::variants current_entries; + bool have_section = false; + auto flush = [&]() { + fc::mutable_variant_object s; + s("name", current); + s("entries", current_entries); + sections.push_back(fc::variant(s)); + current_entries.clear(); + }; + for (const auto &e : cfg) { + if (!have_section || e.section != current) { + if (have_section) flush(); + current = e.section; + have_section = true; + } + fc::mutable_variant_object item; + item("key", e.key); + item("value", sensitive_key(e.key) ? std::string("***") : mask_text(e.value)); + current_entries.push_back(fc::variant(item)); + } + if (have_section) flush(); + + // The editable subset, with its current value: this is what the parameters panel renders. + // Keys are the same full addresses admin.config.plan accepts (`logger.default.level`), so the + // UI never has to know which keys are sectioned. + std::string raw_text; + std::string raw_why; + std::vector all; + if (read_text_file(config_path(), raw_text, raw_why)) all = split_lines(raw_text); + fc::variants allow; + for (const util::config_spec &spec : util::config_allowlist()) { + std::string value; + const bool found = allowed_config_value(all, spec.name, value); + // Report the value in the form the editor accepts, not as written on disk: config.ini + // may say `yes` or `1` where the panel renders a true/false select, and an input that + // does not match any option would silently show as blank. A value that fails + // normalization is returned raw with valid=false so the panel can flag the line. + bool valid = found; + if (found) { + std::string norm; + std::string why; + if (util::normalize_config_value(spec.name, value, norm, why)) value = norm; + else valid = false; + } + fc::mutable_variant_object a; + a("key", spec.name); + a("group", util::config_key_group(spec.name)); + a("section", spec.section); + a("type", spec.type == util::config_type::boolean ? "boolean" + : spec.type == util::config_type::log_level ? "log_level" + : "unsigned_int"); + a("value", found ? fc::variant(value) : fc::variant()); + a("defaulted", !found); + a("valid", valid); + if (spec.type == util::config_type::unsigned_int) { + a("min", spec.min_value); + a("max", spec.max_value); + } + if (spec.type == util::config_type::log_level) { + fc::variants levels; + for (const std::string &l : util::log_level_values()) levels.push_back(fc::variant(l)); + a("values", levels); + } + a("needs_restart", spec.needs_restart); + allow.push_back(fc::variant(a)); + } + + fc::mutable_variant_object m; + m("path", config_path()); + m("exists", exists); + m("sections", sections); + m("allow", allow); + return fc::variant(m); +} + +// Which logs an operator can tail. A file that is configured but not there yet is still listed: +// "the appender has not written anything" is a different fact from "nobody configured it". +fc::variant node_admin_plugin::impl::h_logs_list(const fc::variant &) { + fc::variants arr; + for (const auto &kv : log_files) { + boost::system::error_code ec; + const uintmax_t size = bfs::file_size(kv.second, ec); + fc::mutable_variant_object m; + m("name", kv.first); + m("path", kv.second); + m("exists", !ec); + m("size", ec ? fc::variant() : fc::variant(static_cast(size))); + arr.push_back(fc::variant(m)); + } + + fc::mutable_variant_object m; + m("logs", arr); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_logs_tail(const fc::variant ¶ms) { + if (!params.is_object()) { + throw rpc_error{-32602, "invalid params", "expected an object {name, bytes?, lines?}", ""}; + } + const fc::variant_object &o = params.get_object(); + const std::string name = param_str(o, "name", ""); + if (name.empty()) { + throw rpc_error{-32602, "invalid params", "missing 'name'", "known names: the admin-log-file entries"}; + } + auto it = log_files.find(name); + if (it == log_files.end()) { + std::string known; + for (const auto &kv : log_files) { + if (!known.empty()) known += ", "; + known += kv.first; + } + throw rpc_error{-32004, "unknown log", "no admin-log-file entry named '" + name + "'", + known.empty() ? "no admin-log-file entries are configured" : "known: " + known}; + } + + const int64_t bytes = std::max(1, std::min( + param_int(o, "bytes", 16 * 1024), static_cast(kMaxLogBytes))); + const int64_t lines = std::max(1, std::min( + param_int(o, "lines", 200), static_cast(kMaxLines))); + + tail_result t = tail_file(it->second, static_cast(bytes), static_cast(lines)); + if (!t.ok) { + throw rpc_error{-32004, "cannot read log", t.error, it->second}; + } + + fc::variants out; + for (const std::string &line : t.lines) out.push_back(fc::variant(mask_text(line))); + + fc::mutable_variant_object m; + m("name", name); + m("path", it->second); + m("size", static_cast(t.size)); + m("bytes_returned", static_cast(t.bytes_returned)); + m("lines", out); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_audit_tail(const fc::variant ¶ms) { + const fc::variant_object empty; + const fc::variant_object &o = params.is_object() ? params.get_object() : empty; + const int64_t lines = std::max(1, std::min( + param_int(o, "lines", 100), static_cast(kMaxLines))); + + tail_result t = tail_file(audit_path, kMaxLogBytes, static_cast(lines)); + if (!t.ok) { + throw rpc_error{-32004, "cannot read audit log", t.error, audit_path}; + } + fc::variants out; + for (const std::string &line : t.lines) out.push_back(fc::variant(line)); + + fc::mutable_variant_object m; + m("path", audit_path); + m("size", static_cast(t.size)); + m("lines", out); + return fc::variant(m); +} + +// ── P2P operator methods ───────────────────────────────────────────────────────────────── +graphene::plugins::p2p::p2p_plugin &node_admin_plugin::impl::p2p_started() const { + auto *p2p = appbase::app().find_plugin(); + if (p2p == nullptr || p2p->get_state() != appbase::abstract_plugin::started) { + throw rpc_error{-32004, "p2p unavailable", + "the p2p plugin is not loaded or not started", + "peer administration needs 'plugin = p2p' in config.ini"}; + } + return *p2p; +} + +// Mirrors what the node writes to its own log every minute (log_node_status) and every five +// minutes (log_peer_stats): the same facts, on demand, for an operator or agent. +fc::variant node_admin_plugin::impl::h_p2p_status(const fc::variant &) { + const graphene::network::dlt_peer_report r = p2p_started().get_peer_report_sync(); + + fc::mutable_variant_object m; + m("as_of", now_iso()); + m("uptime_sec", static_cast(r.uptime_sec)); + m("node_status", r.node_status); + m("fork_status", r.fork_status); + m("paused", r.paused); + m("isolated_peers", r.isolated_peers); + m("catching_up", r.catching_up); + m("head_block_num", static_cast(r.our_head)); + m("lib_block_num", static_cast(r.our_lib)); + m("dlt_earliest", static_cast(r.dlt_earliest)); + m("dlt_latest", static_cast(r.dlt_latest)); + m("connections", static_cast(r.connection_count)); + m("active", static_cast(r.active_count)); + m("max_connections", static_cast(r.max_connections)); + m("known_peers", static_cast(r.known_peers)); + m("trusted_peers", static_cast(r.trusted_peers)); + m("banned_peers", static_cast(r.banned_peers)); + m("best_peer_head", static_cast(r.best_peer_head)); + // our head minus the best peer head: negative means we are behind the network + m("best_peer_head_lag", r.best_peer_head_lag); + m("peers_ahead", static_cast(r.peers_ahead)); + + fc::variants peers; + for (const graphene::network::dlt_peer_report_entry &p : r.peers) { + fc::mutable_variant_object e; + e("endpoint", p.endpoint); + e("node_id", p.node_id); + e("lifecycle", p.lifecycle); + e("node_status", p.node_status); + e("fork_status", p.fork_status); + e("incoming", p.incoming); + e("trusted", p.trusted); + e("exchange_enabled", p.exchange_enabled); + e("fork_alignment", p.fork_alignment); + e("connected_sec", static_cast(p.connected_sec)); + e("peer_head", static_cast(p.peer_head)); + e("peer_lib", static_cast(p.peer_lib)); + e("peer_dlt_earliest", static_cast(p.peer_dlt_earliest)); + e("peer_dlt_latest", static_cast(p.peer_dlt_latest)); + e("head_lag", p.head_lag); + e("spam_strikes", static_cast(p.spam_strikes)); + e("ban_reason", p.ban_reason); + e("ban_duration_sec", static_cast(p.ban_duration_sec)); + e("reconnect_backoff_sec", static_cast(p.reconnect_backoff_sec)); + e("next_reconnect_in_sec", static_cast(p.next_reconnect_in_sec)); + peers.push_back(fc::variant(e)); + } + m("peers", peers); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_p2p_add_node(const fc::variant ¶ms, bool trusted) { + if (!params.is_object()) { + throw rpc_error{-32602, "invalid params", "expected an object {endpoint}", + "endpoint is IPv4:port, e.g. 1.2.3.4:1776"}; + } + const fc::variant_object &o = params.get_object(); + const std::string text = trim(param_str(o, "endpoint", "")); + if (text.empty()) { + throw rpc_error{-32602, "invalid params", "missing 'endpoint'", + "endpoint is IPv4:port, e.g. 1.2.3.4:1776"}; + } + + fc::ip::endpoint ep; + try { + ep = fc::ip::endpoint::from_string(text); + } catch (const fc::exception &) { + throw rpc_error{-32602, "invalid endpoint", text, + "expected IPv4:port, e.g. 1.2.3.4:1776"}; + } + if (ep.port() == 0) { + throw rpc_error{-32602, "invalid endpoint", text, "port must be 1..65535"}; + } + + // Serialized onto the p2p thread: the peer maps are owned there. + p2p_started().add_peer_sync(ep, trusted); + + fc::mutable_variant_object m; + m("endpoint", std::string(ep)); + m("trusted", trusted); + m("status", "added"); + m("note", trusted + ? "trusted peers are never soft-banned and are kept through known-peer GC; the trusted " + "set lives in memory and is not persisted across restarts" + : "the peer is dialed now; use admin.p2p.add_trusted to exempt it from soft bans"); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_p2p_clear_banned(const fc::variant &) { + const uint32_t cleared = p2p_started().clear_banned_peers_sync(); + fc::mutable_variant_object m; + m("cleared", static_cast(cleared)); + m("status", "ok"); + return fc::variant(m); +} + +// ── snapshots ──────────────────────────────────────────────────────────────────────────── +graphene::plugins::snapshot::snapshot_plugin &node_admin_plugin::impl::snapshot_started() const { + auto *snap = appbase::app().find_plugin(); + if (!snap) { + throw rpc_error{-32004, "snapshot unavailable", + "the snapshot plugin is not loaded", + "add 'plugin = snapshot' to config.ini"}; + } + return *snap; +} + +fc::variant node_admin_plugin::impl::snapshot_list_variant() const { + auto &snap = snapshot_started(); + const std::string latest_path = snap.latest_snapshot_path(); + fc::variants files; + for (const auto &f : snap.list_snapshots()) { + fc::mutable_variant_object m; + m("name", f.name); + m("path", f.path); + m("block_num", static_cast(f.block_num)); + m("size_bytes", static_cast(f.size_bytes)); + m("modified", static_cast(f.modified)); + m("modified_iso", iso_utc(fc::time_point_sec(f.modified))); + m("latest", !latest_path.empty() && f.path == latest_path); + files.push_back(fc::variant(m)); + } + return fc::variant(files); +} + +fc::variant node_admin_plugin::impl::h_snapshot_status(const fc::variant ¶ms) { + const fc::variant_object empty; + const fc::variant_object &o = params.is_object() ? params.get_object() : empty; + // A node that has been running for a year accumulates hundreds of snapshots; the page shows + // the newest page and asks for the next one on demand. + const int64_t offset = std::max(0, param_int(o, "offset", 0)); + const int64_t limit = std::max(1, std::min( + param_int(o, "limit", 20), static_cast(kMaxSnapshotPage))); + // Plain offset paging on a list that changes under the reader repeats or skips rows: a + // snapshot written between two pages shifts every index below it by one, and the retention + // sweep removes from the tail. A client that sends the name of the last row it already has + // gets the page that follows THAT row ("after"); offset stays the fallback for an anchor that + // has already been pruned, and the reply says so instead of silently serving a shifted page. + const std::string after = param_str(o, "after", ""); + + auto &snap = snapshot_started(); + bool recovering = chain.is_recovering(); + fc::variant head; + { + auto recovery_guard = chain.try_lock_recovery(); + recovering = recovering || !recovery_guard.owns_lock() || chain.is_recovering(); + if (!recovering) + head = chain.db().with_weak_read_lock([&]() { + return fc::variant(static_cast(chain.db().head_block_num())); + }); + } + const fc::variant all_files = snapshot_list_variant(); + const fc::variants &files = all_files.get_array(); + const std::string latest_path = snap.latest_snapshot_path(); + + fc::variants page; + std::size_t begin = std::min(static_cast(offset), files.size()); + bool after_found = false; + if (!after.empty()) { + for (std::size_t i = 0; i < files.size(); ++i) { + if (files[i].get_object()["name"].as_string() == after) { + begin = i + 1; + after_found = true; + break; + } + } + } + const std::size_t end = std::min( + begin + static_cast(limit), files.size()); + for (std::size_t i = begin; i < end; ++i) page.push_back(files[i]); + + fc::mutable_variant_object m; + m("as_of", now_iso()); + m("dir", snap.snapshot_directory()); + // While a restore runs the database is closed or being rebuilt; reading head there + // would either block or report a half-built value, so it is null during that window. + m("head_block_num", head); + m("snapshot_count", static_cast(files.size())); + m("snapshot_total", static_cast(files.size())); + m("offset", offset); + m("limit", limit); + m("after", after.empty() ? fc::variant() : fc::variant(after)); + m("after_found", after_found); + m("has_more", static_cast(end) < static_cast(files.size())); + m("in_progress", snap.is_snapshot_in_progress()); + m("restoring", snap.is_restoring()); + m("recovering", recovering); + m("latest_path", latest_path); + m("latest_name", latest_path.empty() + ? fc::variant() + : fc::variant(bfs::path(latest_path).filename().string())); + m("snapshots", page); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_snapshot_create(const fc::variant &) { + auto &snap = snapshot_started(); + std::string path; + const std::string status = snap.request_snapshot_creation(path); + + if (status == "empty") { + throw rpc_error{-32000, "no state", "node head is 0: nothing to snapshot", + "load a snapshot or wait for the first block"}; + } + if (status == "unavailable") { + throw rpc_error{-32004, "snapshot unavailable", "snapshot plugin is not initialized", ""}; + } + + fc::mutable_variant_object m; + m("status", status); + m("path", path); + if (status == "exists") { + m("note", "a snapshot for the current head block already exists; nothing was written"); + } else if (status == "busy") { + m("note", "another snapshot creation or restore is running; retry when it finishes"); + } else { + m("note", "snapshot creation runs on the node's snapshot thread; poll " + "admin.snapshot.status until in_progress is false"); + } + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_snapshot_restore(const fc::variant ¶ms) { + if (!params.is_object() && !params.is_null()) { + throw rpc_error{-32602, "invalid params", "expected an object {name|path|latest}", ""}; + } + const fc::variant_object empty; + const fc::variant_object &o = params.is_object() ? params.get_object() : empty; + + const std::string name = trim(param_str(o, "name", "")); + const std::string given_path = trim(param_str(o, "path", "")); + bool latest = true; + auto l_it = o.find("latest"); + if (l_it != o.end() && !l_it->value().is_null()) { + if (!l_it->value().is_bool()) { + throw rpc_error{-32602, "invalid params", "'latest' must be a boolean", ""}; + } + latest = l_it->value().as_bool(); + } + + auto &snap = snapshot_started(); + const bfs::path dir(snap.snapshot_directory()); + std::string target; + + if (!given_path.empty()) { + const bfs::path p(given_path); + boost::system::error_code ec; + if (!bfs::exists(p, ec) || ec || !bfs::is_regular_file(p, ec) || ec) { + throw rpc_error{-32602, "snapshot not found", given_path, "see admin.snapshot.status"}; + } + // An absolute path is accepted only if it sits directly in the snapshot directory: + // the admin API must not import arbitrary files from the node's filesystem. + try { + const bfs::path canon_p = bfs::canonical(p); + const bfs::path canon_dir = bfs::canonical(dir); + if (canon_p.parent_path() != canon_dir) { + throw rpc_error{-32602, "path outside snapshot directory", given_path, + "snapshot files live in " + dir.string()}; + } + target = canon_p.string(); + } catch (const rpc_error &) { + throw; + } catch (const std::exception &e) { + throw rpc_error{-32602, "cannot resolve snapshot path", given_path, e.what()}; + } + } else if (!name.empty()) { + if (!is_plain_snapshot_name(name)) { + throw rpc_error{-32602, "invalid snapshot name", name, + "expected a file name ending in .vizjson (see admin.snapshot.status)"}; + } + target = (dir / name).string(); + boost::system::error_code ec; + if (!bfs::exists(target, ec) || ec) { + throw rpc_error{-32602, "snapshot not found", name, "see admin.snapshot.status"}; + } + } else if (!latest) { + throw rpc_error{-32602, "invalid params", "provide 'name'/'path' or set 'latest': true", ""}; + } + + std::string error; + if (!snap.restore_snapshot_now(target, error)) { + throw rpc_error{-32000, "restore not started", error, "see admin.snapshot.status"}; + } + + const std::string resolved = target.empty() ? snap.latest_snapshot_path() : target; + int64_t block = 0; + for (const auto &f : snap.list_snapshots()) { + if (f.path == resolved) { block = static_cast(f.block_num); break; } + } + + fc::mutable_variant_object m; + m("status", "started"); + m("snapshot", resolved); + m("snapshot_name", resolved.empty() ? std::string() : bfs::path(resolved).filename().string()); + m("block_num", block); + m("process_restarted", false); + m("note", "in-process restore: P2P is paused, shared memory is wiped, the snapshot is " + "imported, dlt_block_log is replayed and P2P resumes. The OS process and its " + "uptime are unchanged. Progress: admin.snapshot.status (restoring)."); + return fc::variant(m); +} + +// ── webserver operator methods ─────────────────────────────────────────────────────────── +// The webserver plugin keeps its limits and IP lists in live state: every change below is +// visible to the very next request, without a restart and without re-reading config.ini. +// config.ini seeds the state at startup; changes are persisted to the plugin's admin state +// file (/webserver-admin.json by default) so they survive a restart. +namespace ws = graphene::plugins::webserver; + +graphene::plugins::webserver::webserver_plugin &node_admin_plugin::impl::webserver_started() const { + auto *plugin = appbase::app().find_plugin(); + if (plugin == nullptr || plugin->get_state() != appbase::abstract_plugin::started) { + throw rpc_error{-32004, "webserver unavailable", + "the webserver plugin is not loaded or not started", + "webserver administration needs 'plugin = webserver' in config.ini"}; + } + return *plugin; +} + +fc::variant node_admin_plugin::impl::h_webserver_status(const fc::variant &) { + const ws::webserver_admin_state st = webserver_started().admin_state(0); + + fc::mutable_variant_object limits; + limits("enabled", st.limits.enabled); + limits("per_minute", st.limits.per_minute); + limits("per_hour", st.limits.per_hour); + limits("per_day", st.limits.per_day); + + fc::variants wl, bl; + for (const auto &ip : st.whitelist) wl.push_back(fc::variant(ip)); + for (const auto &ip : st.blacklist) bl.push_back(fc::variant(ip)); + + fc::mutable_variant_object m; + m("as_of", now_iso()); + m("stats_enabled", st.stats_enabled); + m("limits", limits); + m("total_requests", static_cast(st.total_requests)); + m("blocked_requests", static_cast(st.blocked_requests)); + m("limited_requests", static_cast(st.limited_requests)); + m("tracked_ips", static_cast(st.tracked_ips)); + m("whitelist", wl); + m("blacklist", bl); + m("note", "limits are off unless stats are enabled; the blacklist is enforced even " + "when stats are off. Changes apply to the next request."); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_webserver_ips(const fc::variant ¶ms) { + const fc::variant_object empty; + const fc::variant_object &o = params.is_object() ? params.get_object() : empty; + const int64_t limit = std::max(1, std::min(param_int(o, "limit", 200), 5000)); + + const ws::webserver_admin_state st = + webserver_started().admin_state(static_cast(limit)); + + fc::variants ips; + for (const auto &s : st.ips) { + fc::mutable_variant_object r; + r("ip", s.ip); + r("total", static_cast(s.total)); + r("minute", static_cast(s.minute)); + r("hour", static_cast(s.hour)); + r("day", static_cast(s.day)); + r("blocked", static_cast(s.blocked)); + r("limited", static_cast(s.limited)); + r("last_seen", static_cast(s.last_seen)); + r("last_seen_iso", s.last_seen ? fc::variant(unixtime_iso(s.last_seen)) : fc::variant()); + r("whitelisted", s.whitelisted); + r("blacklisted", s.blacklisted); + ips.push_back(fc::variant(r)); + } + + fc::mutable_variant_object m; + m("as_of", now_iso()); + m("tracked_ips", static_cast(st.tracked_ips)); + m("returned", static_cast(ips.size())); + m("ips", ips); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_webserver_limits(const fc::variant ¶ms) { + if (!params.is_object()) { + throw rpc_error{-32602, "invalid params", "expected an object", + "{enabled, per_minute, per_hour, per_day}; omitted fields keep their value"}; + } + auto &plugin = webserver_started(); + const ws::webserver_admin_state st = plugin.admin_state(0); + const fc::variant_object &o = params.get_object(); + + const bool enabled = o.contains("enabled") ? o["enabled"].as_bool() : st.limits.enabled; + const uint32_t per_minute = static_cast( + std::max(1, param_int(o, "per_minute", st.limits.per_minute))); + const uint32_t per_hour = static_cast( + std::max(1, param_int(o, "per_hour", st.limits.per_hour))); + const uint32_t per_day = static_cast( + std::max(1, param_int(o, "per_day", st.limits.per_day))); + + std::string error; + if (!plugin.set_limits(enabled, per_minute, per_hour, per_day, error)) { + throw rpc_error{-32000, "limits not applied", error, ""}; + } + + const ws::webserver_admin_state after = plugin.admin_state(0); + fc::mutable_variant_object limits; + limits("enabled", after.limits.enabled); + limits("per_minute", after.limits.per_minute); + limits("per_hour", after.limits.per_hour); + limits("per_day", after.limits.per_day); + + fc::mutable_variant_object m; + m("status", "applied"); + m("limits", limits); + m("stats_enabled", after.stats_enabled); + m("note", "applied to the live webserver on the next request and persisted to its " + "admin state file; config.ini is unchanged."); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_webserver_stats(const fc::variant ¶ms) { + if (!params.is_object() || !params.get_object().contains("enabled")) { + throw rpc_error{-32602, "invalid params", "expected an object {enabled: bool}", ""}; + } + auto &plugin = webserver_started(); + const bool enabled = params.get_object()["enabled"].as_bool(); + plugin.set_stats_enabled(enabled); + + const ws::webserver_admin_state after = plugin.admin_state(0); + fc::mutable_variant_object m; + m("status", enabled ? "enabled" : "disabled"); + m("stats_enabled", after.stats_enabled); + m("limits_enabled", after.limits.enabled); + m("note", "applied to the live webserver immediately and persisted; limits enforcement " + "is controlled separately by admin.webserver.limits."); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_webserver_list_add(const fc::variant ¶ms, bool whitelist) { + if (!params.is_object()) { + throw rpc_error{-32602, "invalid params", "expected an object {ip}", ""}; + } + const std::string ip = trim(param_str(params.get_object(), "ip", "")); + if (ip.empty()) { + throw rpc_error{-32602, "invalid params", "missing 'ip'", "a single IPv4 or IPv6 address"}; + } + auto &plugin = webserver_started(); + const bool ok = whitelist ? plugin.whitelist_add(ip) : plugin.blacklist_add(ip); + if (!ok) { + throw rpc_error{-32602, "invalid ip", ip, "a single IPv4 or IPv6 address"}; + } + + const ws::webserver_admin_state st = plugin.admin_state(0); + fc::variants wl, bl; + for (const auto &v : st.whitelist) wl.push_back(fc::variant(v)); + for (const auto &v : st.blacklist) bl.push_back(fc::variant(v)); + + fc::mutable_variant_object m; + m("status", "added"); + m("list", whitelist ? "whitelist" : "blacklist"); + m("ip", ip); + m("whitelist", wl); + m("blacklist", bl); + m("note", whitelist + ? "whitelisted addresses bypass the rate limits (they are still counted)" + : "blacklisted addresses are rejected with HTTP 403; the blacklist wins over the whitelist"); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_webserver_list_remove(const fc::variant ¶ms, bool whitelist) { + if (!params.is_object()) { + throw rpc_error{-32602, "invalid params", "expected an object {ip}", ""}; + } + const std::string ip = trim(param_str(params.get_object(), "ip", "")); + if (ip.empty()) { + throw rpc_error{-32602, "invalid params", "missing 'ip'", ""}; + } + auto &plugin = webserver_started(); + const bool erased = whitelist ? plugin.whitelist_remove(ip) : plugin.blacklist_remove(ip); + + const ws::webserver_admin_state st = plugin.admin_state(0); + fc::variants wl, bl; + for (const auto &v : st.whitelist) wl.push_back(fc::variant(v)); + for (const auto &v : st.blacklist) bl.push_back(fc::variant(v)); + + fc::mutable_variant_object m; + m("status", erased ? "removed" : "not_found"); + m("list", whitelist ? "whitelist" : "blacklist"); + m("ip", ip); + m("whitelist", wl); + m("blacklist", bl); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_webserver_reset(const fc::variant &) { + auto &plugin = webserver_started(); + plugin.reset_stats(); + fc::mutable_variant_object m; + m("status", "reset"); + m("note", "counters zeroed; IP lists and limits are unchanged"); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_webserver_reload(const fc::variant &) { + auto &plugin = webserver_started(); + std::string error; + if (!plugin.reload_admin_state(error)) { + throw rpc_error{-32000, "state not reloaded", error, + "fix the admin state file or change values via admin.webserver.limits"}; + } + return h_webserver_status(fc::variant(fc::mutable_variant_object())); +} + + +// ── config/plugin editor: plan/apply ───────────────────────────────────────────────────── +void node_admin_plugin::impl::store_plan(const pending_plan &p) { + std::lock_guard lock(plans_mutex); + const uint32_t now = now_unixtime(); + for (auto it = plans.begin(); it != plans.end();) { + if (it->second.expires <= now) it = plans.erase(it); + else ++it; + } + // Plans are tiny, but an authenticated caller must not be able to grow this map without + // bound; 64 outstanding plans is far more than any single operator needs. + if (plans.size() >= 64) { + auto oldest = plans.begin(); + for (auto it = plans.begin(); it != plans.end(); ++it) { + if (it->second.created < oldest->second.created) oldest = it; + } + plans.erase(oldest); + } + plans[p.id] = p; +} + +node_admin_plugin::impl::pending_plan node_admin_plugin::impl::take_plan( + const std::string &id, const token_info &token, const std::string &kind) { + if (id.empty()) { + throw rpc_error{-32602, "invalid params", "missing plan_id", + "create a plan first: admin.config.plan, admin.plugins.plan or " + "admin.validator.plan"}; + } + std::lock_guard lock(plans_mutex); + auto it = plans.find(id); + if (it == plans.end()) { + throw rpc_error{-32004, "unknown plan", id, + "plans are single-use and expire; create a new one"}; + } + if (it->second.kind != kind) { + throw rpc_error{-32602, "invalid params", + "plan kind is '" + it->second.kind + "', not '" + kind + "'", ""}; + } + if (it->second.label != token.label) { + throw rpc_error{-32002, "forbidden", "plan belongs to another token", + "a plan is bound to the token that created it"}; + } + if (it->second.expires <= now_unixtime()) { + plans.erase(it); + throw rpc_error{-32004, "plan expired", id, "create a new plan"}; + } + pending_plan p = it->second; + // Single-use: consumed even when the write below fails, so a plan cannot be replayed. + plans.erase(it); + return p; +} + +fc::variant node_admin_plugin::impl::h_config_plan(const fc::variant ¶ms, + const token_info &token) { + if (!params.is_object()) { + throw rpc_error{-32602, "invalid params", "expected {set: {key: value}}", ""}; + } + const fc::variant_object &o = params.get_object(); + auto it = o.find("set"); + if (it == o.end() || !it->value().is_object() || it->value().get_object().size() == 0) { + throw rpc_error{-32602, "invalid params", + "'set' must be a non-empty object of key: value", + "only allow-listed keys are accepted; see admin.config.get"}; + } + + std::map set; + for (const auto &kv : it->value().get_object()) { + std::string raw; + if (kv.value().is_string()) raw = kv.value().as_string(); + else if (kv.value().is_bool()) raw = kv.value().as_bool() ? "true" : "false"; + else if (kv.value().is_int64()) raw = std::to_string(kv.value().as_int64()); + else if (kv.value().is_uint64()) raw = std::to_string(kv.value().as_uint64()); + else { + throw rpc_error{-32602, "invalid params", + "value for '" + kv.key() + "' must be a string, number or boolean", + ""}; + } + std::string norm; + std::string why; + if (!normalize_config_value(kv.key(), raw, norm, why)) { + throw rpc_error{-32602, "invalid params", why, + "the admin API edits only allow-listed keys"}; + } + set[kv.key()] = norm; + } + + std::string text; + std::string why; + if (!read_text_file(config_path(), text, why)) { + throw rpc_error{-32004, "config unavailable", why, "check the data directory"}; + } + std::vector lines = split_lines(text); + fc::variants diff; + for (const auto &kv : set) { + std::string old; + const bool found = allowed_config_value(lines, kv.first, old); + if (found && old == kv.second) continue; + line_edit e = set_allowed_config_value(lines, kv.first, kv.second); + if (!e.error.empty()) throw rpc_error{-32602, "invalid params", e.error, ""}; + lines = std::move(e.lines); + fc::mutable_variant_object d; + d("key", kv.first); + d("from", found ? fc::variant(old) : fc::variant()); + d("to", kv.second); + diff.push_back(fc::variant(d)); + } + if (diff.empty()) { + throw rpc_error{-32602, "invalid params", "no changes: every value already matches", ""}; + } + + pending_plan p; + p.id = random_plan_id(); + p.label = token.label; + p.kind = "config"; + p.created = now_unixtime(); + p.expires = p.created + plan_ttl_sec; + p.base_sha256 = text_sha256(text); + p.set = std::move(set); + p.diff = fc::variant(diff); + store_plan(p); + + fc::mutable_variant_object m; + m("plan_id", p.id); + m("kind", "config"); + m("config_path", config_path()); + m("diff", diff); + m("needs_restart", true); + m("expires_at", unixtime_iso(p.expires)); + m("hint", "nothing was written yet; apply with admin.config.apply {plan_id}. The plan is " + "single-use, bound to this token, and refused if config.ini changes meanwhile."); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_config_apply(const fc::variant ¶ms, + const token_info &token) { + if (!params.is_object()) { + throw rpc_error{-32602, "invalid params", "expected {plan_id}", ""}; + } + const pending_plan p = + take_plan(trim(param_str(params.get_object(), "plan_id", "")), token, "config"); + + std::lock_guard write_lock(config_write_mutex); + std::string text; + std::string why; + if (!read_text_file(config_path(), text, why)) { + throw rpc_error{-32004, "config unavailable", why, "check the data directory"}; + } + if (text_sha256(text) != p.base_sha256) { + throw rpc_error{-32000, "config changed", + "config.ini was modified after this plan was made; nothing was written", + "create a new plan with admin.config.plan"}; + } + + std::vector lines = split_lines(text); + for (const auto &kv : p.set) { + std::string norm; + std::string why2; + if (!normalize_config_value(kv.first, kv.second, norm, why2)) { + throw rpc_error{-32602, "invalid params", why2, ""}; + } + line_edit e = set_allowed_config_value(lines, kv.first, norm); + if (!e.error.empty()) throw rpc_error{-32602, "invalid params", e.error, ""}; + lines = std::move(e.lines); + } + + std::string backup; + std::string werr; + if (!write_config_atomic(config_path(), join_lines(lines, true), backup, werr)) { + throw rpc_error{-32000, "write failed", werr, + "the original config.ini was left untouched"}; + } + + fc::mutable_variant_object m; + m("applied", true); + m("kind", "config"); + m("config_path", config_path()); + m("backup", backup); + m("diff", p.diff); + m("needs_restart", true); + m("restart_hint", + "config.ini is read at startup: call admin.restart {confirm:\"restart\"} once the " + "process is supervised (docker --restart unless-stopped or systemd Restart=always)."); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_config_backups(const fc::variant &) { + const bfs::path cfg(config_path()); + const std::string prefix = cfg.filename().string() + ".bak-"; + struct backup_row { + std::time_t mtime; + std::string name; + uintmax_t size; + }; + std::vector rows; + boost::system::error_code ec; + bfs::directory_iterator it(cfg.parent_path(), ec); + const bfs::directory_iterator end; + if (ec) { + throw rpc_error{-32004, "config unavailable", + "cannot list " + cfg.parent_path().string() + ": " + ec.message(), ""}; + } + for (; it != end; it.increment(ec)) { + if (ec) break; + const std::string name = it->path().filename().string(); + if (name.size() <= prefix.size() || name.compare(0, prefix.size(), prefix) != 0) continue; + const uintmax_t size = bfs::file_size(it->path(), ec); + if (ec) continue; + rows.push_back(backup_row{bfs::last_write_time(it->path(), ec), name, size}); + } + std::sort(rows.begin(), rows.end(), [](const backup_row &a, const backup_row &b) { + if (a.mtime != b.mtime) return a.mtime > b.mtime; + return a.name > b.name; + }); + + fc::variants files; + for (const backup_row &r : rows) { + fc::mutable_variant_object f; + f("name", r.name); + f("path", (cfg.parent_path() / r.name).string()); + f("bytes", static_cast(r.size)); + f("modified", iso_utc(fc::time_point_sec(static_cast(r.mtime)))); + files.push_back(fc::variant(f)); + } + + fc::mutable_variant_object m; + m("config_path", config_path()); + m("count", static_cast(files.size())); + m("backups", files); + m("hint", "each apply writes one backup before replacing config.ini; copy one back and " + "restart to roll back by hand"); + return fc::variant(m); +} + +/// Read an array of strings from params, ignoring an absent or null key. +static void read_string_list(const fc::variant_object &o, const char *key, + std::vector &out) { + auto it = o.find(key); + if (it == o.end() || it->value().is_null()) return; + if (!it->value().is_array()) { + throw rpc_error{-32602, "invalid params", std::string("'") + key + "' must be an array", + ""}; + } + for (const fc::variant &v : it->value().get_array()) { + if (!v.is_string()) { + throw rpc_error{-32602, "invalid params", + std::string("'") + key + "' must contain plugin name strings", ""}; + } + const std::string name = trim(v.as_string()); + if (!name.empty()) out.push_back(name); + } +} + +fc::variant node_admin_plugin::impl::h_plugins_plan(const fc::variant ¶ms, + const token_info &token) { + if (!params.is_object()) { + throw rpc_error{-32602, "invalid params", "expected {add: [], remove: []}", ""}; + } + const fc::variant_object &o = params.get_object(); + std::vector add; + std::vector remove; + read_string_list(o, "add", add); + read_string_list(o, "remove", remove); + if (add.empty() && remove.empty()) { + throw rpc_error{-32602, "invalid params", "nothing to do: add and remove are empty", + "example: {\"add\":[\"block_info\"],\"remove\":[]}"}; + } + + std::string text; + std::string why; + if (!read_text_file(config_path(), text, why)) { + throw rpc_error{-32004, "config unavailable", why, "check the data directory"}; + } + const line_edit e = edit_plugin_lines(split_lines(text), add, remove); + if (!e.error.empty()) throw rpc_error{-32602, "invalid params", e.error, ""}; + + fc::variants diff; + for (const std::string &name : add) { + fc::mutable_variant_object d; + d("op", "add"); + d("name", name); + diff.push_back(fc::variant(d)); + } + for (const std::string &name : remove) { + fc::mutable_variant_object d; + d("op", "remove"); + d("name", name); + diff.push_back(fc::variant(d)); + } + + pending_plan p; + p.id = random_plan_id(); + p.label = token.label; + p.kind = "plugins"; + p.created = now_unixtime(); + p.expires = p.created + plan_ttl_sec; + p.base_sha256 = text_sha256(text); + p.add = std::move(add); + p.remove = std::move(remove); + p.diff = fc::variant(diff); + store_plan(p); + + fc::mutable_variant_object m; + m("plan_id", p.id); + m("kind", "plugins"); + m("config_path", config_path()); + m("diff", diff); + m("needs_restart", true); + m("expires_at", unixtime_iso(p.expires)); + m("hint", "nothing was written yet; apply with admin.plugins.apply {plan_id}. The plan is " + "single-use, bound to this token, and refused if config.ini changes meanwhile."); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_plugins_apply(const fc::variant ¶ms, + const token_info &token) { + if (!params.is_object()) { + throw rpc_error{-32602, "invalid params", "expected {plan_id}", ""}; + } + const pending_plan p = + take_plan(trim(param_str(params.get_object(), "plan_id", "")), token, "plugins"); + + std::lock_guard write_lock(config_write_mutex); + std::string text; + std::string why; + if (!read_text_file(config_path(), text, why)) { + throw rpc_error{-32004, "config unavailable", why, "check the data directory"}; + } + if (text_sha256(text) != p.base_sha256) { + throw rpc_error{-32000, "config changed", + "config.ini was modified after this plan was made; nothing was written", + "create a new plan with admin.plugins.plan"}; + } + + const line_edit e = edit_plugin_lines(split_lines(text), p.add, p.remove); + if (!e.error.empty()) throw rpc_error{-32602, "invalid params", e.error, ""}; + + std::string backup; + std::string werr; + if (!write_config_atomic(config_path(), join_lines(e.lines, true), backup, werr)) { + throw rpc_error{-32000, "write failed", werr, + "the original config.ini was left untouched"}; + } + + fc::mutable_variant_object m; + m("applied", true); + m("kind", "plugins"); + m("config_path", config_path()); + m("backup", backup); + m("diff", p.diff); + m("needs_restart", true); + m("restart_hint", + "plugin changes are applied by a restart: call admin.restart {confirm:\"restart\"} once " + "the process is supervised (docker --restart unless-stopped or systemd Restart=always)."); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_plan_cancel(const fc::variant ¶ms, + const token_info &token) { + if (!params.is_object()) { + throw rpc_error{-32602, "invalid params", "expected {plan_id}", ""}; + } + const std::string id = trim(param_str(params.get_object(), "plan_id", "")); + if (id.empty()) throw rpc_error{-32602, "invalid params", "missing plan_id", ""}; + + std::lock_guard lock(plans_mutex); + auto it = plans.find(id); + if (it == plans.end()) { + throw rpc_error{-32004, "unknown plan", id, "plans are single-use and expire"}; + } + if (it->second.label != token.label) { + throw rpc_error{-32002, "forbidden", "plan belongs to another token", ""}; + } + plans.erase(it); + + fc::mutable_variant_object m; + m("cancelled", true); + m("plan_id", id); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_restart(const fc::variant ¶ms) { + if (!params.is_object()) { + throw rpc_error{-32602, "invalid params", "expected {confirm: \"restart\"}", ""}; + } + const std::string confirm = trim(param_str(params.get_object(), "confirm", "")); + if (confirm != "restart") { + throw rpc_error{-32602, "invalid params", + "confirm must be exactly the word 'restart'", + "this stops the node process; a supervisor has to bring it back"}; + } + if (restart_requested.exchange(true)) { + throw rpc_error{-32000, "restart already requested", + "the process is already shutting down", ""}; + } + + // Reply first, quit second: the handler runs on the listener thread and appbase::quit() + // stops the io_service, so the short delay lets this HTTP response reach the client. + std::thread([] { + std::this_thread::sleep_for(std::chrono::milliseconds(300)); + ilog("node_admin: restart requested through the admin API; asking appbase to quit"); + appbase::app().quit(); + }).detach(); + + fc::mutable_variant_object m; + m("ok", true); + m("restarting", true); + m("exit_code", 0); + m("hint", "the process exits cleanly right after this reply. Bring-up belongs to the " + "supervisor: docker --restart unless-stopped or systemd Restart=always. Without " + "one, the node stays down."); + return fc::variant(m); +} + +// ── shared-memory objects (chainbase) ─────────────────────────────────────────────────── +objects_state node_admin_plugin::impl::collect_objects() const { + objects_state s; + s.state_dir = chain.get_state_dir(); + boost::system::error_code ec; + const bfs::path dir(s.state_dir); + s.state_dir_exists = !s.state_dir.empty() && bfs::exists(dir, ec) && bfs::is_directory(dir, ec); + + // The chain plugin opens the database with /state as its data dir, so block_log + // and schema_version live NEXT TO shared_memory.bin, not in the application data dir. + // Reading the app data dir reported both as absent on every healthy node, and the judge + // then called that state "corrupted" and offered a resync. + const bfs::path chain_state = bfs::path(data_dir()) / "state"; + + // The block log is a file in graphene, but the chain plugin wipes it with remove_all, so + // both shapes are read: a directory here would silently report "no block log" otherwise. + const bfs::path block_log = chain_state / "block_log"; + s.block_log_present = bfs::exists(block_log, ec); + if (s.block_log_present) { + if (bfs::is_regular_file(block_log, ec)) { + s.block_log_bytes = bfs::file_size(block_log, ec); + } else if (bfs::is_directory(block_log, ec)) { + for (bfs::directory_iterator it(block_log), end; it != end; ++it) { + if (bfs::is_regular_file(it->path(), ec)) s.block_log_bytes += bfs::file_size(it->path(), ec); + } + } + } + + if (s.state_dir_exists) { + const bfs::path shm = dir / "shared_memory.bin"; + s.shm_exists = bfs::exists(shm, ec); + if (s.shm_exists) s.shm_bytes = bfs::file_size(shm, ec); + + // Files that a successful startup or a successful resize removes. Finding one while + // the node is running means an operation was interrupted, which is exactly the state + // a repair has to clear. + for (const char *marker : {"undo_all_in_progress", "resize_in_progress", + "force_resync", "force_replay"}) { + if (bfs::exists(dir / marker, ec)) s.markers.push_back(marker); + } + } + + s.schema_file_present = read_uint_file(chain_state / "schema_version", s.stored_schema); + + auto recovery_guard = chain.try_lock_recovery(); + if (!recovery_guard.owns_lock() || chain.is_recovering()) { + s.recovering = true; + return s; + } + graphene::chain::database &db = chain.db(); + s.recovering = chain.is_recovering(); + try { + // Sizes are read under the same weak read lock the chain API uses: without it the + // multi-index containers would be walked while the block thread mutates them. + db.with_weak_read_lock([&]() { + s.revision = static_cast(db.revision()); + s.head_block_num = db.head_block_num(); + s.mapped_bytes = static_cast(db.max_memory()); + s.reserved_bytes = static_cast(db.reserved_memory()); + s.free_bytes = static_cast(db.segment_manager()->get_free_memory()); + for (auto it = db.index_list_begin(); it != db.index_list_end(); ++it) { + chainbase::abstract_index *idx = *it; + const int64_t n = static_cast(idx->size()); + const std::string type = idx->name(); + objects_check::index_row row; + row.type = type; + row.type_id = static_cast(idx->type_id()); + row.objects = n; + row.revision = static_cast(idx->revision()); + s.indexes.push_back(row); + s.total_objects += n; + if (ends_with_text(type, "::dynamic_global_property_object")) s.dgp_objects = n; + else if (ends_with_text(type, "::validator_schedule_object")) s.schedule_objects = n; + else if (ends_with_text(type, "::account_object")) s.account_objects = n; + } + }); + } catch (const std::exception &e) { + s.lock_error = e.what(); + } + + std::sort(s.indexes.begin(), s.indexes.end(), + [](const objects_check::index_row &a, const objects_check::index_row &b) { + if (a.objects != b.objects) return a.objects > b.objects; + return a.type < b.type; + }); + return s; +} + +namespace { +std::string state_dir_marker_path(const objects_state &s, const char *name) { + return (bfs::path(s.state_dir) / name).string(); +} +} // namespace + +fc::variant node_admin_plugin::impl::h_objects_list(const fc::variant &) { + const objects_state s = collect_objects(); + + fc::variants indexes; + for (const auto &row : s.indexes) { + fc::mutable_variant_object e; + e("type", row.type); + e("type_id", row.type_id); + e("objects", row.objects); + e("revision", row.revision); + indexes.push_back(fc::variant(e)); + } + + fc::mutable_variant_object schema; + schema("file_present", s.schema_file_present); + schema("stored", static_cast(s.stored_schema)); + schema("compiled", static_cast(CHAIN_SCHEMA_VERSION)); + schema("match", s.schema_file_present && s.stored_schema == CHAIN_SCHEMA_VERSION); + + fc::mutable_variant_object block_log; + block_log("path", (bfs::path(data_dir()) / "state" / "block_log").string()); + block_log("present", s.block_log_present); + block_log("bytes", static_cast(s.block_log_bytes)); + + fc::mutable_variant_object m; + m("state_dir", s.state_dir); + m("state_dir_exists", s.state_dir_exists); + m("shared_memory_path", (bfs::path(s.state_dir) / "shared_memory.bin").string()); + m("shared_memory_exists", s.shm_exists); + m("shared_memory_bytes", static_cast(s.shm_bytes)); + m("mapped_bytes", static_cast(s.mapped_bytes)); + m("free_bytes", static_cast(s.free_bytes)); + m("used_bytes", static_cast(s.mapped_bytes > s.free_bytes ? s.mapped_bytes - s.free_bytes : 0)); + m("reserved_bytes", static_cast(s.reserved_bytes)); + m("revision", s.revision); + m("head_block_num", static_cast(s.head_block_num)); + m("recovering", s.recovering); + m("schema", schema); + m("block_log", block_log); + fc::variants markers; + for (const std::string &x : s.markers) markers.push_back(fc::variant(x)); + m("markers", markers); + m("index_count", static_cast(s.indexes.size())); + m("total_objects", s.total_objects); + m("indexes", indexes); + m("generated_at", now_iso()); + // Always present: a client asks "did the lock answer?" and must be able to tell null from + // a missing field without knowing this server's omissions. + m("lock_error", s.lock_error.empty() ? fc::variant() : fc::variant(s.lock_error)); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_objects_check(const fc::variant &) { + const objects_state s = collect_objects(); + const objects_check::judgement j = + objects_check::judge(s, static_cast(CHAIN_SCHEMA_VERSION)); + + fc::variants findings; + for (const objects_check::finding &f : j.findings) { + fc::mutable_variant_object e; + e("severity", f.severity); + e("code", f.code); + e("text", f.text); + findings.push_back(fc::variant(e)); + } + + fc::mutable_variant_object m; + m("verdict", j.verdict); + m("critical", static_cast(j.critical)); + m("warnings", static_cast(j.warnings)); + m("findings", findings); + m("recommended_repair", j.recommended_repair.empty() ? fc::variant() : fc::variant(j.recommended_repair)); + m("replay_available", j.replay_available); + m("state_dir", s.state_dir); + m("checked_at", now_iso()); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_objects_repair(const fc::variant ¶ms) { + if (!params.is_object()) { + throw rpc_error{-32602, "invalid params", "expected {mode: \"replay\"|\"resync\", confirm: \"repair\"}", ""}; + } + const std::string mode = trim(param_str(params.get_object(), "mode", "replay")); + if (mode != "replay" && mode != "resync") { + throw rpc_error{-32602, "invalid params", "mode must be 'replay' or 'resync'", ""}; + } + const std::string confirm = trim(param_str(params.get_object(), "confirm", "")); + if (confirm != "repair") { + throw rpc_error{-32602, "invalid params", + "confirm must be exactly the word 'repair'", + "repair rewrites node state; the whole point is that it cannot happen by accident"}; + } + + const objects_state s = collect_objects(); + if (!s.state_dir_exists) { + throw rpc_error{-32000, "state dir missing", + "shared-memory directory does not exist: " + s.state_dir, + "start the node once so it creates its state directory"}; + } + if (mode == "replay" && !objects_check::has_replayable_blocks(s)) { + throw rpc_error{-32000, "no block log", + "replay rebuilds state from the block log, and the block log is absent or empty", + "use mode=resync: it wipes state and blocks and rebuilds from the network"}; + } + + // Startup precedence is resync > replay: the chain plugin removes a force_replay marker + // when force_resync is also present. Honouring the mode the operator confirmed therefore + // means clearing the marker that would otherwise outrank it, and naming it in the reply — + // otherwise "replay (keeps blocks)" would silently drop the block log. The chain plugin is + // the other producer of force_resync (the p2p wedge watchdog), so this case is reachable. + const char *opposite = mode == "replay" ? "force_resync" : "force_replay"; + const std::string opposite_path = state_dir_marker_path(s, opposite); + boost::system::error_code rm_ec; + const bool removed_opposite = bfs::remove(opposite_path, rm_ec); + if (rm_ec) { + throw rpc_error{-32000, "cannot clear conflicting marker", + opposite_path + ": " + rm_ec.message(), + "the opposite marker outranks the requested mode at startup; " + "repair refused rather than promising an effect it cannot deliver"}; + } + + if (restart_requested.exchange(true)) { + throw rpc_error{-32000, "restart already requested", "the process is already shutting down", ""}; + } + + // The chain plugin reads these markers at startup, before it maps shared memory: + // force_replay -> wipe state, keep the block log, reindex it from block 1 + // force_resync -> wipe state and block log, come up empty and resync from peers + // Writing them now and quitting is safer than acting on a live mapping: the file is + // closed by an ordinary shutdown, so the repair starts from a consistent file handle. + const char *marker = mode == "replay" ? "force_replay" : "force_resync"; + const std::string marker_path = state_dir_marker_path(s, marker); + { + std::ofstream f(marker_path.c_str(), std::ios::trunc); + f << "node_admin repair request at " << now_iso() << " mode=" << mode << "\n"; + f.flush(); + if (!f) { + restart_requested.store(false); + throw rpc_error{-32000, "cannot write repair marker", marker_path, + "check the state directory permissions"}; + } + } + + std::thread([] { + std::this_thread::sleep_for(std::chrono::milliseconds(300)); + ilog("node_admin: repair requested through the admin API; asking appbase to quit"); + appbase::app().quit(); + }).detach(); + + fc::mutable_variant_object m; + m("ok", true); + m("restarting", true); + m("mode", mode); + m("marker", marker_path); + m("removed_marker", removed_opposite ? fc::variant(opposite_path) : fc::variant()); + // The effect text must match the startup the chain plugin will actually take; see + // objects_check::repair_effect (unit-tested) for the snapshot-mode case. + m("effect", objects_check::repair_effect(mode, snapshot_replay_from, snapshot_configured)); + m("snapshot_replay", snapshot_replay_from); + m("hint", "the process exits cleanly right after this reply. Bring-up belongs to the " + "supervisor: docker --restart unless-stopped or systemd Restart=always."); + return fc::variant(m); +} + +// ── validator chain operations (validator_guard) ───────────────────────────────────────── +// Decision q#3051=D: node_admin does not keep its own key. The guard already holds +// signing+active keys per validator, signs validator_update inside its own plugin, and +// exposes a plain status/action API here. `chain` is granted only while the guard is +// live with an active key, which is what the dispatch check above enforces. + +graphene::plugins::validator_guard::validator_guard_plugin * +node_admin_plugin::impl::guard_plugin() const { + return appbase::app().find_plugin(); +} + +bool node_admin_plugin::impl::guard_ready() const { + auto *g = guard_plugin(); + return g != nullptr && + g->get_state() == appbase::abstract_plugin::started && + g->chain_actions_available(); +} + +fc::variant node_admin_plugin::impl::h_validator_list(const fc::variant &) { + auto recovery_guard = chain.try_lock_recovery(); + if (!recovery_guard.owns_lock() || chain.is_recovering()) + throw rpc_error{-32004, "chain is recovering", "state is temporarily unavailable", "retry after restore"}; + auto *g = guard_plugin(); + const bool loaded = g != nullptr; + const bool started = loaded && g->get_state() == appbase::abstract_plugin::started; + const bool available = loaded && started && g->chain_actions_available(); + + fc::mutable_variant_object ginfo; + ginfo("loaded", loaded); + ginfo("started", started); + ginfo("available", available); + if (!loaded) { + ginfo("reason", "validator_guard is not loaded; add `plugin = validator_guard` to config.ini"); + } else if (!started) { + ginfo("reason", "validator_guard has not finished starting"); + } else if (!available) { + ginfo("reason", "no validator is configured with an active key " + "(validator-guard-validator = [name,signing_wif,active_wif])"); + } + + fc::variants arr; + if (available) { + for (const auto &s : g->validators_status()) { + fc::mutable_variant_object m; + m("name", s.name); + m("on_chain", s.on_chain); + m("enabled", s.enabled); + m("hold", s.hold); + m("active_authority", s.active_authority); + m("scheduled_in", s.scheduled_in > 0 ? fc::variant(static_cast(s.scheduled_in)) + : fc::variant()); + m("signing_key", s.signing_key.empty() ? fc::variant() : fc::variant(s.signing_key)); + m("configured_key", s.configured_key.empty() ? fc::variant() + : fc::variant(s.configured_key)); + arr.push_back(fc::variant(m)); + } + } + + fc::mutable_variant_object m; + m("guard", ginfo); + m("validators", arr); + return fc::variant(m); +} + +// Who is scheduled in the current validator schedule, and whether the chain is in emergency +// consensus. Names only: signing keys live in the validator plugin and are shown by +// admin.validator.list (and only for the validators this node actually runs). +fc::variant node_admin_plugin::impl::h_validators_active(const fc::variant &) { + auto recovery_guard = chain.try_lock_recovery(); + if (!recovery_guard.owns_lock() || chain.is_recovering()) + throw rpc_error{-32004, "chain is recovering", "state is temporarily unavailable", "retry after restore"}; + graphene::chain::database &db = chain.db(); + return db.with_weak_read_lock([&]() { + const auto &wso = db.get_validator_schedule_object(); + const auto &dgp = db.get_dynamic_global_properties(); + + fc::variants names; + std::set seen; + for (const auto &n : wso.current_shuffled_validators) { + const std::string name = n; + if (name.empty() || !seen.insert(name).second) continue; + names.push_back(fc::variant(name)); + } + + bool validator_loaded = false; + if (auto *vp = appbase::app().find_plugin()) { + validator_loaded = vp->get_state() == appbase::abstract_plugin::started; + } + + fc::mutable_variant_object m; + m("as_of", now_iso()); + m("head_block_num", static_cast(db.head_block_num())); + m("count", static_cast(names.size())); + m("num_scheduled", static_cast(wso.num_scheduled_validators)); + m("validators", names); + m("emergency_consensus_active", dgp.emergency_consensus_active); + m("emergency_consensus_start_block", + static_cast(dgp.emergency_consensus_active ? dgp.emergency_consensus_start_block : 0)); + m("validator_plugin_loaded", validator_loaded); + return fc::variant(m); + }); +} + +fc::variant node_admin_plugin::impl::h_validator_plan(const fc::variant ¶ms, + const token_info &token) { + auto recovery_guard = chain.try_lock_recovery(); + if (!recovery_guard.owns_lock() || chain.is_recovering()) + throw rpc_error{-32004, "chain is recovering", "state is temporarily unavailable", "retry after restore"}; + namespace vg = graphene::plugins::validator_guard; + + if (!params.is_object()) { + throw rpc_error{-32602, "invalid params", "expected {actions: [{name, action}]}", ""}; + } + const fc::variant_object &o = params.get_object(); + auto it = o.find("actions"); + if (it == o.end() || !it->value().is_array() || it->value().get_array().empty()) { + throw rpc_error{-32602, "invalid params", "'actions' must be a non-empty array", + "example: {\"actions\":[{\"name\":\"val1\",\"action\":\"disable\"}]}"}; + } + + auto *g = guard_plugin(); + std::map by_name; + for (const auto &s : g->validators_status()) by_name[s.name] = s; + + fc::variants actions; + fc::variants diff; + std::set seen; + std::size_t tx_count = 0; + + for (const fc::variant &a : it->value().get_array()) { + if (!a.is_object()) { + throw rpc_error{-32602, "invalid params", "each action must be an object", ""}; + } + const fc::variant_object &ao = a.get_object(); + const std::string name = trim(param_str(ao, "name", "")); + if (name.empty()) { + throw rpc_error{-32602, "invalid params", "each action needs a 'name'", ""}; + } + if (!seen.insert(name).second) { + throw rpc_error{-32602, "invalid params", + "validator '" + name + "' appears twice in one plan", ""}; + } + auto sit = by_name.find(name); + if (sit == by_name.end()) { + throw rpc_error{-32602, "invalid params", + "validator '" + name + "' is not configured in validator-guard", + "admin.validator.list shows the configured validators"}; + } + if (!sit->second.on_chain) { + throw rpc_error{-32602, "invalid params", + "validator '" + name + "' does not exist on chain", ""}; + } + + const std::string act = trim(param_str(ao, "action", "")); + vg::validator_action kind; + if (act == "enable") kind = vg::validator_action::enable; + else if (act == "disable") kind = vg::validator_action::disable; + else if (act == "set_key") kind = vg::validator_action::set_key; + else { + throw rpc_error{-32602, "invalid params", + "action must be 'enable', 'disable' or 'set_key', got '" + act + "'", ""}; + } + + std::string key; + if (kind == vg::validator_action::set_key) { + key = trim(param_str(ao, "signing_key", "")); + if (key.empty()) { + throw rpc_error{-32602, "invalid params", "set_key requires 'signing_key'", ""}; + } + try { + graphene::protocol::public_key_type parsed(key); + } catch (const fc::exception &e) { + throw rpc_error{-32602, "invalid params", + "invalid signing_key: " + e.to_string(), + "expected a base58 public key, e.g. VIZ8..."}; + } + } + + const std::string from = sit->second.signing_key; + std::string to; + if (kind == vg::validator_action::enable) to = sit->second.configured_key; + else if (kind == vg::validator_action::set_key) to = key; + + const bool hold_to = (kind == vg::validator_action::disable); + const bool tx_needed = (from != to); + const bool hold_change = (sit->second.hold != hold_to); + if (!tx_needed && !hold_change) continue; // already in the requested state + + fc::mutable_variant_object d; + d("name", name); + d("action", act); + d("from", from.empty() ? fc::variant() : fc::variant(from)); + d("to", to.empty() ? fc::variant() : fc::variant(to)); + d("hold_from", sit->second.hold); + d("hold_to", hold_to); + d("tx_needed", tx_needed); + diff.push_back(fc::variant(d)); + + fc::mutable_variant_object sa; + sa("name", name); + sa("action", act); + sa("signing_key", key); + sa("expected_key", from); + sa("hold_from", sit->second.hold); + actions.push_back(fc::variant(sa)); + if (tx_needed) ++tx_count; + } + + if (diff.empty()) { + throw rpc_error{-32602, "invalid params", + "no changes: every validator is already in the requested state", ""}; + } + + pending_plan p; + p.id = random_plan_id(); + p.label = token.label; + p.kind = "validators"; + p.created = now_unixtime(); + p.expires = p.created + plan_ttl_sec; + p.actions = std::move(actions); + p.diff = fc::variant(diff); + store_plan(p); + + fc::mutable_variant_object m; + m("plan_id", p.id); + m("kind", "validators"); + m("diff", diff); + m("transactions", static_cast(tx_count)); + m("expires_at", unixtime_iso(p.expires)); + m("hint", "nothing was signed or broadcast yet; apply with admin.validator.apply {plan_id}. " + "The plan is single-use, bound to this token, and each action is refused if the " + "on-chain signing key changed after this plan was made."); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_validator_apply(const fc::variant ¶ms, + const token_info &token) { + namespace vg = graphene::plugins::validator_guard; + + if (!params.is_object()) { + throw rpc_error{-32602, "invalid params", "expected {plan_id}", ""}; + } + const pending_plan p = + take_plan(trim(param_str(params.get_object(), "plan_id", "")), token, "validators"); + + auto *g = guard_plugin(); + if (g == nullptr || !g->chain_actions_available()) { + throw rpc_error{-32002, "forbidden", "validator-guard is no longer active", + "the plan was consumed; configure the guard and create a new plan"}; + } + + std::vector reqs; + reqs.reserve(p.actions.size()); + for (const fc::variant &sv : p.actions) { + const fc::variant_object &so = sv.get_object(); + vg::validator_action_request r; + r.name = param_str(so, "name", ""); + const std::string act = param_str(so, "action", ""); + if (act == "enable") r.action = vg::validator_action::enable; + else if (act == "disable") r.action = vg::validator_action::disable; + else r.action = vg::validator_action::set_key; + r.signing_key = param_str(so, "signing_key", ""); + r.check_expected = true; + r.expected_key = param_str(so, "expected_key", ""); + reqs.push_back(r); + } + + const std::vector res = g->apply_validator_actions(reqs); + + fc::variants out; + int64_t applied = 0; + int64_t unchanged = 0; + int64_t failed = 0; + for (const auto &r : res) { + fc::mutable_variant_object m; + m("name", r.name); + m("action", r.action); + if (!r.ok) { + m("status", "failed"); + m("error", r.error); + ++failed; + } else if (r.changed) { + m("status", "applied"); + m("tx_id", r.tx_id); + ++applied; + } else { + m("status", "already"); + ++unchanged; + } + if (r.ok) m("hold", r.action == "disable"); + out.push_back(fc::variant(m)); + } + + fc::mutable_variant_object m; + m("plan_id", p.id); + m("kind", "validators"); + m("applied", applied); + m("unchanged", unchanged); + m("failed", failed); + m("results", out); + m("hint", "signed transactions are in this node's pending pool and were broadcast to peers; " + "no restart is needed. A disable sets a persisted operator hold, so auto-restore " + "will not turn the validator back on after a restart either."); + return fc::variant(m); +} + +// ── MCP (Streamable HTTP) ──────────────────────────────────────────────────────────────── +// The MCP endpoint is the same loopback listener and the same bearer tokens as admin.*. +// Tools call the admin methods 1:1, so every tool invocation lands in the audit log with +// its own method name (`tools/call` is the JSON-RPC method; the tool name is in params). +fc::variant node_admin_plugin::impl::mcp_tools() const { + auto schema = [](fc::mutable_variant_object props, fc::variants required) { + fc::mutable_variant_object s; + s("type", "object"); + s("properties", props); + s("additionalProperties", false); + if (!required.empty()) s("required", required); + return fc::variant(s); + }; + auto prop = [](const char *type, const char *desc) { + fc::mutable_variant_object p; + p("type", type); + p("description", desc); + return fc::variant(p); + }; + auto tool = [&](const char *name, const char *desc, const fc::variant &input) { + fc::mutable_variant_object t; + t("name", name); + t("description", desc); + t("inputSchema", input); + return fc::variant(t); + }; + const fc::variant no_args = schema(fc::mutable_variant_object{}, fc::variants{}); + + fc::mutable_variant_object logs_props; + logs_props("name", prop("string", "Name from admin-log-file entries")); + logs_props("bytes", prop("integer", "Maximum bytes to read from the tail (default 16384)")); + logs_props("lines", prop("integer", "Maximum lines to return (default 200)")); + fc::variants logs_required; + logs_required.push_back(fc::variant(std::string("name"))); + + fc::mutable_variant_object audit_props; + audit_props("lines", prop("integer", "Maximum audit lines to return (default 100)")); + + fc::variants tools; + tools.push_back(tool("admin.ping", "Liveness check: server time and ok=true.", no_args)); + tools.push_back(tool("admin.auth.me", + "Who the presented token is: its label and scopes. The token and its " + "hash are never returned. Clients use it to hide actions they cannot run.", + no_args)); + tools.push_back(tool("admin.node_info", + "Chain id, head block, hardfork, uptime, head age and emergency " + "consensus state.", no_args)); + tools.push_back(tool("admin.diagnostics", + "Node info plus data dir, peer count, disk/RSS/loadavg and plugin counters.", + no_args)); + tools.push_back(tool("admin.plugins.list", + "Plugins configured in config.ini with observed lifecycle state and the " + "name of the method that reports each plugin's own status.", no_args)); + + fc::mutable_variant_object plugin_name_props; + plugin_name_props("name", prop("string", "Plugin name exactly as written in config.ini, e.g. p2p")); + fc::variants plugin_name_required; + plugin_name_required.push_back(fc::variant(std::string("name"))); + tools.push_back(tool("admin.plugins.status", + "Lifecycle state plus the plugin's own status for p2p, webserver, " + "snapshot and validator_guard. For other plugins it says why no " + "status exists instead of returning an empty object.", + schema(plugin_name_props, plugin_name_required))); + + tools.push_back(tool("admin.objects.list", + "Chainbase object types with row counts per index, the mapped file " + "size, free space, revision vs head and state-repair markers.", no_args)); + tools.push_back(tool("admin.objects.check", + "Same view as admin.objects.list, judged: verdict ok/warn/corrupted/" + "unreadable, named findings, and the repair mode that would fix them.", + no_args)); + + fc::mutable_variant_object repair_props; + repair_props("mode", prop("string", "'replay' rebuilds state from the block log; " + "'resync' wipes state and blocks and syncs from peers")); + repair_props("confirm", prop("string", "Must be exactly 'repair'")); + fc::variants repair_required; + repair_required.push_back(fc::variant(std::string("mode"))); + repair_required.push_back(fc::variant(std::string("confirm"))); + tools.push_back(tool("admin.objects.repair", + "Write the repair marker and restart: replay keeps the block log, " + "resync drops it. Needs the 'process' scope; the supervisor must " + "bring the node back.", + schema(repair_props, repair_required))); + tools.push_back(tool("admin.config.get", + "Effective config.ini by sections with secret values masked.", no_args)); + tools.push_back(tool("admin.logs.list", + "Log files the admin API may tail, as configured by admin-log-file, " + "with size and whether the file exists yet.", no_args)); + tools.push_back(tool("admin.logs.tail", + "Tail an allow-listed log file; secrets and WIFs are masked.", + schema(logs_props, logs_required))); + tools.push_back(tool("admin.audit.tail", + "Tail the node_admin audit log (one JSON line per request outcome).", + schema(audit_props, fc::variants{}))); + + fc::mutable_variant_object endpoint_props; + endpoint_props("endpoint", prop("string", "Peer endpoint as IPv4:port, e.g. 1.2.3.4:1776")); + fc::variants endpoint_required; + endpoint_required.push_back(fc::variant(std::string("endpoint"))); + + tools.push_back(tool("admin.p2p.status", + "P2P diagnostics, the same facts the node logs every minute and five " + "minutes: head and LIB, the DLT block range held locally, lag behind " + "the best peer, uptime, connection counts, and every peer with its " + "lifecycle, head, fork status, trust and ban state.", + no_args)); + tools.push_back(tool("admin.p2p.add_node", + "Add a P2P peer endpoint and dial it now. Needs the 'p2p' scope.", + schema(endpoint_props, endpoint_required))); + tools.push_back(tool("admin.p2p.add_trusted", + "Add a P2P peer as trusted and dial it now: never soft-banned, kept " + "through known-peer GC. Needs the 'p2p' scope.", + schema(endpoint_props, endpoint_required))); + tools.push_back(tool("admin.p2p.clear_banned", + "Clear every soft ban and retry those peers immediately. " + "Needs the 'p2p' scope.", + no_args)); + + fc::mutable_variant_object restore_props; + restore_props("name", prop("string", + "Snapshot file name from admin.snapshot.status, e.g. snapshot-block-84360000.vizjson")); + restore_props("path", prop("string", + "Absolute path of a snapshot file located directly in the snapshot directory")); + restore_props("latest", prop("boolean", + "Restore the highest-block snapshot in the snapshot directory (default when " + "neither name nor path is given)")); + + fc::mutable_variant_object snap_props; + snap_props("offset", prop("integer", "Skip this many snapshots from the newest (default 0)")); + snap_props("limit", prop("integer", "Return at most this many snapshots (default 20, max 200)")); + snap_props("after", prop("string", + "Name of the last row you already have; the page continues after it. Use this instead " + "of offset when paging through a directory that changes: offset repeats or skips rows " + "when a snapshot is written or pruned between pages. A pruned anchor is reported as " + "after_found=false and offset applies.")); + + tools.push_back(tool("admin.snapshot.status", + "A page of snapshots on disk (newest block first) with size and age, the " + "latest file, and whether a creation or restore is currently running. " + "offset/limit page through the list; snapshot_total and has_more say " + "how much is left. after= is the stable cursor.", + schema(snap_props, fc::variants{}))); + tools.push_back(tool("admin.snapshot.create", + "Create a snapshot of the current head block in the snapshot directory, " + "asynchronously. Needs the 'snapshot' scope.", + no_args)); + tools.push_back(tool("admin.snapshot.restore", + "Restore node state from a snapshot file, or from the latest one, " + "in-process: pauses P2P, wipes shared memory, imports the snapshot, " + "replays dlt_block_log and resumes P2P. Needs the 'snapshot' scope.", + schema(restore_props, fc::variants{}))); + + fc::mutable_variant_object ws_ips_props; + ws_ips_props("limit", prop("integer", "Maximum rows to return (default 200, max 5000)")); + fc::mutable_variant_object ws_limits_props; + ws_limits_props("enabled", prop("boolean", "Enforce the limits (default: keep the current value)")); + ws_limits_props("per_minute", prop("integer", "Max requests per IP per minute")); + ws_limits_props("per_hour", prop("integer", "Max requests per IP per hour")); + ws_limits_props("per_day", prop("integer", "Max requests per IP per day")); + fc::mutable_variant_object ws_stats_props; + ws_stats_props("enabled", prop("boolean", "Enable or disable per-IP request accounting")); + fc::variants ws_stats_required; + ws_stats_required.push_back(fc::variant(std::string("enabled"))); + fc::mutable_variant_object ws_ip_props; + ws_ip_props("ip", prop("string", "A single IPv4 or IPv6 address")); + fc::variants ws_ip_required; + ws_ip_required.push_back(fc::variant(std::string("ip"))); + + tools.push_back(tool("admin.webserver.status", + "Public webserver policy: stats switch, limits, request totals and " + "the whitelist/blacklist. Changes made by the other webserver tools " + "apply to the next request.", + no_args)); + tools.push_back(tool("admin.webserver.ips", + "Per-IP request counts (total, current minute/hour/day, blocked and " + "rate-limited), highest first.", + schema(ws_ips_props, fc::variants{}))); + tools.push_back(tool("admin.webserver.limits", + "Set the per-IP request limits and whether they are enforced. Omitted " + "fields keep their value. Needs the 'webserver' scope.", + schema(ws_limits_props, fc::variants{}))); + tools.push_back(tool("admin.webserver.stats", + "Turn per-IP request accounting on or off at runtime. Needs the " + "'webserver' scope.", + schema(ws_stats_props, ws_stats_required))); + tools.push_back(tool("admin.webserver.whitelist_add", + "Whitelist an IP: it bypasses the rate limits (still counted). " + "Needs the 'webserver' scope.", + schema(ws_ip_props, ws_ip_required))); + tools.push_back(tool("admin.webserver.whitelist_remove", + "Remove an IP from the whitelist. Needs the 'webserver' scope.", + schema(ws_ip_props, ws_ip_required))); + tools.push_back(tool("admin.webserver.blacklist_add", + "Blacklist an IP: every request from it is rejected with HTTP 403. " + "Needs the 'webserver' scope.", + schema(ws_ip_props, ws_ip_required))); + tools.push_back(tool("admin.webserver.blacklist_remove", + "Remove an IP from the blacklist. Needs the 'webserver' scope.", + schema(ws_ip_props, ws_ip_required))); + tools.push_back(tool("admin.webserver.reset", + "Zero the request counters; lists and limits are kept. Needs the " + "'webserver' scope.", + no_args)); + tools.push_back(tool("admin.webserver.reload", + "Re-read the webserver admin state file (limits and IP lists) from " + "disk without restarting. Needs the 'webserver' scope.", + no_args)); + + fc::mutable_variant_object set_prop; + set_prop("type", "object"); + set_prop("description", + "Allow-listed config keys mapped to new values (string, number or boolean). " + "Identity, keys, data paths and listener endpoints are never editable."); + set_prop("additionalProperties", true); + fc::mutable_variant_object cfg_plan_props; + cfg_plan_props("set", fc::variant(set_prop)); + fc::variants cfg_set_required; + cfg_set_required.push_back(fc::variant(std::string("set"))); + fc::mutable_variant_object plan_id_props; + plan_id_props("plan_id", prop("string", "plan_id returned by the matching *.plan call")); + fc::variants plan_id_required; + plan_id_required.push_back(fc::variant(std::string("plan_id"))); + fc::mutable_variant_object list_props; + list_props("add", prop("array", "Plugin names to add (must be registered in vizd)")); + list_props("remove", prop("array", "Plugin names to remove (chain and node_admin are protected)")); + fc::mutable_variant_object confirm_props; + confirm_props("confirm", prop("string", "Must be exactly 'restart'")); + fc::variants confirm_required; + confirm_required.push_back(fc::variant(std::string("confirm"))); + + tools.push_back(tool("admin.config.plan", + "Validate an allow-listed config.ini change and return the exact diff " + "plus a single-use plan_id. Nothing is written. Needs the 'config' scope.", + schema(cfg_plan_props, cfg_set_required))); + tools.push_back(tool("admin.config.apply", + "Write the change shown by admin.config.plan: re-checks the file hash, " + "backs the old file up and replaces config.ini atomically. Needs the " + "'config' scope.", + schema(plan_id_props, plan_id_required))); + tools.push_back(tool("admin.config.backups", + "Backups written next to config.ini by previous applies, newest first.", + no_args)); + tools.push_back(tool("admin.plugins.plan", + "Validate adding/removing `plugin =` lines and return a single-use " + "plan_id with the exact edit. Nothing is written until apply. Needs the " + "'plugins' scope.", + schema(list_props, fc::variants{}))); + tools.push_back(tool("admin.plugins.apply", + "Write the plugin edit shown by admin.plugins.plan, with the same " + "hash check, backup and atomic replace as config.apply. Needs the " + "'plugins' scope.", + schema(plan_id_props, plan_id_required))); + tools.push_back(tool("admin.plan.cancel", + "Drop a plan without applying it. Only the token that created it can " + "cancel it.", + schema(plan_id_props, plan_id_required))); + tools.push_back(tool("admin.restart", + "Ask appbase to quit so the supervisor (docker --restart / systemd " + "Restart=always) starts the node again. Needs the 'process' scope.", + schema(confirm_props, confirm_required))); + + fc::mutable_variant_object validator_props; + validator_props("actions", prop("array", + "Actions: [{name, action: 'enable'|'disable'|'set_key', signing_key?}]. One entry " + "per validator; duplicates are refused.")); + fc::variants validator_required; + validator_required.push_back(fc::variant(std::string("actions"))); + + tools.push_back(tool("admin.validators.active", + "Names in the current validator schedule, the number of scheduled " + "slots and whether the chain is in emergency consensus. No keys.", + no_args)); + tools.push_back(tool("admin.validator.list", + "Validators monitored by validator_guard: on-chain signing key, " + "whether the operator hold is set, the guard's active authority and " + "the next scheduled slot.", + no_args)); + tools.push_back(tool("admin.validator.plan", + "Validate enable/disable/set_key for several validators and return " + "the exact diff plus a single-use plan_id. Nothing is signed yet. " + "Needs the 'chain' scope and a live validator-guard with an active key.", + schema(validator_props, validator_required))); + tools.push_back(tool("admin.validator.apply", + "Sign and broadcast the planned validator_update transactions " + "through validator_guard, refusing any validator whose on-chain key " + "changed since the plan. Needs the 'chain' scope.", + schema(plan_id_props, plan_id_required))); + return fc::variant(tools); +} + +fc::variant node_admin_plugin::impl::h_mcp_initialize(const fc::variant ¶ms) { + std::string requested; + if (params.is_object()) { + requested = param_str(params.get_object(), "protocolVersion", ""); + } + // Echo only versions we actually implement; otherwise answer with our own, as the spec + // requires ("the server MUST respond with a protocol version it supports"). + const std::set supported{"2024-11-05", "2025-03-26", "2025-06-18"}; + const std::string proto = + supported.count(requested) ? requested : std::string("2025-06-18"); + + fc::mutable_variant_object tools_cap; + tools_cap("listChanged", false); + fc::mutable_variant_object caps; + caps("tools", tools_cap); + + fc::mutable_variant_object server; + server("name", "viz-node-admin"); + server("version", "0.1.0"); + + fc::mutable_variant_object m; + m("protocolVersion", proto); + m("capabilities", caps); + m("serverInfo", server); + m("instructions", + "Administration of this VIZ node. Read-only tools need the 'read' scope; the mutating " + "peer tools admin.p2p.add_node, admin.p2p.add_trusted and admin.p2p.clear_banned " + "additionally need the 'p2p' scope; admin.snapshot.create and admin.snapshot.restore " + "additionally need the 'snapshot' scope; the webserver policy tools " + "(admin.webserver.limits / stats / whitelist_* / blacklist_* / reset / reload) " + "additionally need the 'webserver' scope; admin.config.plan/apply need 'config', " + "admin.plugins.plan/apply need 'plugins', and admin.restart needs 'process'. Validator " + "operations (admin.validator.plan/apply) need 'chain' and are accepted only while " + "validator_guard is live with an active key; disable sets a persisted operator hold, so " + "the guard's auto-restore does not undo it (enable clears the hold). Config and " + "plugin edits are two-phase: plan shows the exact diff and returns a single-use, " + "token-bound plan_id; apply re-checks that config.ini did not change, writes it " + "atomically and keeps a .bak copy. Both need a restart to take effect. Webserver changes " + "apply to the next request and persist in the plugin's admin state file. A human-facing " + "UI is served at /ui/ on this same listener and uses the same bearer token."); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_mcp_ping(const fc::variant &) { + return fc::variant(fc::mutable_variant_object()); +} + +fc::variant node_admin_plugin::impl::h_mcp_tools_list(const fc::variant &) { + fc::mutable_variant_object m; + m("tools", mcp_tools()); + return fc::variant(m); +} + +fc::variant node_admin_plugin::impl::h_mcp_tools_call(const fc::variant ¶ms, + const token_info &token) { + if (!params.is_object()) { + throw rpc_error{-32602, "invalid params", "expected an object {name, arguments}", ""}; + } + const fc::variant_object &o = params.get_object(); + const std::string name = param_str(o, "name", ""); + if (name.empty()) { + throw rpc_error{-32602, "invalid params", "missing tool name", "see tools/list"}; + } + const fc::variant tools = mcp_tools(); + bool known = false; + for (const fc::variant &t : tools.get_array()) { + if (t.get_object()["name"].as_string() == name) { + known = true; + break; + } + } + if (!known) { + throw rpc_error{-32602, "unknown tool", name, "see tools/list"}; + } + + fc::variant args; + auto a_it = o.find("arguments"); + if (a_it != o.end() && !a_it->value().is_null()) { + if (!a_it->value().is_object()) { + throw rpc_error{-32602, "invalid params", "'arguments' must be an object", ""}; + } + args = a_it->value(); + } + + auto content = [](const std::string &text, bool is_error) { + fc::mutable_variant_object item; + item("type", "text"); + item("text", text); + fc::mutable_variant_object m; + m("content", fc::variants{fc::variant(item)}); + m("isError", is_error); + return fc::variant(m); + }; + + try { + return content(to_json(dispatch(name, args, token)), false); + } catch (const rpc_error &e) { + // Tool execution errors are reported inside the result (isError), not as a + // protocol-level JSON-RPC error: the model must see what the tool said. + std::string text = "error " + std::to_string(e.code) + ": " + e.message; + if (!e.reason.empty()) text += ": " + e.reason; + if (!e.hint.empty()) text += " (" + e.hint + ")"; + return content(text, true); + } catch (const fc::exception &e) { + return content("internal error: " + e.to_string(), true); + } catch (const std::exception &e) { + return content(std::string("internal error: ") + e.what(), true); + } +} + + +// ── audit ──────────────────────────────────────────────────────────────────────────────── +void node_admin_plugin::impl::audit(const std::string &rid, const std::string &label, + const std::string &method, const fc::variant ¶ms, + const std::string &outcome, int code, int64_t duration_ms, + const std::string &remote) { + try { + fc::mutable_variant_object m; + m("ts", now_iso()); + m("rid", rid); + m("token", label.empty() ? "-" : label); + m("method", method); + if (method == "tools/call" && params.is_object() && params.get_object().contains("name")) + m("tool", params.get_object()["name"]); + if (!params.is_null()) m("params", params); + m("outcome", outcome); + m("code", static_cast(code)); + m("ms", duration_ms); + m("remote", remote.empty() ? "-" : remote); + audit_line(mask_text(to_json(fc::variant(m)))); + } catch (...) { + // Auditing must never break the request path. + } +} + +void node_admin_plugin::impl::audit_line(const std::string &line) { + std::lock_guard lock(audit_mutex); +#ifndef _WIN32 + // rotate at a fixed size instead of growing without bound + struct stat st; + if (::stat(audit_path.c_str(), &st) == 0 && st.st_size > static_cast(kAuditRotateBytes)) { + for (int i = 7; i >= 1; --i) + ::rename((audit_path + "." + std::to_string(i)).c_str(), + (audit_path + "." + std::to_string(i + 1)).c_str()); + ::rename(audit_path.c_str(), (audit_path + ".1").c_str()); + } + const int fd = ::open(audit_path.c_str(), O_WRONLY | O_CREAT | O_APPEND, 0600); + if (fd < 0) return; + const std::string data = line + "\n"; + ssize_t ignored = ::write(fd, data.data(), data.size()); + (void) ignored; + ::close(fd); +#else + std::ofstream out(audit_path.c_str(), std::ios::app); + if (out) out << line << "\n"; +#endif +} + +} // namespace node_admin +} // namespace plugins +} // namespace graphene diff --git a/plugins/node_admin/tests/acceptance.sh b/plugins/node_admin/tests/acceptance.sh new file mode 100755 index 0000000000..b6e31034be --- /dev/null +++ b/plugins/node_admin/tests/acceptance.sh @@ -0,0 +1,1095 @@ +#!/usr/bin/env bash +# Live acceptance for the node_admin plugin: starts a throwaway node (fresh data dir, no peers), +# drives the admin HTTP listener with curl and checks the contract from docs/plugins/node-admin.md. +# +# Not wired into CTest: it needs a built vizd and a free TCP port. Run it after changing the +# plugin: +# ./plugins/node_admin/tests/acceptance.sh [path/to/vizd] +# +# The suite writes fixture files into the node's data dir (snapshots, config.ini edits, repair +# markers), so the node must run as the SAME user as the script. With Docker, pass --user: +# docker run --rm --network host --user "$(id -u):$(id -g)" -v "$WORK:$WORK" \ +# --entrypoint /usr/local/bin/vizd vizblockchain/vizd:pm --data-dir "$WORK" +# Otherwise node-created files are root-owned and those fixtures fail with permission denied; +# the guard below stops the run instead of reporting the resulting noise as product failures. +# +# Exit code: 0 = every check passed, 1 = at least one failed, 2 = the node never came up or the +# data dir is not writable by this user (that is "not measured", not a regression). +set -u + +VIZD=${1:-${VIZD:-build/programs/vizd/vizd}} +PORT=${PORT:-18099} +P2P_PORT=${P2P_PORT:-$((PORT + 1))} +WEB_PORT=${WEB_PORT:-$((PORT + 2))} +BASE="http://127.0.0.1:$PORT" +WEB="http://127.0.0.1:$WEB_PORT" +WORK=$(mktemp -d /tmp/vizd-node-admin.XXXXXX) + +GOOD=accept-good-token +OPS=accept-ops-token +PEER=accept-peer-token +SNAP=accept-snap-token +WS=accept-webserver-token +ADM=accept-admin-token +ADM2=accept-admin2-token +CHN=accept-chain-token +EXPIRED=accept-expired-token +sha() { printf %s "$1" | sha256sum | cut -d' ' -f1; } + +cat > "$WORK/config.ini" < "$WORK/node.log" +printf 'do not read me\n' > "$WORK/secret.log" + +"$VIZD" --data-dir "$WORK" > "$WORK/console.log" 2>&1 & +NODE_PID=$! +cleanup() { kill "$NODE_PID" 2>/dev/null; wait "$NODE_PID" 2>/dev/null; } +trap cleanup EXIT + +for _ in $(seq 1 120); do + grep -q "node_admin: listening" "$WORK/console.log" && break + kill -0 "$NODE_PID" 2>/dev/null || { echo "vizd exited during startup"; tail -20 "$WORK/console.log"; exit 2; } + sleep 0.25 +done +if ! grep -q "node_admin: listening" "$WORK/console.log"; then + echo "node_admin never listened on $PORT" + tail -20 "$WORK/console.log" + exit 2 +fi + +# The node must run as this user: fixtures are written straight into its data dir below. A +# root-owned run (a container without --user) makes every such write fail with permission +# denied, and the resulting cascade would be reported as product failures. +if [ ! -w "$WORK/state" ]; then + echo "FATAL: $WORK/state is not writable by $(id -un); the node runs as another user" + echo " Run the node as this user (docker: --user $(id -u):$(id -g)) or use a local binary." + exit 2 +fi + +FAIL=0 +OUT="$WORK/out.json" +CODE=0 + +# call — sets CODE (http status) and OUT (body) +call() { + CODE=$(curl -s -o "$OUT" -w '%{http_code}' -X POST "$BASE" \ + -H 'Content-Type: application/json' "$@") +} +auth() { call -H "Authorization: Bearer $1" -d "$2"; } +# jq-free field extraction: python is already a dependency of the build docs +field() { python3 -c 'import json,sys +try: v=json.load(open(sys.argv[1])) +except Exception: print(""); raise SystemExit +for k in sys.argv[2].split("."): + if k.isdigit(): v=v[int(k)] + else: v=v[k] +print(json.dumps(v))' "$OUT" "$1"; } +# raw: same walk but prints the value unquoted (plan ids, paths) +raw() { python3 -c 'import json,sys +try: v=json.load(open(sys.argv[1])) +except Exception: print(""); raise SystemExit +for k in sys.argv[2].split("."): + if k.isdigit(): v=v[int(k)] + else: v=v[k] +print(v)' "$OUT" "$1"; } + +check() { # check