Skip to content

HF15: PM audit fixes — gated LP/batch corrections, inclusive quote cap, fork-aware regression suite + CI job - #164

Merged
On1x merged 263 commits into
masterfrom
pm
Sep 28, 2026
Merged

On1x merged 263 commits into
masterfrom
pm

Conversation

@On1x

@On1x On1x commented Sep 27, 2026

Copy link
Copy Markdown
Member

HF15 batch for Prediction Markets, accumulated on pm after #124 landed in master.

What is inside

  • fix(pm): gated LP/batch corrections and inclusive quote cap (fix(pm): gated LP/batch corrections and inclusive quote cap #163, reviewed by @web3blind): LP contributions at/above the floor are no longer rejected by an exclusive comparison; the legacy direct pm_place_bet(mode=1) path is gated so batch bets only enter through commit→reveal; max_leverage_loan quotes are inclusive of the pool cap.
  • fix(pm-api): never quote a loan that apply() will refuse — closes the "quote says available, open fails" divergence.
  • test(pm): fork-aware audit regressions + a CI job that runs consensus_sim/pm unit tests.
  • feat(pm): register the audit fixes as HF15 in both configs.

Why this PR is open now (CI vehicle)
The docker workflow (.github/workflows/docker-pr-build.yml) triggers on pull_request only, so pushing to pm no longer rebuilds vizd:pm. This PR is what makes CI build vizblockchain/vizd:pm — the image the VIZ testnet (testnet.viz.world, shelter) runs.

Do not merge until

  • the image is deployed to the testnet and live-accepted (share/token invariant, listings, batch/LP fixes);
  • the Forecaster "batch" checkbox is switched to commit→reveal (direct mode=1 is refused once HF15 activates);
  • the docs for HF15 activation are in place.

Merging this into master activates HF15 for anyone running vizblockchain/vizd:latest, which is why it stays open until the above is done.

On1x added 30 commits June 21, 2026 21:51
- Introduce readonly JSON-RPC plugin for HF14 prediction markets state access
- Document market-related API methods including markets, outcomes, bets, liquidity, and metadata
- Describe position, leverage, oracle, dispute, lazy pool, and governance methods
- Provide details on kline/time series for market weight history and pagination approach
- Explain computed DTOs representing bets, oracles, votes, and payout structures
- Include example usage and code snippets for API calls and data processing
- Link to relevant protocol operations and chain property documentation

docs(governance): update chain properties with HF13 and PM parameters

- Add chain_properties_hf13 with distribution_epoch_length parameter
- Introduce chain_properties_pm (v5) for ~30 prediction market parameters and kill-switch flags
- Detail all median-voted parameters for oracle, market, batch, dispute, time penalty, lazy pool, leverage, and fairness
- Clarify live kill-switch flags to disable commit-reveal, lazy pool, or leverage without hardfork

docs(advanced): extend hardfork management with HF13 and prediction markets

- Add entries for HF13 epoch length and HF14 prediction markets including CPMM/LMSR, oracles, disputes, commit-reveal, lazy-pool, and chain properties v5

docs(prediction-markets): add comprehensive analysis of conceptual mapping of Onix PM protocol

- Provide detailed table comparing 90 theoretical prediction market concepts against VIZ Onix on-chain implementation
- Categorize concepts as solved, inherent, not needed, partial/roadmap, client layer, or open risks
- Discuss information theory, mechanism design, liquidity and trading aspects in depth
- Highlight Onix innovations: risk-free LP, CPMM binary, LMSR multi, commit-reveal batch bets, optional leverage subsystem, lazy pool governance voting weight
- Explain architectural decisions omitting orderbooks, combinatorial markets, and peer prediction
- Updated chainbase submodule commit from 39ab2c2 to d429230
- Ensures third-party library is aligned with latest upstream changes
…cycle, coverage floors and thin-client APIs

Consensus (HF14 follow-up ops, appended so operation indices stay stable):
- pm_dispute_oracle_respond (op 22): the market oracle posts a public rebuttal
  onto an open dispute; stored on the dispute object (public-hearing model),
  allowed only while open and within oracle_response_deadline, re-post overwrites.
- pm_unban (op 23): the resolver that imposed an account-mode ban (banned_by)
  may lift it early; sets banned_until to epoch and clears banned_by.
- pm_ban_expired (virtual): the per-block cron sweeps temporary oracle/creator
  bans at banned_until, clears them and emits the lift for history/indexers.

On-chain state:
- pm_market gains decision_url/decision_reason — the oracle's resolution
  statement stored on-chain (set by pm_resolve_market / pm_no_contest reason),
  readable via get_market with no history scan.
- pm_resolve_market_operation gains decision_reason (reflected on the wire).
- pm_dispute gains oracle_response/oracle_response_time.
- pm_oracle and pm_creator_ban gain banned_by; pm_creator_ban gains a
  by_ban_expiry index so the cron sweeps expired bans oldest-first (cleared
  bans sort into the 0-bucket, permanent bans past now, both skipped).

Chain properties (witness-median tunables):
- pm_listing_min_coverage_percent (2.5x): hide under-insured markets from the
  default catalog (enforced by the API plugin, revealed via show_risky).
- pm_betting_min_coverage_percent (1.5x, advisory): client risk-confirm
  threshold; validated betting <= listing.

Thin-client read APIs (non-consensus, for the viz-js client):
- get_leverage_quote / get_leverage_close_preview / get_leverage_convert_preview
  reuse the frozen pm::leverage math to mirror the open/close/convert evaluators.
- get_market_categories (taxonomy + live counts), get_market_full (one-call
  enriched, account-scoped), get_lazy_allocations / get_market_lazy_allocation.
- Wallet remote_node_api bindings for all of the above.

Docs & tests:
- EN + ru + zh-CN docs updated (chain-properties, prediction-market-api,
  specification, operations overview/prediction-markets/validators,
  virtual-operations); library-integration spec + thin-client plan added.
- test_pm_lifecycle: cases #58-#63 cover oracle rebuttal + decision_reason,
  no-contest rationale, manual unban and its guards, and ban auto-expiry vop.
…throughs

The cli_wallet build failed because remote_prediction_market_api and the
wallet_api pm_get_*/pm_list_* methods returned the node's typed objects. Those
chainbase state objects (pm_market_object, pm_bet_object, ...) and the API DTOs
embedding them are not default-constructible (deleted default ctor / shared_string
members require a segment manager), so fc::api's client deserializer (T tmp;
var.as<T>()) could not instantiate them.

