diff --git a/.github/workflows/docker-pr-build.yml b/.github/workflows/docker-pr-build.yml index 38cc886a1a..1a9c67bc49 100644 --- a/.github/workflows/docker-pr-build.yml +++ b/.github/workflows/docker-pr-build.yml @@ -174,10 +174,66 @@ jobs: -DBUILD_CONSENSUS_TESTS=ON .. make -j"$(nproc)" \ pm_lmsr_vectors_tests pm_parimutuel_tests pm_leverage_tests \ - pm_meta_parse_tests pm_props_validate_tests + pm_meta_parse_tests pm_props_validate_tests \ + pm_agent_access_tests - name: Run ctest (PM suite) run: | set -euo pipefail cd build ctest -R '^pm_' --output-on-failure + + # No other job compiles the testnet configuration, and no other job runs the + # fork-gated PM regressions. tests/consensus_sim/scenarios/test_pm_audit_fixes.cpp + # is appended by CMake only under -DBUILD_TESTNET=TRUE (the scenarios there need + # HF14 to activate, which the single-validator fixture can only reach with testnet + # constants), while the default Docker build never passes that flag and pm_tests + # builds only the PM unit targets. Compiling alone would not be enough either: the + # hardfork registry is validated by asserts that fire when a node opens the + # database (last_hardfork <= CHAIN_NUM_HARDFORKS, _hardfork_versions[last] <= + # CHAIN_VERSION), so the simulator has to actually run. Gated on the + # `testnet-config` label because the consensus_sim link is heavy. + testnet_config: + name: Testnet config + consensus_sim + runs-on: ubuntu-latest + if: contains(github.event.pull_request.labels.*.name, 'testnet-config') + timeout-minutes: 120 + + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Install distro Boost and build deps + # Runners fetch packages from azure.archive.ubuntu.com; when that mirror goes quiet + # apt keeps retrying it and the job hangs for hours instead of failing (seen + # 2026-08-19). Pin the canonical mirror, bound the wait, and cap the step. + timeout-minutes: 15 + run: | + set -euo pipefail + sudo sed -i 's|azure.archive.ubuntu.com|archive.ubuntu.com|g' \ + /etc/apt/apt-mirrors.txt /etc/apt/sources.list 2>/dev/null || true + apt="sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=20 -o Acquire::https::Timeout=20" + $apt update + $apt install -y --no-install-recommends \ + build-essential cmake libboost-all-dev libssl-dev \ + libbz2-dev liblzma-dev libzstd-dev libreadline-dev \ + libtool ncurses-dev pkg-config zlib1g-dev + + - name: Configure and build the consensus simulator (testnet config) + run: | + set -euo pipefail + sed -i '/add_subdirectory(tests)/d' thirdparty/fc/CMakeLists.txt + mkdir -p build && cd build + cmake -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_SHARED_LIBRARIES=FALSE \ + -DCHAINBASE_CHECK_LOCKING=FALSE \ + -DBUILD_TESTNET=TRUE \ + -DBUILD_CONSENSUS_TESTS=ON .. + make -j"$(nproc)" consensus_sim_tests + + - name: Run the simulator + run: | + set -euo pipefail + cd build + ctest -R '^consensus_sim$' --output-on-failure diff --git a/@l10n/ru/docs/plugins/database-api.md b/@l10n/ru/docs/plugins/database-api.md index 5ad704ea22..a6f27bb77b 100644 --- a/@l10n/ru/docs/plugins/database-api.md +++ b/@l10n/ru/docs/plugins/database-api.md @@ -394,6 +394,18 @@ json_rpc::plugin, chain::plugin --- +### `get_agent_permissions(account)` + +Возвращает агентов принципала (HF15, [агент-доступ](../protocol/operations/agent-access.md)), по порядку имён. Не больше 16 строк, без пагинации. Истёкшие строки отдаются с `expired: true`: они ничего не дают и удаляются при следующей выдаче принципала. + +```json +{ "method": "database_api.get_agent_permissions", "params": ["alice"] } +``` + +**Возвращает:** массив `agent_permission_api_object` — `account`, `agent_name`, `agent_key`, `operations`, `expiration`, `addons`, `expired`. + +--- + ## Коды ошибок | Код | Значение | diff --git a/@l10n/ru/docs/plugins/prediction-market-api.md b/@l10n/ru/docs/plugins/prediction-market-api.md index 7fa3ac25aa..7d4d84c1bf 100644 --- a/@l10n/ru/docs/plugins/prediction-market-api.md +++ b/@l10n/ru/docs/plugins/prediction-market-api.md @@ -69,7 +69,7 @@ Read-only котировки, вызывающие **ту же внутриуз | `get_leverage_close_preview` | `position_id` | `pm_leverage_close_preview` (вычисляемый) | | `get_leverage_convert_preview` | `position_id` | `pm_leverage_convert_preview` (вычисляемый) | -`get_leverage_quote` зеркалит `pm_leverage_open`: возвращает максимальный платёжеспособный заём и итоговое максимальное плечо, кэпы пула/позиции, до 12 стопов слайдера (каждый с токенами, порогом, текущей и худшей стоимостью отмены) и — когда плечо невозможно — `available = false` со списком `failed_constraints[]`. `get_leverage_close_preview` / `get_leverage_convert_preview` зеркалят `pm_leverage_close` / `pm_leverage_convert` при текущих резервах (стоимость отмены, обязательство пула, что получает беттер, закрываемость/конвертируемость и комиссия конвертации при текущей медиане `pm_conversion_profit_cost_percent`). +`get_leverage_quote` зеркалит `pm_leverage_open`: возвращает максимальный платёжеспособный заём и итоговое максимальное плечо, кэпы пула/позиции, до 12 стопов слайдера (каждый с токенами, порогом, текущей и худшей стоимостью отмены) и — когда плечо невозможно — `available = false` со списком `failed_constraints[]`. Блокирующее ограничение никогда не выдаётся рядом с `available = true`: если лучший найденный заём ниже `pm_min_liquidity` (который требует `pm_leverage_open`), котировка падает с `loan_floor_above_cap`, а не рекламирует открываемый заём. `get_leverage_close_preview` / `get_leverage_convert_preview` зеркалят `pm_leverage_close` / `pm_leverage_convert` при текущих резервах (стоимость отмены, обязательство пула, что получает беттер, закрываемость/конвертируемость и комиссия конвертации при текущей медиане `pm_conversion_profit_cost_percent`). > Выплата каждому беттору — виртуальная операция `pm_payout` (стейк, side/outcome, итог; `0` при > проигрыше); закрытие плечевой позиции — `pm_leverage_resolve` (`outcome_index`, `won`, `leverage`). diff --git a/@l10n/ru/docs/prediction-markets/pm-audit-fix-upgrade.md b/@l10n/ru/docs/prediction-markets/pm-audit-fix-upgrade.md new file mode 100644 index 0000000000..6c96ccd167 --- /dev/null +++ b/@l10n/ru/docs/prediction-markets/pm-audit-fix-upgrade.md @@ -0,0 +1,229 @@ +# HF15 — фиксы PM-аудита: чеклист активации и миграция устаревшего состояния + +Статус: оба фикса **реализованы, но на mainnet не запланированы**. Форк зарегистрирован в обеих +конфигурациях (`CHAIN_NUM_HARDFORKS` = 15 везде) и гейтится временем активации плюс кворумом +валидаторов, так что production-бинарь применит его, как только это время наступит; до тех пор ничего +не выполняется, и миграция не нужна ни в каком случае. Два вкомпилированных таймстемпа — в §2. Эта +заметка — операторский чеклист: что именно гейтится, как форк регистрируется и активируется, что +проверять и какое решение принять по состоянию, которое legacy-путь уже оставил после себя. + +## 1. Что меняет форк + +Оба гейта читаются как `has_hardfork(CHAIN_PM_AUDIT_FIX_HARDFORK)` в `pm_evaluator.cpp`; до форка +историческое поведение сохраняется байт-в-байт, так что обычный реплей не задет. + +**A. Частичный вывод LMSR-ликвидности (`pm_withdraw_liquidity`, `market_type == 1`).** Кривая держит +`lmsr_b` на рынок, а каждая LP-строка — свой `b_share`, т.е. свою долю этого `lmsr_b`. При +*частичном* выводе раньше вычитался floored `b_remove` из `market.lmsr_b`, а `b_share` строки +оставался нетронутым → строка продолжала претендовать на кривую больше, чем рынок держит. Следующий +вывод из этой же строки (обычно полный выход) забирал весь устаревший `b_share` и мог загнать +`lmsr_b` в `<= 0`. После фикса обе записи уменьшаются на один и тот же `b_remove`, а вывод, у которого +`b_remove` превысил бы `market.lmsr_b`, **отклоняется** (`FC_ASSERT`), а не клампится. + +Почему отказ, а не кламп: `lmsr_b <= 0` — это не «плоская кривая». `lmsr_q96` падает мягко — +`lmsr_price`, `lmsr_buy_cost` и `lmsr_tokens_for_amount` возвращают `0` при `b <= 0`, вообще не +доходя до `validate_domain` — т.е. все исходы стоят ноль и **ставка бесплатна**, при том что на рынке +остаются и капитал LP, и стейки беттеров. Кламп в ноль передал бы это состояние следующему +вызывающему; отказ сохраняет кривую ценообразования живой, а отклонённый LP не теряет принципал (см. +§4 — он возвращается целиком на сеттлменте). + +**B. Прямые ставки с `mode = 1` (`pm_place_bet`).** Рынок с `allow_instant_bet = false` и +`allow_batch = true` существует, чтобы заставить использовать поток, устойчивый к front-run, и +поддерживаемый вход туда — `pm_commit_bet` → `pm_reveal_bet` (escrow, строка в статусе 5, исполнение +на границе батча). Прямой `pm_place_bet` с `mode = 1` вместо этого попадал на *instant*-путь, т.е. +обходил ровно ту защиту, которую рынок выбрал, по цене instant-гейта. После фикса он отклоняется. +История не затронута: `mode = 1` в уже произведённых блоках реплеится по до-форковым правилам. Это +закрывает обход; само по себе оно не доказывает, что немедленное исполнение было выгодно тому, кто им +пользовался. + +Ни один из фиксов не меняет layout объектов и не требует бампа схемы: в `apply_hardfork` для HF15 нет +`case` (путь `default: break` здесь корректен), импорт снапшота не требует новой секции, и на +активации нечего пересчитывать. + +## 2. Как форк регистрируется и активируется + +* **Регистрация безусловна, в обеих конфигурациях.** `0-preamble.hf` ставит `CHAIN_NUM_HARDFORKS` = 15 + для любой сборки, `hardfork.d/15.hf` всегда определяет `CHAIN_HARDFORK_15` / + `CHAIN_PM_AUDIT_FIX_HARDFORK` и `CHAIN_HARDFORK_15_VERSION`, а `database_hardfork.cpp` безусловно + регистрирует `_hardfork_times[15]` / `_hardfork_versions[15]`. Массивы имеют размер + `[CHAIN_NUM_HARDFORKS + 1]`, так что индексы в границах в обеих сборках. Форк гейтится таймстемпом + активации и кворумом валидаторов — а не вариантом сборки. +* **Почему production тоже несёт форк.** Публичный тестнет (`testnet.viz.world`, та самая нода, с + которой работают парсеры, оракулы и клиенты) — это **production-конфигурация**: он отдаёт + `CHAIN_NAME "VIZ"`, `CHAIN_ID = sha256("VIZ")` и `CHAIN_HARDFORK_REQUIRED_VALIDATORS = 17`. + `config_testnet.hpp` сменил бы `CHAIN_NAME` на `VIZTEST` (а значит и chain id) и уронил бы кворум до + 1 — выкатить такой образ на существующий тестнет нельзя, цепь перестала бы быть той цепью, которой + принадлежат её снапшот и клиенты. Форк, зарегистрированный только под `BUILD_TESTNET`, поэтому + **никогда** не активируется на тестнете, которым мы реально пользуемся: `has_hardfork(15)` там + навсегда false, и деплой ничего не доказывает. Регистрация в обеих конфигурациях, где различие — + только время, делает возможной проверку «сначала тестнет» на том самом артефакте, который уедет в + прод. +* **Единственная ручка — время активации, и оно вкомпилировано.** В `15.hf` два таймстемпа: + `CHAIN_HARDFORK_15_TIME` = 2026-09-27 08:33:20 UTC для `BUILD_TESTNET` (свежая testnet-сборка может + активироваться сразу) и = **2026-10-05 00:00:00 UTC** для production. Production-значение + **предварительное** — это не назначенная дата mainnet, оно существует, чтобы production-тестнет мог + дойти до активации и её можно было наблюдать; см. чеклист ниже. Смена — это правка одной строки плюс + сборка-выкатка, поэтому дату нужно переподтвердить, когда релиз HF14+HF15 будет реально + планироваться. Держать её в **будущем**: с таймстемпом в прошлом форк применяется в том блоке, где + 17-й валидатор случайно обновится, без объявляемого момента (то же предупреждение, что в `14.hf`). +* **Версия, не ревизия.** `version(m, h, r)` упаковывает версию хардфорка в средний компонент, и + `CHAIN_HARDFORK_VERSION` — это именно он. Новый форк обязан двигать сам `CHAIN_VERSION` — теперь и + `config.hpp`, и `config_testnet.hpp` стоят на `4.1.0` — потому что `hardfork_version` отбрасывает + ревизию, так что `4.0.1` для голосования неотличим от `4.0.0`. `database_hardfork.cpp` ассертит + `CHAIN_HARDFORK_VERSION == _hardfork_versions[CHAIN_NUM_HARDFORKS]`, и именно поэтому версия и + `CHAIN_NUM_HARDFORKS` обязаны двигаться вместе в обеих сборках. +* **Голосование автоматическое.** `database.cpp::_generate_block` инжектит `hardfork_version_vote`, + когда записанный голос валидатора отличается от следующего форка бинаря, а `process_hardforks()` + применяет форк, когда сошлись `CHAIN_HARDFORK_REQUIRED_VALIDATORS` (17 на production-конфигурации, + которую крутит тестнет, **1** на testnet-сборке) **и** достигнут таймстемп активации. Все 21 слот + валидаторов тестнета ведёт один аккаунт, так что кворум там мгновенный и решение принимает только + таймстемп. +* **Чеклист активации на mainnet.** (1) Подтвердить или заменить предварительную дату и объявить её + заранее с запасом до таймстемпа — это единственное оставшееся решение по расписанию. (2) Прогнать + детектор устаревшего состояния из §4 и закрыть там же вопрос миграции. (3) Выкатить образ и дать + валидаторам обновиться — голос автоматический. (4) Подтвердить активацию по логу ноды и + перепроверить §3 уже на живой цепи. Отметить, что собственная дата HF14 на mainnet (2026-08-28) уже + в прошлом, поэтому первый mainnet-деплой, несущий оба форка, активирует их вместе в одном блоке + (`process_hardforks` идёт циклом, пока `_hardfork_versions[last] < next_hardfork`); тестнет, в + снапшоте которого HF14 уже обработан, — единственное место, где HF15 можно проверить отдельно, но + только если эта цепь не в emergency-консенсусе (см. §3: emergency-committee и не голосует, и не + считается, так что форк там не становится даже pending). +* **Откат.** До таймстемпа активации вернуть предыдущий образ безопасно: форк просто остаётся + ожидающим (состояние держит проголосованный-но-неприменённый форк; возврат нового образа его + снимает). После активации маркер — это состояние цепи, поэтому откатываться нельзя: до-HF15-бинарь + не знает про форк и оценивал бы гейтед-операции по старым правилам, пока цепь говорит обратное. + Нужно двигаться вперёд. + +## 3. Проверка + +До активации (после деплоя нового образа, до таймстемпа) состояние читается двумя методами, которые +эта сборка реально отдаёт, — `database_api.get_hardfork_property` на VIZ **не зарегистрирован**, +вызов падает с `Could not find method`: + +* `database_api.get_hardfork_version` — **применённая** версия форка (`4.0.0`, пока текущий — HF14); +* `database_api.get_next_scheduled_hardfork` — `hf_version` / `live_time` форка, который назначил + tally голосов валидаторов. После того как валидаторы нового образа проголосуют, тут будет версия + HF15 и вкомпилированное время активации; детектор из §4 (`scripts/pm_stale_bshare_detect.py`) + отчитывается по состоянию, которое вот-вот попадёт под гейт. + +**Production-конфигурация тестнета в emergency-консенсусе до этого состояния дойти не может — это и +есть та ловушка, которую надо знать.** Пока +`dynamic_global_property_object.emergency_consensus_active` истинно, расписание валидаторов +заполнено аккаунтом `CHAIN_EMERGENCY_VALIDATOR_ACCOUNT` (= `committee`, `database.cpp:575`), а этот +аккаунт исключён из голосования за форки **в обе стороны**: `database.cpp:2811` не инжектит голос +для производящего валидатора, а цикл подсчёта (`database.cpp:3413`) пропускает его слоты, чтобы одна +сущность, держащая много слотов, не раздувала собственный вес. Наблюдаемое следствие, замерено на +тестнете 27.09.2026 после деплоя 4.1.0 поверх цепи на 4.0.0: каждый произведённый блок несёт +**пустой `extensions`** (никакого `hardfork_version_vote`), а `get_next_scheduled_hardfork` продолжает +отдавать `4.0.0` со временем *предыдущего* форка. Tally пуст, `process_hardforks` держит +`next_hardfork` равным `current_hardfork_version`, и **ни один** форк не может быть назначен — +вкомпилированное время активации оказывается мёртвой ручкой. Выход из emergency-консенсуса требует, +чтобы обновились `CHAIN_HARDFORK_REQUIRED_VALIDATORS` реальных валидаторов (правило выхода из HF12), +а тестнет, чьи валидаторы — импортированная история мейннета, этого не достигает. + +То есть на такой цепи «сначала развернуть и посмотреть, как форк активируется» проверяет деплой +(образ поднимается, снапшот импортируется, инварианты держатся), но **не** активацию — tally там +не может дать результата в принципе. Выхода два: свежая сборка `BUILD_TESTNET` (кворум 1, обычный +валидатор голосует и применяет форк сам) либо `testnet_plugin` ниже, который форсирует форк прямо на +production-конфигурации тестнета. + +### Форсирование форка на старте: `testnet_plugin` + +`testnet_plugin` сделан ровно под этот случай и не требует правок консенсуса. Он добавляет одну +команду на старте: + + --testnet-hardfork <версия|номер> # например 4.1.0 или 15 + +Если плагин загружен (`plugin = testnet_plugin`, уже есть в `config_testnet.ini`) **и** ему задана +цель, он применяет все форки до указанного сразу после загрузки состояния цепи — после импорта +снапшота, до старта производства блоков — через `database::set_hardfork(n, true)`. Tally голосов +валидаторов обходится полностью, поэтому это работает и при `emergency_consensus_active = true`. +Именно это делает возможной проверку активации на production-конфигурации тестнета **до** прод-даты. + +Безопасность: плагин не делает ничего, пока не загружен и не получил цель, поэтому production-образ +может его содержать; production-`config.ini` его не включает. Форсированный форк нельзя откатить — +наводить его можно только на свою цепь, никогда на мейннет. + +Порядок действий (тестнет на shelter): сначала развернуть образ, в котором плагин **есть** — строка +`plugin =` с плагином, которого нет в бинаре, роняет старт с `unable to find plugin: testnet_plugin`, — +затем перезапустить контейнер с `VIZD_EXTRA_OPTS="--testnet-hardfork 15"` или с `testnet-hardfork = 15` +в конфиге. Нода пишет в лог + + *** testnet_plugin: FORCING HARDFORK 15 (requested '15') at head=#... *** + *** testnet_plugin: hardfork 15 applied at head=#...: last_hardfork=15, current_hardfork_version=4.1.0 *** + +и `get_hardfork_version` с этого момента отдаёт `4.1.0`. После прогона опцию убрать: форк уже в +состоянии цепи, а повторный форс при рестарте со старого снапшота безвреден, но шумен. + +Одна ловушка при проверке деплоя: `--testnet-hardfork` объявлена как опция *конфига*, которую appbase +принимает и в командной строке (argv парсится по объединённому набору `cli + cfg`), но `--help` +печатает только командные опции — в справке флага **не будет**. Проверять флаг надо запуском ноды и +баннером `FORCING HARDFORK`, а не грепом `--help`. Опция, объявленная в *обоих* наборах, хуже +невидимой: boost тогда валит каждый старт с +`option '--testnet-hardfork' is ambiguous and matches different versions of '--testnet-hardfork'`. + +После активации: + +* форк появился в `processed_hardforks`, в логе ноды видна активация; +* прямой `pm_place_bet(mode = 1)` на рынке с `allow_instant_bet = false` отклоняется + (`mode=1` больше не доходит до instant-исполнения), а `pm_commit_bet` → `pm_reveal_bet` работает; +* частичный вывод LMSR оставляет `Σ b_share` по активным строкам рынка равной `market.lmsr_b`; +* полный выход по строке, которую legacy-путь оставил устаревшей, отклоняется ассертом «would drain + the LMSR pricing curve», и рынок продолжает ценообразование; +* обычные пост-деплойные инварианты: SHARES delta 0, TOKEN delta равна legacy-якорю цепи, + `restarts 0`, листинги не пусты. + +## 4. Устаревшее состояние: что детектировать и что решить + +Пост-фиксовый инвариант на LMSR-рынок: **`Σ b_share` по его активным LP-строкам равна +`market.lmsr_b`.** Legacy-расхождение однонаправленное — кривая потеряла `b_remove`, а строка +сохранила полный `b_share`, т.е. строки в сумме претендуют на *больше*, чем рынок держит. Поэтому +детект — это один проход по рынкам с `market_type == 1` с суммированием `b_share` активных строк и +сравнением с `lmsr_b`; любой рынок, где сумма больше, имеет как минимум одну устаревшую строку, и его +следующий полный выход — ровно та операция, которую отклоняет новый гейт. + +В таком состоянии могут быть только рынки, чей LP делал частичный вывод до активации; созданное после +форка — не может, а рынок, где все выходы LP были «всё или ничего», согласован по построению. + +### Детектор + +`scripts/pm_stale_bshare_detect.py ` проходит снапшот и печатает по +каждому рынку `Σ b_share` активных (`status 0`) строк против `lmsr_b`. Read-only: не нужны ни нода, +ни доступ к цепи. Вердикт — код возврата: **0** = инвариант выполнен на всех LMSR-рынках (мигрировать +нечего), **1** = есть расходящиеся рынки (список печатается), **2** = снапшот не прочитался или +секции не найдены. `.vizjson` — это zlib-сжатый JSON, поэтому нужен собственный снапшот ноды, а не +пере-сериализованный экспорт. + +Где лежит снапшот: с `--snapshot-auto-latest` нода пишет `snapshot-block-*.vizjson` в свой vizhome +(`/var/lib/vizd/snapshots/` внутри контейнера, т.е. `/snapshots/` на хосте; на текущем +тестнете — каждые 15 минут). На shelter это `/root/testnethome/snapshots/`, читается только через +`sudo` — сперва скопировать: `sudo cp /tmp/snap.vizjson && sudo chown $USER /tmp/snap.vizjson`. + +Замер 2026-09-27 на снапшоте тестнета блок **83748900** (состояние, которое вот-вот попадёт под HF15): +129 806 рынков, из них 9 615 LMSR; у 8 251 есть активные LP-строки, и **все 8 251 точно +удовлетворяют инварианту** (`Σ b_share == lmsr_b`), расходящихся — 0. То есть на этой цепи legacy-путь +частичного вывода не оставил следа: отклонять нечего, и одноразовый ремонт атрибуции из вариантов ниже +не нужен. Перед планированием форка детектор стоит прогнать на свежем снапшоте — цепь, которая с тех +пор отслужила ещё частичные выводы LMSR, может отличаться. + +Варианты и что с каждым делает цепь: + +* **Ничего не делать (текущее поведение).** Устаревшая строка отклоняется на выходе, т.е. LP не может + досрочно вытащить остаток своего `b_share`. Ничего не теряется: принципал возвращается целиком на + сеттлменте, где пол ликвидности живого рынка уже не применяется, и рынок всё это время продолжает + ценообразование. Цена: опция досрочного выхода для этой строки мертва, а несогласованность видна + только через отказ. Это консервативный вариант без изменения консенсуса, и он дефолтный, потому что + расхождение можно *измерить*, но нельзя *реконструировать* — цепь держит только текущее состояние, + а истории по каждому выводу, чтобы восстановить истинную атрибуцию, нет. +* **Одноразовый ремонт атрибуции.** Для каждого разошедшегося рынка уменьшить `b_share` активных строк + pro-rata до `market.lmsr_b` (с floor, остаток — последней строке), чтобы строки снова сошлись с + кривой и полные выходы заработали. Это детерминированно и идемпотентно на одном и том же состоянии, + но это консенсусно-видимая мутация состояния, и ей нужна своя форк-гейтед миграция, свои тесты и + своё ревью — т.е. это отдельное изменение, а не довесок к этому. +* **Ручные действия по рынку** (подтолкнуть затронутых LP или резолвнуть/сеттлить рынок) — не фикс: + сеттлмент всё равно возвращает принципал, так что на кону только окно досрочного выхода. + +Итоговое решение, которое сети нужно принять до планирования HF15: устраивает ли её «отказать в +устаревшем выходе, вернуть принципал на сеттлменте», или нужен ещё и одноразовый ремонт атрибуции. +Решать должен вывод детектора: если ни один LMSR-рынок не разошёлся, вопрос снимается и форк можно +планировать как есть. diff --git a/@l10n/ru/docs/prediction-markets/settlement-work-bounds.md b/@l10n/ru/docs/prediction-markets/settlement-work-bounds.md index 40878907aa..c9ffa4a330 100644 --- a/@l10n/ru/docs/prediction-markets/settlement-work-bounds.md +++ b/@l10n/ru/docs/prediction-markets/settlement-work-bounds.md @@ -12,6 +12,7 @@ row-бюджете (`pm_settle_rows_per_block`), либо ограничен э поверхности. Соседние внутренние спеки: [early-exit-deferred-claim](./early-exit-deferred-claim.md), +[pm-audit-fix-upgrade](./pm-audit-fix-upgrade.md) (чеклист HF15), [specification](./specification.md) §5 (crons). ## 1. Дыра diff --git a/@l10n/ru/docs/prediction-markets/specification.md b/@l10n/ru/docs/prediction-markets/specification.md index e7ffb1f717..449220aa67 100644 --- a/@l10n/ru/docs/prediction-markets/specification.md +++ b/@l10n/ru/docs/prediction-markets/specification.md @@ -505,6 +505,15 @@ returned = withdraw_amount + fee_share Вывод вычитает исходные `weight_a` и `weight_b` (не пропорциональную долю текущих резервов). Если `reserve_a < weight_a` или `reserve_b < weight_b`, вывод **блокируется**. +**LMSR-рынки (§6) ведут глубину отдельно:** кривая держит `lmsr_b` на рынок, а каждая LP-строка — свой +`b_share`, вычитаемый пропорционально (`b_remove = floor(b_share × withdraw / amount)`). Обе записи +обязаны двигаться вместе — частичный вывод, уменьшавший только кривую, оставлял строку претендующей на +глубину больше, чем рынок держит, и её следующий полный выход мог вычерпать `lmsr_b` в ноль (а это молча +обнуляет все цены и делает ставки бесплатными). Фикс гейтится хардфорком +(`CHAIN_PM_AUDIT_FIX_HARDFORK`): после форка обе записи уменьшаются на один и тот же `b_remove`, а вывод, +чей `b_remove` превысил бы `lmsr_b`, отклоняется. Активация, инвариант устаревших строк и варианты +миграции — в [pm-audit-fix-upgrade](./pm-audit-fix-upgrade.md). + ### Создатель как первый LP Создатель рынка автоматически первый LP. Его `sec_to_expiration` равен полной длительности рынка, давая @@ -975,6 +984,10 @@ Live с HF14 для **бинарных** рынков (мульти форсит - На каждой границе эпохи (`pm_batch_epoch_blocks`, reveal-окно `pm_reveal_window_blocks`) ставки из очереди сеттлятся по **единой цене** через крон `pm_batch_settle` — AMM двигает только нетто-остаток, поэтому внутрибатчевый порядок не даёт преимущества, а инвариант `Σ reserve ≥ L` сохраняется. +- На рынке с `allow_instant_bet = false` единственный принимаемый путь — commit-reveal выше: прямой + `pm_place_bet(mode = 1)` попадал вместо него на instant-исполнение и обходил защиту. Отклонение + такого вызова гейтится хардфорком (`CHAIN_PM_AUDIT_FIX_HARDFORK`) — см. + [pm-audit-fix-upgrade](./pm-audit-fix-upgrade.md). ## 17. On-chain модель объектов diff --git a/@l10n/ru/docs/protocol/operations/agent-access.md b/@l10n/ru/docs/protocol/operations/agent-access.md new file mode 100644 index 0000000000..7bfe06a94f --- /dev/null +++ b/@l10n/ru/docs/protocol/operations/agent-access.md @@ -0,0 +1,73 @@ +# Агент-доступ (HF15) + +Аккаунт (**принципал**) может завести **агентов**: именованные публичные ключи, которым разрешено отправлять заданный список операций от его имени. Агент — не аккаунт, а запись у принципала. Собственные ключи принципала никому не передаются, агент отзывается одной операцией. + +Типичные случаи: торговый бот, который ставит в прогнозных рынках; сервис, который делает выплаты переводами; ключ, который внешний сервис (например vizhub) принимает для входа и своих действий. + +--- + +## `set_agent_permission_operation` (ID 105) + +**Подпись:** `active` аккаунта `account`. До HF15 отвергается. + +| Поле | Тип | Описание | +|------|-----|----------| +| `account` | `account_name_type` | Принципал | +| `agent_name` | `string` | Имя агента, уникально у принципала; `[a-z0-9_-]`, непустое | +| `agent_key` | `public_key_type` | Ключ агента; обязателен при выдаче, при отзыве не проверяется | +| `operations` | `flat_set` | Wire-имена операций, которые может подписывать ключ (`transfer`, `pm_place_bet`, …) | +| `expiration` | `time_point_sec` | `1970-01-01T00:00:00` = бессрочно; время в прошлом = отзыв | +| `addons` | `flat_set` | Метки для внешних сервисов (напр. `vizhub`); до 10 штук, каждая короче 64 байт, без `,`. Нода их хранит, но не толкует: в цепи они ничего не дают | +| `extensions` | `extensions_type` | Всегда `[]` | + +Выдача (бот может ставить и переводить, vizhub принимает его ключ): + +```json +["set_agent_permission", { + "account": "alice", + "agent_name": "trade-bot", + "agent_key": "VIZ6MyX5QiXAXRZk7SYCiqpi6Mtm8UbHWDFSV8HPpt7FJyahCnc2T", + "operations": ["pm_place_bet", "transfer"], + "expiration": "2027-01-01T00:00:00", + "addons": ["vizhub"], + "extensions": [] +}] +``` + +Агент только с addons (ключ для внешнего сервиса, без операций цепи): + +```json +["set_agent_permission", { + "account": "alice", + "agent_name": "hub-login", + "agent_key": "VIZ7…", + "operations": [], + "expiration": "1970-01-01T00:00:00", + "addons": ["vizhub"], + "extensions": [] +}] +``` + +Отзыв — оба списка пусты (ключ можно передать нулевой `VIZ1111111111111111111111111111111114T1Anm`): + +```json +["set_agent_permission", { + "account": "alice", "agent_name": "trade-bot", + "agent_key": "VIZ1111111111111111111111111111111114T1Anm", + "operations": [], "expiration": "1970-01-01T00:00:00", "addons": [], "extensions": [] +}] +``` + +Повторная выдача по тому же имени заменяет ключ, операции, addons и срок (смена ключа = перевыдача). + +## Правила + +- **Не делегируются никогда:** `set_agent_permission`, `proposal_create`, `proposal_update`, `proposal_delete`, `account_update`, `recover_account`, `change_recovery_account`, `set_account_price`, `set_subaccount_price`, `target_account_sale`. Виртуальные операции и устаревшие алиасы (писать `validator_update`, а не `witness_update`) отвергаются. +- **Один ключ — один агент:** ключ, уже привязанный к другому имени того же принципала, отвергается. +- **Не больше 16 агентов** на принципала. Выдача сначала удаляет его истёкших агентов. +- **Когда подпись агента засчитывается:** транзакции не нужны master- и regular-подписи, собственные ключи принципала её не подписывают, агент жив (срок не истёк, список операций не пуст), его список покрывает **каждую** операцию транзакции, которой нужна подпись, и его ключ есть среди подписей. Через вложенные `account_auths` доступа нет. +- **Удаление:** все агенты принципала стираются при смене master, смене active, восстановлении аккаунта, прямой продаже и закрытии аукциона. Смена только regular их не трогает. + +## Чтение агентов + +`database_api.get_agent_permissions(account)` — см. [database_api](../../plugins/database-api.md#get-agent-permissions-account). diff --git a/@l10n/ru/docs/protocol/operations/overview.md b/@l10n/ru/docs/protocol/operations/overview.md index dcfc0a81ab..53434d9fdf 100644 --- a/@l10n/ru/docs/protocol/operations/overview.md +++ b/@l10n/ru/docs/protocol/operations/overview.md @@ -77,7 +77,7 @@ | 98 | `pm_dispute_oracle_respond_operation` | active | [Прогнозные рынки](./prediction-markets.md) | | 99 | `pm_unban_operation` | active | [Прогнозные рынки](./prediction-markets.md) | -> ID — это фиксированный индекс в едином `operation`-варианте цепи (только добавление). Пропуски в этой таблице — **виртуальные** операции (ниже), чередующиеся по ID — например, 62–63, 65, 84–90, 94–97, 100. +> ID — это фиксированный индекс в едином `operation`-варианте цепи (только добавление). Пропуски в этой таблице — **виртуальные** операции (ниже), чередующиеся по ID — например, 62–63, 65, 84–90, 94–97, 100–104. --- @@ -122,6 +122,10 @@ | 96 | `pm_market_accepted_operation` | Рынок запущен (оракул принял / self / авто) | [Прогнозные рынки](./prediction-markets.md) | | 97 | `pm_payout_operation` | Паримутюэль-выплата на беттера | [Прогнозные рынки](./prediction-markets.md) | | 100 | `pm_ban_expired_operation` | Истёк временный бан оракула/создателя | [Прогнозные рынки](./prediction-markets.md) | +| 101 | `pm_market_expired_operation` | Рынок истёк без резолюции | [Прогнозные рынки](./prediction-markets.md) | +| 102 | `pm_dispute_opened_operation` | Открыт спор (история оракула и спорщика) | [Прогнозные рынки](./prediction-markets.md) | +| 103 | `pm_early_exit_claim_paid_operation` | Отложенная выплата досрочного выхода оплачена при расчёте | [Прогнозные рынки](./prediction-markets.md) | +| 104 | `pm_lp_payout_operation` | Доход LP выплачен при расчёте | [Прогнозные рынки](./prediction-markets.md) | --- diff --git a/@l10n/ru/docs/protocol/operations/prediction-markets.md b/@l10n/ru/docs/protocol/operations/prediction-markets.md index 0210f0b1d7..a906038b3d 100644 --- a/@l10n/ru/docs/protocol/operations/prediction-markets.md +++ b/@l10n/ru/docs/protocol/operations/prediction-markets.md @@ -127,7 +127,7 @@ flowchart TD | `outcome_index` | `int16_t` | Мульти: 0..N-1; binary: -1 | | `amount` | `asset` (VIZ) | Стейк (`> 0`) | | `min_tokens` | `share_type` | Порог проскальзывания (0 = нет) | -| `mode` | `uint8_t` | 0 instant, 1 batch | +| `mode` | `uint8_t` | 0 instant, 1 batch. На рынке с `allow_instant_bet = false` единственный допустимый путь — `pm_commit_bet` → `pm_reveal_bet`: после `CHAIN_PM_AUDIT_FIX_HARDFORK` прямой `mode = 1` отклоняется (до форка он молча уходил в мгновенное исполнение, обходя гейт, который рынок выбрал). | ### `pm_commit_bet_operation` (ID 71) **Auth:** `active` аккаунта `account` diff --git a/@l10n/zh-CN/docs/plugins/prediction-market-api.md b/@l10n/zh-CN/docs/plugins/prediction-market-api.md index 6a350cfcd4..eacfa5dea8 100644 --- a/@l10n/zh-CN/docs/plugins/prediction-market-api.md +++ b/@l10n/zh-CN/docs/plugins/prediction-market-api.md @@ -68,7 +68,7 @@ | `get_leverage_close_preview` | `position_id` | `pm_leverage_close_preview`(计算型) | | `get_leverage_convert_preview` | `position_id` | `pm_leverage_convert_preview`(计算型) | -`get_leverage_quote` 镜像 `pm_leverage_open`:返回最大偿付贷款与由此得到的最大杠杆、池/头寸上限、至多 12 个滑块档位(每档含代币、阈值、当前及最坏情形取消价值),且——当无法杠杆时——返回 `available = false` 并附 `failed_constraints[]` 列表。`get_leverage_close_preview` / `get_leverage_convert_preview` 在当前储备下镜像 `pm_leverage_close` / `pm_leverage_convert`(取消价值、池义务、下注者所得、是否可平仓/可转换,以及按当前中位数 `pm_conversion_profit_cost_percent` 的转换费)。 +`get_leverage_quote` 镜像 `pm_leverage_open`:返回最大偿付贷款与由此得到的最大杠杆、池/头寸上限、至多 12 个滑块档位(每档含代币、阈值、当前及最坏情形取消价值),且——当无法杠杆时——返回 `available = false` 并附 `failed_constraints[]` 列表。阻断性约束绝不会与 `available = true` 同时出现:若求解器找到的最佳可行贷款低于 `pm_min_liquidity`(`pm_leverage_open` 强制要求),报价会以 `loan_floor_above_cap` 失败,而不是宣称可以开仓。`get_leverage_close_preview` / `get_leverage_convert_preview` 在当前储备下镜像 `pm_leverage_close` / `pm_leverage_convert`(取消价值、池义务、下注者所得、是否可平仓/可转换,以及按当前中位数 `pm_conversion_profit_cost_percent` 的转换费)。 ### 争议、懒惰池、治理 diff --git a/@l10n/zh-CN/docs/prediction-markets/pm-audit-fix-upgrade.md b/@l10n/zh-CN/docs/prediction-markets/pm-audit-fix-upgrade.md new file mode 100644 index 0000000000..3741b140a9 --- /dev/null +++ b/@l10n/zh-CN/docs/prediction-markets/pm-audit-fix-upgrade.md @@ -0,0 +1,121 @@ +# HF15 — PM 审计修复:激活清单与陈旧状态迁移 + +状态:下述两项修复**已实现,但未在 mainnet 排期**。分叉在两种配置中都会注册(`CHAIN_NUM_HARDFORKS` +均为 15),并由激活时间加验证者法定人数共同门控,因此 production 二进制在该时间到达后即会应用它; +在此之前不会执行任何内容,而无论哪种情况都不需要迁移。两个编译期时间戳见 §2。本文是面向运维的 +清单:被门控的是什么、分叉如何注册与激活、需要验证什么,以及如何处置遗留路径已经留下的状态。 + +## 1. 分叉改变了什么 + +两个门控都以 `has_hardfork(CHAIN_PM_AUDIT_FIX_HARDFORK)` 形式在 `pm_evaluator.cpp` 中读取;分叉前 +历史行为逐字节保留,因此常规重放不受影响。 + +**A. LMSR 流动性部分提取(`pm_withdraw_liquidity`,`market_type == 1`)。** 曲线按市场持有 +`lmsr_b`,每条 LP 记录持有自己的 `b_share`,即该记录在其中的份额。*部分*提取过去会从一个已取整 +(floored)的 `b_remove` 中扣除 `market.lmsr_b`,而记录的 `b_share` 保持不变 → 该记录继续声称拥有 +比市场实际持有更多的曲线。该记录的下一次提取(通常是全额退出)会取走全部陈旧份额,并可能把 +`lmsr_b` 压到 `<= 0`。修复后两条记录按同一个 `b_remove` 同步缩减,而 `b_remove` 会超过 +`market.lmsr_b` 的提取将被**拒绝**(`FC_ASSERT`),而不是被截断(clamp)。 + +为什么拒绝而非截断:`lmsr_b <= 0` 并不是「平坦曲线」。`lmsr_q96` 会软失败——当 `b <= 0` 时 +`lmsr_price`、`lmsr_buy_cost` 与 `lmsr_tokens_for_amount` 都直接返回 `0`,根本不会走到 +`validate_domain`——于是所有结果定价为零,**下注不花任何成本**,而市场上仍留有 LP 资本和投注者的 +本金。截断为零会把这种状态交给下一个调用者;拒绝则让定价曲线保持存活,而被拒绝的 LP 不会损失本金 +(见 §4——结算时会全额返还)。 + +**B. 直接 `mode = 1` 下注(`pm_place_bet`)。** 带有 `allow_instant_bet = false` 与 +`allow_batch = true` 的市场,其存在意义就是强制走抗抢跑流程,受支持的入口是 +`pm_commit_bet` → `pm_reveal_bet`(托管、状态 5 的记录、在批次边界执行)。而直接的 +`pm_place_bet` 搭配 `mode = 1` 会走到 *instant* 成交路径,即绕过该市场自己选择的保护,并按 +instant 门控的价格成交。修复后它会被拒绝。历史不受影响:已产出区块中的 `mode = 1` 交易仍按分叉前 +规则重放。这关闭了绕过路径;它本身并不证明即时成交对使用者是有利可图的。 + +两项修复都不改变对象布局,也不需要提升 schema:`apply_hardfork` 中没有 HF15 的 `case` +(此处 `default: break` 路径是正确的),快照导入不需要新分区,激活时也没有需要重算的内容。 + +## 2. 分叉如何注册与激活 + +* **注册是无条件的,两种配置都有。** `0-preamble.hf` 为所有构建把 `CHAIN_NUM_HARDFORKS` 设为 15, + `hardfork.d/15.hf` 始终定义 `CHAIN_HARDFORK_15` / `CHAIN_PM_AUDIT_FIX_HARDFORK` 与 + `CHAIN_HARDFORK_15_VERSION`,`database_hardfork.cpp` 无条件注册 `_hardfork_times[15]` / + `_hardfork_versions[15]`。数组大小为 `[CHAIN_NUM_HARDFORKS + 1]`,两种构建中索引都不会越界。 + 门控该分叉的是激活时间戳与验证者法定人数——而不是构建类型。 +* **为什么 production 也携带它。** 公共 testnet(`testnet.viz.world`,我们的解析器、预言机与客户端 + 所连接的那个节点)是 **production 配置** 部署:它报告 `CHAIN_NAME "VIZ"`、`CHAIN_ID = sha256("VIZ")` + 以及 `CHAIN_HARDFORK_REQUIRED_VALIDATORS = 17`。`config_testnet.hpp` 会把 `CHAIN_NAME` 改成 + `VIZTEST`(因此改变 chain id)并把法定人数降到 1——把那样的镜像部署到现有 testnet 不是选项, + 这条链将不再是其快照与客户端所属的那条链。因此只在 `BUILD_TESTNET` 下注册的分叉**永远**无法在 + 我们实际使用的 testnet 上激活:那里的 `has_hardfork(15)` 恒为 false,部署也证明不了任何东西。 + 两种配置都注册、只以时间作区分,才能用 production 将要发布的那份产物完成「先在 testnet 部署」的 + 验证。 +* **激活时间是唯一的旋钮,且它是编译期常量。** `15.hf` 携带两个时间戳:`BUILD_TESTNET` 下 + `CHAIN_HARDFORK_15_TIME` = 2026-09-27 08:33:20 UTC(全新的 testnet 配置链可以立即激活), + production 下 = **2026-10-05 00:00:00 UTC**。production 取值是**暂定的**——它不是已排期的 mainnet + 日期,它存在的目的是让 production 配置的 testnet 能够走到激活并被观察到;见下面的清单。修改它 + 只是改一行再构建发布,因此在真正规划 HF14+HF15 发布时必须重新确认该日期。请把它保持在**未来**: + 时间戳落在过去时,分叉会在第 17 个验证者恰好升级的那个区块生效,没有可宣告的时刻(与 `14.hf` + 中的警告相同)。 +* **版本,而非修订号。** `version(m, h, r)` 把分叉版本打进中间分量,`CHAIN_HARDFORK_VERSION` 就是 + 该分量。因此新分叉必须推进 `CHAIN_VERSION` 本身——现在 `config.hpp` 与 `config_testnet.hpp` 都是 + `4.1.0`——因为 `hardfork_version` 会丢弃修订号,所以 `4.0.1` 在投票意义上与 `4.0.0` 无法区分。 + `database_hardfork.cpp` 断言 `CHAIN_HARDFORK_VERSION == _hardfork_versions[CHAIN_NUM_HARDFORKS]`, + 这正是版本与 `CHAIN_NUM_HARDFORKS` 必须在两种构建中同步推进的原因。 +* **投票是自动的。** 当验证者已记录的投票与二进制的下一个分叉不一致时, + `database.cpp::_generate_block` 会注入 `hardfork_version_vote`;当 + `CHAIN_HARDFORK_REQUIRED_VALIDATORS` 达成一致(testnet 所运行的 production 配置为 17,testnet + 构建为 **1**)**且**达到激活时间戳时,`process_hardforks()` 应用该分叉。testnet 的 21 个验证者槽位 + 全部由同一个账户驱动,因此那里的法定人数是瞬时的,只由时间戳决定区块。 +* **mainnet 激活清单。** (1) 确认或替换暂定日期,并提前足够时间公告,留出到时间戳的余量——这是唯一 + 剩余的排期决定。(2) 运行 §3 的陈旧状态检测器,并就 §4 的迁移问题作出决定。(3) 发布镜像并让验证者 + 更新——投票是自动的。(4) 从节点日志确认激活,并针对实时链重跑 §3 的检查。注意 HF14 自身的 mainnet + 日期(2026-08-28)已经过去,因此首次携带两个分叉的 mainnet 部署会在同一个区块激活它们 + (`process_hardforks` 在 `_hardfork_versions[last] < next_hardfork` 时循环);快照中已处理 HF14 的 + testnet 是唯一能单独验证 HF15 的地方。 +* **回滚。** 在激活时间戳之前,回退到旧镜像是安全的:分叉只是保持待定(状态中保留一个已投票但未 + 应用的分叉;重新部署新镜像会清除它)。激活之后,标记是链上状态,因此不要回滚——HF15 之前的 + 二进制不知道这个分叉,会在链已声明新规则的情况下按旧规则评估被门控的操作。此时应向前推进。 + +## 3. 验证 + +激活前(testnet 部署新镜像之后、时间戳未到之前):`get_hardfork_property_object` +(或 `database_api.get_hardfork_property`)显示当前分叉仍为 14、下一个分叉的版本/时间处于待定、 +验证者正在为它投票;下方检测器报告即将被门控的状态。 + +激活后: + +* 分叉出现在 `processed_hardforks` 中,节点日志显示激活; +* 在 `allow_instant_bet = false` 的市场上直接 `pm_place_bet(mode = 1)` 被拒绝 + (`mode=1` 不再走到即时成交),而 `pm_commit_bet` → `pm_reveal_bet` 仍然可用; +* 部分提取 LMSR 后,该市场活跃记录的 `Σ b_share` 等于 `market.lmsr_b`; +* 对遗留路径留下的陈旧记录做全额退出会被「would drain the LMSR pricing curve」断言拒绝, + 且市场继续正常定价; +* 常规的重新部署后不变量:SHARES delta 为 0,TOKEN delta 等于该链的 legacy 锚点, + `restarts 0`,列表非空。 + +## 4. 陈旧状态:检测什么、决定什么 + +修复后的不变量(按 LMSR 市场):**其活跃 LP 记录的 `Σ b_share` 等于 `market.lmsr_b`。** +遗留偏差是单向的——曲线失去了 `b_remove`,而记录保留了完整的 `b_share`,因此记录合计声称的份额 +*多于*市场持有。所以检测就是一次遍历:对 `market_type == 1` 的市场,汇总活跃记录的 `b_share` 并与 +`lmsr_b` 比较;任何合计更大的市场至少有一条陈旧记录,而它下一次全额退出正是新门控拒绝的操作。 + +只有其 LP 在激活前做过部分提取的市场才会处于这种状态;分叉之后创建的不会,而所有 LP 退出都是 +「全有或全无」的市场按构造就是一致的。 + +可选方案及链对各自的做法: + +* **不做任何事(当前行为)。** 陈旧记录在退出时被拒绝,LP 无法提前取回其剩余的 `b_share`。不会有 + 任何损失:本金在结算时全额返还,此时活跃市场的流动性下限已不再适用,而市场在此期间继续定价。 + 代价:该记录的提前退出选项失效,且不一致只能通过拒绝被观察到。这是保守的、无需变更共识的方案, + 也是默认方案,因为偏差可以*测量*但无法*重建*——链只持有当前状态,没有逐笔提取历史可用来还原真实 + 的归属。 +* **一次性修复归属。** 对每个出现偏差的市场,把活跃记录的 `b_share` 按比例缩减到 `market.lmsr_b` + (向下取整,余数给最后一条记录),使记录与曲线重新一致、全额退出恢复可用。在相同状态下它是确定性 + 且幂等的,但这是共识可见的状态变更,需要自己的分叉门控迁移、自己的测试和评审——也就是说,这是一项 + 独立的改动,而不是本次改动的附属品。 +* **按市场的人工处置**(推动受影响的 LP,或解析/结算该市场)不是修复:结算无论如何都会返还本金, + 所以真正处在风险中的只是提前退出窗口。 + +因此,在为 HF15 排期之前网络需要作出的决定是:「拒绝陈旧退出、在结算时返还本金」是否可接受,还是 +网络还需要一次性归属修复。这应由检测器的输出驱动——如果没有任何 LMSR 市场出现偏差,该问题即不成立, +分叉可以按原样排期。 diff --git a/@l10n/zh-CN/docs/prediction-markets/settlement-work-bounds.md b/@l10n/zh-CN/docs/prediction-markets/settlement-work-bounds.md index be3e0fa87b..549917d3ed 100644 --- a/@l10n/zh-CN/docs/prediction-markets/settlement-work-bounds.md +++ b/@l10n/zh-CN/docs/prediction-markets/settlement-work-bounds.md @@ -9,6 +9,7 @@ 仅受经济约束的遍历会发出响亮的日志信号,而不是静默退化。第 7 节汇总了已封堵的攻击面。 姊妹内部规范:[early-exit-deferred-claim](./early-exit-deferred-claim.md)、 +[pm-audit-fix-upgrade](./pm-audit-fix-upgrade.md)(HF15 清单)、 [specification](./specification.md) §5(crons)。 ## 1. 漏洞 diff --git a/@l10n/zh-CN/docs/prediction-markets/specification.md b/@l10n/zh-CN/docs/prediction-markets/specification.md index 35eafcdc3e..072e7533cf 100644 --- a/@l10n/zh-CN/docs/prediction-markets/specification.md +++ b/@l10n/zh-CN/docs/prediction-markets/specification.md @@ -490,6 +490,13 @@ returned = withdraw_amount + fee_share 提取减去原始 `weight_a` 与 `weight_b`(而非当前储备的比例份额)。若 `reserve_a < weight_a` 或 `reserve_b < weight_b`,提取被**阻止**。 +**LMSR 市场(§6)单独跟踪深度:** 曲线按市场持有 `lmsr_b`,每条 LP 记录持有自己的 `b_share`,按比例 +提取(`b_remove = floor(b_share × withdraw / amount)`)。两条记录必须同步变动 —— 部分提取若只缩减曲线, +会让该记录声称的深度超过市场实际持有,其下一次全额退出就可能把 `lmsr_b` 抽干为零(这会静默地把所有 +价格归零并使下注免费)。该修复由分叉门控(`CHAIN_PM_AUDIT_FIX_HARDFORK`):分叉后两条记录按同一个 +`b_remove` 同步缩减,而 `b_remove` 会超过 `lmsr_b` 的提取将被拒绝。激活方式、陈旧记录不变量与迁移选项见 +[pm-audit-fix-upgrade](./pm-audit-fix-upgrade.md)。 + ### 创建者作为首位 LP 市场创建者自动成为首位 LP。其 `sec_to_expiration` 等于完整市场时长,给予最大时间权重。 @@ -942,6 +949,9 @@ API:`get_account_leverage_positions`、`get_market_leverage_positions`、`get_ `pm_commit_no_reveal_penalty_percent`(bp)。 - 在每个纪元边界(`pm_batch_epoch_blocks`,揭示窗口 `pm_reveal_window_blocks`)入队下注由 `pm_batch_settle` 定时任务以**统一价格**结算 —— 只有净残量推动 AMM,故批内排序无优势,且 `Σ reserve ≥ L` 不变量得以保持。 +- 在 `allow_instant_bet = false` 的市场上,上述 commit-reveal 流程是唯一被接受的路径:直接的 + `pm_place_bet(mode = 1)` 会走到即时成交并绕过它。拒绝该调用由分叉门控 + (`CHAIN_PM_AUDIT_FIX_HARDFORK`)—— 见 [pm-audit-fix-upgrade](./pm-audit-fix-upgrade.md)。 ## 17. 链上对象模型 diff --git a/@l10n/zh-CN/docs/protocol/operations/overview.md b/@l10n/zh-CN/docs/protocol/operations/overview.md index 31dcf15306..5c2aede4bc 100644 --- a/@l10n/zh-CN/docs/protocol/operations/overview.md +++ b/@l10n/zh-CN/docs/protocol/operations/overview.md @@ -77,7 +77,7 @@ VIZ Ledger 操作是包含在交易中的原子状态变更动作。每个操作 | 98 | `pm_dispute_oracle_respond_operation` | active | [预测市场](./prediction-markets.md) | | 99 | `pm_unban_operation` | active | [预测市场](./prediction-markets.md) | -> ID 是链上单一 `operation` 变体中的固定索引(仅追加)。本表中的空缺为按 ID 交错的**虚拟**操作(见下文)—— 例如 62–63、65、84–90、94–97、100。 +> ID 是链上单一 `operation` 变体中的固定索引(仅追加)。本表中的空缺为按 ID 交错的**虚拟**操作(见下文)—— 例如 62–63、65、84–90、94–97、100–104。 --- @@ -122,6 +122,10 @@ VIZ Ledger 操作是包含在交易中的原子状态变更动作。每个操作 | 96 | `pm_market_accepted_operation` | 市场上线(预言机接受 / 自预言机 / 自动) | [预测市场](./prediction-markets.md) | | 97 | `pm_payout_operation` | 每下注者的同注分彩赔付 | [预测市场](./prediction-markets.md) | | 100 | `pm_ban_expired_operation` | 临时预言机/创建者封禁失效 | [预测市场](./prediction-markets.md) | +| 101 | `pm_market_expired_operation` | 市场到期未裁定 | [预测市场](./prediction-markets.md) | +| 102 | `pm_dispute_opened_operation` | 争议已提交(预言机与争议者历史) | [预测市场](./prediction-markets.md) | +| 103 | `pm_early_exit_claim_paid_operation` | 提前退出的延迟索赔在结算时支付 | [预测市场](./prediction-markets.md) | +| 104 | `pm_lp_payout_operation` | LP 收益在结算时支付 | [预测市场](./prediction-markets.md) | --- diff --git a/@l10n/zh-CN/docs/protocol/operations/prediction-markets.md b/@l10n/zh-CN/docs/protocol/operations/prediction-markets.md index 03ec881778..a20ca8718d 100644 --- a/@l10n/zh-CN/docs/protocol/operations/prediction-markets.md +++ b/@l10n/zh-CN/docs/protocol/operations/prediction-markets.md @@ -127,7 +127,7 @@ flowchart TD | `outcome_index` | `int16_t` | 多元:0..N-1;二元:-1 | | `amount` | `asset`(VIZ) | 下注(`> 0`) | | `min_tokens` | `share_type` | 滑点下限(0 = 无) | -| `mode` | `uint8_t` | 0 即时,1 批次 | +| `mode` | `uint8_t` | 0 即时,1 批次。当 `allow_instant_bet = false` 时,唯一被接受的路径是 `pm_commit_bet` → `pm_reveal_bet`:`CHAIN_PM_AUDIT_FIX_HARDFORK` 之后,直接发送 `mode = 1` 会被拒绝(分叉前它会静默走即时成交,绕过市场自己选择的门控)。 | ### `pm_commit_bet_operation`(ID 71) **Auth:** `account` 的 `active` diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 054c82bdd8..7b21d93c5c 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -42,6 +42,7 @@ interface SidebarLabels { virtualOperations: string; operations: string; accounts: string; + agentAccess: string; transfersVesting: string; validators: string; content: string; @@ -127,6 +128,7 @@ const en: SidebarLabels = { virtualOperations: 'Virtual Operations', operations: 'Operations', accounts: 'Accounts', + agentAccess: 'Agent Access', transfersVesting: 'Transfers & Vesting', validators: 'Validators', content: 'Content', @@ -212,6 +214,7 @@ const ru: SidebarLabels = { virtualOperations: 'Виртуальные операции', operations: 'Операции', accounts: 'Аккаунты', + agentAccess: 'Агент-доступ', transfersVesting: 'Переводы и вестинг', validators: 'Валидаторы', content: 'Контент', @@ -297,6 +300,7 @@ const zhCN: SidebarLabels = { virtualOperations: '虚拟操作', operations: '操作', accounts: '账户', + agentAccess: 'Agent Access', transfersVesting: '转账与质押', validators: '验证人', content: '内容', @@ -439,6 +443,7 @@ function buildSidebar(t: SidebarLabels, prefix: string): DefaultTheme.SidebarIte { text: t.awards, link: p('/protocol/operations/awards') }, { text: t.subscriptions, link: p('/protocol/operations/subscriptions') }, { text: t.accountMarket, link: p('/protocol/operations/account-market') }, + { text: t.agentAccess, link: p('/protocol/operations/agent-access') }, { text: t.predictionMarkets, link: p('/protocol/operations/prediction-markets') }, { text: t.proposals, link: p('/protocol/operations/proposals') }, ], diff --git a/docs/plugins/database-api.md b/docs/plugins/database-api.md index 4a176c239c..33450c3c47 100644 --- a/docs/plugins/database-api.md +++ b/docs/plugins/database-api.md @@ -394,6 +394,18 @@ Returns account namespace registrations available for sale (subaccount creation --- +### `get_agent_permissions(account)` + +Returns the agents of a principal (HF15 [agent access](../protocol/operations/agent-access.md)), ordered by agent name. At most 16 rows, no paging. Expired rows are returned with `expired: true`: they grant nothing and are removed on the principal's next grant. + +```json +{ "method": "database_api.get_agent_permissions", "params": ["alice"] } +``` + +**Returns:** Array of `agent_permission_api_object` — `account`, `agent_name`, `agent_key`, `operations`, `expiration`, `addons`, `expired`. + +--- + ## Error Codes | Code | Meaning | diff --git a/docs/plugins/prediction-market-api.md b/docs/plugins/prediction-market-api.md index 8ebb13d2ce..6942017088 100644 --- a/docs/plugins/prediction-market-api.md +++ b/docs/plugins/prediction-market-api.md @@ -92,7 +92,7 @@ Read-only quotes that call the **same in-node margin math** the evaluators use, | `get_leverage_close_preview` | `position_id` | `pm_leverage_close_preview` (computed) | | `get_leverage_convert_preview` | `position_id` | `pm_leverage_convert_preview` (computed) | -`get_leverage_quote` mirrors `pm_leverage_open`: it returns the max solvent loan and resulting max leverage, the pool/position caps, up to 12 slider stops (each with tokens, threshold, current & worst-case cancel value), and — when leverage is not possible — `available = false` with a `failed_constraints[]` list. `get_leverage_close_preview` / `get_leverage_convert_preview` mirror `pm_leverage_close` / `pm_leverage_convert` at the current reserves (cancel value, pool obligation, what the bettor receives, whether it is closeable/convertible, and the conversion fee at the current median `pm_conversion_profit_cost_percent`). +`get_leverage_quote` mirrors `pm_leverage_open`: it returns the max solvent loan and resulting max leverage, the pool/position caps, up to 12 slider stops (each with tokens, threshold, current & worst-case cancel value), and — when leverage is not possible — `available = false` with a `failed_constraints[]` list. A blocking constraint is never reported alongside `available = true`: if the solver's best feasible loan falls below `pm_min_liquidity` (which `pm_leverage_open` enforces), the quote fails with `loan_floor_above_cap` instead of advertising an openable loan. `get_leverage_close_preview` / `get_leverage_convert_preview` mirror `pm_leverage_close` / `pm_leverage_convert` at the current reserves (cancel value, pool obligation, what the bettor receives, whether it is closeable/convertible, and the conversion fee at the current median `pm_conversion_profit_cost_percent`). > Per-bettor settlement is emitted as the `pm_payout` virtual op (stake, side/outcome, realized payout — > `0` on a loss); a leveraged position's settlement is the `pm_leverage_resolve` virtual op (with diff --git a/docs/prediction-markets/pm-audit-fix-upgrade.md b/docs/prediction-markets/pm-audit-fix-upgrade.md new file mode 100644 index 0000000000..0bc816aeb9 --- /dev/null +++ b/docs/prediction-markets/pm-audit-fix-upgrade.md @@ -0,0 +1,229 @@ +# HF15 — PM audit fixes: activation checklist and stale-state migration + +Status: the two fixes below are **implemented but not scheduled on mainnet**. The fork is registered +in both configs (`CHAIN_NUM_HARDFORKS = 15` everywhere) and gated by its activation time plus the +validator quorum, so a production binary applies it once that time is reached — nothing runs before +then, and no migration is needed either way. §2 lists the two compiled timestamps. This note is the +operator-facing checklist: what is gated, how the fork is registered and activated, what to verify, +and what to decide about state the legacy path already left behind. + +## 1. What the fork changes + +Both gates are read as `has_hardfork(CHAIN_PM_AUDIT_FIX_HARDFORK)` in `pm_evaluator.cpp`; before the +fork the historical behaviour is kept byte-for-byte, so ordinary replay is unaffected. + +**A. Partial LMSR liquidity withdrawal (`pm_withdraw_liquidity`, `market_type == 1`).** The curve +keeps a per-market `lmsr_b`, and each LP row keeps its own `b_share` — the row's slice of it. A +*partial* withdrawal used to subtract a floored `b_remove` from `market.lmsr_b` while leaving the +row's `b_share` untouched, so the row went on claiming more curve than the market still held. The +next withdrawal from that row (typically the full exit) then took the whole stale share and could +drive `lmsr_b` to `<= 0`. Post-fix both records shrink by the same `b_remove`, and a withdrawal +whose `b_remove` would exceed `market.lmsr_b` is **refused** (`FC_ASSERT`) instead of being clamped. + +Why refuse rather than clamp: `lmsr_b <= 0` is not a flat curve. `lmsr_q96` fails soft — `lmsr_price`, +`lmsr_buy_cost` and `lmsr_tokens_for_amount` all return `0` for `b <= 0` without ever reaching +`validate_domain` — so every outcome prices at zero and **a bet costs nothing** while the market still +holds the remaining LP capital and the bettors' stakes. Clamping to zero would hand that state to the +next caller; refusing keeps the pricing curve alive, and the refused LP keeps the principal (see §4, +it is returned in full at settlement). + +**B. Direct `mode = 1` bets (`pm_place_bet`).** A market with `allow_instant_bet = false` and +`allow_batch = true` exists to force the front-run-resistant flow, and the supported way in is +`pm_commit_bet` → `pm_reveal_bet` (escrow, a status-5 row, execution at the batch boundary). A direct +`pm_place_bet` with `mode = 1` reached the *instant* fill path instead, i.e. it bypassed the very +protection the market opted into, paying the instant gate's price. Post-fix it is rejected. History is +untouched: `mode = 1` transactions in already-produced blocks still replay under the pre-fork rules. +This closes the bypass; it does not by itself establish that the immediate fill was profitable to +whoever used it. + +Neither fix changes an object layout and neither needs a schema bump: `apply_hardfork` has no `case` +for HF15 (the `default: break` path is correct here), snapshot import needs no new section, and there +is nothing to recompute on activation. + +## 2. How the fork is registered and activated + +* **Registration is unconditional, in both configs.** `0-preamble.hf` sets `CHAIN_NUM_HARDFORKS` to + 15 for every build, `hardfork.d/15.hf` always defines `CHAIN_HARDFORK_15` / + `CHAIN_PM_AUDIT_FIX_HARDFORK` and `CHAIN_HARDFORK_15_VERSION`, and `database_hardfork.cpp` + registers `_hardfork_times[15]` / `_hardfork_versions[15]` unconditionally. The arrays are sized + `[CHAIN_NUM_HARDFORKS + 1]`, so the indices stay in bounds in both builds. What gates the fork is + the activation timestamp plus the validator quorum — not the build flavour. +* **Why production carries it too.** The public testnet (`testnet.viz.world`, the node our parsers, + oracles and clients talk to) is a **production-config** deployment: it reports + `CHAIN_NAME "VIZ"`, `CHAIN_ID = sha256("VIZ")` and `CHAIN_HARDFORK_REQUIRED_VALIDATORS = 17`. + `config_testnet.hpp` would change `CHAIN_NAME` to `VIZTEST` and therefore the chain id, and drop + the quorum to 1 — deploying that image to the existing testnet is not an option, the chain would + stop being the chain its snapshot and clients belong to. A fork registered only under + `BUILD_TESTNET` can therefore **never** activate on the testnet we actually use: `has_hardfork(15)` + would be permanently false there and the deployment would prove nothing. Registering in both + configs, with the time as the only difference, makes testnet-first verification possible on the + very artifact that production ships. +* **The activation time is the one knob, and it is compiled in.** `15.hf` carries two timestamps: + `CHAIN_HARDFORK_15_TIME` = 2026-09-27 08:33:20 UTC for `BUILD_TESTNET` (a fresh testnet-config + chain may activate immediately) and = **2026-10-05 00:00:00 UTC** for production. The production + value is **provisional** — it is not a scheduled mainnet date, it exists so the production-config + testnet can reach activation and be observed; see the checklist below. Changing it is a one-line + build-and-ship cycle, so it must be re-confirmed when the HF14+HF15 release is actually planned. + Keep it in the **future**: with a past timestamp the fork applies in whatever block the 17th + validator happens to upgrade in, with no announceable moment (the same warning `14.hf` carries). +* **Version, not revision.** `version(m, h, r)` packs the hardfork version into the middle component + and `CHAIN_HARDFORK_VERSION` is that component. A new fork must therefore move `CHAIN_VERSION` + itself — both `config.hpp` and `config_testnet.hpp` are at `4.1.0` now — because + `hardfork_version` discards the revision, so `4.0.1` would be indistinguishable from `4.0.0` for + voting. `database_hardfork.cpp` asserts `CHAIN_HARDFORK_VERSION == _hardfork_versions[CHAIN_NUM_HARDFORKS]`, + which is exactly why the version and `CHAIN_NUM_HARDFORKS` have to move together in both builds. +* **Voting is automatic.** `database.cpp::_generate_block` injects `hardfork_version_vote` whenever + the validator's recorded vote differs from the binary's next fork, and `process_hardforks()` applies + the fork once `CHAIN_HARDFORK_REQUIRED_VALIDATORS` agree (17 on the production config the testnet + runs, **1** on a testnet-config build) **and** the activation timestamp is reached. The testnet's + 21 validator slots are all driven by one account, so quorum there is immediate and the timestamp + alone decides the block. +* **Mainnet activation checklist.** (1) Confirm or replace the provisional date and announce it well + ahead of the timestamp — that is the only remaining scheduling decision. (2) Run the stale-state + detector from §4 and settle the migration question there. (3) Ship the image and let validators + update; the vote is automatic. (4) Confirm the activation from the node log and re-run the checks + in §3 against the live chain. Note that HF14's own mainnet date (2026-08-28) is already in the + past, so a first mainnet deployment carrying both forks activates them together in one block + (`process_hardforks` walks while `_hardfork_versions[last] < next_hardfork`); the testnet, whose + snapshot carries HF14 already processed, is the only place HF15 can be exercised on its own — but only + if that chain is not in emergency consensus (see §3: an emergency committee neither votes nor is + counted, so the fork never even becomes pending there). +* **Rollback.** Before the activation timestamp, redeploying the previous image is safe: the fork + simply stays pending (the state keeps a voted-but-unapplied fork; rolling the new image back in + clears it). After activation the marker is chain state, so do not roll back — a pre-HF15 binary does + not know the fork and would evaluate the gated operations under the old rules while the chain says + otherwise. Roll forward instead. + +## 3. Verification + +Pre-activation (after deploying the new image and before the timestamp), read the state with the two +RPC methods this build actually exposes — `database_api.get_hardfork_property` is **not** registered on +VIZ, so a call for it fails with `Could not find method`: + +* `database_api.get_hardfork_version` — the **applied** fork version (`4.0.0` while HF14 is current); +* `database_api.get_next_scheduled_hardfork` — `hf_version` / `live_time` of the fork the validator + tally has scheduled. Once the new image's validators vote, this is the HF15 version and the compiled + activation time; the detector in §4 (`scripts/pm_stale_bshare_detect.py`) reports on the state that is + about to be gated. + +**A production-config testnet in emergency consensus cannot get there at all, and this is the trap to +know about.** While `dynamic_global_property_object.emergency_consensus_active` is true the validator +schedule is filled with `CHAIN_EMERGENCY_VALIDATOR_ACCOUNT` (= `committee`, `database.cpp:575`), and +that account is excluded from the fork vote in **both** directions: `database.cpp:2811` skips the vote +injection for the producing validator, and the tally loop (`database.cpp:3413`) skips its slots so a +single entity holding many slots cannot inflate its own weight. The observable consequence, measured on +the testnet 2026-09-27 after deploying 4.1.0 over a chain sitting at 4.0.0: every block the node +produces carries an **empty `extensions`** array (no `hardfork_version_vote`), and +`get_next_scheduled_hardfork` keeps returning `4.0.0` with the *previous* fork's time. The tally is +empty, `process_hardforks` pins `next_hardfork` to `current_hardfork_version`, and **no** hardfork can +be scheduled — the compiled activation time is a dead knob. Exiting emergency consensus needs +`CHAIN_HARDFORK_REQUIRED_VALIDATORS` real validators to update (HF12's exit rule), which a testnet whose +validators are imported mainnet history will not reach. + +So on such a chain "deploy first, watch the fork activate" verifies the deployment (image runs, snapshot +imports, invariants hold) but **not** the activation — the tally there can never produce a result. Two +ways out: a fresh `BUILD_TESTNET` build (quorum 1, an ordinary validator votes and applies the fork on its +own), or `testnet_plugin` below, which forces the fork on the production-config chain itself. + +### Forcing the fork at startup: `testnet_plugin` + +`testnet_plugin` exists for exactly this case and needs no consensus change. It adds one startup command: + + --testnet-hardfork # e.g. 4.1.0, or 15 + +With the plugin loaded (`plugin = testnet_plugin`, already in `config_testnet.ini`) **and** given a target, +it applies every hardfork up to the requested one as soon as the chain state is loaded — after the snapshot +import, before block production starts — through `database::set_hardfork(n, true)`. The validator tally is +bypassed entirely, so it also works while `emergency_consensus_active` is true. That is what makes +"verify the activation on the production-config testnet, ahead of the production date" possible. + +Safety: the plugin does nothing unless it is both loaded and given a target, so the production image can +carry it; the production `config.ini` never enables it. A forced fork cannot be rolled back — point it at a +chain you own, never at mainnet. + +Operator sequence (shelter testnet): deploy the image that **contains** the plugin first — a config line +naming a plugin the binary does not register aborts startup with `unable to find plugin: testnet_plugin` — +then restart the container with `VIZD_EXTRA_OPTS="--testnet-hardfork 15"`, or put `testnet-hardfork = 15` +in the config. The node logs + + *** testnet_plugin: FORCING HARDFORK 15 (requested '15') at head=#... *** + *** testnet_plugin: hardfork 15 applied at head=#...: last_hardfork=15, current_hardfork_version=4.1.0 *** + +and `get_hardfork_version` reports `4.1.0` from then on. Drop the option after the run: the fork is chain +state now, and forcing it again on a restart from an older snapshot is harmless but noisy. + +One trap when checking the deployment: `--testnet-hardfork` is declared as a *config-file* option, which +appbase also accepts on the command line (it parses argv against `cli + cfg` merged) — but `--help` prints +the command-line options only, so the flag is **not** listed there. Verify the flag by starting the node and +reading the `FORCING HARDFORK` banner, not by grepping `--help`. An option declared in *both* descriptions +is worse than invisible: boost then refuses every start with +`option '--testnet-hardfork' is ambiguous and matches different versions of '--testnet-hardfork'`. + +Post-activation: + +* the fork appears in `processed_hardforks` and the node log shows the activation; +* a direct `pm_place_bet(mode = 1)` on an `allow_instant_bet = false` market is rejected + (`mode=1` no longer reaches an instant fill), while `pm_commit_bet` → `pm_reveal_bet` still works; +* an LMSR partial withdrawal leaves `Σ b_share` over the market's active rows equal to + `market.lmsr_b`; +* a full exit of a row that the legacy path left stale is refused with the “would drain the LMSR + pricing curve” assert, and the market keeps pricing; +* the usual post-redeploy invariants: SHARES delta 0, TOKEN delta equal to the chain's legacy anchor, + `restarts 0`, listings non-empty. + +## 4. Stale state: what to detect and what to decide + +Post-fix invariant, per LMSR market: **`Σ b_share` over its active LP rows equals `market.lmsr_b`.** +The legacy divergence is one-directional — the curve lost `b_remove` while the row kept its full +`b_share`, so rows end up claiming *more* than the market holds. Detection is therefore a single walk +over `market_type == 1` markets, summing the active rows' `b_share` and comparing against `lmsr_b`; +any market where the sum is larger has at least one stale row, and its next full exit is exactly the +operation the new gate refuses. + +Only markets whose LP performed a partial withdrawal before activation can be in this state; anything +created after the fork cannot, and a market where every LP exit was all-or-nothing is consistent by +construction. + +### Detector + +`scripts/pm_stale_bshare_detect.py ` walks the snapshot and prints, per +market, `Σ b_share` over the active (`status 0`) rows against `lmsr_b`. It is read-only, needs no +node and no chain access, and is verdict-first: **exit 0** = every LMSR market satisfies the invariant +(nothing to migrate), **1** = at least one market diverges (the list is printed), **2** = the snapshot +could not be read or the sections were not found. A `.vizjson` snapshot is zlib-compressed JSON, so the +file has to be the node's own snapshot, not a re-serialized export. + +Snapshot location: with `--snapshot-auto-latest` the node writes `snapshot-block-*.vizjson` into its +vizhome (`/var/lib/vizd/snapshots/` inside the container, i.e. `/snapshots/` on the host; +every 15 minutes on the current testnet). On the shelter box that is +`/root/testnethome/snapshots/`, readable only via `sudo` — copy it out first: +`sudo cp /tmp/snap.vizjson && sudo chown $USER /tmp/snap.vizjson`. + +Measured 2026-09-27 on testnet snapshot block **83748900** (the state HF15 is about to gate): +129 806 markets, 9 615 of them LMSR; 8 251 have active LP rows and **all 8 251 satisfy the invariant +exactly** (`Σ b_share == lmsr_b`), 0 diverging. So on this chain the legacy partial-withdraw path left +no residue: the "refuse the stale exit" behaviour has nothing to refuse, and the one-shot attribution +repair from the options below is not needed. Re-run the detector against a fresh snapshot right before +scheduling the fork — a chain that has served more partial LMSR withdrawals since can differ. + +Options, and what the chain does about each: + +* **Do nothing (current behaviour).** The stale row is refused on exit, so the LP cannot pull its + remaining `b_share` out early. Nothing is lost: the principal is returned in full at settlement, + where the live-market liquidity floor no longer applies, and the market keeps pricing in the + meantime. Cost: the LP's early-exit option is dead for that row, and the inconsistency is visible + only through the refusal. This is the conservative, no-consensus-change option, and it is the + default because the divergence can be measured but not *reconstructed* — the chain holds current + state only, and there is no per-withdrawal history to rebuild the true attribution from. +* **Repair the attribution once.** For each diverging market, shrink the active rows' `b_share` + pro-rata down to `market.lmsr_b` (floored, remainder to the last row) so the rows agree with the + curve again and full exits work. This is deterministic and idempotent given the same state, but it + is a consensus-visible state mutation and needs its own fork-gated migration, its own tests and its + own review — i.e. it is a separate change, not a rider on this one. +* **Per-market operator action** (nudge the affected LPs, or resolve/settle the market) is not a fix: + settlement restores the principal anyway, so the only thing at stake is the early-exit window. + +The decision the network has to make before scheduling HF15 is therefore: is "refuse the stale exit, +return the principal at settlement" acceptable, or does the network want the one-shot attribution +repair as well? The detector output should drive it — if no LMSR market diverges, the question is +moot and the fork can be scheduled as-is. diff --git a/docs/prediction-markets/settlement-work-bounds.md b/docs/prediction-markets/settlement-work-bounds.md index 41277f13e8..3fb4b2eb24 100644 --- a/docs/prediction-markets/settlement-work-bounds.md +++ b/docs/prediction-markets/settlement-work-bounds.md @@ -12,6 +12,7 @@ walk still bounded only economically emits a loud log signal instead of degradin summarises the closed surface. Sibling internal specs: [early-exit-deferred-claim](./early-exit-deferred-claim.md), +[pm-audit-fix-upgrade](./pm-audit-fix-upgrade.md) (HF15 checklist), [specification](./specification.md) §5 (crons). ## 1. The hole diff --git a/docs/prediction-markets/specification.md b/docs/prediction-markets/specification.md index d80275a7bc..2a48435ce9 100644 --- a/docs/prediction-markets/specification.md +++ b/docs/prediction-markets/specification.md @@ -503,6 +503,10 @@ returned = withdraw_amount + fee_share Withdrawal subtracts original `weight_a` and `weight_b` (not proportional share of current reserves). If `reserve_a < weight_a` or `reserve_b < weight_b`, withdrawal is **blocked**. +**LMSR markets (§6) track depth separately:** the curve carries a per-market `lmsr_b` and each LP row its own `b_share`, withdrawn proportionally (`b_remove = floor(b_share × withdraw / amount)`). Both records must move together — a partial withdrawal that +shrank only the curve left the row claiming more depth than the market held, and the row's next full exit could then drain `lmsr_b` to zero (which silently zeroes every price and makes bets free). Fix is +hardfork-gated (`CHAIN_PM_AUDIT_FIX_HARDFORK`): post-fork both records shrink by the same `b_remove`, and a withdrawal whose `b_remove` would exceed `lmsr_b` is refused. Activation, the stale-row invariant and the migration options are in [pm-audit-fix-upgrade](./pm-audit-fix-upgrade.md). + ### Creator as First LP Market creator is automatically the first LP. Their `sec_to_expiration` equals the full market duration, giving maximum time-weight. @@ -1026,6 +1030,10 @@ opt-in per market (`allow_batch` / `allow_instant_bet`), median kill-switch `pm_ - At each epoch boundary (`pm_batch_epoch_blocks`, reveal window `pm_reveal_window_blocks`) queued bets settle at a **uniform price** via the `pm_batch_settle` cron — only the net residual moves the AMM, so intra-batch ordering carries no advantage and the `Σ reserve ≥ L` invariant is preserved. +- On a market with `allow_instant_bet = false`, the commit-reveal flow above is the only accepted path: + a direct `pm_place_bet(mode = 1)` reached the instant fill instead and bypassed it. Rejecting that + call is hardfork-gated (`CHAIN_PM_AUDIT_FIX_HARDFORK`) — see + [pm-audit-fix-upgrade](./pm-audit-fix-upgrade.md). ## 17. On-Chain Object Model diff --git a/docs/protocol/operations/agent-access.md b/docs/protocol/operations/agent-access.md new file mode 100644 index 0000000000..a96492b8db --- /dev/null +++ b/docs/protocol/operations/agent-access.md @@ -0,0 +1,73 @@ +# Agent Access (HF15) + +An account (the **principal**) can register **agents**: named public keys that may broadcast a listed set of operations on the principal's behalf. An agent is not an account — it is a record on the principal. The principal's own keys are never shared, and one operation revokes the agent. + +Typical uses: a trading bot that places prediction-market bets, a service that pays out transfers, or a key an external service (for example vizhub) accepts for its own login and actions. + +--- + +## `set_agent_permission_operation` (ID 105) + +**Auth:** `active` of `account`. Rejected before HF15. + +| Field | Type | Description | +|-------|------|-------------| +| `account` | `account_name_type` | Principal | +| `agent_name` | `string` | Agent name, unique per principal; `[a-z0-9_-]`, non-empty | +| `agent_key` | `public_key_type` | Agent key; required when granting, ignored on revoke | +| `operations` | `flat_set` | Wire names of operations the key may sign (`transfer`, `pm_place_bet`, …) | +| `expiration` | `time_point_sec` | `1970-01-01T00:00:00` = perpetual; a past time revokes | +| `addons` | `flat_set` | Off-chain scopes for external services (e.g. `vizhub`); at most 10, each shorter than 64 bytes, no `,`. The node stores them but never interprets them: they grant nothing on chain | +| `extensions` | `extensions_type` | Always `[]` | + +Grant (bot may bet and transfer, vizhub accepts its key): + +```json +["set_agent_permission", { + "account": "alice", + "agent_name": "trade-bot", + "agent_key": "VIZ6MyX5QiXAXRZk7SYCiqpi6Mtm8UbHWDFSV8HPpt7FJyahCnc2T", + "operations": ["pm_place_bet", "transfer"], + "expiration": "2027-01-01T00:00:00", + "addons": ["vizhub"], + "extensions": [] +}] +``` + +Addon-only agent (a key for an external service, no chain operations): + +```json +["set_agent_permission", { + "account": "alice", + "agent_name": "hub-login", + "agent_key": "VIZ7…", + "operations": [], + "expiration": "1970-01-01T00:00:00", + "addons": ["vizhub"], + "extensions": [] +}] +``` + +Revoke — both lists empty (the key may be the null key `VIZ1111111111111111111111111111111114T1Anm`): + +```json +["set_agent_permission", { + "account": "alice", "agent_name": "trade-bot", + "agent_key": "VIZ1111111111111111111111111111111114T1Anm", + "operations": [], "expiration": "1970-01-01T00:00:00", "addons": [], "extensions": [] +}] +``` + +Re-issuing by the same name replaces the key, operations, addons and expiration (key rotation = re-issue). + +## Rules + +- **Never delegable:** `set_agent_permission`, `proposal_create`, `proposal_update`, `proposal_delete`, `account_update`, `recover_account`, `change_recovery_account`, `set_account_price`, `set_subaccount_price`, `target_account_sale`. Virtual operations and deprecated aliases (use `validator_update`, not `witness_update`) are rejected. +- **One key, one agent:** a key already bound to another agent name of the same principal is rejected. +- **At most 16 agents** per principal. A grant first removes the principal's expired agents. +- **When an agent signature counts:** the transaction needs no master or regular authority, the principal's own keys do not already sign it, the agent is live (not expired, operation list non-empty), its list covers **every** operation of the transaction that needs a signature, and its key is among the signatures. No reach through nested `account_auths`. +- **Wipes:** all agents of the principal are removed on master change, active change, account recovery, direct sale and auction close. A regular-only change keeps them. + +## Reading agents + +`database_api.get_agent_permissions(account)` — see [database_api](../../plugins/database-api.md#get-agent-permissions-account). diff --git a/docs/protocol/operations/overview.md b/docs/protocol/operations/overview.md index 41fa9eeffd..abb87c7a48 100644 --- a/docs/protocol/operations/overview.md +++ b/docs/protocol/operations/overview.md @@ -77,7 +77,7 @@ These are user-initiated operations that can be broadcast to the network. | 98 | `pm_dispute_oracle_respond_operation` | active | [Prediction Markets](./prediction-markets.md) | | 99 | `pm_unban_operation` | active | [Prediction Markets](./prediction-markets.md) | -> IDs are the fixed index in the chain's single `operation` variant (append-only). Gaps in this table are **virtual** operations (below) interleaved by ID — e.g. 62–63, 65, 84–90, 94–97, 100. +> IDs are the fixed index in the chain's single `operation` variant (append-only). Gaps in this table are **virtual** operations (below) interleaved by ID — e.g. 62–63, 65, 84–90, 94–97, 100–104. --- @@ -122,6 +122,10 @@ Virtual operations are generated by the blockchain itself during block processin | 96 | `pm_market_accepted_operation` | Market went live (oracle accepted / self / auto) | [Prediction Markets](./prediction-markets.md) | | 97 | `pm_payout_operation` | Per-bettor parimutuel payout | [Prediction Markets](./prediction-markets.md) | | 100 | `pm_ban_expired_operation` | Temporary oracle/creator ban lapsed | [Prediction Markets](./prediction-markets.md) | +| 101 | `pm_market_expired_operation` | Market expired without resolution | [Prediction Markets](./prediction-markets.md) | +| 102 | `pm_dispute_opened_operation` | Dispute filed (oracle + disputer history) | [Prediction Markets](./prediction-markets.md) | +| 103 | `pm_early_exit_claim_paid_operation` | Early-exit deferred claim paid at settlement | [Prediction Markets](./prediction-markets.md) | +| 104 | `pm_lp_payout_operation` | LP income paid at settlement | [Prediction Markets](./prediction-markets.md) | --- diff --git a/docs/protocol/operations/prediction-markets.md b/docs/protocol/operations/prediction-markets.md index 33e93e533f..508f2c9218 100644 --- a/docs/protocol/operations/prediction-markets.md +++ b/docs/protocol/operations/prediction-markets.md @@ -127,7 +127,7 @@ Places an instant bet on the live curve. `min_tokens` is the slippage floor. `we | `outcome_index` | `int16_t` | Multi: 0..N-1; binary: -1 | | `amount` | `asset` (VIZ) | Stake (`> 0`) | | `min_tokens` | `share_type` | Slippage floor (0 = none) | -| `mode` | `uint8_t` | 0 instant, 1 batch | +| `mode` | `uint8_t` | 0 instant, 1 batch. On a market with `allow_instant_bet = false` the only accepted route is `pm_commit_bet` → `pm_reveal_bet`: after `CHAIN_PM_AUDIT_FIX_HARDFORK` a direct `mode = 1` is refused (pre-fork it silently took the instant fill, bypassing the gate the market opted into). | ### `pm_commit_bet_operation` (ID 71) **Auth:** `active` of `account` diff --git a/libraries/chain/CMakeLists.txt b/libraries/chain/CMakeLists.txt index 111ecb6042..272ea3e2b4 100644 --- a/libraries/chain/CMakeLists.txt +++ b/libraries/chain/CMakeLists.txt @@ -63,6 +63,7 @@ add_library(graphene_chain ${VIZ_LIBRARY_TYPE} pm/parimutuel.cpp pm/leverage.cpp pm_evaluator.cpp + agent_evaluator.cpp pm_process_markets.cpp include/graphene/chain/pm/lmsr_q96.hpp diff --git a/libraries/chain/agent_evaluator.cpp b/libraries/chain/agent_evaluator.cpp new file mode 100644 index 0000000000..d448c266ff --- /dev/null +++ b/libraries/chain/agent_evaluator.cpp @@ -0,0 +1,233 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace graphene { namespace chain { + +using namespace graphene::protocol; + +namespace { + +/// Wire names of the operations of `trx` that require SOME authority. An operation that requires +/// nothing grants nothing, so it puts no coverage demand on a delegation. +flat_set authority_requiring_operation_names(const signed_transaction& trx) { + flat_set names; + for (const auto& op : trx.operations) { + flat_set active, master, regular; + std::vector other; + operation_get_required_authorities(op, active, master, regular, other); + if (active.empty() && master.empty() && regular.empty() && other.empty()) continue; + names.insert(fc::resolve_operation_name(operation_wire_name(op))); + } + return names; +} + +/// The plain ACTIVE getter: what the chain has always used. Delegation is layered on top of it. +authority_getter plain_active_authority_getter(const database& db) { + return [&db](const account_name_type& name) { + return authority(db.get(name).active); + }; +} + +} // anonymous namespace + +fc::flat_map +delegated_active_authorities(const database& db, const signed_transaction& trx, + const chain_id_type& chain_id) { + fc::flat_map delegated; + + if (!db.has_hardfork(CHAIN_HARDFORK_15)) + return delegated; + + flat_set required_active, required_master, required_regular; + std::vector other; + trx.get_required_authorities(required_active, required_master, required_regular, other); + + if (required_active.empty()) + return delegated; + + // Master and regular are out of scope for an agent, and rather than argue about the nested + // paths that reach them (sign_state resolves nested account authorities through ACTIVE), we + // simply do not delegate in such a transaction. + if (!required_master.empty() || !required_regular.empty()) + return delegated; + + flat_set sigs; + bool sigs_ready = false; + // Signature recovery is the expensive part of validation, and verify_authority performs it + // again right after us. So it is deferred until an agent row actually exists for some + // principal: with no rows the hook must add no work at all to the ordinary path. + auto ensure_signatures = [&]() -> bool { + if (!sigs_ready) { + sigs_ready = true; + try { + sigs = trx.get_signature_keys(chain_id); + } catch (...) { + // Unsigned or malformed: leave the verdict to verify_authority, which reports it. + sigs.clear(); + return false; + } + } + return true; + }; + + const flat_set tx_ops = authority_requiring_operation_names(trx); + const authority_getter get_active = plain_active_authority_getter(db); + const flat_set no_extra_keys; // a validating node can produce no extra keys + + const auto& pidx = db.get_index().indices().get(); + const time_point_sec now = db.head_block_time(); + const flat_set& denied = never_delegable_operation_names(); + + for (const account_name_type& principal : required_active) { + // This principal's agents only — at most CHAIN_AGENT_MAX_PER_ACCOUNT rows. No row, no work. + auto it = pidx.lower_bound(boost::make_tuple(principal)); + if (it == pidx.end() || it->account != principal) + continue; + + if (!ensure_signatures()) + return delegated; + + // The principal's own authority always wins: substituting unconditionally would break valid + // transactions the moment the account issues its first agent. + { + sign_state principal_signs(sigs, get_active, no_extra_keys); + if (principal_signs.check_authority(principal)) + continue; + } + + for (; it != pidx.end() && it->account == principal; ++it) { + const agent_permission_object& row = *it; + + // The agent's key must actually have signed. + if (!sigs.count(row.agent_key)) + continue; + + // Expiration: epoch means perpetual; a past date means the row is already dead. + if (row.expiration != time_point_sec() && row.expiration <= now) + continue; + + const flat_set granted = unpack_operation_names(row.operations); + if (granted.empty()) + continue; + + // A row holding a non-delegable name is an invariant breach (the evaluator refuses + // those), and the hook trusts these rows — so fail closed instead of trusting it. + bool usable = true; + for (const string& name : granted) { + if (denied.count(name)) { usable = false; break; } + } + if (!usable) + continue; + + // Full coverage of the transaction, per the header's contract. + for (const string& name : tx_ops) { + if (!granted.count(name)) { usable = false; break; } + } + if (!usable) + continue; + + delegated[principal] = row.agent_key; + break; + } + } + + return delegated; +} + +// ─── set_agent_permission ──────────────────────────────────────────────────── +// Issue, replace or revoke an agent (label + key) of a principal. The op requires the principal's +// active authority; the single hook in database.cpp recognizes agent keys on later transactions. +// Here we only maintain the object and re-check the list. +// +// The list is re-validated here, not only in the protocol's validate(): the hook consults these +// rows on every transaction, so a row that somehow holds a name it must not hold would be a live +// escalation, not a cosmetic problem. Cheap insurance on a consensus path. +void set_agent_permission_evaluator::do_apply(const set_agent_permission_operation& o) { + auto& db = _db; + FC_ASSERT(db.has_hardfork(CHAIN_HARDFORK_15), "Agent access is not enabled yet"); + + const auto& aidx = db.get_index().indices().get(); + FC_ASSERT(aidx.find(o.account) != aidx.end(), "Principal account ${a} does not exist", ("a", o.account)); + + auto& pidx = db.get_index().indices().get(); + auto existing = pidx.find(boost::make_tuple(o.account, o.agent_name)); + const auto now = db.head_block_time(); + + // Both lists empty = revoke. Addons alone make a valid agent (a key for off-chain services only, + // q1718=A); such a row grants nothing on chain — the hook skips an empty operation list. + if (o.operations.empty() && o.addons.empty()) { + if (existing != pidx.end()) + db.remove(*existing); + return; + } + + // Perpetual = epoch; `time_point_sec(0)`. An expiration in the past revokes the row: that is a + // legitimate way to say "now, and not longer", not an error. + const bool dead_on_arrival = o.expiration != time_point_sec() && o.expiration <= now; + + const flat_set& denied = never_delegable_operation_names(); + for (const string& raw : o.operations) { + const string name = fc::resolve_operation_name(raw); + FC_ASSERT(!denied.count(name), "Operation ${n} is not delegable", ("n", name)); + FC_ASSERT(is_broadcastable_operation_wire_name(name), + "Unknown or non-broadcastable operation ${n}", ("n", name)); + } + + if (dead_on_arrival) { + if (existing != pidx.end()) + db.remove(*existing); + return; + } + + // Touch-time cleanup: every grant sweeps the principal's expired rows, so a dead row lives at + // most until the principal's next grant. Bounded by the per-principal cap below. + uint32_t live_others = 0; + for (auto it = pidx.lower_bound(boost::make_tuple(o.account)); it != pidx.end() && it->account == o.account;) { + const auto& row = *it++; // advance before a possible remove + if (row.expiration != time_point_sec() && row.expiration <= now) + db.remove(row); + else if (row.agent_name != o.agent_name) { + ++live_others; + // One key, one agent: otherwise a signature could not be attributed to a single list, + // and revoking one agent would leave its key alive under another name. + FC_ASSERT(row.agent_key != o.agent_key, + "Key ${k} already belongs to agent ${n} of ${a}", + ("k", o.agent_key)("n", row.agent_name)("a", o.account)); + } + } + existing = pidx.find(boost::make_tuple(o.account, o.agent_name)); // the sweep may have removed it + const string packed = join_operation_names(o.operations); + const string packed_addons = join_operation_names(o.addons); + if (existing != pidx.end()) { + db.modify(*existing, [&](agent_permission_object& p) { + p.agent_key = o.agent_key; + from_string(p.operations, packed); + p.expiration = o.expiration; + from_string(p.addons, packed_addons); + }); + } else { + // The hook walks all of a principal's rows for every transaction the principal did not sign + // itself, and a rejected transaction pays no bandwidth — so the row count must be bounded. + FC_ASSERT(live_others < CHAIN_AGENT_MAX_PER_ACCOUNT, + "Account ${a} already has ${n} agents, the limit is ${m}", + ("a", o.account)("n", live_others)("m", CHAIN_AGENT_MAX_PER_ACCOUNT)); + db.create([&](agent_permission_object& p) { + p.account = o.account; + p.agent_name = o.agent_name; + p.agent_key = o.agent_key; + from_string(p.operations, packed); + p.expiration = o.expiration; + from_string(p.addons, packed_addons); + }); + } +} + +} } // graphene::chain diff --git a/libraries/chain/chain_evaluator.cpp b/libraries/chain/chain_evaluator.cpp index d5da60ec85..52be448259 100644 --- a/libraries/chain/chain_evaluator.cpp +++ b/libraries/chain/chain_evaluator.cpp @@ -196,6 +196,12 @@ namespace graphene { namespace chain { } }); } + // Active change: the delegation dies with the keys (master changes wipe inside + // update_master_authority). A regular-only change leaves it alone — agents sign with + // active, so regular keys never stood behind a grant. + if (o.active) { + _db.wipe_agent_permissions(o.account); + } } @@ -2156,7 +2162,16 @@ namespace graphene { namespace chain { a.current_bidder_key = public_key_type(op.account_authorities_key); a.account_on_auction=true; - time_point_sec expand_start_time=fc::time_point::now() + CHAIN_ACCOUNT_AUCTION_EXTENSION_TIME; + // A late bid extends the auction. Before HF15 the extension was measured from the + // node's WALL CLOCK, so every node (and every replay) computed its own close time: + // validators could close the auction in different blocks, and a replay from the + // block log could not reproduce the historical state. From HF15 it is measured from + // head_block_time(), which every node agrees on. The pre-fork branch is kept as is + // only because past blocks were applied that way. + const time_point_sec extension_base = _db.has_hardfork(CHAIN_HARDFORK_15) + ? _db.head_block_time() + : time_point_sec(fc::time_point::now()); + time_point_sec expand_start_time = extension_base + CHAIN_ACCOUNT_AUCTION_EXTENSION_TIME; a.account_on_sale_start_time = std::max(a.account_on_sale_start_time, expand_start_time); const auto& new_account_bidder = _db.get_account(op.buyer); @@ -2215,6 +2230,8 @@ namespace graphene { namespace chain { auth.regular = auth.active; auth.last_master_update = _db.head_block_time(); }); + // Sold: agent keys must not follow the account to its buyer. + _db.wipe_agent_permissions(account.name); _db.push_virtual_operation( account_sale_operation(op.account,op.account_offer_price,op.buyer,account_seller.name)); } @@ -2275,6 +2292,8 @@ namespace graphene { namespace chain { auth.regular = auth.active; auth.last_master_update = _db.head_block_time(); }); + // Sold: agent keys must not follow the account to its buyer. + _db.wipe_agent_permissions(account.name); _db.push_virtual_operation( account_sale_operation(op.account,op.account_offer_price,op.buyer,account_seller.name)); } diff --git a/libraries/chain/database.cpp b/libraries/chain/database.cpp index 87d90fbb64..1247a6c999 100644 --- a/libraries/chain/database.cpp +++ b/libraries/chain/database.cpp @@ -21,6 +21,8 @@ #include #include #include +#include +#include #include #include #include @@ -4060,6 +4062,19 @@ namespace graphene { namespace chain { auth.master = master_authority; auth.last_master_update = head_block_time(); }); + // Master change (account_update, recover_account): the agents die with the keys. + wipe_agent_permissions(account.name); + } + + void database::wipe_agent_permissions(const account_name_type &name) { + if (!has_hardfork(CHAIN_HARDFORK_15)) + return; + const auto &by_principal = get_index().indices().get(); + for (auto it = by_principal.lower_bound(boost::make_tuple(name)); + it != by_principal.end() && it->account == name;) { + const auto &row = *it++; // advance before remove: remove invalidates `it` + remove(row); + } } void database::process_vesting_withdrawals() { @@ -4843,6 +4858,8 @@ namespace graphene { namespace chain { auth.regular = auth.active; auth.last_master_update = head_block_time(); }); + // Auction closed: the account has a new owner. + wipe_agent_permissions(account.name); account.account_seller = ""; account.account_on_sale=false; @@ -4966,6 +4983,8 @@ namespace graphene { namespace chain { auth.regular = auth.active; auth.last_master_update = head_block_time(); }); + // Auction closed: the account has a new owner. + wipe_agent_permissions(account.name); account.account_seller = ""; account.account_on_sale=false; @@ -5130,6 +5149,7 @@ namespace graphene { namespace chain { _my->_evaluator_registry.register_evaluator(); _my->_evaluator_registry.register_evaluator(); _my->_evaluator_registry.register_evaluator(); + _my->_evaluator_registry.register_evaluator(); // HF15 } void database::set_custom_operation_interpreter(const std::string &id, std::shared_ptr registry) { @@ -5196,6 +5216,7 @@ namespace graphene { namespace chain { add_core_index(*this); add_core_index(*this); add_core_index(*this); + add_core_index(*this); // HF15 agent access _plugin_index_signal(); } @@ -5571,7 +5592,22 @@ namespace graphene { namespace chain { if (!(skip & (skip_transaction_signatures | skip_authority_check))) { const chain_id_type &chain_id = CHAIN_ID; + // HF15 agent access. A principal may issue agent keys, each allowed to sign a listed + // set of operations for it. The decision lives in the chain layer (it needs + // the permission objects); the signatures are still checked by the ordinary + // sign_state path below, which is why substituting the getter is enough — nothing + // here approves anything on its own. The map is empty below HF15 and for every + // transaction master/regular touches, so the pre-fork behaviour is bit-for-bit. + const auto delegated = delegated_active_authorities(*this, trx, chain_id); + auto get_active = [&](const account_name_type& name) { + const auto itr = delegated.find(name); + if (itr != delegated.end()) { + authority a; + a.weight_threshold = 1; + a.key_auths[itr->second] = 1; + return a; + } return authority(get(name).active); }; diff --git a/libraries/chain/database_hardfork.cpp b/libraries/chain/database_hardfork.cpp index 1b2a24f340..54fa0fd116 100644 --- a/libraries/chain/database_hardfork.cpp +++ b/libraries/chain/database_hardfork.cpp @@ -102,6 +102,14 @@ inline u256 to256(const fc::uint128_t &t) { _hardfork_times[CHAIN_HARDFORK_14] = fc::time_point_sec(CHAIN_HARDFORK_14_TIME); _hardfork_versions[CHAIN_HARDFORK_14] = CHAIN_HARDFORK_14_VERSION; + // 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. + _hardfork_times[CHAIN_HARDFORK_15] = fc::time_point_sec(CHAIN_HARDFORK_15_TIME); + _hardfork_versions[CHAIN_HARDFORK_15] = CHAIN_HARDFORK_15_VERSION; + const auto &hardforks = get_hardfork_property_object(); FC_ASSERT( hardforks.last_hardfork <= CHAIN_NUM_HARDFORKS, @@ -171,6 +179,15 @@ inline u256 to256(const fc::uint128_t &t) { } } + fc::optional database::get_hardfork_number(const protocol::hardfork_version &v) const { + for (uint32_t i = 0; i <= CHAIN_NUM_HARDFORKS; ++i) { + if (_hardfork_versions[i] == v) { + return i; + } + } + return fc::optional(); + } + void database::apply_hardfork(uint32_t hardfork) { if (_log_hardforks) { elog("HARDFORK ${hf} at block ${b}", ("hf", hardfork)("b", head_block_num())); diff --git a/libraries/chain/hardfork.d/0-preamble.hf b/libraries/chain/hardfork.d/0-preamble.hf index be8af26aac..a7dfc85d97 100644 --- a/libraries/chain/hardfork.d/0-preamble.hf +++ b/libraries/chain/hardfork.d/0-preamble.hf @@ -52,4 +52,10 @@ 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 -#define CHAIN_NUM_HARDFORKS 14 +// 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 diff --git a/libraries/chain/hardfork.d/15.hf b/libraries/chain/hardfork.d/15.hf new file mode 100644 index 0000000000..66029c8017 --- /dev/null +++ b/libraries/chain/hardfork.d/15.hf @@ -0,0 +1,43 @@ +// 15 Hardfork — PM audit fixes: partial-LP b_share bookkeeping on LMSR markets (a legacy +// partial withdrawal took the full historical share out of the curve while the position kept +// its full b_share, so the next withdrawal could drive market.lmsr_b to <= 0 and every outcome +// then priced at zero — a bet cost nothing while the market still held the LP capital and the +// bettors' stakes) and the mode=1 batch bypass (a "batch" bet was filled immediately, skipping +// the front-run-resistant commit -> reveal flow that the market opted into). +// See docs/prediction-markets/pm-audit-fix-upgrade.md. +#ifndef CHAIN_HARDFORK_15 +#define CHAIN_HARDFORK_15 15 +// 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_AUDIT_FIX_HARDFORK +#define CHAIN_PM_AUDIT_FIX_HARDFORK 15 +#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_15_TIME 1790498000 // 2026-09-27 08:33:20 UTC +#define CHAIN_HARDFORK_15_VERSION hardfork_version( version(4, 1, 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. +// +// CONFIRMED by the owner (2026-09-27, question #1642): 2026-10-05 00:00:00 UTC is the announced +// mainnet date, and this is the value the validator announcement names. 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). +// Note that HF14's own mainnet date (1787875200) has already passed, so on mainnet the pair will +// activate together in one block whenever quorum is reached; only the testnet, whose snapshot +// carries HF14 already processed, can exercise HF15 on its own. +#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. +#endif diff --git a/libraries/chain/include/graphene/chain/agent_evaluator.hpp b/libraries/chain/include/graphene/chain/agent_evaluator.hpp new file mode 100644 index 0000000000..ce5bc4894f --- /dev/null +++ b/libraries/chain/include/graphene/chain/agent_evaluator.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include + +namespace graphene { namespace chain { + + /// HF15 agent access: applies set_agent_permission_operation (grant / re-grant / revoke). + class set_agent_permission_evaluator + : public evaluator_impl { + public: + typedef graphene::protocol::set_agent_permission_operation operation_type; + + set_agent_permission_evaluator(database& db) + : evaluator_impl(db) {} + + void do_apply(const operation_type& o); + }; + + /// HF15 agent access: principals of `trx` whose required ACTIVE authority is answered by one + /// of their agent keys, mapped to that key. The authority hook in database.cpp answers + /// `get_active(principal)` with a single-key authority of that key, so the ordinary + /// sign_state path still performs the signature check — this function only decides whether + /// an agent applies at all, and never grants anything by itself. + /// + /// Empty unless HF15 is active, and for any transaction that asks for MASTER or REGULAR + /// authority. An agent must cover EVERY authority-requiring operation of the transaction: + /// the hook cannot tell a top-level requirement from a nested one, so the only statement it + /// can stand behind is "the agent acted within its list for every operation here". + fc::flat_map + delegated_active_authorities(const database& db, + const graphene::protocol::signed_transaction& trx, + const graphene::protocol::chain_id_type& chain_id); + +} } // graphene::chain diff --git a/libraries/chain/include/graphene/chain/agent_objects.hpp b/libraries/chain/include/graphene/chain/agent_objects.hpp new file mode 100644 index 0000000000..87be6f114d --- /dev/null +++ b/libraries/chain/include/graphene/chain/agent_objects.hpp @@ -0,0 +1,88 @@ +#pragma once + +#include + +#include + +// HF15 agent access (Onix) — consensus object behind set_agent_permission_operation. +// +// One row per (principal, agent name): a key the principal issued may sign the listed operations on +// the principal's behalf until `expiration` (epoch = perpetual). The agent is NOT an account — it is a +// label plus a public key stored on the principal's side. The list is what a delegation IS, so it is +// stored explicitly — never as a role, a level or a prefix mask: a mask would silently widen the +// grant the day a new operation is appended to the chain. + +namespace graphene { namespace chain { + + using protocol::string_less; + + /// Operation names are `[a-z0-9_]` only (enforced by the op's validate()), so a `,`-joined + /// string is unambiguous. Storing the list as one allocator-aware string keeps the object a + /// flat POD: no container allocator plumbing, no layout surprises on snapshot import, and a + /// client reading the object sees the names directly. Canonical form: sorted, unique. + /// `shared_string` is allocator-aware and cannot be default-constructed off the heap, so the + /// packers work on plain strings and the caller (which owns a live object) converts. + inline string join_operation_names(const flat_set& names) { + string joined; + for (const string& n : names) { + if (!joined.empty()) joined += ','; + joined += n; + } + return joined; + } + + inline flat_set unpack_operation_names(const shared_string& packed) { + flat_set names; + const string joined = to_string(packed); + if (joined.empty()) return names; + size_t pos = 0; + while (pos <= joined.size()) { + const size_t comma = joined.find(',', pos); + const size_t end = (comma == string::npos) ? joined.size() : comma; + if (end > pos) names.insert(joined.substr(pos, end - pos)); + if (comma == string::npos) break; + pos = comma + 1; + } + return names; + } + + // ───────────────────────── 1.14 agent_permission_object ───────────────────────── + class agent_permission_object + : public object { + public: + agent_permission_object() = delete; + template + agent_permission_object(Constructor&& c, allocator a) : operations(a), addons(a) { c(*this); } + + id_type id; + account_name_type account; ///< principal that granted the access + account_name_type agent_name; ///< label, unique per principal (not an account) + public_key_type agent_key; ///< key that signs for the principal; unique per principal + shared_string operations; ///< canonical `,`-joined wire names; empty = addon-only agent + time_point_sec expiration; ///< epoch = perpetual; past = no longer valid + shared_string addons; ///< `,`-joined off-chain scopes; opaque to consensus + }; + + struct by_permission_account; + typedef multi_index_container< + agent_permission_object, + indexed_by< + ordered_unique, + member>, + // (principal, agent name): one row per name, so re-granting replaces instead of piling + // up, and the authority hook walks exactly one principal's rows by prefix. + ordered_unique, + composite_key, + member + >, + composite_key_compare + > + >, + allocator + > agent_permission_index; + +} } // graphene::chain + +FC_REFLECT((graphene::chain::agent_permission_object), (id)(account)(agent_name)(agent_key)(operations)(expiration)(addons)) +CHAINBASE_SET_INDEX_TYPE(graphene::chain::agent_permission_object, graphene::chain::agent_permission_index) diff --git a/libraries/chain/include/graphene/chain/chain_object_types.hpp b/libraries/chain/include/graphene/chain/chain_object_types.hpp index 8efc82aa02..d228792def 100644 --- a/libraries/chain/include/graphene/chain/chain_object_types.hpp +++ b/libraries/chain/include/graphene/chain/chain_object_types.hpp @@ -91,7 +91,8 @@ namespace graphene { namespace chain { pm_creator_ban_object_type, pm_lazy_withdraw_request_object_type, pm_deferred_claim_object_type, - pm_settlement_object_type + pm_settlement_object_type, + agent_permission_object_type // HF15 agent access }; class dynamic_global_property_object; @@ -145,6 +146,7 @@ namespace graphene { namespace chain { class pm_lazy_withdraw_request_object; class pm_deferred_claim_object; class pm_settlement_object; + class agent_permission_object; // HF15 agent access typedef object_id dynamic_global_property_id_type; typedef object_id account_id_type; @@ -196,6 +198,7 @@ namespace graphene { namespace chain { typedef object_id pm_lazy_withdraw_request_id_type; typedef object_id pm_deferred_claim_id_type; typedef object_id pm_settlement_id_type; + typedef object_id agent_permission_id_type; // HF15 } } //graphene::chain @@ -309,6 +312,7 @@ FC_REFLECT_ENUM(graphene::chain::object_type, (pm_lazy_withdraw_request_object_type) (pm_deferred_claim_object_type) (pm_settlement_object_type) + (agent_permission_object_type) ) FC_REFLECT_TYPENAME((graphene::chain::shared_string)) diff --git a/libraries/chain/include/graphene/chain/database.hpp b/libraries/chain/include/graphene/chain/database.hpp index 618d7ab7c3..9d33b5bc85 100644 --- a/libraries/chain/include/graphene/chain/database.hpp +++ b/libraries/chain/include/graphene/chain/database.hpp @@ -9,6 +9,7 @@ #include #include +#include #include @@ -465,6 +466,11 @@ namespace graphene { namespace chain { void update_master_authority(const account_object &account, const authority &master_authority); + /// HF15 agent access: drop every agent `name` has issued. Called in the same step as any + /// change of the account's master/active authority, recovery and sale, so an agent key + /// never survives the owner keys it was issued under. No-op below HF15. + void wipe_agent_permissions(const account_name_type &name); + asset get_balance(const account_object &a, asset_symbol_type symbol) const; asset get_balance(const string &aname, asset_symbol_type symbol) const { @@ -582,6 +588,11 @@ namespace graphene { namespace chain { with id N, applies all hardforks with id <= N */ void set_hardfork(uint32_t hardfork, bool process_now = true); + /* For testing and debugging only. Number of the hardfork whose version equals the + given one, so a caller can feed set_hardfork() a version like 4.1.0 instead of a + number. Empty when this binary knows no hardfork with that version. */ + fc::optional get_hardfork_number(const protocol::hardfork_version &v) const; + void validate_invariants() const; /** diff --git a/libraries/chain/include/graphene/chain/pm/leverage.hpp b/libraries/chain/include/graphene/chain/pm/leverage.hpp index 6e24b95e95..c785c006a4 100644 --- a/libraries/chain/include/graphene/chain/pm/leverage.hpp +++ b/libraries/chain/include/graphene/chain/pm/leverage.hpp @@ -45,7 +45,7 @@ namespace graphene { namespace chain { namespace pm { namespace leverage { // §4.4 — pool obligation / liquidation threshold = loan × (1 + r_percent/100). int64_t liquidation_threshold(int64_t loan, uint16_t r_percent); - // §4.6 Constraint 2 (API preview) — max loan L (50-iter binary search over [0, hi]) + // §4.6 Constraint 2 (API preview) — max loan L (inclusive binary search over [0, hi]) // such that, after placing (collateral+L) on `outcome`, the worst-case cancel value // ≥ liquidation_threshold(L) × (1 + s_percent/100). Reserves are the PRE-bet market // reserves. Returns the loan (0 if none qualifies). diff --git a/libraries/chain/pm/leverage.cpp b/libraries/chain/pm/leverage.cpp index 652a97f54d..270ceeb469 100644 --- a/libraries/chain/pm/leverage.cpp +++ b/libraries/chain/pm/leverage.cpp @@ -74,19 +74,24 @@ namespace graphene { namespace chain { namespace pm { namespace leverage { int64_t collateral, int outcome, int64_t hi_loan, uint16_t r_percent, uint16_t s_percent, uint16_t sl_percent, uint16_t m_factor_percent) { - int64_t lo = 0, hi = hi_loan, best = 0; - for (int i = 0; i < 50; ++i) { - int64_t mid = (lo + hi) / 2; - if (mid <= lo) break; + if (hi_loan <= 0) return 0; + int64_t lo = 0, hi = hi_loan; + // Inclusive upper-bound search. The previous floor midpoint never tested + // hi itself and returned cap-1 even when the cap was solvent. In quotes + // this could turn the minimum valid loan into an invalid sub-floor loan. + // Upper midpoint ensures progress; computing the distance avoids lo+hi overflow. + while (lo < hi) { + const int64_t distance = hi - lo; + const int64_t mid = lo + distance / 2 + distance % 2; cpmm_fill f = cpmm_buy(reserve_a, reserve_b, k, collateral + mid, outcome); int64_t m = worst_opposing_bet(f.new_reserve_a, f.new_reserve_b, sl_percent, m_factor_percent); int64_t cvw = cancel_value_after_opposing(f.new_reserve_a, f.new_reserve_b, k, f.tokens, outcome, m); int64_t thr_safe = mul_pct(liquidation_threshold(mid, r_percent), s_percent); - if (cvw >= thr_safe) { best = mid; lo = mid; } - else { hi = mid; } + if (cvw >= thr_safe) lo = mid; + else hi = mid - 1; } - return best; + return lo; } }}}} // graphene::chain::pm::leverage diff --git a/libraries/chain/pm_evaluator.cpp b/libraries/chain/pm_evaluator.cpp index f966c37f96..d5eec112da 100644 --- a/libraries/chain/pm_evaluator.cpp +++ b/libraries/chain/pm_evaluator.cpp @@ -405,8 +405,13 @@ void pm_place_bet_evaluator::do_apply(const pm_place_bet_operation& o) { FC_ASSERT(mkt.status == 1, "Market not active"); FC_ASSERT(mkt.betting_expiration == time_point_sec() || now < mkt.betting_expiration, "Betting period ended"); - // Betting-mode gate (scenario #55): a market may disable instant bets (allow_instant_bet=false) - // to force the front-run-resistant batch / commit-reveal flow. mode 0 = instant, mode 1 = batch. + // Pre-upgrade mode=1 was filled immediately despite its batch label. Preserve + // historical replay; after HF15 (CHAIN_PM_AUDIT_FIX_HARDFORK) batch stakes must + // enter through commit -> reveal (which creates status=5 rows). + // docs/prediction-markets/pm-audit-fix-upgrade.md + if (db.has_hardfork(CHAIN_PM_AUDIT_FIX_HARDFORK)) + FC_ASSERT(o.mode == 0, "Batch bets must use commit and reveal"); + // Betting-mode gate (scenario #55): a market may disable instant bets (allow_instant_bet=false). if (o.mode == 0) FC_ASSERT(mkt.allow_instant_bet, "Instant betting is disabled for this market"); else FC_ASSERT(mkt.allow_batch, "Batch betting is not enabled for this market"); @@ -876,17 +881,36 @@ void pm_withdraw_liquidity_evaluator::do_apply(const pm_withdraw_liquidity_opera db.adjust_balance(db.get_account(o.provider), asset(total, TOKEN_SYMBOL)); db.pm_adjust_frozen(o.provider, 0, -withdraw); // UNLOCK: principal back to free (earned_fee is profit, not frozen) + // Compute once from the pre-withdraw LP state. Legacy replay subtracts from + // the curve but leaves the partial LP share untouched; post-upgrade both + // records must shrink by the same (integer-rounded) amount. + share_type b_remove(0); + if (mkt.market_type == 1 && lp.b_share.value > 0 && lp.amount.value > 0) { + b_remove = (withdraw == lp.amount) ? lp.b_share : + share_type((int64_t)(fc::uint128_t((uint64_t)lp.b_share.value) * + fc::uint128_t((uint64_t)withdraw.value) / + fc::uint128_t((uint64_t)lp.amount.value)).lo); + // Post-fix the two records are written in step, so Σ b_share over the active rows is equal + // to mkt.lmsr_b and b_remove can never exceed it. State written by the legacy path can + // break that: a pre-fix partial withdrawal took b_remove out of the curve while leaving + // this row's b_share untouched, so the row now claims more than the market holds. Taking it + // out anyway would drive lmsr_b to <= 0, and that is not a merely flat curve — lmsr_q96 + // fails soft (lmsr_price/lmsr_buy_cost/lmsr_tokens_for_amount all return 0 for b <= 0), so + // every outcome prices at zero and a bet costs nothing while the market still holds the + // remaining LP capital and the bettors' stakes. Fail closed instead: the position keeps its + // principal (returned in full at settlement, where the live-market floor below no longer + // applies) and the stale row shows up in the log and in the pre-upgrade detector instead of + // as a drained curve. Unreachable for markets created after the fix. + FC_ASSERT(!db.has_hardfork(CHAIN_PM_AUDIT_FIX_HARDFORK) || b_remove <= mkt.lmsr_b, + "Withdrawal would drain the LMSR pricing curve: this position's b_share is larger " + "than the market holds (pre-audit-fix partial-withdraw bookkeeping)", + ("b_remove", b_remove)("lmsr_b", mkt.lmsr_b)("market", mkt.id)("liquidity", lp.id)); + } db.modify(mkt, [&](pm_market_object& m) { const int64_t L = m.liquidity_sum.value; // capital BEFORE this withdrawal m.liquidity_sum -= withdraw; if (m.market_type == 1) { - if (lp.b_share.value > 0 && lp.amount.value > 0) { - share_type b_remove = (withdraw == lp.amount) ? lp.b_share : - share_type((int64_t)(fc::uint128_t((uint64_t)lp.b_share.value) * - fc::uint128_t((uint64_t)withdraw.value) / - fc::uint128_t((uint64_t)lp.amount.value)).lo); - m.lmsr_b -= b_remove; - } + m.lmsr_b -= b_remove; } else if (L > 0) { // CPMM: price-neutral withdraw. Shrink both reserves by (L - withdraw) / L so // the reserve ratio (the odds) is unchanged and depth falls with capital. @@ -902,7 +926,11 @@ void pm_withdraw_liquidity_evaluator::do_apply(const pm_withdraw_liquidity_opera if (withdraw == lp.amount) { db.modify(lp, [](pm_liquidity_object& l) { l.status = 3; l.earned_fee = 0; }); } else { - db.modify(lp, [&](pm_liquidity_object& l) { l.amount -= withdraw; l.earned_fee = 0; }); + db.modify(lp, [&](pm_liquidity_object& l) { + l.amount -= withdraw; + l.earned_fee = 0; + if (db.has_hardfork(CHAIN_PM_AUDIT_FIX_HARDFORK)) l.b_share -= b_remove; + }); } // #2 cascade backstop: the raised floor above bounds AGGREGATE depth, but shrinking the reserves diff --git a/libraries/protocol/CMakeLists.txt b/libraries/protocol/CMakeLists.txt index 0cae865edf..877fae7dd5 100644 --- a/libraries/protocol/CMakeLists.txt +++ b/libraries/protocol/CMakeLists.txt @@ -35,6 +35,7 @@ list(APPEND ${CURRENT_TARGET}_SOURCES sign_state.cpp chain_operations.cpp pm_operations.cpp + agent_operations.cpp transaction.cpp types.cpp version.cpp diff --git a/libraries/protocol/agent_operations.cpp b/libraries/protocol/agent_operations.cpp new file mode 100644 index 0000000000..e32dc0920b --- /dev/null +++ b/libraries/protocol/agent_operations.cpp @@ -0,0 +1,82 @@ +#include +#include +#include + +namespace graphene { namespace protocol { + + /// Longest wire name of an existing operation is well below this; the cap is anti-spam, not + /// a semantic limit (the list itself is what a delegation is). + static const size_t AGENT_MAX_OPERATION_NAME_LEN = 64; + static const size_t AGENT_MAX_ADDONS = 10; // owner decision 2026-09-28 + static const size_t AGENT_MAX_ADDON_LEN = 63; // "shorter than 64" + + const flat_set& never_delegable_operation_names() { + static const flat_set names = []() { + flat_set s; + // An agent must not mint itself further rights: the grant is signed with the + // principal's active authority, so an agent holding active could otherwise + // re-delegate. Escalation chains are refused structurally. + s.insert("set_agent_permission"); + // Proposal wrappers carry arbitrary operations whose authorities are collected at + // EXECUTION time from the wrapped ops. Delegating `proposal_create` would therefore + // not mean "may create a proposal" but "may execute anything the principal can" — + // the explicit list would be bypassed while looking narrow. + s.insert("proposal_create"); + s.insert("proposal_update"); + s.insert("proposal_delete"); + // Authority rotation. `account_update` is the sharp one: an op without the `master` + // field is satisfied by the ACTIVE authority (see account_update_operation:: + // get_required_active_authorities) and may carry a new `active` authority — so an + // agent granted account_update for "metadata edits" could simply rotate the + // principal's active key to one it controls and own the account outright. + s.insert("account_update"); + // Operations that always demand the principal's MASTER authority are structurally + // out of reach for an agent (the hook never substitutes master). Granting one would + // create a permission that can never succeed: a silent no-op, refused on principle. + s.insert("recover_account"); + s.insert("change_recovery_account"); + s.insert("set_account_price"); + s.insert("set_subaccount_price"); + s.insert("target_account_sale"); + return s; + }(); + return names; + } + + void set_agent_permission_operation::validate() const { + FC_ASSERT(is_valid_account_name(account), "Account name ${n} is invalid", ("n", account)); + const string label = agent_name; + FC_ASSERT(!label.empty(), "agent_name is empty"); + FC_ASSERT(label.find_first_not_of("abcdefghijklmnopqrstuvwxyz0123456789_-") == string::npos, + "agent_name ${n} must be lower-case ascii, digits, '_' or '-'", ("n", label)); + if (!operations.empty() || !addons.empty()) + FC_ASSERT(agent_key != public_key_type(), "agent_key is required when granting"); + + // Addons are opaque to the node; only bounded. ',' is the storage separator. + FC_ASSERT(addons.size() <= AGENT_MAX_ADDONS, "at most ${c} addons", ("c", AGENT_MAX_ADDONS)); + for (const string& a : addons) { + FC_ASSERT(!a.empty(), "empty addon"); + FC_ASSERT(a.size() <= AGENT_MAX_ADDON_LEN, "addon ${a} is longer than ${c} bytes", + ("a", a)("c", AGENT_MAX_ADDON_LEN)); + FC_ASSERT(a.find(',') == string::npos, "addon ${a} must not contain ','", ("a", a)); + } + + for (const string& raw : operations) { + FC_ASSERT(!raw.empty(), "empty operation name in the permission list"); + FC_ASSERT(raw.size() <= AGENT_MAX_OPERATION_NAME_LEN, + "operation name ${n} is longer than ${c} bytes", + ("n", raw)("c", AGENT_MAX_OPERATION_NAME_LEN)); + FC_ASSERT(raw.find_first_not_of("abcdefghijklmnopqrstuvwxyz0123456789_") == string::npos, + "operation name ${n} must be lower-case ascii, digits or underscore", ("n", raw)); + const string name = fc::resolve_operation_name(raw); + FC_ASSERT(name == raw, "operation name ${n} is a legacy alias, use ${c}", ("n", raw)("c", name)); + FC_ASSERT(!never_delegable_operation_names().count(name), + "operation ${n} is not delegable", ("n", name)); + // A name that no operation answers to (or a virtual one, which is never broadcast) + // would be a silently dead permission: refuse it at grant time. + FC_ASSERT(is_broadcastable_operation_wire_name(name), + "unknown or non-broadcastable operation ${n}", ("n", name)); + } + } + +} } // graphene::protocol diff --git a/libraries/protocol/include/graphene/protocol/agent_operations.hpp b/libraries/protocol/include/graphene/protocol/agent_operations.hpp new file mode 100644 index 0000000000..0a7cec0df0 --- /dev/null +++ b/libraries/protocol/include/graphene/protocol/agent_operations.hpp @@ -0,0 +1,62 @@ +#pragma once + +#include +#include + +#include + +namespace graphene { namespace protocol { + + // HF15 agent access (Onix). Account-level delegation with an explicit, frozen list of + // operations — no roles, no levels, no wildcard masks. A mask (`pm_*`) would silently + // widen the grant the day a new operation is appended to the chain, which is exactly the + // failure mode this design exists to avoid. + // + // Op-id NOTE: appended to the single `operation` static_variant (see operations.hpp); the + // variant index IS the consensus op-id. + + /// Names that must never appear in a delegation list. Single source of truth: the grant + /// operation refuses them in validate(), and the chain-side authority hook refuses them + /// again at execution time — the rule must not live in one place only. + const flat_set& never_delegable_operation_names(); + + /// Issue, replace or revoke an agent of `account`. An agent is not an account: it is a + /// label (`agent_name`, unique per principal) and a public key (`agent_key`) that may sign the + /// listed operations on the principal's behalf. The principal may hold up to + /// CHAIN_AGENT_MAX_PER_ACCOUNT live agents. + /// + /// Rules enforced here and in the evaluator: + /// - signed by the principal's ACTIVE authority; + /// - a transaction signed by an agent key passes only if every authority-requiring operation + /// in it is on that agent's list and nothing in it needs master or regular authority; + /// - never-delegable names are refused (see never_delegable_operation_names()); unknown + /// or virtual names are refused too — a typo must not become a dead permission; + /// - one key per agent, and a key may belong to one agent of the principal only; + /// - empty `operations` = revoke the named agent; `expiration` in the past = revoke; + /// epoch (default) = perpetual; + /// - any change of the principal's master or active authority, recovery or sale wipes all + /// of the principal's agents. + /// + /// Operation names are the wire names (`transfer`, `pm_place_bet`, ...), stored normalized + /// through fc::resolve_operation_name. + struct set_agent_permission_operation : public base_operation { + account_name_type account; ///< principal granting the access + account_name_type agent_name; ///< label, unique per principal; [a-z0-9_-], 1..32 + public_key_type agent_key; ///< key the agent signs with; ignored on revoke + flat_set operations; ///< wire names; empty with empty addons = revoke + time_point_sec expiration; ///< epoch (default) = perpetual + /// Off-chain scopes for external services (e.g. "vizhub": the service accepts this key's + /// signatures for its own actions). The node stores them and never interprets them: + /// they grant nothing on chain. At most AGENT_MAX_ADDONS, each shorter than 64 bytes. + flat_set addons; + + extensions_type extensions; + + void validate() const; + void get_required_active_authorities(flat_set& a) const { a.insert(account); } + }; + +} } // graphene::protocol + +FC_REFLECT((graphene::protocol::set_agent_permission_operation), + (account)(agent_name)(agent_key)(operations)(expiration)(addons)(extensions)) diff --git a/libraries/protocol/include/graphene/protocol/config.hpp b/libraries/protocol/include/graphene/protocol/config.hpp index fe68f61419..a1a6f2a12e 100644 --- a/libraries/protocol/include/graphene/protocol/config.hpp +++ b/libraries/protocol/include/graphene/protocol/config.hpp @@ -2,7 +2,12 @@ #define CHAIN_STARTUP_VERSION (version(1, 0, 0)) #define CHAIN_HARDFORK_STARTUP_VERSION (hardfork_version(CHAIN_STARTUP_VERSION)) -#define CHAIN_VERSION (version(4, 0, 0)) +// 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 +// 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, 0)) #define CHAIN_HARDFORK_VERSION (hardfork_version(CHAIN_VERSION)) #define CHAIN_NAME "VIZ" @@ -130,6 +135,9 @@ #define CHAIN_ACCOUNT_AUCTION_MIN_STEP (CHAIN_100_PERCENT/10) // 10% #define CHAIN_ACCOUNT_AUCTION_EXTENSION_TIME fc::minutes(5) +/// HF15 agent access: live delegations one principal may hold (owner decision 2026-09-28). +#define CHAIN_AGENT_MAX_PER_ACCOUNT 16 + #define COMMITTEE_MIN_DURATION (60*60*24*5) #define COMMITTEE_MAX_DURATION (60*60*24*30) #define COMMITTEE_MAX_REQUIRED_AMOUNT int64_t(CHAIN_INIT_SUPPLY/100) diff --git a/libraries/protocol/include/graphene/protocol/config_testnet.hpp b/libraries/protocol/include/graphene/protocol/config_testnet.hpp index 802bab04a1..b3561190a1 100644 --- a/libraries/protocol/include/graphene/protocol/config_testnet.hpp +++ b/libraries/protocol/include/graphene/protocol/config_testnet.hpp @@ -2,7 +2,14 @@ #define CHAIN_STARTUP_VERSION (version(1, 0, 0)) #define CHAIN_HARDFORK_STARTUP_VERSION (hardfork_version(CHAIN_STARTUP_VERSION)) -#define CHAIN_VERSION (version(4, 0, 0)) +// 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 +// 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, +// 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 +// are registered. +#define CHAIN_VERSION (version(4, 1, 0)) #define CHAIN_HARDFORK_VERSION (hardfork_version(CHAIN_VERSION)) #define CHAIN_NAME "VIZTEST" @@ -130,6 +137,9 @@ #define CHAIN_ACCOUNT_AUCTION_MIN_STEP (CHAIN_100_PERCENT/10) // 10% #define CHAIN_ACCOUNT_AUCTION_EXTENSION_TIME fc::minutes(5) +/// HF15 agent access: live delegations one principal may hold (owner decision 2026-09-28). +#define CHAIN_AGENT_MAX_PER_ACCOUNT 16 + #define COMMITTEE_MIN_DURATION (60*60*1) #define COMMITTEE_MAX_DURATION (60*60*24*30) diff --git a/libraries/protocol/include/graphene/protocol/operations.hpp b/libraries/protocol/include/graphene/protocol/operations.hpp index e15748e440..33719140c6 100644 --- a/libraries/protocol/include/graphene/protocol/operations.hpp +++ b/libraries/protocol/include/graphene/protocol/operations.hpp @@ -6,6 +6,7 @@ #include #include #include +#include namespace graphene { namespace protocol { @@ -154,7 +155,9 @@ namespace graphene { namespace protocol { // F1/#300 early-exit deferred claim paid at settlement (virtual) pm_early_exit_claim_paid_operation, // #442/#681=D: LP income paid at settlement -> LP's own history (virtual) - pm_lp_payout_operation + pm_lp_payout_operation, + // HF15 agent access (Onix): account-level delegation of an explicit operation list + set_agent_permission_operation > operation; /*void operation_get_required_authorities( const operation& op, @@ -168,6 +171,15 @@ namespace graphene { namespace protocol { bool is_virtual_operation(const operation &op); bool is_data_operation(const operation &op); + /// Wire/JSON name of an operation — the first element of its broadcast array + /// (`transfer`, `pm_place_bet`). Derived from the type name, same as the wire format uses. + std::string operation_wire_name(const operation &op); + + /// True if `name` is the canonical wire name of an operation that can be broadcast. + /// Unknown names and virtual operations are not: `set_agent_permission` refuses them so a + /// typo cannot become a silently dead permission. + bool is_broadcastable_operation_wire_name(const std::string &name); + struct operation_wrapper { operation_wrapper(const operation& op = operation()) : op(op) {} diff --git a/libraries/protocol/operations.cpp b/libraries/protocol/operations.cpp index 9ac0b7c3ad..51f0fb63b3 100644 --- a/libraries/protocol/operations.cpp +++ b/libraries/protocol/operations.cpp @@ -2,6 +2,8 @@ #include +#include + namespace graphene { namespace protocol { @@ -51,6 +53,53 @@ namespace graphene { return op.visit(is_dop_visitor()); } + // Wire name of an operation, i.e. what the client writes as the first element of an + // operation array: `graphene::protocol::transfer_operation` -> `transfer`. + struct op_wire_name_visitor { + typedef std::string result_type; + + template + std::string operator()(const T &) const { + return fc::name_from_type(fc::get_typename::name()); + } + }; + + // Compile-time classification: the visitor never reads the visited object, which matters + // because the enumeration below visits default-constructed variants tagged via set_which() + // (the same pattern DEFINE_OPERATION_TYPE uses). A runtime is_virtual() call would touch a + // member of an object that was never constructed as that alternative. + struct op_virtual_type_visitor { + typedef bool result_type; + + template + bool operator()(const T &) const { + return std::is_base_of::value; + } + }; + + std::string operation_wire_name(const operation &op) { + return op.visit(op_wire_name_visitor()); + } + + const flat_set &broadcastable_operation_wire_names() { + static const flat_set names = []() { + flat_set s; + for (int i = 0; i < operation::count(); ++i) { + operation tmp; + tmp.set_which(i); + if (tmp.visit(op_virtual_type_visitor())) + continue; // virtual operations are generated by the chain, never broadcast + s.insert(tmp.visit(op_wire_name_visitor())); + } + return s; + }(); + return names; + } + + bool is_broadcastable_operation_wire_name(const std::string &name) { + return broadcastable_operation_wire_names().count(name) > 0; + } + } } // graphene::protocol diff --git a/plugins/database_api/api.cpp b/plugins/database_api/api.cpp index 026819678b..0be22f99aa 100755 --- a/plugins/database_api/api.cpp +++ b/plugins/database_api/api.cpp @@ -916,6 +916,23 @@ DEFINE_API(plugin, get_proposed_transactions) { }); } +DEFINE_API(plugin, get_agent_permissions) { + CHECK_ARG_SIZE(1); + auto account = args.args->at(0).as(); + + return my->database().with_weak_read_lock([&]() { + std::vector result; + const auto& db = my->database(); + const auto now = db.head_block_time(); + // At most CHAIN_AGENT_MAX_PER_ACCOUNT rows per principal (the cap is consensus), so no paging. + const auto& idx = db.get_index().indices().get(); + for (auto itr = idx.lower_bound(boost::make_tuple(account_name_type(account))); itr != idx.end() && itr->account == account; ++itr) { + result.emplace_back(*itr, now); + } + return result; + }); +} + void plugin::plugin_initialize(const boost::program_options::variables_map &options) { ilog("database_api plugin: plugin_initialize() begin"); my = std::make_unique(); diff --git a/plugins/database_api/include/graphene/plugins/database_api/plugin.hpp b/plugins/database_api/include/graphene/plugins/database_api/plugin.hpp index b2f5c8a126..8b32b6ee2e 100755 --- a/plugins/database_api/include/graphene/plugins/database_api/plugin.hpp +++ b/plugins/database_api/include/graphene/plugins/database_api/plugin.hpp @@ -14,6 +14,7 @@ #include #include #include +#include #include @@ -131,6 +132,27 @@ struct subaccount_on_sale_api_object { } }; +/// HF15 agent access: one agent of a principal (see set_agent_permission_operation). +struct agent_permission_api_object { + std::string account; + std::string agent_name; + public_key_type agent_key; + std::vector operations; + time_point_sec expiration; ///< epoch = perpetual + std::vector addons; ///< off-chain scopes (e.g. "vizhub"); opaque to consensus + bool expired = false; ///< past expiration at head block time: grants nothing + + agent_permission_api_object(const graphene::chain::agent_permission_object& o, time_point_sec now) + : account(o.account), agent_name(o.agent_name), agent_key(o.agent_key), expiration(o.expiration) { + for (const auto& n : graphene::chain::unpack_operation_names(o.operations)) operations.push_back(n); + for (const auto& n : graphene::chain::unpack_operation_names(o.addons)) addons.push_back(n); + expired = o.expiration != time_point_sec() && o.expiration <= now; + } + + agent_permission_api_object() { + } +}; + using block_applied_callback = std::function; /// API, args, return @@ -167,6 +189,7 @@ DEFINE_API_ARGS(get_proposed_transactions, msg_pack, std::vector) DEFINE_API_ARGS(get_accounts_on_auction, msg_pack, std::vector) DEFINE_API_ARGS(get_subaccounts_on_sale, msg_pack, std::vector) +DEFINE_API_ARGS(get_agent_permissions, msg_pack, std::vector) /** @@ -395,6 +418,13 @@ class plugin final : public appbase::plugin { (get_accounts_on_sale) (get_accounts_on_auction) (get_subaccounts_on_sale) + + /** + * @brief Agents of a principal (HF15 agent access), ordered by agent name + * @param account -- principal + * @return All rows, expired ones flagged `expired` (they grant nothing and are swept on the next grant) + */ + (get_agent_permissions) ) private: @@ -426,3 +456,4 @@ FC_REFLECT((graphene::plugins::database_api::database_info), (total_size)(free_s FC_REFLECT((graphene::plugins::database_api::account_on_sale_api_object), (account)(account_seller)(account_offer_price)(account_on_sale_start_time)(target_buyer)(current_bid)(current_bidder)(current_bidder_key)(last_bid)) FC_REFLECT((graphene::plugins::database_api::subaccount_on_sale_api_object), (account)(subaccount_seller)(subaccount_offer_price)) +FC_REFLECT((graphene::plugins::database_api::agent_permission_api_object), (account)(agent_name)(agent_key)(operations)(expiration)(addons)(expired)) diff --git a/plugins/prediction_market_api/prediction_market_api.cpp b/plugins/prediction_market_api/prediction_market_api.cpp index fca6a6542a..c39e7e1aba 100644 --- a/plugins/prediction_market_api/prediction_market_api.cpp +++ b/plugins/prediction_market_api/prediction_market_api.cpp @@ -1804,7 +1804,7 @@ namespace graphene { namespace plugins { namespace prediction_market_api { const int64_t pos_room = pos_cap - collateral; // loan headroom vs market-size cap if (pos_room <= 0) fail("position_size", "Collateral already at/above market position cap"); // Per-position cap vs loan floor: if pool is too small, max loan < pm_min_liquidity → no valid loan exists. - // The evaluator enforces loan >= pm_min_liquidity (anti-Sybil, pm_evaluator.cpp:1439), so quote must + // The evaluator enforces loan >= pm_min_liquidity (anti-Sybil, pm_evaluator.cpp:1504), so quote must // surface this impossibility rather than returning available:true for loans that will fail at apply. if (per_pos_cap < mp.pm_min_liquidity.amount) fail("loan_floor_above_cap", "Per-position cap below minimum loan (pool too small for leverage)"); @@ -1821,10 +1821,17 @@ namespace graphene { namespace plugins { namespace prediction_market_api { mp.pm_leverage_pool_profit_percent, mp.pm_leverage_safety_margin_percent, mp.pm_leverage_max_slippage_percent, mp.pm_leverage_m_factor_percent); if (max_loan <= 0) fail("solvency", "No loan size passes the worst-case solvency check"); + // The cap check above only proves that a NOMINAL room exists. The search can still come + // back with a positive loan below the evaluator's loan floor whenever free_amount/pos_room + // (not the per-position cap) is what binds, so the same impossibility has to be surfaced + // here — otherwise the quote advertises available:true for a loan that apply() rejects. + else if (max_loan < mp.pm_min_liquidity.amount.value) + fail("loan_floor_above_cap", "Best feasible loan is below the minimum loan (pm_min_liquidity)"); } out.max_loan = share_type(max_loan); - out.available = (max_loan > 0); + // A blocking constraint means "no loan can be opened" — never report availability next to one. + out.available = (max_loan > 0 && out.failed_constraints.empty()); out.max_leverage_x100 = (collateral > 0) ? (uint32_t)(((int64_t)(collateral + max_loan) * 100) / collateral) : 100; diff --git a/plugins/snapshot/plugin.cpp b/plugins/snapshot/plugin.cpp index 03f6f7f1f0..a0d3b89736 100644 --- a/plugins/snapshot/plugin.cpp +++ b/plugins/snapshot/plugin.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include @@ -969,6 +970,27 @@ inline uint32_t import_pm_outcomes(graphene::chain::database& db, const fc::vari return count; } +inline uint32_t import_agent_permissions(graphene::chain::database& db, const fc::variants& arr) { + // HF15 agent access. Not import_simple_objects: `operations` is a shared_string, which needs the + // allocator-aware setter rather than from_variant. + uint32_t count = 0; + for (const auto& v : arr) { + auto& mutable_idx = db.get_mutable_index(); + mutable_idx.set_next_id(agent_permission_id_type(v["id"].as_int64())); + db.create([&](agent_permission_object& obj) { + obj.account = v["account"].as(); + obj.agent_name = v["agent_name"].as(); + obj.agent_key = v["agent_key"].as(); + set_shared_string(obj.operations, v["operations"]); + obj.expiration = v["expiration"].as(); + if (v.get_object().contains("addons")) + set_shared_string(obj.addons, v["addons"]); + }); + ++count; + } + return count; +} + inline uint32_t import_pm_disputes(graphene::chain::database& db, const fc::variants& arr) { uint32_t count = 0; for (const auto& v : arr) { @@ -1413,6 +1435,7 @@ fc::mutable_variant_object snapshot_plugin::plugin_impl::serialize_state() { EXPORT_INDEX(pm_commit_index, pm_commit_object, "pm_commit") EXPORT_INDEX(pm_dispute_index, pm_dispute_object, "pm_dispute") EXPORT_INDEX(pm_dispute_vote_index, pm_dispute_vote_object, "pm_dispute_vote") + EXPORT_INDEX(agent_permission_index, agent_permission_object, "agent_permission") EXPORT_INDEX(pm_lazy_pool_index, pm_lazy_pool_object, "pm_lazy_pool") EXPORT_INDEX(pm_lazy_deposit_index, pm_lazy_deposit_object, "pm_lazy_deposit") EXPORT_INDEX(pm_lazy_allocation_index,pm_lazy_allocation_object,"pm_lazy_allocation") @@ -2085,6 +2108,10 @@ void snapshot_plugin::plugin_impl::load_snapshot(const fc::path& input_path) { auto n = detail::import_pm_disputes(db, state["pm_dispute"].get_array()); ilog(CLOG_ORANGE "Imported ${n} pm_dispute objects" CLOG_RESET, ("n", n)); } + if (state.contains("agent_permission")) { + auto n = detail::import_agent_permissions(db, state["agent_permission"].get_array()); + ilog(CLOG_ORANGE "Imported ${n} agent_permission objects" CLOG_RESET, ("n", n)); + } if (state.contains("pm_dispute_vote")) { auto n = detail::import_simple_objects(db, state["pm_dispute_vote"].get_array()); ilog(CLOG_ORANGE "Imported ${n} pm_dispute_vote objects" CLOG_RESET, ("n", n)); diff --git a/plugins/testnet_plugin/CMakeLists.txt b/plugins/testnet_plugin/CMakeLists.txt new file mode 100644 index 0000000000..aa6dcc98c9 --- /dev/null +++ b/plugins/testnet_plugin/CMakeLists.txt @@ -0,0 +1,36 @@ +set(CURRENT_TARGET testnet_plugin) + +list(APPEND CURRENT_TARGET_HEADERS + include/graphene/plugins/testnet_plugin/testnet_plugin.hpp + ) + +list(APPEND CURRENT_TARGET_SOURCES + testnet_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_plugin + graphene::protocol + graphene_utilities + graphene_time + appbase +) + +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/testnet_plugin/include/graphene/plugins/testnet_plugin/testnet_plugin.hpp b/plugins/testnet_plugin/include/graphene/plugins/testnet_plugin/testnet_plugin.hpp new file mode 100644 index 0000000000..efb90a5727 --- /dev/null +++ b/plugins/testnet_plugin/include/graphene/plugins/testnet_plugin/testnet_plugin.hpp @@ -0,0 +1,60 @@ +#pragma once + +#include +#include + +#include + +namespace graphene { +namespace plugins { +namespace testnet_plugin { + +/** + * TESTNET-ONLY helper: force a hardfork at startup. + * + * Adds one startup command, `--testnet-hardfork ` (for example `4.1.0` or + * `15`). When it is set, the plugin applies every hardfork up to that one as soon as the + * chain state is loaded — after the snapshot import, before block production starts. + * + * It exists because a chain stuck in emergency consensus mode can never activate a hardfork + * on its own: the committee holds every schedule slot and is deliberately excluded from the + * hardfork vote tally, so neither `next_hardfork` nor the quorum can ever move (see HF12). + * Forcing the fork bypasses the tally entirely and lets a testnet verify the activation and + * the gated behaviour ahead of the production date. + * + * The plugin does nothing unless it is both loaded (`plugin = testnet_plugin` in config.ini + * or `--plugin testnet_plugin`) and given a target, so shipping it in the production image is + * inert. It must never be given a target on the production network: it rewrites hardfork + * state without consensus, and a forced fork cannot be rolled back. + */ +class testnet_plugin final : public appbase::plugin { +public: + APPBASE_PLUGIN_REQUIRES((graphene::plugins::chain::plugin)) + + constexpr static const char *plugin_name = "testnet_plugin"; + + static const std::string &name() { + static std::string name = plugin_name; + return name; + } + + testnet_plugin(); + ~testnet_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; +}; + +} // testnet_plugin +} // plugins +} // graphene diff --git a/plugins/testnet_plugin/testnet_plugin.cpp b/plugins/testnet_plugin/testnet_plugin.cpp new file mode 100644 index 0000000000..8723916210 --- /dev/null +++ b/plugins/testnet_plugin/testnet_plugin.cpp @@ -0,0 +1,164 @@ +#include + +#include +#include + +#include +#include + +#include + +#include +#include + +#include +#include +#include + +namespace graphene { +namespace plugins { +namespace testnet_plugin { + +namespace bpo = boost::program_options; + +namespace { + +/// "15" (a hardfork number) or "4.1.0"/"4.1" (a hardfork version) -> the number to apply. +uint32_t resolve_hardfork_number(graphene::chain::database &db, const std::string &requested) { + bool numeric = !requested.empty(); + for (char c : requested) { + if (!std::isdigit(static_cast(c))) { + numeric = false; + break; + } + } + + if (numeric) { + const unsigned long n = std::stoul(requested); + FC_ASSERT(n <= CHAIN_NUM_HARDFORKS, + "testnet-hardfork: hardfork number ${n} is beyond this binary (max ${max})", + ("n", requested)("max", CHAIN_NUM_HARDFORKS)); + return static_cast(n); + } + + std::vector parts; + boost::split(parts, requested, boost::is_any_of(".")); + FC_ASSERT(parts.size() == 2 || parts.size() == 3, + "testnet-hardfork: '${v}' is neither a hardfork number nor a version " + "(expected MAJOR.MINOR or MAJOR.MINOR.RELEASE)", + ("v", requested)); + for (const std::string &part : parts) { + FC_ASSERT(!part.empty(), "testnet-hardfork: cannot parse version '${v}'", ("v", requested)); + for (char c : part) { + FC_ASSERT(std::isdigit(static_cast(c)), + "testnet-hardfork: cannot parse version '${v}'", ("v", requested)); + } + } + + const uint32_t major = std::stoul(parts[0]); + const uint32_t minor = std::stoul(parts[1]); + FC_ASSERT(major <= 255 && minor <= 255, + "testnet-hardfork: version '${v}' is out of range", ("v", requested)); + + const protocol::hardfork_version wanted(static_cast(major), static_cast(minor)); + const fc::optional number = db.get_hardfork_number(wanted); + FC_ASSERT(number.valid(), + "testnet-hardfork: this binary knows no hardfork with version ${v}", + ("v", std::string(wanted))); + return *number; +} + +} // namespace + +struct testnet_plugin::impl { + impl() + : chain(appbase::app().get_plugin()) {} + + graphene::chain::database &db() { return chain.db(); } + + graphene::plugins::chain::plugin &chain; + std::string requested; // raw --testnet-hardfork value; empty means "do nothing" +}; + +testnet_plugin::testnet_plugin() {} + +testnet_plugin::~testnet_plugin() = default; + +void testnet_plugin::set_program_options( + bpo::options_description &command_line_options, + bpo::options_description &config_file_options +) { + // Registered in the CONFIG description only, which appbase also accepts on the command line: + // initialize_impl() parses argv against `all_options = _cli_options + _cfg_options`, so this + // option is settable both as `--testnet-hardfork 4.1.0` and as a `testnet-hardfork = 4.1.0` + // line in config.ini. Adding it to the command-line description as well (the obvious-looking + // "also show it in --help" move) puts two descriptions of the same long name into all_options + // and boost then refuses every start with "option '--testnet-hardfork' is ambiguous and + // matches different versions of '--testnet-hardfork'". The price is that --help does not list + // it (help prints _cli_options alone), so diagnostics should look at the startup log, not help. + config_file_options.add_options() + ("testnet-hardfork", + bpo::value()->default_value(""), + "TESTNET ONLY: apply all hardforks up to this one at startup, bypassing the validator " + "vote tally (so it also works on a chain stuck in emergency consensus mode). Accepts a " + "hardfork number (15) or a version (4.1.0). Empty (the default) does nothing. A forced " + "hardfork cannot be rolled back — never use this on the production network."); +} + +void testnet_plugin::plugin_initialize(const bpo::variables_map &options) { + // The chain plugin is resolved HERE, not in the constructor. appbase constructs the plugin + // object when it is registered — before any plugin is initialized — and get_plugin() refuses + // plugins that are still in the 'registered' state, so building the impl eagerly threw + // "unable to find plugin: chain" while merely registering the plugin. That aborted every + // startup of the binary, including `--help`. appbase's plugin<>::initialize() runs the + // dependencies declared through APPBASE_PLUGIN_REQUIRES first, so by the time this is called + // chain::plugin is initialized and reachable. + pimpl = std::make_unique(); + if (options.count("testnet-hardfork")) { + pimpl->requested = options.at("testnet-hardfork").as(); + } +} + +void testnet_plugin::plugin_startup() { + if (pimpl->requested.empty()) { + ilog("testnet_plugin: loaded, no hardfork forced (set --testnet-hardfork to activate one)"); + return; + } + + graphene::chain::database &db = pimpl->db(); + const uint32_t head = db.head_block_num(); + const uint32_t number = resolve_hardfork_number(db, pimpl->requested); + const uint32_t applied = db.get_hardfork_property_object().last_hardfork; + + ilog("testnet_plugin: head=#${h}, last_hardfork=${a}, requested '${r}' -> hardfork ${n}", + ("h", head)("a", applied)("r", pimpl->requested)("n", number)); + + if (number <= applied) { + ilog("testnet_plugin: hardfork ${n} is already applied, nothing to do", ("n", number)); + return; + } + + elog("*** testnet_plugin: FORCING HARDFORK ${n} (requested '${r}') at head=#${h} ON A " + "TESTNET-ONLY BASIS — this rewrites hardfork state without consensus and cannot be " + "rolled back ***", + ("n", number)("r", pimpl->requested)("h", head)); + + db.set_hardfork(number, true); + + const auto &hfp = db.get_hardfork_property_object(); + elog("*** testnet_plugin: hardfork ${n} applied at head=#${h}: last_hardfork=${a}, " + "current_hardfork_version=${v}, processed_hardforks=${c} ***", + ("n", number)("h", db.head_block_num())("a", hfp.last_hardfork) + ("v", std::string(hfp.current_hardfork_version)) + ("c", hfp.processed_hardforks.size())); + + ilog("testnet_plugin: hardfork ${n} is now live from the next block on; remove " + "--testnet-hardfork once the chain carries the fork on its own", + ("n", number)); +} + +void testnet_plugin::plugin_shutdown() {} + +} // testnet_plugin +} // plugins +} // graphene diff --git a/programs/vizd/CMakeLists.txt b/programs/vizd/CMakeLists.txt index a867ded985..813de61b1c 100644 --- a/programs/vizd/CMakeLists.txt +++ b/programs/vizd/CMakeLists.txt @@ -37,6 +37,7 @@ target_link_libraries( graphene::custom_protocol_api graphene::snapshot graphene::validator_guard + graphene::testnet_plugin graphene_protocol fc ${CMAKE_DL_LIBS} diff --git a/programs/vizd/main.cpp b/programs/vizd/main.cpp index 0d8b5f43a8..9f077b7752 100644 --- a/programs/vizd/main.cpp +++ b/programs/vizd/main.cpp @@ -22,6 +22,7 @@ #include #include +#include #include #include @@ -88,6 +89,7 @@ namespace graphene { appbase::app().register_plugin(); appbase::app().register_plugin(); appbase::app().register_plugin(); + appbase::app().register_plugin(); ///plugins }; } diff --git a/scripts/pm_stale_bshare_detect.py b/scripts/pm_stale_bshare_detect.py new file mode 100755 index 0000000000..545bbe0e4a --- /dev/null +++ b/scripts/pm_stale_bshare_detect.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""HF15 stale-state detector: per LMSR market, Sigma b_share over its active LP rows vs lmsr_b. + +Post-audit-fix invariant (see docs/prediction-markets/pm-audit-fix-upgrade.md, section 4): + + Sigma b_share over a market's ACTIVE (status 0) LP rows == market.lmsr_b + +The legacy partial-withdrawal path subtracted a floored b_remove from market.lmsr_b while leaving the +withdrawn row's own b_share untouched, so a row could end up claiming more curve than the market still +held. HF15 refuses such a withdrawal ("would drain the LMSR pricing curve") instead of clamping, because +lmsr_b <= 0 makes lmsr_q96 fail soft: every outcome prices at zero and a bet costs nothing while the +market still holds the LP capital and the bettors' stakes. + +Run this against a snapshot BEFORE scheduling the fork. If no market diverges, the migration question +is moot and the fork can be scheduled as-is; a diverging market means at least one LP's early-exit +option is dead for a stale row (the principal still returns in full at settlement). + +Usage: + pm_stale_bshare_detect.py + +Exit codes: + 0 every LMSR market satisfies the invariant (nothing to migrate) + 1 at least one market diverges (active Sigma b_share > lmsr_b) - see the printed list + 2 the snapshot could not be read or the expected sections were not found + +A *.vizjson snapshot is zlib-compressed JSON ("78 01" magic, fc::compress, not gzip): {"header":..., +"state":{}}. Snapshots live in the node's snapshot directory (`snapshot-auto-latest` writes +/var/lib/vizd/snapshots/snapshot-block-*.vizjson inside the container, i.e. /snapshots/ on +the host); the object field names below are the raw JSON names, not the C++ ones. +""" + +import json +import sys +import zlib + + +def load_state(path): + with open(path, "rb") as fh: + raw = fh.read() + if raw[:2] != b"\x78\x01" and raw[:2] != b"\x78\x9c" and raw[:2] != b"\x78\xda": + print("warning: %s does not start with a zlib header - not a .vizjson?" % path, file=sys.stderr) + return json.loads(zlib.decompress(raw)).get("state", {}) + + +def find_section(state, key): + """Locate a snapshot section by a field its rows carry (section names are not part of the ABI).""" + for name, rows in state.items(): + if isinstance(rows, list) and rows and isinstance(rows[0], dict) and key in rows[0]: + return name, rows + return None, None + + +def main(): + if len(sys.argv) != 2: + print(__doc__.strip().splitlines()[0], file=sys.stderr) + print("usage: %s " % sys.argv[0], file=sys.stderr) + return 2 + try: + state = load_state(sys.argv[1]) + except Exception as exc: # noqa: BLE001 - any read/parse failure is the same verdict + print("cannot read snapshot: %s" % exc, file=sys.stderr) + return 2 + + mk_name, markets = find_section(state, "lmsr_b") + lq_name, rows = find_section(state, "b_share") + if markets is None or rows is None: + print("cannot find the market/liquidity sections (no row carries lmsr_b / b_share)", + file=sys.stderr) + return 2 + print("sections: markets=%s (%d rows) liquidity=%s (%d rows)" % (mk_name, len(markets), + lq_name, len(rows))) + + lmsr_b = {m["id"]: (m.get("lmsr_b") or 0) for m in markets if m.get("market_type") == 1} + active, everything = {}, {} + for row in rows: + mid = row.get("market") + if mid not in lmsr_b: + continue + share = row.get("b_share") or 0 + everything[mid] = everything.get(mid, 0) + share + if row.get("status") == 0: + active[mid] = active.get(mid, 0) + share + + diverging = sorted((mid, lmsr_b[mid], active.get(mid, 0)) for mid in lmsr_b + if active.get(mid, 0) > lmsr_b[mid]) + healthy = sum(1 for mid in lmsr_b if 0 < active.get(mid, 0) == lmsr_b[mid]) + idle = sum(1 for mid in lmsr_b if not active.get(mid, 0)) + + print("LMSR markets: %d (with active LP rows: %d, without: %d)" + % (len(lmsr_b), len(lmsr_b) - idle, idle)) + print("invariant holds (active Sigma b_share == lmsr_b): %d" % healthy) + print("diverging (active Sigma b_share > lmsr_b): %d" % len(diverging)) + for mid, lb, act in diverging[:50]: + print(" market %d: lmsr_b=%d active_sum=%d over_by=%d all_rows=%d" + % (mid, lb, act, act - lb, everything.get(mid, 0))) + if len(diverging) > 50: + print(" ... %d more" % (len(diverging) - 50)) + return 1 if diverging else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/share/vizd/config/config_testnet.ini b/share/vizd/config/config_testnet.ini index eac8f83021..25ea16d057 100644 --- a/share/vizd/config/config_testnet.ini +++ b/share/vizd/config/config_testnet.ini @@ -79,6 +79,7 @@ plugin = chain p2p json_rpc webserver network_broadcast_api database_api plugin = account_history operation_history plugin = committee_api invite_api paid_subscription_api custom_protocol_api plugin = account_by_key block_info raw_block +plugin = testnet_plugin # Remove votes before defined block, should increase performance clear-votes-before-block = 0 # clear votes after each cashout diff --git a/tests/consensus_sim/CMakeLists.txt b/tests/consensus_sim/CMakeLists.txt index d61665881d..2f3080b39a 100644 --- a/tests/consensus_sim/CMakeLists.txt +++ b/tests/consensus_sim/CMakeLists.txt @@ -67,6 +67,15 @@ set(SCENARIO_SOURCES scenarios/test_routable_endpoint.cpp ) +# These scenarios require an actual HF14 activation and never soft-skip. +# 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_agent_access.cpp) +else() + message(STATUS "PM audit consensus regressions require BUILD_TESTNET=ON") +endif() + add_executable(consensus_sim_tests ${SCENARIO_SOURCES}) target_link_libraries(consensus_sim_tests diff --git a/tests/consensus_sim/scenarios/test_agent_access.cpp b/tests/consensus_sim/scenarios/test_agent_access.cpp new file mode 100644 index 0000000000..84802697a4 --- /dev/null +++ b/tests/consensus_sim/scenarios/test_agent_access.cpp @@ -0,0 +1,564 @@ +// HF15 agent access — chain-level tests for the authority hook in database.cpp. +// +// A principal issues agents: a label plus a public key, each allowed to sign a listed set of +// operations on the principal's behalf. The agent is not an account. tests/pm/agent_access_test.cpp covers the protocol half (operation +// validation, the never-delegable list, the packed name list); it links the protocol library only +// and has no chain state, so it cannot reach the part where the delegation actually takes effect. +// That is what this file exercises: the transaction is pushed into a real database and accepted or +// rejected by the ordinary sign_state path. +// +// BUILD_TESTNET-only, like test_pm_audit_fixes.cpp: the single-validator fixture reaches HF14 +// quorum (CHAIN_HARDFORK_REQUIRED_VALIDATORS=1) only with testnet constants, and the mainnet build +// would leave every case below soft-skipping. HF15 itself is switched on with the same +// deterministic marker trick, so nothing here depends on the wall clock reaching an activation +// time. +#include +#include "simulated_node.hpp" +#include "genesis_factory.hpp" +#include "virtual_clock.hpp" +#include +#include +#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); +} + +// `nonce` shifts the expiration by a second: two transactions with identical operations signed by +// the same key in the same block hash to the same trx_id and the second is dropped as a duplicate, +// so the expiration is the only free field (the chain has no per-transaction nonce). +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)); +} + +// Start just past HF14, the same start the PM audit regressions use: the simulation clock is a +// virtual one, so it stays BEFORE CHAIN_HARDFORK_15_TIME and the fork can only ever be switched on +// deliberately (see enable_hf15), never by time passing. Both constants are compile-time, so the +// ordering cannot drift at runtime. +fc::time_point_sec sim_start() { + return fc::time_point_sec(CHAIN_HARDFORK_14_TIME) + fc::seconds(CHAIN_BLOCK_INTERVAL * 3); +} + +// Register "viz" as a staked, self-voted validator and advance until HF14 activates. +void bring_to_hf14(simulated_node& node, const genesis_params& gp, fc::time_point_sec& when) { + 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); +} + +// Deterministic HF15 switch: the same mechanism as set_pm_audit_fix in test_pm_audit_fixes.cpp. +// Pushing the marker makes has_hardfork(CHAIN_HARDFORK_15) true without waiting for the activation +// time. Idempotent on purpose — a second push would break the processed_hardforks / last_hardfork +// arithmetic and the next apply_hardfork would fail its own sanity assert. +void enable_hf15(simulated_node& n) { + if (n.db().has_hardfork(CHAIN_HARDFORK_15)) return; + const auto& hf = n.db().get_hardfork_property_object(); + n.db().modify(hf, [&](hardfork_property_object& h) { + h.processed_hardforks.push_back(n.head_block_time()); + }); + BOOST_REQUIRE(n.db().has_hardfork(CHAIN_HARDFORK_15)); +} + +void create_account(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); +} + +// Accounts transact on bandwidth, so a fixture account that is only funded with liquid TOKEN +// cannot sign anything yet — it has to be vested. Its own block, for the same reason as in +// test_pm_lifecycle.cpp: inside one block the two transactions would be ordered by trx_id, not by +// intent. +void vest(simulated_node& node, const genesis_params& gp, fc::time_point_sec& when, + const std::string& name, const fc::ecc::private_key& key, share_type token) { + transfer_to_vesting_operation tv; + tv.from = name; tv.to = name; + tv.amount = asset(token, TOKEN_SYMBOL); + node.push_pending_transaction(sign_ops({tv}, key, node)); + produce(node, gp, when); +} + +void grant(simulated_node& node, const genesis_params& gp, fc::time_point_sec& when, + const account_name_type& principal, const fc::ecc::private_key& pkey, + const account_name_type& name, const public_key_type& key, const std::vector& ops, + fc::time_point_sec expiration = fc::time_point_sec(), const std::vector& addons = {}) { + set_agent_permission_operation op; + op.account = principal; + op.agent_name = name; + op.agent_key = key; + op.expiration = expiration; + for (const auto& s : ops) op.operations.insert(s); + for (const auto& s : addons) op.addons.insert(s); + node.push_pending_transaction(sign_ops({op}, pkey, node)); + produce(node, gp, when); +} + +/// Push and require rejection. A transaction that the chain refuses must be refused at push time: +/// database::push_transaction validates it through the same hook the block path uses, so an +/// accepted-into-the-pool transaction is a failure of the assertion, not a timing artefact. +void expect_rejected(simulated_node& n, const signed_transaction& tx, const char* what) { + bool threw = false; + try { + n.push_pending_transaction(tx); + } catch (const std::exception&) { + threw = true; + } + BOOST_CHECK_MESSAGE(threw, what); +} + +int64_t liquid(const simulated_node& n, const account_name_type& who) { + return n.db().get_account(who).balance.amount.value; +} + +bool has_row(simulated_node& n, const account_name_type& p, const account_name_type& a) { // a = agent name + const auto& idx = n.db().get_index().indices().get(); + return idx.find(boost::make_tuple(p, a)) != idx.end(); +} + +transfer_operation transfer_op(const account_name_type& from, const account_name_type& to, + share_type amount, const asset_symbol_type& sym) { + transfer_operation t; + t.from = from; + t.to = to; + t.amount = asset(amount, sym); + return t; +} + +// One node per case: HF14, then the HF15 marker, then one ordinary account (the principal) and an +// agent key that belongs to no account. An ordinary account rather than the genesis initiator, so +// the authority path under test is the one a real user has. +struct agent_fixture { + genesis_params gp; + virtual_clock clk; + simulated_node node; + fc::time_point_sec when; + + account_name_type principal = "principal"; + account_name_type bot = "trading-bot"; + fc::ecc::private_key principal_key = derive_key("principal-key"); + fc::ecc::private_key agent_key = derive_key("agent-key"); + + agent_fixture(uint64_t seed, const char* label) + : gp(make_genesis_params(seed, 1)), clk(sim_start()), node(label, gp, clk), + when(clk.now() - fc::seconds(CHAIN_BLOCK_INTERVAL)) { + bring_to_hf14(node, gp, when); + BOOST_REQUIRE_MESSAGE(node.db().has_hardfork(CHAIN_HARDFORK_14), + "harness could not reach HF14 (needs BUILD_TESTNET)"); + enable_hf15(node); + create_account(node, gp, when, principal, principal_key, 100000); + vest(node, gp, when, principal, principal_key, 50000); + } + + void issue(const std::vector& ops, fc::time_point_sec exp = fc::time_point_sec(), + const std::vector& addons = {}) { + grant(node, gp, when, principal, principal_key, bot, agent_key.get_public_key(), ops, exp, addons); + } + transfer_operation pay(share_type amount) { + return transfer_op(principal, gp.initiator_name, amount, TOKEN_SYMBOL); + } +}; + +// Account sales. A price set on testnet opens a 10-minute auction window +// (CHAIN_ACCOUNT_ON_SALE_DELAY): a buy inside the window is a BID and the account changes hands when +// the auction closes; a buy after the window is a direct sale. Two code paths, both tested. +void put_on_sale(agent_fixture& f) { + set_account_price_operation sp; + sp.account = f.principal; + sp.account_seller = f.principal; + sp.account_offer_price = asset(10000, TOKEN_SYMBOL); + sp.account_on_sale = true; + f.node.push_pending_transaction(sign_ops({sp}, f.principal_key, f.node)); + produce(f.node, f.gp, f.when); +} +void buy(agent_fixture& f) { + buy_account_operation bo; + bo.buyer = f.gp.initiator_name; + bo.account = f.principal; + bo.account_offer_price = asset(10000, TOKEN_SYMBOL); + bo.account_authorities_key = derive_key("buyer-key").get_public_key(); + bo.tokens_to_shares = f.node.db().get_validator_schedule_object().median_props.account_creation_fee; + f.node.push_pending_transaction(sign_ops({bo}, f.gp.initiator_key, f.node)); + produce(f.node, f.gp, f.when); +} +bool sold_to_buyer(agent_fixture& f) { + return f.node.db().get(f.principal).active == + single_key_auth(derive_key("buyer-key").get_public_key()); +} + +} // anonymous namespace + +// The plain positive: a transaction signed ONLY by the agent key passes for the principal. +BOOST_AUTO_TEST_CASE(agent_access_agent_key_signs_granted_operation) { + agent_fixture f(0xAA9E17, "aa-granted"); + f.issue({"transfer"}); + + const auto before = liquid(f.node, f.gp.initiator_name); + f.node.push_pending_transaction(sign_ops({f.pay(1000)}, f.agent_key, f.node)); + produce(f.node, f.gp, f.when); + BOOST_CHECK_EQUAL(liquid(f.node, f.gp.initiator_name) - before, 1000); +} + +std::string stored_addons(simulated_node& n, const account_name_type& p, const account_name_type& a) { + const auto& idx = n.db().get_index().indices().get(); + auto it = idx.find(boost::make_tuple(p, a)); + return it == idx.end() ? std::string("") : to_string(it->addons); +} + +// Addons (q1718=A) are off-chain scopes: an addon-only agent is a real row the services can read, +// but on chain its key signs for nothing. Adding operations later keeps the addons; clearing both +// lists revokes. +BOOST_AUTO_TEST_CASE(agent_access_addons_are_stored_and_grant_nothing_on_chain) { + agent_fixture f(0xAA9E30, "aa-addons"); + f.issue({}, fc::time_point_sec(), {"vizhub", "mail"}); + BOOST_REQUIRE(has_row(f.node, f.principal, f.bot)); + BOOST_CHECK_EQUAL(stored_addons(f.node, f.principal, f.bot), "mail,vizhub"); + + expect_rejected(f.node, sign_ops({f.pay(1000)}, f.agent_key, f.node), "addon-only agent moved funds"); + + f.issue({"transfer"}, fc::time_point_sec(), {"vizhub"}); + BOOST_CHECK_EQUAL(stored_addons(f.node, f.principal, f.bot), "vizhub"); + const auto before = liquid(f.node, f.gp.initiator_name); + f.node.push_pending_transaction(sign_ops({f.pay(1000)}, f.agent_key, f.node)); + produce(f.node, f.gp, f.when); + BOOST_CHECK_EQUAL(liquid(f.node, f.gp.initiator_name) - before, 1000); + + f.issue({}); // both lists empty = revoke + BOOST_CHECK(!has_row(f.node, f.principal, f.bot)); +} + +// Issuing an agent must not break the principal's own transactions. +BOOST_AUTO_TEST_CASE(agent_access_principal_still_signs_for_itself) { + agent_fixture f(0xAA9E18, "aa-principal"); + f.issue({"transfer"}); + + const auto before = liquid(f.node, f.gp.initiator_name); + f.node.push_pending_transaction(sign_ops({f.pay(2000)}, f.principal_key, f.node)); + produce(f.node, f.gp, f.when); + BOOST_CHECK_EQUAL(liquid(f.node, f.gp.initiator_name) - before, 2000); +} + +// A key that was never issued — or was issued by someone else — grants nothing. +BOOST_AUTO_TEST_CASE(agent_access_unknown_key_grants_nothing) { + agent_fixture f(0xAA9E24, "aa-unknown-key"); + f.issue({"transfer"}); + expect_rejected(f.node, sign_ops({f.pay(1000)}, derive_key("stranger"), f.node), + "a key that is not an agent of the principal signed for it"); +} + +// An agent's list is not a blank cheque: EVERY authority-requiring operation must be on it. +BOOST_AUTO_TEST_CASE(agent_access_requires_full_coverage_of_the_transaction) { + agent_fixture f(0xAA9E19, "aa-coverage"); + f.issue({"transfer"}); + + transfer_to_vesting_operation tv; + tv.from = f.principal; tv.to = f.principal; + tv.amount = asset(3000, TOKEN_SYMBOL); + expect_rejected(f.node, sign_ops({f.pay(3000), tv}, f.agent_key, f.node), + "agent acted outside its list: one operation of the transaction was not listed"); + + // Control: the principal signing the same pair is accepted. + const auto before = liquid(f.node, f.gp.initiator_name); + f.node.push_pending_transaction(sign_ops({f.pay(3000), tv}, f.principal_key, f.node, 1)); + produce(f.node, f.gp, f.when); + BOOST_CHECK_EQUAL(liquid(f.node, f.gp.initiator_name) - before, 3000); +} + +// A transaction that needs master authority is never answered by an agent key, even when the rest +// of it is on the list. (Master-only operations are also on the deny-list, so this is the second, +// structural wall.) +BOOST_AUTO_TEST_CASE(agent_access_never_reaches_master_authority) { + agent_fixture f(0xAA9E1A, "aa-master"); + f.issue({"transfer"}); + + change_recovery_account_operation cr; + cr.account_to_recover = f.principal; + cr.new_recovery_account = f.gp.initiator_name; + expect_rejected(f.node, sign_ops({f.pay(1000), cr}, f.agent_key, f.node), + "agent key answered inside a master-authority transaction"); +} + +// Row validity: each way a row can be dead or illegal grants nothing. +BOOST_AUTO_TEST_CASE(agent_access_invalid_rows_grant_nothing) { + agent_fixture f(0xAA9E1B, "aa-rows"); + + // 1) No row at all. + expect_rejected(f.node, sign_ops({f.pay(4000)}, f.agent_key, f.node), "agent key acted with no row"); + + // 2) An expiration in the past revokes rather than errors, so no row is left behind. + f.issue({"transfer"}, fc::time_point_sec(f.node.db().head_block_time() - fc::seconds(60))); + BOOST_CHECK_MESSAGE(!has_row(f.node, f.principal, f.bot), "an expired grant left a row behind"); + + // 3) A never-delegable name is refused at grant time. + set_agent_permission_operation bad; + bad.account = f.principal; + bad.agent_name = f.bot; + bad.agent_key = f.agent_key.get_public_key(); + bad.operations.insert("account_update"); + expect_rejected(f.node, sign_ops({bad}, f.principal_key, f.node), "granting account_update was accepted"); + + // 4) Unknown name. + bad.operations.clear(); + bad.operations.insert("no_such_operation"); + expect_rejected(f.node, sign_ops({bad}, f.principal_key, f.node, 1), "granting an unknown name was accepted"); + + // 5) Revoke by name: a live agent, then an empty list removes it and its key stops working. + f.issue({"transfer"}); + BOOST_REQUIRE(has_row(f.node, f.principal, f.bot)); + grant(f.node, f.gp, f.when, f.principal, f.principal_key, f.bot, public_key_type(), {}); + BOOST_CHECK(!has_row(f.node, f.principal, f.bot)); + expect_rejected(f.node, sign_ops({f.pay(4000)}, f.agent_key, f.node, 2), "revoked agent key still signs"); +} + +// The agent key stands in for the principal only where the principal is required — never wherever +// the principal's authority happens to be nested in another account. +BOOST_AUTO_TEST_CASE(agent_access_does_not_leak_through_nested_authorities) { + agent_fixture f(0xAA9E1C, "aa-nested"); + + const account_name_type second = "second"; + fc::ecc::private_key second_key = derive_key("second-key"); + create_account(f.node, f.gp, f.when, second, second_key, 100000); + + authority nested; + nested.weight_threshold = 1; + nested.account_auths[f.principal] = 1; + account_update_operation au; + au.account = second; + au.master = single_key_auth(second_key.get_public_key()); + au.active = nested; + f.node.push_pending_transaction(sign_ops({au}, second_key, f.node)); + produce(f.node, f.gp, f.when); + + // Control: the principal's own key reaches second's authority by nesting. + const auto before = liquid(f.node, f.gp.initiator_name); + f.node.push_pending_transaction( + sign_ops({transfer_op(second, f.gp.initiator_name, 5000, TOKEN_SYMBOL)}, f.principal_key, f.node)); + produce(f.node, f.gp, f.when); + BOOST_CHECK_EQUAL(liquid(f.node, f.gp.initiator_name) - before, 5000); + + f.issue({"transfer"}); + expect_rejected(f.node, + sign_ops({transfer_op(second, f.gp.initiator_name, 6000, TOKEN_SYMBOL)}, f.agent_key, f.node), + "the principal's agent key reached an account that only nests the principal"); +} + +// One key, one agent: the same key under a second name is refused. +BOOST_AUTO_TEST_CASE(agent_access_key_is_unique_per_principal) { + agent_fixture f(0xAA9E25, "aa-key-unique"); + f.issue({"transfer"}); + + set_agent_permission_operation dup; + dup.account = f.principal; + dup.agent_name = "other-bot"; + dup.agent_key = f.agent_key.get_public_key(); + dup.operations.insert("award"); + expect_rejected(f.node, sign_ops({dup}, f.principal_key, f.node), "one key accepted for two agents"); + + // Re-issuing the SAME name with a new key replaces the key: the old one stops working. + const auto new_key = derive_key("agent-key-2"); + grant(f.node, f.gp, f.when, f.principal, f.principal_key, f.bot, new_key.get_public_key(), {"transfer"}); + expect_rejected(f.node, sign_ops({f.pay(1000)}, f.agent_key, f.node), "replaced key still signs"); + const auto before = liquid(f.node, f.gp.initiator_name); + f.node.push_pending_transaction(sign_ops({f.pay(1000)}, new_key, f.node, 1)); + produce(f.node, f.gp, f.when); + BOOST_CHECK_EQUAL(liquid(f.node, f.gp.initiator_name) - before, 1000); +} + +// Wipes: the agents never outlive the owner keys they were issued under. +BOOST_AUTO_TEST_CASE(agent_access_active_change_wipes) { + agent_fixture f(0xAA9E1D, "aa-wipe-active"); + f.issue({"transfer"}); + + account_update_operation au; + au.account = f.principal; + au.active = single_key_auth(derive_key("principal-key-2").get_public_key()); + f.node.push_pending_transaction(sign_ops({au}, f.principal_key, f.node)); + produce(f.node, f.gp, f.when); + + BOOST_CHECK_MESSAGE(!has_row(f.node, f.principal, f.bot), "active change left the agent"); + expect_rejected(f.node, sign_ops({f.pay(1000)}, f.agent_key, f.node), "agent key signed after active change"); +} + +BOOST_AUTO_TEST_CASE(agent_access_master_change_wipes) { + agent_fixture f(0xAA9E1F, "aa-wipe-master"); + f.issue({"transfer"}); + + account_update_operation au; + au.account = f.principal; + au.master = single_key_auth(derive_key("principal-master-2").get_public_key()); + f.node.push_pending_transaction(sign_ops({au}, f.principal_key, f.node)); + produce(f.node, f.gp, f.when); + + BOOST_CHECK_MESSAGE(!has_row(f.node, f.principal, f.bot), "master change left the agent"); +} + +// A regular-only change keeps the agents: they never stood on regular keys. +BOOST_AUTO_TEST_CASE(agent_access_regular_change_keeps_agents) { + agent_fixture f(0xAA9E1E, "aa-keep-regular"); + f.issue({"transfer"}); + + account_update_operation au; + au.account = f.principal; + au.regular = single_key_auth(derive_key("principal-regular-2").get_public_key()); + f.node.push_pending_transaction(sign_ops({au}, f.principal_key, f.node)); + produce(f.node, f.gp, f.when); + + BOOST_CHECK(has_row(f.node, f.principal, f.bot)); +} + +BOOST_AUTO_TEST_CASE(agent_access_direct_sale_wipes) { + agent_fixture f(0xAA9E21, "aa-wipe-sale"); + f.issue({"transfer"}); + put_on_sale(f); + const auto until = f.node.db().get_account(f.principal).account_on_sale_start_time; + for (int i = 0; i < 400 && f.node.head_block_time() <= until; ++i) produce(f.node, f.gp, f.when); + buy(f); + BOOST_REQUIRE_MESSAGE(sold_to_buyer(f), "direct sale did not go through — the wipe check would be vacuous"); + BOOST_CHECK_MESSAGE(!has_row(f.node, f.principal, f.bot), "sold account kept its agent"); +} + +BOOST_AUTO_TEST_CASE(agent_access_auction_close_wipes) { + agent_fixture f(0xAA9E20, "aa-wipe-auction"); + f.issue({"transfer"}); + put_on_sale(f); + buy(f); // inside the window: a bid + BOOST_REQUIRE_MESSAGE(!sold_to_buyer(f), "expected a bid, got an immediate sale"); + BOOST_CHECK_MESSAGE(has_row(f.node, f.principal, f.bot), "a mere bid already wiped the agent"); + for (int i = 0; i < 400 && !sold_to_buyer(f); ++i) produce(f.node, f.gp, f.when); + BOOST_REQUIRE_MESSAGE(sold_to_buyer(f), "auction did not close — the wipe check would be vacuous"); + BOOST_CHECK_MESSAGE(!has_row(f.node, f.principal, f.bot), "account sold at auction kept its agent"); +} + +// Recovery: the principal first rotates master (recovery needs a "recent" master to point at), then +// issues the agent, then recovers — so the row the recovery must wipe really exists at that moment. +BOOST_AUTO_TEST_CASE(agent_access_recovery_wipes) { + agent_fixture f(0xAA9E22, "aa-wipe-recover"); + const auto stolen = derive_key("principal-master-stolen"); + const auto restored = derive_key("principal-master-restored"); + + account_update_operation au; + au.account = f.principal; + au.master = single_key_auth(stolen.get_public_key()); + f.node.push_pending_transaction(sign_ops({au}, f.principal_key, f.node)); + produce(f.node, f.gp, f.when); + + f.issue({"transfer"}); + BOOST_REQUIRE(has_row(f.node, f.principal, f.bot)); + + request_account_recovery_operation rq; + rq.recovery_account = f.node.db().get_account(f.principal).recovery_account; + rq.account_to_recover = f.principal; + rq.new_master_authority = single_key_auth(restored.get_public_key()); + BOOST_REQUIRE_MESSAGE(rq.recovery_account == f.gp.initiator_name, "fixture assumes the creator recovers"); + f.node.push_pending_transaction(sign_ops({rq}, f.gp.initiator_key, f.node)); + produce(f.node, f.gp, f.when); + + recover_account_operation rc; + rc.account_to_recover = f.principal; + rc.new_master_authority = single_key_auth(restored.get_public_key()); + rc.recent_master_authority = single_key_auth(f.principal_key.get_public_key()); + signed_transaction tx = sign_ops({rc}, restored, f.node); + tx.sign(f.principal_key, f.node.chain_id()); + f.node.push_pending_transaction(tx); + produce(f.node, f.gp, f.when); + + const bool recovered = f.node.db().get(f.principal).master == + single_key_auth(restored.get_public_key()); + BOOST_REQUIRE_MESSAGE(recovered, "recovery did not go through — the wipe check would be vacuous"); + BOOST_CHECK_MESSAGE(!has_row(f.node, f.principal, f.bot), "recovered account kept its agent"); +} + +// Cap: at most CHAIN_AGENT_MAX_PER_ACCOUNT live agents; re-issuing a name is an overwrite; an +// expired agent is swept on the principal's next grant and frees its slot. +BOOST_AUTO_TEST_CASE(agent_access_cap_and_expired_sweep) { + agent_fixture f(0xAA9E23, "aa-cap"); + auto name = [](int i) { return account_name_type("bot-" + std::to_string(i)); }; + auto key = [](int i) { return derive_key("bot-key-" + std::to_string(i)).get_public_key(); }; + + for (int i = 0; i < CHAIN_AGENT_MAX_PER_ACCOUNT; ++i) + grant(f.node, f.gp, f.when, f.principal, f.principal_key, name(i), key(i), {"transfer"}); + BOOST_REQUIRE(has_row(f.node, f.principal, name(CHAIN_AGENT_MAX_PER_ACCOUNT - 1))); + + set_agent_permission_operation op; + op.account = f.principal; + op.agent_name = name(CHAIN_AGENT_MAX_PER_ACCOUNT); + op.agent_key = key(CHAIN_AGENT_MAX_PER_ACCOUNT); + op.operations.insert("transfer"); + expect_rejected(f.node, sign_ops({op}, f.principal_key, f.node), "17th agent accepted over the cap"); + + // Re-issuing an existing name at the cap is an overwrite: here it shortens bot-0 to a few blocks. + grant(f.node, f.gp, f.when, f.principal, f.principal_key, name(0), key(0), {"transfer"}, + fc::time_point_sec(f.node.head_block_time() + fc::seconds(CHAIN_BLOCK_INTERVAL * 2))); + BOOST_REQUIRE(has_row(f.node, f.principal, name(0))); + + for (int i = 0; i < 4; ++i) produce(f.node, f.gp, f.when); + BOOST_REQUIRE_MESSAGE(has_row(f.node, f.principal, name(0)), "expired row vanished before any grant"); + f.node.push_pending_transaction(sign_ops({op}, f.principal_key, f.node, 1)); + produce(f.node, f.gp, f.when); + BOOST_CHECK_MESSAGE(!has_row(f.node, f.principal, name(0)), "expired agent not swept on the next grant"); + BOOST_CHECK(has_row(f.node, f.principal, name(CHAIN_AGENT_MAX_PER_ACCOUNT))); + + op.agent_name = "bot-extra"; + op.agent_key = derive_key("bot-key-extra").get_public_key(); + expect_rejected(f.node, sign_ops({op}, f.principal_key, f.node, 2), "agent over the cap accepted"); +} diff --git a/tests/consensus_sim/scenarios/test_pm_audit_fixes.cpp b/tests/consensus_sim/scenarios/test_pm_audit_fixes.cpp new file mode 100644 index 0000000000..29d9223f55 --- /dev/null +++ b/tests/consensus_sim/scenarios/test_pm_audit_fixes.cpp @@ -0,0 +1,341 @@ +// PM audit regressions: legacy replay and explicit, test-only opt-in to the +// unscheduled next hardfork. No production activation or wire-layout changes. +#include +#include "simulated_node.hpp" +#include "genesis_factory.hpp" +#include "virtual_clock.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace consensus_sim; +using namespace graphene::chain; +using namespace graphene::protocol; + +namespace { +void produce(simulated_node& n, const genesis_params& gp, fc::time_point_sec& when) { + when += fc::seconds(CHAIN_BLOCK_INTERVAL); + auto validator = n.db().get_scheduled_validator(1); + n.produce_block(validator, validator == gp.initiator_name ? gp.initiator_key : gp.genesis_witness_key, when); +} +signed_transaction sign(const operation& op, const fc::ecc::private_key& key, const simulated_node& n, int nonce = 0) { + signed_transaction tx; + tx.set_reference_block(n.head_block_id()); + tx.set_expiration(n.head_block_time() + fc::seconds(60 + nonce)); + tx.operations.emplace_back(op); + tx.sign(key, n.chain_id()); + return tx; +} +void apply(simulated_node& n, const genesis_params& gp, fc::time_point_sec& when, + const operation& op, const fc::ecc::private_key& key, int nonce = 0) { + n.push_pending_transaction(sign(op, key, n, nonce)); + produce(n, gp, when); +} +void hf14(simulated_node& n, const genesis_params& gp, fc::time_point_sec& when) { + // Register the initiator as a staked, self-voted validator and advance until + // HF14 activates. In a BUILD_TESTNET harness (CHAIN_HARDFORK_REQUIRED_VALIDATORS=1) + // the single validator's version vote reaches quorum; in a mainnet build the + // 17/21 quorum is unreachable and the existing pm_lifecycle tests soft-skip. + // These regressions are therefore only exercised under BUILD_TESTNET, matching + // the rest of the PM consensus suite. + produce(n, gp, when); // block 1 (committee gap-filler) + const auto bal = n.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; + signed_transaction tx; + tx.set_reference_block(n.head_block_id()); + tx.set_expiration(n.head_block_time() + fc::seconds(60)); + tx.operations = {tv, vu, vv}; + tx.sign(gp.initiator_key, n.chain_id()); + n.push_pending_transaction(tx); + for (int i = 0; i < 300 && !n.db().has_hardfork(CHAIN_HARDFORK_14); ++i) produce(n, gp, when); + BOOST_REQUIRE(n.db().has_hardfork(CHAIN_HARDFORK_14)); +} +// Test-only switch for the PM audit fork. On a BUILD_TESTNET build the fork is REGISTERED +// (CHAIN_NUM_HARDFORKS=15) and the harness validator reaches quorum on its own, so — unlike the +// first cut of these regressions — "fixed" is not something the test grants, it is what the node +// does by itself once the fork's activation time is reached. The deterministic way to get both +// sides is therefore to keep the simulation clock BEFORE CHAIN_HARDFORK_15_TIME (see sim_start) +// and to toggle the processed marker here: push to enable, pop to get the legacy rules back. +// +// Popping is stable: process_hardforks() only re-applies while _hardfork_versions[last_hardfork] +// < next_hardfork, and by the time the marker exists the tally has already pinned next_hardfork to +// the audit-fork version, so the removed marker stays removed for the rest of the run. Re-pushing +// while it is already set would corrupt the arithmetic (processed_hardforks.size() would no longer +// equal last_hardfork+1 and the next apply_hardfork would fail its own sanity assert), hence the +// idempotent early return. +void set_pm_audit_fix(simulated_node& n, bool enabled) { + if (n.db().has_hardfork(CHAIN_PM_AUDIT_FIX_HARDFORK) == enabled) return; + const auto& hf = n.db().get_hardfork_property_object(); + n.db().modify(hf, [&](hardfork_property_object& h) { + if (enabled) h.processed_hardforks.push_back(n.head_block_time()); + else h.processed_hardforks.pop_back(); + }); + BOOST_REQUIRE_EQUAL(n.db().has_hardfork(CHAIN_PM_AUDIT_FIX_HARDFORK), enabled); +} + +// Start the simulation just past HF14 so the chain clock never reaches CHAIN_HARDFORK_15_TIME: the +// legacy legs below are only legacy while the audit fork is pending, and a clock that tracks +// fc::time_point::now() would silently flip them to the fixed rules on the day the fork's testnet +// activation time passes (the first cut of these tests had exactly that time bomb). Both constants +// are compile-time, so the pair stays ordered forever. +fc::time_point_sec sim_start() { + return fc::time_point_sec(CHAIN_HARDFORK_14_TIME) + fc::seconds(CHAIN_BLOCK_INTERVAL * 3); +} +void oracle(simulated_node& n, const genesis_params& gp, fc::time_point_sec& when) { + pm_oracle_register_operation op; + op.owner = gp.initiator_name; + op.insurance = n.db().get_validator_schedule_object().median_props.pm_min_oracle_insurance; + op.fixed_fee = asset(0, TOKEN_SYMBOL); + apply(n, gp, when, op, gp.initiator_key); +} +// Σ b_share over the market's STILL-ACTIVE LP rows. A fully withdrawn row is kept with status 3 +// (the object is the position's history), and those no longer carry curve weight. +int64_t active_rows_b_share(simulated_node& n, pm_market_id_type mid) { + int64_t sum = 0; + for (const auto& l : n.db().get_index().indices()) + if (l.market == mid && l.status != 3) sum += l.b_share.value; + return sum; +} +} + +BOOST_AUTO_TEST_CASE(pm_lmsr_partial_lp_replay_and_future_fix) { + for (bool fixed : {false, true}) { + auto gp = make_genesis_params(fixed ? 0xA15A : 0xA15B, 1); + virtual_clock clk(sim_start()); + simulated_node n(fixed ? "pm-lp-fixed" : "pm-lp-legacy", gp, clk); + fc::time_point_sec when = clk.now() - fc::seconds(CHAIN_BLOCK_INTERVAL); + hf14(n, gp, when); oracle(n, gp, when); + set_pm_audit_fix(n, fixed); + const int64_t unit = n.db().get_validator_schedule_object().median_props.pm_min_liquidity.amount.value; + pm_create_market_operation cm; + cm.creator = gp.initiator_name; cm.oracle = gp.initiator_name; + cm.market_type = 1; cm.outcomes = {"A", "B", "C"}; cm.url = "criteria"; + cm.liquidity = asset(share_type(unit), TOKEN_SYMBOL); + cm.lmsr_b = lmsr::lmsr_b_from_liquidity(unit, 3); + cm.betting_expiration = n.head_block_time() + fc::seconds(3600); + cm.result_expiration = n.head_block_time() + fc::seconds(7200); + apply(n, gp, when, cm, gp.initiator_key); + const pm_market_id_type mid(0); + const auto seed_b = n.db().get(mid).lmsr_b.value; + pm_add_liquidity_operation add; + add.provider = gp.initiator_name; add.market_id = 0; + add.amount = asset(share_type(unit * 2), TOKEN_SYMBOL); + apply(n, gp, when, add, gp.initiator_key); + const pm_liquidity_id_type lid(1); + const auto added_b = n.db().get(lid).b_share.value; + BOOST_REQUIRE_GT(added_b, 0); + pm_withdraw_liquidity_operation w; + w.provider = gp.initiator_name; w.liquidity_id = 1; + w.amount = asset(share_type(unit), TOKEN_SYMBOL); + apply(n, gp, when, w, gp.initiator_key); + const int64_t removed = added_b / 2; + BOOST_CHECK_EQUAL(n.db().get(mid).lmsr_b.value, seed_b + added_b - removed); + BOOST_CHECK_EQUAL(n.db().get(lid).b_share.value, + fixed ? added_b - removed : added_b); + w.amount = asset(0, TOKEN_SYMBOL); + apply(n, gp, when, w, gp.initiator_key); + BOOST_CHECK_EQUAL(n.db().get(mid).lmsr_b.value, fixed ? seed_b : seed_b - removed); + BOOST_CHECK_EQUAL(n.db().get(mid).liquidity_sum.value, unit); + } +} + +BOOST_AUTO_TEST_CASE(pm_mode_one_requires_commit_reveal_after_gate) { + for (bool fixed : {false, true}) { + auto gp = make_genesis_params(fixed ? 0xA151 : 0xA152, 1); + virtual_clock clk(sim_start()); + simulated_node n(fixed ? "pm-batch-fixed" : "pm-batch-legacy", gp, clk); + fc::time_point_sec when = clk.now() - fc::seconds(CHAIN_BLOCK_INTERVAL); + hf14(n, gp, when); oracle(n, gp, when); + set_pm_audit_fix(n, fixed); + const auto& mp = n.db().get_validator_schedule_object().median_props; + BOOST_REQUIRE(mp.pm_commit_reveal_enabled); + const int64_t unit = mp.pm_min_liquidity.amount.value; + pm_create_market_operation cm; + cm.creator = gp.initiator_name; cm.oracle = gp.initiator_name; + cm.market_type = 0; cm.outcomes = {"A", "B"}; cm.url = "criteria"; + cm.liquidity = asset(share_type(unit * 4), TOKEN_SYMBOL); + cm.betting_expiration = n.head_block_time() + fc::seconds(3600); + cm.result_expiration = n.head_block_time() + fc::seconds(7200); + cm.allow_batch = true; cm.allow_instant_bet = false; + apply(n, gp, when, cm, gp.initiator_key); + pm_place_bet_operation bet; + bet.account = gp.initiator_name; bet.market_id = 0; + bet.side = 0; bet.outcome_index = -1; + bet.amount = asset(share_type(unit), TOKEN_SYMBOL); bet.mode = 1; + const auto before = n.db().get_account(gp.initiator_name).balance; + const auto reserve = n.db().get(pm_market_id_type(0)).reserve_a.value; + if (fixed) { + BOOST_CHECK_THROW(n.push_pending_transaction(sign(bet, gp.initiator_key, n)), std::runtime_error); + BOOST_CHECK_EQUAL(n.db().get_account(gp.initiator_name).balance.amount.value, before.amount.value); + BOOST_CHECK_EQUAL(n.db().get(pm_market_id_type(0)).reserve_a.value, reserve); + BOOST_CHECK_EQUAL(n.db().get_index().indices().size(), 0u); + + // The supported batch path still escrows once, reveals a queued row, + // then moves stake into the curve at the epoch boundary (no double debit). + const int64_t mid = 0, amount = unit, min_tokens = 0; + const int8_t side = 0; + const int16_t outcome = -1; + const account_name_type account = gp.initiator_name; + const std::string salt = "audit-batch"; + fc::sha256::encoder enc; + enc.write((const char*)&mid, sizeof(mid)); + enc.write((const char*)&account.data, sizeof(account.data)); + enc.write((const char*)&side, sizeof(side)); + enc.write((const char*)&outcome, sizeof(outcome)); + enc.write((const char*)&amount, sizeof(amount)); + enc.write((const char*)&min_tokens, sizeof(min_tokens)); + enc.write(salt.data(), (uint32_t)salt.size()); + pm_commit_bet_operation commit; + commit.account = account; commit.market_id = mid; + commit.commitment = enc.result(); + commit.escrow_amount = asset(share_type(unit), TOKEN_SYMBOL); + commit.no_reveal_fee_percent = mp.pm_commit_no_reveal_penalty_percent; + apply(n, gp, when, commit, gp.initiator_key); + const auto after_commit = n.db().get_account(account).balance; + BOOST_CHECK_EQUAL((before - after_commit).amount.value, unit); + pm_reveal_bet_operation reveal; + reveal.account = account; reveal.commit_id = 0; + reveal.side = side; reveal.outcome_index = outcome; + reveal.amount = asset(share_type(unit), TOKEN_SYMBOL); + reveal.min_tokens = share_type(0); reveal.salt = salt; + apply(n, gp, when, reveal, gp.initiator_key); + const pm_bet_id_type bid(0); + BOOST_CHECK_EQUAL(n.db().get_account(account).balance.amount.value, after_commit.amount.value); + BOOST_CHECK_EQUAL(n.db().get(bid).mode, 1); + BOOST_CHECK_EQUAL(n.db().get(bid).status, 5); + BOOST_CHECK_EQUAL(n.db().get(pm_market_id_type(0)).reserve_a.value, reserve); + for (int i = 0; i < 150 && n.db().get(bid).status == 5; ++i) + produce(n, gp, when); + BOOST_CHECK_EQUAL(n.db().get(bid).status, 0); + BOOST_CHECK_GT(n.db().get(bid).weight.value, 0); + BOOST_CHECK_EQUAL(n.db().get(pm_market_id_type(0)).reserve_a.value, + reserve + unit); + BOOST_CHECK_EQUAL(n.db().get_account(account).balance.amount.value, after_commit.amount.value); + } else { + apply(n, gp, when, bet, gp.initiator_key); + const auto& row = *n.db().get_index().indices().begin(); + BOOST_CHECK_EQUAL(row.mode, 1); + BOOST_CHECK_EQUAL(row.status, 0); // historical immediate fill, preserved for replay + BOOST_CHECK_GT(row.weight.value, 0); + BOOST_CHECK_GT(n.db().get(pm_market_id_type(0)).reserve_a.value, reserve); + } + } +} + +// Why the b_share fix matters at all, and what the gate does with state the legacy path already +// broke. The market is created at exactly pm_min_liquidity and topped up 3× that, so the top-up row +// carries exactly 3·seed_b of the curve's 4·seed_b. Half of that row leaves (the withdrawal is +// inside the live-market floor, so it is allowed): +// * legacy — the curve gives up the b_removed but the row keeps its full 3·seed_b, i.e. the row +// now claims more than the market holds. The final exit takes ALL of it and lmsr_b lands at +// −0.5·seed_b. That is not a merely flat curve: lmsr_q96 fails soft for b <= 0 (price/buy-cost/ +// tokens-for-amount return 0 without ever reaching validate_domain), so every outcome prices at +// 0 and a bet costs nothing while the market still holds the remaining LP capital and the +// bettors' stakes — the drain, not a cosmetic bookkeeping drift. +// * fixed — both records shrank by the same floored amount, so the exit removes exactly the row's +// share and the curve lands back on the untouched seed row: lmsr_b == seed_b, Σ active rows == +// lmsr_b, and the market still prices. +// * stale + fix (the real testnet case) — the row keeps the legacy claim, so the fix's gate +// refuses the exit instead of clamping the curve to zero: the position keeps its principal +// (settlement returns it in full, the live-market floor does not apply there), the curve keeps +// pricing, and the inconsistency surfaces as a loud assert rather than as free tokens. +BOOST_AUTO_TEST_CASE(pm_lmsr_partial_withdraw_curve_drain_and_stale_row_gate) { + const std::vector q0 = {0, 0, 0}; // no bets: the drain is pure LP bookkeeping + enum leg_t { LEGACY = 0, FIXED = 1, STALE_GATE = 2 }; + for (int leg = 0; leg < 3; ++leg) { + const leg_t mode = (leg_t)leg; + auto gp = make_genesis_params(0xA160u + (uint64_t)leg, 1); + virtual_clock clk(sim_start()); + simulated_node n("pm-lp-drain" + std::to_string(leg), gp, clk); + fc::time_point_sec when = clk.now() - fc::seconds(CHAIN_BLOCK_INTERVAL); + hf14(n, gp, when); oracle(n, gp, when); + if (mode == FIXED) set_pm_audit_fix(n, true); + + const int64_t unit = n.db().get_validator_schedule_object().median_props.pm_min_liquidity.amount.value; + pm_create_market_operation cm; + cm.creator = gp.initiator_name; cm.oracle = gp.initiator_name; + cm.market_type = 1; cm.outcomes = {"A", "B", "C"}; cm.url = "criteria"; + cm.liquidity = asset(share_type(unit), TOKEN_SYMBOL); + cm.lmsr_b = lmsr::lmsr_b_from_liquidity(unit, 3); + cm.betting_expiration = n.head_block_time() + fc::seconds(3600); + cm.result_expiration = n.head_block_time() + fc::seconds(7200); + apply(n, gp, when, cm, gp.initiator_key); + const pm_market_id_type mid(0); + const pm_liquidity_id_type seed(0), topup(1); + const int64_t seed_b = n.db().get(mid).lmsr_b.value; + BOOST_REQUIRE_GT(seed_b, 1); // the exact half-share split below assumes a non-degenerate b + + pm_add_liquidity_operation add; + add.provider = gp.initiator_name; add.market_id = 0; + add.amount = asset(share_type(unit * 3), TOKEN_SYMBOL); + apply(n, gp, when, add, gp.initiator_key); + const int64_t added_b = n.db().get(topup).b_share.value; + BOOST_REQUIRE_EQUAL(added_b, seed_b * 3); // top-up share is exactly proportional + BOOST_REQUIRE_EQUAL(n.db().get(mid).lmsr_b.value, seed_b * 4); + + const int64_t w1 = unit * 3 / 2; + const int64_t b_removed = added_b * w1 / (unit * 3); // mirrors the evaluator's floored share + BOOST_REQUIRE_GT(b_removed, 0); + pm_withdraw_liquidity_operation w; + w.provider = gp.initiator_name; w.liquidity_id = topup._id; + w.amount = asset(share_type(w1), TOKEN_SYMBOL); + apply(n, gp, when, w, gp.initiator_key); + BOOST_REQUIRE_EQUAL(n.db().get(mid).lmsr_b.value, seed_b * 4 - b_removed); + BOOST_REQUIRE_EQUAL(n.db().get(topup).amount.value, w1); + if (mode == FIXED) + BOOST_REQUIRE_EQUAL(n.db().get(topup).b_share.value, added_b - b_removed); + else // the legacy divergence the whole case is about + BOOST_REQUIRE_EQUAL(n.db().get(topup).b_share.value, added_b); + + if (mode == STALE_GATE) set_pm_audit_fix(n, true); // fix arrives AFTER the state was broken + const auto balance_before = n.db().get_account(gp.initiator_name).balance; + w.amount = asset(0, TOKEN_SYMBOL); // 0 = the rest of the position + + if (mode == STALE_GATE) { + BOOST_CHECK_THROW(apply(n, gp, when, w, gp.initiator_key), std::runtime_error); + // Refused, not clamped: row, curve and balance are exactly as the legacy path left them, + // and the market keeps pricing. + BOOST_CHECK_EQUAL(n.db().get(mid).lmsr_b.value, seed_b * 4 - b_removed); + BOOST_CHECK_EQUAL(n.db().get(topup).b_share.value, added_b); + BOOST_CHECK_EQUAL(n.db().get(topup).amount.value, w1); + BOOST_CHECK_EQUAL(n.db().get(topup).status, 0); + BOOST_CHECK_EQUAL(n.db().get_account(gp.initiator_name).balance.amount.value, + balance_before.amount.value); + BOOST_CHECK_GT(lmsr::lmsr_price(q0, n.db().get(mid).lmsr_b.value, 0), 0); + BOOST_CHECK_GT(lmsr::lmsr_buy_cost(q0, n.db().get(mid).lmsr_b.value, 0, unit), 0); + continue; + } + + apply(n, gp, when, w, gp.initiator_key); + const int64_t final_b = n.db().get(mid).lmsr_b.value; + if (mode == LEGACY) { + BOOST_CHECK_LT(final_b, 0); + // The row asked for more b than the curve held even before the exit — that gap IS the + // drain; the negative b is only how it surfaces. + BOOST_CHECK_LT(seed_b * 4 - b_removed - added_b, 0); + BOOST_CHECK_EQUAL(lmsr::lmsr_price(q0, final_b, 0), 0); // every outcome free + BOOST_CHECK_EQUAL(lmsr::lmsr_buy_cost(q0, final_b, 0, unit), 0); // a bet costs nothing + BOOST_CHECK_GT(n.db().get(mid).liquidity_sum.value, 0); // capital parked + } else { + BOOST_CHECK_EQUAL(final_b, seed_b); // back to the untouched row's b + BOOST_CHECK_EQUAL(active_rows_b_share(n, mid), final_b); + BOOST_CHECK_EQUAL(n.db().get(seed).b_share.value, final_b); + BOOST_CHECK_EQUAL(n.db().get(topup).status, 3); + BOOST_CHECK_GT(lmsr::lmsr_price(q0, final_b, 0), 0); + BOOST_CHECK_GT(lmsr::lmsr_buy_cost(q0, final_b, 0, unit), 0); + } + } +} diff --git a/tests/consensus_sim/scenarios/test_pm_lifecycle.cpp b/tests/consensus_sim/scenarios/test_pm_lifecycle.cpp index e1a7e762c0..3aea8a5d08 100644 --- a/tests/consensus_sim/scenarios/test_pm_lifecycle.cpp +++ b/tests/consensus_sim/scenarios/test_pm_lifecycle.cpp @@ -110,6 +110,26 @@ namespace { produce(node, gp, when); } + // The vote floors (pm_dispute_vote_min_vesting / committee_vote_min_vesting) are compared + // against effective_vesting_shares(), i.e. VESTS — funding a voter with liquid TOKEN does not + // clear them, it is rejected before the ballot's weight is ever read. Vest in its own block: + // inside one block the create/fund tx and this one would be ordered by trx_id, not by intent. + void vest(simulated_node& node, const genesis_params& gp, fc::time_point_sec& when, + const std::string& name, const fc::ecc::private_key& key, share_type token) { + transfer_to_vesting_operation tv; + tv.from = name; tv.to = name; + tv.amount = asset(token, TOKEN_SYMBOL); + node.push_pending_transaction(sign_ops({tv}, key, node)); + produce(node, gp, when); + } + + // Twice the dispute-vote floor: the VIZ→VESTS conversion truncates, so staking exactly the + // floor can land one satoshi short of it. + share_type vote_stake(const simulated_node& node) { + return share_type(node.db().get_validator_schedule_object() + .median_props.pm_dispute_vote_min_vesting.amount.value * 2); + } + // Long-market odds-drift simulation, shared by the binary/skewed/multi cases. // Places the given (outcome, stake) bets from the initiator, prints how far the // commission-baked BOARD coefficient shown at bet time drifts from the FINAL one, then @@ -574,12 +594,16 @@ BOOST_AUTO_TEST_CASE(committee_dispute_lazy_pool_voting_weight) { node.push_pending_transaction(sign_ops({oreg}, gp.initiator_key, node)); produce(node, gp, when); - // carol: no vesting SHARES; her only governance weight is a lazy-pool deposit. Size it to - // ~tvf/3 so that even after it inflates the quorum denominator she clears the 10% bar. + // carol: her *governance weight* comes from a lazy-pool deposit, sized to ~tvf/3 so that even + // after it inflates the quorum denominator she clears the 10% bar. She still has to clear the + // vesting FLOOR first: pm_dispute_vote gates on effective_vesting_shares() (the lazy stake is + // counted in the TALLY only), so a deposit-only voter is rejected before her weight is read. const int64_t tvf = node.db().get_dynamic_global_properties().total_vesting_fund.amount.value; const int64_t deposit_amt = tvf / 3; + const share_type stake = vote_stake(node); auto carol_key = derive_key("carol"), bob_key = derive_key("bob"); - create_and_fund(node, gp, when, "carol", carol_key, share_type(deposit_amt + unit)); + create_and_fund(node, gp, when, "carol", carol_key, share_type(deposit_amt + unit + stake.value)); + vest(node, gp, when, "carol", carol_key, stake); create_and_fund(node, gp, when, "bob", bob_key, share_type(unit * 4)); pm_lazy_deposit_operation dep; @@ -2596,19 +2620,28 @@ BOOST_AUTO_TEST_CASE(instant_bet_disabled_gate) { BOOST_CHECK_THROW(node.push_pending_transaction(sign_ops({instant}, alice_key, node)), std::runtime_error); - // Batch bet (mode 1) is accepted. + // Batch bet (mode 1): accepted while the audit fork is pending (historical immediate fill), and + // rejected once it is active — the whole point of the fix is that mode=1 could be sent straight + // into an instant fill, bypassing the commit -> reveal flow the market opted into with + // allow_batch. Which side runs depends on whether this node has the fork: on a BUILD_TESTNET + // build the harness validator reaches quorum by itself, so it is active whenever the simulation + // clock is past the fork time (this case starts at now(); the dedicated regression in + // test_pm_audit_fixes.cpp drives both sides explicitly on a pinned clock). const asset alice_before = node.db().get_account("alice").balance; + const bool fix_active = node.db().has_hardfork(CHAIN_PM_AUDIT_FIX_HARDFORK); pm_place_bet_operation batch = instant; batch.mode = 1; - node.push_pending_transaction(sign_ops({batch}, alice_key, node)); + if (fix_active) BOOST_CHECK_THROW(node.push_pending_transaction(sign_ops({batch}, alice_key, node)), + std::runtime_error); + else node.push_pending_transaction(sign_ops({batch}, alice_key, node)); produce(node, gp, when); const int64_t spent = (alice_before - node.db().get_account("alice").balance).amount.value; - BOOST_TEST_MESSAGE("instant-gate: batch spent=" << spent); - BOOST_CHECK_EQUAL(spent, unit); // batch bet went through (stake debited) + BOOST_TEST_MESSAGE("instant-gate: fix_active=" << fix_active << " batch spent=" << spent); + BOOST_CHECK_EQUAL(spent, fix_active ? 0 : unit); // pre-fix: stake debited; post-fix: refused uint32_t bets = 0; for (const auto& b : node.db().get_index().indices()) if (b.market == market_id && b.account == account_name_type("alice")) ++bets; - BOOST_CHECK_EQUAL(bets, 1u); + BOOST_CHECK_EQUAL(bets, fix_active ? 0u : 1u); } // #14 — Dispute + time-limited creator ban. An account-mode resolver bars the creator from opening @@ -5649,10 +5682,12 @@ BOOST_AUTO_TEST_CASE(dispute_tally_row_budget_defers_next) { // markets' own timers out from under the test. std::vector voters; std::vector vkeys; + const share_type stake = vote_stake(node); for (int v = 0; v < VOTERS; ++v) { std::string name = "voter" + std::to_string(v); auto k = derive_key(name); - create_and_fund(node, gp, when, name, k, share_type(1000)); + create_and_fund(node, gp, when, name, k, share_type(stake.value + unit)); + vest(node, gp, when, name, k, stake); // liquid TOKEN alone is barred by the vote floor voters.push_back(name); vkeys.push_back(k); } auto bob_key = derive_key("bob"); @@ -5786,10 +5821,12 @@ BOOST_AUTO_TEST_CASE(dispute_ballot_counter_matches_rows) { std::vector voters; std::vector vkeys; + const share_type stake = vote_stake(node); for (int v = 0; v < VOTERS; ++v) { std::string name = "elector" + std::to_string(v); auto k = derive_key(name); - create_and_fund(node, gp, when, name, k, share_type(1000)); + create_and_fund(node, gp, when, name, k, share_type(stake.value + unit)); + vest(node, gp, when, name, k, stake); voters.push_back(name); vkeys.push_back(k); } auto bob_key = derive_key("bob"); diff --git a/tests/pm/CMakeLists.txt b/tests/pm/CMakeLists.txt index fdb5e3b0a2..b266266a7b 100644 --- a/tests/pm/CMakeLists.txt +++ b/tests/pm/CMakeLists.txt @@ -70,3 +70,15 @@ target_link_libraries(pm_props_validate_tests ) add_test(NAME pm_props_validate COMMAND pm_props_validate_tests) + +# HF15 agent access — op-id/wire contract of set_agent_permission_operation + validate() gates +# (typo / virtual name / escalation / proposal wrappers). Protocol library only, no chain. +add_executable(pm_agent_access_tests agent_access_test.cpp) + +target_link_libraries(pm_agent_access_tests + PRIVATE graphene_protocol + fc + Boost::unit_test_framework +) + +add_test(NAME pm_agent_access COMMAND pm_agent_access_tests) diff --git a/tests/pm/agent_access_test.cpp b/tests/pm/agent_access_test.cpp new file mode 100644 index 0000000000..8e25045690 --- /dev/null +++ b/tests/pm/agent_access_test.cpp @@ -0,0 +1,148 @@ +// HF15 agent access — wire contract of set_agent_permission_operation. +// +// Two things are pinned here, and both are cheap to get silently wrong: +// 1. the op is APPENDED to the operation static_variant: its index (the consensus op-id, 105) +// and the indices of its neighbours must not move, or old transactions re-interpret as new ops; +// 2. validate() is the only gate against a delegation list that looks fine and does nothing +// (typo / virtual name) or does far more than it says (a proposal wrapper, which carries +// arbitrary operations whose authorities are collected at execution time). +// +// Not a consensus test: it links the protocol library only, no chain. + +#define BOOST_TEST_MODULE pm_agent_access +#include + +#include +#include +#include +#include + +using namespace graphene::protocol; + +namespace { + +set_agent_permission_operation grant(std::initializer_list ops) { + set_agent_permission_operation g; + g.account = "alice"; + g.agent_name = "trading-bot"; + g.agent_key = fc::ecc::private_key::regenerate(fc::sha256::hash(std::string("agent"))).get_public_key(); + for (const char* o : ops) g.operations.insert(o); + g.expiration = fc::time_point_sec(); // epoch = perpetual + return g; +} + +bool accepts(const set_agent_permission_operation& op) { + try { op.validate(); return true; } catch (const fc::exception&) { return false; } +} + +} // namespace + +BOOST_AUTO_TEST_SUITE(agent_access) + +BOOST_AUTO_TEST_CASE(op_id_is_appended_never_renumbered) { + BOOST_CHECK_EQUAL(operation::count(), 106); + + // Anchors on both sides of the append: if either index moves, the wire format broke. + operation probe; + probe.set_which(104); + BOOST_CHECK_EQUAL(operation_wire_name(probe), "pm_lp_payout"); + probe.set_which(105); + BOOST_CHECK_EQUAL(operation_wire_name(probe), "set_agent_permission"); + + // The wire name clients write as the first element of the operation array. + BOOST_CHECK_EQUAL(operation_wire_name(operation(set_agent_permission_operation())), + "set_agent_permission"); +} + +BOOST_AUTO_TEST_CASE(wire_names_exclude_virtual_operations) { + // A delegation is meaningless for an operation the chain generates itself. + BOOST_CHECK(is_broadcastable_operation_wire_name("transfer")); + BOOST_CHECK(is_broadcastable_operation_wire_name("pm_place_bet")); + BOOST_CHECK(is_broadcastable_operation_wire_name("set_agent_permission")); + BOOST_CHECK(!is_broadcastable_operation_wire_name("pm_lp_payout")); + BOOST_CHECK(!is_broadcastable_operation_wire_name("pm_ban_expired")); + BOOST_CHECK(!is_broadcastable_operation_wire_name("nonsense_op")); + BOOST_CHECK(!is_broadcastable_operation_wire_name("transfer_operation")); +} + +BOOST_AUTO_TEST_CASE(validate_accepts_plain_grants) { + BOOST_CHECK(accepts(grant({"transfer"}))); + BOOST_CHECK(accepts(grant({"transfer", "pm_place_bet", "pm_resolve_market"}))); + BOOST_CHECK(accepts(grant({}))); // empty list = revoke + BOOST_CHECK(accepts(grant({"transfer_to_vesting"}))); // money-moving, but explicit and active-only +} + +BOOST_AUTO_TEST_CASE(validate_bounds_addons_only) { + auto g = grant({}); + g.addons = {"vizhub"}; + BOOST_CHECK(accepts(g)); // addon-only agent (q1718=A) + g.addons = {"Any Text/with:chars", std::string(63, 'x')}; + BOOST_CHECK(accepts(g)); // opaque to the node + g.addons = {std::string(64, 'x')}; + BOOST_CHECK(!accepts(g)); // must be shorter than 64 + g.addons = {""}; + BOOST_CHECK(!accepts(g)); + g.addons = {"a,b"}; + BOOST_CHECK(!accepts(g)); // ',' is the storage separator + g.addons.clear(); + for (int i = 0; i < 10; ++i) g.addons.insert("s" + std::to_string(i)); + BOOST_CHECK(accepts(g)); + g.addons.insert("s10"); + BOOST_CHECK(!accepts(g)); // at most 10 + g.addons = {"vizhub"}; + g.agent_key = public_key_type(); + BOOST_CHECK(!accepts(g)); // addon-only still needs a key +} +BOOST_AUTO_TEST_CASE(validate_refuses_escalation_and_wrappers) { + BOOST_CHECK(!accepts(grant({"set_agent_permission"}))); // would let an agent re-delegate + BOOST_CHECK(!accepts(grant({"proposal_create"}))); // wraps arbitrary ops: bypasses the list + BOOST_CHECK(!accepts(grant({"proposal_update"}))); + BOOST_CHECK(!accepts(grant({"proposal_delete"}))); + // An active-signed account_update without the master field may rewrite the ACTIVE authority, + // i.e. rotate it to a key the agent controls: one granted op would be ownership itself. + BOOST_CHECK(!accepts(grant({"account_update"}))); +} + +BOOST_AUTO_TEST_CASE(validate_refuses_master_only_operations) { + // Not reachable through a delegation (the hook never substitutes master), so granting one + // would be a permission that can never succeed. + BOOST_CHECK(!accepts(grant({"recover_account"}))); + BOOST_CHECK(!accepts(grant({"change_recovery_account"}))); + BOOST_CHECK(!accepts(grant({"set_account_price"}))); + BOOST_CHECK(!accepts(grant({"set_subaccount_price"}))); + BOOST_CHECK(!accepts(grant({"target_account_sale"}))); +} + +BOOST_AUTO_TEST_CASE(validate_refuses_dead_permissions) { + BOOST_CHECK(!accepts(grant({"transfer_typo"}))); // no silent no-op + BOOST_CHECK(!accepts(grant({"pm_lp_payout"}))); // virtual: never broadcast + BOOST_CHECK(!accepts(grant({"witness_update"}))); // legacy alias of validator_update + BOOST_CHECK(!accepts(grant({"Transfer"}))); + BOOST_CHECK(!accepts(grant({""}))); +} + +BOOST_AUTO_TEST_CASE(validate_refuses_malformed_participants) { + auto bad_principal = grant({"transfer"}); + bad_principal.account = "Alice"; + BOOST_CHECK(!accepts(bad_principal)); + + auto bad_name = grant({"transfer"}); + bad_name.agent_name = "Bot!"; + BOOST_CHECK(!accepts(bad_name)); + + auto empty_name = grant({"transfer"}); + empty_name.agent_name = ""; + BOOST_CHECK(!accepts(empty_name)); + + // A grant without a key could never sign anything. + auto no_key = grant({"transfer"}); + no_key.agent_key = public_key_type(); + BOOST_CHECK(!accepts(no_key)); + + // ...but a revoke needs only the name. + auto revoke = grant({}); + revoke.agent_key = public_key_type(); + BOOST_CHECK(accepts(revoke)); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/tests/pm/leverage_test.cpp b/tests/pm/leverage_test.cpp index d2a3f1e526..b038860aa9 100644 --- a/tests/pm/leverage_test.cpp +++ b/tests/pm/leverage_test.cpp @@ -59,6 +59,20 @@ BOOST_AUTO_TEST_CASE(opposing_bet_lowers_value) { BOOST_CHECK_EQUAL(m, RA / 10); // min(ra,rb) × 10% × 100% } +// Regression: a feasible upper endpoint must not be rounded down by the search. +// With the mainnet minimum loan as the cap, returning 99.999 instead of +// 100.000 VIZ produces a quote that the evaluator cannot accept. +BOOST_AUTO_TEST_CASE(max_leverage_includes_feasible_cap) { + const int64_t reserve = 2500000; + const auto k = fc::uint128_t(uint64_t(reserve)) * fc::uint128_t(uint64_t(reserve)); + for (int outcome : {0, 1}) { + for (int64_t cap : {int64_t(0), int64_t(1), int64_t(2), int64_t(100000)}) { + BOOST_CHECK_EQUAL(max_leverage_loan(reserve, reserve, k, 150000, + outcome, cap, 10, 1, 10, 50), cap); + } + } +} + // §4.6 Constraint 2 — max leverage binary search returns a bounded loan whose // worst-case cancel value satisfies the safety threshold after the bet is placed. BOOST_AUTO_TEST_CASE(max_leverage_constraint) {