From 15541b739b6b779a0d982e932c462fdd26ba99ea Mon Sep 17 00:00:00 2001 From: Anatoly Piskunov Date: Fri, 16 Oct 2026 04:09:05 +0200 Subject: [PATCH 001/105] docs: add --restart unless-stopped to quick-start docker run examples docker.md and validator-node.md already recommend it; getting-started (en/ru/zh-CN) and README did not, so a node started from them stays down after a host reboot. --- @l10n/ru/docs/node/getting-started.md | 1 + @l10n/zh-CN/docs/node/getting-started.md | 1 + README.md | 2 +- docs/node/getting-started.md | 1 + 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/@l10n/ru/docs/node/getting-started.md b/@l10n/ru/docs/node/getting-started.md index a35c15c081..d47ebc82f5 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 \ diff --git a/@l10n/zh-CN/docs/node/getting-started.md b/@l10n/zh-CN/docs/node/getting-started.md index 483001422f..012de525cb 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 \ 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/node/getting-started.md b/docs/node/getting-started.md index 50245fbc80..1a0eb98ffe 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 \ From 45ee433f7daf24e38934f7338e239c9597dbd4bd Mon Sep 17 00:00:00 2001 From: Anatoly Piskunov Date: Mon, 19 Oct 2026 12:30:07 +0200 Subject: [PATCH 002/105] PM: freeze the no-contest fee at announcement (HF16, 4.2.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pm_no_contest today opens a 12-hour dispute window and leaves the penalty to be computed from the median chain_properties_pm of the settlement moment, so a parameter vote inside the window silently repriced an announced void. The fork records the fee on the market at announcement and charges exactly that. Consensus: * pm_market_object gains no_contest_fee + no_contest_fee_frozen. The flag is separate from the amount on purpose: a row announced before the fork has frozen == false and keeps the legacy median computation (no seed pass), while a legitimate frozen zero (penalty percent voted to 0) must not fall back to a later vote. * pm_no_contest_evaluator freezes max(0, dispute_fee * pct / 10000) at announcement; settle_market(win < 0) charges the frozen value under HF16. * pm_oracle_object gains no_contest_slashed_total, grown in the same modify() as total_insurance_slashed, for exact attribution without a virtual op. * Registration in hardfork.d/16.hf + database_hardfork.cpp, CHAIN_VERSION 4.2.0 in both configs, snapshot import guarded by contains() for the new fields. Activation timestamps are placeholders pending the owner's mainnet date. API (no consensus): * get_oracle / list_oracles: no_contest_pending_count / _stake / _fee_nominal / _fee_capped over by_oracle_finalized (open obligations only, per-block cache). * market_card: settlement_charge — the price of a void before it is charged. * get_pm_chain_properties: derived pm_no_contest_fee_per_market, additive. Proof: tests/consensus_sim/scenarios/test_pm_no_contest_fee.cpp drives three markets around the activation block (legacy, frozen 1u, frozen 0), moves the median afterwards, and measures the insurance drop and the counter: 9u total (8 + 1 + 0). pm_lifecycle_suite: 72 tests, no errors. Docs: operator checklist in English and Russian, API reference updated. --- .../ru/docs/plugins/prediction-market-api.md | 6 +- .../pm-no-contest-fee-freeze.md | 154 ++++++++++ docs/plugins/prediction-market-api.md | 8 +- .../pm-no-contest-fee-freeze.md | 154 ++++++++++ libraries/chain/database_hardfork.cpp | 9 +- libraries/chain/hardfork.d/0-preamble.hf | 15 +- libraries/chain/hardfork.d/15.hf | 7 +- libraries/chain/hardfork.d/16.hf | 48 ++++ .../include/graphene/chain/pm_objects.hpp | 22 +- libraries/chain/pm_evaluator.cpp | 17 ++ libraries/chain/pm_process_markets.cpp | 16 +- .../include/graphene/protocol/config.hpp | 11 +- .../graphene/protocol/config_testnet.hpp | 10 +- .../prediction_market_api.hpp | 19 +- .../prediction_market_api.cpp | 90 +++++- plugins/snapshot/plugin.cpp | 11 + tests/consensus_sim/CMakeLists.txt | 1 + .../scenarios/test_pm_no_contest_fee.cpp | 262 ++++++++++++++++++ 18 files changed, 823 insertions(+), 37 deletions(-) create mode 100644 @l10n/ru/docs/prediction-markets/pm-no-contest-fee-freeze.md create mode 100644 docs/prediction-markets/pm-no-contest-fee-freeze.md create mode 100644 libraries/chain/hardfork.d/16.hf create mode 100644 tests/consensus_sim/scenarios/test_pm_no_contest_fee.cpp 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/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..9d89183616 --- /dev/null +++ b/@l10n/ru/docs/prediction-markets/pm-no-contest-fee-freeze.md @@ -0,0 +1,154 @@ +# HF16 — заморозка цены no-contest: чеклист активации и семантика бэкфилла + +Статус: **реализовано, но не запланировано.** Форк зарегистрирован в обеих конфигурациях +(`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. +* **Таймстемпы в `16.hf` — PLACEHOLDER'ы, они не объявлены.** + `CHAIN_HARDFORK_16_TIME` = 2026-11-15 00:00:00 UTC для `BUILD_TESTNET` и 2026-12-01 00:00:00 UTC для + production-конфига. Они нужны, чтобы бинарь собирался и форк можно было прогнать; дату mainnet + называет владелец, и объявлять её нужно заранее. Держать значение в **будущем**: с прошедшим + таймстемпом форк активируется в том блоке, где случился кворум, и объявляемого момента нет. + Менять его вместе с текстом объявления и никогда — после публикации. +* **Версия, а не ревизия.** `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/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/prediction-markets/pm-no-contest-fee-freeze.md b/docs/prediction-markets/pm-no-contest-fee-freeze.md new file mode 100644 index 0000000000..3a2b138cce --- /dev/null +++ b/docs/prediction-markets/pm-no-contest-fee-freeze.md @@ -0,0 +1,154 @@ +# HF16 — no-contest fee freeze: activation checklist and backfill semantics + +Status: **implemented, not scheduled.** 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. +* **The timestamps in `16.hf` are PLACEHOLDERS and are not announced.** + `CHAIN_HARDFORK_16_TIME` = 2026-11-15 00:00:00 UTC for `BUILD_TESTNET` and 2026-12-01 00:00:00 UTC for + the production config. They exist so the binary compiles and can be exercised; the mainnet date is + named by the owner and must be announced well before the timestamp. 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/libraries/chain/database_hardfork.cpp b/libraries/chain/database_hardfork.cpp index 54fa0fd116..34405d0383 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, diff --git a/libraries/chain/hardfork.d/0-preamble.hf b/libraries/chain/hardfork.d/0-preamble.hf index a7dfc85d97..db34891595 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 (no-contest 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..bcb43a0999 --- /dev/null +++ b/libraries/chain/hardfork.d/16.hf @@ -0,0 +1,48 @@ +// 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 1794700800 // 2026-11-15 00:00:00 UTC (PLACEHOLDER — not announced) +#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. +// +// PLACEHOLDER — NOT ANNOUNCED. The owner names the mainnet date; this value exists only so the +// binary has a future milestone to compile and test against. 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 any announcement text, never after one is published. +#define CHAIN_HARDFORK_16_TIME 1796083200 // 2026-12-01 00:00:00 UTC (PLACEHOLDER — not announced) +#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/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/pm_evaluator.cpp b/libraries/chain/pm_evaluator.cpp index d5eec112da..5da2e8e99f 100644 --- a/libraries/chain/pm_evaluator.cpp +++ b/libraries/chain/pm_evaluator.cpp @@ -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/protocol/include/graphene/protocol/config.hpp b/libraries/protocol/include/graphene/protocol/config.hpp index 6d54a0eeb3..c3feb99f9d 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 (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. 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" diff --git a/libraries/protocol/include/graphene/protocol/config_testnet.hpp b/libraries/protocol/include/graphene/protocol/config_testnet.hpp index cd5ea5ca75..b683738828 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" diff --git a/plugins/prediction_market_api/include/graphene/plugins/prediction_market_api/prediction_market_api.hpp b/plugins/prediction_market_api/include/graphene/plugins/prediction_market_api/prediction_market_api.hpp index 460c88bbf8..bad2b7ecee 100644 --- a/plugins/prediction_market_api/include/graphene/plugins/prediction_market_api/prediction_market_api.hpp +++ b/plugins/prediction_market_api/include/graphene/plugins/prediction_market_api/prediction_market_api.hpp @@ -32,6 +32,18 @@ namespace graphene { namespace plugins { namespace prediction_market_api { // resolution_time_hist buckets. 0 until it has resolved at least one market (P5). uint32_t resolution_time_p50 = 0; uint32_t resolution_time_p95 = 0; + // No-contest price BEFORE it is charged: this oracle's markets that are void-announced and + // still inside the dispute grace (status 3, payout pending, no dispute filed), with stake on + // them. `no_contest_pending_stake` is the bet base, `fee_nominal` = Σ per-market price and + // `fee_capped` = min(fee_nominal, insurance) — the settlement caps per market, so the capped + // number is an upper bound, and both are shown because a cap hit would otherwise hide the + // nominal. Computed on read (per-block cache), display-only. + uint32_t no_contest_pending_count = 0; + // `= 0`, not `(0)`: inside a class body `share_type x(0);` is parsed as a member FUNCTION + // declaration (parameter type int), and FC_REFLECT then fails on a pointer-to-member-function. + share_type no_contest_pending_stake = 0; + share_type no_contest_fee_nominal = 0; + share_type no_contest_fee_capped = 0; }; // A bet plus the parimutuel payout it would receive if its side wins (or its @@ -225,7 +237,9 @@ namespace graphene { namespace plugins { namespace prediction_market_api { DEFINE_API_ARGS(get_lazy_withdraw_requests, msg_pack, std::vector) DEFINE_API_ARGS(get_deferred_claims, msg_pack, std::vector) DEFINE_API_ARGS(get_account_commits, msg_pack, std::vector) - DEFINE_API_ARGS(get_pm_chain_properties, msg_pack, chain_properties_pm) + // Returns the median chain_properties_pm as an object PLUS derived read-only fields + // (pm_no_contest_fee_per_market) — a variant rather than the bare struct, additive for clients. + DEFINE_API_ARGS(get_pm_chain_properties, msg_pack, fc::variant) DEFINE_API_ARGS(get_market_meta, msg_pack, pm_market_meta_object) DEFINE_API_ARGS(list_markets_by_category, msg_pack, std::vector) DEFINE_API_ARGS(list_markets_by_event, msg_pack, std::vector) @@ -310,7 +324,8 @@ namespace graphene { namespace plugins { namespace prediction_market_api { FC_REFLECT((graphene::plugins::prediction_market_api::pm_oracle_api_object), (oracle)(reliability_score)(markets_awaiting_resolution)(oldest_unresolved_age) - (resolution_time_p50)(resolution_time_p95)) + (resolution_time_p50)(resolution_time_p95) + (no_contest_pending_count)(no_contest_pending_stake)(no_contest_fee_nominal)(no_contest_fee_capped)) FC_REFLECT((graphene::plugins::prediction_market_api::pm_position_api_object), (bet)(expected_payout)(market_status)(resolved_outcome)) FC_REFLECT((graphene::plugins::prediction_market_api::pm_weight_entry), diff --git a/plugins/prediction_market_api/prediction_market_api.cpp b/plugins/prediction_market_api/prediction_market_api.cpp index c39e7e1aba..8d03abb5c7 100644 --- a/plugins/prediction_market_api/prediction_market_api.cpp +++ b/plugins/prediction_market_api/prediction_market_api.cpp @@ -144,6 +144,56 @@ namespace graphene { namespace plugins { namespace prediction_market_api { return r; } + // No-contest price BEFORE settlement: this oracle's void-announced markets still inside the + // dispute grace (status 3, payout pending, no dispute filed — a filed dispute flips + // payout_status away from 1 and makes the penalty conditional, so it is excluded) that have + // stake on them. Prices per market: the HF16 frozen fee when the row carries one, otherwise + // the legacy median computation — the same branch settle_market(win < 0) takes. Walk is over + // by_oracle_finalized (the oracle's OPEN obligations: finalized_time == 0 sorts first), so + // the oracle's resolved history is never read. Display-only; cached per (block, oracle). + struct oracle_nc_pending_info { + // `= 0`, not `(0)`: in a class body `share_type x(0);` declares a member function. + uint32_t count = 0; + share_type stake = 0; + share_type fee_nominal = 0; + share_type fee_capped = 0; + }; + oracle_nc_pending_info oracle_no_contest_pending(const database& db, const account_name_type& owner) { + struct entry { uint32_t epoch = 0; oracle_nc_pending_info v; }; + static thread_local std::map cache; + const uint32_t epoch = db.head_block_num(); + auto cit = cache.find(owner); + if (cit != cache.end() && cit->second.epoch == epoch) return cit->second.v; + if (cache.size() > 512) cache.clear(); + + oracle_nc_pending_info r; + const auto& mp = db.get_validator_schedule_object().median_props; + const int64_t legacy_unit = + (int64_t)(fc::uint128_t((uint64_t)mp.pm_dispute_fee.amount.value) + * (uint64_t)mp.pm_no_contest_penalty_percent / 10000).lo; + const auto& idx = db.get_index().indices().get(); + auto it = idx.lower_bound(boost::make_tuple(owner, fc::time_point_sec(), pm_market_id_type())); + for (; it != idx.end() && it->oracle == owner + && it->finalized_time == fc::time_point_sec(); ++it) { + if (it->status != 3 || it->payout_status != 1 || it->resolved_outcome >= 0) continue; + if (it->bets_sum.value <= 0) continue; // settle_market charges only with stake + ++r.count; + r.stake += it->bets_sum; + r.fee_nominal += share_type(it->no_contest_fee_frozen ? it->no_contest_fee.value + : legacy_unit); + } + const auto& oidx = db.get_index().indices().get(); + auto oit = oidx.find(owner); + const int64_t ins = (oit != oidx.end()) ? oit->insurance.value : 0; + // Upper bound: settlement caps EACH market at the insurance available then, so the sum + // of per-market prices can never exceed the current insurance; the nominal is reported + // alongside so a cap hit is visible instead of silently swallowed. + r.fee_capped = share_type(r.fee_nominal.value < ins ? r.fee_nominal.value : ins); + + cache[owner] = entry{epoch, r}; + return r; + } + // p50/p95 resolution latency (seconds) from the oracle's 8-bucket histogram: the UPPER // boundary of the bucket where the cumulative count first reaches the target percentile. The // open top bucket (>30d) reports its lower edge (2592000) as a floor. 0 if no resolutions yet. @@ -631,6 +681,26 @@ namespace graphene { namespace plugins { namespace prediction_market_api { o["image"] = image; o["category"] = category; o["metadata"] = fc::variant(std::move(md)); // reconstructed; clients parse market.metadata + // Price of a no-contest BEFORE it is charged: non-zero only while the market is + // void-announced and still disputable (status 3, payout pending, no dispute filed) and has + // stake. Frozen fee when the row carries one (HF16), otherwise the median unit — exactly + // the branch settle_market(win < 0) takes — capped by the oracle's insurance. + int64_t charge = 0; + if (m.status == 3 && m.payout_status == 1 && m.resolved_outcome < 0 && m.bets_sum.value > 0) { + int64_t want; + if (m.no_contest_fee_frozen) { + want = m.no_contest_fee.value; + } else { + const auto& mp = db.get_validator_schedule_object().median_props; + want = (int64_t)(fc::uint128_t((uint64_t)mp.pm_dispute_fee.amount.value) + * (uint64_t)mp.pm_no_contest_penalty_percent / 10000).lo; + } + const auto& oidx = db.get_index().indices().get(); + auto oit = oidx.find(m.oracle); + const int64_t ins = (oit != oidx.end()) ? oit->insurance.value : 0; + charge = want < ins ? want : ins; + } + o["settlement_charge"] = charge; return fc::variant(std::move(o)); } @@ -1124,9 +1194,11 @@ namespace graphene { namespace plugins { namespace prediction_market_api { auto itr = idx.find(owner); FC_ASSERT(itr != idx.end(), "Oracle not found"); auto aw = oracle_awaiting(db, itr->owner); + auto nc = oracle_no_contest_pending(db, itr->owner); return pm_oracle_api_object{pm_oracle_object(*itr), reliability_score(*itr, db.head_block_time()), aw.count, aw.oldest_age, - rt_percentile(*itr, 50), rt_percentile(*itr, 95)}; + rt_percentile(*itr, 50), rt_percentile(*itr, 95), + nc.count, nc.stake, nc.fee_nominal, nc.fee_capped}; }); } @@ -1405,7 +1477,17 @@ namespace graphene { namespace plugins { namespace prediction_market_api { CHECK_ARG_SIZE(0) auto& db = pimpl->database(); return db.with_weak_read_lock([&]() { - return db.get_validator_schedule_object().median_props; + const auto& mp = db.get_validator_schedule_object().median_props; + // One derived number clients/scripts would otherwise recompute by hand (and have got + // wrong): the nominal no-contest penalty per market, dispute_fee × pct / 10000. The + // settlement still caps it by the oracle's insurance per market. + fc::variant v; + fc::to_variant(mp, v); + fc::mutable_variant_object o(v.get_object()); + o["pm_no_contest_fee_per_market"] = + (int64_t)(fc::uint128_t((uint64_t)mp.pm_dispute_fee.amount.value) + * (uint64_t)mp.pm_no_contest_penalty_percent / 10000).lo; + return fc::variant(std::move(o)); }); } @@ -1964,9 +2046,11 @@ namespace graphene { namespace plugins { namespace prediction_market_api { auto oit = oidx.find(mkt.oracle); if (oit != oidx.end()) { auto oaw = oracle_awaiting(db, oit->owner); + auto onc = oracle_no_contest_pending(db, oit->owner); oracle = pm_oracle_api_object{pm_oracle_object(*oit), reliability_score(*oit, db.head_block_time()), oaw.count, oaw.oldest_age, - rt_percentile(*oit, 50), rt_percentile(*oit, 95)}; + rt_percentile(*oit, 50), rt_percentile(*oit, 95), + onc.count, onc.stake, onc.fee_nominal, onc.fee_capped}; } } diff --git a/plugins/snapshot/plugin.cpp b/plugins/snapshot/plugin.cpp index c736774f28..80126a2c78 100644 --- a/plugins/snapshot/plugin.cpp +++ b/plugins/snapshot/plugin.cpp @@ -843,6 +843,10 @@ inline uint32_t import_pm_oracles(graphene::chain::database& db, const fc::varia obj.resolved_late_count = static_cast(v["resolved_late_count"].as_uint64()); if (v.get_object().contains("resolution_time_hist")) obj.resolution_time_hist = v["resolution_time_hist"].as>(); + // HF16: forward-accumulating no-contest attribution counter; absent in pre-HF16 + // snapshots, where 0 is the correct starting point (no backfill — see pm_objects.hpp). + if (v.get_object().contains("no_contest_slashed_total")) + obj.no_contest_slashed_total = v["no_contest_slashed_total"].as(); }); ++count; } @@ -914,6 +918,13 @@ inline uint32_t import_pm_markets(graphene::chain::database& db, const fc::varia // default to 0; reveal/forfeit decrements are clamped, so drift can only relax the cap). if (v.get_object().contains("open_commits")) obj.open_commits = static_cast(v["open_commits"].as_uint64()); + // HF16: per-market frozen no-contest price. Both fields move together; a snapshot from + // before the fork has neither, and the defaults (0/false) are exactly the "legacy row" + // state the settlement expects. + if (v.get_object().contains("no_contest_fee")) + obj.no_contest_fee = v["no_contest_fee"].as(); + if (v.get_object().contains("no_contest_fee_frozen")) + obj.no_contest_fee_frozen = v["no_contest_fee_frozen"].as(); // NB: `metadata` is no longer a consensus field (moved off-chain to the // prediction_market_api plugin); older snapshots that still carry it are ignored. }); diff --git a/tests/consensus_sim/CMakeLists.txt b/tests/consensus_sim/CMakeLists.txt index 2f3080b39a..0d0454ff42 100644 --- a/tests/consensus_sim/CMakeLists.txt +++ b/tests/consensus_sim/CMakeLists.txt @@ -71,6 +71,7 @@ set(SCENARIO_SOURCES # The single-validator fixture reaches quorum only with testnet constants. if(BUILD_TESTNET) list(APPEND SCENARIO_SOURCES scenarios/test_pm_audit_fixes.cpp) + list(APPEND SCENARIO_SOURCES scenarios/test_pm_no_contest_fee.cpp) list(APPEND SCENARIO_SOURCES scenarios/test_agent_access.cpp) else() message(STATUS "PM audit consensus regressions require BUILD_TESTNET=ON") diff --git a/tests/consensus_sim/scenarios/test_pm_no_contest_fee.cpp b/tests/consensus_sim/scenarios/test_pm_no_contest_fee.cpp new file mode 100644 index 0000000000..17f8853674 --- /dev/null +++ b/tests/consensus_sim/scenarios/test_pm_no_contest_fee.cpp @@ -0,0 +1,262 @@ +// HF16: the no-contest fee is frozen when the oracle ANNOUNCES the void, not when the market +// settles. Without the freeze the bill is priced from whatever median the parameter vote has +// reached by settlement time — a price the oracle (and everyone else) only learns after the +// announcement. The regression pins all three states the flag distinguishes: +// * a row announced BEFORE the fork keeps the legacy median-at-settlement pricing, +// * a row announced AFTER the fork charges the frozen amount even if the vote moves, +// * a frozen amount of 0 stays 0 (never falls back to a later vote). +// Only BUILD_TESTNET can reach HF14/HF16 with the single-validator harness (see CMakeLists). +#include +#include "simulated_node.hpp" +#include "genesis_factory.hpp" +#include "virtual_clock.hpp" +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace consensus_sim; +using namespace graphene::chain; +using namespace graphene::protocol; + +namespace { + +account_name_type next_validator(simulated_node& n) { return n.db().get_scheduled_validator(1); } + +fc::ecc::private_key key_for(const genesis_params& gp, const account_name_type& v) { + return (v == gp.initiator_name) ? gp.initiator_key : gp.genesis_witness_key; +} + +void produce(simulated_node& n, const genesis_params& gp, fc::time_point_sec& when) { + when += fc::seconds(CHAIN_BLOCK_INTERVAL); + const auto v = next_validator(n); + n.produce_block(v, key_for(gp, v), when); +} + +// Identical operation batches signed by the same key in the same block hash to the same trx_id; +// the expiration is the only free field, so `nonce` shifts it. +signed_transaction sign_ops(const std::vector& ops, + const fc::ecc::private_key& key, const simulated_node& node, + uint32_t nonce = 0) { + signed_transaction tx; + tx.set_reference_block(node.head_block_id()); + tx.set_expiration(node.head_block_time() + fc::seconds(60 + (nonce % 1800))); + for (const auto& op : ops) tx.operations.emplace_back(op); + tx.sign(key, node.chain_id()); + return tx; +} + +authority single_key_auth(const public_key_type& k) { + authority a; a.weight_threshold = 1; a.key_auths[k] = 1; return a; +} + +fc::ecc::private_key derive_key(const std::string& name) { + return fc::ecc::private_key::regenerate(fc::sha256::hash(name)); +} + +// Register the initiator as a staked, self-voted validator and advance until HF14 activates. +bool bring_to_hf14(simulated_node& node, const genesis_params& gp, fc::time_point_sec& when) { + try { + produce(node, gp, when); // block 1 (committee gap-filler) + const share_type bal = node.db().get_account(gp.initiator_name).balance.amount; + transfer_to_vesting_operation tv; + tv.from = gp.initiator_name; tv.to = gp.initiator_name; + tv.amount = asset(share_type(bal.value / 2), TOKEN_SYMBOL); + validator_update_operation vu; + vu.owner = gp.initiator_name; vu.url = "viz"; + vu.block_signing_key = gp.initiator_key.get_public_key(); + account_validator_vote_operation vv; + vv.account = gp.initiator_name; vv.validator = gp.initiator_name; vv.approve = true; + node.push_pending_transaction(sign_ops({tv, vu, vv}, gp.initiator_key, node)); + for (int i = 0; i < 300 && !node.db().has_hardfork(CHAIN_HARDFORK_14); ++i) + produce(node, gp, when); + } catch (const std::exception& e) { + BOOST_TEST_MESSAGE(std::string("could not reach HF14 (harness/genesis limit): ") + e.what()); + } + return node.db().has_hardfork(CHAIN_HARDFORK_14); +} + +void create_and_fund(simulated_node& node, const genesis_params& gp, fc::time_point_sec& when, + const std::string& name, const fc::ecc::private_key& key, share_type liquid) { + const auto pub = key.get_public_key(); + const auto& mp = node.db().get_validator_schedule_object().median_props; + account_create_operation ac; + ac.fee = mp.account_creation_fee; + ac.delegation = asset(0, SHARES_SYMBOL); + ac.creator = gp.initiator_name; + ac.new_account_name = name; + ac.master = single_key_auth(pub); + ac.active = single_key_auth(pub); + ac.regular = single_key_auth(pub); + ac.memo_key = pub; + transfer_operation tr; + tr.from = gp.initiator_name; tr.to = name; tr.amount = asset(liquid, TOKEN_SYMBOL); + node.push_pending_transaction(sign_ops({ac, tr}, gp.initiator_key, node)); + produce(node, gp, when); +} + +// Blocks sufficient for the settle cron to traverse the dispute grace from an announcement. +int cron_grace_blocks(simulated_node& node) { + const uint64_t grace = node.db().get_validator_schedule_object().median_props.pm_dispute_grace_sec; + return (int)(grace / CHAIN_BLOCK_INTERVAL) + 500; +} + +// Push a PM parameter vote and advance until the median actually reflects it. The harness runs a +// single validator, so its own vote IS the median once the tally recomputes. +void set_pm_median(simulated_node& node, const genesis_params& gp, fc::time_point_sec& when, + int64_t dispute_fee, uint16_t penalty_percent, uint32_t grace_sec) { + chain_properties_pm props; + props.pm_dispute_fee = asset(share_type(dispute_fee), TOKEN_SYMBOL); + props.pm_dispute_grace_sec = grace_sec; + props.pm_no_contest_penalty_percent = penalty_percent; + versioned_chain_properties_update_operation vp; + vp.owner = gp.initiator_name; vp.props = props; + node.push_pending_transaction(sign_ops({vp}, gp.initiator_key, node)); + const auto& mp = node.db().get_validator_schedule_object().median_props; + for (int i = 0; i < 120 && (mp.pm_dispute_fee.amount.value != dispute_fee + || mp.pm_no_contest_penalty_percent != penalty_percent); ++i) + produce(node, gp, when); + BOOST_REQUIRE_EQUAL(mp.pm_dispute_fee.amount.value, dispute_fee); + BOOST_REQUIRE_EQUAL(mp.pm_no_contest_penalty_percent, penalty_percent); +} + +// Create + accept a binary market, place two opposing bets of `unit`, return its id. +pm_market_id_type active_market_with_bets(simulated_node& node, const genesis_params& gp, + fc::time_point_sec& when, + const fc::ecc::private_key& orac_key, + const std::string& creator, + const fc::ecc::private_key& alice_key, + const fc::ecc::private_key& bob_key, + int64_t unit) { + pm_create_market_operation cm; + cm.creator = creator; cm.oracle = "orac"; + cm.market_type = 0; cm.outcomes = {"A", "B"}; cm.url = "criteria"; + cm.liquidity = asset(share_type(unit * 4), TOKEN_SYMBOL); + cm.betting_expiration = node.head_block_time() + fc::seconds(60); + cm.result_expiration = node.head_block_time() + fc::seconds(120); + cm.dispute_mode = 0; + node.push_pending_transaction(sign_ops({cm}, gp.initiator_key, node, (uint32_t)node.head_block_num())); + produce(node, gp, when); + const auto mid = node.db().get_index().indices().get().rbegin()->id; + + pm_oracle_accept_market_operation acc; + acc.oracle = "orac"; acc.market_id = mid._id; acc.accept = true; + node.push_pending_transaction(sign_ops({acc}, orac_key, node)); + produce(node, gp, when); + + pm_place_bet_operation ba; + ba.account = "alice"; ba.market_id = mid._id; ba.side = 0; ba.outcome_index = -1; + ba.amount = asset(share_type(unit), TOKEN_SYMBOL); ba.mode = 0; + pm_place_bet_operation bb = ba; + bb.account = "bob"; bb.side = 1; + node.push_pending_transaction(sign_ops({ba}, alice_key, node)); + node.push_pending_transaction(sign_ops({bb}, bob_key, node)); + produce(node, gp, when); + return mid; +} + +void announce_no_contest(simulated_node& node, const genesis_params& gp, fc::time_point_sec& when, + const fc::ecc::private_key& orac_key, const pm_market_id_type& mid) { + pm_no_contest_operation nc; + nc.oracle = "orac"; nc.market_id = mid._id; nc.reason = "cannot judge"; + node.push_pending_transaction(sign_ops({nc}, orac_key, node)); + produce(node, gp, when); +} + +} // namespace + +BOOST_AUTO_TEST_CASE(pm_no_contest_fee_three_states) { + auto gp = make_genesis_params(0xA16C0u, 1); + fc::time_point_sec start(fc::time_point::now()); + if (fc::time_point_sec(CHAIN_HARDFORK_14_TIME) > start) start = fc::time_point_sec(CHAIN_HARDFORK_14_TIME); + start += fc::seconds(CHAIN_BLOCK_INTERVAL); + virtual_clock clk(start); + simulated_node node("pm-nc-freeze", gp, clk); + fc::time_point_sec when = start - fc::seconds(CHAIN_BLOCK_INTERVAL); + + if (!bring_to_hf14(node, gp, when)) { + BOOST_TEST_MESSAGE("HF14 not reachable; skipping no-contest fee freeze."); + return; + } + const int64_t unit = node.db().get_validator_schedule_object().median_props.pm_min_liquidity.amount.value; + const int64_t min_insurance = + node.db().get_validator_schedule_object().median_props.pm_min_oracle_insurance.amount.value; + BOOST_REQUIRE_GE(min_insurance, unit * 10); // the legacy leg below charges 8u in one go + + auto alice_key = derive_key("alice"), bob_key = derive_key("bob"); + create_and_fund(node, gp, when, "alice", alice_key, share_type(unit * 4)); + create_and_fund(node, gp, when, "bob", bob_key, share_type(unit * 4)); + + auto orac_key = derive_key("orac"); + const int64_t reg_fee = node.db().get_validator_schedule_object().median_props.pm_oracle_registration_fee.amount.value; + create_and_fund(node, gp, when, "orac", orac_key, share_type(min_insurance + reg_fee)); + pm_oracle_register_operation oreg; + oreg.owner = "orac"; + oreg.insurance = asset(share_type(min_insurance), TOKEN_SYMBOL); + oreg.fixed_fee = asset(0, TOKEN_SYMBOL); oreg.rules_url = ""; + node.push_pending_transaction(sign_ops({oreg}, orac_key, node)); + produce(node, gp, when); + + // Announcement-time price: 50% of a 2u dispute fee = 1u. + set_pm_median(node, gp, when, unit * 2, 5000, 3600); + + // Market 0: announced BEFORE the fork. Legacy rows must keep the pre-fork pricing rule. + const auto m0 = active_market_with_bets(node, gp, when, orac_key, gp.initiator_name, + alice_key, bob_key, unit); + announce_no_contest(node, gp, when, orac_key, m0); + BOOST_CHECK_EQUAL(node.db().get(m0).no_contest_fee_frozen, false); + BOOST_CHECK_EQUAL(node.db().get(m0).no_contest_fee.value, 0); + + // The fork activates while market 0 sits in its dispute window: the row carries no frozen + // amount, so no seed pass is needed and settlement falls back to the median (legacy). + node.db().set_hardfork(CHAIN_HARDFORK_16, true); + BOOST_REQUIRE(node.db().has_hardfork(CHAIN_HARDFORK_16)); + + // Market 1: announced UNDER the fork at the same 2u/50% median → frozen 1u. + const auto m1 = active_market_with_bets(node, gp, when, orac_key, gp.initiator_name, + alice_key, bob_key, unit); + announce_no_contest(node, gp, when, orac_key, m1); + BOOST_CHECK_EQUAL(node.db().get(m1).no_contest_fee_frozen, true); + BOOST_CHECK_EQUAL(node.db().get(m1).no_contest_fee.value, unit); + + // Market 2: announced with a 0% penalty → frozen ZERO. The flag must still be set, otherwise + // a later vote would silently attach a price to an already-announced void. + set_pm_median(node, gp, when, unit * 2, 0, 3600); + const auto m2 = active_market_with_bets(node, gp, when, orac_key, gp.initiator_name, + alice_key, bob_key, unit); + announce_no_contest(node, gp, when, orac_key, m2); + BOOST_CHECK_EQUAL(node.db().get(m2).no_contest_fee_frozen, true); + BOOST_CHECK_EQUAL(node.db().get(m2).no_contest_fee.value, 0); + + // Move the median AFTER all three announcements: 8u at 100%. Legacy market 0 should be charged + // the new price (pre-fork behaviour of a row that never froze); frozen markets must not move. + set_pm_median(node, gp, when, unit * 8, 10000, 3600); + + const auto& oidx = node.db().get_index().indices().get(); + const int64_t insurance_before = oidx.find(account_name_type("orac"))->insurance.value; + const int64_t nc_slashed_before = oidx.find(account_name_type("orac"))->no_contest_slashed_total.value; + + for (int i = 0; i < cron_grace_blocks(node) && + (node.db().get(m0).payout_status != 3 || + node.db().get(m1).payout_status != 3 || + node.db().get(m2).payout_status != 3); ++i) + produce(node, gp, when); + + BOOST_REQUIRE_EQUAL(node.db().get(m0).payout_status, 3); + BOOST_REQUIRE_EQUAL(node.db().get(m1).payout_status, 3); + BOOST_REQUIRE_EQUAL(node.db().get(m2).payout_status, 3); + + const int64_t insurance_after = oidx.find(account_name_type("orac"))->insurance.value; + const int64_t nc_slashed_after = oidx.find(account_name_type("orac"))->no_contest_slashed_total.value; + + // Legacy row: charged the CURRENT median (8u), exactly as before the fork. + // Frozen rows: charged what they announced (1u and 0), despite the same median move. + BOOST_TEST_MESSAGE("no-contest fees: legacy=" << (unit * 8) << " frozen=" << unit + << " zero=0; insurance " << insurance_before << "->" << insurance_after); + BOOST_CHECK_EQUAL(insurance_before - insurance_after, unit * 9); + BOOST_CHECK_EQUAL(nc_slashed_after - nc_slashed_before, unit * 9); +} From 3080ddb338fa897d9acae6b0256f8eb24105645d Mon Sep 17 00:00:00 2001 From: Anatoly Piskunov Date: Mon, 19 Oct 2026 12:56:49 +0200 Subject: [PATCH 003/105] Add node_admin plugin: loopback admin API with bearer tokens (read-only v1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An administrative JSON-RPC endpoint inside the node process, for operator tooling and AI agents. It opens its own HTTP listener rather than riding on webserver: that one is published through nginx, so every request already arrives as 127.0.0.1 and a source-address check there protects nothing. - Authentication: `label:scope[,scope]:sha256hex[:expires-unixtime]`, only the digest is stored, comparison is constant-time, and a token carrying no `read` scope gets -32002. - Guards: loopback-only unless `admin-allow-remote`, no listener without tokens, POST only, 64 KB body cap, 20 req/s per token, log tails restricted to an explicit `admin-log-file` allow-list. - Read-only methods: ping, node_info, diagnostics, plugins.list, config.get, logs.tail, audit.tail. Numbers are emitted as JSON numbers, not fc's default quoted form. - Every outcome, including failed authentication and rate limiting, appends one JSON audit line with a label, never the token or its hash. - p2p: add get_connections_count_sync() — the peer map belongs to the p2p thread, so diagnostics reads it through fc::thread::async().wait(). - Docs in English and Russian; live acceptance in plugins/node_admin/tests/acceptance.sh (35 checks against a throwaway node). Mutating methods, plan/apply and restart are the next stage. --- @l10n/ru/docs/plugins/node-admin.md | 211 ++++ @l10n/ru/docs/plugins/overview.md | 1 + docs/plugins/node-admin.md | 210 ++++ docs/plugins/overview.md | 1 + plugins/node_admin/CMakeLists.txt | 38 + .../plugins/node_admin/node_admin_plugin.hpp | 59 + plugins/node_admin/node_admin_plugin.cpp | 1053 +++++++++++++++++ plugins/node_admin/tests/acceptance.sh | 191 +++ .../graphene/plugins/p2p/p2p_plugin.hpp | 12 + plugins/p2p/p2p_plugin.cpp | 8 + programs/vizd/CMakeLists.txt | 1 + programs/vizd/main.cpp | 2 + 12 files changed, 1787 insertions(+) create mode 100644 @l10n/ru/docs/plugins/node-admin.md create mode 100644 docs/plugins/node-admin.md create mode 100644 plugins/node_admin/CMakeLists.txt create mode 100644 plugins/node_admin/include/graphene/plugins/node_admin/node_admin_plugin.hpp create mode 100644 plugins/node_admin/node_admin_plugin.cpp create mode 100755 plugins/node_admin/tests/acceptance.sh diff --git a/@l10n/ru/docs/plugins/node-admin.md b/@l10n/ru/docs/plugins/node-admin.md new file mode 100644 index 0000000000..172271f9ea --- /dev/null +++ b/@l10n/ru/docs/plugins/node-admin.md @@ -0,0 +1,211 @@ +# Административный API ноды (`node_admin`) + +Административный JSON-RPC-эндпоинт самого процесса ноды: здоровье, конфигурация, +состояние плагинов, логи и аудит. Предназначен для инструментов оператора и AI-агентов, +у которых есть bearer-токен; не для конечных пользователей и не вместо публичного API. + +> **v1 работает только на чтение.** Он не меняет конфигурацию, не включает/выключает +> плагины, не перезапускает процесс и не трогает ключ делегата. Изменяющие методы — +> следующий этап, на той же модели аутентификации и аудита с двухфазными `plan`/`apply`. + +--- + +## Почему отдельный слушатель + +Публичный 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-endpoint` задан, а токенов нет: админский +порт без аутентификации не бывает допустимым состоянием. + +### Токены + +Токен — 32 случайных байта, например `openssl rand -hex 32`. В конфигурации хранится +только его SHA-256; сравнение — constant-time. Скоупы: + +* `read` — все методы v1. +* `plugins`, `config`, `process`, `chain` — зарезервированы под изменяющие этапы; токен + только с ними пока не может вызвать ничего. + +Отзыв — правка строки и перезапуск. Необязательный `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` | Внутренняя ошибка | + +Аутентификация проверяется **до** разбора тела. + +--- + +## Методы + +### `admin.ping` + +```json +{"ok": true, "server_time": "2026-10-19T14:40:00Z"} +``` + +Проверка живости; базу цепи не трогает. + +### `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` | Последний необратимый блок | +| `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` без дублей. Реестр плагинов AppBase закрыт +(поиск по имени — protected, перечисления нет), поэтому видимое население — сам конфиг; +плагины, включённые только командной строкой, здесь не видны. + +У каждой записи есть `loaded`, `state` и `state_source`: + +* `state_source: "observed"` — состояние прочитано у живого плагина (`chain`, `p2p`, + `node_admin`). `state` — `registered`, `initialized`, `started` или `stopped`; `loaded` + равен false только пока плагин в состоянии `registered`. +* `state_source: "inferred"` — AppBase инициализирует каждую строку `plugin =` и падает на + старте при неизвестном имени, значит на работающей ноде сконфигурированный плагин загружен. + `state` при этом `null`, а не догадка. + +### `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":"***"}]}]} +``` + +### `admin.logs.tail` + +Параметры: `{"name":"node","bytes":16384,"lines":200}`. `name` обязан совпасть с +записью `admin-log-file`; ничего вне этого allowlist прочитать нельзя. `bytes` +ограничивается 64 КБ, `lines` — 2000. Строки, похожие на WIF, и секреты вида +`ключ = значение` маскируются. + +### `admin.audit.tail` + +Параметры: `{"lines":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` и ротируется в `.1` на 16 МБ. + +--- + +## Замечания по безопасности + +* Слушатель по умолчанию только на loopback; не ставьте его за nginx. +* Никогда не передавайте токен в query string — он попадёт в логи прокси и access-логи. +* Токен — это доступ к процессу ноды. Один токен на агента, чтобы аудит атрибутировал + вызовы, и минимальный набор скоупов. +* `admin.config.get` маскирует секреты по имени ключа, а не по форме значения. Секрет под + безобидным именем будет напечатан — не заводите таких ключей. +* Админский API никогда не отдаёт WIF-ключи; ни один метод этой версии ничего не пишет + на диск, кроме журнала аудита. + +--- + +## См. также + +* [Обзор плагинов](./overview.md) +* [Webserver](./webserver.md) — публичный API, который этот плагин намеренно не использует + +--- + +## Тесты + +`plugins/node_admin/tests/acceptance.sh` поднимает одноразовую ноду, дёргает слушатель +curl'ом и проверяет этот контракт (аутентификация, скоупы, маскирование, allowlist, коды, +аудит, лимит). В CTest не входит: нужны собранный `vizd` и свободный порт. 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/docs/plugins/node-admin.md b/docs/plugins/node-admin.md new file mode 100644 index 0000000000..221a66ea40 --- /dev/null +++ b/docs/plugins/node-admin.md @@ -0,0 +1,210 @@ +# Node Admin API (`node_admin`) + +Administrative JSON-RPC endpoint for the node process itself: health, configuration, +plugin state, logs and the audit trail. 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. + +> **v1 is read-only.** It cannot change configuration, enable/disable plugins, restart the +> process or touch the validator key. Mutating methods are a later stage and will follow +> the same authentication/audit model with two-phase `plan`/`apply`. + +--- + +## 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. | + +The plugin refuses to start if `admin-endpoint` is set but no token is configured: an +unauthenticated admin port is never a valid state. + +### 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 v1 method. +* `plugins`, `config`, `process`, `chain` — reserved for the mutating stages; a token that + carries only these cannot call anything yet. + +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. + +--- + +## Methods + +### `admin.ping` + +```json +{"ok": true, "server_time": "2026-10-19T14:40:00Z"} +``` + +Liveness check; does not touch the chain database. + +### `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 | +| `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 `plugin = …` entries from `config.ini`. 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`, `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. + +### `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":"***"}]}]} +``` + +### `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. + +--- + +## Audit + +Every request — including failed authentication, parse errors and rate limiting — appends +one JSON line: + +```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 to `.1` at 16 MB. + +--- + +## 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, and no method in this version writes to disk + except the audit log. + +--- + +## 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 (auth, scopes, masking, allow-list, status codes, audit, rate +limit). It is not part of CTest because it needs a built `vizd` and a free port. 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/plugins/node_admin/CMakeLists.txt b/plugins/node_admin/CMakeLists.txt new file mode 100644 index 0000000000..1879de4b0c --- /dev/null +++ b/plugins/node_admin/CMakeLists.txt @@ -0,0 +1,38 @@ +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::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/node_admin_plugin.cpp b/plugins/node_admin/node_admin_plugin.cpp new file mode 100644 index 0000000000..0ca51e5712 --- /dev/null +++ b/plugins/node_admin/node_admin_plugin.cpp @@ -0,0 +1,1053 @@ +#include + +#include +#include +#include +#include + +#include + +#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 { + +// 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 == node_admin_plugin::name()) return appbase::app().find_plugin(); + return nullptr; +} + +// ── 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 kAuditRotateBytes = 16 * 1024 * 1024; +const uint32_t kRatePerSecond = 20; + +// ── small string helpers ──────────────────────────────────────────────────────────────────── +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); +} + +std::string to_lower(std::string s) { + for (char &c : s) c = static_cast(std::tolower(static_cast(c))); + return s; +} + +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; + } +} + +/// 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()))); +} + +// ── secret masking ────────────────────────────────────────────────────────────────────────── +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" + }; + 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. +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***$3"); + out = std::regex_replace(out, inline_secret, "$1***"); + return out; +} + +// ── config file ──────────────────────────────────────────────────────────────────────────── +struct cfg_entry { + std::string section; + std::string key; + std::string value; +}; + +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(); + const std::string t = trim(line); + if (t.empty() || t[0] == '#' || 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; +} + +// ── 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; +} + +// ── 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 = 0; +}; + +typedef websocketpp::server websocket_server_type; + +// ── JSON-RPC error ───────────────────────────────────────────────────────────────────────── +struct rpc_error { + int code; + std::string message; + std::string reason; + std::string hint; +}; + +// ── tokens ───────────────────────────────────────────────────────────────────────────────── +struct token_info { + std::string label; + std::set scopes; + std::string hash_hex; + uint64_t expires = 0; +}; + +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", + "admin-token: unknown scope '${s}' (allowed: read, plugins, config, process, chain)", + ("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 + +// ── 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::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; + + // ── 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_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_config_get(const fc::variant ¶ms); + fc::variant h_logs_tail(const fc::variant ¶ms); + fc::variant h_audit_tail(const fc::variant ¶ms); + + // ── 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) { + try { + con->append_header("Content-Type", "application/json"); + con->append_header("Cache-Control", "no-store"); + con->set_body(body); + 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-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(); + + // 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_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 > 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(); + + 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); + }; + + 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(); + + 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", ""}; + } + + 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.config.get") return h_config_get(params); + if (method == "admin.logs.tail") return h_logs_tail(params); + if (method == "admin.audit.tail") return h_audit_tail(params); + + throw rpc_error{-32601, "method not found", method, ""}; +} + +// ── methods ────────────────────────────────────────────────────────────────────────────── +fc::mutable_variant_object node_admin_plugin::impl::make_node_info() const { + const graphene::chain::database &db = chain.db(); + 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)); + m("last_irreversible_block_num", + static_cast(db.get_dynamic_global_properties().last_irreversible_block_num)); + 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); +} + +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()) configured.insert(e.value); + } + 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; + const bfs::path shm = bfs::path(data_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; + if (!seen.insert(e.value).second) continue; + + const appbase::abstract_plugin *p = observe_plugin(e.value); + 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", e.value); + // 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)); + plugins.push_back(fc::variant(item)); + } + + fc::mutable_variant_object m; + m("config_path", config_path()); + m("config_exists", exists); + m("note", "Entries come from config.ini. Appbase keeps its plugin registry private, so " + "lifecycle state is observed only for chain, p2p and node_admin; for the others " + "loaded=true is implied by startup (an unknown plugin name aborts the node)."); + m("plugins", plugins); + 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("***") : e.value); + current_entries.push_back(fc::variant(item)); + } + if (have_section) flush(); + + fc::mutable_variant_object m; + m("path", config_path()); + m("exists", exists); + m("sections", sections); + 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); +} + +// ── 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 (!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)) { + ::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..312d54a7aa --- /dev/null +++ b/plugins/node_admin/tests/acceptance.sh @@ -0,0 +1,191 @@ +#!/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] +# +# Exit code: 0 = every check passed, 1 = at least one failed, 2 = the node never came up. +set -u + +VIZD=${1:-${VIZD:-build/programs/vizd/vizd}} +PORT=${PORT:-18099} +BASE="http://127.0.0.1:$PORT" +WORK=$(mktemp -d /tmp/vizd-node-admin.XXXXXX) + +GOOD=accept-good-token +OPS=accept-ops-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 + +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"; } + +check() { # check