Return fc::variant instead: the node already emits fully-formed JSON and cli_wallet
prints the variant unchanged, so the read surface is identical.
…te_node_api

cli_wallet failed to compile because remote_node_api.hpp pulled in
<graphene/plugins/prediction_market_api/prediction_market_api.hpp> transitively,
but programs/cli_wallet has no include path to that plugin. After the read
pass-throughs switched to fc::variant, the header (and the pmapi alias) are no
longer referenced anywhere in the wallet, so remove them. graphene_wallet still
builds; the public wallet header no longer leaks a plugin-only dependency.
…LP fee

Add two HF14 median-voted consensus parameters and their enforcement:

- pm_oracle_accept_window_sec (default 1h): a pending market the named
  oracle never accepts nor rejects is voided by the per-block cron once
  now >= created_time + window. The creators seed liquidity is refunded
  (return_liquidity); the non-refundable creation fee stays with the DAO
  fund. Tracked via a new pm_market_object.accept_deadline field and a
  by_accept_deadline index; emits the new pm_market_expired virtual op
  (op-id 101, appended last in the operation variant to keep tags stable).

- pm_lazy_min_liquidity_fee_percent (default 2%): the lazy pool skips
  markets whose liquidity_fee_percent is below this reward floor, so it
  never subsidizes depth it is not paid enough to provide.

Wired into calc_median and chain_properties_pm::validate().
… fee

Cover the new pm_oracle_accept_window_sec / pm_market_expired lifecycle
and the pm_lazy_min_liquidity_fee_percent reward-floor gate across:

- EN docs (chain-properties, specification, operations, virtual-operations)
- RU and zh-CN localizations (@l10n) at full parity with the EN source
- library integration spec (delta section + property/vop tables, op-id 101)
  and thin-client plan
- Onix paper EN + RU (state machine, acceptance flow, lazy-pool gate);
  PDFs rebuilt via pandoc + xelatex (EN 30pp, RU 32pp, 0 missing glyphs).
- Added warning that the live protocol uses basis points (bp), not permille (‰)
- Explained the conversion from original PHP prototype’s permille to bp in on-chain code
- Specified that all fee fields (oracle_fee_percent, creator_fee_percent, liquidity_fee_percent, etc.) use bp (10000 = 100%)
- Highlighted the use of `fromBP` parser for fee fields and rejection of markets exceeding fee sum 10000
- Warned that using deprecated `fromPermille` leads to incorrect fee values, off by a factor of 10
…line

The ?: between time_point_sec() and (now + fc::seconds(...)) has no common
type — the latter yields fc::time_point, and each type converts to the other,
which GCC rejects as ambiguous. Wrap the second branch in an explicit
time_point_sec(), matching the copy-init conversion already used for the
reveal/dispute deadlines in this file.
…erations

The generic impacted-account visitor only collected signing authorities, so
prediction-market events were missing from the histories of accounts that did
not sign them:

- signed ops lost their counterparties (pm_create_market -> oracle,
  pm_transfer_position -> recipient, pm_unban -> target, oracle auto-accept
  whitelist);
- virtual ops carry no authority at all, so payouts, forfeits, liquidations,
  oracle penalties, market accept/expire and ban expiry were invisible to the
  affected users.

Add explicit get_impacted_account_visitor overloads for the PM user and
virtual operations, inserting every account field they carry. Market-only
virtual ops that reference a market by id but carry no account name
(pm_batch_settle / pm_dispute_finalize / pm_dispute_auto_close /
pm_lazy_recall) are intentionally left to the generic handler.
…, per-node)

The free-form `metadata` JSON was stored in the consensus `pm_market_object`
(shared_string) permanently — never pruned — even though consensus never reads
it (it is written once and only parsed off-chain by the prediction_market_api
plugin). That let a market permanently bloat every node's chainbase/shared
memory with unbounded, unvalidated data.

Move it out of consensus entirely:

- pm_market_object: drop the `metadata` field (member, ctor, FC_REFLECT). The
  operation `pm_create_market_operation.metadata` is unchanged — clients still
  send it and it lives in the block log, exactly like custom_operation.json.
- pm_create_market_evaluator: stop persisting metadata into state.
- prediction_market_api: ingest metadata off-chain from the create operation
  (post_apply_operation) into the existing prunable pm_market_meta_object,
  instead of reading it back from the consensus object in on_block.
- snapshot: drop the metadata import/export for pm_market (auto-excluded from
  the reflected dump; import of legacy snapshots ignores the field).

Because it is now non-consensus, each node prunes it on its own schedule via
--pmm-ttl-days (default lowered 7 -> 5; 0 keeps it forever for archival nodes).
No consensus length/UTF-8 cap is needed — the blob no longer touches state.
…xed retention

A resolved+settled market (status 3, payout_status 3) is immutable — no betting,
dispute, resolve or payout can touch it again; it only lingered in chainbase
"for history", growing shared-memory state without bound.

process_pm_markets() now GCs such markets and their whole object cluster
(outcomes, bets, liquidity, commits, dispute votes, leverage positions, the
dispute and lazy-allocation rows) once they have been closed for a FIXED protocol
constant PM_CLOSED_MARKET_RETENTION_SEC = 5 days (measured from
result_expiration + dispute grace). The retention is hardcoded and identical on
every node, so pruning is fully deterministic: every node deletes exactly the
same markets at the same block, keeping shared-memory state and snapshots in
lock-step network-wide (a node syncing from a snapshot ends up with the same
market set as everyone else). Work is bounded by the existing per-block cap.

Only status-3/payout-3 markets are collected; disputed (payout_status 2) and
never-settled markets are left untouched. Nothing holds an id-reference to a
settled market, so there are no dangling references after removal.
…ones

Extend the market garbage collector to reclaim ANY dead market a fixed 5 days
after it becomes terminal — not only resolved+paid ones. A market is dead once
nothing can act on it: resolved and paid out, void/no-contest, oracle-rejected,
or the oracle never accepted and the accept window expired.

To anchor the retention on the actual moment of death (rather than the declared
result_expiration), add a `finalized_time` field to pm_market_object, set to the
head-block time at every terminal transition:
  - oracle rejects the market (status -1)
  - accept window expires, market voided (pm_market_expired)
  - oracle misses resolution, refund (pm_oracle_missed_penalty)
  - dispute auto-close refund
  - settlement / auto-payout (covers resolved, no-contest, post-dispute)

A new by_finalized index (finalized_time, id) lets process_pm_markets() sweep
terminal markets in time order, skipping the finalized_time==0 live bucket, and
delete each cluster PM_CLOSED_MARKET_RETENTION_SEC (5 days) later. Retention is a
fixed protocol constant identical on every node, so pruning stays deterministic
and snapshots identical network-wide. Snapshot import reads finalized_time when
present. Work stays bounded by the per-block cap.
Add consensus_sim scenarios asserting that every way a market can die gets its
whole object cluster reclaimed from state after the retention window:
  - gc_resolved_market_after_retention        (resolved + auto-paid)
  - gc_oracle_reject_after_retention          (oracle rejects the terms)
  - gc_accept_window_expired_after_retention  (oracle never accepts, window expires)
  - gc_oracle_missed_after_retention          (oracle misses the resolution deadline)
  - gc_dispute_auto_close_after_retention     (dispute filed, nobody votes, auto-close)

Each drives the market to its terminal state, asserts finalized_time is stamped
and the cluster is still present, then advances past the retention and asserts
the market plus its outcomes/bets/liquidity/commits/dispute-votes/leverage/
dispute/lazy-allocation rows are all gone (market_cluster_absent helper).

To make the 5-day retention testable, expose it as a median-voted chain
property pm_closed_market_retention_sec (default 432000 = 5 days) instead of a
hardcoded constant. It stays identical on every node at any block (median), so
GC remains deterministic and snapshot-safe, while tests can shorten it to 30s.
The GC cron now reads mp.pm_closed_market_retention_sec.
…tor forks

On a testnet fork where one operator controls all validators, the
minority-fork detector loops forever: healthy participation (>=33%)
auto-clears the enable-stale-production override every tick, then
"last 21 blocks all ours" triggers a reset-to-LIB resync. Add an
explicit disable-minority-fork-detection flag that bypasses both the
standard and DLT detection blocks and is never auto-cleared.
…e-operator forks

- Introduce `disable-minority-fork-detection` config to fully skip minority fork detection
- Document usage warnings: only for testnets or single-operator forks, never enable in public networks
- Clarify difference from `enable-stale-production` which auto-clears at high participation
- Update validator plugin docs and configuration references to include new flag
- Explain behavior in validator-node docs and warnings for detection loop avoidance
- Note watchdog and fork detection interactions with new flag in documentation tables
The prediction_market_api plugin extracted only category/subcategory/tags/
banned_jurisdictions from a market's metadata JSON and discarded title, image
and the source condition_id, so thin clients had no human-readable question or
icon to render (markets showed as 'Market #N'). Add title, image and
condition_id to pm_market_meta_object + parse_market_metadata + FC_REFLECT and
copy them in ingest_market_meta. Non-consensus prunable index — no hardfork;
a replay re-extracts these for existing markets from the block log. condition_id
also enables reliable client back-linking of a mirrored market to its source.

Tests extended (meta_parse_test).
The consensus pm_market_object deliberately does NOT carry the free-form
metadata; the plugin parses it off-chain into pm_market_meta_index. So clients
that read market.metadata (title/image/category) got nothing — cards and the
market detail showed 'Market #N' with no thumbnail.

Add market_card(db, market): serialize the market and inject a reconstructed
'metadata' object (title/image/category/subcategory/tags[]/banned_jurisdictions[]/
condition_id) plus flat title/image/category fields. Route the market-returning
read APIs through it: get_market, list_markets, list_markets_by_oracle,
list_markets_by_creator (return vector<fc::variant>), and get_market_full (overlay
the enriched card onto the .market field). Existing clients that parse
market.metadata now work unchanged; no consensus/state change, no replay.

remote_node_api already types these as fc::variant, so the wallet/CLI is unaffected.
Read-only query for the markets that need an oracle's result now: active
(status 1) markets whose betting window has closed (betting_expiration <=
head_block_time) and are therefore not yet resolved. "Awaiting" is not a
distinct status — a market stays active from open through betting-close until
it is resolved — so it can't be filtered by status alone. Walk the
by_betting_expiration index (keyed status, betting_expiration, id) over the
bounded prefix of active markets past their betting deadline and keep the
given oracle's rows. This lets an Oracle Console list pending-resolution
markets without scanning the oracle's full (mostly resolved) history client-side.

Plugin-only read method — no consensus change.
…et meta

The parser already ships each market's short rules text under metadata.description
(Polymarket description / Kalshi rules_primary), but meta_parse dropped it. Add
description to the meta whitelist: parse it, store it in pm_market_meta_object, and
return it in market_card's metadata. The on-chain url still points to the full legal
terms at the source; description is the short "how the oracle resolves" text for
clients. Display/indexing only — no consensus change, no hardfork.
list_markets gains an optional order arg ("oldest" default = legacy, "newest"
= id desc via reverse traversal of the by_status equal-range). Discovery feeds
need newest-first without a full scan.

Meta backfill: after --replay-from-snapshot, off-chain market metadata is rebuilt
only for markets whose create op fell in the reindex window. The DLT rolling block
log can reach further back than the snapshot, so those older create ops are still
on disk. A one-shot pass (run from on_block, budgeted 500 blocks/apply) scans the
DLT log and re-ingests meta for any market still missing it. Op->market mapping is
positional per block (meta ingest is all-or-nothing per block, so the k-th create
op created the k-th market of that block); create_meta_for() is idempotent.
Runs post-replay (reindex_from_dlt does not flush applied_block), no-op when nothing
is missing (normal restart) or when there is no DLT log.
…ional)

The first backfill mapped create ops to markets positionally by block timestamp,
assuming market.created_time == the DLT block's own timestamp. created_time is set
from head_block_time() which lags by a block, so the timestamp bucket was off and
titles landed on the wrong markets (observed shift on testnet).

Replace with a strong identity key (creator + url + betting/result expiration +
outcome count) — all consensus fields the evaluator copies verbatim from the op.
A key mismatch now simply leaves a market's meta empty; it can never mis-assign a
title to the wrong market. Duplicate keys resolve in id order via a small list.

Recovery: replay-from-snapshot starts with an empty meta index, so rebuilding the
node on this image re-ingests all meta correctly (the previous wrong meta is gone).
…ves)

A market created with betting_expiration == 0 keeps betting open until the
oracle resolves it; result_expiration (<= now + pm_max_market_duration, i.e.
<= 1 year) becomes a pure emergency backstop. If the oracle never resolves by
then, the existing missed-resolution path in process_pm_markets refunds every
bet and slashes the oracle insurance — so the "oracle abandoned the market"
case has a trustless recourse without a dispute.

- create: betting_expiration==0 branch requires allow_early_resolution and a
  result_expiration within (now, now + pm_max_market_duration]. Non-open-ended
  markets keep the existing checks.
- betting / liquidity gates (place, commit, reveal, add, withdraw): treat 0 as
  "open while the market is active".
- leverage: available on open-ended markets too (the extra volatility is the
  bettor's own risk). The expiration-buffer check only applies when there is a
  real betting deadline; guarding it also avoids the epoch-0 underflow of
  (betting_expiration - buffer).
- prediction_market_api: mirror the leverage buffer guard; report
  auto_close_time = 0 (no force-close point) for open-ended markets.

Resolve evaluator and the missed-resolution backstop are reused unchanged.
Gated by HF14, no separate feature flag.
Adds a time-based funding cost to leverage positions so a long-held loan (now
possible on open-ended markets) pays the lazy pool for the capital it ties up.

- New median-voted chain property pm_leverage_funding_rate_ppm_per_day (uint32,
  ppm of the loan per 24h; default 50 = 0.005%/day; 0 disables). Validated <= 100%/day.
- pm_leverage_position_object gains funding_paid (cumulative) and funding_due_time
  (next 24h boundary), plus a by_lev_funding_due index (status, funding_due_time, id).
- accrue_leverage_funding() charges whole 24h periods (one-shot catch-up) from the
  bettor's equity into funding_paid, which raises the effective obligation
  (liquidation_threshold + funding_paid) and thus pulls up the liquidation point.
  Funding flows to LP yield through the existing pool_profit path.
- process_pm_markets() gets a funding sweep: for each due active position, accrue,
  reprice at current reserves, and liquidate (reason 3) if now underwater.
- open sets funding_due_time = created_time + 24h; close/convert/liquidate accrue
  first and settle funding to the pool. cascade trigger includes funding_paid.
- prediction_market_api leverage quote exposes funding_rate_ppm_per_day;
  get_pm_chain_properties returns the new property automatically.

Snapshot uses generic FC_REFLECT import/export so the new fields are covered.
Sibling markets of one real-world event (a match/game) carry the same opaque
metadata.event key. Index it off-chain in the prediction_market_api plugin
(non-consensus, prunable — same mechanism as category/description), mirroring
by_meta_category with a by_meta_event index, and expose:
  list_markets_by_event(event, from, limit) -> full market cards, oldest-first
market_card and get_market_meta now surface the event field; wallet CLI gets
pm_list_markets_by_event. Parsers already emit meta.event (Polymarket event_id,
Kalshi event_ticker). Enables client event pages / parent-child nesting.
Plugin TU compiles clean; meta_parse_test covers the new field.
Fresh chains built from this branch enable leverage out of the box. On an
already-running testnet the median value persists in state, so validators
must still vote pm_leverage_enabled on to activate it there.
On1x added 27 commits September 27, 2026 09:25
The activation checklist told the operator to watch the fork become pending on the
testnet before its timestamp. Measured on the testnet after deploying 4.1.0 over a
chain at 4.0.0, that cannot happen: emergency_consensus_active is true and the
schedule is filled with CHAIN_EMERGENCY_VALIDATOR_ACCOUNT (committee), which is
excluded from the fork vote in both directions - database.cpp:2811 skips the vote
injection for the producing validator and database.cpp:3413 skips its slots in the
tally. Every produced block carries an empty extensions array and
get_next_scheduled_hardfork keeps returning 4.0.0 with the previous fork's time, so
next_hardfork stays pinned to current_hardfork_version and the compiled activation
time is a dead knob. Deploying first still verifies the image, the snapshot import
and the invariants, but not activation; observing activation before mainnet needs a
chain whose producer is not the emergency committee (a fresh BUILD_TESTNET build).

Also corrects §3 to the RPC methods this build actually exposes
(get_hardfork_property is not registered on VIZ; get_hardfork_version and
get_next_scheduled_hardfork are), and both locales carry the same correction.
A chain in emergency consensus mode can never activate a hardfork: the
committee holds every schedule slot and is excluded from the vote tally,
so neither next_hardfork nor the 17-of-21 quorum can ever move. That blocks
verifying an activation on a testnet before the production date.

testnet_plugin adds one startup command, --testnet-hardfork <version|number>.
When set, 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 - bypassing the vote tally entirely.

The plugin is inert unless it is loaded and given a target, so shipping it
in the production image is harmless; config_testnet.ini enables it.

Also add database::get_hardfork_number() so a caller can name a hardfork by
version (4.1.0) instead of the number set_hardfork() takes.
…on on the testnet

An emergency-consensus chain can never schedule a hardfork, so the previous
note ended at "activation is unobservable on the production-config
testnet". testnet_plugin removes that limitation: describe the command, the
operator sequence (image first, then the target - a config line for an
unregistered plugin aborts startup) and the log lines to expect, in both
locales.
main.cpp registers the plugin, so the binary must link it: plugins/CMakeLists.txt
globs its subdirectories, which builds the library but leaves main.cpp's
reference to testnet_plugin::testnet_plugin() unresolved.
appbase constructs a plugin object when it is registered — before any
plugin is initialized — and get_plugin() deliberately refuses plugins
that are still in the 'registered' state. Building the impl in the
constructor therefore threw "unable to find plugin: chain" while merely
registering the plugin, which aborted every startup of the binary,
`--help` included. Chain::plugin is resolved in plugin_initialize
instead: appbase's plugin<>::initialize() initializes the dependencies
declared via APPBASE_PLUGIN_REQUIRES before calling it, so the chain
plugin is reachable there.
Declaring the option in both descriptions (the "also show it in --help"
move) puts two options with the same long name into the set appbase
parses argv with (all_options = _cli_options + _cfg_options), and boost
then refuses every start with

  option '--testnet-hardfork' is ambiguous and matches different
  versions of '--testnet-hardfork'

Registering it in the config description alone keeps both documented
forms working — appbase parses the command line against cli + cfg — at
the cost of it not being listed by --help, which prints _cli_options
alone. That trade is documented in the upgrade notes, together with how
to verify the flag (the FORCING HARDFORK banner), because grepping
--help is the obvious-but-wrong check.
The owner confirmed 2026-10-05 00:00:00 UTC (question steemit#1642), so the comment
claiming the date is provisional is stale: it reads as if the date still had to
be re-set before the release is scheduled. A validator or packager reading that
line could take the announced date for a draft.
The operations table stopped at id 100 while the chain variant already
contains 101 pm_market_expired, 102 pm_dispute_opened,
103 pm_early_exit_claim_paid and 104 pm_lp_payout (all virtual, declared
in pm_virtual_operations.hpp). A client author reading the doc would
build a wrong op-id map.

Verified by diffing the chain operation variant against the table in all
three locales: 105 rows, zero mismatches. Gap note updated to 100-104.
…id 105

Account-level delegation of an explicit operation list: `agent` may broadcast the
listed operations on behalf of `account`, signing with its own active key, until
`expiration` (epoch = perpetual, empty list = revoke). No roles, no masks: a mask
would silently widen the grant when a new operation is appended to the chain.

Wiring:
- op struct appended at the END of the operation static_variant (index 105), so no
  existing op-id moves;
- operation_wire_name() / is_broadcastable_operation_wire_name() in operations.cpp:
  name lookup derived from the same type names the wire format uses, with virtual
  operations excluded (they are never broadcast, so a delegation to one is dead);
- validate() refuses what looks fine but is not: unknown names, virtual names,
  legacy aliases (would duplicate a canonical name), malformed names, self-agent,
  set_agent_permission itself (an agent must not re-delegate) and the proposal
  wrappers (a proposal carries arbitrary operations whose authorities are collected
  at execution time, so delegating proposal_create would bypass the explicit list).

Tests: tests/pm/agent_access_test.cpp (6 cases: op-id anchors on both sides of the
append, wire-name classification, and the validate() accept/refuse sets). Proven to
be able to fail: with the never-delegable assert disabled, exactly the 4 escalation
checks go red.
… delegation names

Step 2 of HF15 agent access: the consensus state behind set_agent_permission.

Object `agent_permission_object` holds one row per (principal, agent) pair: the canonical
`,`-joined list of operation names and an expiration (epoch = perpetual). Unique index on
(principal, agent) so a re-grant replaces rather than accumulates, and a non-unique index on
the agent — the wipe rules need both directions.

Two adversarial findings drove the deny-list change (protocol side, same branch):
- `account_update` is an account takeover: an op WITHOUT the master field is satisfied by the
  ACTIVE authority and may carry a new `active` authority, so an agent granted account_update
  for "metadata edits" can rotate the principal's active key to its own and own the account.
- master-only operations (recover_account, change_recovery_account, set_account_price,
  set_subaccount_price, target_account_sale) are unreachable through a delegation — the hook
  never substitutes master — so granting one would be a permission that can never succeed.

The deny-list is now a single source of truth in protocol (`never_delegable_operation_names`),
checked both at grant time and, in step 3, in the authority hook. The evaluator re-checks the
list and the operation names on every grant: the hook trusts these rows, so a bad row would be
live escalation rather than cosmetics.

Tests: 7 cases / 33 checks green (op-id anchors, wire names, accept/refuse sets). Verified:
graphene_protocol + graphene_chain build clean (rc=0) with the new object type in the enum.
A principal's active authority is answered with its agent's active
authority only when a live delegation row covers every
authority-requiring operation of the transaction, the principal's own
signatures do not already satisfy it, and the transaction asks for no
master or regular authority. Signature recovery is deferred until a row
exists, so ordinary transactions pay nothing extra.

Chain-level tests (consensus_sim, BUILD_TESTNET): granted op, principal
still signs, full-coverage rule (mutation-checked), master-mixed tx,
missing/expired/deny-listed/unknown rows, no leak through nested
account_auths.
Export the index and import it with an explicit setter for the
shared_string operations list.
Rows go on both sides (principal and agent) in the same step as a
master change (account_update, recover_account), an active change, a
direct sale (buy_account) and an auction close. Regular-only updates
keep the row: agents sign with active.

Tests: principal active rotation and agent master rotation wipe the row
(mutation-checked), regular-only change keeps it.
buy_account extended an auction by fc::time_point::now() + 5 min, so
each node (and every replay) computed its own close time. Validators
could close the same auction in different blocks and a block-log replay
could not reproduce historical state. From HF15 the base is
head_block_time(); older blocks keep the legacy read.
The auction-close path is left untested for now: bid extensions are
measured by wall clock, which the virtual-clock harness cannot reach
(fixed separately in fix-auction-wallclock).
… on grant

The authority hook walks all of a principal's rows for each transaction
the principal did not sign itself, and a rejected transaction pays no
bandwidth, so the row count must be bounded. Every grant now removes
the principal's expired rows before counting, so a dead row lives at
most until the next grant and the total stays <= 16.

Test: cap refuses the 17th agent, a re-grant is an overwrite, an
expired row is swept and its slot reused (cap mutation-checked).
HF15: measure auction bid extension from head block time, not wall clock
Possible now that #166 (auction extension from head block time) is in pm.
An agent is no longer a separate account but a record on the principal:
agent_name (unique per principal) + agent_key + operation list + expiration.
A transaction signed by the agent key passes on behalf of the principal when
every operation is in the list.

- one key per agent; reissue by name replaces the key; key reuse across names rejected
- all agents wiped on master/active change, recovery, direct sale and auction close;
  regular-only change keeps them
- snapshot imports agent_name/agent_key
- 15 chain tests rewritten; mutations (coverage, wipe, key uniqueness) are caught
Returns a principal's agents (name, key, operation list, expiration) ordered
by name, with expired rows flagged. At most CHAIN_AGENT_MAX_PER_ACCOUNT rows
per principal, so no paging.
Agents carry an optional set of addons, e.g. ["vizhub"]: scopes external
services read to accept the agent key for their own actions. The node stores
them and never interprets them; they grant nothing on chain. At most 10, each
shorter than 64 bytes, no ','. An addon-only agent is valid; revoke = both
lists empty. Stored in the object, snapshot and get_agent_permissions.
…object

The addons import landed in import_transactions (same expiration line) and used
variant::contains, which does not exist; the Docker build failed on it.
HF15: agent access — delegate listed operations to an agent account
@On1x
On1x merged commit 502389a into master Sep 28, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pm-tests testnet-config Compile the testnet config and run consensus_sim (fork-gated PM regressions)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants