From 947440c4dfb41c48bfe9a6d42e9502756f82163b Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Wed, 2 Sep 2026 11:29:08 -0500 Subject: [PATCH 01/16] feat(sysio.system): derive producer rank from a score, and demote on missed rounds (WIRE-367) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Producer rank stops being a governance write and becomes position in a score-ordered index. `producer_info.rank` is replaced by `rank_score`, a packed key of two tier bits over one weighted composite: adding a scoring factor is a new weight field on the `prodscorecfg` singleton, never a re-layout of an unbounded table. Three factors ship live — collateral (linear, uncapped, min across the required pairs, on `slashable_now`), participation, and snapshot attestations; relay / api / benchmark ship at weight 0 pending an attestation path. `setrank` and `assign_producer_ranks` are gone. Missing rounds is a separate model, not just a factor. `onblock` walks the active schedule between the previous block's producer and this one's — the existing counters record presence only, so absence left no trace — and `max_consecutive_missed_rounds` (default 3) sets a demoted tier no score can climb out of. `regproducer` is the single door back, from a voluntary `unregprod` park and from demotion alike; there is no cooldown and no expiry. Rank is now ONE predicate — active row, ACTIVE PRODUCER operator, active finalizer key — shared by all four consumers, each counting schedulable entries while walking rather than taking the first N index slots. `compute` sinks every non-ACTIVE producer into the demoted tier, which is what bounds those walks over a permissionless table. A weight change or a `req_prod_collat` change invalidates every stored score, so `onblock` drains a bounded rescore cursor inside the throttle it already pays for. sysio.opreg cannot cheaply notify sysio.system, so the collateral half is detected by comparing a `scored_collateral_stamp` on the global against the live config. Also: `opreg::deposit` now rejects bootstrapped operators, matching `depositinle`; `regoperator` re-evaluates eligibility so a producer's score is correct from registration; and `termcheck` records why an indefinitely-demoted producer stays demoted rather than being terminated. `regsnapprov`'s capacity check becomes unreachable — `max_snap_providers` is defined as `max_snap_provider_rank`, and derived positions are necessarily distinct where the stored ordinal could repeat. It stays as a structural guard. Making producers schedulable also made the snapshot-attestation fixture expensive: once finalizer keys are registered, `update_ranked_producers` proposes a five-finalizer policy and the node signs plus verifies a vote on every block of the `block_spacing` advance. The fixture now takes a cadence-period count and builds that history in its constructor — before the system contract is deployed and before any key is registered — so those blocks run no contract code under the one-finalizer genesis policy, and `vote_block_num` asserts rather than silently advancing at the expensive price. Two height-precondition negatives need no history at all; two purging tests build both periods up front instead of one mid-test. The suite is its own ctest entry now, so the other 685 cases no longer queue behind it: 580 s and 854 s in parallel against a 2700 s budget, where one entry previously ran the whole binary to the edge of it. WIRE-382 tracks making `block_spacing` configurable, which retires the advance entirely. Change-Id: I86b251fd0915615946acb72eb2a6e69f690cb8a3 --- contracts/sysio.opreg/src/sysio.opreg.cpp | 63 +- contracts/sysio.opreg/sysio.opreg.wasm | Bin 89003 -> 91711 bytes contracts/sysio.system/CMakeLists.txt | 2 + .../include/sysio.system/producer_rank.hpp | 230 ++++++ .../include/sysio.system/producer_score.hpp | 239 ++++++ .../include/sysio.system/sysio.system.hpp | 113 ++- contracts/sysio.system/src/emissions.cpp | 34 +- contracts/sysio.system/src/peer_keys.cpp | 14 +- contracts/sysio.system/src/producer_pay.cpp | 153 ++++ contracts/sysio.system/src/ranking.cpp | 102 ++- .../sysio.system/src/snapshot_attest.cpp | 72 +- contracts/sysio.system/src/sysio.system.cpp | 48 -- contracts/sysio.system/sysio.system.abi | 110 ++- contracts/sysio.system/sysio.system.wasm | Bin 183935 -> 192783 bytes contracts/tests/CMakeLists.txt | 24 +- contracts/tests/emissions_tests.cpp | 734 ++++++++++++++++-- contracts/tests/getpeerkeys_tests.cpp | 18 +- contracts/tests/sysio.finalizer_key_tests.cpp | 82 +- .../tests/sysio.snapshot_attest_tests.cpp | 383 ++++++--- contracts/tests/sysio.system_tester.hpp | 62 +- plugins/snapshot_api_plugin/README.md | 11 +- tests/producer_rank_test.py | 121 +-- tests/snapshot_attest_test.py | 57 +- unittests/snapshot_attest_fixture.hpp | 33 +- unittests/sysio_system_tester.hpp | 38 + unittests/test_contracts.hpp.in | 1 + 26 files changed, 2236 insertions(+), 508 deletions(-) create mode 100644 contracts/sysio.system/include/sysio.system/producer_rank.hpp create mode 100644 contracts/sysio.system/include/sysio.system/producer_score.hpp diff --git a/contracts/sysio.opreg/src/sysio.opreg.cpp b/contracts/sysio.opreg/src/sysio.opreg.cpp index 89a24c3bcc..7aacabc1ed 100644 --- a/contracts/sysio.opreg/src/sysio.opreg.cpp +++ b/contracts/sysio.opreg/src/sysio.opreg.cpp @@ -23,6 +23,13 @@ using opp::attestations::DepositRevert; namespace { +/// Forward declaration -- defined with the other eligibility helpers further down. `regoperator` +/// needs it so registering a PRODUCER notifies sysio.system to score the new operator row. +void reevaluate_eligibility(opreg::operators_t& ops, + const opreg::operator_key& op_pk, + name self, + name account); + using namespace sysio::slug_name_literals; // System-owned rows bill to the sysio RAM pool, not this contract account (privileged-contract @@ -362,6 +369,12 @@ void opreg::regoperator(name account, .registered_at = now, .available_at = is_bootstrapped ? now : 0, }); + + // Producer rank is scored from the operator row, so registering one -- which is what decides its + // tier -- must bring sysio.system's stored score in step. reevaluate_eligibility dispatches + // processprod for producers regardless of transition, which is the notification that does it. + // Declared below; see the forward declaration above regoperator. + reevaluate_eligibility(ops, op_pk, get_self(), account); } // --------------------------------------------------------------------------- @@ -1054,14 +1067,16 @@ void reevaluate_eligibility(opreg::operators_t& ops, const opreg::operator_key& op_pk, name self, name account) { + // An absent config must not silently skip evaluation: `meets_role_min` already treats a default + // (empty) requirement vector as "no operator of this role can activate", and a bootstrapped + // operator bypasses it either way. Returning early here also suppressed the producer rescore + // notification on chains that had not yet installed opconfig. opreg::opconfig_t cfg_tbl(self); - if (!cfg_tbl.exists()) return; - auto cfg = cfg_tbl.get(); + auto cfg = cfg_tbl.get_or_default(opreg::op_config{}); auto refreshed = ops.get(op_pk); if (has_terminal_status(refreshed.status)) return; bool was_eligible = (refreshed.status == OperatorStatus::OPERATOR_STATUS_ACTIVE); bool is_eligible = meets_role_min(refreshed, cfg); - if (was_eligible == is_eligible) return; name handler; switch (refreshed.type) { @@ -1070,6 +1085,15 @@ void reevaluate_eligibility(opreg::operators_t& ops, case OperatorType::OPERATOR_TYPE_UNDERWRITER: handler = "processuw"_n; break; default: return; } + + // Producers dispatch on EVERY balance change, not only on an eligibility transition, because + // sysio.system scores producer rank on the collateral actually posted: a top-up while already + // ACTIVE must raise that score, and a partial withdraw must lower it. `processprod` is a no-op + // on the status when was == is; its notification is the point. Batch operators and underwriters + // have no such score, so they keep the transition-only dispatch. + if (was_eligible == is_eligible && refreshed.type != OperatorType::OPERATOR_TYPE_PRODUCER) { + return; + } action( permission_level{self, "active"_n}, self, handler, @@ -1100,6 +1124,12 @@ void opreg::deposit(name account, uint64_t amount) { check(op.status != OperatorStatus::OPERATOR_STATUS_SLASHED && op.status != OperatorStatus::OPERATOR_STATUS_TERMINATED, "operator not in a deposit-eligible state"); + // Bootstrapped operators are ACTIVE by fiat and bypass `meets_role_min` entirely, so collateral + // credited to one can never affect its eligibility -- the deposit would be accepted into a + // balance that does nothing. `depositinle` already rejects them; this closes the WIRE-direct + // path. There is deliberately no way to collateralise a bootstrap: an operator who wants a + // collateralised producer registers a new account. + check(!op.is_bootstrapped, "bootstrapped operators cannot deposit collateral"); // Credit collateral BEFORE the outbound WIRE transfer, with the cap check // performed ATOMICALLY inside the same `modify` as the credit — reading the @@ -1367,14 +1397,19 @@ void process_eligibility_change(name self, name account, o.status = OperatorStatus::OPERATOR_STATUS_ACTIVE; o.available_at = now; }); - if (notify_system) { - require_recipient(opreg::SYSTEM_ACCOUNT); - } } else if (was_eligible && !is_eligible) { ops.modify(same_payer, op_pk, [&](auto& o) { o.status = OperatorStatus::OPERATOR_STATUS_UNKNOWN; }); } + + // Notify OUTSIDE the transition branches. sysio.system rescores the producer's rank from its + // live collateral, so it must hear about a top-up that changed no status, and about a drop out + // of ACTIVE -- not only about a promotion into it. A stale score is not merely cosmetic: it + // leaves a de-collateralized producer holding an index slot ahead of bonded ones. + if (notify_system) { + require_recipient(opreg::SYSTEM_ACCOUNT); + } } } // anonymous namespace @@ -1671,9 +1706,19 @@ void opreg::termcheck(name account) { // `batch_operator_minimum_active` with no remaining ACTIVE operators // to advance consensus and no recovery path. if (op.is_bootstrapped) return; - // Termination on rolling-buffer underperformance is, for now, scoped to - // batch operators. Producer schedule misses + underwriter offline-too-long - // are open questions per the plan §1; revisit when those decisions land. + // Termination on rolling-buffer underperformance is scoped to batch operators, and for + // producers that is now a DECISION rather than an open question. + // + // A producer that misses `max_consecutive_missed_rounds` consecutive scheduled rounds is + // DEMOTED by sysio.system -- moved to a categorical tier no score can climb out of, so it + // leaves the schedule and draws no pay. Demotion is deliberately recoverable: the producer + // re-registers via `regproducer` when it is ready again. Termination is not recoverable, and + // it also returns the bond, so applying it to an offline-but-bonded producer would convert a + // reversible outage into a permanent exit and hand back the collateral that makes the operator + // accountable. An indefinitely-demoted producer therefore stays demoted -- holding its row and + // its bond -- until it either re-registers or withdraws of its own accord. + // + // Underwriter offline-too-long remains open; they have no committee and no schedule to miss. if (op.type != OperatorType::OPERATOR_TYPE_BATCH) return; // Thresholds come from opconfig — tests can dial them down so the diff --git a/contracts/sysio.opreg/sysio.opreg.wasm b/contracts/sysio.opreg/sysio.opreg.wasm index 9d7f7283d8c0b3027e7ddab2735f1cb2119a4463..9989dc237a71b0d8a09e2a849f99b21c08be46fa 100755 GIT binary patch delta 11097 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 I)=E+S6E;vsOaK4? delta 8790 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 diff --git a/contracts/sysio.system/CMakeLists.txt b/contracts/sysio.system/CMakeLists.txt index 1bdd50d36e..b2d68a8709 100644 --- a/contracts/sysio.system/CMakeLists.txt +++ b/contracts/sysio.system/CMakeLists.txt @@ -30,6 +30,8 @@ set(SYSIO_SYSTEM_OPP_INCLUDE_DIRS ${CMAKE_CURRENT_BINARY_DIR}/../../libraries/opp/generated-cdt ${CMAKE_CURRENT_SOURCE_DIR}/../sysio.opp.common/include ${CMAKE_CURRENT_SOURCE_DIR}/../sysio.opreg/include + ${CMAKE_CURRENT_SOURCE_DIR}/../sysio.uwrit/include + ${CMAKE_CURRENT_SOURCE_DIR}/../../libraries/libfc-lite/include ${CMAKE_CURRENT_SOURCE_DIR}/../sysio.epoch/include) if(BUILD_SYSTEM_CONTRACTS) diff --git a/contracts/sysio.system/include/sysio.system/producer_rank.hpp b/contracts/sysio.system/include/sysio.system/producer_rank.hpp new file mode 100644 index 0000000000..1c845a5d69 --- /dev/null +++ b/contracts/sysio.system/include/sysio.system/producer_rank.hpp @@ -0,0 +1,230 @@ +#pragma once + +#include + +#include +#include + +#include +#include + +#include + +// Producer ranking -- the packed sort key and the governance-tunable weights behind it. +// +// This header is deliberately LIGHT: it carries no sysio.opreg / sysio.uwrit dependency, so +// sysio.system.hpp can include it for `producer_info::rank_score`'s default. The factors that read +// the operator registry live in producer_score.hpp, included only by the translation units that +// actually compute a score -- the same split opreg_status.hpp uses for the same reason. +// +// `rank` is NOT stored. It is position in the "prodrank" index among producers that pass the +// schedulable predicate, derived by iteration. What IS stored on producer_info is `rank_score`: +// the packed key this header builds. + +namespace sysiosystem { + + /** + * Ordering tier -- the high bits of the packed producer sort key. + * + * A tier is CATEGORICAL: no score within a lower tier can overtake a higher one. That is what + * makes the uncapped collateral term safe -- no amount of money buys a demoted producer back + * into the schedule. + * + * Ordered so ascending index iteration yields healthy producers first, then the bootstrapped + * backstop, then producers demoted for missing rounds. A demoted producer is demonstrably + * offline right now; a bootstrap is the foundation-run backstop and must backfill ahead of it. + */ + enum class producer_tier : uint8_t { + healthy = 0, + bootstrapped = 1, + demoted = 2 + }; + + namespace producer_rank { + + /// Fixed-point scale for every factor and weight: basis points. A factor at `score_scale` is + /// "100%"; a collateral ratio of exactly the configured minimum bond is also `score_scale`. + constexpr uint64_t score_scale = 10'000; + + /// Bits the packed key reserves for producer_tier (the two high bits). + constexpr unsigned tier_bits = 2; + + /// Bits left for the composite score. + constexpr unsigned composite_bits = 64 - tier_bits; + + /// Largest representable composite, and the saturation point. + /// + /// The collateral factor is deliberately uncapped as a POLICY -- producers compete for rank by + /// posting more -- so this ceiling is a bit-budget artifact, not a policy cap, and must stay + /// economically unreachable. At a collateral weight of `score_scale` it corresponds to a bond + /// roughly 4.6e10 times the configured minimum. + constexpr uint64_t composite_max = (uint64_t{1} << composite_bits) - 1; + + /** + * Saturating add over the composite's range. + * + * @param lhs first addend. + * @param rhs second addend. + * @return the sum, clamped to `composite_max`. + */ + inline uint64_t add_sat(uint64_t lhs, uint64_t rhs) { + return lhs > composite_max - rhs ? composite_max : lhs + rhs; + } + + /** + * Saturating multiply of a factor by its weight. + * + * Mandatory, not defensive: the collateral factor is deliberately UNCAPPED as a policy, so it + * runs to `composite_max` (~2^62) on a large enough bond. Multiplying that by any weight + * above 1 overflows uint64 and would wrap a top-ranked producer to the bottom of the index. + * The uint128 intermediate makes the saturation explicit. + * + * @param factor a normalised factor, in basis points. + * @param weight the configured weight for that factor. + * @return factor * weight, clamped to `composite_max`. + */ + inline uint64_t mul_sat(uint64_t factor, uint32_t weight) { + const unsigned __int128 product = + static_cast(factor) * static_cast(weight); + return product > static_cast(composite_max) + ? composite_max + : static_cast(product); + } + + /** + * Pack a tier + composite score into the `prodrank` sort key. + * + * The composite is INVERTED so a higher score sorts EARLIER under the index's ascending + * iteration, while the tier is not -- a higher tier must sort later. Equal keys fall back to + * primary-key order (the account name value), which supplies the name tiebreak for free. + * + * @param tier the producer's ordering tier. + * @param composite the weighted composite score, saturated to `composite_max`. + * @return the packed sort key stored as `producer_info::rank_score`. + */ + inline uint64_t pack(producer_tier tier, uint64_t composite) { + const uint64_t bounded = composite > composite_max ? composite_max : composite; + return (static_cast(magic_enum::enum_integer(tier)) << composite_bits) + | (composite_max - bounded); + } + + /** + * The tier encoded in a packed sort key. + * + * @param rank_score a key produced by `pack`. + * @return the encoded tier; `demoted` for any unrecognised value, so an unknown tier sorts + * last rather than being trusted. + */ + inline producer_tier tier_of(uint64_t rank_score) { + const auto raw = static_cast(rank_score >> composite_bits); + return magic_enum::enum_cast(raw).value_or(producer_tier::demoted); + } + + /** + * The sort key of a producer that has never been scored: worst composite in the demoted tier. + * + * This is `producer_info::rank_score`'s default, and it is the safe one. A zero key would + * decode as tier `healthy` with a MAXIMUM inverted composite -- i.e. a registered-but-unscored + * row would sort ahead of every real producer. + * + * @return the packed key for an unscored producer. + */ + inline uint64_t unscored() { + return pack(producer_tier::demoted, 0); + } + + /** + * Participation factor, derived from the same counter the demotion model maintains -- no + * additional state. + * + * It only orders producers across the misses BEFORE demotion fires; the categorical + * consequence of being offline is the tier, not this term. + * + * @param consecutive_missed_rounds the producer's current miss streak. + * @param max_consecutive_missed_rounds the configured demotion threshold. + * @return the participation factor in basis points, clamped to [0, score_scale]. + */ + inline uint64_t participation_factor(uint32_t consecutive_missed_rounds, + uint32_t max_consecutive_missed_rounds) { + if (max_consecutive_missed_rounds == 0) return score_scale; + if (consecutive_missed_rounds >= max_consecutive_missed_rounds) return 0; + const uint64_t missed = static_cast(consecutive_missed_rounds) * score_scale + / static_cast(max_consecutive_missed_rounds); + return score_scale - missed; + } + + /** + * Per-factor weights for the composite producer score, plus the demotion threshold. + * + * Adding a scoring factor is a new weight field defaulting to 0 plus a new factor function -- + * the packed key's LAYOUT never changes, so the mere existence of a new factor invalidates no + * stored key. Only a weight CHANGE invalidates scores, and that is what the rescore cursor on + * the global singleton drains. + * + * `relay` / `api` / `benchmark` ship at 0 deliberately. A `peerkeys` row proves registration, + * not service, and nothing on chain observes an API node or a CPU benchmark at all; weighting + * a self-declared factor is a free-points vector. They are enabled when an attestation path + * exists. + */ + struct [[sysio::table("prodscorecfg"), sysio::contract("sysio.system")]] producer_score_config { + /// Weight on the collateral ratio (linear, uncapped, min across the required pairs). + uint32_t collateral_weight = static_cast(score_scale); + /// Weight on the participation factor derived from consecutive missed rounds. + uint32_t participation_weight = static_cast(score_scale); + /// Weight on the snapshot-provider attestation rate. + uint32_t snapshot_weight = static_cast(score_scale); + /// Reserved -- needs an attestation path before it can carry weight. + uint32_t relay_weight = 0; + /// Reserved -- needs an attestation path before it can carry weight. + uint32_t api_weight = 0; + /// Reserved -- needs an attestation path before it can carry weight. + uint32_t benchmark_weight = 0; + + /// Consecutive missed rounds that demote a producer to standby. Demotion lasts until the + /// producer re-registers: there is no cooldown, no expiry and no automatic recovery, + /// because a demoted producer is scheduled for no rounds and so can never clear the + /// counter by producing. + uint32_t max_consecutive_missed_rounds = 3; + + /// Snapshot attestations within one pay period that earn full marks on the snapshot + /// factor. The counter is reset on the same cadence as the block counters, so this is the + /// window's target rather than an all-time total. + uint32_t snapshot_target_attestations = 1; + + SYSLIB_SERIALIZE(producer_score_config, + (collateral_weight)(participation_weight)(snapshot_weight) + (relay_weight)(api_weight)(benchmark_weight) + (max_consecutive_missed_rounds)(snapshot_target_attestations)) + }; + + /// The `prodscorecfg` singleton. Mirrors `emitcfg_t`: absent until governance installs it, so + /// every read goes through `get_or_default(producer_score_config{})`. + using producer_score_config_t = sysio::kv::global<"prodscorecfg"_n, producer_score_config>; + + /** + * The active producer schedule as `onblock` last observed it. + * + * Miss attribution walks the span between the previous block's producer and this one's, so it + * is only meaningful while the schedule is unchanged: across a change the old names may not be + * in the new set, and a producer newly added to the schedule has not yet had a slot to miss. + * There is no schedule-version intrinsic in CDT -- `get_active_producers()` returns only the + * name list -- so the comparison is against this stored snapshot. + * + * Kept in its OWN singleton rather than on `sysio_global_state` because the global is read and + * written on every block through a cached handle; a 21-name vector on it would widen every + * one of those reads. This row is read per block and written only when the schedule actually + * changes. + */ + struct [[sysio::table("prodsched"), sysio::contract("sysio.system")]] observed_schedule { + /// The active producer names, in schedule order, as of the last observation. + std::vector producers; + + SYSLIB_SERIALIZE(observed_schedule, (producers)) + }; + + /// The `prodsched` singleton -- absent until the first block observes a schedule. + using observed_schedule_t = sysio::kv::global<"prodsched"_n, observed_schedule>; + + } // namespace producer_rank + +} // namespace sysiosystem diff --git a/contracts/sysio.system/include/sysio.system/producer_score.hpp b/contracts/sysio.system/include/sysio.system/producer_score.hpp new file mode 100644 index 0000000000..44622c3aeb --- /dev/null +++ b/contracts/sysio.system/include/sysio.system/producer_score.hpp @@ -0,0 +1,239 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include + +#include +#include + +// The producer-score FACTORS -- everything that reads the operator registry to turn a producer's +// on-chain standing into the composite score `producer_rank::pack` encodes. +// +// Split from producer_rank.hpp, which sysio.system.hpp includes, because this header pulls in the +// sysio.opreg and sysio.uwrit table declarations plus the OPP protobuf types. Only the translation +// units that actually compute a score include it -- the same reason opreg_status.hpp exists +// separately. + +namespace sysiosystem { + + namespace producer_rank { + + /// Well-known sysio.uwrit account -- `locksums` lives here and is the O(1) read cache for a + /// bucket's active locks. + namespace uwrit_refs { + constexpr sysio::name account = "sysio.uwrit"_n; + } + + /** + * Balance minus active locks for one (chain, token) pair, read cross-contract from sysio.opreg + * and sysio.uwrit. + * + * This is opreg's `slashable_now`, NOT its `available()`. available() subtracts pending + * withdraws, and withdraw / cancelwtdw are both free and uncapped -- an operator could + * oscillate their own rank without moving funds. available() also walks the withdraw queue per + * account with no row cap, where this is two O(1) lookups. Ranking on what is actually + * slashable is also the right principle: score should track what can be taken from you, and a + * queued withdraw does not reduce exposure. + * + * @param op the operator row read from sysio.opreg. + * @param chain_code the chain slug of the pair. + * @param token_code the token slug of the pair. + * @return balance minus active locks, saturating at zero. + */ + inline uint64_t slashable_now(const sysio::opreg::operator_entry& op, + sysio::slug_name chain_code, + sysio::slug_name token_code) { + uint64_t balance = 0; + bool found = false; + for (const auto& entry : op.balances) { + if (entry.chain_code == chain_code && entry.token_code == token_code) { + balance = entry.balance; + found = true; + break; + } + } + if (!found) return 0; + + sysio::uwrit::locksums_t sums(uwrit_refs::account); + const sysio::uwrit::lock_sum_key key{op.account, chain_code, token_code}; + const uint64_t locked = sums.contains(key) ? sums.get(key).amount : 0; + return balance > locked ? balance - locked : 0; + } + + /** + * Collateral factor: the MINIMUM, across every (chain, token) pair in `req_prod_collat`, of + * slashable / min_bond -- in basis points, linear and uncapped (saturating only at the bit + * budget). + * + * `min` rather than a sum: posting extra on the cheapest chain must do nothing, so raising the + * score requires lifting EVERY pair and the marginal cost is dominated by the most expensive + * chain. There is no secondary sum term -- it existed only to stop every min compressing to + * 1.0 and the ordering falling through to alphabetical, and participation and snapshot now + * break that tie. + * + * An empty `req_prod_collat` scores 0, consistent with opreg's `meets_role_min` returning + * false for it: such a producer cannot be ACTIVE and so is not schedulable anyway. + * + * @param op the operator row read from sysio.opreg. + * @param cfg the live opreg configuration carrying `req_prod_collat`. + * @return the collateral factor in basis points. + */ + inline uint64_t collateral_factor(const sysio::opreg::operator_entry& op, + const sysio::opreg::op_config& cfg) { + if (cfg.req_prod_collat.empty()) return 0; + + uint64_t lowest = std::numeric_limits::max(); + for (const auto& req : cfg.req_prod_collat) { + // setconfig's require_positive_min_bond already guarantees this (SEC-22); assert rather + // than divide by zero if that ever regresses. + check(req.min_bond > 0, "req_prod_collat entry has a zero min_bond"); + + // uint128 intermediate is mandatory: slashable_now runs to 2^62 and multiplying by + // score_scale overflows uint64. + const unsigned __int128 scaled = + static_cast(slashable_now(op, req.chain_code, req.token_code)) + * static_cast(score_scale); + const unsigned __int128 ratio = scaled / static_cast(req.min_bond); + const uint64_t bounded = ratio > static_cast(composite_max) + ? composite_max + : static_cast(ratio); + if (bounded < lowest) lowest = bounded; + } + return lowest; + } + + /** + * Snapshot-service factor: how much of the configured attestation target the producer met in + * the current pay period. + * + * Scored on ATTESTATIONS, not on registration. A `snapprovs` row is free to create; actually + * voting a snapshot hash that reaches quorum is not. The counter is maintained by + * `snapshot_attest::votesnaphash` and reset on the same `payepoch` cadence as the block + * counters, which supplies the trailing window with no extra machinery. + * + * @param snapshot_attestations the producer's attestation count this pay period. + * @param target the configured count that earns full marks. + * @return the snapshot factor in basis points, clamped to [0, score_scale]. + */ + inline uint64_t snapshot_factor(uint32_t snapshot_attestations, uint32_t target) { + if (target == 0) return 0; + if (snapshot_attestations >= target) return score_scale; + return static_cast(snapshot_attestations) * score_scale + / static_cast(target); + } + + /** + * The ONE schedulable predicate every rank consumer walks. + * + * `rank` is position among the producers this returns true for -- so every consumer must + * COUNT matches while walking the index, never take the first N index entries. An unbonded + * non-bootstrapped registrant is UNKNOWN in opreg and occupies an index slot ahead of the + * bootstrap tier; taking the first N would let a handful of them crowd real producers out of + * peer discovery and snapshot-provider eligibility. + * + * Before this existed the four consumers disagreed -- update_ranked_producers checked all + * three conditions, emissions only the first two, peer_keys and snapshot_attest none. Making + * emissions honour the finalizer-key check is a behavioural fix, not a regression: a producer + * with no active finalizer key can never be scheduled, so it should not draw top-21 pay. + * + * @param producer the producer row under consideration. + * @param finalizers the sysio.system finalizers table. + * @return true iff the producer is eligible to occupy a rank position. + */ + inline bool is_schedulable(const producer_info& producer, finalizers_table& finalizers) { + if (!producer.active()) return false; + if (!is_op_active(producer.owner, sysio::opp::types::OperatorType::OPERATOR_TYPE_PRODUCER)) { + return false; + } + const auto key = finalizer_key_t{producer.owner.value}; + if (!finalizers.contains(key)) return false; + return !finalizers.get(key).active_key_binary.empty(); + } + + /** + * The producer's ordering tier. + * + * Demotion outranks the bootstrap flag: a demoted bootstrap is still offline, and the whole + * point of the bootstrapped tier is to be a live backstop. + * + * @param is_demoted whether the producer is currently demoted for missed rounds. + * @param is_bootstrapped the operator row's genesis flag. + * @return the tier to encode in the packed key. + */ + inline producer_tier tier_for(bool is_demoted, bool is_bootstrapped) { + if (is_demoted) return producer_tier::demoted; + if (is_bootstrapped) return producer_tier::bootstrapped; + return producer_tier::healthy; + } + + /// The per-producer inputs a score needs from `producer_info`. Passed as a struct rather than + /// four positional flags so a new factor's input is a new member, not a new parameter at every + /// call site. + struct score_inputs { + /// Whether the producer is currently demoted for consecutive missed rounds. + bool is_demoted = false; + /// The producer's current miss streak. + uint32_t consecutive_missed_rounds = 0; + /// Snapshot attestations credited this pay period. + uint32_t snapshot_attestations = 0; + }; + + /** + * Compute a producer's packed `rank_score` from its on-chain standing. + * + * The composite is a weighted sum of capped, normalised factors; the collateral term is the + * one deliberately-unbounded input, so the sum saturates rather than wraps. Adding a factor + * means adding a weight and a term here -- the packed layout never changes. + * + * @param producer the producer account being scored. + * @param inputs the per-producer counters read from `producer_info`. + * @param weights the live `prodscorecfg` weights. + * @return the packed sort key to store on `producer_info::rank_score`. + */ + inline uint64_t compute(const sysio::name& producer, + const score_inputs& inputs, + const producer_score_config& weights) { + // A producer that is not a live, collateral-backed PRODUCER operator scores into the + // demoted tier. That is correct on its own terms -- an unbonded registrant must never + // outrank a bonded one -- and it is also what BOUNDS the rank walk: `regproducer` is + // permissionless, so without this every consumer would scan an unbounded table. With it, + // the healthy and bootstrapped tiers hold only ACTIVE producer operators, and a consumer + // stops at the first demoted entry. + if (!is_op_active(producer, sysio::opp::types::OperatorType::OPERATOR_TYPE_PRODUCER)) { + return unscored(); + } + + sysio::opreg::operators_t ops(opreg_refs::account); + const auto op_key = sysio::opreg::operator_key{producer.value}; + if (!ops.contains(op_key)) return unscored(); + const auto op = ops.get(op_key); + + sysio::opreg::opconfig_t opreg_cfg_tbl(opreg_refs::account); + const auto opreg_cfg = opreg_cfg_tbl.get_or_default(sysio::opreg::op_config{}); + + // Every term saturates: the collateral factor is uncapped by design, so factor * weight + // must not be allowed to wrap. + const uint64_t collateral = + mul_sat(collateral_factor(op, opreg_cfg), weights.collateral_weight); + const uint64_t participation = + mul_sat(participation_factor(inputs.consecutive_missed_rounds, + weights.max_consecutive_missed_rounds), + weights.participation_weight); + const uint64_t snapshot = + mul_sat(snapshot_factor(inputs.snapshot_attestations, + weights.snapshot_target_attestations), + weights.snapshot_weight); + + const uint64_t composite = add_sat(add_sat(collateral, participation), snapshot); + return pack(tier_for(inputs.is_demoted, op.is_bootstrapped), composite); + } + + } // namespace producer_rank + +} // namespace sysiosystem diff --git a/contracts/sysio.system/include/sysio.system/sysio.system.hpp b/contracts/sysio.system/include/sysio.system/sysio.system.hpp index 9ec8020832..545bee03b7 100644 --- a/contracts/sysio.system/include/sysio.system/sysio.system.hpp +++ b/contracts/sysio.system/include/sysio.system/sysio.system.hpp @@ -14,6 +14,7 @@ #include #include +#include #include #include @@ -94,12 +95,45 @@ namespace sysiosystem { uint32_t total_unpaid_blocks = 0; /// all blocks which have been produced but not paid uint16_t last_producer_schedule_size = 0; + /// Producer of the previous block -- the cursor `onblock` walks to attribute a MISSED round: + /// the producers sitting between this one and the current block's producer in the active + /// schedule produced nothing in their slot. Whether that walk is meaningful is decided by + /// comparing the live schedule against the `prodsched` snapshot, since CDT exposes no + /// schedule version. + name last_producer; + + /// Rescore cursor. A weight change (`setscorecfg`) or a `req_prod_collat` change + /// invalidates every stored `rank_score`, and the producers table is unbounded because + /// `regproducer` is permissionless. Rather than a mass rewrite, `rescore_generation` is + /// bumped and `onblock` drains `rescore_cursor` a bounded number of rows per schedule-rebuild + /// tick. The cursor walks PRIMARY-key order: rescoring mutates the secondary key, so walking + /// `prodrank` would revisit or skip rows. + uint64_t rescore_cursor = 0; + /// Non-zero while a rescore sweep is in progress. + uint32_t rescore_generation = 0; + + /// `config_timestamp_ms` of the `req_prod_collat` entries the stored scores were computed + /// against, or 0 when that requirement vector was empty. + /// + /// The collateral factor is a RATIO against those minimums, so changing them invalidates + /// every stored score at once -- and `sysio.opreg::setconfig` is the only writer, on a + /// contract this one cannot reach into. Rather than have opreg notify (a ten-parameter + /// handler that would still miss any future writer), `onblock` compares this stamp against + /// the live config inside the throttle it already pays for and opens a sweep on a mismatch. + /// setconfig re-stamps every entry unconditionally, so an unrelated field change opens a + /// sweep too; that is harmless, since rescoring a row whose score is unchanged writes + /// nothing. + uint64_t scored_collateral_stamp = 0; + // explicit serialization macro is not necessary, used here only to improve compilation time SYSLIB_SERIALIZE_DERIVED( sysio_global_state, sysio::blockchain_parameters, (max_ram_size)(total_ram_bytes_reserved) (last_producer_schedule_update)(last_pervote_bucket_fill) (total_unpaid_blocks) - (last_producer_schedule_size) ) + (last_producer_schedule_size) + (last_producer) + (rescore_cursor)(rescore_generation) + (scored_collateral_stamp) ) }; inline sysio::block_signing_authority convert_to_block_signing_authority( const sysio::public_key& producer_key ) { @@ -115,7 +149,11 @@ namespace sysiosystem { struct [[sysio::table("producers"), sysio::contract("sysio.system")]] producer_info { name owner; sysio::public_key producer_key; /// a packed public key object - uint32_t rank = std::numeric_limits::max(); + /// Packed ordering key: producer_tier in the high bits, inverted composite score below. + /// NOT a rank -- `rank` is position in the "prodrank" index among schedulable producers, + /// derived by iteration. Defaults to the demoted tier's worst score so a registered but + /// never-scored row can never outrank a scored one. + uint64_t rank_score = producer_rank::unscored(); bool is_active = true; std::string url; uint32_t unpaid_blocks = 0; @@ -125,8 +163,18 @@ namespace sysiosystem { uint32_t last_block_num = no_prev_block; uint16_t current_round_blocks = 0; // blocks in current (in-progress) round uint32_t eligible_rounds = 0; // rounds meeting >= min_blocks threshold (per epoch) - - uint64_t by_rank()const { return rank; } + /// Rounds this producer was scheduled for and produced nothing in, consecutively. Reset to 0 + /// the moment it produces. At prodscorecfg's max_consecutive_missed_rounds it sets + /// `is_demoted`; see producer_rank.hpp. + uint32_t consecutive_missed_rounds = 0; + /// Demoted to standby for missing rounds. Categorical -- no score overcomes it. Cleared only + /// by `regproducer`, which is the single door back from both a voluntary `unregprod` park and + /// an involuntary demotion. + bool is_demoted = false; + /// Snapshot attestations credited this pay period; reset alongside the block counters. + uint32_t snapshot_attestations = 0; + + uint64_t by_rank_score()const { return rank_score; } bool active()const { return is_active; } void deactivate() { producer_key = public_key(); producer_authority = sysio::block_signing_authority{}; is_active = false; } @@ -134,12 +182,13 @@ namespace sysiosystem { return producer_authority; } - SYSLIB_SERIALIZE( producer_info, (owner)(producer_key)(rank)(is_active)(url)(unpaid_blocks)(last_claim_time)(location)(producer_authority) - (last_block_num)(current_round_blocks)(eligible_rounds) ) + SYSLIB_SERIALIZE( producer_info, (owner)(producer_key)(rank_score)(is_active)(url)(unpaid_blocks)(last_claim_time)(location)(producer_authority) + (last_block_num)(current_round_blocks)(eligible_rounds) + (consecutive_missed_rounds)(is_demoted)(snapshot_attestations) ) }; using producers_table = sysio::kv::table< "producers"_n, producer_key_t, producer_info, - sysio::kv::index<"prodrank"_n, const_mem_fun> + sysio::kv::index<"prodrank"_n, const_mem_fun> >; struct finkey_key_t { @@ -422,20 +471,21 @@ namespace sysiosystem { */ [[sysio::action]] void unregprod( const name& producer ); - /** - * Set the rank of an individual producer. Rank determines scheduling - * priority -- lower rank values are scheduled first. Producers with - * rank > 21 are considered standby. + * Install the producer-score weights. + * + * Each weight scales one normalised factor of the composite score that orders the + * `prodrank` index; a weight of 0 removes that factor's influence entirely, which is how + * `relay` / `api` / `benchmark` ship until an attestation path exists for them. Changing a + * weight invalidates every stored `rank_score`, so this bumps the global's rescore + * generation and `onblock` drains the cursor. * - * @param producer - registered producer account, - * @param rank - positive integer rank (1 = highest priority). + * @param weights - the full weight set plus the demotion threshold. * * @pre Require the authority of the contract itself - * @pre producer must be a registered producer */ [[sysio::action]] - void setrank( const name& producer, uint32_t rank ); + void setscorecfg( const producer_rank::producer_score_config& weights ); /** * Action to register a finalizer key by a registered producer. @@ -550,6 +600,19 @@ namespace sysiosystem { [[sysio::on_notify("auth.msg::onlinkauth")]] void onlinkauth(const name &user, const name &permission, const sysio::public_key &pub_key); + /** + * Rescore a producer whose collateral standing just changed on sysio.opreg. + * + * `sysio.opreg::processprod` notifies this contract on every producer balance change -- + * not only on an eligibility transition -- because producer rank is scored on the + * collateral actually posted: a top-up must raise the score and a withdraw must lower it. + * The handler recomputes from authoritative tables, so it needs no argument beyond the + * account and asserts no authority of its own: `require_recipient` delivers it only from + * sysio.opreg, and its sole effect is to bring a derived value back in step. + */ + [[sysio::on_notify("sysio.opreg::processprod")]] + void onprocessprod( name account, bool was_eligible, bool is_eligible ); + // ---- Emissions actions (defined in emissions.cpp) ---- /** @@ -723,8 +786,26 @@ namespace sysiosystem { void register_producer( const name& producer, const sysio::block_signing_authority& producer_authority, const std::string& url, uint16_t location ); void update_ranked_producers( const block_timestamp& timestamp ); + /// Recompute and store one producer's packed `rank_score`. Called from every path that can + /// move a scoring input: regproducer (tier clear), the opreg eligibility notification + /// (collateral), onblock (miss counter), and the rescore sweep. + void rescore_producer( const name& producer ); + + /// Attribute missed rounds to the producers the active schedule skipped, and demote any + /// that crossed the threshold. Runs on every block; see producer_pay.cpp. + void record_round_participation( const name& current_producer ); + + /// Charge one producer a missed round, demoting it if that crosses the threshold. + void record_missed_round( const name& producer, uint32_t max_consecutive_missed_rounds ); + + /// Open a rescore sweep when sysio.opreg's producer collateral minimums have moved since + /// the stored scores were computed. See `sysio_global_state::scored_collateral_stamp`. + void detect_collateral_config_change(); + + /// Drain a bounded slice of the rescore cursor when weights or collateral minimums changed. + void drain_rescore_cursor(); + // defined in sysio.system.cpp - void assign_producer_ranks( const std::vector& producers ); // defined in block_info.cpp void add_to_blockinfo_table(const sysio::checksum256& previous_block_id, const sysio::block_timestamp timestamp) const; diff --git a/contracts/sysio.system/src/emissions.cpp b/contracts/sysio.system/src/emissions.cpp index 49449cdc29..f41133d627 100644 --- a/contracts/sysio.system/src/emissions.cpp +++ b/contracts/sysio.system/src/emissions.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include #include @@ -846,31 +847,41 @@ void system_contract::payepoch(uint32_t epoch_index, // (eligible) and the counter-reset list (to_reset). The lists differ -- // to_reset includes slashed / terminated producers with stale counters, // eligible does not. + // `rank` is POSITION in this index among SCHEDULABLE producers, counted while walking -- not + // a stored ordinal. The demoted tier sorts last and is never schedulable, so it bounds the + // walk over what is a permissionless, unbounded table. + // + // is_schedulable also requires an active finalizer key, which this loop did not check before. + // That is a fix, not a regression: a producer without one can never be scheduled, so it must + // not draw top-21 pay either. + uint32_t position = 0; for (auto it = prod_by_rank.begin(); it != prod_by_rank.end(); ++it) { - if (it->rank > cfg.standby_end_rank) break; + if (producer_rank::tier_of(it->rank_score) == producer_tier::demoted) break; - // Reset list: every rank-ranged producer with stale counters gets - // reset, regardless of is_active / opreg status. Slashed producers - // still need their counters cleared for the next epoch. - if (it->unpaid_blocks > 0 || it->eligible_rounds > 0 || it->current_round_blocks > 0) { + // Reset list: every producer walked with stale counters gets reset, regardless of + // is_active / opreg status. Slashed producers still need their counters cleared for the + // next epoch. + if (it->unpaid_blocks > 0 || it->eligible_rounds > 0 || it->current_round_blocks > 0 + || it->snapshot_attestations > 0) { to_reset.push_back(it->owner); } - if (!it->is_active) continue; - // opreg filter: skip slashed / terminated / unknown - if (!is_op_active(it->owner, OperatorType::OPERATOR_TYPE_PRODUCER)) continue; + if (!producer_rank::is_schedulable(*it, _finalizers)) continue; + + ++position; + if (position > cfg.standby_end_rank) break; uint32_t w = 0; bool standby = false; uint32_t rounds = 0; - if (it->rank >= 1 && it->rank <= ACTIVE_PRODUCER_COUNT) { + if (position <= ACTIVE_PRODUCER_COUNT) { rounds = it->eligible_rounds; if (it->current_round_blocks >= min_blocks_per_round_for_pay) rounds++; if (rounds == 0) continue; w = ACTIVE_PRODUCER_WEIGHT; - } else if (it->rank >= STANDBY_START_RANK && it->rank <= cfg.standby_end_rank) { - w = cfg.standby_end_rank + 1 - it->rank; + } else if (position >= STANDBY_START_RANK && position <= cfg.standby_end_rank) { + w = cfg.standby_end_rank + 1 - position; standby = true; } @@ -916,6 +927,7 @@ void system_contract::payepoch(uint32_t epoch_index, p.eligible_rounds = 0; p.current_round_blocks = 0; p.last_block_num = no_prev_block; + p.snapshot_attestations = 0; }); } if (reclaimed_unpaid_blocks > 0) { diff --git a/contracts/sysio.system/src/peer_keys.cpp b/contracts/sysio.system/src/peer_keys.cpp index 64e882beea..3bdc6166bd 100644 --- a/contracts/sysio.system/src/peer_keys.cpp +++ b/contracts/sysio.system/src/peer_keys.cpp @@ -1,4 +1,5 @@ #include +#include #include #include @@ -58,9 +59,20 @@ peer_keys::getpeerkeys_res_t peer_keys::getpeerkeys() { }; auto idx = producers.get_index<"prodrank"_n>(); + finalizers_table finalizers(get_self()); + // `rank` is POSITION among SCHEDULABLE producers, so this counts matches rather than taking the + // first `max_rank` index entries. Taking the first N would let unbonded registrants -- which + // occupy index slots but can never be scheduled -- crowd real producers out of peer discovery. + // The demoted tier sorts last and is never schedulable, so it also bounds the walk over what is + // a permissionless, unbounded table. + uint32_t position = 0; for (auto i = idx.cbegin(); i != idx.cend() && resp.size() < max_return; ++i) { - if (i->rank > max_rank) + if (producer_rank::tier_of(i->rank_score) == producer_tier::demoted) + break; + if (!producer_rank::is_schedulable(*i, finalizers)) + continue; + if (++position > max_rank) break; add_peer(*i); } diff --git a/contracts/sysio.system/src/producer_pay.cpp b/contracts/sysio.system/src/producer_pay.cpp index 92ed84a3cc..225ffb781d 100644 --- a/contracts/sysio.system/src/producer_pay.cpp +++ b/contracts/sysio.system/src/producer_pay.cpp @@ -1,6 +1,10 @@ #include +#include #include +#include +#include + namespace sysiosystem { using sysio::current_time_point; @@ -67,10 +71,159 @@ namespace sysiosystem { }); } + // Attribute the rounds nobody produced. This must happen on every block: the counters above + // record PRESENCE only -- a producer that produces nothing is never visited by onblock at + // all, so absence leaves no trace unless the schedule is walked explicitly. + record_round_participation( producer ); + /// only update block producers once every minute, block_timestamp is in half seconds if( timestamp.slot - _global.get().last_producer_schedule_update.slot > 120 ) { + // A collateral-minimum change invalidates every stored score, and this throttle is the + // only place the chain looks. Detect BEFORE draining so a change noticed on this tick + // starts draining on it rather than a minute later. + detect_collateral_config_change(); + // Drain any pending rescore BEFORE rebuilding, so the rebuild sees the freshest scores it + // can. A sweep spans several ticks; the schedule is proposed from a partially-rescored + // index in the meantime, which is safe because the tiers -- not the composite -- decide + // membership, and a tier only changes on demotion or recovery. + drain_rescore_cursor(); update_ranked_producers( timestamp ); } } + void system_contract::record_round_participation( const name& current_producer ) { + const auto& state = _global.get(); + + // Mid-round: the same producer made the previous block, so no slot was skipped and its miss + // counter was already cleared on the first block of this round. This is 11 of every 12 + // blocks, and returning here keeps the schedule read and the snapshot compare off the hot + // path for all of them. + if( state.last_producer == current_producer ) return; + + const auto active_schedule = sysio::get_active_producers(); + + producer_rank::observed_schedule_t observed_tbl( get_self() ); + const auto observed = observed_tbl.get_or_default( producer_rank::observed_schedule{} ); + + // A schedule change invalidates the cursor. The span between the previous producer and this + // one is only a list of MISSES while the schedule is the same set in the same order: after a + // change, a newly-added producer sitting in that span never had a slot to miss, and charging + // it a miss would count toward a demotion it did not earn. There is no schedule-version + // intrinsic, so the comparison is against the stored snapshot. + const bool schedule_unchanged = observed.producers == active_schedule; + + if( !schedule_unchanged ) { + observed_tbl.set( producer_rank::observed_schedule{ .producers = active_schedule }, get_self() ); + } + + if( schedule_unchanged && state.last_producer.value != 0 ) { + const auto previous = std::find( active_schedule.begin(), active_schedule.end(), + state.last_producer ); + const auto current = std::find( active_schedule.begin(), active_schedule.end(), + current_producer ); + if( previous != active_schedule.end() && current != active_schedule.end() ) { + producer_rank::producer_score_config_t weights_tbl( get_self() ); + const auto weights = weights_tbl.get_or_default( producer_rank::producer_score_config{} ); + + // Walk forward from the slot AFTER the previous producer to the current one, wrapping + // at the end of the round-robin. Every name in between held a slot and produced + // nothing. Bounded by the schedule size (max_producers); normally zero iterations, + // since the next producer follows the previous one directly. + auto slot = previous + 1; + for( size_t stepped = 0; stepped < active_schedule.size(); ++stepped ) { + if( slot == active_schedule.end() ) slot = active_schedule.begin(); + if( slot == current ) break; + record_missed_round( *slot, weights.max_consecutive_missed_rounds ); + ++slot; + } + } + } + + // The producer of this block is, by construction, not missing its round. + auto key = producer_key_t{current_producer.value}; + if( _producers.contains(key) && _producers.get(key).consecutive_missed_rounds > 0 ) { + _producers.modify( same_payer, key, []( auto& p ) { p.consecutive_missed_rounds = 0; }); + rescore_producer( current_producer ); + } + + _global.modify( get_self(), [&]( auto& g ) { g.last_producer = current_producer; }); + } + + void system_contract::record_missed_round( const name& producer, + uint32_t max_consecutive_missed_rounds ) { + auto key = producer_key_t{producer.value}; + if( !_producers.contains(key) ) return; + + _producers.modify( same_payer, key, [&]( auto& p ) { + p.consecutive_missed_rounds++; + // Demotion is CATEGORICAL: it moves the producer into a tier no score can climb out of, + // and it lasts until the producer re-registers. There is no cooldown and no expiry -- + // a demoted producer is scheduled for no rounds, so it can never clear the counter by + // producing. + if( !p.is_demoted && max_consecutive_missed_rounds > 0 + && p.consecutive_missed_rounds >= max_consecutive_missed_rounds ) { + p.is_demoted = true; + } + }); + + // The miss moved the participation factor, and a demotion moved the tier; either way the + // stored sort key is stale. + rescore_producer( producer ); + } + + void system_contract::detect_collateral_config_change() { + sysio::opreg::opconfig_t cfg_tbl( opreg_refs::account ); + const auto cfg = cfg_tbl.get_or_default( sysio::opreg::op_config{} ); + + // setconfig stamps every entry with the same on-chain time, so the first entry represents the + // whole vector. An empty vector has no stamp; 0 stands for it, and it is distinguishable from + // any real stamp because current_time_ms() is never 0 on a live chain. Both directions of the + // empty/non-empty transition therefore register as a change. + const uint64_t stamp = cfg.req_prod_collat.empty() + ? uint64_t{0} + : cfg.req_prod_collat.front().config_timestamp_ms; + + if( stamp == _global.get().scored_collateral_stamp ) return; + + _global.modify( get_self(), [&]( auto& g ) { + g.scored_collateral_stamp = stamp; + g.rescore_cursor = 0; + g.rescore_generation++; + }); + } + + void system_contract::drain_rescore_cursor() { + const auto& state = _global.get(); + if( state.rescore_generation == 0 ) return; + + // Bounded per tick, mirroring opreg's MAX_WTDW_FLUSH_PER_EPOCH: the producers table is + // unbounded, so a weight change can never rewrite it inline. + constexpr uint32_t max_rescore_per_tick = 32; + + uint64_t cursor = state.rescore_cursor; + bool done = true; + + // COLLECT first, rescore after. rescore_producer writes the row, which moves its entry in the + // secondary index; mutating the table while an iterator into it is live is not safe to rely + // on. The batch is bounded by max_rescore_per_tick, so the vector is small and fixed. + std::vector batch; + batch.reserve( max_rescore_per_tick ); + for( auto it = _producers.lower_bound( producer_key_t{cursor} ); it != _producers.end(); ++it ) { + if( batch.size() >= max_rescore_per_tick ) { + cursor = it->owner.value; // resume here next tick + done = false; + break; + } + batch.push_back( it->owner ); + } + for( const auto& producer : batch ) { + rescore_producer( producer ); + } + + _global.modify( get_self(), [&]( auto& g ) { + g.rescore_cursor = done ? 0 : cursor; + g.rescore_generation = done ? 0 : g.rescore_generation; + }); + } + } //namespace sysiosystem diff --git a/contracts/sysio.system/src/ranking.cpp b/contracts/sysio.system/src/ranking.cpp index a8450d5c82..180b079dde 100644 --- a/contracts/sysio.system/src/ranking.cpp +++ b/contracts/sysio.system/src/ranking.cpp @@ -7,6 +7,7 @@ #include #include +#include #include #include @@ -55,7 +56,61 @@ namespace sysiosystem { info.producer_authority = producer_authority; if ( info.last_claim_time == time_point() ) info.last_claim_time = ct; + // regproducer is the SINGLE door back -- from a voluntary `unregprod` park and from an + // involuntary demotion alike. `unregprod` erases the signing key, so re-registering has + // to re-supply it, which makes this a genuine assertion of readiness rather than a + // no-op. There is deliberately no cooldown and no expiry: a producer that comes back + // before it is ready is demoted again within max_consecutive_missed_rounds rounds, + // which is self-correcting. + info.is_demoted = false; + info.consecutive_missed_rounds = 0; }); + + // The clear above changes the producer's tier, so its sort key is stale until rescored. + rescore_producer( producer ); + } + + void system_contract::rescore_producer( const name& producer ) { + auto key = producer_key_t{producer.value}; + if( !_producers.contains(key) ) return; + + producer_rank::producer_score_config_t weights_tbl( get_self() ); + const auto weights = weights_tbl.get_or_default( producer_rank::producer_score_config{} ); + + const auto info = _producers.get(key); + const auto score = producer_rank::compute( + producer, + producer_rank::score_inputs{ + .is_demoted = info.is_demoted, + .consecutive_missed_rounds = info.consecutive_missed_rounds, + .snapshot_attestations = info.snapshot_attestations + }, + weights ); + + if( score == info.rank_score ) return; // no index move needed + _producers.modify( same_payer, key, [&]( auto& p ) { p.rank_score = score; }); + } + + void system_contract::onprocessprod( name account, bool, bool ) { + // The eligibility flags are not consulted: rescore_producer reads the operator's live status + // and balances, which is the same information after the flip and cannot go stale between the + // notification and this handler. + rescore_producer( account ); + } + + void system_contract::setscorecfg( const producer_rank::producer_score_config& weights ) { + require_auth( get_self() ); + + producer_rank::producer_score_config_t weights_tbl( get_self() ); + weights_tbl.set( weights, get_self() ); + + // Every stored rank_score was computed under the OLD weights. Rather than rewrite an + // unbounded table inline, open a rescore sweep: onblock drains a bounded number of rows per + // schedule-rebuild tick until the cursor is exhausted. + _global.modify( get_self(), []( auto& g ) { + g.rescore_cursor = 0; + g.rescore_generation++; + }); } void system_contract::regproducer( const name& producer, const sysio::public_key& producer_key, const std::string& url, uint16_t location ) { @@ -97,43 +152,20 @@ namespace sysiosystem { top_producers.reserve(max_producers); proposed_finalizers.reserve(max_producers); - // Standbys (rank above max_producers, up to standby_end_rank) may backfill - // active slots vacated by ineligible producers, so the schedule stays at - // max_producers whenever replacements exist. standby_end_rank is - // governance-tunable on the emitcfg singleton (>= 22, capped by - // setemitcfg); before emissions config is installed there are no standbys, - // so fall back to max_producers. - uint32_t schedule_rank_limit = max_producers; - emissions::emitcfg_t emitcfg( get_self() ); - if( emitcfg.exists() ) { - schedule_rank_limit = emitcfg.get().standby_end_rank; - } - + // `rank` is POSITION in this index among schedulable producers, so the first max_producers + // matches ARE ranks 1..max_producers -- the active schedule. Standbys are the positions past + // it and never enter the schedule, which is why the old schedule_rank_limit branch is gone: + // a slot vacated by an ineligible producer is filled by the next schedulable entry for free, + // with no explicit backfill. + // + // The walk is bounded by the demoted tier. `regproducer` is permissionless, so the table is + // unbounded -- but producer_rank::compute sinks every non-ACTIVE producer operator into the + // demoted tier, which sorts last, so the scan stops before the spam tail. for( auto it = idx.cbegin(); it != idx.cend() && top_producers.size() < max_producers; ++it ) { - if( it->rank > schedule_rank_limit ) break; // past the last standby - if( !it->active() ) continue; - - // A producer must be a live, collateral-backed producer operator in - // sysio.opreg. A producer that withdrew collateral (status UNKNOWN), - // was slashed, or was terminated is no longer OPERATOR_STATUS_ACTIVE - // and must not be scheduled. Requiring OPERATOR_TYPE_PRODUCER prevents - // an account that is ACTIVE only as a different operator type (e.g. a - // batch operator, backed by different collateral) from being scheduled. - if( !is_op_active( it->owner, sysio::opp::types::OperatorType::OPERATOR_TYPE_PRODUCER ) ) { - continue; - } - - // Require active finalizer key for all scheduled producers - auto fin_key = finalizer_key_t{it->owner.value}; - if( !_finalizers.contains(fin_key) ) { - continue; - } - auto finalizer = _finalizers.get(fin_key); - if( finalizer.active_key_binary.empty() ) { - continue; - } + if( producer_rank::tier_of( it->rank_score ) == producer_tier::demoted ) break; + if( !producer_rank::is_schedulable( *it, _finalizers ) ) continue; - proposed_finalizers.emplace_back(finalizer); + proposed_finalizers.emplace_back( _finalizers.get( finalizer_key_t{it->owner.value} ) ); top_producers.emplace_back( sysio::producer_authority{ .producer_name = it->owner, diff --git a/contracts/sysio.system/src/snapshot_attest.cpp b/contracts/sysio.system/src/snapshot_attest.cpp index b43c32f9b4..f9dabb1d41 100644 --- a/contracts/sysio.system/src/snapshot_attest.cpp +++ b/contracts/sysio.system/src/snapshot_attest.cpp @@ -1,4 +1,5 @@ #include +#include #include #include @@ -42,24 +43,68 @@ enum class snapshot_producer_eligibility { }; /// Returns the producer-table eligibility used only when a provider mapping is registered. -snapshot_producer_eligibility get_snapshot_producer_eligibility(const producer_info& producer) { +/// +/// `rank` is no longer a stored field -- it is POSITION in the "prodrank" index among schedulable +/// producers. So the rank gate is a bounded walk of at most `max_snap_provider_rank` schedulable +/// entries, testing membership, rather than a point read. Counting matches (rather than taking the +/// first N index entries) is what stops unbonded registrants -- which occupy index slots but can +/// never be scheduled -- from crowding real producers out of snapshot-provider eligibility. +/// The producers holding rank positions 1..max_snap_provider_rank, in rank order. +/// +/// Computed ONCE per caller and then tested for membership, rather than re-walked per producer: +/// the prune path checks up to max_snap_providers entries, and a per-entry walk would make that +/// quadratic. +std::vector snapshot_ranked_producers(name self) { + producers_table producers(self); + finalizers_table finalizers(self); + + std::vector ranked; + ranked.reserve(max_snap_provider_rank); + + auto idx = producers.get_index<"prodrank"_n>(); + for (auto i = idx.cbegin(); i != idx.cend() && ranked.size() < max_snap_provider_rank; ++i) { + if (producer_rank::tier_of(i->rank_score) == producer_tier::demoted) break; + if (!producer_rank::is_schedulable(*i, finalizers)) continue; + ranked.push_back(i->owner); + } + return ranked; +} + +/// Returns the producer-table eligibility used only when a provider mapping is registered. +snapshot_producer_eligibility get_snapshot_producer_eligibility(const producer_info& producer, + const std::vector& ranked) { if (!producer.active()) { return snapshot_producer_eligibility::inactive; } - if (producer.rank > max_snap_provider_rank) { + if (std::find(ranked.begin(), ranked.end(), producer.owner) == ranked.end()) { return snapshot_producer_eligibility::rank_exceeds_maximum; } return snapshot_producer_eligibility::eligible; } /// Requires the producer's current table state to permit snapshot-provider registration. -void require_snapshot_producer_eligibility(const producers_table& producers, name producer) { +void require_snapshot_producer_eligibility(name self, name producer) { + producers_table producers(self); const auto prod_itr = producers.require_find(producer_key_t{producer.value}, producer_not_registered_error); - const auto eligibility = get_snapshot_producer_eligibility(*prod_itr); + const auto eligibility = get_snapshot_producer_eligibility(*prod_itr, snapshot_ranked_producers(self)); check(eligibility != snapshot_producer_eligibility::inactive, producer_not_active_error); check(eligibility != snapshot_producer_eligibility::rank_exceeds_maximum, producer_rank_too_high_error); } +/// Credit every producer whose vote contributed to a quorum-reaching snapshot record. +/// +/// The vote rows -- the only place a per-producer voter list exists -- are PURGED once the record is +/// finalized, so without this counter there is no attestation history to score. Reset on the same +/// `payepoch` cadence as the block counters, which supplies the trailing window. +void credit_snapshot_attestations(name self, const std::vector& voters) { + producers_table producers(self); + for (const auto& voter : voters) { + auto key = producer_key_t{voter.value}; + if (!producers.contains(key)) continue; + producers.modify(same_payer, key, [](auto& row) { row.snapshot_attestations++; }); + } +} + /// Counts provider mappings for the bounded registration-capacity check. uint32_t count_snapshot_providers(const snap_providers_table& providers) { uint32_t provider_count = 0; @@ -75,12 +120,13 @@ void prune_stale_snapshot_providers_if_full(name self, snap_providers_table& pro return; } - producers_table producers(self); - auto provider_itr = providers.begin(); + producers_table producers(self); + const auto ranked = snapshot_ranked_producers(self); + auto provider_itr = providers.begin(); while (provider_itr != providers.end()) { const auto producer_itr = producers.try_get(producer_key_t{provider_itr->producer.value}); if (!producer_itr - || get_snapshot_producer_eligibility(*producer_itr) != snapshot_producer_eligibility::eligible) { + || get_snapshot_producer_eligibility(*producer_itr, ranked) != snapshot_producer_eligibility::eligible) { const name stale_producer = provider_itr->producer; const name stale_snap_account = provider_itr->snap_account; provider_itr = providers.erase(std::move(provider_itr)); @@ -131,7 +177,7 @@ void snapshot_attest::regsnapprov(name producer, name snap_account) { require_auth(producer); producers_table producers(get_self()); - require_snapshot_producer_eligibility(producers, producer); + require_snapshot_producer_eligibility(get_self(), producer); snap_providers_table providers(get_self()); const auto provider_itr = providers.find(snap_provider_key_t{snap_account.value}); @@ -188,13 +234,15 @@ void snapshot_attest::votesnaphash(name snap_account, checksum256 block_id, chec std::optional matching_vote_id; uint32_t voter_count = 0; bool exact_retry = false; + std::vector quorum_voters; for (auto vote_itr = by_block_num.lower_bound(static_cast(block_num)); vote_itr != by_block_num.end() && vote_itr->block_num == block_num; ++vote_itr) { if (std::find(vote_itr->voters.begin(), vote_itr->voters.end(), producer) != vote_itr->voters.end()) { check(vote_itr->block_id == block_id && vote_itr->snapshot_hash == snapshot_hash, vote_equivocation_error); - voter_count = static_cast(vote_itr->voters.size()); - exact_retry = true; + voter_count = static_cast(vote_itr->voters.size()); + quorum_voters = vote_itr->voters; + exact_retry = true; break; } if (vote_itr->block_id == block_id && vote_itr->snapshot_hash == snapshot_hash) { @@ -204,6 +252,7 @@ void snapshot_attest::votesnaphash(name snap_account, checksum256 block_id, chec if (exact_retry) { if (voter_count >= config.min_providers) { + credit_snapshot_attestations(get_self(), quorum_voters); finalize_snapshot_vote(get_self(), block_num, block_id, snapshot_hash); } return; @@ -215,6 +264,7 @@ void snapshot_attest::votesnaphash(name snap_account, checksum256 block_id, chec voter_count = static_cast(matching_vote.voters.size()) + 1; votes.modify(same_payer, snap_vote_key_t{*matching_vote_id}, [&](auto& row) { row.voters.push_back(producer); + quorum_voters = row.voters; }); } else { const uint64_t new_id = votes.available_primary_key(); @@ -224,10 +274,12 @@ void snapshot_attest::votesnaphash(name snap_account, checksum256 block_id, chec row.block_id = block_id; row.snapshot_hash = snapshot_hash; row.voters = {producer}; + quorum_voters = row.voters; }); } if (voter_count >= config.min_providers) { + credit_snapshot_attestations(get_self(), quorum_voters); finalize_snapshot_vote(get_self(), block_num, block_id, snapshot_hash); } } diff --git a/contracts/sysio.system/src/sysio.system.cpp b/contracts/sysio.system/src/sysio.system.cpp index 6f6ba51114..d5ec9b5ac2 100644 --- a/contracts/sysio.system/src/sysio.system.cpp +++ b/contracts/sysio.system/src/sysio.system.cpp @@ -175,61 +175,13 @@ namespace sysiosystem { set_resource_limits( account, ram, current_net, current_cpu ); } - void system_contract::assign_producer_ranks( const std::vector& producers ) { - auto idx = _producers.get_index<"prodrank"_n>(); - std::set rm_sched_prods; - for( auto i = idx.cbegin(); i != idx.cend(); ++i ) { - if( i->rank > max_producers ) break; - rm_sched_prods.insert(i->owner); - } - uint32_t rank = 0; - for( const auto& prod_name : producers ) { - ++rank; - auto key = producer_key_t{prod_name.value}; - if( _producers.contains(key) ) { - _producers.modify(same_payer, key, [&](auto& p) { - p.rank = rank; - }); - } - rm_sched_prods.erase(prod_name); - } - for( const auto& prod : rm_sched_prods ) { - auto key = producer_key_t{prod.value}; - if( _producers.contains(key) ) { - _producers.modify(same_payer, key, [&](auto& p) { - p.rank = p.rank + max_producers; - }); - } - } - } - - void system_contract::setrank( const name& producer, uint32_t rank ) { - require_auth( get_self() ); - auto key = producer_key_t{producer.value}; - check( _producers.contains(key), "producer not found" ); - check( rank > 0, "rank must be positive" ); - _producers.modify( same_payer, key, [&](auto& p) { - p.rank = rank; - }); - } - void system_contract::setprods( const std::vector& schedule ) { require_auth( get_self() ); - std::vector names; - names.reserve(schedule.size()); - for( const auto& prod : schedule ) - names.push_back(prod.producer_name); - assign_producer_ranks(names); set_proposed_producers( schedule ); } void system_contract::setprodkeys( const std::vector& schedule ) { require_auth( get_self() ); - std::vector names; - names.reserve(schedule.size()); - for( const auto& prod : schedule ) - names.push_back(prod.producer_name); - assign_producer_ranks(names); set_proposed_producers( schedule ); } diff --git a/contracts/sysio.system/sysio.system.abi b/contracts/sysio.system/sysio.system.abi index 0fc321265a..70d325dc9a 100644 --- a/contracts/sysio.system/sysio.system.abi +++ b/contracts/sysio.system/sysio.system.abi @@ -830,6 +830,16 @@ } ] }, + { + "name": "observed_schedule", + "base": "", + "fields": [ + { + "name": "producers", + "type": "name[]" + } + ] + }, { "name": "onblock", "base": "", @@ -951,8 +961,8 @@ "type": "public_key" }, { - "name": "rank", - "type": "uint32" + "name": "rank_score", + "type": "uint64" }, { "name": "is_active", @@ -989,6 +999,18 @@ { "name": "eligible_rounds", "type": "uint32" + }, + { + "name": "consecutive_missed_rounds", + "type": "uint32" + }, + { + "name": "is_demoted", + "type": "bool" + }, + { + "name": "snapshot_attestations", + "type": "uint32" } ] }, @@ -1030,6 +1052,44 @@ } ] }, + { + "name": "producer_score_config", + "base": "", + "fields": [ + { + "name": "collateral_weight", + "type": "uint32" + }, + { + "name": "participation_weight", + "type": "uint32" + }, + { + "name": "snapshot_weight", + "type": "uint32" + }, + { + "name": "relay_weight", + "type": "uint32" + }, + { + "name": "api_weight", + "type": "uint32" + }, + { + "name": "benchmark_weight", + "type": "uint32" + }, + { + "name": "max_consecutive_missed_rounds", + "type": "uint32" + }, + { + "name": "snapshot_target_attestations", + "type": "uint32" + } + ] + }, { "name": "regfinkey", "base": "", @@ -1299,16 +1359,12 @@ ] }, { - "name": "setrank", + "name": "setscorecfg", "base": "", "fields": [ { - "name": "producer", - "type": "name" - }, - { - "name": "rank", - "type": "uint32" + "name": "weights", + "type": "producer_score_config" } ] }, @@ -1451,6 +1507,22 @@ { "name": "last_producer_schedule_size", "type": "uint16" + }, + { + "name": "last_producer", + "type": "name" + }, + { + "name": "rescore_cursor", + "type": "uint64" + }, + { + "name": "rescore_generation", + "type": "uint32" + }, + { + "name": "scored_collateral_stamp", + "type": "uint64" } ] }, @@ -1971,8 +2043,8 @@ "ricardian_contract": "" }, { - "name": "setrank", - "type": "setrank", + "name": "setscorecfg", + "type": "setscorecfg", "ricardian_contract": "" }, { @@ -2178,6 +2250,22 @@ "key_types": ["name"], "table_id": 16636 }, + { + "name": "prodsched", + "type": "observed_schedule", + "index_type": "i64", + "key_names": ["name"], + "key_types": ["name"], + "table_id": 35433 + }, + { + "name": "prodscorecfg", + "type": "producer_score_config", + "index_type": "i64", + "key_names": ["name"], + "key_types": ["name"], + "table_id": 26071 + }, { "name": "producers", "type": "producer_info", diff --git a/contracts/sysio.system/sysio.system.wasm b/contracts/sysio.system/sysio.system.wasm index de043a2f738e1d26b4e3a500f4a370bb7ae05184..d0fa6834c2069253c0562f3eb359ea81fe5d2b2b 100755 GIT binary patch delta 75140 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%0AA-1^Kh4+ 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 diff --git a/contracts/tests/CMakeLists.txt b/contracts/tests/CMakeLists.txt index 24f1b9c22d..af3f399998 100644 --- a/contracts/tests/CMakeLists.txt +++ b/contracts/tests/CMakeLists.txt @@ -32,7 +32,23 @@ target_include_directories(${CONTRACT_UNIT_TEST_EXE} PRIVATE ${CMAKE_SOURCE_DIR}/libraries/opp/test/include) # add_sysio_test_executable(${CONTRACT_UNIT_TEST_EXE} ${UNIT_TESTS}) # build unit tests as one executable -add_test(NAME ${CONTRACT_UNIT_TEST_EXE} COMMAND ${CONTRACT_UNIT_TEST_EXE} WORKING_DIRECTORY ${CMAKE_BINARY_DIR}) -# Snapshot-attestation cases exercise the production 25,000-block cadence. Keep this aggregate -# binary parallelizable while allowing sanitizer configurations enough time to complete it. -set_tests_properties(${CONTRACT_UNIT_TEST_EXE} PROPERTIES TIMEOUT ${CONTRACT_UNIT_TEST_TIMEOUT_SECONDS}) +# The snapshot-attestation suite runs as its OWN ctest entry instead of inside the aggregate one. +# Reaching an attestable height means building the production 25,000-block cadence, which costs +# more than the other ~685 cases put together -- inline, it made this single entry the critical +# path of the whole parallel lane and pushed it against its timeout under CI contention. The two +# filters are exact complements, so every case still runs in the same gate; the pair now finishes +# in the time the slower half takes rather than the sum. +set(CONTRACT_SNAPSHOT_ATTEST_SUITE sysio_snapshot_attest_tests) +set(CONTRACT_SNAPSHOT_ATTEST_TEST contracts_snapshot_attest_test) + +add_test(NAME ${CONTRACT_UNIT_TEST_EXE} + COMMAND ${CONTRACT_UNIT_TEST_EXE} --run_test=!${CONTRACT_SNAPSHOT_ATTEST_SUITE} + WORKING_DIRECTORY ${CMAKE_BINARY_DIR}) +add_test(NAME ${CONTRACT_SNAPSHOT_ATTEST_TEST} + COMMAND ${CONTRACT_UNIT_TEST_EXE} --run_test=${CONTRACT_SNAPSHOT_ATTEST_SUITE} + WORKING_DIRECTORY ${CMAKE_BINARY_DIR}) + +# Both halves keep the full budget: they are the same binary, and a sanitizer configuration slows +# either one well past the parallel lane's default 1000 s. +set_tests_properties(${CONTRACT_UNIT_TEST_EXE} ${CONTRACT_SNAPSHOT_ATTEST_TEST} + PROPERTIES TIMEOUT ${CONTRACT_UNIT_TEST_TIMEOUT_SECONDS}) diff --git a/contracts/tests/emissions_tests.cpp b/contracts/tests/emissions_tests.cpp index 011e2d7117..943704a02f 100644 --- a/contracts/tests/emissions_tests.cpp +++ b/contracts/tests/emissions_tests.cpp @@ -34,6 +34,8 @@ #include #include "sysio.system_tester.hpp" +#include + #include "finalizer_test_keys.hpp" #include @@ -771,6 +773,59 @@ class sysio_emissions_tester : public tester { // // If `register_opreg` is false, the caller is exercising the filter and will // handle opreg registration manually (e.g. to test a slashed operator). + /// Derive a producer's rank -- POSITION in the score-ordered index, counting from 1. + /// + /// `rank` is no longer a stored field: it is position in the "prodrank" index among schedulable + /// producers. A test that asserts on rank therefore reproduces the contract's own ordering -- + /// ascending `rank_score`, ties broken by account name (the primary key). Scans the fixture's + /// `producer_name_at` roster, which is every producer these fixtures create. + /// + /// @param target the producer whose position is wanted. + /// @param scan how many roster slots to consider. + /// @return the 1-based position, or 0 when the producer holds none. + uint32_t producer_rank_position(account_name target, uint32_t scan = 40) { + std::vector> ordered; + for (uint32_t i = 0; i < scan; ++i) { + auto candidate = producer_name_at(i); + auto info = get_producer_info(candidate); + if (info.is_null()) continue; + if (!info["is_active"].as()) continue; + ordered.emplace_back(info["rank_score"].as(), candidate.to_uint64_t()); + } + std::sort(ordered.begin(), ordered.end()); + for (uint32_t i = 0; i < ordered.size(); ++i) { + if (ordered[i].second == target.to_uint64_t()) return i + 1; + } + return 0; + } + + action_result register_finalizer_key(account_name act, const std::string& key, const std::string& pop) { + return push_system_action(act, "regfinkey"_n, mvo() + ("finalizer_name", act)("finalizer_key", key)("proof_of_possession", pop)); + } + + /// Register an active finalizer key for each of the first `count` names, and configure the node + /// to vote with them. regfinkey auto-activates a producer's first key, which is what + /// `producer_rank::is_schedulable` requires -- a producer without one occupies no rank position, + /// so it is neither scheduled nor paid. + /// + /// The keys come from `get_bls_key(name)`, which the tester HOLDS the private half of. That is + /// load-bearing: update_ranked_producers proposes a finalizer policy built from the registered + /// keys, and a policy this node cannot sign for stops it voting -- LIB freezes, and a frozen LIB + /// means a pending producer schedule never becomes final and so never activates. Deriving from + /// the account name also gives a distinct key per producer, satisfying regfinkey's global + /// uniqueness check without a fixed key table. + void register_finalizer_keys(const std::vector& names, uint32_t count) { + std::vector registered; + for (uint32_t i = 0; i < count && i < names.size(); ++i) { + auto [privkey, pubkey, pop, sig_provider] = sysio::testing::get_bls_key(names[i]); + BOOST_REQUIRE_EQUAL(success(), + register_finalizer_key(names[i], pubkey.to_string(), pop.to_string())); + registered.push_back(names[i]); + } + set_node_finalizers(registered); + } + void setup_producers( uint32_t count, bool register_opreg = true ) { std::vector prod_names; for (uint32_t i = 0; i < count; ++i) { @@ -805,6 +860,19 @@ class sysio_emissions_tester : public tester { produce_blocks(1); } + // Every producer needs an active finalizer key: rank is position among SCHEDULABLE producers, + // and a producer without one holds no position -- so it is neither scheduled nor paid. + // regfinkey stores a row on the producer, which needs RAM this fixture does not otherwise + // grant (it does not activate the ROA / RAM market). + for (auto& pname : prod_names) { + BOOST_REQUIRE_EQUAL(success(), push_system_action(config::system_account_name, "setacctram"_n, + mvo()("account", pname)("ram_bytes", int64_t(1'000'000)))); + } + produce_blocks(1); + + register_finalizer_keys(prod_names, count); + produce_blocks(1); + // Build schedule and call setprodkeys set_producer_schedule(prod_names); produce_blocks(1); @@ -3134,7 +3202,7 @@ BOOST_FIXTURE_TEST_CASE( standby_paid_without_block_check, sysio_emissions_teste // Verify the standby producer has rank 22 auto standby_info = get_producer_info(standby_name); BOOST_REQUIRE( !standby_info.is_null() ); - uint32_t standby_rank = standby_info["rank"].as(); + uint32_t standby_rank = producer_rank_position(standby_name); BOOST_REQUIRE( standby_rank >= T_STANDBY_START_RANK && standby_rank <= T_STANDBY_END_RANK ); BOOST_REQUIRE_EQUAL( success(), advance_epoch_state() ); @@ -3248,8 +3316,17 @@ BOOST_FIXTURE_TEST_CASE( producer_promoted_mid_epoch, sysio_emissions_tester ) t wait_for_producer_schedule(); produce_complete_cycles(21, 1); // 1 cycle sufficient - // Promote the standby (rank 22) to active by replacing producera in the schedule - // New schedule: producers b..v + standby producer (index 21) + // Promote the standby (position 22) into the active band. Rank is POSITION in the score-ordered + // index, so governance can no longer hand out ranks -- `setprodkeys` proposes a schedule and + // nothing more. The lever that actually moves a position is the set of schedulable producers: + // unregistering the producer holding position 1 shifts every later producer up by one, promoting + // the standby at 22 into 21. + BOOST_REQUIRE_EQUAL( success(), push_system_action(producer_name_at(0), "unregprod"_n, + mvo()("producer", producer_name_at(0))) ); + produce_blocks(1); + + // `setprodkeys` still publishes a schedule -- it simply no longer assigns ranks -- so it stays + // the way this fixture puts the promoted producer on the roster that produces blocks. std::vector new_schedule; for (uint32_t i = 1; i <= 21; ++i) { new_schedule.push_back(producer_name_at(i)); @@ -3268,7 +3345,7 @@ BOOST_FIXTURE_TEST_CASE( producer_promoted_mid_epoch, sysio_emissions_tester ) t name promoted = producer_name_at(21); // "producerv" auto promoted_info = get_producer_info(promoted); BOOST_REQUIRE( !promoted_info.is_null() ); - uint32_t promoted_rank = promoted_info["rank"].as(); + uint32_t promoted_rank = producer_rank_position(promoted); BOOST_REQUIRE( promoted_rank >= 1 && promoted_rank <= T_ACTIVE_PRODUCER_COUNT ); asset promoted_before = get_wire_balance_paid(promoted); @@ -3279,9 +3356,9 @@ BOOST_FIXTURE_TEST_CASE( producer_promoted_mid_epoch, sysio_emissions_tester ) t BOOST_REQUIRE( promoted_got > 0 ); } FC_LOG_AND_RETHROW() -BOOST_FIXTURE_TEST_CASE( producer_demoted_mid_epoch, sysio_emissions_tester ) try { - // Producer starts as active, accumulates eligible_rounds, then gets demoted to standby - // At epoch end, treated as standby → full standby weight (no performance check) +BOOST_FIXTURE_TEST_CASE( producer_unregistered_mid_epoch, sysio_emissions_tester ) try { + // Producer starts as active and accumulates eligible_rounds, then unregisters mid-epoch. + // It holds no rank position at epoch end, so it draws neither active nor standby pay. create_t5_holding_accounts(); // Start with 22 producers: 21 active + 1 standby @@ -3289,12 +3366,12 @@ BOOST_FIXTURE_TEST_CASE( producer_demoted_mid_epoch, sysio_emissions_tester ) tr wait_for_producer_schedule(); produce_complete_cycles(21, 1); // producera accumulates eligible_rounds - // Demote producera: new schedule replaces producera with the standby - std::vector new_schedule; - for (uint32_t i = 1; i <= 21; ++i) { - new_schedule.push_back(producer_name_at(i)); - } - BOOST_REQUIRE_EQUAL( success(), set_producer_schedule(new_schedule) ); + // Take producera out of the schedulable set. Rank is POSITION in the score-ordered index, so + // governance cannot demote a producer by republishing a schedule -- `setprodkeys` proposes and + // nothing more. `unregprod` is the real lever: it clears `is_active`, which drops the producer + // out of every rank position. + BOOST_REQUIRE_EQUAL( success(), push_system_action("producera"_n, "unregprod"_n, + mvo()("producer", "producera"_n)) ); produce_blocks(1); wait_for_producer_schedule(); produce_complete_cycles(21, 1); @@ -3302,20 +3379,18 @@ BOOST_FIXTURE_TEST_CASE( producer_demoted_mid_epoch, sysio_emissions_tester ) tr const uint32_t start = head_secs() - ONE_EPOCH - 1; BOOST_REQUIRE_EQUAL( success(), initt5( config::system_account_name, tpsec(start) ) ); - // producera should now be demoted (rank 22+) - auto pa_info = get_producer_info("producera"_n); - BOOST_REQUIRE( !pa_info.is_null() ); - uint32_t pa_rank = pa_info["rank"].as(); - BOOST_REQUIRE( pa_rank >= T_STANDBY_START_RANK ); + // An unregistered producer holds NO rank position -- it is not merely pushed into the standby + // band. Displacement into standby by a higher-scoring producer is a different scenario, covered + // by the collateral-ordering tests. + uint32_t pa_rank = producer_rank_position("producera"_n); + BOOST_REQUIRE_EQUAL( 0u, pa_rank ); asset demoted_before = get_wire_balance_paid("producera"_n); BOOST_REQUIRE_EQUAL( success(), advance_epoch_state() ); - if (pa_rank <= T_STANDBY_END_RANK) { - // Treated as standby → gets standby weight share (no performance check) - int64_t demoted_got = get_wire_balance_paid("producera"_n).get_amount() - demoted_before.get_amount(); - BOOST_REQUIRE( demoted_got > 0 ); - } + // No position means no pay -- neither the active share nor the standby weight. The + // eligible_rounds it accumulated before unregistering do not survive losing its slot. + BOOST_REQUIRE_EQUAL( get_wire_balance_paid("producera"_n), demoted_before ); } FC_LOG_AND_RETHROW() BOOST_FIXTURE_TEST_CASE( producer_replaced_mid_epoch, sysio_emissions_tester ) try { @@ -3351,7 +3426,7 @@ BOOST_FIXTURE_TEST_CASE( producer_replaced_mid_epoch, sysio_emissions_tester ) t // Verify: old producer (now standby) gets standby pay if in range auto pa_info = get_producer_info("producera"_n); - uint32_t pa_rank = pa_info["rank"].as(); + uint32_t pa_rank = producer_rank_position("producera"_n); if (pa_rank <= T_STANDBY_END_RANK) { int64_t old_got = get_wire_balance_paid("producera"_n).get_amount() - old_before.get_amount(); BOOST_REQUIRE( old_got > 0 ); @@ -3530,7 +3605,7 @@ BOOST_FIXTURE_TEST_CASE( all_actives_excluded_standbys_still_paid, sysio_emissio // Standby should get paid (no block production check for standbys) auto standby_info = get_producer_info(standby); - uint32_t standby_rank = standby_info["rank"].as(); + uint32_t standby_rank = producer_rank_position(standby); if (standby_rank >= T_STANDBY_START_RANK && standby_rank <= T_STANDBY_END_RANK) { int64_t standby_got = get_wire_balance_paid(standby).get_amount() - standby_before.get_amount(); BOOST_REQUIRE( standby_got > 0 ); @@ -4126,10 +4201,10 @@ BOOST_FIXTURE_TEST_CASE( rank_29_and_above_get_nothing, sysio_emissions_tester ) auto b2_info = get_producer_info(beyond2); // Only check if their rank is actually > 28 - if (!b1_info.is_null() && b1_info["rank"].as() > T_STANDBY_END_RANK) { + if (!b1_info.is_null() && producer_rank_position(beyond1) > T_STANDBY_END_RANK) { BOOST_REQUIRE_EQUAL( get_wire_balance_paid(beyond1), beyond1_before ); } - if (!b2_info.is_null() && b2_info["rank"].as() > T_STANDBY_END_RANK) { + if (!b2_info.is_null() && producer_rank_position(beyond2) > T_STANDBY_END_RANK) { BOOST_REQUIRE_EQUAL( get_wire_balance_paid(beyond2), beyond2_before ); } } FC_LOG_AND_RETHROW() @@ -4152,7 +4227,7 @@ BOOST_FIXTURE_TEST_CASE( rank_28_standby_gets_minimum_weight, sysio_emissions_te BOOST_REQUIRE_EQUAL( success(), advance_epoch_state() ); auto info = get_producer_info(last_standby); - if (!info.is_null() && info["rank"].as() == T_STANDBY_END_RANK) { + if (!info.is_null() && producer_rank_position(last_standby) == T_STANDBY_END_RANK) { int64_t got = get_wire_balance_paid(last_standby).get_amount() - last_before.get_amount(); BOOST_REQUIRE( got > 0 ); // weight = 1, should still get paid } @@ -5044,34 +5119,18 @@ struct producer_eligibility_tester : public sysio_emissions_tester { produce_blocks(1); } - action_result register_finalizer_key(account_name act, const std::string& key, const std::string& pop) { - return push_system_action(act, "regfinkey"_n, mvo() - ("finalizer_name", act)("finalizer_key", key)("proof_of_possession", pop)); - } - - /// Assigns key_pairs[i] to names[i] for the first `count` producers. regfinkey - /// auto-activates a producer's first key, satisfying the schedule's finalizer gate. - void register_finalizer_keys(const std::vector& names, uint32_t count) { - for (uint32_t i = 0; i < count && i < names.size(); ++i) { - BOOST_REQUIRE_EQUAL(success(), - register_finalizer_key(names[i], sysio_test::key_pairs[i].pub_key, sysio_test::key_pairs[i].pop)); - } - } - - action_result setrank(account_name producer, uint32_t rank) { - return push_system_action(config::system_account_name, "setrank"_n, mvo() - ("producer", producer)("rank", rank)); - } - action_result terminate_operator(account_name account, const std::string& reason = "test terminate") { return push_opreg_action(OPREG, "terminate"_n, mvo()("account", account)("reason", reason)); } /// Registers `count` producers, each an ACTIVE bootstrapped PRODUCER operator - /// with an active finalizer key, ranked 1..count via setrank. setrank is used - /// rather than setprodkeys because setprodkeys publishes the whole set through - /// set_proposed_producers, which the native layer caps at max_producers; this - /// helper must be able to seed standby ranks (> max_producers) for backfill. + /// with an active finalizer key. + /// + /// Rank is POSITION in the score-ordered "prodrank" index, not a stored ordinal, so nothing + /// assigns it here. Every producer registered by this fixture is bootstrapped and holds no + /// collateral, so they all land in the bootstrapped tier with an identical composite score -- + /// and equal keys fall back to primary-key order, which is account-name order. `producer_name_at` + /// yields ascending names, so positions 1..count follow the index order the caller expects. /// The producer/finalizer rows are populated but no schedule is published /// until the caller triggers update_ranked_producers via trigger_reschedule(). std::vector setup_ranked_producers(uint32_t count) { @@ -5084,9 +5143,6 @@ struct producer_eligibility_tester : public sysio_emissions_tester { for (auto& p : names) { BOOST_REQUIRE_EQUAL(success(), register_operator(p, OperatorType::OPERATOR_TYPE_PRODUCER, true)); } - for (uint32_t i = 0; i < count; ++i) { - BOOST_REQUIRE_EQUAL(success(), setrank(names[i], i + 1)); - } register_finalizer_keys(names, count); produce_blocks(1); return names; @@ -5169,7 +5225,6 @@ BOOST_FIXTURE_TEST_CASE( noncollateralized_producer_not_scheduled_then_restored, for (uint32_t i = 0; i < 4; ++i) { BOOST_REQUIRE_EQUAL( success(), register_operator(names[i], OperatorType::OPERATOR_TYPE_PRODUCER, true) ); } - for (uint32_t i = 0; i < 5; ++i) BOOST_REQUIRE_EQUAL( success(), setrank(names[i], i + 1) ); register_finalizer_keys(names, 5); produce_blocks(1); trigger_reschedule(); @@ -5197,7 +5252,6 @@ BOOST_FIXTURE_TEST_CASE( active_batch_operator_not_scheduled_as_producer, produc } // names[4] is ACTIVE, but as a BATCH operator -- wrong type for producing. BOOST_REQUIRE_EQUAL( success(), register_operator(names[4], OperatorType::OPERATOR_TYPE_BATCH, true) ); - for (uint32_t i = 0; i < 5; ++i) BOOST_REQUIRE_EQUAL( success(), setrank(names[i], i + 1) ); register_finalizer_keys(names, 5); produce_blocks(1); trigger_reschedule(); @@ -5247,3 +5301,571 @@ BOOST_FIXTURE_TEST_CASE( schedule_not_shrunk_below_floor, producer_eligibility_t } FC_LOG_AND_RETHROW() BOOST_AUTO_TEST_SUITE_END() // sysio_producer_eligibility_tests + +// =========================================================================== +// Producer SCORE, tiers and demotion (sysio_producer_score_tests) +// +// The suite above asserts WHO is schedulable. This one asserts the ORDER they +// are schedulable in, and the demotion model that can take a producer out of +// the schedule with no governance action at all. +// +// Nothing here reads a `rank` field, because none exists: rank is POSITION in +// the "prodrank" index among schedulable producers. What IS stored is +// `rank_score`, the packed key that index sorts on -- +// `tier << 62 | (composite_max - composite)`. Two consequences drive every +// assertion below: a HIGHER composite is a NUMERICALLY LOWER key, and a higher +// tier outweighs any composite whatsoever, which is what makes the uncapped +// collateral term safe. +// +// A collateral-backed producer needs an opreg `opconfig` row to exist at all -- +// with none, `req_prod_collat` reads empty and `meets_role_min` refuses every +// non-bootstrapped operator by design (SEC-22). That is why the eligibility +// fixture above registers everything bootstrapped, and why this fixture's +// collateral helpers install a config first. +// =========================================================================== + +struct producer_score_tester : public producer_eligibility_tester { + + /// Packed-key tier values, mirroring `producer_tier` in producer_rank.hpp. + static constexpr uint64_t tier_healthy = 0; + static constexpr uint64_t tier_bootstrapped = 1; + static constexpr uint64_t tier_demoted = 2; + + /// Bits the packed key gives the composite; the tier occupies the two above them. + static constexpr unsigned composite_bits = 62; + + /// Slots one producer holds before the round-robin rotates (config::producer_repetitions). + static constexpr uint32_t slots_per_producer = 12; + + /// Chain/token pair the collateral helpers use. Any pair works -- opreg stores slug names + /// opaquely -- so these name a plausible outpost rather than carrying meaning. + static constexpr std::string_view collateral_chain = "ETH"; + static constexpr std::string_view collateral_token = "ETH"; + + /// A second pair, for the "minimum across pairs" case. + static constexpr std::string_view second_chain = "SOL"; + static constexpr std::string_view second_token = "SOL"; + + /// The minimum bond every collateral test measures its ratios against. + static constexpr uint64_t base_min_bond = 1'000'000; + + /// The tier packed into a `rank_score`, mirroring `producer_rank::tier_of`. + static uint64_t tier_of(uint64_t rank_score) { return rank_score >> composite_bits; } + + /// A `slug_name` in the shape the ABI serializes it: a single `value` field. + static fc::mutable_variant_object slug_mvo(std::string_view code) { + return mvo()("value", fc::slug_name{code}.value); + } + + /// One `(chain, token, min_bond)` entry for opreg's `req_*_collat` vectors. The + /// `config_timestamp_ms` supplied here is ignored -- `setconfig` overwrites it with on-chain + /// time so consumers never trust the caller's clock. + static fc::variant min_bond_mvo(std::string_view chain, std::string_view token, uint64_t min_bond) { + return fc::variant(mvo() + ("chain_code", slug_mvo(chain)) + ("token_code", slug_mvo(token)) + ("min_bond", min_bond) + ("config_timestamp_ms", uint64_t{0})); + } + + /// Install an opreg configuration carrying `req_prod_collat`. + /// + /// @param req_prod_collat producer collateral requirement, built from `min_bond_mvo`. + /// @return the action result. + action_result set_producer_collateral(const fc::variants& req_prod_collat) { + return push_opreg_action(OPREG, "setconfig"_n, mvo() + ("max_available_producers", uint32_t{21}) + ("max_available_batch_ops", uint32_t{63}) + ("max_available_underwriters", uint32_t{21}) + ("terminate_prune_delay_ms", uint64_t{600'000}) + ("terminate_max_consecutive_misses", uint32_t{5}) + ("terminate_max_pct_misses_24h", uint32_t{5}) + ("terminate_window_ms", uint64_t{24ULL * 60 * 60 * 1000}) + ("req_prod_collat", req_prod_collat) + ("req_batchop_collat", fc::variants{}) + ("req_uw_collat", fc::variants{})); + } + + /// The single-pair requirement most tests use. + action_result set_single_pair_collateral(uint64_t min_bond = base_min_bond) { + return set_producer_collateral(fc::variants{ + min_bond_mvo(collateral_chain, collateral_token, min_bond)}); + } + + /// Credit an outpost-side collateral row the way `sysio.msgch` does when it dispatches an + /// inbound DEPOSIT_REQUEST. Signing as sysio.opreg satisfies `depositinle`'s + /// `require_auth(get_self())`. + /// + /// This is the seam the score hangs off: a credit runs `reevaluate_eligibility`, which + /// dispatches `processprod` for producers on EVERY balance change, whose notification + /// sysio.system turns into a rescore. + action_result credit_collateral(account_name account, uint64_t amount, + std::string_view chain = collateral_chain, + std::string_view token = collateral_token) { + return push_opreg_action(OPREG, "depositinle"_n, mvo() + ("account", account) + ("chain_code", slug_mvo(chain)) + ("token_code", slug_mvo(token)) + ("amount", amount) + ("actor_chain", ChainKind::CHAIN_KIND_EVM) + ("actor_address", std::vector(20, '\x06')) + ("original_message_id", fc::sha256())); + } + + /// Push `setscorecfg`. Defaults mirror the contract's own so a test names only the weight it + /// is exercising. + action_result set_score_config(uint32_t collateral_weight = 10'000, + uint32_t participation_weight = 10'000, + uint32_t snapshot_weight = 10'000, + uint32_t max_consecutive_missed_rounds = 3, + uint32_t snapshot_target_attestations = 1) { + return push_system_action(config::system_account_name, "setscorecfg"_n, mvo() + ("weights", mvo() + ("collateral_weight", collateral_weight) + ("participation_weight", participation_weight) + ("snapshot_weight", snapshot_weight) + ("relay_weight", uint32_t{0}) + ("api_weight", uint32_t{0}) + ("benchmark_weight", uint32_t{0}) + ("max_consecutive_missed_rounds", max_consecutive_missed_rounds) + ("snapshot_target_attestations", snapshot_target_attestations))); + } + + /// The packed sort key stored on a producer. + uint64_t rank_score_of(account_name producer) { + auto info = get_producer_info(producer); + BOOST_REQUIRE_MESSAGE(!info.is_null(), "no producers row for " << producer.to_string()); + return info["rank_score"].as(); + } + + uint32_t missed_rounds_of(account_name producer) { + auto info = get_producer_info(producer); + BOOST_REQUIRE_MESSAGE(!info.is_null(), "no producers row for " << producer.to_string()); + return info["consecutive_missed_rounds"].as(); + } + + bool demoted(account_name producer) { + auto info = get_producer_info(producer); + BOOST_REQUIRE_MESSAGE(!info.is_null(), "no producers row for " << producer.to_string()); + return info["is_demoted"].as(); + } + + /// The sysio.system global singleton, which carries the rescore cursor. + fc::variant get_global_state() { + auto data = get_row_by_account(config::system_account_name, config::system_account_name, + "global"_n, "global"_n); + if (data.empty()) return fc::variant(); + return sysio_abi_ser.binary_to_variant("sysio_global_state", data, + abi_serializer::create_yield_function(abi_serializer_max_time)); + } + + uint32_t rescore_generation() { + auto g = get_global_state(); + BOOST_REQUIRE(!g.is_null()); + return g["rescore_generation"].as(); + } + + /// Register `count` producers as NON-bootstrapped operators, each bonded at `deposit` on the + /// single required pair, with an active finalizer key. + /// + /// Order is load-bearing: `regproducer` must precede the deposit, because a rescore is a no-op + /// while no producers row exists, and the deposit's `processprod` notification is what writes + /// the first real score. + /// + /// @param count how many producers to create, from the fixture's roster. + /// @param deposit the bond credited to each, in the same units as the configured minimum. + /// @return the producer names, in roster (and therefore name) order. + std::vector setup_collateralized_producers(uint32_t count, + uint64_t deposit = base_min_bond) { + BOOST_REQUIRE_EQUAL(success(), set_single_pair_collateral()); + produce_blocks(1); + + auto names = producer_names(count); + create_producer_accounts(names); + for (auto& p : names) { + BOOST_REQUIRE_EQUAL(success(), push_system_action(p, "regproducer"_n, mvo() + ("producer", p)("producer_key", get_public_key(p, "active"))("url", "")("location", 0))); + } + for (auto& p : names) { + BOOST_REQUIRE_EQUAL(success(), register_operator(p, OperatorType::OPERATOR_TYPE_PRODUCER, false)); + } + for (auto& p : names) { + BOOST_REQUIRE_EQUAL(success(), credit_collateral(p, deposit)); + } + produce_blocks(1); + for (auto& p : names) { + auto op = get_opreg_operator(p); + BOOST_REQUIRE_MESSAGE(!op.is_null(), "no opreg row for " << p.to_string()); + BOOST_REQUIRE_EQUAL("OPERATOR_STATUS_ACTIVE", op["status"].as_string()); + } + register_finalizer_keys(names, count); + produce_blocks(1); + return names; + } + + /// The active producer schedule, in schedule order. + std::vector active_schedule_names() { + std::vector names; + for (const auto& p : control->active_producers().producers) { + names.push_back(p.producer_name); + } + return names; + } + + /// Produce until `expected` is in the ACTIVE schedule -- not merely proposed. + /// + /// Miss attribution reads the live schedule, so these tests need the proposal to have gone + /// final and activated, which the eligibility suite's `is_scheduled` deliberately does not + /// wait for. + void wait_for_active_schedule(account_name expected, uint32_t max_blocks = 400) { + for (uint32_t produced = 0; produced < max_blocks; ++produced) { + const auto schedule = active_schedule_names(); + if (std::find(schedule.begin(), schedule.end(), expected) != schedule.end()) return; + produce_blocks(1); + } + BOOST_FAIL("producer " << expected.to_string() << " never entered the active schedule"); + } + + /// Skip `target`'s entire slot window so it produces nothing and is charged a missed round. + /// + /// A tester produces every scheduled block, so a miss has to be manufactured: advance one + /// window at a time until the producer immediately BEFORE the target holds the head block, + /// then jump the rest of that window plus the target's whole window in one step. The next + /// block therefore belongs to the producer AFTER the target, and the contract's walk from the + /// previous producer to this one finds exactly the target in between. + /// + /// @param target the producer whose round should go unproduced. + void skip_round_of(account_name target) { + const auto schedule = active_schedule_names(); + BOOST_REQUIRE_MESSAGE(schedule.size() >= 3, + "skipping a round needs at least three scheduled producers"); + + const auto target_it = std::find(schedule.begin(), schedule.end(), target); + BOOST_REQUIRE_MESSAGE(target_it != schedule.end(), + target.to_string() << " is not in the active schedule"); + const size_t target_index = static_cast(std::distance(schedule.begin(), target_it)); + const size_t before_index = (target_index + schedule.size() - 1) % schedule.size(); + + // Producer for a slot, exactly as the chain assigns it. + const auto index_at = [&](uint32_t slot) { + return (slot % (schedule.size() * slots_per_producer)) / slots_per_producer; + }; + + // Advance to the window immediately before the target's. Bounded by one full rotation plus + // a window, so a schedule change mid-walk fails loudly rather than spinning. + const uint32_t walk_limit = static_cast(schedule.size() + 1) * slots_per_producer; + uint32_t walked = 0; + while (index_at(control->head().header().timestamp.slot) != before_index) { + produce_blocks(1); + BOOST_REQUIRE_MESSAGE(++walked < walk_limit, + "never reached the window before " << target.to_string()); + } + + const uint32_t slot = control->head().header().timestamp.slot; + const uint32_t into_window = slot % slots_per_producer; + const uint32_t jump = (slots_per_producer - into_window) + slots_per_producer; + produce_block(fc::milliseconds(int64_t(config::block_interval_ms) * jump)); + + BOOST_REQUIRE_MESSAGE(control->head().header().producer != target, + "the jump landed on " << target.to_string() << " instead of skipping it"); + } +}; + +BOOST_AUTO_TEST_SUITE(sysio_producer_score_tests) + +// --------------------------------------------------------------------------- +// Composite score +// --------------------------------------------------------------------------- + +// Collateral is linear and uncapped, so a top-up strictly improves the score and moves the +// producer up the index -- the "producers compete for rank by posting more" property. It also +// covers the top-up seam: a deposit while already ACTIVE changes no status, and only reaches +// sysio.system because producers dispatch `processprod` on every balance change. +BOOST_FIXTURE_TEST_CASE( collateral_topup_raises_score_and_position, producer_score_tester ) try { + auto names = setup_collateralized_producers(5); + + // Equal bonds, so the composite is equal and the index falls through to account-name order. + const uint64_t before = rank_score_of(names[4]); + BOOST_REQUIRE_EQUAL( before, rank_score_of(names[0]) ); + BOOST_REQUIRE_EQUAL( 5u, producer_rank_position(names[4]) ); + + BOOST_REQUIRE_EQUAL( success(), credit_collateral(names[4], base_min_bond * 4) ); + produce_blocks(1); + + // A higher composite is a numerically LOWER key, and the last-by-name producer is now first. + BOOST_REQUIRE_LT( rank_score_of(names[4]), before ); + BOOST_REQUIRE_EQUAL( 1u, producer_rank_position(names[4]) ); + BOOST_REQUIRE_EQUAL( 2u, producer_rank_position(names[0]) ); +} FC_LOG_AND_RETHROW() + +// The collateral factor is the MINIMUM across the required pairs, with no sum term: posting extra +// on the cheapest chain must do nothing at all, so raising the score requires lifting EVERY pair. +BOOST_FIXTURE_TEST_CASE( collateral_is_minimum_across_required_pairs, producer_score_tester ) try { + BOOST_REQUIRE_EQUAL( success(), set_producer_collateral(fc::variants{ + min_bond_mvo(collateral_chain, collateral_token, base_min_bond), + min_bond_mvo(second_chain, second_token, base_min_bond)}) ); + produce_blocks(1); + + auto names = producer_names(2); + create_producer_accounts(names); + for (auto& p : names) { + BOOST_REQUIRE_EQUAL( success(), push_system_action(p, "regproducer"_n, mvo() + ("producer", p)("producer_key", get_public_key(p, "active"))("url", "")("location", 0)) ); + BOOST_REQUIRE_EQUAL( success(), register_operator(p, OperatorType::OPERATOR_TYPE_PRODUCER, false) ); + BOOST_REQUIRE_EQUAL( success(), credit_collateral(p, base_min_bond) ); + BOOST_REQUIRE_EQUAL( success(), credit_collateral(p, base_min_bond, second_chain, second_token) ); + } + register_finalizer_keys(names, 2); + produce_blocks(1); + BOOST_REQUIRE_EQUAL( rank_score_of(names[0]), rank_score_of(names[1]) ); + + // Ten times the bond on ONE pair leaves the minimum -- and so the score -- untouched. + BOOST_REQUIRE_EQUAL( success(), credit_collateral(names[1], base_min_bond * 9) ); + produce_blocks(1); + BOOST_REQUIRE_EQUAL( rank_score_of(names[0]), rank_score_of(names[1]) ); + + // Lifting the OTHER pair moves the minimum, and only then does the score improve. + BOOST_REQUIRE_EQUAL( success(), credit_collateral(names[1], base_min_bond, second_chain, second_token) ); + produce_blocks(1); + BOOST_REQUIRE_LT( rank_score_of(names[1]), rank_score_of(names[0]) ); +} FC_LOG_AND_RETHROW() + +// A weight of zero removes its factor's influence entirely -- the property that lets a new factor +// ship at weight 0 without disturbing any existing ordering. +BOOST_FIXTURE_TEST_CASE( zero_weight_removes_factor_influence, producer_score_tester ) try { + auto names = setup_collateralized_producers(3); + BOOST_REQUIRE_EQUAL( success(), credit_collateral(names[2], base_min_bond * 20) ); + produce_blocks(1); + BOOST_REQUIRE_LT( rank_score_of(names[2]), rank_score_of(names[0]) ); + + BOOST_REQUIRE_EQUAL( success(), set_score_config(/*collateral_weight=*/0) ); + trigger_reschedule(); + + // Twenty times the bond now buys nothing: every producer carries the same composite. + BOOST_REQUIRE_EQUAL( rank_score_of(names[0]), rank_score_of(names[2]) ); + BOOST_REQUIRE_EQUAL( rank_score_of(names[1]), rank_score_of(names[2]) ); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// Rescore sweep +// --------------------------------------------------------------------------- + +// A weight change invalidates every stored score at once, and `producers` is unbounded because +// `regproducer` is permissionless -- so the rewrite is a cursor `onblock` drains a bounded slice +// of per schedule-rebuild tick. With more producers than one slice holds, the sweep must survive +// across ticks: still in progress after the first, finished after the second. +BOOST_FIXTURE_TEST_CASE( rescore_sweep_drains_across_ticks, producer_score_tester ) try { + constexpr uint32_t max_rescore_per_tick = 32; + constexpr uint32_t producer_count = max_rescore_per_tick + 2; + + auto names = setup_ranked_producers(producer_count); + trigger_reschedule(); + BOOST_REQUIRE_EQUAL( 0u, rescore_generation() ); + + BOOST_REQUIRE_EQUAL( success(), set_score_config(/*collateral_weight=*/5'000) ); + BOOST_REQUIRE( rescore_generation() != 0u ); + + trigger_reschedule(); + BOOST_REQUIRE_MESSAGE( rescore_generation() != 0u, + "a sweep of " << producer_count << " rows must not finish in one " + << max_rescore_per_tick << "-row tick" ); + + trigger_reschedule(); + BOOST_REQUIRE_EQUAL( 0u, rescore_generation() ); +} FC_LOG_AND_RETHROW() + +// The collateral minimums live on sysio.opreg, a contract sysio.system cannot be notified by +// without a ten-parameter handler that would still miss any future writer. Instead the throttled +// `onblock` tick compares the config's stamp against the one the stored scores were computed +// under, and opens a sweep when they differ. +BOOST_FIXTURE_TEST_CASE( collateral_minimum_change_opens_rescore_sweep, producer_score_tester ) try { + auto names = setup_collateralized_producers(3); + trigger_reschedule(); + BOOST_REQUIRE_EQUAL( 0u, rescore_generation() ); + const uint64_t before = rank_score_of(names[0]); + + // Halving the minimum doubles every ratio, so every stored score is now wrong. + BOOST_REQUIRE_EQUAL( success(), set_single_pair_collateral(base_min_bond / 2) ); + trigger_reschedule(); + + BOOST_REQUIRE_EQUAL( 0u, rescore_generation() ); // three rows drain in one tick + BOOST_REQUIRE_LT( rank_score_of(names[0]), before ); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// Tiers +// --------------------------------------------------------------------------- + +// A bootstrap is the foundation-run backstop, so every healthy producer outranks it -- and does so +// on the TIER, not on the composite: the bootstrap holds no collateral and could not close the gap +// by posting any, because the tier sits above the composite in the packed key. +BOOST_FIXTURE_TEST_CASE( healthy_tier_outranks_the_bootstrap_backstop, producer_score_tester ) try { + auto collateralized = setup_collateralized_producers(2); + + // A bootstrapped producer: ACTIVE by fiat, holding no collateral at all. + const auto bootstrap = producer_name_at(5); + create_producer_accounts({bootstrap}); + BOOST_REQUIRE_EQUAL( success(), push_system_action(bootstrap, "regproducer"_n, mvo() + ("producer", bootstrap)("producer_key", get_public_key(bootstrap, "active"))("url", "")("location", 0)) ); + BOOST_REQUIRE_EQUAL( success(), register_operator(bootstrap, OperatorType::OPERATOR_TYPE_PRODUCER, true) ); + produce_blocks(1); + + BOOST_REQUIRE_EQUAL( tier_healthy, tier_of(rank_score_of(collateralized[0])) ); + BOOST_REQUIRE_EQUAL( tier_bootstrapped, tier_of(rank_score_of(bootstrap)) ); + + // Every healthy producer outranks the bootstrap backstop, whatever the composites are. + BOOST_REQUIRE_LT( rank_score_of(collateralized[0]), rank_score_of(bootstrap) ); + BOOST_REQUIRE_LT( rank_score_of(collateralized[1]), rank_score_of(bootstrap) ); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// Missed rounds and demotion +// --------------------------------------------------------------------------- + +// Attribution is exact: skipping one producer's window charges that producer and nobody else, and +// producing clears the streak. +BOOST_FIXTURE_TEST_CASE( missed_round_is_charged_only_to_the_skipped_producer, producer_score_tester ) try { + auto names = setup_ranked_producers(5); + trigger_reschedule(); + wait_for_active_schedule(names[2]); + + for (const auto& p : names) BOOST_REQUIRE_EQUAL( 0u, missed_rounds_of(p) ); + + skip_round_of(names[2]); + + BOOST_REQUIRE_EQUAL( 1u, missed_rounds_of(names[2]) ); + for (const auto& p : names) { + if (p == names[2]) continue; + BOOST_REQUIRE_MESSAGE( missed_rounds_of(p) == 0u, + p.to_string() << " was charged a miss it did not earn" ); + } + + // One full rotation returns the skipped producer to its slot; producing resets the streak. + produce_blocks(names.size() * slots_per_producer + slots_per_producer); + BOOST_REQUIRE_EQUAL( 0u, missed_rounds_of(names[2]) ); +} FC_LOG_AND_RETHROW() + +// Demotion fires at EXACTLY the configured threshold -- not before -- and no amount of money +// survives it. The target below carries twenty times every other producer's bond, which buys it +// rank 1 while it is healthy and buys it nothing at all once it is demoted: the tier sits above +// the composite in the packed key, which is precisely what makes the uncapped collateral term safe. +BOOST_FIXTURE_TEST_CASE( demotion_fires_at_threshold_and_outweighs_collateral, producer_score_tester ) try { + auto names = setup_collateralized_producers(5); + const auto target = names[2]; + BOOST_REQUIRE_EQUAL( success(), credit_collateral(target, base_min_bond * 19) ); + produce_blocks(1); + BOOST_REQUIRE_EQUAL( 1u, producer_rank_position(target) ); + + trigger_reschedule(); + wait_for_active_schedule(target); + + for (uint32_t miss = 1; miss <= 3; ++miss) { + skip_round_of(target); + BOOST_REQUIRE_EQUAL( miss, missed_rounds_of(target) ); + BOOST_REQUIRE_MESSAGE( demoted(target) == (miss == 3), + "demotion at miss " << miss << " should be " << (miss == 3) ); + } + + BOOST_REQUIRE_EQUAL( tier_demoted, tier_of(rank_score_of(target)) ); + for (const auto& p : names) { + if (p == target) continue; + BOOST_REQUIRE_LT( rank_score_of(p), rank_score_of(target) ); + } + BOOST_REQUIRE_EQUAL( names.size(), producer_rank_position(target) ); // last, despite the bond +} FC_LOG_AND_RETHROW() + +// `regproducer` is the single door back, from an involuntary demotion as much as from a voluntary +// park. There is no cooldown and no expiry -- a demoted producer is scheduled for no rounds, so it +// could never clear the counter by producing. +BOOST_FIXTURE_TEST_CASE( regproducer_clears_demotion_immediately, producer_score_tester ) try { + auto names = setup_ranked_producers(5); + trigger_reschedule(); + wait_for_active_schedule(names[2]); + + const auto target = names[2]; + for (uint32_t miss = 0; miss < 3; ++miss) skip_round_of(target); + BOOST_REQUIRE( demoted(target) ); + + BOOST_REQUIRE_EQUAL( success(), push_system_action(target, "regproducer"_n, mvo() + ("producer", target)("producer_key", get_public_key(target, "active"))("url", "")("location", 0)) ); + produce_blocks(1); + + BOOST_REQUIRE( !demoted(target) ); + BOOST_REQUIRE_EQUAL( 0u, missed_rounds_of(target) ); + BOOST_REQUIRE_EQUAL( tier_bootstrapped, tier_of(rank_score_of(target)) ); +} FC_LOG_AND_RETHROW() + +// A voluntary park costs the producer its schedule slot and its rank position, but nothing else: +// its opreg status and bond are untouched, so it returns at the position its collateral earns. +BOOST_FIXTURE_TEST_CASE( unregprod_parks_without_touching_the_bond, producer_score_tester ) try { + auto names = setup_collateralized_producers(5); + trigger_reschedule(); + BOOST_REQUIRE( is_scheduled(names[0]) ); + const uint64_t before = rank_score_of(names[0]); + + BOOST_REQUIRE_EQUAL( success(), + push_system_action(names[0], "unregprod"_n, mvo()("producer", names[0])) ); + trigger_reschedule(); + + BOOST_REQUIRE( !is_scheduled(names[0]) ); + BOOST_REQUIRE_EQUAL( 0u, producer_rank_position(names[0]) ); // consumes no position + { + const auto op = get_opreg_operator(names[0]); + BOOST_REQUIRE_EQUAL( "OPERATOR_STATUS_ACTIVE", op["status"].as_string() ); + } + + BOOST_REQUIRE_EQUAL( success(), push_system_action(names[0], "regproducer"_n, mvo() + ("producer", names[0])("producer_key", get_public_key(names[0], "active"))("url", "")("location", 0)) ); + trigger_reschedule(); + + BOOST_REQUIRE( is_scheduled(names[0]) ); + BOOST_REQUIRE_EQUAL( before, rank_score_of(names[0]) ); // same bond, same position + BOOST_REQUIRE_EQUAL( 1u, producer_rank_position(names[0]) ); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// Position versus index slot +// --------------------------------------------------------------------------- + +// A permissionless `regproducer` with no bond behind it occupies an index slot but must consume no +// rank POSITION: it sinks to the demoted tier, so it sorts behind every real producer and every +// consumer's walk stops before reaching it. +BOOST_FIXTURE_TEST_CASE( unbonded_registrant_consumes_no_rank_position, producer_score_tester ) try { + // Five producers, not three: below min_schedule_size (4) update_ranked_producers retains the + // last good schedule instead of publishing, so the scheduling assertions below would be vacuous. + auto names = setup_collateralized_producers(5); + + // A registrant that never bonded: a producers row, no operator row at all. + const auto squatter = producer_name_at(5); + create_producer_accounts({squatter}); + BOOST_REQUIRE_EQUAL( success(), push_system_action(squatter, "regproducer"_n, mvo() + ("producer", squatter)("producer_key", get_public_key(squatter, "active"))("url", "")("location", 0)) ); + produce_blocks(1); + + BOOST_REQUIRE_EQUAL( tier_demoted, tier_of(rank_score_of(squatter)) ); + for (uint32_t i = 0; i < names.size(); ++i) { + BOOST_REQUIRE_EQUAL( i + 1, producer_rank_position(names[i]) ); + } + + trigger_reschedule(); + BOOST_REQUIRE( !is_scheduled(squatter) ); + BOOST_REQUIRE( is_scheduled(names[0]) ); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// Collateralising a bootstrap +// --------------------------------------------------------------------------- + +// A bootstrapped operator is ACTIVE by fiat and bypasses `meets_role_min` entirely, so collateral +// credited to one could never affect its eligibility -- the deposit would land in a balance that +// does nothing. `depositinle` already refused it; the WIRE-direct `deposit` now does too. There is +// deliberately no way to collateralise a bootstrap: an operator who wants one registers a new +// account. +BOOST_FIXTURE_TEST_CASE( deposit_rejects_a_bootstrapped_operator, producer_score_tester ) try { + auto names = setup_ranked_producers(1); + + BOOST_REQUIRE_EQUAL( wasm_assert_msg("bootstrapped operators cannot deposit collateral"), + push_opreg_action(names[0], "deposit"_n, mvo()("account", names[0])("amount", uint64_t{1'000})) ); +} FC_LOG_AND_RETHROW() + +BOOST_AUTO_TEST_SUITE_END() // sysio_producer_score_tests diff --git a/contracts/tests/getpeerkeys_tests.cpp b/contracts/tests/getpeerkeys_tests.cpp index 07e5d8a192..ec1fd100f1 100644 --- a/contracts/tests/getpeerkeys_tests.cpp +++ b/contracts/tests/getpeerkeys_tests.cpp @@ -15,6 +15,7 @@ #include #include "sysio.system_tester.hpp" +#include "finalizer_test_keys.hpp" using namespace sysio_system; @@ -47,7 +48,22 @@ class getpeerkeys_tester : public sysio_system_tester { BOOST_AUTO_TEST_SUITE(getpeerkeys_tests) BOOST_FIXTURE_TEST_CASE( getpeerkeys_test, getpeerkeys_tester ) { try { - std::vector prod_names = activate_producers(); + // getpeerkeys ranks by POSITION among SCHEDULABLE producers, so the roster needs opreg + // operator rows and an active finalizer key each -- not just regproducer. + std::vector prod_names = activate_producers_with_operators(); + for (size_t i = 0; i < prod_names.size(); ++i) { + push_action(config::system_account_name, "setacctram"_n, + mvo()("account", prod_names[i])("ram_bytes", int64_t(1'000'000))); + } + produce_block(); + for (size_t i = 0; i < prod_names.size(); ++i) { + BOOST_REQUIRE_EQUAL(success(), + push_action(prod_names[i], "regfinkey"_n, mvo() + ("finalizer_name", prod_names[i]) + ("finalizer_key", sysio_test::key_pairs[i].pub_key) + ("proof_of_possession", sysio_test::key_pairs[i].pop))); + } + produce_block(); // Register peer keys for the even-indexed producers; the odd ones stay keyless. std::map registered; diff --git a/contracts/tests/sysio.finalizer_key_tests.cpp b/contracts/tests/sysio.finalizer_key_tests.cpp index a8552375d8..8369009d17 100644 --- a/contracts/tests/sysio.finalizer_key_tests.cpp +++ b/contracts/tests/sysio.finalizer_key_tests.cpp @@ -59,41 +59,6 @@ struct finalizer_key_tester : sysio_system_tester { } } - // sysio.system now schedules a producer only if it is an ACTIVE - // OPERATOR_TYPE_PRODUCER operator in sysio.opreg. activate_producers() alone no - // longer yields a schedulable set, so this deploys sysio.opreg (once) and - // registers each activated producer as a bootstrapped producer operator -- - // ACTIVE-by-fiat, bypassing the collateral requirement -- before returning. - std::vector activate_producers_with_operators( uint32_t count = 21 ) { - std::vector producer_names = activate_producers(count); - if (!opreg_deployed) { - create_account("sysio.opreg"_n, config::system_account_name, false, false, false, true); - // opreg is not privileged yet (setpriv requires setcode first). Give it - // RAM for the ~800KB wasm and NET/CPU to sign regoperator; a sysio.* - // account is created with none by default. - push_action(config::system_account_name, "setacctram"_n, mvo() - ("account", "sysio.opreg"_n)("ram_bytes", int64_t(2'000'000))); - push_action(config::system_account_name, "setacctnet"_n, mvo() - ("account", "sysio.opreg"_n)("net_weight", int64_t(1'000'000))); - push_action(config::system_account_name, "setacctcpu"_n, mvo() - ("account", "sysio.opreg"_n)("cpu_weight", int64_t(1'000'000))); - produce_block(); - set_code("sysio.opreg"_n, contracts::opreg_wasm()); - set_abi ("sysio.opreg"_n, contracts::opreg_abi().data()); - set_privileged("sysio.opreg"_n); - produce_block(); - opreg_deployed = true; - } - for (const auto& p : producer_names) { - base_tester::push_action("sysio.opreg"_n, "regoperator"_n, "sysio.opreg"_n, mvo() - ("account", p) - ("type", sysio::opp::types::OperatorType::OPERATOR_TYPE_PRODUCER) - ("is_bootstrapped", true)); - } - produce_block(); - return producer_names; - } - bool opreg_deployed = false; // Verify finalizers_table and last_prop_fins_table match void verify_last_proposed_finalizers(const std::vector& producer_names) { @@ -614,10 +579,11 @@ BOOST_FIXTURE_TEST_CASE(update_ranked_producers_finalizers_replaced_test, finali auto producerv_info = get_finalizer_info(producerv_name); uint64_t producerv_id = producerv_info["active_key_id"].as_uint64(); - // Use setrank to promote defproducerv into top 21 - // and demote defproducera out - BOOST_REQUIRE_EQUAL( success(), setrank("defproducerv"_n, 1) ); - BOOST_REQUIRE_EQUAL( success(), setrank("defproducera"_n, 22) ); + // Rank is POSITION in the score-ordered index -- governance no longer assigns it. Removing the + // producer holding position 1 shifts every later producer up by one, which promotes + // defproducerv from position 22 into the top 21. + BOOST_REQUIRE_EQUAL( success(), push_action("defproducera"_n, "unregprod"_n, + mvo()("producer", "defproducera"_n)) ); // Wait for update_ranked_producers to pick up new ranking produce_block( fc::minutes(2) ); @@ -630,44 +596,6 @@ BOOST_FIXTURE_TEST_CASE(update_ranked_producers_finalizers_replaced_test, finali } FC_LOG_AND_RETHROW() -// Test that setrank correctly assigns individual producer rank -BOOST_FIXTURE_TEST_CASE(setrank_test, finalizer_key_tester) try { - auto producer_names = activate_producers(); - - // Check initial ranks are assigned (1..21) - auto prod_info = get_producer_info("defproducera"); - BOOST_REQUIRE_EQUAL( 1, prod_info["rank"].as() ); - - prod_info = get_producer_info("defproduceru"); - BOOST_REQUIRE_EQUAL( 21, prod_info["rank"].as() ); - - // setrank requires system authority - BOOST_REQUIRE_EQUAL( error( "missing authority of sysio" ), - push_action( alice, "setrank"_n, mvo() - ("producer", "defproducera") - ("rank", 5) - ) ); - - // setrank with rank=0 should fail - BOOST_REQUIRE_EQUAL( wasm_assert_msg( "rank must be positive" ), - setrank("defproducera"_n, 0) ); - - // setrank with nonexistent producer should fail - BOOST_REQUIRE_EQUAL( wasm_assert_msg( "producer not found" ), - setrank("nonexistent1"_n, 1) ); - - // Promote defproduceru to rank 1, demote defproducera to rank 22 - BOOST_REQUIRE_EQUAL( success(), setrank("defproduceru"_n, 1) ); - BOOST_REQUIRE_EQUAL( success(), setrank("defproducera"_n, 22) ); - - prod_info = get_producer_info("defproduceru"); - BOOST_REQUIRE_EQUAL( 1, prod_info["rank"].as() ); - - prod_info = get_producer_info("defproducera"); - BOOST_REQUIRE_EQUAL( 22, prod_info["rank"].as() ); -} -FC_LOG_AND_RETHROW() - // Verify that update_ranked_producers correctly populates the controller's // active producer schedule and proposes the correct finalizer policy. // diff --git a/contracts/tests/sysio.snapshot_attest_tests.cpp b/contracts/tests/sysio.snapshot_attest_tests.cpp index 028536db60..1f53d7e98a 100644 --- a/contracts/tests/sysio.snapshot_attest_tests.cpp +++ b/contracts/tests/sysio.snapshot_attest_tests.cpp @@ -12,6 +12,7 @@ #include #include "sysio.system_tester.hpp" +#include "finalizer_test_keys.hpp" using namespace sysio_system; @@ -20,14 +21,46 @@ using namespace sysio_system; // --------------------------------------------------------------------------- class snapshot_attest_tester : public sysio_system_tester { public: - snapshot_attest_tester() : sysio_system_tester(setup_level::full) { + /// The five producers every test in this fixture delegates snapshot providers from. + static std::vector fixture_producers() { + return {"producer1"_n, "producer2"_n, "producer3"_n, "producer4"_n, "producer5"_n}; + } + + snapshot_attest_tester() : snapshot_attest_tester(std::vector{}, 0) {} + + /** + * @param extra_producers producers a single test needs beyond the fixture's five. They are + * created HERE rather than in the test because every registered finalizer key has to be in + * the node's voting set from the start -- see `register_schedulable_finalizer_keys`. + * @param cadence_periods how many `block_spacing` periods of history to build before anything + * else. 0 -- the default -- costs nothing, and is what every registration test uses: it + * deliberately does NOT advance, because the advance costs 25 000 blocks and permanently + * forfeits the validating controller. 1 reaches the first attestable height + * (`snapshot_voting_tester`); 2 reaches the second, for the purging tests that need two + * scheduled heights live at once (`snapshot_multi_height_tester`). A test pays for the + * periods its assertions actually require and no more. + */ + explicit snapshot_attest_tester(const std::vector& extra_producers, + uint32_t cadence_periods = 0) + // A cadence-advancing fixture stops ONE level short of `full`, so the chain reaches the + // attestable height with only `sysio.bios` on the system account. bios declares no + // `onblock`, so those blocks execute no contract code at all — where under `sysio.system` + // every one of them runs the interpreted `onblock` (blockinfo write, round attribution, + // and a schedule rebuild every 120 slots). That difference, times `block_spacing` + // (25 000) blocks times two dozen tests, is the whole cost of this suite. + : sysio_system_tester(cadence_periods > 0 ? setup_level::core_token : setup_level::full) { + if (cadence_periods > 0) { + advance_to_attestation_cadence(cadence_periods); + // The rest of what `setup_level::full` would have done, now that the expensive part of + // the chain's history is behind us. + initialize_multisig(); + deploy_contract(); + remaining_setup(); + } produce_blocks(); - // Create producer accounts (setup_producer_accounts gives them resources) - const std::vector producers = { - "producer1"_n, "producer2"_n, "producer3"_n, - "producer4"_n, "producer5"_n - }; + std::vector producers = fixture_producers(); + producers.insert(producers.end(), extra_producers.begin(), extra_producers.end()); setup_producer_accounts(producers); // Create snap provider accounts with resources @@ -45,11 +78,87 @@ class snapshot_attest_tester : public sysio_system_tester { } produce_blocks(); - // Set ranks for producers (all within max_snap_provider_rank = 30) - for (uint32_t i = 0; i < producers.size(); ++i) { - BOOST_REQUIRE_EQUAL(success(), setrank(producers[i], i + 1)); + // Snapshot-provider eligibility is POSITION among schedulable producers, not a stored rank + // governance hands out. A producer is schedulable only as an ACTIVE PRODUCER operator in + // sysio.opreg carrying an active finalizer key, so the fixture must supply both. With equal + // scores the index orders by account name, so producer1..producer5 take positions 1..5 -- + // all inside max_snap_provider_rank. + deploy_opreg_once(); + register_producer_operators(std::vector(producers.begin(), producers.end())); + produce_blocks(); + // Reach the attestation cadence BEFORE registering finalizer keys, i.e. while no producer + // is schedulable yet. Ordering is the whole point: once they are, `update_ranked_producers` + // publishes a policy of ALL of them, and the node then signs and verifies one vote per + // finalizer on EVERY block. Paying that across a `block_spacing` (25 000) block advance is + // what took this suite from minutes to over an hour and blew CI's 1000 s ctest timeout. + // Advancing first leaves the cheap single-finalizer genesis policy in force for those + // 25 000 blocks, and the larger policy applies only to the handful of blocks a test + // produces afterwards. + register_schedulable_finalizer_keys(std::vector(producers.begin(), producers.end())); + produce_blocks(); + } + + /** + * Produce blocks until the head reaches the given number of `block_spacing` periods. + * + * `votesnaphash` rejects a height above the head, and only multiples of `block_spacing` are + * scheduled, so a test that votes needs the chain at least one period along -- and a test that + * needs two scheduled heights live at once needs two. + * + * Called from the constructor BEFORE `sysio.system` is deployed and BEFORE any finalizer key is + * registered, which is what makes it affordable: those blocks then run no contract code and + * carry the single-finalizer genesis policy. The same advance performed later -- mid-test, with + * the system contract live and five finalizers voting -- costs several times as much per block, + * which is why the period count is a constructor decision and not something a test can reach + * for on its own. + * + * It skips duplicate validation too, and those flags STAY set: the validating controller never + * received these blocks, so re-enabling it afterwards makes the very next block unlinkable + * against a node tens of thousands of blocks behind. + * + * @param periods how many `block_spacing` periods of history to build. + */ + void advance_to_attestation_cadence(uint32_t periods) { + const uint32_t target = scheduled_height(periods); + if (control->head().block_num() >= target) return; + + // Flush anything the setup above left pending FIRST: the empty-block advance deliberately + // skips pending transactions, so a transaction queued before it would instead be applied + // after -- by which point the chain has jumped hours of block time and it has expired. + produce_block(); + skip_validate = true; + primary_only_production = true; + produce_blocks(target - control->head().block_num(), true); + } + + /// Give each name an active finalizer key -- required for a rank position -- and configure the + /// node to vote with every one of them. `get_bls_key` derives a distinct key per account name, + /// so there is no fixed key table to run out of and regfinkey's global uniqueness check is + /// satisfied by construction. + /// + /// `set_node_finalizers` is not optional here, and the reason is easy to miss: once these + /// producers are schedulable, `onblock`'s throttled rebuild proposes a finalizer policy built + /// from exactly these keys. A policy this node cannot vote for freezes LIB -- and these tests + /// then advance `block_spacing` (25 000) blocks to reach an attestable height, so an unpruned + /// fork database grows the whole way and the chainbase segment is exhausted long before the + /// test finishes. It is called ONCE, over every key any test in this fixture will register, + /// which is why `extra_producers` is a constructor parameter rather than test-local setup. + void register_schedulable_finalizer_keys(const std::vector& names) { + for (const auto& p : names) { + push_action(config::system_account_name, "setacctram"_n, + mvo()("account", p)("ram_bytes", int64_t(1'000'000))); + } + produce_blocks(); + for (const auto& p : names) { + auto [privkey, pubkey, pop, sig_provider] = sysio::testing::get_bls_key(p); + BOOST_REQUIRE_EQUAL(success(), + push_action(p, "regfinkey"_n, mvo() + ("finalizer_name", p) + ("finalizer_key", pubkey.to_string()) + ("proof_of_possession", pop.to_string()))); } produce_blocks(); + set_node_finalizers(names); } /** Produce a block with traces, skipping duplicate validation only after cadence mode begins. */ @@ -136,23 +245,30 @@ class snapshot_attest_tester : public sysio_system_tester { return count; } - /// Advance lazily to the first cadence boundary and return the latest scheduled height. + /// The height a given `block_spacing` period falls on. + /// + /// Usable WITHOUT advancing: `votesnaphash` runs its two height checks -- is this a scheduled + /// multiple, and is it at or below the head -- before it reads any table, so a test asserting on + /// either of those needs no chain history at all and belongs on the base fixture. + static constexpr uint32_t scheduled_height(uint32_t period) { + return period * sysio::protocol::snapshot_attestation::block_spacing; + } + + /// The latest attestable height at or below the head. + /// + /// This does NOT advance. How far the chain runs is a constructor decision (`cadence_periods`) + /// because that is the only point at which the advance is affordable; a fixture that did not ask + /// for one has no attestable height and says so, rather than silently buying one mid-test at + /// several times the price. uint32_t vote_block_num() { const uint32_t spacing = sysio::protocol::snapshot_attestation::block_spacing; - uint32_t head_block_num = control->head().block_num(); - if (head_block_num < spacing) { - // Commit registrations and configuration queued by the test before empty cadence blocks - // intentionally skip pending transactions. - produce_block(); - head_block_num = control->head().block_num(); - - // Only the expensive empty-block advance skips duplicate validation. Fast registration - // and configuration cases retain normal validating-controller coverage. - skip_validate = true; - primary_only_production = true; - produce_blocks(spacing - head_block_num, true); - } - return control->head().block_num() / spacing * spacing; + // Commit registrations and configuration the test queued before reading the height. + produce_block(); + const uint32_t head_block_num = control->head().block_num(); + BOOST_REQUIRE_MESSAGE(head_block_num >= spacing, + "fixture never advanced to a cadence boundary -- construct it with " + "cadence_periods >= 1 (see snapshot_voting_tester) to vote"); + return head_block_num / spacing * spacing; } /** @@ -192,6 +308,36 @@ class snapshot_attest_tester : public sysio_system_tester { }; // =========================================================================== +/** + * Fixture for the VOTING tests -- the ones whose assertions need a real attestable height. + * + * It builds one `block_spacing` (25 000) block period BEFORE the system contract is deployed and + * BEFORE any finalizer key is registered, which is the only cheap order: once the producers are + * schedulable, `update_ranked_producers` publishes a policy of all of them and the node signs plus + * verifies one vote per finalizer on EVERY block. Paying that across the advance took this suite + * from minutes to over an hour. + * + * The registration tests, and the two height-precondition negatives whose checks fire before the + * contract reads any table, use the base fixture and never advance at all. + */ +struct snapshot_voting_tester : public snapshot_attest_tester { + snapshot_voting_tester() + : snapshot_attest_tester(std::vector{}, /*cadence_periods*/ 1) {} +}; + +/** + * Fixture for the purging tests, which need TWO scheduled heights live at once. + * + * Both periods are built in the constructor, on the cheap side of the system-contract deploy. The + * alternative -- advance one period, then produce the second from inside the test -- is what this + * replaces: those blocks ran the interpreted `onblock` and carried a five-finalizer policy, making + * that second period several times more expensive than the first. + */ +struct snapshot_multi_height_tester : public snapshot_attest_tester { + snapshot_multi_height_tester() + : snapshot_attest_tester(std::vector{}, /*cadence_periods*/ 2) {} +}; + BOOST_AUTO_TEST_SUITE(sysio_snapshot_attest_tests) // --------------------------------------------------------------------------- @@ -206,38 +352,49 @@ BOOST_FIXTURE_TEST_CASE(regsnapprov_basic, snapshot_attest_tester) { try { BOOST_REQUIRE_EQUAL("producer1", prov["producer"].as_string()); } FC_LOG_AND_RETHROW() } -BOOST_FIXTURE_TEST_CASE(regsnapprov_rejects_provider_beyond_maximum, snapshot_attest_tester) { try { +// The provider table is bounded by the RANK BAND, not by its own capacity check. +// +// `max_snap_providers` is defined as `max_snap_provider_rank`, and a producer holds at most one +// mapping at a time, so at most `max_snap_provider_rank` producers can ever hold one. Once rank +// became POSITION among schedulable producers -- necessarily distinct, where the stored ordinal it +// replaced could repeat -- a 31st rank-eligible producer stopped existing, and with it the only way +// to reach `maximum registered snapshot providers reached`. The `check` stays as the guard that +// keeps the table bounded if the two constants ever diverge; the reachable rejection at a full +// table is now the rank gate, and the reachable RECOVERY is the stale-mapping prune. +/// Fixture for the capacity case: twenty-six producers beyond the fixture's five. +/// +/// They are constructor-supplied rather than created inside the test because every finalizer key +/// the chain knows about has to be in the node's voting set BEFORE `onblock` proposes a policy +/// from them -- see `snapshot_attest_tester::register_schedulable_finalizer_keys`. +/// +/// The `z` prefix is load-bearing: rank is POSITION in the score-ordered index, and equal scores +/// order by account name. A `capprov*` name sorts BEFORE `producer*`, which would push the +/// fixture's own five past max_snap_provider_rank and break their registrations. +struct snapshot_capacity_tester : public snapshot_attest_tester { + snapshot_capacity_tester() : snapshot_attest_tester(capacity_producers()) {} + + static std::vector capacity_producers() { + return { + "zcapprova"_n, "zcapprovb"_n, "zcapprovc"_n, "zcapprovd"_n, "zcapprove"_n, + "zcapprovf"_n, "zcapprovg"_n, "zcapprovh"_n, "zcapprovi"_n, "zcapprovj"_n, + "zcapprovk"_n, "zcapprovl"_n, "zcapprovm"_n, "zcapprovn"_n, "zcapprovo"_n, + "zcapprovp"_n, "zcapprovq"_n, "zcapprovr"_n, "zcapprovs"_n, "zcapprovt"_n, + "zcapprovu"_n, "zcapprovv"_n, "zcapprovw"_n, "zcapprovx"_n, "zcapprovy"_n, + "zcapprovz"_n, + }; + } +}; + +BOOST_FIXTURE_TEST_CASE(regsnapprov_rank_band_bounds_provider_table, snapshot_capacity_tester) { try { constexpr uint32_t max_registered_snapshot_providers = 30; constexpr uint32_t fixture_snapshot_providers = 5; constexpr uint32_t additional_providers_to_fill_cap = max_registered_snapshot_providers - fixture_snapshot_providers; - // The fixture provides five producers. Add 26 producers so 25 can fill the - // remaining slots and the final, rank-eligible producer can exercise the rejection path. - const std::vector capacity_producers = { - "capprova"_n, "capprovb"_n, "capprovc"_n, "capprovd"_n, "capprove"_n, - "capprovf"_n, "capprovg"_n, "capprovh"_n, "capprovi"_n, "capprovj"_n, - "capprovk"_n, "capprovl"_n, "capprovm"_n, "capprovn"_n, "capprovo"_n, - "capprovp"_n, "capprovq"_n, "capprovr"_n, "capprovs"_n, "capprovt"_n, - "capprovu"_n, "capprovv"_n, "capprovw"_n, "capprovx"_n, "capprovy"_n, - "capprovz"_n, - }; + // Twenty-five fill the remaining slots; the twenty-sixth is the rank-ineligible newcomer. + const auto capacity_producers = snapshot_capacity_tester::capacity_producers(); BOOST_REQUIRE_EQUAL(additional_providers_to_fill_cap + 1, capacity_producers.size()); - setup_producer_accounts(capacity_producers); - produce_blocks(); - for (const auto& producer : capacity_producers) { - regproducer(producer); - } - produce_blocks(); - for (uint32_t index = 0; index < capacity_producers.size(); ++index) { - const uint32_t rank = index < additional_providers_to_fill_cap - ? fixture_snapshot_providers + index + 1 - : max_registered_snapshot_providers; - BOOST_REQUIRE_EQUAL(success(), setrank(capacity_producers[index], rank)); - } - produce_blocks(); - BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer1"_n, "snapprov1"_n)); BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer2"_n, "snapprov2"_n)); BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer3"_n, "snapprov3"_n)); @@ -249,12 +406,16 @@ BOOST_FIXTURE_TEST_CASE(regsnapprov_rejects_provider_beyond_maximum, snapshot_at BOOST_REQUIRE_EQUAL(success(), regsnapprov(capacity_producers[index], capacity_producers[index])); } - BOOST_REQUIRE_EQUAL(wasm_assert_msg("maximum registered snapshot providers reached"), + // Thirty producers now hold every mapping AND every rank position. The 31st producer is + // position 31, so it is turned away by the rank gate -- the capacity check below it is never + // reached, because a full table and a rank-eligible newcomer cannot coexist. + BOOST_REQUIRE_EQUAL(wasm_assert_msg("producer rank exceeds maximum for snapshot providers"), regsnapprov(capacity_producers.back(), capacity_producers.back())); - // Eligibility changes do not touch the normal lifecycle path. A full-table registration lazily - // removes stale mappings before enforcing the cap, but a registration that already conflicts - // must fail without pruning unrelated rows. + // Deactivating a holder frees the rank position the 31st producer was waiting on -- and leaves + // that holder's mapping stale. A full-table registration lazily removes stale mappings before + // enforcing the cap, but a registration that already conflicts must fail without pruning + // unrelated rows. BOOST_REQUIRE_EQUAL(success(), unregproducer("producer1"_n)); BOOST_REQUIRE(!get_snap_provider("snapprov1"_n).is_null()); BOOST_REQUIRE_EQUAL(wasm_assert_msg("snap_account is already registered as a provider"), @@ -262,6 +423,8 @@ BOOST_FIXTURE_TEST_CASE(regsnapprov_rejects_provider_beyond_maximum, snapshot_at BOOST_REQUIRE(!get_snap_provider("snapprov1"_n).is_null()); BOOST_REQUIRE_EQUAL(success(), regsnapprov(capacity_producers.back(), capacity_producers.back())); + // The stale mapping was consumed to make room, so the table held at max_snap_providers rather + // than growing past it -- the bound holds without the capacity check ever firing. BOOST_REQUIRE(get_snap_provider("snapprov1"_n).is_null()); BOOST_REQUIRE(!get_snap_provider(capacity_producers.back()).is_null()); } FC_LOG_AND_RETHROW() } @@ -301,7 +464,8 @@ BOOST_FIXTURE_TEST_CASE(regsnapprov_rank_too_high, snapshot_attest_tester) { try create_account("highrank"_n, config::system_account_name, false, false, true, true); produce_blocks(); regproducer("highrank"_n); - BOOST_REQUIRE_EQUAL(success(), setrank("highrank"_n, 31)); + // No opreg operator row and no finalizer key, so it occupies no rank position at all -- which + // is exactly the "outside the eligible band" case this rejects. produce_blocks(); BOOST_REQUIRE_EQUAL(wasm_assert_msg("producer rank exceeds maximum for snapshot providers"), @@ -342,7 +506,7 @@ BOOST_FIXTURE_TEST_CASE(setsnpcfg_validation, snapshot_attest_tester) { try { // --------------------------------------------------------------------------- // votesnaphash tests // --------------------------------------------------------------------------- -BOOST_FIXTURE_TEST_CASE(votesnaphash_unregistered, snapshot_attest_tester) { try { +BOOST_FIXTURE_TEST_CASE(votesnaphash_unregistered, snapshot_voting_tester) { try { auto bid = make_block_id(vote_block_num()); auto shash = make_snap_hash(1); BOOST_REQUIRE_EQUAL(wasm_assert_msg("snap_account is not a registered snapshot provider"), @@ -350,8 +514,7 @@ BOOST_FIXTURE_TEST_CASE(votesnaphash_unregistered, snapshot_attest_tester) { try } FC_LOG_AND_RETHROW() } /// Producer eligibility is a registration gate; later lifecycle churn does not retract authority or votes. -BOOST_FIXTURE_TEST_CASE(votesnaphash_preserves_registered_authority_after_producer_churn, - snapshot_attest_tester) { try { +BOOST_FIXTURE_TEST_CASE(votesnaphash_preserves_registered_authority_after_producer_churn, snapshot_voting_tester) { try { BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer1"_n, "snapprov1"_n)); BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer2"_n, "snapprov2"_n)); BOOST_REQUIRE_EQUAL(success(), setsnpcfg(2)); @@ -368,7 +531,7 @@ BOOST_FIXTURE_TEST_CASE(votesnaphash_preserves_registered_authority_after_produc } FC_LOG_AND_RETHROW() } /// A governance change applies to pending votes, and an exact retry can finalize the existing tuple. -BOOST_FIXTURE_TEST_CASE(votesnaphash_uses_current_fixed_k_for_pending_votes, snapshot_attest_tester) { try { +BOOST_FIXTURE_TEST_CASE(votesnaphash_uses_current_fixed_k_for_pending_votes, snapshot_voting_tester) { try { BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer1"_n, "snapprov1"_n)); BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer2"_n, "snapprov2"_n)); BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer3"_n, "snapprov3"_n)); @@ -387,8 +550,7 @@ BOOST_FIXTURE_TEST_CASE(votesnaphash_uses_current_fixed_k_for_pending_votes, sna } FC_LOG_AND_RETHROW() } /// Every competing tuple at one height is measured against the same current governance-set K. -BOOST_FIXTURE_TEST_CASE(votesnaphash_uses_current_fixed_k_for_competing_tuples, - snapshot_attest_tester) { try { +BOOST_FIXTURE_TEST_CASE(votesnaphash_uses_current_fixed_k_for_competing_tuples, snapshot_voting_tester) { try { BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer1"_n, "snapprov1"_n)); BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer2"_n, "snapprov2"_n)); BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer3"_n, "snapprov3"_n)); @@ -409,7 +571,7 @@ BOOST_FIXTURE_TEST_CASE(votesnaphash_uses_current_fixed_k_for_competing_tuples, BOOST_REQUIRE(!getsnaphash(block_num).is_null()); } FC_LOG_AND_RETHROW() } -BOOST_FIXTURE_TEST_CASE(votesnaphash_single_no_quorum, snapshot_attest_tester) { try { +BOOST_FIXTURE_TEST_CASE(votesnaphash_single_no_quorum, snapshot_voting_tester) { try { // Fixed K is two, so a single vote remains pending regardless of registration count. BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer1"_n, "snapprov1"_n)); BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer2"_n, "snapprov2"_n)); @@ -428,7 +590,7 @@ BOOST_FIXTURE_TEST_CASE(votesnaphash_single_no_quorum, snapshot_attest_tester) { BOOST_REQUIRE_EQUAL(true, rec.is_null()); } FC_LOG_AND_RETHROW() } -BOOST_FIXTURE_TEST_CASE(votesnaphash_quorum_reached, snapshot_attest_tester) { try { +BOOST_FIXTURE_TEST_CASE(votesnaphash_quorum_reached, snapshot_voting_tester) { try { // Fixed K is two, so the second distinct producer finalizes the tuple. BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer1"_n, "snapprov1"_n)); BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer2"_n, "snapprov2"_n)); @@ -452,7 +614,43 @@ BOOST_FIXTURE_TEST_CASE(votesnaphash_quorum_reached, snapshot_attest_tester) { t BOOST_REQUIRE_EQUAL(block_num, rec["block_num"].as_uint64()); } FC_LOG_AND_RETHROW() } -BOOST_FIXTURE_TEST_CASE(votesnaphash_same_tuple_retry_is_idempotent, snapshot_attest_tester) { try { +// Snapshot service is a SCORING factor, and the only per-producer history it can be scored from is +// this counter: the vote rows that name the voters are purged the moment a record finalizes, so +// without crediting at quorum there would be nothing left to score. Registration is free and +// therefore worthless as a signal; reaching quorum is not, so only the producers whose votes +// carried the record are credited. +BOOST_FIXTURE_TEST_CASE(votesnaphash_quorum_credits_voting_producers, snapshot_voting_tester) { try { + BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer1"_n, "snapprov1"_n)); + BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer2"_n, "snapprov2"_n)); + BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer3"_n, "snapprov3"_n)); + BOOST_REQUIRE_EQUAL(success(), setsnpcfg(2)); + produce_blocks(); + + const auto attestations_of = [this](account_name producer) { + return get_producer_info(producer)["snapshot_attestations"].as(); + }; + for (const auto& p : {"producer1"_n, "producer2"_n, "producer3"_n}) { + BOOST_REQUIRE_EQUAL(0u, attestations_of(p)); + } + + const auto block_num = vote_block_num(); + auto bid = make_block_id(block_num); + auto shash = make_snap_hash(1); + + // Below quorum nothing is credited: an unfinalized tuple is not service rendered. + BOOST_REQUIRE_EQUAL(success(), votesnaphash("snapprov1"_n, bid, shash)); + BOOST_REQUIRE_EQUAL(0u, attestations_of("producer1"_n)); + + BOOST_REQUIRE_EQUAL(success(), votesnaphash("snapprov2"_n, bid, shash)); + BOOST_REQUIRE(!getsnaphash(block_num).is_null()); + + BOOST_REQUIRE_EQUAL(1u, attestations_of("producer1"_n)); + BOOST_REQUIRE_EQUAL(1u, attestations_of("producer2"_n)); + // producer3 registered a provider but never voted, so it earned nothing. + BOOST_REQUIRE_EQUAL(0u, attestations_of("producer3"_n)); +} FC_LOG_AND_RETHROW() } + +BOOST_FIXTURE_TEST_CASE(votesnaphash_same_tuple_retry_is_idempotent, snapshot_voting_tester) { try { // Need 2 providers, min_providers=2 so single vote won't attest and purge BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer1"_n, "snapprov1"_n)); BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer2"_n, "snapprov2"_n)); @@ -469,7 +667,7 @@ BOOST_FIXTURE_TEST_CASE(votesnaphash_same_tuple_retry_is_idempotent, snapshot_at } FC_LOG_AND_RETHROW() } /// An exact retry remains idempotent after finalization and subsequent eligibility removal. -BOOST_FIXTURE_TEST_CASE(votesnaphash_final_tuple_retry_is_idempotent, snapshot_attest_tester) { try { +BOOST_FIXTURE_TEST_CASE(votesnaphash_final_tuple_retry_is_idempotent, snapshot_voting_tester) { try { BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer1"_n, "snapprov1"_n)); BOOST_REQUIRE_EQUAL(success(), setsnpcfg(1)); @@ -486,7 +684,7 @@ BOOST_FIXTURE_TEST_CASE(votesnaphash_final_tuple_retry_is_idempotent, snapshot_a } FC_LOG_AND_RETHROW() } /// Voting is disabled until governance explicitly chooses a nonzero fixed K. -BOOST_FIXTURE_TEST_CASE(votesnaphash_rejects_unconfigured_quorum, snapshot_attest_tester) { try { +BOOST_FIXTURE_TEST_CASE(votesnaphash_rejects_unconfigured_quorum, snapshot_voting_tester) { try { BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer1"_n, "snapprov1"_n)); const auto block_num = vote_block_num(); @@ -496,12 +694,16 @@ BOOST_FIXTURE_TEST_CASE(votesnaphash_rejects_unconfigured_quorum, snapshot_attes } FC_LOG_AND_RETHROW() } /// A provider cannot pre-attest a tuple for a block height the chain has not reached. +/// +/// On the base fixture: the head is far below the first scheduled height, so that height is itself +/// in the future. The check runs before `votesnaphash` reads any table, so proving it needs no +/// chain history -- only a height the chain has not reached, which is every scheduled height here. BOOST_FIXTURE_TEST_CASE(votesnaphash_rejects_future_block_height, snapshot_attest_tester) { try { BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer1"_n, "snapprov1"_n)); BOOST_REQUIRE_EQUAL(success(), setsnpcfg(1)); - const uint32_t future_block_num = - vote_block_num() + sysio::protocol::snapshot_attestation::block_spacing; + const uint32_t future_block_num = scheduled_height(1); + BOOST_REQUIRE(control->head().block_num() < future_block_num); BOOST_REQUIRE_EQUAL(wasm_assert_msg("snapshot block cannot be in the future"), votesnaphash("snapprov1"_n, make_block_id(future_block_num), @@ -509,11 +711,15 @@ BOOST_FIXTURE_TEST_CASE(votesnaphash_rejects_future_block_height, snapshot_attes } FC_LOG_AND_RETHROW() } /// A manual snapshot height cannot enter the bounded on-chain tally space. +/// +/// On the base fixture: the scheduled-multiple check is the FIRST thing `votesnaphash` evaluates +/// after decoding the height -- ahead of the future-height check and every table read -- so an +/// off-cadence height is rejected for being off-cadence no matter where the head sits. BOOST_FIXTURE_TEST_CASE(votesnaphash_rejects_unscheduled_block_height, snapshot_attest_tester) { try { BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer1"_n, "snapprov1"_n)); BOOST_REQUIRE_EQUAL(success(), setsnpcfg(1)); - const uint32_t unscheduled_block_num = vote_block_num() + 1; + const uint32_t unscheduled_block_num = scheduled_height(1) + 1; BOOST_REQUIRE_EQUAL(wasm_assert_msg("snapshot block is not a scheduled attestation height"), votesnaphash("snapprov1"_n, make_block_id(unscheduled_block_num), @@ -523,7 +729,7 @@ BOOST_FIXTURE_TEST_CASE(votesnaphash_rejects_unscheduled_block_height, snapshot_ // --------------------------------------------------------------------------- // fixed-K tests // --------------------------------------------------------------------------- -BOOST_FIXTURE_TEST_CASE(fixed_k_can_be_reached_after_more_providers_register, snapshot_attest_tester) { try { +BOOST_FIXTURE_TEST_CASE(fixed_k_can_be_reached_after_more_providers_register, snapshot_voting_tester) { try { BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer1"_n, "snapprov1"_n)); BOOST_REQUIRE_EQUAL(success(), setsnpcfg(2)); produce_blocks(); @@ -539,7 +745,7 @@ BOOST_FIXTURE_TEST_CASE(fixed_k_can_be_reached_after_more_providers_register, sn BOOST_REQUIRE(!getsnaphash(block_num).is_null()); } FC_LOG_AND_RETHROW() } -BOOST_FIXTURE_TEST_CASE(fixed_k_is_independent_of_registration_count, snapshot_attest_tester) { try { +BOOST_FIXTURE_TEST_CASE(fixed_k_is_independent_of_registration_count, snapshot_voting_tester) { try { BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer1"_n, "snapprov1"_n)); BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer2"_n, "snapprov2"_n)); BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer3"_n, "snapprov3"_n)); @@ -566,7 +772,7 @@ BOOST_FIXTURE_TEST_CASE(fixed_k_is_independent_of_registration_count, snapshot_a // --------------------------------------------------------------------------- // disagreement tests // --------------------------------------------------------------------------- -BOOST_FIXTURE_TEST_CASE(disagreement_detection, snapshot_attest_tester) { try { +BOOST_FIXTURE_TEST_CASE(disagreement_detection, snapshot_voting_tester) { try { // K=1 finalizes on the first vote regardless of the two registered mappings. BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer1"_n, "snapprov1"_n)); BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer2"_n, "snapprov2"_n)); @@ -588,7 +794,7 @@ BOOST_FIXTURE_TEST_CASE(disagreement_detection, snapshot_attest_tester) { try { votesnaphash("snapprov2"_n, bid, bad_hash)); } FC_LOG_AND_RETHROW() } -BOOST_FIXTURE_TEST_CASE(blockid_mismatch_votes_not_aggregated, snapshot_attest_tester) { try { +BOOST_FIXTURE_TEST_CASE(blockid_mismatch_votes_not_aggregated, snapshot_voting_tester) { try { // Three providers are registered, but the fixed K remains two. BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer1"_n, "snapprov1"_n)); BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer2"_n, "snapprov2"_n)); @@ -617,7 +823,7 @@ BOOST_FIXTURE_TEST_CASE(blockid_mismatch_votes_not_aggregated, snapshot_attest_t BOOST_REQUIRE_EQUAL(shash.str(), rec["snapshot_hash"].as_string()); } FC_LOG_AND_RETHROW() } -BOOST_FIXTURE_TEST_CASE(votesnaphash_rejects_producer_equivocation_across_hashes, snapshot_attest_tester) { try { +BOOST_FIXTURE_TEST_CASE(votesnaphash_rejects_producer_equivocation_across_hashes, snapshot_voting_tester) { try { BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer1"_n, "snapprov1"_n)); BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer2"_n, "snapprov2"_n)); BOOST_REQUIRE_EQUAL(success(), setsnpcfg(2)); @@ -628,7 +834,7 @@ BOOST_FIXTURE_TEST_CASE(votesnaphash_rejects_producer_equivocation_across_hashes votesnaphash("snapprov1"_n, bid, make_snap_hash(81))); } FC_LOG_AND_RETHROW() } -BOOST_FIXTURE_TEST_CASE(votesnaphash_reports_disagreement_before_eligibility_failure, snapshot_attest_tester) { try { +BOOST_FIXTURE_TEST_CASE(votesnaphash_reports_disagreement_before_eligibility_failure, snapshot_voting_tester) { try { BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer1"_n, "snapprov1"_n)); BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer2"_n, "snapprov2"_n)); BOOST_REQUIRE_EQUAL(success(), setsnpcfg(1)); @@ -640,7 +846,7 @@ BOOST_FIXTURE_TEST_CASE(votesnaphash_reports_disagreement_before_eligibility_fai votesnaphash("snapprov2"_n, bid, make_snap_hash(84))); } FC_LOG_AND_RETHROW() } -BOOST_FIXTURE_TEST_CASE(record_blockid_disagreement, snapshot_attest_tester) { try { +BOOST_FIXTURE_TEST_CASE(record_blockid_disagreement, snapshot_voting_tester) { try { // K=1 finalizes on the first vote regardless of the two registered mappings. BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer1"_n, "snapprov1"_n)); BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer2"_n, "snapprov2"_n)); @@ -665,20 +871,18 @@ BOOST_FIXTURE_TEST_CASE(record_blockid_disagreement, snapshot_attest_tester) { t // purging tests // --------------------------------------------------------------------------- /// Votes at different scheduled heights coexist until a final record purges older pending rows. -BOOST_FIXTURE_TEST_CASE(votesnaphash_keeps_scheduled_heights_independent_until_finalization, - snapshot_attest_tester) { try { +BOOST_FIXTURE_TEST_CASE(votesnaphash_keeps_scheduled_heights_independent_until_finalization, snapshot_multi_height_tester) { try { BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer1"_n, "snapprov1"_n)); BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer2"_n, "snapprov2"_n)); BOOST_REQUIRE_EQUAL(success(), setsnpcfg(2)); - const uint32_t older_block_num = vote_block_num(); + // Both heights are already behind the head -- the fixture built two cadence periods up front. + const uint32_t newer_block_num = vote_block_num(); + const uint32_t older_block_num = newer_block_num - sysio::protocol::snapshot_attestation::block_spacing; const auto older_block_id = make_block_id(older_block_num); const auto older_hash = make_snap_hash(5); BOOST_REQUIRE_EQUAL(success(), votesnaphash("snapprov1"_n, older_block_id, older_hash)); - const uint32_t newer_block_num = - older_block_num + sysio::protocol::snapshot_attestation::block_spacing; - produce_blocks(newer_block_num - control->head().block_num(), true); const auto newer_block_id = make_block_id(newer_block_num); const auto newer_hash = make_snap_hash(6); BOOST_REQUIRE_EQUAL(success(), votesnaphash("snapprov1"_n, newer_block_id, newer_hash)); @@ -694,20 +898,18 @@ BOOST_FIXTURE_TEST_CASE(votesnaphash_keeps_scheduled_heights_independent_until_f } FC_LOG_AND_RETHROW() } /// A newer finalization permanently closes older heights whose unfinished rows were purged. -BOOST_FIXTURE_TEST_CASE(votesnaphash_rejects_reopening_purged_historical_height, - snapshot_attest_tester) { try { +BOOST_FIXTURE_TEST_CASE(votesnaphash_rejects_reopening_purged_historical_height, snapshot_multi_height_tester) { try { BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer1"_n, "snapprov1"_n)); BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer2"_n, "snapprov2"_n)); BOOST_REQUIRE_EQUAL(success(), setsnpcfg(2)); - const uint32_t older_block_num = vote_block_num(); + // Both heights are already behind the head -- the fixture built two cadence periods up front. + const uint32_t newer_block_num = vote_block_num(); + const uint32_t older_block_num = newer_block_num - sysio::protocol::snapshot_attestation::block_spacing; const auto older_block_id = make_block_id(older_block_num); const auto older_hash = make_snap_hash(25); BOOST_REQUIRE_EQUAL(success(), votesnaphash("snapprov1"_n, older_block_id, older_hash)); - const uint32_t newer_block_num = - older_block_num + sysio::protocol::snapshot_attestation::block_spacing; - produce_blocks(newer_block_num - control->head().block_num(), true); const auto newer_block_id = make_block_id(newer_block_num); const auto newer_hash = make_snap_hash(26); BOOST_REQUIRE_EQUAL(success(), votesnaphash("snapprov1"_n, newer_block_id, newer_hash)); @@ -722,7 +924,7 @@ BOOST_FIXTURE_TEST_CASE(votesnaphash_rejects_reopening_purged_historical_height, } FC_LOG_AND_RETHROW() } /// Registration churn cannot erase pending votes cast by other producers. -BOOST_FIXTURE_TEST_CASE(votesnaphash_registration_churn_preserves_other_votes, snapshot_attest_tester) { try { +BOOST_FIXTURE_TEST_CASE(votesnaphash_registration_churn_preserves_other_votes, snapshot_voting_tester) { try { BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer1"_n, "snapprov1"_n)); BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer2"_n, "snapprov2"_n)); BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer3"_n, "snapprov3"_n)); @@ -758,7 +960,7 @@ BOOST_FIXTURE_TEST_CASE(getsnaphash_action_not_found, snapshot_attest_tester) { } FC_LOG_AND_RETHROW() } /// Governance owns the fixed-K tradeoff; K=1 deliberately permits one of many providers to attest. -BOOST_FIXTURE_TEST_CASE(votesnaphash_honors_governance_fixed_k, snapshot_attest_tester) { try { +BOOST_FIXTURE_TEST_CASE(votesnaphash_honors_governance_fixed_k, snapshot_voting_tester) { try { BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer1"_n, "snapprov1"_n)); BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer2"_n, "snapprov2"_n)); BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer3"_n, "snapprov3"_n)); @@ -773,8 +975,7 @@ BOOST_FIXTURE_TEST_CASE(votesnaphash_honors_governance_fixed_k, snapshot_attest_ } FC_LOG_AND_RETHROW() } /// Rotating a snapshot account cannot add Sybil weight and makes exact retries idempotent. -BOOST_FIXTURE_TEST_CASE(votesnaphash_snap_account_rotation_does_not_add_sybil_weight, - snapshot_attest_tester) { try { +BOOST_FIXTURE_TEST_CASE(votesnaphash_snap_account_rotation_does_not_add_sybil_weight, snapshot_voting_tester) { try { BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer1"_n, "snapprov1"_n)); BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer2"_n, "snapprov2"_n)); BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer3"_n, "snapprov3"_n)); diff --git a/contracts/tests/sysio.system_tester.hpp b/contracts/tests/sysio.system_tester.hpp index 6035f8705a..7cd39cc3fd 100644 --- a/contracts/tests/sysio.system_tester.hpp +++ b/contracts/tests/sysio.system_tester.hpp @@ -1,6 +1,8 @@ #pragma once #include +#include +#include #include #include #include "contracts.hpp" @@ -355,6 +357,60 @@ class sysio_system_tester : public TESTER { msig_abi_ser.set_abi(msig_abi, abi_serializer::create_yield_function(abi_serializer_max_time)); } + /// activate_producers(), plus the sysio.opreg operator rows `producer_rank::is_schedulable` + /// requires. + /// + /// sysio.system schedules -- and getpeerkeys / snapshot-provider eligibility rank -- only + /// producers that are ACTIVE OPERATOR_TYPE_PRODUCER operators in sysio.opreg AND carry an + /// active finalizer key. `activate_producers()` yields neither, so this deploys sysio.opreg + /// (once) and registers each producer as a bootstrapped producer operator -- ACTIVE-by-fiat, + /// bypassing collateral. + /// + /// Finalizer keys are deliberately left to the caller. Which keys a test registers decides + /// whether this node can vote for the policy update_ranked_producers proposes, and some tests + /// depend on it NOT being able to (so the policy stays pending at the controller). + vector activate_producers_with_operators( uint32_t count = 21 ) { + std::vector producer_names = activate_producers(count); + deploy_opreg_once(); + register_producer_operators(producer_names); + return producer_names; + } + + /// Deploy sysio.opreg into the test chain, once. `sysio_system_tester` does not ship it, but + /// every rank consumer reads it through `is_op_active`. + void deploy_opreg_once() { + if (!opreg_deployed) { + create_account("sysio.opreg"_n, config::system_account_name, false, false, false, true); + // opreg is not privileged yet (setpriv requires setcode first). Give it RAM for the + // ~800KB wasm and NET/CPU to sign regoperator; a sysio.* account has none by default. + push_action(config::system_account_name, "setacctram"_n, mvo() + ("account", "sysio.opreg"_n)("ram_bytes", int64_t(2'000'000))); + push_action(config::system_account_name, "setacctnet"_n, mvo() + ("account", "sysio.opreg"_n)("net_weight", int64_t(1'000'000))); + push_action(config::system_account_name, "setacctcpu"_n, mvo() + ("account", "sysio.opreg"_n)("cpu_weight", int64_t(1'000'000))); + produce_block(); + set_code("sysio.opreg"_n, contracts::opreg_wasm()); + set_abi ("sysio.opreg"_n, contracts::opreg_abi().data()); + set_privileged("sysio.opreg"_n); + produce_block(); + opreg_deployed = true; + } + } + + /// Register each name as a bootstrapped PRODUCER operator -- ACTIVE-by-fiat, bypassing the + /// collateral minimum -- which is what `is_op_active` gates every rank position on. + void register_producer_operators(const std::vector& names) { + for (const auto& p : names) { + base_tester::push_action("sysio.opreg"_n, "regoperator"_n, "sysio.opreg"_n, mvo() + ("account", p) + ("type", sysio::opp::types::OperatorType::OPERATOR_TYPE_PRODUCER) + ("is_bootstrapped", true)); + } + produce_block(); + } + bool opreg_deployed = false; + vector activate_producers( uint32_t count = 21 ) { //stake more than 15% of total SYS supply to activate chain transfer( "sysio"_n, "alice1111111"_n, core_sym::from_string("650000000.0000"), config::system_account_name ); @@ -407,12 +463,6 @@ class sysio_system_tester : public TESTER { return producer_names; } - action_result setrank( const name& producer, uint32_t rank ) { - return push_action( config::system_account_name, "setrank"_n, mvo() - ("producer", producer) - ("rank", rank) ); - } - abi_serializer abi_ser; abi_serializer token_abi_ser; diff --git a/plugins/snapshot_api_plugin/README.md b/plugins/snapshot_api_plugin/README.md index c260fc8017..ebb85e03e7 100644 --- a/plugins/snapshot_api_plugin/README.md +++ b/plugins/snapshot_api_plugin/README.md @@ -92,10 +92,13 @@ clio push action sysio regproducer \ '{"producer": "myproducer1", "producer_key": "SYS6...", "url": "", "location": 0}' \ -p myproducer1@active -# Set producer rank (must be <= 30 to be eligible as a snapshot provider) -clio push action sysio setrank \ - '{"producer": "myproducer1", "rank": 1}' \ - -p sysio@active +# Register a finalizer key. Snapshot-provider eligibility requires the producer to hold one of +# the top 30 rank POSITIONS, and rank is position in the score-ordered producer index among +# schedulable producers -- an ACTIVE OPERATOR_TYPE_PRODUCER operator in sysio.opreg carrying an +# active finalizer key. There is no action that assigns a rank. +clio push action sysio regfinkey \ + '{"finalizer_name": "myproducer1", "finalizer_key": "PUB_BLS...", "proof_of_possession": "SIG_BLS..."}' \ + -p myproducer1@active ``` ### 2. Register a snapshot provider account diff --git a/tests/producer_rank_test.py b/tests/producer_rank_test.py index a25c8b49c7..7e06b7cb12 100755 --- a/tests/producer_rank_test.py +++ b/tests/producer_rank_test.py @@ -14,12 +14,15 @@ # 1. Launch 5-node cluster with system contract (bootstrap schedule has 5 producers) # 2. Record initial producer schedule (5 producers from bootstrap) # 3. Register all 5 producers via regproducer -# 4. Rank only 4 via setrank (leave one unranked at UINT32_MAX) -# 5. Register BLS finalizer keys for the 4 ranked producers -# 6. Wait for update_ranked_producers to fire via onblock -# 7. Verify producer schedule changed (5 → 4 producers, version increased) -# 8. Verify finalizer policy set (4 finalizers from ranked producers) -# 9. Test setrank action (positive and negative cases) +# 4. Register BLS finalizer keys for only 4 of them +# 5. Wait for update_ranked_producers to fire via onblock +# 6. Verify producer schedule changed (5 → 4 producers, version increased) +# 7. Verify finalizer policy set (4 finalizers from the keyed producers) +# +# Rank is POSITION in the score-ordered producer index, derived by iteration -- there is no +# action that assigns it. A producer holds a position only if it is schedulable, which requires +# an ACTIVE PRODUCER operator row in sysio.opreg AND an active finalizer key. Withholding the +# finalizer key from one producer is therefore what makes the schedule drop from 5 to 4. # ############################################################### @@ -30,7 +33,7 @@ Utils.Debug = args.v pnodes = 5 totalNodes = pnodes -rankedCount = 4 # only rank 4 of the 5 producers +keyedCount = 4 # only rank 4 of the 5 producers dumpErrorDetails = args.dump_error_details testSuccessful = False @@ -65,13 +68,13 @@ prodNames = sorted(producers.keys()) Print(f"Producers: {prodNames}") - # The first rankedCount producers (alphabetically) will be ranked; - # the last one will remain unranked and should be excluded from the - # schedule and finalizer policy after update_ranked_producers fires. - rankedProdNames = prodNames[:rankedCount] - excludedProd = prodNames[rankedCount] - Print(f"Ranked producers: {rankedProdNames}") - Print(f"Excluded (unranked) producer: {excludedProd}") + # The first keyedCount producers (alphabetically) get finalizer keys; the last one does not + # and so holds no rank position -- it should be excluded from the schedule and the finalizer + # policy once update_ranked_producers fires. + keyedProdNames = prodNames[:keyedCount] + excludedProd = prodNames[keyedCount] + Print(f"Finalizer-keyed producers: {keyedProdNames}") + Print(f"Excluded (no finalizer key) producer: {excludedProd}") # ---------------------------------------------------------------- # Record initial producer schedule from bootstrap @@ -115,28 +118,10 @@ # satisfied and scheduling turns purely on rank + finalizer key below. # ---------------------------------------------------------------- - # Phase 2: Assign ranks to only rankedCount producers via setrank - # ---------------------------------------------------------------- - # regproducer creates entries with rank=UINT32_MAX (unranked). - # update_ranked_producers only considers producers with rank <= 21. - # We intentionally leave the last producer unranked to verify that - # update_ranked_producers changes the schedule from 5 to 4. - Print("=== Phase 2: Assign producer ranks via setrank ===") - for i, name in enumerate(rankedProdNames): - rank = i + 1 - data = json.dumps({"producer": name, "rank": rank}) - opts = "--permission sysio@active" - trans = node0.pushMessage("sysio", "setrank", data, opts) - assert trans is not None and trans[0], f"Failed to set rank for {name}: {trans}" - Print(f"Set rank {rank} for producer {name}") - - assert node0.waitForHeadToAdvance(blocksToAdvance=2), "Head should advance after setrank" - - # ---------------------------------------------------------------- - # Phase 3: Register BLS finalizer keys for ranked producers only + # Phase 2: Register BLS finalizer keys for all but one producer # ---------------------------------------------------------------- - Print("=== Phase 3: Register finalizer keys via regfinkey ===") - for name in rankedProdNames: + Print("=== Phase 2: Register finalizer keys via regfinkey ===") + for name in keyedProdNames: n = producers[name] blsKey = n.keys[0].blspubkey blsPop = n.keys[0].blspop @@ -153,14 +138,14 @@ assert node0.waitForHeadToAdvance(blocksToAdvance=2), "Head should advance after regfinkey" # ---------------------------------------------------------------- - # Phase 4: Wait for update_ranked_producers to fire + # Phase 3: Wait for update_ranked_producers to fire # ---------------------------------------------------------------- # onblock calls update_ranked_producers when timestamp.slot - last_update.slot > 120. # After system contract init, last_producer_schedule_update is 0, so the first # update_ranked_producers fires on the very first onblock. Since that happens before # we register keys, it finds no qualified producers and sets last_update to current time. # We need to wait ~120 more slots (60 seconds) for the next cycle. - Print("=== Phase 4: Waiting for update_ranked_producers cycle (~65 seconds) ===") + Print("=== Phase 3: Waiting for update_ranked_producers cycle (~65 seconds) ===") assert node0.waitForHeadToAdvance(blocksToAdvance=135, timeout=90), \ "Head should advance 135 blocks for update_ranked_producers cycle" @@ -173,9 +158,9 @@ assert node0.waitForLibToAdvance(timeout=30), "LIB should still be advancing" # ---------------------------------------------------------------- - # Phase 5: Verify producer schedule changed + # Phase 4: Verify producer schedule changed # ---------------------------------------------------------------- - Print("=== Phase 5: Verify producer schedule changed ===") + Print("=== Phase 4: Verify producer schedule changed ===") schedule = node0.processUrllibRequest("chain", "get_producer_schedule") activeSchedule = schedule["payload"]["active"] @@ -187,24 +172,24 @@ assert newVersion > initVersion, \ f"Schedule version should have increased from {initVersion}, got {newVersion}" - # Should have exactly rankedCount producers - assert len(activeProducers) == rankedCount, \ - f"Expected {rankedCount} active producers, got {len(activeProducers)}: {activeProducers}" + # Should have exactly keyedCount producers + assert len(activeProducers) == keyedCount, \ + f"Expected {keyedCount} active producers, got {len(activeProducers)}: {activeProducers}" # All ranked producers should be in the schedule - for name in rankedProdNames: + for name in keyedProdNames: assert name in activeProducers, f"Ranked producer {name} should be in active schedule" # The excluded producer should NOT be in the schedule assert excludedProd not in activeProducers, \ f"Unranked producer {excludedProd} should NOT be in active schedule" - Print(f"Producer schedule changed: {pnodes} -> {rankedCount} producers, " + Print(f"Producer schedule changed: {pnodes} -> {keyedCount} producers, " f"version {initVersion} -> {newVersion}") # ---------------------------------------------------------------- - # Phase 6: Verify finalizer policy + # Phase 5: Verify finalizer policy # ---------------------------------------------------------------- - Print("=== Phase 6: Verify finalizer policy ===") + Print("=== Phase 5: Verify finalizer policy ===") finInfo = node0.getFinalizerInfo() activeFP = finInfo["payload"]["active_finalizer_policy"] @@ -218,30 +203,30 @@ Print(f"Pending finalizer policy: generation={pendingFP.get('generation', 'N/A')}, " f"threshold={pendingFP.get('threshold', 'N/A')}, finalizers={pendingFinCount}") - # Look for the rankedCount-finalizer policy in either active or pending. + # Look for the keyedCount-finalizer policy in either active or pending. policyToCheck = None policyState = None - if activeFinCount == rankedCount: + if activeFinCount == keyedCount: policyToCheck = activeFP policyState = "active" - elif pendingFinCount == rankedCount: + elif pendingFinCount == keyedCount: policyToCheck = pendingFP policyState = "pending" assert policyToCheck is not None, \ - f"Expected a finalizer policy with {rankedCount} finalizers. " \ + f"Expected a finalizer policy with {keyedCount} finalizers. " \ f"Active has {activeFinCount}, Pending has {pendingFinCount}" Print(f"System contract finalizer policy is {policyState}") # Verify threshold: (N * 2) / 3 + 1 - expectedThreshold = rankedCount * 2 // 3 + 1 + expectedThreshold = keyedCount * 2 // 3 + 1 assert policyToCheck["threshold"] == expectedThreshold, \ f"Expected threshold {expectedThreshold}, got {policyToCheck['threshold']}" # Verify each ranked producer has a finalizer entry with weight 1 finalizerDescs = sorted([f["description"] for f in policyToCheck["finalizers"]]) Print(f"Finalizer descriptions: {finalizerDescs}") - for name in rankedProdNames: + for name in keyedProdNames: assert name in finalizerDescs, f"Producer {name} should be in finalizer policy" # The excluded producer should NOT be a finalizer @@ -251,38 +236,6 @@ for f in policyToCheck["finalizers"]: assert f["weight"] == 1, f"Finalizer weight should be 1, got {f['weight']}" - # ---------------------------------------------------------------- - # Phase 7: Test setrank action - # ---------------------------------------------------------------- - Print("=== Phase 7: Test setrank action ===") - - # setrank requires sysio@active authority - target = rankedProdNames[0] - data = json.dumps({"producer": target, "rank": 1}) - opts = "--permission sysio@active" - trans = node0.pushMessage("sysio", "setrank", data, opts) - assert trans is not None and trans[0], f"setrank for {target} to rank 1 should succeed: {trans}" - Print(f"setrank: {target} -> rank 1 succeeded") - - # Verify setrank with rank 0 (invalid) is rejected - data = json.dumps({"producer": target, "rank": 0}) - trans = node0.pushMessage("sysio", "setrank", data, opts, silentErrors=True) - assert trans is None or not trans[0], "setrank with rank 0 should fail" - Print("setrank with rank 0 correctly rejected") - - # Verify setrank with non-existent producer fails - data = json.dumps({"producer": "nonexistent1", "rank": 1}) - trans = node0.pushMessage("sysio", "setrank", data, opts, silentErrors=True) - assert trans is None or not trans[0], "setrank with non-existent producer should fail" - Print("setrank with non-existent producer correctly rejected") - - # Verify setrank without sysio authority fails - data = json.dumps({"producer": target, "rank": 2}) - badOpts = f"--permission {target}@active" - trans = node0.pushMessage("sysio", "setrank", data, badOpts, silentErrors=True) - assert trans is None or not trans[0], "setrank without sysio authority should fail" - Print("setrank without sysio authority correctly rejected") - # ---------------------------------------------------------------- # Final verification: LIB still advancing # ---------------------------------------------------------------- diff --git a/tests/snapshot_attest_test.py b/tests/snapshot_attest_test.py index 96cee5d734..2dbf9580a6 100755 --- a/tests/snapshot_attest_test.py +++ b/tests/snapshot_attest_test.py @@ -92,7 +92,7 @@ account = cluster.defProducerAccounts[name] walletMgr.importKey(account, ignWallet, ignoreDupKeyWarning=True) - # Register producers via regproducer (required before setrank) + # Register producers via regproducer (required before regfinkey) regProducerTransIds = [] for name in [producerA, producerB]: account = cluster.defProducerAccounts[name] @@ -108,37 +108,36 @@ regProducerTransIds.append(node0.getTransId(trans)) Print(f"Registered producer {name}") - # setrank reads the on-chain producers table and asserts "producer not found" - # if a registration is missing. - # pushMessage only confirms speculative - # execution, and waitForHeadToAdvance() does not guarantee these specific - # transactions were applied — with multiple producers a transaction pushed - # to node0 can be forwarded into a peer's block. Wait for both regproducer - # transactions to appear in a block so setrank speculatively executes - # against state that already contains the registrations. + # regfinkey asserts "is not a registered producer" if the producers row is missing. + # pushMessage only confirms speculative execution, and waitForHeadToAdvance() does not + # guarantee these specific transactions were applied — with multiple producers a transaction + # pushed to node0 can be forwarded into a peer's block. Wait for both regproducer + # transactions to appear in a block first. assert node0.waitForTransactionsInBlock(regProducerTransIds, timeout=60), \ - "regproducer transactions did not make it into a block before setrank" + "regproducer transactions did not make it into a block before regfinkey" - setRankTransIds = [] - Print(f"Set rank for {producerA}") - success, trans = node0.pushMessage("sysio", "setrank", - json.dumps({"producer": producerA, "rank": 1}), - "--permission sysio@active") - assert success, f"Failed to set rank for {producerA}: {trans}" - setRankTransIds.append(node0.getTransId(trans)) + # Snapshot-provider eligibility is POSITION in the score-ordered producer index, not a rank + # governance assigns. A producer holds a position only when it is schedulable, which requires + # an active finalizer key on top of the opreg operator row the bootstrap already created. + finKeyTransIds = [] + for name in [producerA, producerB]: + node = next(cluster.getNode(i) for i in range(pnodes) + if cluster.getNode(i).producerName == name) + Print(f"Register finalizer key for {name}") + success, trans = node0.pushMessage("sysio", "regfinkey", + json.dumps({ + "finalizer_name": name, + "finalizer_key": node.keys[0].blspubkey, + "proof_of_possession": node.keys[0].blspop + }), + f"--permission {name}@active") + assert success, f"Failed to register finalizer key for {name}: {trans}" + finKeyTransIds.append(node0.getTransId(trans)) - Print(f"Set rank for {producerB}") - success, trans = node0.pushMessage("sysio", "setrank", - json.dumps({"producer": producerB, "rank": 2}), - "--permission sysio@active") - assert success, f"Failed to set rank for {producerB}: {trans}" - setRankTransIds.append(node0.getTransId(trans)) - - # regsnapprov reads producer ranks from the on-chain producers table. A - # generic head advance can race the exact setrank transactions under - # multi-producer scheduling, so wait for those transactions specifically. - assert node0.waitForTransactionsInBlock(setRankTransIds, timeout=60), \ - "setrank transactions did not make it into a block before regsnapprov" + # regsnapprov walks the producer index for its eligibility check, so wait for those + # registrations specifically rather than a generic head advance. + assert node0.waitForTransactionsInBlock(finKeyTransIds, timeout=60), \ + "regfinkey transactions did not make it into a block before regsnapprov" # --------------------------------------------------------------- # Register snapshot providers diff --git a/unittests/snapshot_attest_fixture.hpp b/unittests/snapshot_attest_fixture.hpp index fd746bddd8..a46f34c083 100644 --- a/unittests/snapshot_attest_fixture.hpp +++ b/unittests/snapshot_attest_fixture.hpp @@ -33,15 +33,6 @@ inline constexpr uint32_t preceding_block_offset = 1; namespace system_contract { -/// Producer rank assignment action used during fixture bootstrap. -inline constexpr auto action_setrank = "setrank"_n; - -/// Producer rank field in the rank-assignment action. -inline constexpr auto field_rank = "rank"; - -/// Top eligible producer rank assigned by the fixture. -inline constexpr uint32_t producer_rank = 1; - } // namespace system_contract /** Convert a BLAKE3 snapshot root to the checksum representation stored by the contract. */ @@ -68,12 +59,24 @@ class snapshot_attest_fixture : public sysio_system::sysio_system_tester { regproducer(producer_account); produce_blocks(); - BOOST_REQUIRE_EQUAL( - success(), - push_action( - config::system_account_name, system_contract::action_setrank, - mvo()(snapshot_attestation::field::producer, producer_account) - (system_contract::field_rank, system_contract::producer_rank))); + // Snapshot-provider eligibility is POSITION in the score-ordered producer index, not a rank + // governance assigns. A producer holds a position only as an ACTIVE PRODUCER operator in + // sysio.opreg carrying an active finalizer key, so the fixture must supply both. + deploy_opreg_once(); + register_producer_operators({producer_account}); + push_action(config::system_account_name, "setacctram"_n, + mvo()("account", producer_account)("ram_bytes", int64_t(1'000'000))); + produce_blocks(); + { + auto [privkey, pubkey, pop, sig_provider] = sysio::testing::get_bls_key(producer_account); + BOOST_REQUIRE_EQUAL( + success(), + push_action(producer_account, "regfinkey"_n, + mvo()("finalizer_name", producer_account) + ("finalizer_key", pubkey.to_string()) + ("proof_of_possession", pop.to_string()))); + } + set_node_finalizers(std::vector{producer_account}); produce_blocks(); BOOST_REQUIRE_EQUAL( diff --git a/unittests/sysio_system_tester.hpp b/unittests/sysio_system_tester.hpp index fb4a4415e1..cb586d6761 100644 --- a/unittests/sysio_system_tester.hpp +++ b/unittests/sysio_system_tester.hpp @@ -2,6 +2,7 @@ #include #include +#include #include @@ -144,6 +145,43 @@ class sysio_system_tester : public validating_tester { ("ram_reserve_ratio", 100 + n); } + /// Deploy sysio.opreg into the test chain, once. Every producer rank position is gated on an + /// ACTIVE OPERATOR_TYPE_PRODUCER row there via `is_op_active`, and this tester does not ship it. + void deploy_opreg_once() { + if (opreg_deployed) return; + create_account("sysio.opreg"_n, config::system_account_name, false, false, false, true); + // opreg is not privileged yet (setpriv requires setcode first). Give it RAM for the wasm and + // NET/CPU to sign regoperator; a sysio.* account is created with none by default. + push_action(config::system_account_name, "setacctram"_n, mvo() + ("account", "sysio.opreg"_n)("ram_bytes", int64_t(2'000'000))); + push_action(config::system_account_name, "setacctnet"_n, mvo() + ("account", "sysio.opreg"_n)("net_weight", int64_t(1'000'000))); + push_action(config::system_account_name, "setacctcpu"_n, mvo() + ("account", "sysio.opreg"_n)("cpu_weight", int64_t(1'000'000))); + produce_block(); + set_code("sysio.opreg"_n, test_contracts::sysio_opreg_wasm()); + set_abi ("sysio.opreg"_n, test_contracts::sysio_opreg_abi().data()); + set_privileged("sysio.opreg"_n); + produce_block(); + opreg_deployed = true; + } + + /// Register each name as a bootstrapped PRODUCER operator -- ACTIVE-by-fiat, bypassing the + /// collateral minimum -- which is what every rank position is gated on. + void register_producer_operators(const std::vector& names) { + for (const auto& p : names) { + base_tester::push_action("sysio.opreg"_n, "regoperator"_n, "sysio.opreg"_n, mvo() + ("account", p) + // The ABI spelling, not the C++ enum: this header is included by several test + // targets and pulling the OPP proto headers onto all of their include paths is far + // more fragile than naming the wire value at this one serialization boundary. + ("type", "OPERATOR_TYPE_PRODUCER") + ("is_bootstrapped", true)); + } + produce_block(); + } + bool opreg_deployed = false; + action_result regproducer( const account_name& acnt, int params_fixture = 1 ) { action_result r = push_action( acnt, "regproducer"_n, mvo() ("producer", acnt ) diff --git a/unittests/test_contracts.hpp.in b/unittests/test_contracts.hpp.in index 89af1228a8..63db4f267b 100644 --- a/unittests/test_contracts.hpp.in +++ b/unittests/test_contracts.hpp.in @@ -22,6 +22,7 @@ namespace sysio { // Contracts in `contracts' directory MAKE_READ_WASM_ABI(sysio_msig, sysio.msig, contracts) MAKE_READ_WASM_ABI(sysio_system, sysio.system, contracts) + MAKE_READ_WASM_ABI(sysio_opreg, sysio.opreg, contracts) MAKE_READ_WASM_ABI(sysio_token, sysio.token, contracts) MAKE_READ_WASM_ABI(sysio_wrap, sysio.wrap, contracts) MAKE_READ_WASM_ABI(sysio_authex, sysio.authex, contracts) From 31e666a7622b69898213a4034070d3914ebacdd5 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Thu, 3 Sep 2026 14:17:28 -0500 Subject: [PATCH 02/16] feat(sysio.system): pay producers per block, keep peer discovery key-free (WIRE-367) Review fixes on #599: - getpeerkeys ranks eligible operators (active producers row + ACTIVE opreg PRODUCER) without requiring a finalizer key, so BP gossip still reaches a producer scheduled through setprods; is_schedulable stays the schedule / pay predicate. Restores auto_bp_gossip_peering_test. - The snapshot factor reaches the index: producer_rank::rescore at credit and at the payepoch reset. rescore_generation -> rescore_pending (boolean). - Tests: mul_sat saturation, the 22-position bootstrap displacement boundary, a partial miss lowering the composite, peer discovery without a finalizer key. Pay model (agreed 2026-09-03): - Producers are paid PER BLOCK: the active slice of the producer pool is spread over the period's block slots (the nominal count, raised to the blocks produced when a period runs long) and every schedulable producer is credited that rate per block. Missed blocks stay in the treasury; nothing is redistributed to the producers that showed up. - Standbys draw a retainer from standby_bps (new emitcfg field, 8% in the harness) of the pool, split into fixed per-position shares decaying linearly from position 22. A vacant position pays nobody. - No forfeiture: a producer that is not schedulable at a payepoch (parked, keyless, demoted) is neither paid nor reset, and its blocks are paid at the first payepoch where it is. Demotion stays at three consecutive zero-block rounds; regproducer remains the only door back. - Removed: eligible_rounds / current_round_blocks / last_block_num, the 6-of-12 round threshold, the weight walk, and the global total_unpaid_blocks sequence-stamp counter. - sysio.epoch deserializes emitcfg and reads pay_cadence_epochs, which now follows the new field, so its wasm is refreshed with the layout. The Python TestHarness emitcfg payload carries standby_bps. Change-Id: Ifd5a306d2d1aa5d02f1d33afc44c0dca12e6d165 --- .../include/sysio.epoch/sysio.epoch.hpp | 10 +- contracts/sysio.epoch/sysio.epoch.wasm | Bin 94459 -> 78946 bytes .../include/sysio.system/emissions.hpp | 7 +- .../include/sysio.system/producer_score.hpp | 82 +- .../include/sysio.system/sysio.system.hpp | 31 +- contracts/sysio.system/src/emissions.cpp | 213 +++--- contracts/sysio.system/src/peer_keys.cpp | 12 +- contracts/sysio.system/src/producer_pay.cpp | 56 +- contracts/sysio.system/src/ranking.cpp | 23 +- .../sysio.system/src/snapshot_attest.cpp | 3 + contracts/sysio.system/sysio.system.abi | 24 +- contracts/sysio.system/sysio.system.wasm | Bin 192783 -> 191793 bytes contracts/tests/emissions_tests.cpp | 718 ++++++++++++------ contracts/tests/getpeerkeys_tests.cpp | 20 +- contracts/tests/sysio.dispatch_tests.cpp | 2 +- .../tests/sysio.epoch_flushwtdw_tests.cpp | 2 +- contracts/tests/sysio.msgch_chain_tests.cpp | 2 +- contracts/tests/sysio.roa_tests.cpp | 2 +- .../tests/sysio.snapshot_attest_tests.cpp | 12 + contracts/tests/sysio.system_tester.hpp | 2 +- tests/TestHarness/Cluster.py | 2 +- 21 files changed, 747 insertions(+), 476 deletions(-) diff --git a/contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp b/contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp index 5a8e6a5154..4f39e90c27 100644 --- a/contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp +++ b/contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp @@ -128,11 +128,11 @@ namespace sysio { static constexpr name UWRIT_ACCOUNT = "sysio.uwrit"_n; static constexpr name RESERV_ACCOUNT = "sysio.reserv"_n; - /// Bounds on `epoch_duration_sec`. Floor is a typo-guard: well below this - /// value, `expected_rounds` in sysio.system::payepoch falls back to 1 - /// for any non-trivial epoch, masking misconfig. Ceiling bounds the - /// `(epoch_duration_sec * 2) / TOTAL_BLOCKS_PER_ROUND` arithmetic and - /// prevents governance typo from setting a multi-year epoch. + /// Bounds on `epoch_duration_sec`. Floor is a typo-guard: below it a pay + /// period holds fewer block slots than one producer rotation, so the + /// per-block pay in sysio.system::payepoch degenerates to a handful of + /// slots per producer, masking misconfig. Ceiling bounds the slot + /// arithmetic and prevents a governance typo from setting a multi-year epoch. static constexpr uint32_t MIN_EPOCH_DURATION_SEC = 60; static constexpr uint32_t MAX_EPOCH_DURATION_SEC = 30u * 24u * 60u * 60u; diff --git a/contracts/sysio.epoch/sysio.epoch.wasm b/contracts/sysio.epoch/sysio.epoch.wasm index a6c81423c6b049901180ffca574ed54b210cc949..cf4bf2df6c6e1985cb1b148ca817acaa6f382f0c 100755 GIT binary patch literal 78946 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 literal 94459 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 PF::max(); // sentinel: no previous block // All fields (including max_action_return_value_size, KV limits) are now // in the base sysio::blockchain_parameters struct. @@ -92,7 +90,6 @@ namespace sysiosystem { block_timestamp last_producer_schedule_update; time_point last_pervote_bucket_fill; - uint32_t total_unpaid_blocks = 0; /// all blocks which have been produced but not paid uint16_t last_producer_schedule_size = 0; /// Producer of the previous block -- the cursor `onblock` walks to attribute a MISSED round: @@ -104,13 +101,14 @@ namespace sysiosystem { /// Rescore cursor. A weight change (`setscorecfg`) or a `req_prod_collat` change /// invalidates every stored `rank_score`, and the producers table is unbounded because - /// `regproducer` is permissionless. Rather than a mass rewrite, `rescore_generation` is - /// bumped and `onblock` drains `rescore_cursor` a bounded number of rows per schedule-rebuild - /// tick. The cursor walks PRIMARY-key order: rescoring mutates the secondary key, so walking + /// `regproducer` is permissionless. Rather than a mass rewrite, `rescore_pending` is set + /// and `onblock` drains `rescore_cursor` a bounded number of rows per schedule-rebuild tick. + /// The cursor walks PRIMARY-key order: rescoring mutates the secondary key, so walking /// `prodrank` would revisit or skip rows. uint64_t rescore_cursor = 0; - /// Non-zero while a rescore sweep is in progress. - uint32_t rescore_generation = 0; + /// True while a rescore sweep is in progress. A change landing mid-sweep restarts the + /// cursor from 0 under the same flag; there is nothing to count. + bool rescore_pending = false; /// `config_timestamp_ms` of the `req_prod_collat` entries the stored scores were computed /// against, or 0 when that requirement vector was empty. @@ -129,10 +127,9 @@ namespace sysiosystem { SYSLIB_SERIALIZE_DERIVED( sysio_global_state, sysio::blockchain_parameters, (max_ram_size)(total_ram_bytes_reserved) (last_producer_schedule_update)(last_pervote_bucket_fill) - (total_unpaid_blocks) (last_producer_schedule_size) (last_producer) - (rescore_cursor)(rescore_generation) + (rescore_cursor)(rescore_pending) (scored_collateral_stamp) ) }; @@ -156,13 +153,16 @@ namespace sysiosystem { uint64_t rank_score = producer_rank::unscored(); bool is_active = true; std::string url; + /// Blocks produced and not yet paid -- the ONE pay input. `payepoch` credits every block at + /// the period's per-block rate and zeroes the count of every producer it pays; a block the + /// producer's slot did not deliver is simply never counted, so its pay stays in the treasury. + /// The count SURVIVES a park, a demotion, or a lost key: a producer that is not schedulable + /// at a payepoch is neither paid nor reset, and whatever it made is paid at the first + /// payepoch where it is schedulable again (never, for a slashed or terminated one). uint32_t unpaid_blocks = 0; time_point last_claim_time; uint16_t location = 0; sysio::block_signing_authority producer_authority; // added in version 1.9.0 - uint32_t last_block_num = no_prev_block; - uint16_t current_round_blocks = 0; // blocks in current (in-progress) round - uint32_t eligible_rounds = 0; // rounds meeting >= min_blocks threshold (per epoch) /// Rounds this producer was scheduled for and produced nothing in, consecutively. Reset to 0 /// the moment it produces. At prodscorecfg's max_consecutive_missed_rounds it sets /// `is_demoted`; see producer_rank.hpp. @@ -183,7 +183,6 @@ namespace sysiosystem { } SYSLIB_SERIALIZE( producer_info, (owner)(producer_key)(rank_score)(is_active)(url)(unpaid_blocks)(last_claim_time)(location)(producer_authority) - (last_block_num)(current_round_blocks)(eligible_rounds) (consecutive_missed_rounds)(is_demoted)(snapshot_attestations) ) }; @@ -477,8 +476,8 @@ namespace sysiosystem { * Each weight scales one normalised factor of the composite score that orders the * `prodrank` index; a weight of 0 removes that factor's influence entirely, which is how * `relay` / `api` / `benchmark` ship until an attestation path exists for them. Changing a - * weight invalidates every stored `rank_score`, so this bumps the global's rescore - * generation and `onblock` drains the cursor. + * weight invalidates every stored `rank_score`, so this flags a rescore sweep on the + * global and `onblock` drains the cursor. * * @param weights - the full weight set plus the demotion threshold. * diff --git a/contracts/sysio.system/src/emissions.cpp b/contracts/sysio.system/src/emissions.cpp index f41133d627..17b0790c2a 100644 --- a/contracts/sysio.system/src/emissions.cpp +++ b/contracts/sysio.system/src/emissions.cpp @@ -40,11 +40,9 @@ namespace { constexpr sysio::symbol WIRE_SYMBOL{"WIRE", 9}; -constexpr uint32_t ACTIVE_PRODUCER_COUNT = 21; constexpr uint32_t STANDBY_START_RANK = 22; -constexpr uint32_t MAX_STANDBY_END_RANK = 100; // safety cap: bounds inline-action count in payepoch -constexpr uint32_t TOTAL_BLOCKS_PER_ROUND = ACTIVE_PRODUCER_COUNT * blocks_per_round; // 252 -constexpr uint32_t ACTIVE_PRODUCER_WEIGHT = 15; // > any standby weight (1..cfg.standby_end_rank-21) +constexpr uint32_t MAX_STANDBY_END_RANK = 100; // safety cap: bounds the standby credit count in payepoch +constexpr int64_t MS_PER_SECOND = 1000; // Basis-point denominator for all category / sub-split ratios. constexpr int64_t BPS_DENOMINATOR = 10000; @@ -260,7 +258,7 @@ emission_config get_emit_cfg(name self) { } // Canonical epoch duration lives on sysio.epoch::epochcfg. Both payepoch -// (producer expected_rounds) and viewepoch (seconds_until_next) read it +// (the producer pay period's slot count) and viewepoch (seconds_until_next) read it // here cross-contract so the value cannot drift from what advance() uses. uint32_t get_epoch_duration_sec() { sysio::epoch::epochcfg_t cfg_tbl(epoch_refs::account); @@ -318,6 +316,8 @@ void system_contract::setemitcfg(const emissions::emission_config& cfg) { "standby_end_rank must be >= standby_start_rank (22)"); sysio::check(cfg.standby_end_rank <= MAX_STANDBY_END_RANK, "standby_end_rank exceeds safety cap"); + sysio::check(cfg.standby_bps <= BPS_DENOMINATOR, + "standby_bps must be <= 10000"); // Audit-log retention sysio::check(cfg.epoch_log_retention_count > 0, @@ -794,144 +794,129 @@ void system_contract::payepoch(uint32_t epoch_index, int64_t fee_paid = 0; // swap-fee rewards actually distributed (does NOT count toward treasury) // ======================================================================= - // Producer + standby pay. Active producers (rank 1..21) are paid in - // proportion to their eligible_rounds across the pay period; standbys - // (rank 22..cfg.standby_end_rank) are paid by the existing rank- - // decreasing weight without an eligible_rounds requirement. Producer - // counters accumulate across non-pay epochs (no reset by accrueepoch) - // and are zeroed at the end of this action. Recipients are filtered - // by opreg status so slashed / terminated operators are skipped. + // Producer + standby pay. + // + // Producers are paid PER BLOCK. The active slice of the producer pool is spread over the block + // slots the period held, and every schedulable producer is credited that rate for each block + // it made. A missed block is never counted, so its pay stays in the treasury: it does not flow + // to the producers that did show up, because the rate does not depend on who did. The divisor + // is the period's nominal slot count, raised to the blocks actually produced when a period runs + // long (an epoch can extend while a batch operator delivers), so the slice is never exceeded. + // + // Standbys (positions 22..cfg.standby_end_rank) draw a retainer from the standby slice + // (cfg.standby_bps of the pool). Each POSITION holds a fixed share, decaying linearly from + // position 22, over the constant sum of every position's weight -- a vacant position's share + // stays in the treasury. Block pay is not gated on position, so a producer that slid from 21 + // to 22 mid-period is still paid for the blocks it made before the schedule caught up. + // + // Counters accumulate across non-pay epochs (no reset by accrueepoch) and are zeroed at the + // end of this action for every producer PAID by it. A producer that is not schedulable when the + // walk reaches it -- parked, keyless, slashed, terminated -- is neither paid nor reset: its + // block count waits for the first payepoch where it is schedulable again (a parked or re-keyed + // producer's return; never, for a slashed or terminated one). A producer BELOW the walk + // (demoted, or parked and already rescored) is not visited at all, with the same effect. Every + // block a producer makes is paid exactly once, at the first payepoch where it is payable. // ======================================================================= { auto prod_by_rank = _producers.get_index<"prodrank"_n>(); - // expected_rounds is derived from the configured epoch duration on - // sysio.epoch (canonical source of truth) scaled by the period's ACTUAL - // accrued epoch count, because elig_rounds accumulates across exactly those - // epochs. It must NOT scale by cfg.pay_cadence_epochs: a mid-period cadence - // change makes the two disagree (see accrued_epochs above), and the - // mismatch silently distorts every producer's pay share -- too small a - // denominator lets everyone hit the clamp and collect their full share, too - // large a one forfeits pay that was earned. Unlike the batch-op pool this - // cannot overpay past producer_pool (the clamp bounds each share by - // emis_share), so it skews proportions rather than the total. - const uint32_t epoch_duration_sec = get_epoch_duration_sec(); - // Compute in uint64: epoch_duration_sec (<= 30 days) * the accrued epoch - // count * 2 overflows uint32 at the extremes, and a wrapped - // denominator would silently distort every producer's pay share. uint64 - // holds the full product with room to spare; the result is a small round - // count that fits back into uint64 for the divide below. - uint64_t expected_rounds = - (static_cast(epoch_duration_sec) - * static_cast(accrued_epochs > 0 ? accrued_epochs : 1) * 2) / TOTAL_BLOCKS_PER_ROUND; - // Below ~126s of effective period duration (one full 21-producer round - // at 0.5s/block), expected_rounds truncates to zero. Falling back to 1 - // keeps the pay formula well-defined -- producer pay collapses to - // "elig_rounds clamped to 1, pay = full_share" at the floor. This - // coarse-grained pay is the price of allowing sub-rotation period - // durations; documented at MIN_EPOCH_DURATION_SEC. - if (expected_rounds == 0) expected_rounds = 1; - - struct prod_entry { + const int64_t standby_pool = split_bps(producer_pool, cfg.standby_bps); + const int64_t active_pool = producer_pool - standby_pool; + + // Nominal block slots in the period: the configured epoch duration (canonical on + // sysio.epoch) times the epochs the period ACTUALLY accrued -- never + // cfg.pay_cadence_epochs, which a mid-period change makes disagree with the accrual -- + // at one slot per block interval. uint64: a 30-day epoch times a large cadence overflows + // uint32. + const uint64_t nominal_slots = + static_cast(get_epoch_duration_sec()) + * static_cast(accrued_epochs > 0 ? accrued_epochs : 1) + * static_cast(MS_PER_SECOND) + / static_cast(sysio::block_timestamp::block_interval_ms); + + // Standby position weights run N at position 22 down to 1 at standby_end_rank; their sum + // is the divisor, so a position's share is the same whether or not it is filled. + const uint64_t standby_positions = cfg.standby_end_rank + 1 - STANDBY_START_RANK; + const uint64_t standby_weight_sum = standby_positions * (standby_positions + 1) / 2; + + struct pay_entry { name owner; - uint32_t weight; - uint32_t elig_rounds; - bool is_standby; + uint32_t blocks; + uint64_t standby_weight; }; - std::vector eligible; - std::vector to_reset; // snapshot before modify: avoids - // iterating while mutating secondary idx - uint32_t total_weight = 0; - - // Single pass over the rank-ordered producers: builds both the pay list - // (eligible) and the counter-reset list (to_reset). The lists differ -- - // to_reset includes slashed / terminated producers with stale counters, - // eligible does not. - // `rank` is POSITION in this index among SCHEDULABLE producers, counted while walking -- not - // a stored ordinal. The demoted tier sorts last and is never schedulable, so it bounds the - // walk over what is a permissionless, unbounded table. + struct reset_entry { + name owner; + bool blocks; // a paid row starts its count over; an unpayable row keeps it + }; + std::vector entries; + std::vector to_reset; // snapshot before modify: avoids + // iterating while mutating secondary idx + uint64_t produced_blocks = 0; + + // Single pass over the rank-ordered producers: builds both the pay list (entries) and the + // counter-reset list (to_reset). `position` is POSITION in this index among SCHEDULABLE + // producers, counted while walking -- not a stored ordinal. The demoted tier sorts last and + // is never schedulable, so it bounds the walk over what is a permissionless, unbounded + // table; every row above it is an ACTIVE bonded producer operator. // - // is_schedulable also requires an active finalizer key, which this loop did not check before. - // That is a fix, not a regression: a producer without one can never be scheduled, so it must - // not draw top-21 pay either. + // The divisor counts exactly the blocks this payepoch pays for. A count that waits on an + // unpayable row is neither paid nor counted now; when its producer is payable again the + // carried blocks are paid at THAT period's rate and counted in THAT period's divisor. uint32_t position = 0; for (auto it = prod_by_rank.begin(); it != prod_by_rank.end(); ++it) { if (producer_rank::tier_of(it->rank_score) == producer_tier::demoted) break; - // Reset list: every producer walked with stale counters gets reset, regardless of - // is_active / opreg status. Slashed producers still need their counters cleared for the - // next epoch. - if (it->unpaid_blocks > 0 || it->eligible_rounds > 0 || it->current_round_blocks > 0 - || it->snapshot_attestations > 0) { - to_reset.push_back(it->owner); + // is_schedulable requires an active row, ACTIVE opreg status, and an active finalizer + // key: a producer missing any of them can never be scheduled, so it draws neither block + // pay nor a standby retainer -- and keeps its block count for when it can. The snapshot + // counter is per period regardless. + if (!producer_rank::is_schedulable(*it, _finalizers)) { + if (it->snapshot_attestations > 0) to_reset.push_back({it->owner, false}); + continue; } - if (!producer_rank::is_schedulable(*it, _finalizers)) continue; - - ++position; - if (position > cfg.standby_end_rank) break; - - uint32_t w = 0; - bool standby = false; - uint32_t rounds = 0; - - if (position <= ACTIVE_PRODUCER_COUNT) { - rounds = it->eligible_rounds; - if (it->current_round_blocks >= min_blocks_per_round_for_pay) rounds++; - if (rounds == 0) continue; - w = ACTIVE_PRODUCER_WEIGHT; - } else if (position >= STANDBY_START_RANK && position <= cfg.standby_end_rank) { - w = cfg.standby_end_rank + 1 - position; - standby = true; + produced_blocks += it->unpaid_blocks; + if (it->unpaid_blocks > 0 || it->snapshot_attestations > 0) { + to_reset.push_back({it->owner, true}); } - if (w > 0) { - eligible.push_back({it->owner, w, rounds, standby}); - total_weight += w; + ++position; + const bool standby = position >= STANDBY_START_RANK && position <= cfg.standby_end_rank; + const uint64_t standby_weight = standby ? cfg.standby_end_rank + 1 - position : 0; + if (it->unpaid_blocks > 0 || standby_weight > 0) { + entries.push_back({it->owner, it->unpaid_blocks, standby_weight}); } } + const uint64_t slot_divisor = std::max(std::max(nominal_slots, produced_blocks), 1); + // Producers are paid the emission share only — swap fees go to the // underwriter + batch operators (see the fold-in comment above). int64_t distributed_to_producers = 0; - if (total_weight > 0) { - for (const auto& pe : eligible) { - const int64_t emis_share = static_cast( - static_cast<__int128>(producer_pool) * pe.weight / total_weight); - int64_t pay; - if (pe.is_standby) { - pay = emis_share; - } else { - uint64_t r = (pe.elig_rounds > expected_rounds) ? expected_rounds : pe.elig_rounds; - pay = static_cast( - static_cast<__int128>(emis_share) * r / expected_rounds); - } - if (pay > 0) { - credit_pay(get_self(), pe.owner, pay, memo::producer_reward); - distributed_to_producers += pay; - } + for (const auto& entry : entries) { + int64_t pay = static_cast( + static_cast<__int128>(active_pool) * entry.blocks / slot_divisor); + if (entry.standby_weight > 0) { + pay += static_cast( + static_cast<__int128>(standby_pool) * entry.standby_weight / standby_weight_sum); + } + if (pay > 0) { + credit_pay(get_self(), entry.owner, pay, memo::producer_reward); + distributed_to_producers += pay; } } actual_paid += distributed_to_producers; - // Reset round-tracking after distribution (iteration-safe: uses PK snapshot). - // The reclaimed count is accumulated across the loop and applied to the global in one - // modify, so the whole reset costs a single deferred KV write rather than one per producer. - uint32_t reclaimed_unpaid_blocks = 0; - for (const auto& owner : to_reset) { - auto key = producer_key_t{owner.value}; + // Reset the period's counters after distribution (iteration-safe: uses PK snapshot). + for (const auto& entry : to_reset) { + auto key = producer_key_t{entry.owner.value}; _producers.modify(same_payer, key, [&](auto& p) { - reclaimed_unpaid_blocks += p.unpaid_blocks; - p.unpaid_blocks = 0; - p.eligible_rounds = 0; - p.current_round_blocks = 0; - p.last_block_num = no_prev_block; + if (entry.blocks) p.unpaid_blocks = 0; p.snapshot_attestations = 0; }); - } - if (reclaimed_unpaid_blocks > 0) { - _global.modify(get_self(), [&](auto& g) { g.total_unpaid_blocks -= reclaimed_unpaid_blocks; }); + // Zeroing snapshot_attestations moved the snapshot factor; keep the sort key in step. + rescore_producer(entry.owner); } } diff --git a/contracts/sysio.system/src/peer_keys.cpp b/contracts/sysio.system/src/peer_keys.cpp index 3bdc6166bd..1b6f4c3d66 100644 --- a/contracts/sysio.system/src/peer_keys.cpp +++ b/contracts/sysio.system/src/peer_keys.cpp @@ -59,18 +59,22 @@ peer_keys::getpeerkeys_res_t peer_keys::getpeerkeys() { }; auto idx = producers.get_index<"prodrank"_n>(); - finalizers_table finalizers(get_self()); - // `rank` is POSITION among SCHEDULABLE producers, so this counts matches rather than taking the + // `rank` is POSITION among ELIGIBLE producers, so this counts matches rather than taking the // first `max_rank` index entries. Taking the first N would let unbonded registrants -- which // occupy index slots but can never be scheduled -- crowd real producers out of peer discovery. - // The demoted tier sorts last and is never schedulable, so it also bounds the walk over what is + // The demoted tier sorts last and is never eligible, so it also bounds the walk over what is // a permissionless, unbounded table. + // + // Peer discovery walks `is_eligible_operator`, NOT `is_schedulable`: it must not require a + // finalizer key. A producer scheduled through `setprods` -- the bootstrap window, and every + // harness that publishes schedules directly -- produces blocks before it registers one, and a + // block producer that `getpeerkeys` hides is a block producer the BP gossip mesh cannot reach. uint32_t position = 0; for (auto i = idx.cbegin(); i != idx.cend() && resp.size() < max_return; ++i) { if (producer_rank::tier_of(i->rank_score) == producer_tier::demoted) break; - if (!producer_rank::is_schedulable(*i, finalizers)) + if (!producer_rank::is_eligible_operator(*i)) continue; if (++position > max_rank) break; diff --git a/contracts/sysio.system/src/producer_pay.cpp b/contracts/sysio.system/src/producer_pay.cpp index 225ffb781d..81064eb76d 100644 --- a/contracts/sysio.system/src/producer_pay.cpp +++ b/contracts/sysio.system/src/producer_pay.cpp @@ -34,45 +34,15 @@ namespace sysiosystem { * At startup the initial producer may not be one that is registered / elected * and therefore there may be no producer object for them. */ + // Pay is per block: count it. payepoch credits every counted block at the period's rate and + // zeroes the count; a block this producer's slot did not deliver is simply never counted. auto key = producer_key_t{producer.value}; if ( _producers.contains(key) ) { - // Round-boundary detection uses the global's total_unpaid_blocks as a - // per-producer "sequence stamp" -- NOT a monotonic block height. - // The counter is decremented by processepoch when it resets producer - // unpaid_blocks, so its absolute value is not stable across epochs. - // The gap check (stamp != last_stamp + 1) only remains correct because - // processepoch ALSO resets each producer's last_block_num to the - // no_prev_block sentinel, forcing the check to skip on the first - // onblock after a reset. Invariant: if a producer's last_block_num is - // non-sentinel, some counter (unpaid_blocks / eligible_rounds / - // current_round_blocks) is non-zero, so processepoch will reset it. - uint32_t prod_counter_stamp = _global.get().total_unpaid_blocks; // capture BEFORE increment - _global.modify( get_self(), []( auto& g ) { g.total_unpaid_blocks++; }); - _producers.modify( same_payer, key, [&](auto& p) { - p.unpaid_blocks++; - - // Round boundary detection: gap in sequence = new round started - if (p.last_block_num != no_prev_block && prod_counter_stamp != p.last_block_num + 1) { - // Previous round ended - check threshold - if (p.current_round_blocks >= min_blocks_per_round_for_pay) { - p.eligible_rounds++; - } - p.current_round_blocks = 0; - } - - p.current_round_blocks++; - p.last_block_num = prod_counter_stamp; - - // Full round always eligible - if (p.current_round_blocks >= blocks_per_round) { - p.eligible_rounds++; - p.current_round_blocks = 0; - } - }); + _producers.modify( same_payer, key, []( auto& p ) { p.unpaid_blocks++; }); } - // Attribute the rounds nobody produced. This must happen on every block: the counters above - // record PRESENCE only -- a producer that produces nothing is never visited by onblock at + // Attribute the rounds nobody produced. This must happen on every block: the count above + // records PRESENCE only -- a producer that produces nothing is never visited by onblock at // all, so absence leaves no trace unless the schedule is walked explicitly. record_round_participation( producer ); @@ -97,7 +67,10 @@ namespace sysiosystem { // Mid-round: the same producer made the previous block, so no slot was skipped and its miss // counter was already cleared on the first block of this round. This is 11 of every 12 // blocks, and returning here keeps the schedule read and the snapshot compare off the hot - // path for all of them. + // path for all of them. The same test also fires when EVERY other producer missed and the + // round-robin came back to this one; that case is indistinguishable from mid-round here and + // is deliberately left uncharged -- with every other producer absent the chain has no + // finality left to activate a replacement schedule anyway. if( state.last_producer == current_producer ) return; const auto active_schedule = sysio::get_active_producers(); @@ -167,7 +140,8 @@ namespace sysiosystem { }); // The miss moved the participation factor, and a demotion moved the tier; either way the - // stored sort key is stale. + // stored sort key is stale. A demoted producer keeps its block count: it is paid for those + // blocks at the first payepoch after regproducer brings it back into the pay walk. rescore_producer( producer ); } @@ -188,13 +162,13 @@ namespace sysiosystem { _global.modify( get_self(), [&]( auto& g ) { g.scored_collateral_stamp = stamp; g.rescore_cursor = 0; - g.rescore_generation++; + g.rescore_pending = true; }); } void system_contract::drain_rescore_cursor() { const auto& state = _global.get(); - if( state.rescore_generation == 0 ) return; + if( !state.rescore_pending ) return; // Bounded per tick, mirroring opreg's MAX_WTDW_FLUSH_PER_EPOCH: the producers table is // unbounded, so a weight change can never rewrite it inline. @@ -221,8 +195,8 @@ namespace sysiosystem { } _global.modify( get_self(), [&]( auto& g ) { - g.rescore_cursor = done ? 0 : cursor; - g.rescore_generation = done ? 0 : g.rescore_generation; + g.rescore_cursor = done ? 0 : cursor; + g.rescore_pending = !done; }); } diff --git a/contracts/sysio.system/src/ranking.cpp b/contracts/sysio.system/src/ranking.cpp index 180b079dde..9d3b328046 100644 --- a/contracts/sysio.system/src/ranking.cpp +++ b/contracts/sysio.system/src/ranking.cpp @@ -71,24 +71,7 @@ namespace sysiosystem { } void system_contract::rescore_producer( const name& producer ) { - auto key = producer_key_t{producer.value}; - if( !_producers.contains(key) ) return; - - producer_rank::producer_score_config_t weights_tbl( get_self() ); - const auto weights = weights_tbl.get_or_default( producer_rank::producer_score_config{} ); - - const auto info = _producers.get(key); - const auto score = producer_rank::compute( - producer, - producer_rank::score_inputs{ - .is_demoted = info.is_demoted, - .consecutive_missed_rounds = info.consecutive_missed_rounds, - .snapshot_attestations = info.snapshot_attestations - }, - weights ); - - if( score == info.rank_score ) return; // no index move needed - _producers.modify( same_payer, key, [&]( auto& p ) { p.rank_score = score; }); + producer_rank::rescore( get_self(), _producers, producer ); } void system_contract::onprocessprod( name account, bool, bool ) { @@ -108,8 +91,8 @@ namespace sysiosystem { // unbounded table inline, open a rescore sweep: onblock drains a bounded number of rows per // schedule-rebuild tick until the cursor is exhausted. _global.modify( get_self(), []( auto& g ) { - g.rescore_cursor = 0; - g.rescore_generation++; + g.rescore_cursor = 0; + g.rescore_pending = true; }); } diff --git a/contracts/sysio.system/src/snapshot_attest.cpp b/contracts/sysio.system/src/snapshot_attest.cpp index f9dabb1d41..8cb850b10a 100644 --- a/contracts/sysio.system/src/snapshot_attest.cpp +++ b/contracts/sysio.system/src/snapshot_attest.cpp @@ -102,6 +102,9 @@ void credit_snapshot_attestations(name self, const std::vector& voters) { auto key = producer_key_t{voter.value}; if (!producers.contains(key)) continue; producers.modify(same_payer, key, [](auto& row) { row.snapshot_attestations++; }); + // The credit moved the snapshot factor, so the stored sort key is stale until rescored. + // Without this the factor would reach the index only on the next unrelated rescore. + producer_rank::rescore(self, producers, voter); } } diff --git a/contracts/sysio.system/sysio.system.abi b/contracts/sysio.system/sysio.system.abi index 70d325dc9a..534cd6a1ff 100644 --- a/contracts/sysio.system/sysio.system.abi +++ b/contracts/sysio.system/sysio.system.abi @@ -412,6 +412,10 @@ "name": "standby_end_rank", "type": "uint32" }, + { + "name": "standby_bps", + "type": "uint16" + }, { "name": "epoch_log_retention_count", "type": "uint32" @@ -988,18 +992,6 @@ "name": "producer_authority", "type": "block_signing_authority" }, - { - "name": "last_block_num", - "type": "uint32" - }, - { - "name": "current_round_blocks", - "type": "uint16" - }, - { - "name": "eligible_rounds", - "type": "uint32" - }, { "name": "consecutive_missed_rounds", "type": "uint32" @@ -1500,10 +1492,6 @@ "name": "last_pervote_bucket_fill", "type": "time_point" }, - { - "name": "total_unpaid_blocks", - "type": "uint32" - }, { "name": "last_producer_schedule_size", "type": "uint16" @@ -1517,8 +1505,8 @@ "type": "uint64" }, { - "name": "rescore_generation", - "type": "uint32" + "name": "rescore_pending", + "type": "bool" }, { "name": "scored_collateral_stamp", diff --git a/contracts/sysio.system/sysio.system.wasm b/contracts/sysio.system/sysio.system.wasm index d0fa6834c2069253c0562f3eb359ea81fe5d2b2b..b506fa3c7e56e20aa32980655ce4b8dfdbe96784 100755 GIT binary patch delta 31116 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 K)4tN<`Tqbu{T8MG delta 32111 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 +#include + #include #include #include @@ -137,6 +139,7 @@ struct emit_cfg_result { uint16_t producer_bps; uint16_t batch_op_bps; uint32_t standby_end_rank; + uint16_t standby_bps; uint32_t epoch_log_retention_count; }; FC_REFLECT( emit_cfg_result, @@ -147,7 +150,7 @@ FC_REFLECT( emit_cfg_result, (annual_initial_emission)(annual_max_emission)(annual_min_emission) (compute_bps)(capex_bps)(governance_bps) (producer_bps)(batch_op_bps) - (standby_end_rank)(epoch_log_retention_count) ) + (standby_end_rank)(standby_bps)(epoch_log_retention_count) ) // T5 test helper: compute expected split static int64_t test_split_bps(int64_t total, uint16_t bps) { @@ -225,6 +228,11 @@ static constexpr uint16_t PRODUCER_BPS = 7000; static constexpr uint32_t T_ACTIVE_PRODUCER_COUNT = 21; static constexpr uint32_t T_STANDBY_START_RANK = 22; static constexpr uint32_t T_STANDBY_END_RANK = 28; +/// Share of the producer pool reserved for the standby retainer -- 8% keeps the economics where +/// the weight-based model left them (28 of 343 weight units at full attendance). +static constexpr uint16_t T_STANDBY_BPS = 800; +/// The fixture's default epoch (init_epoch_state) is 60s at one block per 500ms. +static constexpr uint32_t T_EPOCH_SECS = 60; // Helper: amount NOT transferred at payepoch when no producers / batch // members are paid. Equals producer_pool + batch_pool (compute share, both @@ -240,6 +248,37 @@ static int64_t compute_undistributed_if_no_operators(int64_t emission) { return emission - capex - gov; } +// --------------------------------------------------------------------------- +// Producer pay model (pay per block + a position-decaying standby retainer) +// --------------------------------------------------------------------------- + +/// Block slots a pay period of `epoch_secs` (cadence 1) holds: one per 500ms block interval. +static uint64_t test_nominal_slots(uint32_t epoch_secs) { + return static_cast(epoch_secs) * 1000 / 500; +} +/// The slice of the producer pool spread over the period's slots as the per-block rate. +static int64_t test_active_pool(int64_t compute) { + const int64_t producer_pool = test_split_bps(compute, PRODUCER_BPS); + return producer_pool - test_split_bps(producer_pool, T_STANDBY_BPS); +} +/// The slice of the producer pool reserved for the standby retainer. +static int64_t test_standby_pool(int64_t compute) { + return test_split_bps(test_split_bps(compute, PRODUCER_BPS), T_STANDBY_BPS); +} +/// What `blocks` blocks earn: the active slice over `divisor` slots (the nominal count, raised to +/// the blocks actually produced when the period ran long), truncated exactly as payepoch does. +static int64_t test_block_pay(int64_t active_pool, uint64_t blocks, uint64_t divisor) { + return static_cast(static_cast<__int128>(active_pool) * blocks / divisor); +} +/// A standby POSITION's fixed share of the retainer: weight N at position 22 down to 1 at +/// T_STANDBY_END_RANK, over the constant sum of every position's weight. +static int64_t test_standby_pay(int64_t standby_pool, uint32_t position) { + const uint64_t positions = T_STANDBY_END_RANK + 1 - T_STANDBY_START_RANK; + const uint64_t weight_sum = positions * (positions + 1) / 2; + const uint64_t weight = T_STANDBY_END_RANK + 1 - position; + return static_cast(static_cast<__int128>(standby_pool) * weight / weight_sum); +} + class sysio_emissions_tester : public tester { public: sysio_emissions_tester() { @@ -569,6 +608,7 @@ class sysio_emissions_tester : public tester { ("producer_bps", PRODUCER_BPS) ("batch_op_bps", uint16_t(3000)) ("standby_end_rank", T_STANDBY_END_RANK) + ("standby_bps", T_STANDBY_BPS) ("epoch_log_retention_count", uint32_t(8640)) ("pay_cadence_epochs", cadence); } @@ -746,6 +786,16 @@ class sysio_emissions_tester : public tester { abi_serializer::create_yield_function(abi_serializer_max_time)); } + /// Blocks `producer` has made this pay period -- the row's `unpaid_blocks`, the one pay input. + /// Read after the last block-closing call (`produce_blocks`, and every `push_system_action` + /// such as `initt5`) and before the advance is pushed: the pending block's onblock has already + /// counted, and the advance lands in that same pending block. + uint32_t unpaid_blocks_of( account_name producer ) { + auto info = get_producer_info(producer); + BOOST_REQUIRE_MESSAGE(!info.is_null(), "no producers row for " << producer.to_string()); + return info["unpaid_blocks"].as(); + } + // ----------------------------- // Producer name helpers // ----------------------------- @@ -2063,7 +2113,7 @@ BOOST_FIXTURE_TEST_CASE( setemitcfg_requires_sysio_auth, sysio_emissions_tester ("annual_initial_emission", int64_t(1)) ("annual_max_emission", int64_t(1)) ("annual_min_emission", int64_t(0)) ("compute_bps", uint16_t(10000)) ("capex_bps", uint16_t(0)) ("governance_bps", uint16_t(0)) ("producer_bps", uint16_t(5000)) ("batch_op_bps", uint16_t(5000)) - ("standby_end_rank", uint32_t(28)) + ("standby_end_rank", uint32_t(28))("standby_bps", T_STANDBY_BPS) ("epoch_log_retention_count", uint32_t(8640))("pay_cadence_epochs", uint16_t(1)); auto r = setemitcfg("alice"_n, cfg); @@ -2083,7 +2133,7 @@ BOOST_FIXTURE_TEST_CASE( setemitcfg_rejects_bad_category_bps, sysio_emissions_te ("annual_initial_emission", int64_t(1)) ("annual_max_emission", int64_t(1)) ("annual_min_emission", int64_t(0)) ("compute_bps", uint16_t(5000)) ("capex_bps", uint16_t(4000)) ("governance_bps", uint16_t(2000)) ("producer_bps", uint16_t(5000)) ("batch_op_bps", uint16_t(5000)) - ("standby_end_rank", uint32_t(28)) + ("standby_end_rank", uint32_t(28))("standby_bps", T_STANDBY_BPS) ("epoch_log_retention_count", uint32_t(8640))("pay_cadence_epochs", uint16_t(1)); auto r = setemitcfg(config::system_account_name, cfg); @@ -2101,7 +2151,7 @@ BOOST_FIXTURE_TEST_CASE( setemitcfg_rejects_bad_compute_subsplit, sysio_emission ("annual_initial_emission", int64_t(1)) ("annual_max_emission", int64_t(1)) ("annual_min_emission", int64_t(0)) ("compute_bps", uint16_t(4000)) ("capex_bps", uint16_t(2000)) ("governance_bps", uint16_t(1000)) ("producer_bps", uint16_t(6000)) ("batch_op_bps", uint16_t(3000)) - ("standby_end_rank", uint32_t(28)) + ("standby_end_rank", uint32_t(28))("standby_bps", T_STANDBY_BPS) ("epoch_log_retention_count", uint32_t(8640))("pay_cadence_epochs", uint16_t(1)); auto r = setemitcfg(config::system_account_name, cfg); @@ -2119,7 +2169,7 @@ BOOST_FIXTURE_TEST_CASE( setemitcfg_rejects_zero_duration, sysio_emissions_teste ("annual_initial_emission", int64_t(1)) ("annual_max_emission", int64_t(1)) ("annual_min_emission", int64_t(0)) ("compute_bps", uint16_t(4000)) ("capex_bps", uint16_t(2000)) ("governance_bps", uint16_t(1000)) ("producer_bps", uint16_t(7000)) ("batch_op_bps", uint16_t(3000)) - ("standby_end_rank", uint32_t(28)) + ("standby_end_rank", uint32_t(28))("standby_bps", T_STANDBY_BPS) ("epoch_log_retention_count", uint32_t(8640))("pay_cadence_epochs", uint16_t(1)); auto r = setemitcfg(config::system_account_name, cfg); @@ -2139,7 +2189,7 @@ BOOST_FIXTURE_TEST_CASE( setemitcfg_rejects_invalid_decay_target, sysio_emission ("annual_initial_emission", int64_t(1)) ("annual_max_emission", int64_t(1)) ("annual_min_emission", int64_t(0)) ("compute_bps", uint16_t(4000)) ("capex_bps", uint16_t(2000)) ("governance_bps", uint16_t(1000)) ("producer_bps", uint16_t(7000)) ("batch_op_bps", uint16_t(3000)) - ("standby_end_rank", uint32_t(28)) + ("standby_end_rank", uint32_t(28))("standby_bps", T_STANDBY_BPS) ("epoch_log_retention_count", uint32_t(8640))("pay_cadence_epochs", uint16_t(1)); }; @@ -2173,7 +2223,7 @@ BOOST_FIXTURE_TEST_CASE( setemitcfg_rejects_round_to_zero_per_epoch, sysio_emiss ("compute_bps", COMPUTE_BPS) ("capex_bps", CAPEX_BPS) ("governance_bps", uint16_t(1000)) ("producer_bps", PRODUCER_BPS) ("batch_op_bps", uint16_t(3000)) - ("standby_end_rank", T_STANDBY_END_RANK) + ("standby_end_rank", T_STANDBY_END_RANK)("standby_bps", T_STANDBY_BPS) ("epoch_log_retention_count", uint32_t(8640))("pay_cadence_epochs", uint16_t(1)); }; @@ -2203,7 +2253,7 @@ BOOST_FIXTURE_TEST_CASE( setemitcfg_rejects_bad_standby_rank, sysio_emissions_te ("annual_initial_emission", int64_t(1)) ("annual_max_emission", int64_t(1)) ("annual_min_emission", int64_t(0)) ("compute_bps", uint16_t(4000)) ("capex_bps", uint16_t(2000)) ("governance_bps", uint16_t(1000)) ("producer_bps", uint16_t(7000)) ("batch_op_bps", uint16_t(3000)) - ("standby_end_rank", uint32_t(21)) + ("standby_end_rank", uint32_t(21))("standby_bps", T_STANDBY_BPS) ("epoch_log_retention_count", uint32_t(8640))("pay_cadence_epochs", uint16_t(1)); auto r = setemitcfg(config::system_account_name, cfg); @@ -2222,7 +2272,7 @@ BOOST_FIXTURE_TEST_CASE( setemitcfg_rejects_standby_rank_over_cap, sysio_emissio ("annual_initial_emission", int64_t(1)) ("annual_max_emission", int64_t(1)) ("annual_min_emission", int64_t(0)) ("compute_bps", uint16_t(4000)) ("capex_bps", uint16_t(2000)) ("governance_bps", uint16_t(1000)) ("producer_bps", uint16_t(7000)) ("batch_op_bps", uint16_t(3000)) - ("standby_end_rank", uint32_t(101)) + ("standby_end_rank", uint32_t(101))("standby_bps", T_STANDBY_BPS) ("epoch_log_retention_count", uint32_t(8640))("pay_cadence_epochs", uint16_t(1)); auto r = setemitcfg(config::system_account_name, cfg); @@ -2230,6 +2280,14 @@ BOOST_FIXTURE_TEST_CASE( setemitcfg_rejects_standby_rank_over_cap, sysio_emissio require_substr( r, "standby_end_rank exceeds safety cap" ); } FC_LOG_AND_RETHROW() +BOOST_FIXTURE_TEST_CASE( setemitcfg_rejects_standby_bps_over_full, sysio_emissions_tester ) try { + // The retainer is a slice of the producer pool; more than the whole pool is a typo. + auto cfg = mvo(default_emit_cfg(uint16_t(1)))("standby_bps", uint16_t(10001)); + auto r = setemitcfg(config::system_account_name, cfg); + BOOST_REQUIRE( r != success() ); + require_substr( r, "standby_bps must be <= 10000" ); +} FC_LOG_AND_RETHROW() + BOOST_FIXTURE_TEST_CASE( setinittime_rejects_epoch_zero, sysio_emissions_tester ) try { // time_point_sec{} default-constructs to epoch 0; accepting it would brick // claim paths permanently via compute_node_claim's start_secs > 0 guard. @@ -2259,7 +2317,7 @@ BOOST_FIXTURE_TEST_CASE( setemitcfg_reconfigurable, sysio_emissions_tester ) try ("governance_bps", uint16_t(1000)) ("producer_bps", uint16_t(7000)) ("batch_op_bps", uint16_t(3000)) - ("standby_end_rank", uint32_t(28)) + ("standby_end_rank", uint32_t(28))("standby_bps", T_STANDBY_BPS) ("epoch_log_retention_count", uint32_t(8640))("pay_cadence_epochs", uint16_t(1)); BOOST_REQUIRE_EQUAL( success(), setemitcfg(config::system_account_name, cfg) ); @@ -2298,6 +2356,7 @@ BOOST_FIXTURE_TEST_CASE( viewemitcfg_returns_current_config, sysio_emissions_tes BOOST_REQUIRE_EQUAL( cfg.producer_bps, PRODUCER_BPS ); BOOST_REQUIRE_EQUAL( cfg.batch_op_bps, uint16_t(3000) ); BOOST_REQUIRE_EQUAL( cfg.standby_end_rank, T_STANDBY_END_RANK ); + BOOST_REQUIRE_EQUAL( cfg.standby_bps, T_STANDBY_BPS ); } FC_LOG_AND_RETHROW() /// The view actions exist to be called through clio --read / send_read_only_transaction, so they @@ -2434,7 +2493,7 @@ BOOST_FIXTURE_TEST_CASE( viewemitcfg_reflects_update, sysio_emissions_tester ) t ("governance_bps", uint16_t(2500)) ("producer_bps", uint16_t(5000)) ("batch_op_bps", uint16_t(5000)) - ("standby_end_rank", uint32_t(30)) + ("standby_end_rank", uint32_t(30))("standby_bps", uint16_t(1234)) ("epoch_log_retention_count", uint32_t(2880))("pay_cadence_epochs", uint16_t(1)); BOOST_REQUIRE_EQUAL( success(), setemitcfg(config::system_account_name, cfg) ); @@ -2448,6 +2507,7 @@ BOOST_FIXTURE_TEST_CASE( viewemitcfg_reflects_update, sysio_emissions_tester ) t BOOST_REQUIRE_EQUAL( result.compute_bps, uint16_t(2500) ); BOOST_REQUIRE_EQUAL( result.producer_bps, uint16_t(5000) ); BOOST_REQUIRE_EQUAL( result.standby_end_rank, uint32_t(30) ); + BOOST_REQUIRE_EQUAL( result.standby_bps, uint16_t(1234) ); } FC_LOG_AND_RETHROW() BOOST_AUTO_TEST_SUITE_END() // sysio_emissions_tests @@ -2576,7 +2636,7 @@ BOOST_FIXTURE_TEST_CASE( setemitcfg_post_initt5_rejects_brick_reduce, sysio_emis ("compute_bps", COMPUTE_BPS) ("capex_bps", CAPEX_BPS) ("governance_bps", uint16_t(1000)) ("producer_bps", PRODUCER_BPS) ("batch_op_bps", uint16_t(3000)) - ("standby_end_rank", T_STANDBY_END_RANK) + ("standby_end_rank", T_STANDBY_END_RANK)("standby_bps", T_STANDBY_BPS) ("epoch_log_retention_count", uint32_t(8640))("pay_cadence_epochs", uint16_t(1)); auto r = setemitcfg(config::system_account_name, cfg); @@ -2619,7 +2679,7 @@ BOOST_FIXTURE_TEST_CASE( setemitcfg_post_initt5_rejects_unreachable_min_emission ("compute_bps", COMPUTE_BPS) ("capex_bps", CAPEX_BPS) ("governance_bps", uint16_t(1000)) ("producer_bps", PRODUCER_BPS) ("batch_op_bps", uint16_t(3000)) - ("standby_end_rank", T_STANDBY_END_RANK) + ("standby_end_rank", T_STANDBY_END_RANK)("standby_bps", T_STANDBY_BPS) ("epoch_log_retention_count", uint32_t(8640))("pay_cadence_epochs", uint16_t(1)); auto r = setemitcfg(config::system_account_name, cfg); @@ -2716,7 +2776,7 @@ BOOST_FIXTURE_TEST_CASE( gate_block_reason_change_updates_row, sysio_emissions_t ("compute_bps", COMPUTE_BPS) ("capex_bps", CAPEX_BPS) ("governance_bps", uint16_t(1000)) ("producer_bps", PRODUCER_BPS)("batch_op_bps", uint16_t(3000)) - ("standby_end_rank", T_STANDBY_END_RANK) + ("standby_end_rank", T_STANDBY_END_RANK)("standby_bps", T_STANDBY_BPS) ("epoch_log_retention_count", uint32_t(8640))("pay_cadence_epochs", uint16_t(1)); BOOST_REQUIRE_EQUAL( success(), setemitcfg(config::system_account_name, cfg) ); BOOST_REQUIRE_EQUAL( success(), initt5(config::system_account_name, tpsec(head_secs())) ); @@ -2936,31 +2996,52 @@ BOOST_FIXTURE_TEST_CASE( no_producers_undistributed_stays_in_sysio, sysio_emissi BOOST_REQUIRE_EQUAL( sysio_decrease, emission - undist ); } FC_LOG_AND_RETHROW() -BOOST_FIXTURE_TEST_CASE( active_producers_get_equal_share, sysio_emissions_tester ) try { +// Pay is per block: every producer is credited the period's per-block rate times the blocks it +// made, and the slots nobody filled are paid to nobody -- they stay in the treasury rather than +// flowing to the producers that did show up. +BOOST_FIXTURE_TEST_CASE( active_producers_are_paid_per_block, sysio_emissions_tester ) try { create_t5_holding_accounts(); setup_producers(3); // Wait for schedule to activate, then produce complete cycles wait_for_producer_schedule(); - produce_complete_cycles(3, 2); // 2 cycles sufficient for eligible_rounds + produce_complete_cycles(3, 2); const uint32_t start = head_secs() - ONE_EPOCH - 1; BOOST_REQUIRE_EQUAL( success(), initt5( config::system_account_name, tpsec(start) ) ); - asset bal_a_before = get_wire_balance_paid("producera"_n); - asset bal_b_before = get_wire_balance_paid("producerb"_n); - asset bal_c_before = get_wire_balance_paid("producerc"_n); + const std::vector producers{ "producera"_n, "producerb"_n, "producerc"_n }; + std::map blocks; + std::map before; + uint64_t produced = 0; + for (const auto& p : producers) { + blocks.emplace(p, unpaid_blocks_of(p)); + before.emplace(p, get_wire_balance_paid(p)); + produced += blocks.at(p); + } + BOOST_REQUIRE_GT( produced, 0u ); + // Two rotations of three producers fill far fewer than the period's slots, so the divisor is + // the nominal count and the unfilled slots are the treasury's. + const uint64_t slots = test_nominal_slots(T_EPOCH_SECS); + BOOST_REQUIRE_LT( produced, slots ); + const int64_t t5_before = get_t5_state()["total_distributed"].as(); BOOST_REQUIRE_EQUAL( success(), advance_epoch_state() ); - int64_t got_a = get_wire_balance_paid("producera"_n).get_amount() - bal_a_before.get_amount(); - int64_t got_b = get_wire_balance_paid("producerb"_n).get_amount() - bal_b_before.get_amount(); - int64_t got_c = get_wire_balance_paid("producerc"_n).get_amount() - bal_c_before.get_amount(); - - // All producers should receive equal payment (same eligible_rounds) - BOOST_REQUIRE_EQUAL( got_a, got_b ); - BOOST_REQUIRE_EQUAL( got_b, got_c ); - BOOST_REQUIRE( got_a > 0 ); + auto log = get_epoch_log(1); + const int64_t compute = log["compute_amount"].as(); + const int64_t active_pool = test_active_pool(compute); + int64_t paid = 0; + for (const auto& p : producers) { + const int64_t got = get_wire_balance_paid(p).get_amount() - before.at(p).get_amount(); + BOOST_REQUIRE_EQUAL( got, test_block_pay(active_pool, blocks.at(p), slots) ); + BOOST_REQUIRE_GT( got, 0 ); + paid += got; + } + // The unfilled slots' pay was distributed to no one. + BOOST_REQUIRE_LT( paid, active_pool ); + BOOST_REQUIRE_EQUAL( get_t5_state()["total_distributed"].as() - t5_before, + paid + log["capex_amount"].as() + log["governance_amount"].as() ); } FC_LOG_AND_RETHROW() // A producer cannot halt epoch pay for everyone by refusing its own payout. @@ -3001,8 +3082,8 @@ BOOST_FIXTURE_TEST_CASE( blocking_producer_cannot_stall_payepoch, sysio_emission const int64_t owed_b = pay_claimable("producerb"_n); const int64_t owed_c = pay_claimable("producerc"_n); BOOST_REQUIRE( owed_a > 0 ); - BOOST_REQUIRE_EQUAL( owed_a, owed_b ); // equal eligible_rounds -> equal share - BOOST_REQUIRE_EQUAL( owed_b, owed_c ); + BOOST_REQUIRE( owed_b > 0 ); // the blocker is credited for its blocks like anyone else + BOOST_REQUIRE( owed_c > 0 ); // The cooperative producers pull their pay normally. BOOST_REQUIRE_EQUAL( success(), @@ -3125,10 +3206,10 @@ BOOST_FIXTURE_TEST_CASE( viewepoch_estimates_next_emission, sysio_emissions_test // --------------------------------------------------------------------------- BOOST_FIXTURE_TEST_CASE( non_producing_active_excluded, sysio_emissions_tester ) try { - // Producers with rank 1-21 but 0 eligible_rounds get no pay + // Producers holding active positions but with 0 blocks made are paid nothing create_t5_holding_accounts(); setup_producers(3); - // Do NOT produce extra blocks — schedule hasn't activated, so producers have 0 eligible_rounds + // Do NOT produce extra blocks — schedule hasn't activated, so no producer has made a block const uint32_t start = head_secs() - ONE_EPOCH - 1; BOOST_REQUIRE_EQUAL( success(), initt5( config::system_account_name, tpsec(start) ) ); @@ -3139,34 +3220,29 @@ BOOST_FIXTURE_TEST_CASE( non_producing_active_excluded, sysio_emissions_tester ) BOOST_REQUIRE_EQUAL( success(), advance_epoch_state() ); - // Producers should receive nothing (0 eligible_rounds → excluded) + // Producers should receive nothing (0 blocks → nothing to pay for) BOOST_REQUIRE_EQUAL( get_wire_balance_paid("producera"_n), bal_a_before ); BOOST_REQUIRE_EQUAL( get_wire_balance_paid("producerb"_n), bal_b_before ); BOOST_REQUIRE_EQUAL( get_wire_balance_paid("producerc"_n), bal_c_before ); } FC_LOG_AND_RETHROW() -BOOST_FIXTURE_TEST_CASE( partial_uptime_proportional_pay, sysio_emissions_tester ) try { - // Producers with eligible_rounds < expected_rounds get proportional share. - // Override epoch_duration_sec so expected_rounds (=epoch_secs*2/252) is - // well above the eligible_rounds the test produces (~2 from 2 cycles), so - // the proportional path is exercised rather than the elig>=expected cap. - BOOST_REQUIRE_EQUAL( success(), init_epoch_state(7200) ); +BOOST_FIXTURE_TEST_CASE( partial_uptime_pays_the_per_block_rate, sysio_emissions_tester ) try { + // A producer that made a fraction of its period's slots is paid exactly that fraction. A long + // epoch makes the slot count dwarf the blocks the test produces (~24 of 14400), so the rate is + // small and the pay is far below an even third of the pool. + constexpr uint32_t EPOCH_SECS = 7200; + BOOST_REQUIRE_EQUAL( success(), init_epoch_state(EPOCH_SECS) ); create_t5_holding_accounts(); setup_producers(3); - // Produce blocks so producers accumulate eligible_rounds wait_for_producer_schedule(); - produce_complete_cycles(3, 2); // 2 cycles sufficient - - // Read eligible_rounds for producera before advance - auto pa_info = get_producer_info("producera"_n); - BOOST_REQUIRE( !pa_info.is_null() ); - uint16_t elig_a = pa_info["eligible_rounds"].as(); - BOOST_REQUIRE( elig_a > 0 ); + produce_complete_cycles(3, 2); const uint32_t start = head_secs() - ONE_EPOCH - 1; BOOST_REQUIRE_EQUAL( success(), initt5( config::system_account_name, tpsec(start) ) ); + const uint32_t blocks_a = unpaid_blocks_of("producera"_n); + BOOST_REQUIRE_GT( blocks_a, 0u ); asset bal_a_before = get_wire_balance_paid("producera"_n); BOOST_REQUIRE_EQUAL( success(), advance_epoch_state() ); @@ -3174,13 +3250,11 @@ BOOST_FIXTURE_TEST_CASE( partial_uptime_proportional_pay, sysio_emissions_tester int64_t got_a = get_wire_balance_paid("producera"_n).get_amount() - bal_a_before.get_amount(); BOOST_REQUIRE( got_a > 0 ); - // Verify proportional: got_a < full_share (since elig < expected) auto log = get_epoch_log(1); - int64_t compute = log["compute_amount"].as(); - int64_t producer_pool = test_split_bps(compute, PRODUCER_BPS); - // Full share for one of 3 equal-weight producers - int64_t full_share = producer_pool / 3; - BOOST_REQUIRE( got_a < full_share ); + const int64_t compute = log["compute_amount"].as(); + BOOST_REQUIRE_EQUAL( got_a, test_block_pay(test_active_pool(compute), blocks_a, + test_nominal_slots(EPOCH_SECS)) ); + BOOST_REQUIRE_LT( got_a, test_split_bps(compute, PRODUCER_BPS) / 3 ); } FC_LOG_AND_RETHROW() BOOST_FIXTURE_TEST_CASE( standby_paid_without_block_check, sysio_emissions_tester ) try { @@ -3190,13 +3264,14 @@ BOOST_FIXTURE_TEST_CASE( standby_paid_without_block_check, sysio_emissions_teste // Set up 24 producers: 21 active + 3 standby (ranks 22-24) setup_producers(24); wait_for_producer_schedule(); - produce_complete_cycles(21, 1); // 1 cycle sufficient for eligible_rounds + produce_complete_cycles(21, 1); const uint32_t start = head_secs() - ONE_EPOCH - 1; BOOST_REQUIRE_EQUAL( success(), initt5( config::system_account_name, tpsec(start) ) ); // Standby producer (rank 22) is "producerw" (index 22) name standby_name = producer_name_at(21); // index 21 = 'v', rank 22 + BOOST_REQUIRE_EQUAL( 0u, unpaid_blocks_of(standby_name) ); asset standby_before = get_wire_balance_paid(standby_name); // Verify the standby producer has rank 22 @@ -3207,44 +3282,39 @@ BOOST_FIXTURE_TEST_CASE( standby_paid_without_block_check, sysio_emissions_teste BOOST_REQUIRE_EQUAL( success(), advance_epoch_state() ); - // Standby should receive payment even with 0 blocks produced + // Standby should receive payment even with 0 blocks produced -- its POSITION's fixed share of + // the retainer slice, not the whole slice: the vacant positions' shares stay in the treasury. int64_t standby_got = get_wire_balance_paid(standby_name).get_amount() - standby_before.get_amount(); BOOST_REQUIRE( standby_got > 0 ); + const int64_t standby_pool = test_standby_pool(get_epoch_log(1)["compute_amount"].as()); + BOOST_REQUIRE_EQUAL( standby_got, test_standby_pay(standby_pool, standby_rank) ); + BOOST_REQUIRE_LT( standby_got, standby_pool ); } FC_LOG_AND_RETHROW() -BOOST_FIXTURE_TEST_CASE( round_tracking_reset_after_epoch, sysio_emissions_tester ) try { - // After advance, all round-tracking fields should be reset +BOOST_FIXTURE_TEST_CASE( block_count_reset_after_pay, sysio_emissions_tester ) try { + // After the pay-epoch every paid producer's block count starts over create_t5_holding_accounts(); setup_producers(3); wait_for_producer_schedule(); produce_complete_cycles(3, 2); - // Verify fields are non-zero before advance - auto pa_before = get_producer_info("producera"_n); - BOOST_REQUIRE( pa_before["eligible_rounds"].as() > 0 ); - BOOST_REQUIRE( pa_before["unpaid_blocks"].as() > 0 ); + BOOST_REQUIRE_GT( unpaid_blocks_of("producera"_n), 0u ); const uint32_t start = head_secs() - ONE_EPOCH - 1; BOOST_REQUIRE_EQUAL( success(), initt5( config::system_account_name, tpsec(start) ) ); BOOST_REQUIRE_EQUAL( success(), advance_epoch_state() ); + produce_blocks(1); - // After advance, fields are reset; however, the block that commits the - // advance transaction is itself produced by one of the test producers, - // so onblock runs once after the reset and that producer's per-block tracking - // (current_round_blocks + unpaid_blocks + last_block_num) gets re-bumped by 1. - // eligible_rounds should still be 0 because a single block cannot satisfy - // the per-round threshold. - auto pa_after = get_producer_info("producera"_n); - BOOST_REQUIRE_EQUAL( pa_after["eligible_rounds"].as(), 0u ); - BOOST_REQUIRE( pa_after["current_round_blocks"].as() <= 1 ); - BOOST_REQUIRE( pa_after["unpaid_blocks"].as() <= 1 ); + // The count is reset by payepoch; the block that followed it is produced by one of the test + // producers, so onblock may have counted one block for producera again. + BOOST_REQUIRE_LE( unpaid_blocks_of("producera"_n), 1u ); } FC_LOG_AND_RETHROW() BOOST_FIXTURE_TEST_CASE( total_distributed_excludes_undistributed, sysio_emissions_tester ) try { // When some producers are excluded, total_distributed < emission create_t5_holding_accounts(); setup_producers(3); - // Producers have 0 eligible_rounds - all excluded - producer_pool undistributed. + // No producer has made a block - nothing to pay - producer_pool undistributed. // Batch-op pool is also undistributed (no members in the rotation group). const uint32_t start = head_secs() - ONE_EPOCH - 1; @@ -3261,45 +3331,42 @@ BOOST_FIXTURE_TEST_CASE( total_distributed_excludes_undistributed, sysio_emissio BOOST_REQUIRE( distributed < emission ); } FC_LOG_AND_RETHROW() -BOOST_FIXTURE_TEST_CASE( inprogress_round_finalized, sysio_emissions_tester ) try { - // A producer with current_round_blocks >= 6 (but < 12) gets credit at epoch end +// There is no round threshold: a producer that made a handful of blocks in a round it did not +// complete is paid for exactly those blocks. (A fork switch or a rough handoff costs a producer +// the blocks it lost, and nothing more.) +BOOST_FIXTURE_TEST_CASE( every_block_is_paid_without_a_round_threshold, sysio_emissions_tester ) try { create_t5_holding_accounts(); setup_producers(3); wait_for_producer_schedule(); - // Produce complete cycles so producers have some eligible_rounds - produce_complete_cycles(3, 1); - - // Now produce blocks one-at-a-time until producera has a partial round with >= 6 blocks - for (int i = 0; i < 200; ++i) { - produce_blocks(1); - auto info = get_producer_info("producera"_n); - uint16_t cur = info["current_round_blocks"].as(); - if (cur >= 6 && cur < 12) break; - } - - // Check producera has in-progress round - auto pa_info = get_producer_info("producera"_n); - BOOST_REQUIRE( !pa_info.is_null() ); - uint16_t current_blocks = pa_info["current_round_blocks"].as(); - uint16_t elig_before = pa_info["eligible_rounds"].as(); - - // producera should have in-progress blocks >= 6 and accumulated eligible rounds - BOOST_REQUIRE( current_blocks >= 6 ); - BOOST_REQUIRE( current_blocks < 12 ); - BOOST_REQUIRE( elig_before >= 0 ); + // A few blocks past activation: whoever holds the current window has made fewer than half a + // round, and no producer has completed one. + produce_blocks(2); const uint32_t start = head_secs() - ONE_EPOCH - 1; BOOST_REQUIRE_EQUAL( success(), initt5( config::system_account_name, tpsec(start) ) ); - asset bal_a_before = get_wire_balance_paid("producera"_n); - BOOST_REQUIRE_EQUAL( success(), advance_epoch_state() ); + // Read AFTER initt5 -- push_system_action closes a block, and its producer is credited for it. + const std::vector producers{ "producera"_n, "producerb"_n, "producerc"_n }; + std::map blocks; + std::map before; + uint32_t partial_producers = 0; + for (const auto& p : producers) { + blocks.emplace(p, unpaid_blocks_of(p)); + before.emplace(p, get_wire_balance_paid(p)); + BOOST_REQUIRE_LT( blocks.at(p), 6u ); + if (blocks.at(p) > 0) ++partial_producers; + } + BOOST_REQUIRE_GT( partial_producers, 0u ); - int64_t got_a = get_wire_balance_paid("producera"_n).get_amount() - bal_a_before.get_amount(); + BOOST_REQUIRE_EQUAL( success(), advance_epoch_state() ); - // payepoch finalizes in-progress round (>= 6 blocks) -> adds 1 to eligible_rounds - // Pay should be based on (elig_before + 1) rounds > 0 - BOOST_REQUIRE( got_a > 0 ); + const int64_t active_pool = test_active_pool(get_epoch_log(1)["compute_amount"].as()); + for (const auto& p : producers) { + const int64_t got = get_wire_balance_paid(p).get_amount() - before.at(p).get_amount(); + BOOST_REQUIRE_EQUAL( got, test_block_pay(active_pool, blocks.at(p), test_nominal_slots(T_EPOCH_SECS)) ); + if (blocks.at(p) > 0) BOOST_REQUIRE_GT( got, 0 ); + } } FC_LOG_AND_RETHROW() // --------------------------------------------------------------------------- @@ -3308,7 +3375,7 @@ BOOST_FIXTURE_TEST_CASE( inprogress_round_finalized, sysio_emissions_tester ) tr BOOST_FIXTURE_TEST_CASE( producer_promoted_mid_epoch, sysio_emissions_tester ) try { // Producer starts as standby, gets promoted to active mid-epoch - // Should receive proportional active pay based on eligible_rounds after promotion + // Should be paid for the blocks it made after promotion create_t5_holding_accounts(); // Start with 22 producers: 21 active + 1 standby @@ -3357,14 +3424,14 @@ BOOST_FIXTURE_TEST_CASE( producer_promoted_mid_epoch, sysio_emissions_tester ) t } FC_LOG_AND_RETHROW() BOOST_FIXTURE_TEST_CASE( producer_unregistered_mid_epoch, sysio_emissions_tester ) try { - // Producer starts as active and accumulates eligible_rounds, then unregisters mid-epoch. + // Producer starts as active and makes blocks, then unregisters mid-epoch. // It holds no rank position at epoch end, so it draws neither active nor standby pay. create_t5_holding_accounts(); // Start with 22 producers: 21 active + 1 standby setup_producers(22); wait_for_producer_schedule(); - produce_complete_cycles(21, 1); // producera accumulates eligible_rounds + produce_complete_cycles(21, 1); // producera makes blocks // Take producera out of the schedulable set. Rank is POSITION in the score-ordered index, so // governance cannot demote a producer by republishing a schedule -- `setprodkeys` proposes and @@ -3388,8 +3455,9 @@ BOOST_FIXTURE_TEST_CASE( producer_unregistered_mid_epoch, sysio_emissions_tester asset demoted_before = get_wire_balance_paid("producera"_n); BOOST_REQUIRE_EQUAL( success(), advance_epoch_state() ); - // No position means no pay -- neither the active share nor the standby weight. The - // eligible_rounds it accumulated before unregistering do not survive losing its slot. + // No position means no pay at THIS payout -- neither block pay nor the standby retainer. The + // blocks it made before parking stay on the row and are paid at the first payout after it + // re-registers (see the park_and_return tests). BOOST_REQUIRE_EQUAL( get_wire_balance_paid("producera"_n), demoted_before ); } FC_LOG_AND_RETHROW() @@ -3441,6 +3509,88 @@ BOOST_FIXTURE_TEST_CASE( producer_replaced_mid_epoch, sysio_emissions_tester ) t BOOST_REQUIRE( state["total_distributed"].as() < emission ); } FC_LOG_AND_RETHROW() +// Every block a producer makes is paid, at the first payout where it is back in the pay walk. +// A park (`unregprod`) does not cost the blocks made before it: re-register before the payout and +// they are paid at that payout like anyone else's. +BOOST_FIXTURE_TEST_CASE( park_and_return_before_the_payout_keeps_the_blocks, sysio_emissions_tester ) try { + create_t5_holding_accounts(); + setup_producers(3); + wait_for_producer_schedule(); + produce_complete_cycles(3, 2); + + const uint32_t made_before_park = unpaid_blocks_of("producera"_n); + BOOST_REQUIRE_GT( made_before_park, 0u ); + + // Park, fix "the issue", come back -- all before the payout. + BOOST_REQUIRE_EQUAL( success(), push_system_action("producera"_n, "unregprod"_n, + mvo()("producer", "producera"_n)) ); + BOOST_REQUIRE_GE( unpaid_blocks_of("producera"_n), made_before_park ); // the park kept them + BOOST_REQUIRE_EQUAL( success(), push_system_action("producera"_n, "regproducer"_n, mvo() + ("producer", "producera"_n) + ("producer_key", get_public_key("producera"_n, "active")) + ("url", "")("location", 0)) ); + + const uint32_t start = head_secs() - ONE_EPOCH - 1; + BOOST_REQUIRE_EQUAL( success(), initt5( config::system_account_name, tpsec(start) ) ); + + const uint32_t blocks_a = unpaid_blocks_of("producera"_n); + BOOST_REQUIRE_GE( blocks_a, made_before_park ); + asset bal_a_before = get_wire_balance_paid("producera"_n); + + BOOST_REQUIRE_EQUAL( success(), advance_epoch_state() ); + + const int64_t got_a = get_wire_balance_paid("producera"_n).get_amount() - bal_a_before.get_amount(); + const int64_t active_pool = test_active_pool(get_epoch_log(1)["compute_amount"].as()); + BOOST_REQUIRE_EQUAL( got_a, test_block_pay(active_pool, blocks_a, test_nominal_slots(T_EPOCH_SECS)) ); + BOOST_REQUIRE_GT( got_a, 0 ); +} FC_LOG_AND_RETHROW() + +// A park that spans a payout defers the blocks rather than losing them: nothing at that payout +// (the row sits below the walk), and the carried count is paid at the first payout after the +// return -- at that period's rate, and counted in that period's divisor. +BOOST_FIXTURE_TEST_CASE( park_across_a_payout_defers_the_blocks_to_the_return, sysio_emissions_tester ) try { + create_t5_holding_accounts(); + setup_producers(3); + wait_for_producer_schedule(); + produce_complete_cycles(3, 2); + + const uint32_t start = head_secs() - ONE_EPOCH - 1; + BOOST_REQUIRE_EQUAL( success(), initt5( config::system_account_name, tpsec(start) ) ); + + BOOST_REQUIRE_EQUAL( success(), push_system_action("producera"_n, "unregprod"_n, + mvo()("producer", "producera"_n)) ); + const uint32_t carried = unpaid_blocks_of("producera"_n); + BOOST_REQUIRE_GT( carried, 0u ); + + // Payout 1: parked, so nothing -- and the count is untouched. + asset bal_a_before = get_wire_balance_paid("producera"_n); + BOOST_REQUIRE_EQUAL( success(), advance_epoch_state() ); + BOOST_REQUIRE_EQUAL( get_wire_balance_paid("producera"_n), bal_a_before ); + BOOST_REQUIRE_EQUAL( carried, unpaid_blocks_of("producera"_n) ); + + // Return, then run out the next period. + BOOST_REQUIRE_EQUAL( success(), push_system_action("producera"_n, "regproducer"_n, mvo() + ("producer", "producera"_n) + ("producer_key", get_public_key("producera"_n, "active")) + ("url", "")("location", 0)) ); + produce_blocks(130); + + const std::vector producers{ "producera"_n, "producerb"_n, "producerc"_n }; + uint64_t produced = 0; + for (const auto& p : producers) produced += unpaid_blocks_of(p); + const uint32_t blocks_a = unpaid_blocks_of("producera"_n); + BOOST_REQUIRE_GE( blocks_a, carried ); + bal_a_before = get_wire_balance_paid("producera"_n); + + // Payout 2: the carried blocks are paid with this period's, at this period's rate. + BOOST_REQUIRE_EQUAL( success(), advance_epoch_state() ); + const int64_t got_a = get_wire_balance_paid("producera"_n).get_amount() - bal_a_before.get_amount(); + const int64_t active_pool = test_active_pool(get_epoch_log(2)["compute_amount"].as()); + const uint64_t divisor = std::max(produced, test_nominal_slots(T_EPOCH_SECS)); + BOOST_REQUIRE_EQUAL( got_a, test_block_pay(active_pool, blocks_a, divisor) ); + BOOST_REQUIRE_GT( got_a, 0 ); +} FC_LOG_AND_RETHROW() + // --------------------------------------------------------------------------- // Additional coverage: timing & epoch boundaries // --------------------------------------------------------------------------- @@ -3581,17 +3731,17 @@ BOOST_FIXTURE_TEST_CASE( epoch_log_records_all_fields, sysio_emissions_tester ) // --------------------------------------------------------------------------- BOOST_FIXTURE_TEST_CASE( all_actives_excluded_standbys_still_paid, sysio_emissions_tester ) try { - // When all 21 active producers have 0 eligible_rounds, only standbys receive payment. + // When no active producer has made a block, only standbys receive payment. create_t5_holding_accounts(); // Set up 24 producers: 21 active + 3 standby - // Do NOT wait for schedule or produce blocks — actives have 0 eligible_rounds + // Do NOT wait for schedule or produce blocks — no active has made a block setup_producers(24); const uint32_t start = head_secs() - ONE_EPOCH - 1; BOOST_REQUIRE_EQUAL( success(), initt5( config::system_account_name, tpsec(start) ) ); - // Active producer should have 0 eligible_rounds + // Active producer has made no block name active = producer_name_at(0); name standby = producer_name_at(21); @@ -3600,7 +3750,7 @@ BOOST_FIXTURE_TEST_CASE( all_actives_excluded_standbys_still_paid, sysio_emissio BOOST_REQUIRE_EQUAL( success(), advance_epoch_state() ); - // Active should get nothing (0 eligible_rounds) + // Active should get nothing (0 blocks) BOOST_REQUIRE_EQUAL( get_wire_balance_paid(active), active_before ); // Standby should get paid (no block production check for standbys) @@ -3612,8 +3762,9 @@ BOOST_FIXTURE_TEST_CASE( all_actives_excluded_standbys_still_paid, sysio_emissio } } FC_LOG_AND_RETHROW() -BOOST_FIXTURE_TEST_CASE( single_active_producer_full_active_share, sysio_emissions_tester ) try { - // A single active producer who produces blocks should get the entire active-weight share +BOOST_FIXTURE_TEST_CASE( single_active_producer_paid_per_block, sysio_emissions_tester ) try { + // A lone producer is paid the per-block rate for its blocks -- never the whole pool: the slots + // it did not fill and the standby slice both stay in the treasury. create_t5_holding_accounts(); setup_producers(1); wait_for_producer_schedule(); @@ -3622,20 +3773,17 @@ BOOST_FIXTURE_TEST_CASE( single_active_producer_full_active_share, sysio_emissio const uint32_t start = head_secs() - ONE_EPOCH - 1; BOOST_REQUIRE_EQUAL( success(), initt5( config::system_account_name, tpsec(start) ) ); + const uint32_t blocks = unpaid_blocks_of("producera"_n); asset bal_before = get_wire_balance_paid("producera"_n); BOOST_REQUIRE_EQUAL( success(), advance_epoch_state() ); int64_t got = get_wire_balance_paid("producera"_n).get_amount() - bal_before.get_amount(); BOOST_REQUIRE( got > 0 ); - // With only 1 active (weight=15, total_weight=15), full_share = producer_pool - // Payment is proportional: pool * min(elig, expected) / expected - auto log = get_epoch_log(1); - int64_t compute = log["compute_amount"].as(); - int64_t producer_pool = test_split_bps(compute, PRODUCER_BPS); - - // They must get something > 0 and <= producer_pool - BOOST_REQUIRE( got <= producer_pool ); + const int64_t compute = get_epoch_log(1)["compute_amount"].as(); + BOOST_REQUIRE_EQUAL( got, test_block_pay(test_active_pool(compute), blocks, + test_nominal_slots(T_EPOCH_SECS)) ); + BOOST_REQUIRE_LT( got, test_active_pool(compute) ); } FC_LOG_AND_RETHROW() // Swap-fee rewards (sysio.reserv rewards_bucket) are folded into payepoch's @@ -3707,6 +3855,7 @@ BOOST_FIXTURE_TEST_CASE( payepoch_folds_swap_fee_rewards, sysio_emissions_tester BOOST_REQUIRE_EQUAL( success(), initt5( config::system_account_name, tpsec(start) ) ); const int64_t t5_before = get_t5_state()["total_distributed"].as(); + const uint32_t blocks = unpaid_blocks_of("producera"_n); const int64_t bal_before = get_wire_balance_paid("producera"_n).get_amount(); // Must NOT overdraw: payepoch queues the reserv->sysio drain ahead of the @@ -3724,9 +3873,11 @@ BOOST_FIXTURE_TEST_CASE( payepoch_folds_swap_fee_rewards, sysio_emissions_tester // The producer received its emission share and NOTHING MORE. Swap fees pay // the parties that carry an individual swap — the winning underwriter and the // batch operators that relay it — never producers, who earn emissions for - // securing the chain. (expected_rounds clamps to 1 at the 60s epoch, so the - // single active producer takes the whole producer pool.) - BOOST_REQUIRE_EQUAL( got, producer_pool ); + // securing the chain. (The share is the per-block rate times its blocks; the + // slots it did not fill and the standby slice stay in the treasury.) + BOOST_REQUIRE_EQUAL( got, test_block_pay(test_active_pool(compute), blocks, + test_nominal_slots(T_EPOCH_SECS)) ); + BOOST_REQUIRE_LT( got, producer_pool ); // Nothing was distributed out of the fee: the whole pool is allocated to the // batch-operator distribution and this fixture has no active rotation group, @@ -3739,11 +3890,12 @@ BOOST_FIXTURE_TEST_CASE( payepoch_folds_swap_fee_rewards, sysio_emissions_tester // unconditional on there being a recipient, and it must not overdraw. BOOST_REQUIRE_EQUAL( reward_balance(), 0 ); - // total_distributed counts emission only (producer_pool + capex + gov, with - // the empty batch group's share staying in treasury) -- the fee is NOT - // charged against the emission curve. + // total_distributed counts emission only (the producer's block pay + capex + + // gov, with the unfilled slots, the standby slice and the empty batch group's + // share staying in treasury) -- the fee is NOT charged against the emission + // curve. const int64_t t5_after = get_t5_state()["total_distributed"].as(); - BOOST_REQUIRE_EQUAL( t5_after - t5_before, producer_pool + capex + gov ); + BOOST_REQUIRE_EQUAL( t5_after - t5_before, got + capex + gov ); } FC_LOG_AND_RETHROW() // The POSITIVE counterpart: a swap fee actually reaching an ACTIVE batch @@ -3829,6 +3981,7 @@ BOOST_FIXTURE_TEST_CASE( payepoch_pays_swap_fee_to_active_batch_operator, sysio_ BOOST_REQUIRE_EQUAL( success(), initt5( config::system_account_name, tpsec(start) ) ); const int64_t t5_before = get_t5_state()["total_distributed"].as(); + const uint32_t producer_blocks = unpaid_blocks_of("producera"_n); const int64_t bal_before = get_wire_balance(BATCH_OP).get_amount(); BOOST_REQUIRE_EQUAL( success(), advance_epoch_state() ); @@ -3857,7 +4010,9 @@ BOOST_FIXTURE_TEST_CASE( payepoch_pays_swap_fee_to_active_batch_operator, sysio_ const int64_t capex = log["capex_amount"].as(); const int64_t gov = log["governance_amount"].as(); const int64_t t5_after = get_t5_state()["total_distributed"].as(); - BOOST_REQUIRE_EQUAL( t5_after - t5_before, producer_pool + batch_pool + capex + gov ); + const int64_t producer_pay = test_block_pay(test_active_pool(compute), producer_blocks, + test_nominal_slots(T_EPOCH_SECS)); + BOOST_REQUIRE_EQUAL( t5_after - t5_before, producer_pay + batch_pool + capex + gov ); } FC_LOG_AND_RETHROW() // Lowering pay_cadence_epochs MID-PERIOD must not multiply the payout. @@ -3944,6 +4099,7 @@ BOOST_FIXTURE_TEST_CASE( cadence_drop_midperiod_does_not_multiply_batch_fee_payo const int64_t bal_before = get_wire_balance(BATCH_OP).get_amount(); produce_blocks(130); + const uint32_t producer_blocks = unpaid_blocks_of("producera"_n); BOOST_REQUIRE_EQUAL( success(), advance_epoch_state() ); // pay-epoch BOOST_REQUIRE_EQUAL( get_t5_state()["epoch_count"].as(), 1u ); @@ -3962,16 +4118,20 @@ BOOST_FIXTURE_TEST_CASE( cadence_drop_midperiod_does_not_multiply_batch_fee_payo BOOST_REQUIRE_EQUAL( log["fee_distributed"].as(), fee_total ); BOOST_REQUIRE_EQUAL( reward_balance(), 0 ); - // And the emission side is not double-paid either. + // And the emission side is not double-paid either: the producer's count spans both accrued + // epochs and is paid once, over the two epochs' worth of slots. const int64_t capex = log["capex_amount"].as(); const int64_t gov = log["governance_amount"].as(); + const int64_t producer_pay = test_block_pay(test_active_pool(compute), producer_blocks, + 2 * test_nominal_slots(T_EPOCH_SECS)); BOOST_REQUIRE_EQUAL( get_t5_state()["total_distributed"].as(), - producer_pool + batch_pool + capex + gov ); + producer_pay + batch_pool + capex + gov ); } FC_LOG_AND_RETHROW() BOOST_FIXTURE_TEST_CASE( standby_weight_decreases_by_rank, sysio_emissions_tester ) try { - // Rank 22 should receive more than rank 23, which should receive more than rank 24, etc. - // Weight formula: w = 29 - rank (22→7, 23→6, 24→5) + // Position 22 receives more than 23, which receives more than 24, etc. -- each an exact, + // position-fixed share of the retainer slice: weight 29 - position (22→7, 23→6, 24→5) over + // the constant sum 28, so the four vacant positions' shares stay in the treasury. create_t5_holding_accounts(); // Set up 25 producers: 21 active + 4 standby (ranks 22-25) @@ -3986,6 +4146,15 @@ BOOST_FIXTURE_TEST_CASE( standby_weight_decreases_by_rank, sysio_emissions_teste name sb2 = producer_name_at(22); // rank 23, weight 6 name sb3 = producer_name_at(23); // rank 24, weight 5 + // The fixture's setprodkeys schedule names all 25 producers until the ranked rebuild trims it + // to 21, so a standby may have held a window -- and block pay is not gated on position, so + // those blocks are paid on top of the retainer. Expect both, over the roster-wide divisor + // (one 21-producer rotation already exceeds the 120 slots a 60s period holds). + uint64_t produced = 0; + for (uint32_t i = 0; i < 25; ++i) produced += unpaid_blocks_of(producer_name_at(i)); + const uint64_t divisor = std::max(produced, test_nominal_slots(T_EPOCH_SECS)); + const uint32_t blocks1 = unpaid_blocks_of(sb1), blocks2 = unpaid_blocks_of(sb2), blocks3 = unpaid_blocks_of(sb3); + asset sb1_before = get_wire_balance_paid(sb1); asset sb2_before = get_wire_balance_paid(sb2); asset sb3_before = get_wire_balance_paid(sb3); @@ -4000,77 +4169,76 @@ BOOST_FIXTURE_TEST_CASE( standby_weight_decreases_by_rank, sysio_emissions_teste BOOST_REQUIRE( got1 > got2 ); BOOST_REQUIRE( got2 > got3 ); BOOST_REQUIRE( got3 > 0 ); + + const int64_t compute = get_epoch_log(1)["compute_amount"].as(); + const int64_t standby_pool = test_standby_pool(compute); + const int64_t active_pool = test_active_pool(compute); + BOOST_REQUIRE_EQUAL( got1, test_standby_pay(standby_pool, 22) + test_block_pay(active_pool, blocks1, divisor) ); + BOOST_REQUIRE_EQUAL( got2, test_standby_pay(standby_pool, 23) + test_block_pay(active_pool, blocks2, divisor) ); + BOOST_REQUIRE_EQUAL( got3, test_standby_pay(standby_pool, 24) + test_block_pay(active_pool, blocks3, divisor) ); + // The retainer alone never exhausts its slice: four positions are vacant. + BOOST_REQUIRE_LT( test_standby_pay(standby_pool, 22) + test_standby_pay(standby_pool, 23) + + test_standby_pay(standby_pool, 24), standby_pool ); } FC_LOG_AND_RETHROW() -BOOST_FIXTURE_TEST_CASE( inprogress_round_below_threshold_no_credit, sysio_emissions_tester ) try { - // A producer with current_round_blocks < 6 should NOT get credit from in-progress finalization. - // If that means 0 total eligible_rounds, they get excluded from payment. +// A single standby holds position 22's share alone: the six vacant positions pay nobody. +BOOST_FIXTURE_TEST_CASE( vacant_standby_positions_pay_nobody, sysio_emissions_tester ) try { create_t5_holding_accounts(); - setup_producers(3); + setup_producers(22); wait_for_producer_schedule(); + produce_complete_cycles(21, 1); - // Produce blocks one-at-a-time until producera has < 6 current_round_blocks - // We need: eligible_rounds == 0 AND 0 < current_round_blocks < 6 - // Strategy: produce less than one full cycle so producera doesn't complete a round - for (int i = 0; i < 5; ++i) { - produce_blocks(1); - } + const uint32_t start = head_secs() - ONE_EPOCH - 1; + BOOST_REQUIRE_EQUAL( success(), initt5( config::system_account_name, tpsec(start) ) ); - auto pa_info = get_producer_info("producera"_n); - if (pa_info.is_null()) { - // producera hasn't produced yet, eligible_rounds=0 - } else { - uint16_t current_blocks = pa_info["current_round_blocks"].as(); - uint16_t elig_rounds = pa_info["eligible_rounds"].as(); - - // If producera has produced, it should have < 6 blocks in current round and 0 eligible - if (current_blocks > 0 && current_blocks < 6 && elig_rounds == 0) { - const uint32_t start = head_secs() - ONE_EPOCH - 1; - BOOST_REQUIRE_EQUAL( success(), initt5( config::system_account_name, tpsec(start) ) ); - - asset bal_before = get_wire_balance_paid("producera"_n); - BOOST_REQUIRE_EQUAL( success(), advance_epoch_state() ); - - // Finalization should NOT credit this round (< 6 blocks) - // So eligible_rounds stays 0 → excluded from payment - BOOST_REQUIRE_EQUAL( get_wire_balance_paid("producera"_n), bal_before ); - } - } + const name standby = producer_name_at(21); + BOOST_REQUIRE_EQUAL( 22u, producer_rank_position(standby) ); + asset standby_before = get_wire_balance_paid(standby); + + BOOST_REQUIRE_EQUAL( success(), advance_epoch_state() ); + + const int64_t standby_pool = test_standby_pool(get_epoch_log(1)["compute_amount"].as()); + const int64_t got = get_wire_balance_paid(standby).get_amount() - standby_before.get_amount(); + BOOST_REQUIRE_EQUAL( got, test_standby_pay(standby_pool, 22) ); + BOOST_REQUIRE_EQUAL( got, standby_pool * 7 / 28 ); } FC_LOG_AND_RETHROW() -BOOST_FIXTURE_TEST_CASE( active_capped_at_expected_rounds, sysio_emissions_tester ) try { - // expected_rounds = (epoch_duration_sec * 2) / TOTAL_BLOCKS_PER_ROUND. - // Use 7200s so expected_rounds = 57, well above the elig_rounds the test - // produces (~2 from 2 cycles). Pay then = elig/expected * full_share which - // is strictly less than full_share -- exercising the proportional path. - BOOST_REQUIRE_EQUAL( success(), init_epoch_state(7200) ); +// A period that runs long holds more blocks than its nominal slots (an epoch can extend while a +// batch operator delivers). The divisor rises to the blocks actually produced, so the rate scales +// down and the active slice is never exceeded. +BOOST_FIXTURE_TEST_CASE( period_running_long_scales_the_rate_down, sysio_emissions_tester ) try { create_t5_holding_accounts(); setup_producers(3); wait_for_producer_schedule(); - produce_complete_cycles(3, 2); // some eligible_rounds + // Four rotations of three producers: 144 blocks, past the 120 slots a 60s period holds. + produce_complete_cycles(3, 4); const uint32_t start = head_secs() - ONE_EPOCH - 1; BOOST_REQUIRE_EQUAL( success(), initt5( config::system_account_name, tpsec(start) ) ); - // All 3 producers have same eligible_rounds and same weight - asset bal_a_before = get_wire_balance_paid("producera"_n); - BOOST_REQUIRE_EQUAL( success(), advance_epoch_state() ); - - int64_t got_a = get_wire_balance_paid("producera"_n).get_amount() - bal_a_before.get_amount(); - - auto log = get_epoch_log(1); - int64_t compute = log["compute_amount"].as(); - int64_t producer_pool = test_split_bps(compute, PRODUCER_BPS); - - // Each has weight 15 out of total 45, so full_share = pool * 15 / 45 = pool / 3 - int64_t full_share = static_cast( - static_cast<__int128>(producer_pool) * 15 / 45); + const std::vector producers{ "producera"_n, "producerb"_n, "producerc"_n }; + std::map blocks; + std::map before; + uint64_t produced = 0; + for (const auto& p : producers) { + blocks.emplace(p, unpaid_blocks_of(p)); + before.emplace(p, get_wire_balance_paid(p)); + produced += blocks.at(p); + } + BOOST_REQUIRE_GT( produced, test_nominal_slots(T_EPOCH_SECS) ); - // Payment is min(elig_rounds, expected_rounds) / expected_rounds * full_share - // Since elig_rounds << expected_rounds (2 cycles vs 685), pay < full_share - BOOST_REQUIRE( got_a > 0 ); - BOOST_REQUIRE( got_a < full_share ); + BOOST_REQUIRE_EQUAL( success(), advance_epoch_state() ); + const int64_t active_pool = test_active_pool(get_epoch_log(1)["compute_amount"].as()); + int64_t paid = 0; + for (const auto& p : producers) { + const int64_t got = get_wire_balance_paid(p).get_amount() - before.at(p).get_amount(); + BOOST_REQUIRE_EQUAL( got, test_block_pay(active_pool, blocks.at(p), produced) ); + paid += got; + } + BOOST_REQUIRE_LE( paid, active_pool ); + // Within rounding of the whole slice: every slot the period held was filled. + BOOST_REQUIRE_GT( paid, active_pool - static_cast(producers.size()) ); } FC_LOG_AND_RETHROW() // --------------------------------------------------------------------------- @@ -4270,28 +4438,6 @@ BOOST_FIXTURE_TEST_CASE( inactive_producer_excluded_from_distribution, sysio_emi // Additional coverage: round tracking correctness // --------------------------------------------------------------------------- -BOOST_FIXTURE_TEST_CASE( eligible_rounds_increment_per_complete_cycle, sysio_emissions_tester ) try { - // Verify that eligible_rounds increments correctly as complete rounds are produced - create_t5_holding_accounts(); - setup_producers(3); - wait_for_producer_schedule(); - - // Produce 1 complete cycle: each of 3 producers does 12 blocks = 1 eligible round each - produce_complete_cycles(3, 1); - - auto pa_info = get_producer_info("producera"_n); - BOOST_REQUIRE( !pa_info.is_null() ); - uint16_t elig_1 = pa_info["eligible_rounds"].as(); - BOOST_REQUIRE( elig_1 >= 1 ); - - // Produce another cycle - produce_complete_cycles(3, 1); - - auto pa_info2 = get_producer_info("producera"_n); - uint16_t elig_2 = pa_info2["eligible_rounds"].as(); - BOOST_REQUIRE( elig_2 > elig_1 ); -} FC_LOG_AND_RETHROW() - BOOST_FIXTURE_TEST_CASE( unpaid_blocks_track_actual_production, sysio_emissions_tester ) try { // Verify unpaid_blocks counts actual blocks produced create_t5_holding_accounts(); @@ -4341,6 +4487,8 @@ BOOST_FIXTURE_TEST_CASE( opreg_slashed_producer_excluded_from_pay, sysio_emissio const uint32_t start = head_secs() - ONE_EPOCH - 1; BOOST_REQUIRE_EQUAL( success(), initt5( config::system_account_name, tpsec(start) ) ); + const uint32_t blocks_a = unpaid_blocks_of("producera"_n); + const uint32_t blocks_c = unpaid_blocks_of("producerc"_n); asset bal_a_before = get_wire_balance_paid("producera"_n); asset bal_b_before = get_wire_balance_paid("producerb"_n); asset bal_c_before = get_wire_balance_paid("producerc"_n); @@ -4354,9 +4502,11 @@ BOOST_FIXTURE_TEST_CASE( opreg_slashed_producer_excluded_from_pay, sysio_emissio BOOST_REQUIRE_EQUAL( got_b, 0 ); BOOST_REQUIRE( got_a > 0 ); BOOST_REQUIRE( got_c > 0 ); - // producera / producerc keep their original 1/3 share (weighted by rank * eligible_rounds); - // producerb's share does not flow to them. - BOOST_REQUIRE_EQUAL( got_a, got_c ); + // producera / producerc are paid exactly their own blocks at the period's rate; producerb's + // slots' pay does not flow to them. + const int64_t active_pool = test_active_pool(get_epoch_log(1)["compute_amount"].as()); + BOOST_REQUIRE_EQUAL( got_a, test_block_pay(active_pool, blocks_a, test_nominal_slots(T_EPOCH_SECS)) ); + BOOST_REQUIRE_EQUAL( got_c, test_block_pay(active_pool, blocks_c, test_nominal_slots(T_EPOCH_SECS)) ); } FC_LOG_AND_RETHROW() BOOST_FIXTURE_TEST_CASE( opreg_unregistered_producer_excluded_from_pay, sysio_emissions_tester ) try { @@ -4682,7 +4832,7 @@ BOOST_FIXTURE_TEST_CASE( setemitcfg_rejects_zero_retention, sysio_emissions_test ("compute_bps", COMPUTE_BPS) ("capex_bps", CAPEX_BPS) ("governance_bps", uint16_t(1000)) ("producer_bps", PRODUCER_BPS) ("batch_op_bps", uint16_t(3000)) - ("standby_end_rank", T_STANDBY_END_RANK) + ("standby_end_rank", T_STANDBY_END_RANK)("standby_bps", T_STANDBY_BPS) ("epoch_log_retention_count", uint32_t(0))("pay_cadence_epochs", uint16_t(1)); auto r = setemitcfg(config::system_account_name, cfg); @@ -4708,7 +4858,7 @@ BOOST_FIXTURE_TEST_CASE( epochlog_prunes_past_retention_cap, sysio_emissions_tes ("compute_bps", COMPUTE_BPS) ("capex_bps", CAPEX_BPS) ("governance_bps", uint16_t(1000)) ("producer_bps", PRODUCER_BPS) ("batch_op_bps", uint16_t(3000)) - ("standby_end_rank", T_STANDBY_END_RANK) + ("standby_end_rank", T_STANDBY_END_RANK)("standby_bps", T_STANDBY_BPS) ("epoch_log_retention_count", uint32_t(3))("pay_cadence_epochs", uint16_t(1)); BOOST_REQUIRE_EQUAL( success(), setemitcfg(config::system_account_name, cfg) ); @@ -4889,7 +5039,7 @@ BOOST_FIXTURE_TEST_CASE( pay_cadence_treasury_exhausted_gates_non_pay_epoch, sys ("compute_bps", COMPUTE_BPS) ("capex_bps", CAPEX_BPS) ("governance_bps", uint16_t(1000)) ("producer_bps", PRODUCER_BPS) ("batch_op_bps", uint16_t(3000)) - ("standby_end_rank", T_STANDBY_END_RANK) + ("standby_end_rank", T_STANDBY_END_RANK)("standby_bps", T_STANDBY_BPS) ("epoch_log_retention_count", uint32_t(8640)) ("pay_cadence_epochs", uint16_t(3)); // non-pay epochs in the period BOOST_REQUIRE_EQUAL( success(), setemitcfg(config::system_account_name, cfg) ); @@ -5037,7 +5187,7 @@ BOOST_FIXTURE_TEST_CASE( fundclaim_caps_to_remaining_pool_and_records_shortfall, ("compute_bps", COMPUTE_BPS) ("capex_bps", CAPEX_BPS) ("governance_bps", GOV_BPS) ("producer_bps", PRODUCER_BPS) ("batch_op_bps", uint16_t(3000)) - ("standby_end_rank", T_STANDBY_END_RANK) + ("standby_end_rank", T_STANDBY_END_RANK)("standby_bps", T_STANDBY_BPS) ("epoch_log_retention_count", uint32_t(8640))("pay_cadence_epochs", uint16_t(1)); BOOST_REQUIRE_EQUAL( success(), setemitcfg( config::system_account_name, cfg ) ); @@ -5459,10 +5609,16 @@ struct producer_score_tester : public producer_eligibility_tester { abi_serializer::create_yield_function(abi_serializer_max_time)); } - uint32_t rescore_generation() { + bool rescore_pending() { auto g = get_global_state(); BOOST_REQUIRE(!g.is_null()); - return g["rescore_generation"].as(); + return g["rescore_pending"].as(); + } + + uint32_t unpaid_blocks_of(account_name producer) { + auto info = get_producer_info(producer); + BOOST_REQUIRE_MESSAGE(!info.is_null(), "no producers row for " << producer.to_string()); + return info["unpaid_blocks"].as(); } /// Register `count` producers as NON-bootstrapped operators, each bonded at `deposit` on the @@ -5660,18 +5816,18 @@ BOOST_FIXTURE_TEST_CASE( rescore_sweep_drains_across_ticks, producer_score_teste auto names = setup_ranked_producers(producer_count); trigger_reschedule(); - BOOST_REQUIRE_EQUAL( 0u, rescore_generation() ); + BOOST_REQUIRE( !rescore_pending() ); BOOST_REQUIRE_EQUAL( success(), set_score_config(/*collateral_weight=*/5'000) ); - BOOST_REQUIRE( rescore_generation() != 0u ); + BOOST_REQUIRE( rescore_pending() ); trigger_reschedule(); - BOOST_REQUIRE_MESSAGE( rescore_generation() != 0u, + BOOST_REQUIRE_MESSAGE( rescore_pending(), "a sweep of " << producer_count << " rows must not finish in one " << max_rescore_per_tick << "-row tick" ); trigger_reschedule(); - BOOST_REQUIRE_EQUAL( 0u, rescore_generation() ); + BOOST_REQUIRE( !rescore_pending() ); } FC_LOG_AND_RETHROW() // The collateral minimums live on sysio.opreg, a contract sysio.system cannot be notified by @@ -5681,14 +5837,14 @@ BOOST_FIXTURE_TEST_CASE( rescore_sweep_drains_across_ticks, producer_score_teste BOOST_FIXTURE_TEST_CASE( collateral_minimum_change_opens_rescore_sweep, producer_score_tester ) try { auto names = setup_collateralized_producers(3); trigger_reschedule(); - BOOST_REQUIRE_EQUAL( 0u, rescore_generation() ); + BOOST_REQUIRE( !rescore_pending() ); const uint64_t before = rank_score_of(names[0]); // Halving the minimum doubles every ratio, so every stored score is now wrong. BOOST_REQUIRE_EQUAL( success(), set_single_pair_collateral(base_min_bond / 2) ); trigger_reschedule(); - BOOST_REQUIRE_EQUAL( 0u, rescore_generation() ); // three rows drain in one tick + BOOST_REQUIRE( !rescore_pending() ); // three rows drain in one tick BOOST_REQUIRE_LT( rank_score_of(names[0]), before ); } FC_LOG_AND_RETHROW() @@ -5730,10 +5886,15 @@ BOOST_FIXTURE_TEST_CASE( missed_round_is_charged_only_to_the_skipped_producer, p wait_for_active_schedule(names[2]); for (const auto& p : names) BOOST_REQUIRE_EQUAL( 0u, missed_rounds_of(p) ); + const uint64_t before = rank_score_of(names[2]); skip_round_of(names[2]); BOOST_REQUIRE_EQUAL( 1u, missed_rounds_of(names[2]) ); + // One miss is not a demotion, but it IS a worse participation factor: the key moves (a + // higher key sorts later) while the tier stays put. + BOOST_REQUIRE_GT( rank_score_of(names[2]), before ); + BOOST_REQUIRE_EQUAL( tier_of(before), tier_of(rank_score_of(names[2])) ); for (const auto& p : names) { if (p == names[2]) continue; BOOST_REQUIRE_MESSAGE( missed_rounds_of(p) == 0u, @@ -5743,6 +5904,7 @@ BOOST_FIXTURE_TEST_CASE( missed_round_is_charged_only_to_the_skipped_producer, p // One full rotation returns the skipped producer to its slot; producing resets the streak. produce_blocks(names.size() * slots_per_producer + slots_per_producer); BOOST_REQUIRE_EQUAL( 0u, missed_rounds_of(names[2]) ); + BOOST_REQUIRE_EQUAL( before, rank_score_of(names[2]) ); // and the key comes back with it } FC_LOG_AND_RETHROW() // Demotion fires at EXACTLY the configured threshold -- not before -- and no amount of money @@ -5759,6 +5921,11 @@ BOOST_FIXTURE_TEST_CASE( demotion_fires_at_threshold_and_outweighs_collateral, p trigger_reschedule(); wait_for_active_schedule(target); + // One full rotation, so the target has produced its own window and holds pay counters to + // lose -- which is what the reclaim assertions after the demotion need. + produce_blocks(names.size() * slots_per_producer); + BOOST_REQUIRE_GT( unpaid_blocks_of(target), 0u ); + for (uint32_t miss = 1; miss <= 3; ++miss) { skip_round_of(target); BOOST_REQUIRE_EQUAL( miss, missed_rounds_of(target) ); @@ -5772,6 +5939,10 @@ BOOST_FIXTURE_TEST_CASE( demotion_fires_at_threshold_and_outweighs_collateral, p BOOST_REQUIRE_LT( rank_score_of(p), rank_score_of(target) ); } BOOST_REQUIRE_EQUAL( names.size(), producer_rank_position(target) ); // last, despite the bond + + // Demotion does NOT touch the block count: the producer is paid for the blocks it made at the + // first payepoch after regproducer brings it back into the pay walk. + BOOST_REQUIRE_GT( unpaid_blocks_of(target), 0u ); } FC_LOG_AND_RETHROW() // `regproducer` is the single door back, from an involuntary demotion as much as from a voluntary @@ -5868,4 +6039,107 @@ BOOST_FIXTURE_TEST_CASE( deposit_rejects_a_bootstrapped_operator, producer_score push_opreg_action(names[0], "deposit"_n, mvo()("account", names[0])("amount", uint64_t{1'000})) ); } FC_LOG_AND_RETHROW() +// --------------------------------------------------------------------------- +// Saturation +// --------------------------------------------------------------------------- + +// The collateral factor is UNCAPPED by policy, so a large enough bond runs it to the composite's +// bit budget; the weight multiply and the composite sum then saturate rather than wrap. A wrap +// would drop the best-bonded producer to the bottom of the index -- exactly what the uint128 +// intermediate exists to prevent. Two producers past the ceiling must tie, and both must still +// outrank a producer at exactly the minimum. +BOOST_FIXTURE_TEST_CASE( collateral_factor_saturates_at_the_bit_budget, producer_score_tester ) try { + // A minimum bond of 1 makes the ratio the raw deposit times score_scale, so a deposit of 1e15 + // is a factor of 1e19 -- past the 2^62 composite ceiling (~4.6e18) before any weight applies. + constexpr uint64_t unit_min_bond = 1; + constexpr uint64_t saturating_bond = 1'000'000'000'000'000ULL; + + BOOST_REQUIRE_EQUAL( success(), set_single_pair_collateral(unit_min_bond) ); + produce_blocks(1); + + auto names = producer_names(3); + create_producer_accounts(names); + for (auto& p : names) { + BOOST_REQUIRE_EQUAL( success(), push_system_action(p, "regproducer"_n, mvo() + ("producer", p)("producer_key", get_public_key(p, "active"))("url", "")("location", 0)) ); + BOOST_REQUIRE_EQUAL( success(), register_operator(p, OperatorType::OPERATOR_TYPE_PRODUCER, false) ); + } + BOOST_REQUIRE_EQUAL( success(), credit_collateral(names[0], saturating_bond) ); + BOOST_REQUIRE_EQUAL( success(), credit_collateral(names[1], saturating_bond * 2) ); + BOOST_REQUIRE_EQUAL( success(), credit_collateral(names[2], unit_min_bond) ); + produce_blocks(1); + register_finalizer_keys(names, 3); + produce_blocks(1); + + for (const auto& p : names) { + BOOST_REQUIRE_EQUAL( tier_healthy, tier_of(rank_score_of(p)) ); + } + // Past the ceiling, twice the bond buys nothing: the two saturated keys are identical... + BOOST_REQUIRE_EQUAL( rank_score_of(names[0]), rank_score_of(names[1]) ); + // ...and neither wrapped: both still sort ahead of the producer at exactly the minimum. + BOOST_REQUIRE_LT( rank_score_of(names[0]), rank_score_of(names[2]) ); + BOOST_REQUIRE_EQUAL( 1u, producer_rank_position(names[0]) ); // equal keys: name order + BOOST_REQUIRE_EQUAL( 2u, producer_rank_position(names[1]) ); + BOOST_REQUIRE_EQUAL( 3u, producer_rank_position(names[2]) ); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// Displacement at the schedule boundary +// --------------------------------------------------------------------------- + +// The bootstrapped tier is a BACKSTOP: with max_producers collateralised producers ahead of it a +// bootstrap holds position 22 and no schedule slot, yet stays ACTIVE and eligible. The moment a +// collateralised producer leaves it moves into the schedule, and the moment one returns it yields +// the slot again -- with no governance action anywhere. +BOOST_FIXTURE_TEST_CASE( collateralised_producers_displace_the_bootstrap_at_the_boundary, producer_score_tester ) try { + constexpr uint32_t collateralised_count = 21; // max_producers + static_assert( collateralised_count == 21, "this test pins the 21/22 schedule boundary" ); + + BOOST_REQUIRE_EQUAL( success(), set_single_pair_collateral() ); + produce_blocks(1); + + // One roster: 21 collateralised producers plus the bootstrap, keyed in ONE call so the node + // holds every finalizer key any proposed policy can carry. + auto names = producer_names(collateralised_count + 1); + const auto bootstrap = names.back(); + create_producer_accounts(names); + for (auto& p : names) { + BOOST_REQUIRE_EQUAL( success(), push_system_action(p, "regproducer"_n, mvo() + ("producer", p)("producer_key", get_public_key(p, "active"))("url", "")("location", 0)) ); + } + for (uint32_t i = 0; i < collateralised_count; ++i) { + BOOST_REQUIRE_EQUAL( success(), register_operator(names[i], OperatorType::OPERATOR_TYPE_PRODUCER, false) ); + BOOST_REQUIRE_EQUAL( success(), credit_collateral(names[i], base_min_bond) ); + } + BOOST_REQUIRE_EQUAL( success(), register_operator(bootstrap, OperatorType::OPERATOR_TYPE_PRODUCER, true) ); + produce_blocks(1); + register_finalizer_keys(names, collateralised_count + 1); + produce_blocks(1); + + // Position 22: outside the schedule, but ACTIVE and holding a rank position. + trigger_reschedule(); + BOOST_REQUIRE_EQUAL( tier_bootstrapped, tier_of(rank_score_of(bootstrap)) ); + BOOST_REQUIRE_EQUAL( collateralised_count + 1, producer_rank_position(bootstrap) ); + BOOST_REQUIRE( !is_scheduled(bootstrap) ); + BOOST_REQUIRE( is_scheduled(names[0]) ); + BOOST_REQUIRE_EQUAL( "OPERATOR_STATUS_ACTIVE", get_opreg_operator(bootstrap)["status"].as_string() ); + + // A collateralised producer parks: the bootstrap moves up into the schedule. + BOOST_REQUIRE_EQUAL( success(), + push_system_action(names[0], "unregprod"_n, mvo()("producer", names[0])) ); + trigger_reschedule(); + BOOST_REQUIRE_EQUAL( collateralised_count, producer_rank_position(bootstrap) ); + BOOST_REQUIRE( is_scheduled(bootstrap) ); + BOOST_REQUIRE( !is_scheduled(names[0]) ); + + // It returns: the bootstrap yields the slot again, and is still ACTIVE for the next time. + BOOST_REQUIRE_EQUAL( success(), push_system_action(names[0], "regproducer"_n, mvo() + ("producer", names[0])("producer_key", get_public_key(names[0], "active"))("url", "")("location", 0)) ); + trigger_reschedule(); + BOOST_REQUIRE_EQUAL( collateralised_count + 1, producer_rank_position(bootstrap) ); + BOOST_REQUIRE( !is_scheduled(bootstrap) ); + BOOST_REQUIRE( is_scheduled(names[0]) ); + BOOST_REQUIRE_EQUAL( "OPERATOR_STATUS_ACTIVE", get_opreg_operator(bootstrap)["status"].as_string() ); +} FC_LOG_AND_RETHROW() + BOOST_AUTO_TEST_SUITE_END() // sysio_producer_score_tests diff --git a/contracts/tests/getpeerkeys_tests.cpp b/contracts/tests/getpeerkeys_tests.cpp index ec1fd100f1..d344d5433f 100644 --- a/contracts/tests/getpeerkeys_tests.cpp +++ b/contracts/tests/getpeerkeys_tests.cpp @@ -15,7 +15,6 @@ #include #include "sysio.system_tester.hpp" -#include "finalizer_test_keys.hpp" using namespace sysio_system; @@ -48,22 +47,11 @@ class getpeerkeys_tester : public sysio_system_tester { BOOST_AUTO_TEST_SUITE(getpeerkeys_tests) BOOST_FIXTURE_TEST_CASE( getpeerkeys_test, getpeerkeys_tester ) { try { - // getpeerkeys ranks by POSITION among SCHEDULABLE producers, so the roster needs opreg - // operator rows and an active finalizer key each -- not just regproducer. + // getpeerkeys ranks by POSITION among ELIGIBLE producers -- an active producers row whose + // owner is an ACTIVE PRODUCER operator in sysio.opreg -- so the roster needs operator rows, + // not just regproducer. It deliberately needs NO finalizer key: peer discovery has to cover a + // producer scheduled through setprods before it registers one, or BP gossip cannot reach it. std::vector prod_names = activate_producers_with_operators(); - for (size_t i = 0; i < prod_names.size(); ++i) { - push_action(config::system_account_name, "setacctram"_n, - mvo()("account", prod_names[i])("ram_bytes", int64_t(1'000'000))); - } - produce_block(); - for (size_t i = 0; i < prod_names.size(); ++i) { - BOOST_REQUIRE_EQUAL(success(), - push_action(prod_names[i], "regfinkey"_n, mvo() - ("finalizer_name", prod_names[i]) - ("finalizer_key", sysio_test::key_pairs[i].pub_key) - ("proof_of_possession", sysio_test::key_pairs[i].pop))); - } - produce_block(); // Register peer keys for the even-indexed producers; the odd ones stay keyless. std::map registered; diff --git a/contracts/tests/sysio.dispatch_tests.cpp b/contracts/tests/sysio.dispatch_tests.cpp index de12e9373e..71619ec07a 100644 --- a/contracts/tests/sysio.dispatch_tests.cpp +++ b/contracts/tests/sysio.dispatch_tests.cpp @@ -1356,7 +1356,7 @@ class sysio_dispatch_tester : public tester { ("governance_bps", uint16_t(1000)) ("producer_bps", uint16_t(7000)) ("batch_op_bps", uint16_t(3000)) - ("standby_end_rank", uint32_t(28)) + ("standby_end_rank", uint32_t(28))("standby_bps", uint16_t(800)) ("epoch_log_retention_count", uint32_t(8640)) ("pay_cadence_epochs", uint16_t(1))))); produce_blocks(); diff --git a/contracts/tests/sysio.epoch_flushwtdw_tests.cpp b/contracts/tests/sysio.epoch_flushwtdw_tests.cpp index 72895a0c0f..f3911a5eb6 100644 --- a/contracts/tests/sysio.epoch_flushwtdw_tests.cpp +++ b/contracts/tests/sysio.epoch_flushwtdw_tests.cpp @@ -183,7 +183,7 @@ class sysio_epoch_flushwtdw_tester : public tester { ("governance_bps", uint16_t(1000)) ("producer_bps", uint16_t(7000)) ("batch_op_bps", uint16_t(3000)) - ("standby_end_rank", uint32_t(28)) + ("standby_end_rank", uint32_t(28))("standby_bps", uint16_t(800)) ("epoch_log_retention_count", uint32_t(8640)) ("pay_cadence_epochs", uint16_t(1)))); } diff --git a/contracts/tests/sysio.msgch_chain_tests.cpp b/contracts/tests/sysio.msgch_chain_tests.cpp index 3cb66df44a..2b9934cd98 100644 --- a/contracts/tests/sysio.msgch_chain_tests.cpp +++ b/contracts/tests/sysio.msgch_chain_tests.cpp @@ -334,7 +334,7 @@ class sysio_msgch_chain_tester : public tester { ("governance_bps", uint16_t(1000)) ("producer_bps", uint16_t(7000)) ("batch_op_bps", uint16_t(3000)) - ("standby_end_rank", uint32_t(28)) + ("standby_end_rank", uint32_t(28))("standby_bps", uint16_t(800)) ("epoch_log_retention_count", uint32_t(8640)) ("pay_cadence_epochs", uint16_t(1)))); } diff --git a/contracts/tests/sysio.roa_tests.cpp b/contracts/tests/sysio.roa_tests.cpp index 5d98c78478..7eb4c8dc20 100644 --- a/contracts/tests/sysio.roa_tests.cpp +++ b/contracts/tests/sysio.roa_tests.cpp @@ -91,7 +91,7 @@ class sysio_roa_tester : public tester { ("governance_bps", uint16_t(1000)) ("producer_bps", uint16_t(7000)) ("batch_op_bps", uint16_t(3000)) - ("standby_end_rank", uint32_t(28)) + ("standby_end_rank", uint32_t(28))("standby_bps", uint16_t(800)) ("epoch_log_retention_count", uint32_t(8640))("pay_cadence_epochs", uint16_t(1)); auto act_type = sys_abi_ser.get_action_type("setemitcfg"_n); diff --git a/contracts/tests/sysio.snapshot_attest_tests.cpp b/contracts/tests/sysio.snapshot_attest_tests.cpp index 1f53d7e98a..c831826fb3 100644 --- a/contracts/tests/sysio.snapshot_attest_tests.cpp +++ b/contracts/tests/sysio.snapshot_attest_tests.cpp @@ -629,9 +629,15 @@ BOOST_FIXTURE_TEST_CASE(votesnaphash_quorum_credits_voting_producers, snapshot_v const auto attestations_of = [this](account_name producer) { return get_producer_info(producer)["snapshot_attestations"].as(); }; + const auto key_of = [this](account_name producer) { + return get_producer_info(producer)["rank_score"].as(); + }; for (const auto& p : {"producer1"_n, "producer2"_n, "producer3"_n}) { BOOST_REQUIRE_EQUAL(0u, attestations_of(p)); } + const uint64_t key1 = key_of("producer1"_n); + const uint64_t key2 = key_of("producer2"_n); + const uint64_t key3 = key_of("producer3"_n); const auto block_num = vote_block_num(); auto bid = make_block_id(block_num); @@ -648,6 +654,12 @@ BOOST_FIXTURE_TEST_CASE(votesnaphash_quorum_credits_voting_producers, snapshot_v BOOST_REQUIRE_EQUAL(1u, attestations_of("producer2"_n)); // producer3 registered a provider but never voted, so it earned nothing. BOOST_REQUIRE_EQUAL(0u, attestations_of("producer3"_n)); + + // The credit is a SCORING factor and it reaches the index at once: a higher composite is a + // numerically LOWER key. producer3 earned nothing, so its key is untouched. + BOOST_REQUIRE_LT(key_of("producer1"_n), key1); + BOOST_REQUIRE_LT(key_of("producer2"_n), key2); + BOOST_REQUIRE_EQUAL(key3, key_of("producer3"_n)); } FC_LOG_AND_RETHROW() } BOOST_FIXTURE_TEST_CASE(votesnaphash_same_tuple_retry_is_idempotent, snapshot_voting_tester) { try { diff --git a/contracts/tests/sysio.system_tester.hpp b/contracts/tests/sysio.system_tester.hpp index 7cd39cc3fd..fc12c5d7de 100644 --- a/contracts/tests/sysio.system_tester.hpp +++ b/contracts/tests/sysio.system_tester.hpp @@ -47,7 +47,7 @@ inline fc::mutable_variant_object default_emission_config() { ("annual_min_emission", int64_t(100000000000000LL * 365)) ("compute_bps", uint16_t(4000))("capex_bps", uint16_t(2000))("governance_bps", uint16_t(1000)) ("producer_bps", uint16_t(7000))("batch_op_bps", uint16_t(3000)) - ("standby_end_rank", uint32_t(28))("epoch_log_retention_count", uint32_t(8640)) + ("standby_end_rank", uint32_t(28))("standby_bps", uint16_t(800))("epoch_log_retention_count", uint32_t(8640)) ("pay_cadence_epochs", uint16_t(1)); } diff --git a/tests/TestHarness/Cluster.py b/tests/TestHarness/Cluster.py index f25c38cdd3..0e45dfe0f5 100644 --- a/tests/TestHarness/Cluster.py +++ b/tests/TestHarness/Cluster.py @@ -1565,7 +1565,7 @@ def createSystemAccount(accountName): if loadSystemContract: Utils.Print("Set default emission config") action="setemitcfg" - data='{"cfg":{"t1_allocation":"7500000000000000","t2_allocation":"1000000000000000","t3_allocation":"100000000000000","t1_duration":31104000,"t2_duration":62208000,"t3_duration":93312000,"min_claimable":"10000000000","t5_distributable":"375000000000000000","t5_floor":"125000000000000000","target_annual_decay_bps":6940,"annual_initial_emission":"205549750000000000","annual_max_emission":"1095000000000000000","annual_min_emission":"36500000000000000","compute_bps":4000,"capex_bps":2000,"governance_bps":1000,"producer_bps":7000,"batch_op_bps":3000,"standby_end_rank":28,"epoch_log_retention_count":8640,"pay_cadence_epochs":2}}' + data='{"cfg":{"t1_allocation":"7500000000000000","t2_allocation":"1000000000000000","t3_allocation":"100000000000000","t1_duration":31104000,"t2_duration":62208000,"t3_duration":93312000,"min_claimable":"10000000000","t5_distributable":"375000000000000000","t5_floor":"125000000000000000","target_annual_decay_bps":6940,"annual_initial_emission":"205549750000000000","annual_max_emission":"1095000000000000000","annual_min_emission":"36500000000000000","compute_bps":4000,"capex_bps":2000,"governance_bps":1000,"producer_bps":7000,"batch_op_bps":3000,"standby_end_rank":28,"standby_bps":800,"epoch_log_retention_count":8640,"pay_cadence_epochs":2}}' opts="--permission %s@active" % (sysioAccount.name) trans=biosNode.pushMessage(sysioAccount.name, action, data, opts) transId=Node.getTransId(trans[1]) From bcd82cdcb7291fd3e74d74f3828425469082ab54 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Thu, 3 Sep 2026 20:43:49 -0500 Subject: [PATCH 03/16] fix(sysio.system): open the rescore sweep from opreg's setconfig notification (WIRE-367) sysio.system scores producer rank against opreg's producer collateral minimums, but it learned of a change only by comparing a second-granular config stamp inside onblock's throttle, so two setconfig calls landing inside one second could leave the second one unscored. sysio.opreg::setconfig now require_recipient()s sysio.system, and an on_notify handler opens the sweep the moment the action lands. The stamp field, its detector, and the throttle probe are gone; the global row loses scored_collateral_stamp (ABI change; sysio.system wasm/abi and sysio.opreg wasm rebuilt). collateral_minimum_change_opens_rescore_sweep now asserts the sweep is pending as soon as setconfig lands and that a second change reopens it. Change-Id: If7387f7f8b97698d8bc9bcbe6631fababcc48c32 --- contracts/sysio.opreg/src/sysio.opreg.cpp | 5 +++ contracts/sysio.opreg/sysio.opreg.wasm | Bin 91711 -> 91723 bytes .../include/sysio.system/sysio.system.hpp | 35 +++++++++--------- contracts/sysio.system/src/producer_pay.cpp | 25 ------------- contracts/sysio.system/src/ranking.cpp | 10 +++++ contracts/sysio.system/sysio.system.abi | 4 -- contracts/sysio.system/sysio.system.wasm | Bin 191793 -> 191661 bytes contracts/tests/emissions_tests.cpp | 22 ++++++++--- 8 files changed, 48 insertions(+), 53 deletions(-) diff --git a/contracts/sysio.opreg/src/sysio.opreg.cpp b/contracts/sysio.opreg/src/sysio.opreg.cpp index 7aacabc1ed..8d4af527ca 100644 --- a/contracts/sysio.opreg/src/sysio.opreg.cpp +++ b/contracts/sysio.opreg/src/sysio.opreg.cpp @@ -282,6 +282,11 @@ void opreg::setconfig(uint32_t max_available_producers, cfg.req_batchop_collat = std::move(req_batchop_collat); cfg.req_uw_collat = std::move(req_uw_collat); cfg_tbl.set(cfg, ram_payer); + + // sysio.system scores producer rank on the ratio of posted collateral to these minimums, so + // every stored score is stale the moment they move. Tell it on the same channel processprod + // uses; it opens a bounded rescore sweep on the notification. + require_recipient(opreg::SYSTEM_ACCOUNT); } // --------------------------------------------------------------------------- diff --git a/contracts/sysio.opreg/sysio.opreg.wasm b/contracts/sysio.opreg/sysio.opreg.wasm index 9989dc237a71b0d8a09e2a849f99b21c08be46fa..37d5654084b70b0ebbe40c5d9412daaa05725e5a 100755 GIT binary patch delta 1057 zcmZuwdq`7Z6!+Zg=AC!i^|6<2cBhrAftK0Bvf8y;icvHtJ%yqqSQKUy5hPL|St7<) zX;Cs**~1>vmyN_yB>P8AqgGZ{SeZelMFvGiov#rnv_HRV{p`-OpC zkTle6Fav~>bmK1wr*lpFI1oww#9biJ1oI34f#h0>(7A8P2GCMzQXD(%@-~17Laehg6v}%eDG_+vKTXvJ3G!<=OQXfp9F54^)?@Oo4Adw5xYaog^ z?b#Tqv>QM}h5|;~V)ybG*qlOu7E zyfle4IPK#yuy#S#G>D}IS*aRaRrTtR!L|PE;>WC&oOC_Fx`Ny~%!zVm;Qy8Ge*+e~ zpJOL<-I^5|Y_E2cp2zm?Y^ez|2v%NH&VB?Lu@tRG=4@O!haweiuhU~r{J}c{9=%68 z*a|L{r^K=X&cQjb>$2aR5~74iF?6szmEBLOV0~s+bjE^EwaW+!S?epqM&9`LBW?b< zHbAVz+V!Y7Ex!00JyLwR3c`H)x=;)muD@fPqisGF9^Up|9u+^(|alF9m944W)&xQ#!sZK75p|{xtMZ#bG@gILvf{F0cj-EXk_{EQ3G8B4^%*@x*!}o@0xdJ0Ob{(ZCx1Oe zBqD*VDUmfs4YMbi0kkwXxf`@BCuIYHI2sz{0g;V1j{y+rk~A+457Y7hbj&;?mA6UK zN&r1QYH0yI+iV>OV4!<#1z=!pw)K!CS_GZk^o&S?L}Y!5MC`;z@Gj4=FyC~g568MW zy%qYg#rAQ;Dq55}Ng=z_~9~GiU`%{*21dOyGGcQH7sCC2{g;z@| zzN7jBe2)+zCm?QDeKtGOLeVXJRw5inKdP0m3*#>rR1L{j#Sf`&z!Tq<14Znj}v+?=b| z>o8_jLdj5tuGA0nA(>7uOzqLhZ4iRX(?SRBH_?ey`Qc3+E!LK{ zQmpxJ8e#@_clCaWbVU0_gm~4CiJ{-y4 120 ) { - // A collateral-minimum change invalidates every stored score, and this throttle is the - // only place the chain looks. Detect BEFORE draining so a change noticed on this tick - // starts draining on it rather than a minute later. - detect_collateral_config_change(); // Drain any pending rescore BEFORE rebuilding, so the rebuild sees the freshest scores it // can. A sweep spans several ticks; the schedule is proposed from a partially-rescored // index in the meantime, which is safe because the tiers -- not the composite -- decide @@ -145,27 +141,6 @@ namespace sysiosystem { rescore_producer( producer ); } - void system_contract::detect_collateral_config_change() { - sysio::opreg::opconfig_t cfg_tbl( opreg_refs::account ); - const auto cfg = cfg_tbl.get_or_default( sysio::opreg::op_config{} ); - - // setconfig stamps every entry with the same on-chain time, so the first entry represents the - // whole vector. An empty vector has no stamp; 0 stands for it, and it is distinguishable from - // any real stamp because current_time_ms() is never 0 on a live chain. Both directions of the - // empty/non-empty transition therefore register as a change. - const uint64_t stamp = cfg.req_prod_collat.empty() - ? uint64_t{0} - : cfg.req_prod_collat.front().config_timestamp_ms; - - if( stamp == _global.get().scored_collateral_stamp ) return; - - _global.modify( get_self(), [&]( auto& g ) { - g.scored_collateral_stamp = stamp; - g.rescore_cursor = 0; - g.rescore_pending = true; - }); - } - void system_contract::drain_rescore_cursor() { const auto& state = _global.get(); if( !state.rescore_pending ) return; diff --git a/contracts/sysio.system/src/ranking.cpp b/contracts/sysio.system/src/ranking.cpp index 9d3b328046..b2bbbedaa6 100644 --- a/contracts/sysio.system/src/ranking.cpp +++ b/contracts/sysio.system/src/ranking.cpp @@ -90,6 +90,16 @@ namespace sysiosystem { // Every stored rank_score was computed under the OLD weights. Rather than rewrite an // unbounded table inline, open a rescore sweep: onblock drains a bounded number of rows per // schedule-rebuild tick until the cursor is exhausted. + open_rescore_sweep(); + } + + void system_contract::onsetconfig() { + // The collateral minimums moved (sysio.opreg::setconfig notified us), so every stored score's + // collateral ratio is stale. Same remedy as a weight change. + open_rescore_sweep(); + } + + void system_contract::open_rescore_sweep() { _global.modify( get_self(), []( auto& g ) { g.rescore_cursor = 0; g.rescore_pending = true; diff --git a/contracts/sysio.system/sysio.system.abi b/contracts/sysio.system/sysio.system.abi index 534cd6a1ff..fd50653772 100644 --- a/contracts/sysio.system/sysio.system.abi +++ b/contracts/sysio.system/sysio.system.abi @@ -1507,10 +1507,6 @@ { "name": "rescore_pending", "type": "bool" - }, - { - "name": "scored_collateral_stamp", - "type": "uint64" } ] }, diff --git a/contracts/sysio.system/sysio.system.wasm b/contracts/sysio.system/sysio.system.wasm index b506fa3c7e56e20aa32980655ce4b8dfdbe96784..7d61ea6ef9f06b37278bca0fc279f18a05f874a9 100755 GIT binary patch delta 18946 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 F{{Y>HZ5jXo delta 19935 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 P?liyX9e$dh&;9=ZWzhRj diff --git a/contracts/tests/emissions_tests.cpp b/contracts/tests/emissions_tests.cpp index 00caea5e22..fb0bc98f53 100644 --- a/contracts/tests/emissions_tests.cpp +++ b/contracts/tests/emissions_tests.cpp @@ -5830,22 +5830,32 @@ BOOST_FIXTURE_TEST_CASE( rescore_sweep_drains_across_ticks, producer_score_teste BOOST_REQUIRE( !rescore_pending() ); } FC_LOG_AND_RETHROW() -// The collateral minimums live on sysio.opreg, a contract sysio.system cannot be notified by -// without a ten-parameter handler that would still miss any future writer. Instead the throttled -// `onblock` tick compares the config's stamp against the one the stored scores were computed -// under, and opens a sweep when they differ. +// The collateral minimums live on sysio.opreg, whose `setconfig` notifies sysio.system on the +// same channel `processprod` uses. The notification opens the sweep at once -- no throttle tick +// has to notice a stamp -- so two changes inside one second cannot lose the second one. BOOST_FIXTURE_TEST_CASE( collateral_minimum_change_opens_rescore_sweep, producer_score_tester ) try { auto names = setup_collateralized_producers(3); trigger_reschedule(); BOOST_REQUIRE( !rescore_pending() ); const uint64_t before = rank_score_of(names[0]); - // Halving the minimum doubles every ratio, so every stored score is now wrong. + // Halving the minimum doubles every ratio, so every stored score is now wrong -- and the + // sweep is pending the moment setconfig lands, before any tick. BOOST_REQUIRE_EQUAL( success(), set_single_pair_collateral(base_min_bond / 2) ); + BOOST_REQUIRE( rescore_pending() ); trigger_reschedule(); BOOST_REQUIRE( !rescore_pending() ); // three rows drain in one tick - BOOST_REQUIRE_LT( rank_score_of(names[0]), before ); + const uint64_t halved = rank_score_of(names[0]); + BOOST_REQUIRE_LT( halved, before ); + + // Every change opens a sweep, the second as surely as the first; the drain scores against the + // config that is live when it runs. + BOOST_REQUIRE_EQUAL( success(), set_single_pair_collateral(base_min_bond / 4) ); + BOOST_REQUIRE( rescore_pending() ); + trigger_reschedule(); + BOOST_REQUIRE( !rescore_pending() ); + BOOST_REQUIRE_LT( rank_score_of(names[0]), halved ); } FC_LOG_AND_RETHROW() // --------------------------------------------------------------------------- From 23b52fc9603c661f398276fb9cc2064b14b0691b Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Thu, 3 Sep 2026 21:36:32 -0500 Subject: [PATCH 04/16] fix(sysio.system): rank keys track live standing; snapshot credit restarts on re-entry (WIRE-367) Review fixes on the score-based ranking: - The demoted tier now bounds every rank walk by LIVE standing. A parked (`unregprod`) row scores into the demoted tier and is rescored in the same action; sysio.opreg dispatches its `processprod` notification from `slash` and `terminate_inline` too (`notify_producer_standing`), and the eligibility sink notifies even for a terminal row while still refusing to transition it. Before this a parked, slashed or terminated producer kept its healthy-tier key until some unrelated event rescored it -- skipped by every walk but visited by all of them, and contradicting the comments that said otherwise. - `rescore` zeroes `snapshot_attestations` when a row re-enters the walk. `payepoch` resets the counter only on rows it visits and never visits the demoted tier, so a credit earned before a demotion, park or de-collateralization rode back in as a stale factor. The miss streak is deliberately kept: resetting it would let withdraw/cancel toggling dodge a demotion. - The collateral factor reads the bonded balance only. The sysio.uwrit `locksums` subtraction could never hit -- an account holds one operator row of one type and only active underwriters carry locks -- so the read, the uwrit include and the two include dirs it needed are gone. - payepoch comments corrected: a terminated operator can settle, re-register and be paid its carried blocks; a slashed one never returns. Tests: the park test asserts the tier sinks at once and is restored by regproducer; `slash_and_termination_sink_the_key_at_once`; `re_entering_the_walk_restarts_the_snapshot_credit` (snapshot suite). ABIs unchanged; sysio.system and sysio.opreg wasm rebuilt. Change-Id: I41d1f1ed2aeee437f84c1afa7f8788d37a965d6f --- contracts/sysio.opreg/src/sysio.opreg.cpp | 36 +++++- contracts/sysio.opreg/sysio.opreg.wasm | Bin 91723 -> 91957 bytes contracts/sysio.system/CMakeLists.txt | 2 - .../include/sysio.system/producer_score.hpp | 118 +++++++++--------- contracts/sysio.system/src/emissions.cpp | 15 ++- contracts/sysio.system/src/ranking.cpp | 6 + contracts/sysio.system/sysio.system.wasm | Bin 191661 -> 191249 bytes contracts/tests/emissions_tests.cpp | 28 +++++ .../tests/sysio.snapshot_attest_tests.cpp | 34 +++++ 9 files changed, 168 insertions(+), 71 deletions(-) diff --git a/contracts/sysio.opreg/src/sysio.opreg.cpp b/contracts/sysio.opreg/src/sysio.opreg.cpp index 8d4af527ca..487f8d86e3 100644 --- a/contracts/sysio.opreg/src/sysio.opreg.cpp +++ b/contracts/sysio.opreg/src/sysio.opreg.cpp @@ -1106,6 +1106,22 @@ void reevaluate_eligibility(opreg::operators_t& ops, ).send(); } +/// Tell sysio.system that a producer's standing ended through a path +/// `reevaluate_eligibility` does not cover -- a terminal transition (slash, +/// termination). Same `processprod` channel, no eligibility transition +/// (was == is), so the notification is the whole effect: sysio.system rescores +/// the producer from its live status and sinks its rank key at once, instead of +/// leaving a slashed or terminated producer in the healthy tier until some +/// unrelated event rescored it. +void notify_producer_standing(name self, const opreg::operator_entry& op) { + if (op.type != OperatorType::OPERATOR_TYPE_PRODUCER) return; + action( + permission_level{self, "active"_n}, + self, "processprod"_n, + std::make_tuple(op.account, false, false) + ).send(); +} + } // anonymous namespace // --------------------------------------------------------------------------- @@ -1394,24 +1410,28 @@ void process_eligibility_change(name self, name account, // belongs at the transition sink as well as at each caller. A stale or // newly introduced callback must never reactivate an operator after slash // or termination merely because its collateral predicate says eligible. - if (has_terminal_status(ops.get(op_pk).status)) return; + // The notification below is NOT gated on it: a producer's terminal + // transition is exactly what sysio.system must hear about, and slash and + // termination dispatch through here (`notify_producer_standing`) to say so. + const bool terminal = has_terminal_status(ops.get(op_pk).status); auto now = current_time_ms(); - if (!was_eligible && is_eligible) { + if (!terminal && !was_eligible && is_eligible) { ops.modify(same_payer, op_pk, [&](auto& o) { o.status = OperatorStatus::OPERATOR_STATUS_ACTIVE; o.available_at = now; }); - } else if (was_eligible && !is_eligible) { + } else if (!terminal && was_eligible && !is_eligible) { ops.modify(same_payer, op_pk, [&](auto& o) { o.status = OperatorStatus::OPERATOR_STATUS_UNKNOWN; }); } // Notify OUTSIDE the transition branches. sysio.system rescores the producer's rank from its - // live collateral, so it must hear about a top-up that changed no status, and about a drop out - // of ACTIVE -- not only about a promotion into it. A stale score is not merely cosmetic: it - // leaves a de-collateralized producer holding an index slot ahead of bonded ones. + // live standing, so it must hear about a top-up that changed no status, about a drop out of + // ACTIVE, and about a slash or termination -- not only about a promotion. A stale score is not + // merely cosmetic: it leaves a de-collateralized, slashed or terminated producer holding an + // index slot ahead of bonded ones. if (notify_system) { require_recipient(opreg::SYSTEM_ACCOUNT); } @@ -1484,6 +1504,8 @@ void opreg::slash(name account, std::string reason) { emit_slash_attestation(get_self(), slash_action); append_action_log(ops, op_pk, slash_action, /*success*/ true, ""); } + + notify_producer_standing(get_self(), op); } // --------------------------------------------------------------------------- @@ -1628,6 +1650,8 @@ void terminate_inline(name self, name account, const std::string& reason) { append_action_log(ops, op_pk, remit_action, /*success*/ true, std::string("terminate-remit")); } + + notify_producer_standing(self, op); } } // anonymous namespace diff --git a/contracts/sysio.opreg/sysio.opreg.wasm b/contracts/sysio.opreg/sysio.opreg.wasm index 37d5654084b70b0ebbe40c5d9412daaa05725e5a..da041d3ddae57fddf273426aa04db026443965cb 100755 GIT binary patch delta 5194 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 RE>4W5ntRfpG6u12{sZMVhF|~y diff --git a/contracts/sysio.system/CMakeLists.txt b/contracts/sysio.system/CMakeLists.txt index b2d68a8709..1bdd50d36e 100644 --- a/contracts/sysio.system/CMakeLists.txt +++ b/contracts/sysio.system/CMakeLists.txt @@ -30,8 +30,6 @@ set(SYSIO_SYSTEM_OPP_INCLUDE_DIRS ${CMAKE_CURRENT_BINARY_DIR}/../../libraries/opp/generated-cdt ${CMAKE_CURRENT_SOURCE_DIR}/../sysio.opp.common/include ${CMAKE_CURRENT_SOURCE_DIR}/../sysio.opreg/include - ${CMAKE_CURRENT_SOURCE_DIR}/../sysio.uwrit/include - ${CMAKE_CURRENT_SOURCE_DIR}/../../libraries/libfc-lite/include ${CMAKE_CURRENT_SOURCE_DIR}/../sysio.epoch/include) if(BUILD_SYSTEM_CONTRACTS) diff --git a/contracts/sysio.system/include/sysio.system/producer_score.hpp b/contracts/sysio.system/include/sysio.system/producer_score.hpp index 03c4944a89..6c01d59def 100644 --- a/contracts/sysio.system/include/sysio.system/producer_score.hpp +++ b/contracts/sysio.system/include/sysio.system/producer_score.hpp @@ -3,7 +3,6 @@ #include #include #include -#include #include #include @@ -16,59 +15,43 @@ // on-chain standing into the composite score `producer_rank::pack` encodes. // // Split from producer_rank.hpp, which sysio.system.hpp includes, because this header pulls in the -// sysio.opreg and sysio.uwrit table declarations plus the OPP protobuf types. Only the translation -// units that actually compute a score include it -- the same reason opreg_status.hpp exists -// separately. +// sysio.opreg table declarations plus the OPP protobuf types. Only the translation units that +// actually compute a score include it -- the same reason opreg_status.hpp exists separately. namespace sysiosystem { namespace producer_rank { - /// Well-known sysio.uwrit account -- `locksums` lives here and is the O(1) read cache for a - /// bucket's active locks. - namespace uwrit_refs { - constexpr sysio::name account = "sysio.uwrit"_n; - } - /** - * Balance minus active locks for one (chain, token) pair, read cross-contract from sysio.opreg - * and sysio.uwrit. + * The bond a producer has posted on one (chain, token) pair, read from its sysio.opreg row. * - * This is opreg's `slashable_now`, NOT its `available()`. available() subtracts pending - * withdraws, and withdraw / cancelwtdw are both free and uncapped -- an operator could - * oscillate their own rank without moving funds. available() also walks the withdraw queue per - * account with no row cap, where this is two O(1) lookups. Ranking on what is actually - * slashable is also the right principle: score should track what can be taken from you, and a - * queued withdraw does not reduce exposure. + * This is what a slash seizes from a producer, and it is deliberately the BALANCE rather than + * opreg's `available()`: available() subtracts pending withdraws, and withdraw / cancelwtdw are + * both free, uncapped and cooldown-free, so an operator could oscillate their own rank without + * moving funds. Score tracks what can be taken from you, and a queued withdraw does not reduce + * exposure. Nothing is subtracted for sysio.uwrit locks: an account holds exactly one operator + * row of one type, and only ACTIVE underwriters ever carry locks, so a PRODUCER's whole balance + * is slashable. * * @param op the operator row read from sysio.opreg. * @param chain_code the chain slug of the pair. * @param token_code the token slug of the pair. - * @return balance minus active locks, saturating at zero. + * @return the pair's balance, or 0 when the operator holds no row for it. */ - inline uint64_t slashable_now(const sysio::opreg::operator_entry& op, - sysio::slug_name chain_code, - sysio::slug_name token_code) { - uint64_t balance = 0; - bool found = false; + inline uint64_t bonded_balance(const sysio::opreg::operator_entry& op, + sysio::slug_name chain_code, + sysio::slug_name token_code) { for (const auto& entry : op.balances) { if (entry.chain_code == chain_code && entry.token_code == token_code) { - balance = entry.balance; - found = true; - break; + return entry.balance; } } - if (!found) return 0; - - sysio::uwrit::locksums_t sums(uwrit_refs::account); - const sysio::uwrit::lock_sum_key key{op.account, chain_code, token_code}; - const uint64_t locked = sums.contains(key) ? sums.get(key).amount : 0; - return balance > locked ? balance - locked : 0; + return 0; } /** * Collateral factor: the MINIMUM, across every (chain, token) pair in `req_prod_collat`, of - * slashable / min_bond -- in basis points, linear and uncapped (saturating only at the bit + * bonded / min_bond -- in basis points, linear and uncapped (saturating only at the bit * budget). * * `min` rather than a sum: posting extra on the cheapest chain must do nothing, so raising the @@ -94,10 +77,10 @@ namespace sysiosystem { // than divide by zero if that ever regresses. check(req.min_bond > 0, "req_prod_collat entry has a zero min_bond"); - // uint128 intermediate is mandatory: slashable_now runs to 2^62 and multiplying by + // uint128 intermediate is mandatory: a bond runs to 2^62 and multiplying by // score_scale overflows uint64. const unsigned __int128 scaled = - static_cast(slashable_now(op, req.chain_code, req.token_code)) + static_cast(bonded_balance(op, req.chain_code, req.token_code)) * static_cast(score_scale); const unsigned __int128 ratio = scaled / static_cast(req.min_bond); const uint64_t bounded = ratio > static_cast(composite_max) @@ -193,6 +176,8 @@ namespace sysiosystem { /// four positional flags so a new factor's input is a new member, not a new parameter at every /// call site. struct score_inputs { + /// Whether the producers row is active -- false after `unregprod` parks it. + bool is_active = true; /// Whether the producer is currently demoted for consecutive missed rounds. bool is_demoted = false; /// The producer's current miss streak. @@ -216,16 +201,19 @@ namespace sysiosystem { inline uint64_t compute(const sysio::name& producer, const score_inputs& inputs, const producer_score_config& weights) { - // A producer that is not a live, collateral-backed PRODUCER operator scores into the - // demoted tier. That is correct on its own terms -- an unbonded registrant must never - // outrank a bonded one -- and it is also what BOUNDS the rank walk: `regproducer` is - // permissionless, so without this every consumer would scan an unbounded table. With it, - // the healthy and bootstrapped tiers hold only producers that were ACTIVE operators at - // their LAST rescore, and a consumer stops at the first demoted entry. "Last rescore" is - // deliberate: a terminated or slashed operator is not notified (`reevaluate_eligibility` - // returns early on a terminal status), so its key sits in its old tier until the next - // sweep. That is harmless -- every consumer also tests the live predicate before counting - // a position -- so the one row is read once here rather than twice. + // A producer that is not a live, collateral-backed PRODUCER operator -- parked by + // `unregprod`, unbonded, slashed, terminated -- scores into the demoted tier. That is + // correct on its own terms (an unbonded registrant must never outrank a bonded one) and it + // is also what BOUNDS the rank walk: `regproducer` is permissionless, so without this + // every consumer would scan an unbounded table. With it, the healthy and bootstrapped + // tiers hold only producers that were live at their LAST rescore, and a consumer stops at + // the first demoted entry. Every event that can end a producer's standing rescores it -- + // `unregprod` directly, and sysio.opreg through its `processprod` notification, which it + // dispatches on every balance change AND on slash and termination. The one row a rescore + // cannot reach is one `prune` erased, and the termination before it already sank the + // key; every consumer still tests the live predicate before counting a position. + if (!inputs.is_active) return unscored(); + sysio::opreg::operators_t ops(opreg_refs::account); const auto op_key = sysio::opreg::operator_key{producer.value}; if (!ops.contains(op_key)) return unscored(); @@ -276,18 +264,34 @@ namespace sysiosystem { producer_score_config_t weights_tbl(self); const auto weights = weights_tbl.get_or_default(producer_score_config{}); - const auto info = producers.get(key); - const auto score = compute( - producer, - score_inputs{ - .is_demoted = info.is_demoted, - .consecutive_missed_rounds = info.consecutive_missed_rounds, - .snapshot_attestations = info.snapshot_attestations - }, - weights); + const auto info = producers.get(key); + auto inputs = score_inputs{ + .is_active = info.active(), + .is_demoted = info.is_demoted, + .consecutive_missed_rounds = info.consecutive_missed_rounds, + .snapshot_attestations = info.snapshot_attestations + }; + uint64_t score = compute(producer, inputs, weights); + + // A producer RE-ENTERING the walk starts the period's snapshot credit at zero. `payepoch` + // zeroes the counter only on the rows it visits, and it never visits the demoted tier, so + // a credit earned before a demotion, a park or a de-collateralization would otherwise + // ride back in as a stale factor and outrank a producer that actually attested this + // period. Only the credit restarts: the miss streak stays, so leaving and re-entering + // cannot be used to dodge a demotion, and the block count is pay, never a score input. + const bool re_entering = tier_of(info.rank_score) == producer_tier::demoted + && tier_of(score) != producer_tier::demoted; + const bool reset_credit = re_entering && info.snapshot_attestations > 0; + if (reset_credit) { + inputs.snapshot_attestations = 0; + score = compute(producer, inputs, weights); + } - if (score == info.rank_score) return; // no index move needed - producers.modify(same_payer, key, [&](auto& row) { row.rank_score = score; }); + if (score == info.rank_score && !reset_credit) return; // no index move needed + producers.modify(same_payer, key, [&](auto& row) { + row.rank_score = score; + if (reset_credit) row.snapshot_attestations = 0; + }); } } // namespace producer_rank diff --git a/contracts/sysio.system/src/emissions.cpp b/contracts/sysio.system/src/emissions.cpp index 17b0790c2a..89c3eb7ba2 100644 --- a/contracts/sysio.system/src/emissions.cpp +++ b/contracts/sysio.system/src/emissions.cpp @@ -811,11 +811,13 @@ void system_contract::payepoch(uint32_t epoch_index, // // Counters accumulate across non-pay epochs (no reset by accrueepoch) and are zeroed at the // end of this action for every producer PAID by it. A producer that is not schedulable when the - // walk reaches it -- parked, keyless, slashed, terminated -- is neither paid nor reset: its - // block count waits for the first payepoch where it is schedulable again (a parked or re-keyed - // producer's return; never, for a slashed or terminated one). A producer BELOW the walk - // (demoted, or parked and already rescored) is not visited at all, with the same effect. Every - // block a producer makes is paid exactly once, at the first payepoch where it is payable. + // walk reaches it -- keyless, or one whose standing ended since its last rescore -- is neither + // paid nor reset: its block count waits for the first payepoch where it is schedulable again + // (a re-keyed producer's return; a terminated operator's, should it settle and re-register; + // never, for a slashed one, whose row is never pruned and which `regoperator` refuses). A + // producer BELOW the walk (demoted, parked, unbonded, slashed, terminated -- each rescored at + // the event) is not visited at all, with the same effect. Every block a producer makes is paid + // exactly once, at the first payepoch where it is payable. // ======================================================================= { auto prod_by_rank = _producers.get_index<"prodrank"_n>(); @@ -857,7 +859,8 @@ void system_contract::payepoch(uint32_t epoch_index, // counter-reset list (to_reset). `position` is POSITION in this index among SCHEDULABLE // producers, counted while walking -- not a stored ordinal. The demoted tier sorts last and // is never schedulable, so it bounds the walk over what is a permissionless, unbounded - // table; every row above it is an ACTIVE bonded producer operator. + // table; every row above it was a live, bonded producer operator at its last rescore, and + // every event that ends that standing rescores the row (see producer_rank::compute). // // The divisor counts exactly the blocks this payepoch pays for. A count that waits on an // unpayable row is neither paid nor counted now; when its producer is payable again the diff --git a/contracts/sysio.system/src/ranking.cpp b/contracts/sysio.system/src/ranking.cpp index b2bbbedaa6..09c39f7276 100644 --- a/contracts/sysio.system/src/ranking.cpp +++ b/contracts/sysio.system/src/ranking.cpp @@ -132,6 +132,12 @@ namespace sysiosystem { _producers.modify( get_self(), key, [&]( producer_info& info ){ info.deactivate(); }); + + // A parked row scores into the demoted tier, so the sort key is stale until rescored. This + // is what keeps the tier a statement about LIVE standing: every rank walk stops at the first + // demoted entry, and a parked producer left in its old tier would be visited (and skipped) + // by every one of them until some unrelated event happened to rescore it. + rescore_producer( producer ); } void system_contract::update_ranked_producers( const block_timestamp& block_time ) { diff --git a/contracts/sysio.system/sysio.system.wasm b/contracts/sysio.system/sysio.system.wasm index 7d61ea6ef9f06b37278bca0fc279f18a05f874a9..26892cd09fd044538ce192e871bd40e1a15eedeb 100755 GIT binary patch delta 10485 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 US3IY(); + }; + const auto key_of = [this](account_name producer) { + return get_producer_info(producer)["rank_score"].as(); + }; + const uint64_t uncredited = key_of("producer1"_n); + + const auto block_num = vote_block_num(); + BOOST_REQUIRE_EQUAL(success(), votesnaphash("snapprov1"_n, make_block_id(block_num), make_snap_hash(1))); + BOOST_REQUIRE_EQUAL(success(), votesnaphash("snapprov2"_n, make_block_id(block_num), make_snap_hash(1))); + BOOST_REQUIRE_EQUAL(1u, attestations_of("producer1"_n)); + BOOST_REQUIRE_LT(key_of("producer1"_n), uncredited); + + // Leaving the walk keeps the counter (nothing visits the row to zero it) ... + BOOST_REQUIRE_EQUAL(success(), unregproducer("producer1"_n)); + BOOST_REQUIRE_EQUAL(1u, attestations_of("producer1"_n)); + + // ... and re-entering restarts it: the key comes back exactly as it was before the credit. + BOOST_REQUIRE_EQUAL(success(), regproducer("producer1"_n)); + BOOST_REQUIRE_EQUAL(0u, attestations_of("producer1"_n)); + BOOST_REQUIRE_EQUAL(uncredited, key_of("producer1"_n)); +} FC_LOG_AND_RETHROW() } + BOOST_FIXTURE_TEST_CASE(votesnaphash_same_tuple_retry_is_idempotent, snapshot_voting_tester) { try { // Need 2 providers, min_providers=2 so single vote won't attest and purge BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer1"_n, "snapprov1"_n)); From bd35a87a7d3d6a29743c49be4dcfac47f50ac3ce Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Fri, 4 Sep 2026 08:15:57 -0500 Subject: [PATCH 05/16] feat(sysio.system): recover a demotion by producing, and weight snapshot service as a tiebreak (WIRE-367) Three review outcomes, decided together: - A demoted producer that is STILL in the active schedule now clears its demotion by producing a block, not only by pushing `regproducer`. Demotion and rescheduling are separate events, and the `min_schedule_size` floor can hold the gap open indefinitely: when demotions drop the schedulable count below it, the rebuild retains the last good schedule and the demoted producers keep producing under it. Without this they produce for nothing -- `payepoch` stops at the demoted tier -- until every operator re-registers by hand, which is what a mass outage would have caused. A producer the schedule has already dropped never reaches this path and still needs `regproducer`. - `snapshot_weight` defaults to a tenth of the collateral weight. At parity one quorum attestation moved the composite by as much as an entire minimum bond, so among producers bonded near each other the credit decided the top-21 boundary and the pay-period reset decided it back, proposing a producer schedule and a finalizer policy each way for no change in real standing. Snapshot service should separate producers the collateral term has left tied. - Documented the participation penalty's lasting consequence, which is intended rather than an oversight: the streak clears only by producing, so a producer whose penalty drops it below the active schedule holds that penalty until it re-registers or outbids on collateral. Clearing the streak for producers outside the schedule would let a boundary producer flap in and out at every rebuild. Test: `producing_while_still_scheduled_clears_a_demotion` demotes one of four producers, so three remain schedulable and the floor forces the schedule to be retained, then shows the demoted producer recovering by producing. Change-Id: I943155f8b3ef3e020d10599d08a6b1dc7c277e83 --- .../include/sysio.system/producer_rank.hpp | 26 +++++++++--- contracts/sysio.system/src/producer_pay.cpp | 35 +++++++++++---- contracts/sysio.system/src/ranking.cpp | 9 ++-- contracts/sysio.system/sysio.system.wasm | Bin 191249 -> 191299 bytes contracts/tests/emissions_tests.cpp | 40 ++++++++++++++++-- 5 files changed, 90 insertions(+), 20 deletions(-) diff --git a/contracts/sysio.system/include/sysio.system/producer_rank.hpp b/contracts/sysio.system/include/sysio.system/producer_rank.hpp index 1c845a5d69..cb3d633860 100644 --- a/contracts/sysio.system/include/sysio.system/producer_rank.hpp +++ b/contracts/sysio.system/include/sysio.system/producer_rank.hpp @@ -140,6 +140,15 @@ namespace sysiosystem { * It only orders producers across the misses BEFORE demotion fires; the categorical * consequence of being offline is the tier, not this term. * + * A miss short of demotion still has a lasting consequence, and it is deliberate. The streak + * clears only when the producer PRODUCES, so a producer whose penalty drops it below the + * active schedule stops being scheduled, stops producing, and holds the penalty until it + * acts: `regproducer` clears the streak, and enough additional collateral outranks it. That + * is the intended shape -- a producer that missed is worth less than an identical one that + * did not, and the way back is an explicit assertion of readiness rather than the passage of + * time. The alternative, clearing the streak for producers outside the schedule, would let a + * producer sitting on the boundary flap in and out at every rebuild. + * * @param consecutive_missed_rounds the producer's current miss streak. * @param max_consecutive_missed_rounds the configured demotion threshold. * @return the participation factor in basis points, clamped to [0, score_scale]. @@ -171,8 +180,14 @@ namespace sysiosystem { uint32_t collateral_weight = static_cast(score_scale); /// Weight on the participation factor derived from consecutive missed rounds. uint32_t participation_weight = static_cast(score_scale); - /// Weight on the snapshot-provider attestation rate. - uint32_t snapshot_weight = static_cast(score_scale); + /// Weight on the snapshot-provider attestation rate. A TENTH of the collateral weight, + /// deliberately: at parity a single quorum attestation moved the composite by as much as + /// an entire minimum bond, so among producers bonded near each other the credit decided + /// the top-21 boundary and the pay-period reset decided it back -- two producer-schedule + /// and finalizer-policy proposals per snapshot event, with no change in real standing. + /// Snapshot service should separate producers the collateral term has left tied, not + /// outrank collateral. + uint32_t snapshot_weight = static_cast(score_scale) / 10; /// Reserved -- needs an attestation path before it can carry weight. uint32_t relay_weight = 0; /// Reserved -- needs an attestation path before it can carry weight. @@ -180,10 +195,9 @@ namespace sysiosystem { /// Reserved -- needs an attestation path before it can carry weight. uint32_t benchmark_weight = 0; - /// Consecutive missed rounds that demote a producer to standby. Demotion lasts until the - /// producer re-registers: there is no cooldown, no expiry and no automatic recovery, - /// because a demoted producer is scheduled for no rounds and so can never clear the - /// counter by producing. + /// Consecutive missed rounds that demote a producer to standby. There is no cooldown and + /// no expiry: a demoted producer recovers by re-registering, or by producing a block while + /// still in the active schedule -- the window a schedule too small to rebuild holds open. uint32_t max_consecutive_missed_rounds = 3; /// Snapshot attestations within one pay period that earn full marks on the snapshot diff --git a/contracts/sysio.system/src/producer_pay.cpp b/contracts/sysio.system/src/producer_pay.cpp index ce73d1ef20..2130426409 100644 --- a/contracts/sysio.system/src/producer_pay.cpp +++ b/contracts/sysio.system/src/producer_pay.cpp @@ -108,11 +108,29 @@ namespace sysiosystem { } } - // The producer of this block is, by construction, not missing its round. + // The producer of this block is, by construction, not missing its round -- and a block is + // the strongest liveness proof there is, so it clears a DEMOTION as well as the streak. + // + // That matters because demotion and rescheduling are not simultaneous. A producer is demoted + // the moment its third miss lands, but it leaves the schedule only at the next rebuild, and + // when demotions drop the schedulable count below `min_schedule_size` the rebuild retains the + // last good schedule instead of publishing a short one -- so the demoted producers come back + // and keep producing under it. Without this, they would produce indefinitely while `payepoch` + // skipped them (the walk stops at the demoted tier), earning nothing until every operator + // pushed `regproducer` by hand. A mass outage is exactly the case that produces it. + // + // A demoted producer that is NOT in the active schedule never reaches this path, so recovery + // still requires `regproducer` for anyone the schedule has actually dropped. auto key = producer_key_t{current_producer.value}; - if( _producers.contains(key) && _producers.get(key).consecutive_missed_rounds > 0 ) { - _producers.modify( same_payer, key, []( auto& p ) { p.consecutive_missed_rounds = 0; }); - rescore_producer( current_producer ); + if( _producers.contains(key) ) { + const auto& info = _producers.get(key); + if( info.consecutive_missed_rounds > 0 || info.is_demoted ) { + _producers.modify( same_payer, key, []( auto& p ) { + p.consecutive_missed_rounds = 0; + p.is_demoted = false; + }); + rescore_producer( current_producer ); + } } _global.modify( get_self(), [&]( auto& g ) { g.last_producer = current_producer; }); @@ -125,10 +143,11 @@ namespace sysiosystem { _producers.modify( same_payer, key, [&]( auto& p ) { p.consecutive_missed_rounds++; - // Demotion is CATEGORICAL: it moves the producer into a tier no score can climb out of, - // and it lasts until the producer re-registers. There is no cooldown and no expiry -- - // a demoted producer is scheduled for no rounds, so it can never clear the counter by - // producing. + // Demotion is CATEGORICAL: it moves the producer into a tier no score can climb out of. + // There is no cooldown and no expiry, and the only two ways back are `regproducer` and + // producing a block while still in the active schedule -- the latter covering the window + // between a demotion and the rebuild that acts on it, which the schedule-size floor can + // hold open indefinitely. if( !p.is_demoted && max_consecutive_missed_rounds > 0 && p.consecutive_missed_rounds >= max_consecutive_missed_rounds ) { p.is_demoted = true; diff --git a/contracts/sysio.system/src/ranking.cpp b/contracts/sysio.system/src/ranking.cpp index 09c39f7276..b7352606bc 100644 --- a/contracts/sysio.system/src/ranking.cpp +++ b/contracts/sysio.system/src/ranking.cpp @@ -56,12 +56,15 @@ namespace sysiosystem { info.producer_authority = producer_authority; if ( info.last_claim_time == time_point() ) info.last_claim_time = ct; - // regproducer is the SINGLE door back -- from a voluntary `unregprod` park and from an - // involuntary demotion alike. `unregprod` erases the signing key, so re-registering has + // regproducer is the door back for a producer the schedule has DROPPED -- a voluntary + // `unregprod` park, an involuntary demotion, or a participation penalty that pushed it + // below the active set (the streak clears only by producing, and a producer that is not + // scheduled cannot produce). `unregprod` erases the signing key, so re-registering has // to re-supply it, which makes this a genuine assertion of readiness rather than a // no-op. There is deliberately no cooldown and no expiry: a producer that comes back // before it is ready is demoted again within max_consecutive_missed_rounds rounds, - // which is self-correcting. + // which is self-correcting. A demoted producer that is still in the active schedule + // recovers on its own by producing -- see `record_round_participation`. info.is_demoted = false; info.consecutive_missed_rounds = 0; }); diff --git a/contracts/sysio.system/sysio.system.wasm b/contracts/sysio.system/sysio.system.wasm index 26892cd09fd044538ce192e871bd40e1a15eedeb..4521e422d7ab23b2f61376ed0d0ae431fa3ab81d 100755 GIT binary patch delta 1311 zcmZ8h&ubG=5PtKvyPGyfw%6)m6G2n3hzFBWLHZ(9#4334kJx%pP_ZCxz(9)^Q)7)t z4?TzkR0y<%{O4J}Z)S?h2crh2DCr{S;&D+hkkwD&$nfbo?W@g{Qc6@z1UL7Bj zFMh8~4E>y?SEb2ES4%ewXJpr2sT5Am9{QS)F<(A7Ce)5y>g*o(tK>PJD_t>ah^$c| zS1OT_8(4E9D{FjSA~>ju{;$FPwIL^6nT9cCP9+3_&5W#*YdX2Ooa7%{mnmiOx&*Ho z-mFr}=Cw+x*v!R*fSc)-?){xR96^JoN~NK_bJq=5<{swrc_%Bk2z%txMK3r8<@eZ8|)3$Dw6J{-Ptkosshv^Ko25a0xgFyDL7OJU%gZbWp zuVO_KfD8?x0&YVSX|u0}+lky*Bk)yPU({-MadfXP; zzhU?~fN9dQ4%D>VFr48LR?axV$6-3>@lo(i)9SUcZh20bM!0jRd6R1uL&p@)52y0d zVwL)9;=p+;W%fdY2{4tst-7W3Zo-GXZTGA#`yg=JX5umN#*UCz?LOq^c0aOIU7lQx zzOMT=JRst(aLK6~T88Rtr`oxOuI)>?9}rGQd|Mg>N}wv3@3{!E;w$O413ebDK$8+f2&K@Baa0i6#jE delta 1232 zcmZWp&ubGw6rLZM?WSvCiapezu)BgLkYEMPhAPsjD2l)OA9xhD7lnH0#SK`n2f?(f zrWe6Op+zkS4K#-y3Pq^6w7FP31T9|7u@|8SYkhBavsxsOdHdd*?|t8!H?zKN&1_rm zE4{4qb9uP;$0S_{wYS#$A?Vq|=^rdezn-nviO=o+{TxMCdF zA-4yYVmSGG)=F>ytQa0UxO9g>oyXN`ReEeJE_n?W;Oc1#wM~e9&&EVHSVCfnJG#P< zo7Ko}&sF4-XJg^gffxLBk1PK0dLOYYAWF476JQCbaRpE?eOaSg21v7GkNWzF$Qg=g zP3mNkg=$bg&xi_i6}3oxS*JO`R6&_FD5jLi6jk(r#tPI^ov(WWNxKAhyJmNPEMtxM zwyD^J>a6Q{e}-6gHYT$p{$rzB86QYuMNq29F!_=JQLMmsgvVQ8>kQL(vVrCd7?6Lc zFldB+DU6s&@x>2uaSos;dCn-aX@SzP-Ue`t^T8?+D5}#6{I%in8ukmbqBILEdDFRT zCV^ih<%@ZpG!LvZlw6>w1S30X1T1|_iN;l^ff}1oZ&4(=>gp^#8brVp!x{iu3>FFl zOU}| zNGI{SQ9%C1f4oD97oMJ#HyQf*IF0I6hu*^1slMgV Q(`Y=|bLh&R!)|>112Sz2w*UYD diff --git a/contracts/tests/emissions_tests.cpp b/contracts/tests/emissions_tests.cpp index 9a0977da9d..c82ee499dd 100644 --- a/contracts/tests/emissions_tests.cpp +++ b/contracts/tests/emissions_tests.cpp @@ -5955,9 +5955,8 @@ BOOST_FIXTURE_TEST_CASE( demotion_fires_at_threshold_and_outweighs_collateral, p BOOST_REQUIRE_GT( unpaid_blocks_of(target), 0u ); } FC_LOG_AND_RETHROW() -// `regproducer` is the single door back, from an involuntary demotion as much as from a voluntary -// park. There is no cooldown and no expiry -- a demoted producer is scheduled for no rounds, so it -// could never clear the counter by producing. +// `regproducer` is the door back for a producer the schedule has DROPPED, from an involuntary +// demotion as much as from a voluntary park. There is no cooldown and no expiry. BOOST_FIXTURE_TEST_CASE( regproducer_clears_demotion_immediately, producer_score_tester ) try { auto names = setup_ranked_producers(5); trigger_reschedule(); @@ -5976,6 +5975,41 @@ BOOST_FIXTURE_TEST_CASE( regproducer_clears_demotion_immediately, producer_score BOOST_REQUIRE_EQUAL( tier_bootstrapped, tier_of(rank_score_of(target)) ); } FC_LOG_AND_RETHROW() +// The OTHER door back, and the one no operator has to walk through: a demoted producer that is +// still in the active schedule recovers by producing a block. +// +// Demotion and rescheduling are separate events, and the schedule-size floor can hold the gap +// between them open indefinitely. Four producers here, one demoted, leaves three schedulable -- +// below `min_schedule_size` -- so `update_ranked_producers` retains the last good schedule rather +// than publish a short one, and the demoted producer keeps its slot. That is the shape a mass +// outage takes: without this, those producers would produce indefinitely while `payepoch` skipped +// them, earning nothing until every operator pushed `regproducer` by hand. +BOOST_FIXTURE_TEST_CASE( producing_while_still_scheduled_clears_a_demotion, producer_score_tester ) try { + auto names = setup_ranked_producers(4); + trigger_reschedule(); + wait_for_active_schedule(names[2]); + + const auto target = names[2]; + for (uint32_t miss = 0; miss < 3; ++miss) skip_round_of(target); + BOOST_REQUIRE( demoted(target) ); + BOOST_REQUIRE_EQUAL( tier_demoted, tier_of(rank_score_of(target)) ); + + // The floor kept it in the schedule: three schedulable producers cannot replace four. + trigger_reschedule(); + const auto schedule = active_schedule_names(); + BOOST_REQUIRE_MESSAGE( + std::find(schedule.begin(), schedule.end(), target) != schedule.end(), + "the demoted producer should still hold its slot under the schedule-size floor" ); + + // Its window comes round and it produces. One block is proof of life, so the demotion and the + // streak both clear and the key returns to the tier its standing earns. + produce_blocks(names.size() * slots_per_producer + slots_per_producer); + + BOOST_REQUIRE( !demoted(target) ); + BOOST_REQUIRE_EQUAL( 0u, missed_rounds_of(target) ); + BOOST_REQUIRE_EQUAL( tier_bootstrapped, tier_of(rank_score_of(target)) ); +} FC_LOG_AND_RETHROW() + // A voluntary park costs the producer its schedule slot and its rank position, but nothing else: // its opreg status and bond are untouched, so it returns at the position its collateral earns. BOOST_FIXTURE_TEST_CASE( unregprod_parks_without_touching_the_bond, producer_score_tester ) try { From 07f743c3eb42bc82ed0f572301e38e0ee4a4c9e5 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Fri, 4 Sep 2026 09:09:32 -0500 Subject: [PATCH 06/16] test(producer_rank): cover demotion and recovery on a live cluster (WIRE-367) Three phases on the existing 5-node fixture, exercising what no single-process contract test can reach: miss attribution against real block production, and recovery against real finality. - Stop one keyed producer's node and assert it is demoted at exactly `max_consecutive_missed_rounds`, that no producer which kept producing is charged a miss, and that LIB keeps advancing with one of four finalizers absent -- which is what lets the chain run long enough to demote it. - Assert the `min_schedule_size` floor RETAINS the demoted producer rather than publishing a short schedule: one demotion leaves three schedulable against a floor of four, so `update_ranked_producers` keeps the last good schedule and the demoted producer holds its slot. - Relaunch the node and assert producing a block clears both the demotion and the miss streak, with no `regproducer` and no operator intervention. The second and third phases are the same window: on a real outage the gap between "demoted" and "rescheduled" can stay open indefinitely, and recovery by producing is what closes it. Observed on the run: demotion 80s after the node stopped, the schedule retained all four producers, recovery 19s after relaunch. 390s total, well inside the default ctest timeout. Change-Id: Ie1a0dfd0f29539202061687e4281732eed50d0d4 --- tests/producer_rank_test.py | 122 ++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/tests/producer_rank_test.py b/tests/producer_rank_test.py index 7e06b7cb12..f75249ee19 100755 --- a/tests/producer_rank_test.py +++ b/tests/producer_rank_test.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 import json +import signal +import time from TestHarness import Cluster, TestHelper, Utils, WalletMgr @@ -18,12 +20,19 @@ # 5. Wait for update_ranked_producers to fire via onblock # 6. Verify producer schedule changed (5 → 4 producers, version increased) # 7. Verify finalizer policy set (4 finalizers from the keyed producers) +# 8. Take one scheduled producer's node down and verify it is DEMOTED for missed rounds +# 9. Verify the schedule-size floor retains it rather than publishing a short schedule +# 10. Restart the node and verify producing a block clears the demotion # # Rank is POSITION in the score-ordered producer index, derived by iteration -- there is no # action that assigns it. A producer holds a position only if it is schedulable, which requires # an ACTIVE PRODUCER operator row in sysio.opreg AND an active finalizer key. Withholding the # finalizer key from one producer is therefore what makes the schedule drop from 5 to 4. # +# The demotion phases exercise what no single-process contract test can: miss attribution against +# real block production, and recovery against real finality. They are only reachable on a live +# cluster because a producer has to actually stop producing, and then actually start again. +# ############################################################### Print = Utils.Print @@ -236,6 +245,119 @@ for f in policyToCheck["finalizers"]: assert f["weight"] == 1, f"Finalizer weight should be 1, got {f['weight']}" + # ---------------------------------------------------------------- + # Phase 6: A scheduled producer that stops producing is demoted + # ---------------------------------------------------------------- + # Demotion is what makes the score model self-defending: a producer that holds a slot but is + # absent has to lose its standing without anyone intervening. `onblock` charges a missed round + # to every name the round-robin passed over, and once `max_consecutive_missed_rounds` (3 by + # default) land consecutively the producer moves into a tier no amount of collateral can climb + # out of. + Print("=== Phase 6: Demote a producer by taking its node down ===") + + def producerRow(name): + """The producer's `sysio.system::producers` row, or None if it has none. + + v6 promotes the table to KV, so each row arrives as {"key": ..., "value": ...} and the + fields live under `value`; the fallback keeps this working if that ever flattens. + """ + resp = node0.processUrllibRequest("chain", "get_table_rows", { + "code": "sysio", "scope": "sysio", "table": "producers", "limit": 100, "json": True + }) + assert resp["code"] == 200, f"get_table_rows(producers) returned {resp['code']}: {resp}" + for row in resp["payload"]["rows"]: + fields = row.get("value", row) + if fields.get("owner") == name: + return fields + return None + + def waitForDemotedFlag(name, expected, timeout=300): + """Poll `name`'s producers row until `is_demoted` is `expected`. + + Returns the row, or None if the flag never got there. Polling one producer window at a + time keeps the query count low while a node is down and blocks arrive at a reduced rate. + """ + deadline = time.time() + timeout + while time.time() < deadline: + row = producerRow(name) + if row is not None and row["is_demoted"] == expected: + return row + node0.waitForHeadToAdvance(blocksToAdvance=12, timeout=60) + return None + + # Demote a producer node0 does NOT host, so every query below keeps working while it is down. + demotedProd = next(name for name in keyedProdNames + if producers[name].nodeId != node0.nodeId) + demotedNode = producers[demotedProd] + Print(f"Demotion target: {demotedProd} (node {demotedNode.nodeId})") + + beforeRow = producerRow(demotedProd) + assert beforeRow is not None, f"{demotedProd} should have a producers row" + assert not beforeRow["is_demoted"], f"{demotedProd} should not be demoted before its outage" + + demotedNode.kill(signal.SIGTERM) + Print(f"Stopped {demotedProd}'s node; waiting for its rounds to go unproduced") + + demotedRow = waitForDemotedFlag(demotedProd, True) + assert demotedRow is not None, \ + f"{demotedProd} should have been demoted after missing consecutive rounds" + assert demotedRow["consecutive_missed_rounds"] >= 3, \ + f"Expected at least 3 consecutive missed rounds, got {demotedRow['consecutive_missed_rounds']}" + Print(f"{demotedProd} demoted after {demotedRow['consecutive_missed_rounds']} missed rounds") + + # Every other producer keeps producing, so none of them may be charged a miss: attribution is + # per-slot, not a blanket penalty on the round. + for name in keyedProdNames: + if name == demotedProd: + continue + row = producerRow(name) + assert row is not None and not row["is_demoted"], \ + f"{name} was still producing and must not be demoted" + + # Finality survives the outage. The policy carries keyedCount finalizers with a threshold of + # keyedCount * 2 // 3 + 1, so one absent finalizer still leaves enough to reach it -- which is + # what lets the chain keep advancing long enough to demote the absent producer at all. + assert node0.waitForLibToAdvance(timeout=60), \ + "LIB should keep advancing with one of the finalizers down" + + # ---------------------------------------------------------------- + # Phase 7: The schedule-size floor retains the demoted producer + # ---------------------------------------------------------------- + # Demotion drops the schedulable count to keyedCount - 1, below `min_schedule_size`. + # `update_ranked_producers` refuses to publish a schedule under that floor -- it retains the + # last good one rather than concentrate block production and finality onto too few nodes -- + # so the demoted producer keeps its slot. That gap between "demoted" and "rescheduled" is + # exactly the window Phase 8 recovers from, and on a real outage it can stay open for good. + Print("=== Phase 7: Verify the schedule-size floor retains the demoted producer ===") + assert node0.waitForHeadToAdvance(blocksToAdvance=135, timeout=240), \ + "Head should advance through an update_ranked_producers cycle" + + heldSchedule = node0.processUrllibRequest("chain", "get_producer_schedule") + heldProducers = sorted([p["producer_name"] for p in heldSchedule["payload"]["active"]["producers"]]) + Print(f"Schedule after demotion: {heldProducers}") + assert demotedProd in heldProducers, \ + f"The floor should have retained {demotedProd}; schedule is {heldProducers}" + assert len(heldProducers) == keyedCount, \ + f"Expected the schedule retained at {keyedCount} producers, got {heldProducers}" + + # ---------------------------------------------------------------- + # Phase 8: Producing again clears the demotion + # ---------------------------------------------------------------- + # A block is the strongest liveness proof there is, so a demoted producer that is STILL in the + # active schedule recovers by producing one -- no `regproducer`, no operator intervention. + # Without it the producers a mass outage demoted would keep producing under the retained + # schedule while `payepoch` skipped them, earning nothing until every operator re-registered by + # hand. A producer the schedule has actually dropped never reaches this path. + Print("=== Phase 8: Restart the node and verify the demotion clears ===") + assert demotedNode.relaunch(), f"Failed to relaunch {demotedProd}'s node" + + recoveredRow = waitForDemotedFlag(demotedProd, False) + assert recoveredRow is not None, \ + f"{demotedProd} should have cleared its demotion by producing a block" + assert recoveredRow["consecutive_missed_rounds"] == 0, \ + f"Expected the miss streak to reset, got {recoveredRow['consecutive_missed_rounds']}" + Print(f"{demotedProd} recovered by producing -- demotion and miss streak both cleared") + # ---------------------------------------------------------------- # Final verification: LIB still advancing # ---------------------------------------------------------------- From f88cb2b69c93981c2988fd39a48900a55696aa54 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Fri, 4 Sep 2026 12:50:14 -0500 Subject: [PATCH 07/16] docs: add the operator guide to becoming a block producer (WIRE-367) The collateral-backed path onto the schedule is entirely self-service, and nothing described it end to end. This walks an operator through it: link the outpost addresses, register as a producer operator, bond on every required chain through the outpost contracts, register the block-signing and finalizer keys, run the node -- and then nothing, because ranking schedules them on its own. It also covers what an operator has to reason about afterwards: how the score is weighted and why collateral is the minimum across chains rather than a sum, that pay is per block with a standby retainer and no forfeiture, that a missed round is a whole unproduced window, that demotion clears either by producing while still scheduled or by re-registering, and that a single miss short of demotion can still cost a slot because the streak only clears by producing. Change-Id: Ie48f0f19ed2e771bd27f1a8d5c50146c763f2aa6 --- docs/becoming-a-block-producer.md | 189 ++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 docs/becoming-a-block-producer.md diff --git a/docs/becoming-a-block-producer.md b/docs/becoming-a-block-producer.md new file mode 100644 index 0000000000..c7b9af1a88 --- /dev/null +++ b/docs/becoming-a-block-producer.md @@ -0,0 +1,189 @@ +# Becoming a block producer on WIRE + +This guide is for an operator who wants to produce blocks on WIRE. Every step below is +**self-service**: you sign each action with your own account, and nothing on this path requires a +vote, a governance action, or anyone's approval. Once you have bonded collateral and registered +your keys, the chain schedules you automatically by rank. + +There are two ways onto the schedule, and this guide covers the second: + +- **Genesis producers** are placed in the schedule when a chain is bootstrapped. They are + registered as *bootstrapped* operators, which only the registry contract itself can do. +- **Collateral-backed producers** post a bond on the outpost chains and earn a schedule position + by rank. That is the open path, and it is the one described here. + +## What you need before you start + +| | | +|---|---| +| A WIRE account | The account that will produce blocks. Its `active` permission signs everything below. | +| An Ethereum wallet | Funded with the required bond plus gas. | +| A Solana keypair | Funded with the required bond plus fees. | +| A machine running `nodeop` | Reachable by the peer network, with your signing keys available to it. | + +You must bond on **every** chain the network requires, not just one. The requirement lives in +`sysio.opreg`'s configuration as a per-chain minimum bond, and eligibility takes the **minimum** +across all of them. Posting extra on the cheapest chain buys you nothing. + +## Step 1 — Link your outpost addresses + +``` +sysio.authex::createlink(chain_kind, account, sig, pub_key, nonce) +``` + +Sign this once per chain, with your Ethereum and Solana keys respectively. The link is what makes +a deposit you send on an outpost attributable to your WIRE account; without it the chain has no way +to know the bond is yours. + +The `nonce` is a millisecond timestamp and is rejected if it is more than ten minutes old, so +generate it at signing time. + +## Step 2 — Register as a producer operator + +``` +sysio.opreg::regoperator(account, OPERATOR_TYPE_PRODUCER, is_bootstrapped = false) +``` + +Signed by your own account. `is_bootstrapped` must be `false`; setting it `true` requires the +registry contract's own authority and is reserved for genesis producers. + +You are now registered but not yet eligible. Your status stays `UNKNOWN` until the bond arrives. + +## Step 3 — Post your collateral on each outpost + +Deposit on the outpost chains themselves, signed by the wallets you linked in step 1: + +- **Ethereum** — `OperatorRegistry.deposit(...)` +- **Solana** — the outpost program's `deposit` instruction + +Each deposit travels to WIRE over the cross-chain protocol and credits your balance in +`sysio.opreg`. When every required chain is at or above its minimum, your operator status flips to +`ACTIVE` on its own and your rank score is computed from the bond you posted. + +You can top up at any time. Every balance change rescores you, so additional collateral raises your +rank as soon as it lands. + +## Step 4 — Register your block-signing key + +``` +sysio.system::regproducer(producer, producer_key, url, location) +``` + +Use `regproducer2` instead if you want a multi-key block-signing authority rather than a single +key. The `url` is where you publish information about your operation, and `location` is an +advisory number used for peer topology. + +## Step 5 — Register a finalizer key + +``` +sysio.system::regfinkey(finalizer_name, finalizer_key, proof_of_possession) +``` + +This is a BLS key with its proof of possession, generated with `sys-util`: + +```bash +sys-util bls create key --to-console +``` + +Two rules matter here. The key must be **globally unique**, so you cannot reuse another producer's +key or share one across accounts you control. And the first key you register is activated +automatically; if you later register additional keys, `actfinkey` chooses which one is active and +`delfinkey` removes one. + +A producer without an active finalizer key can never be scheduled, because it could not take part +in finality. + +## Step 6 — Run your node + +Start `nodeop` with your producer name and both keys available to it, the block-signing key and the +finalizer key. The node must be peered into the network and caught up before its first slot +arrives, or it will simply miss the round. + +## What happens next + +Nothing. That is the point. + +Rank is **position in a score-ordered index**, derived by iteration rather than assigned by any +action. The chain rebuilds the schedule at most once every 120 blocks; at that point the highest +ranked eligible producers become the active schedule, and the finalizer policy is rebuilt to match. +If your score puts you in the top 21 you are scheduled, and you begin producing in your slot. + +To hold a position at all you need three things at once: an active producer row, an `ACTIVE` +producer operator registration backed by collateral, and an active finalizer key. Missing any one +of them means no position, no pay, and no schedule slot. + +## How your rank is scored + +The score is a weighted sum of normalised factors, ordered within a tier. Tier always beats score, +so no amount of collateral lifts a demoted producer above a healthy one. + +| Factor | What it measures | +|---|---| +| Collateral | Your bond divided by the required minimum, taken as the **minimum** across every required chain. Linear and uncapped, so more collateral always outranks less. | +| Participation | Falls with each consecutive missed round and recovers when you produce. | +| Snapshot service | Snapshot attestations that reached quorum in the current pay period. Weighted at a tenth of collateral, so it separates producers the bond has left tied rather than outranking a larger bond. | + +Three further factors, relay, API and benchmark service, exist in the configuration at zero weight. +They stay at zero until the chain can observe them; a self-declared factor would only be a source +of free points. + +Snapshot service is optional. If you want it, register a snapshot provider account with +`regsnapprov` and vote snapshot hashes with `votesnaphash`. Only votes that reach quorum are +credited, so registering alone earns nothing. + +## Getting paid + +Producers are paid **per block produced**. Each block earns the same rate, computed as the active +share of the producer pool divided by the period's slot count. A block you miss is simply not paid, +and that pay stays in the treasury rather than being handed to whoever did produce. + +Positions 22 and beyond, up to a configured end rank, are **standbys**. They draw a retainer from a +separate slice of the pool, decaying linearly with position, so the network keeps a ready bench. + +Nothing is ever forfeited. If you are not payable when a payout runs, whether parked, demoted, or +temporarily under-collateralized, your block count is held rather than cleared, and it is paid at +the first payout after you are payable again. Unregistering right after producing and +re-registering before your next round costs you nothing. + +Claim what you have earned with `claimpay`. + +## Staying in the schedule + +A **round** is your entire slot window. You are charged a missed round only when the whole window +goes unproduced, so a brief hiccup that costs you a block or two is not a miss. + +Miss three rounds in a row and you are **demoted**. Demotion is categorical: it moves you into a +tier that no amount of collateral climbs out of, and the next rebuild drops you from the schedule. + +There are two ways back: + +1. **Produce a block while you are still scheduled.** Demotion and rescheduling are separate + events, and the schedule floor can hold that gap open. A block is proof you are alive, so it + clears the demotion outright, with no action on your part beyond being back online. +2. **Call `regproducer` again.** This is the way back once the schedule has actually dropped you. + It re-supplies your signing key, which makes it a real statement that you are ready rather than + a formality. There is no cooldown and no waiting period. + +One subtlety worth planning around: even a single missed round short of demotion lowers your +participation factor, and if that drops you below the last scheduled position you stop being +scheduled. The miss counter only clears by producing, so a producer displaced this way holds the +penalty until it acts. Calling `regproducer` clears it, and so does posting enough additional +collateral to outrank the producer that displaced you. + +## Leaving, voluntarily or otherwise + +- **Park** with `unregprod`. Your bond is untouched and your operator status stays `ACTIVE`; you + simply hold no schedule position. `regproducer` brings you back at the position your collateral + earns. +- **Withdraw** with `sysio.opreg::withdraw`, which queues the request; `cancelwtdw` cancels it + before it flushes. Once your balance falls below the minimum on any required chain you leave + `ACTIVE` and the schedule drops you at the next rebuild. +- **Slashing** is punitive and permanent. A slashed operator's row is never pruned and the registry + refuses to re-register it, so a slashed account cannot come back. + +## A note on schedule size + +The chain will not publish a schedule smaller than its safety floor. If demotions or withdrawals +would leave too few eligible producers, it keeps the last good schedule rather than concentrate +block production and finality onto too few nodes. During such a window a demoted producer may keep +its slot, which is exactly the case the "produce a block to recover" rule above exists for. From 4cd31dd671d79435ba0b8f4a00bba003839e9eed Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Fri, 4 Sep 2026 16:23:31 -0500 Subject: [PATCH 08/16] fix(sysio.system): pay rounding to zero must not consume blocks; peer discovery follows the schedule (WIRE-367) Two PR review findings, plus the contract artifacts the master merge should have carried. - payepoch cleared a producer's block count whenever it had blocks, but credited pay only when the amount was positive. Integer division means a real block count over a small pool floors to zero, so the count was consumed while nothing was paid -- destroying work that was actually done, which is the one thing this model promises never to do. The reset is now driven by whether the BLOCK portion credited, computed apart from the standby retainer, so a standby whose retainer paid but whose block pay floored to zero keeps its blocks too. An uncredited count carries exactly as an unpayable row's does. - getpeerkeys ranked candidates and stopped at the demoted tier, so it could omit a producer that is currently producing blocks. `update_peer_keys` returns early only on an EMPTY response, so a non-empty one ERASES every producer it omits: omitting a live producer evicts it from the BP peer map and cuts it out of the gossip mesh. A demoted producer retained by the min_schedule_size floor is exactly that case -- it holds its slot and its next block is what clears the demotion, yet it sorts into the tier the walk stops at. Peer discovery now seeds from the active schedule first, deduplicates, then fills the remainder by rank. Seeding is keyed by NAME, so a producer scheduled through setprods with no producers row at all is discoverable too. - The merge commit captured the pre-merge wasm/abi: they were staged during conflict resolution and the rebuild that followed was never re-staged. The binaries here are built from the merged source, which is what CI runs. Both tests were confirmed to FAIL without their fix: the pay test reports "uncredited blocks were consumed: had 15, now 1", and the peer test reports a scheduled producer omitted and would-be evicted. Change-Id: I2ee413a8dca20d453b74ff382837ca915d45065e --- contracts/sysio.epoch/sysio.epoch.wasm | Bin 78946 -> 79801 bytes contracts/sysio.opreg/sysio.opreg.wasm | Bin 91957 -> 91958 bytes contracts/sysio.system/src/emissions.cpp | 30 +++++++--- contracts/sysio.system/src/peer_keys.cpp | 40 ++++++++++++-- contracts/sysio.system/sysio.system.abi | 63 +++++++++++++++++++++ contracts/sysio.system/sysio.system.wasm | Bin 191299 -> 200305 bytes contracts/tests/emissions_tests.cpp | 40 ++++++++++++++ contracts/tests/getpeerkeys_tests.cpp | 67 +++++++++++++++++++++++ 8 files changed, 228 insertions(+), 12 deletions(-) diff --git a/contracts/sysio.epoch/sysio.epoch.wasm b/contracts/sysio.epoch/sysio.epoch.wasm index cf4bf2df6c6e1985cb1b148ca817acaa6f382f0c..4757eeca426611eeef58c7d35227c2436859d2ca 100755 GIT binary patch delta 7132 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 diff --git a/contracts/sysio.opreg/sysio.opreg.wasm b/contracts/sysio.opreg/sysio.opreg.wasm index da041d3ddae57fddf273426aa04db026443965cb..949bd74fd8b2c2c98bf1ebb5556b04145f813336 100755 GIT binary patch delta 47 zcmV+~0MP%n&IPv41+XFp0sXTh1{qrco3lM(CIbeEuBDRI2(xHpg#85xS4mPuxA9>C FLTmS$7 diff --git a/contracts/sysio.system/src/emissions.cpp b/contracts/sysio.system/src/emissions.cpp index b025cd592d..bd860281b0 100644 --- a/contracts/sysio.system/src/emissions.cpp +++ b/contracts/sysio.system/src/emissions.cpp @@ -966,7 +966,7 @@ void system_contract::payepoch(uint32_t epoch_index, }; struct reset_entry { name owner; - bool blocks; // a paid row starts its count over; an unpayable row keeps it + bool snapshot; // this payout consumed the row's attestation credit }; std::vector entries; std::vector to_reset; // snapshot before modify: avoids @@ -992,13 +992,13 @@ void system_contract::payepoch(uint32_t epoch_index, // pay nor a standby retainer -- and keeps its block count for when it can. The snapshot // counter is per period regardless. if (!producer_rank::is_schedulable(*it, _finalizers)) { - if (it->snapshot_attestations > 0) to_reset.push_back({it->owner, false}); + if (it->snapshot_attestations > 0) to_reset.push_back({it->owner, true}); continue; } produced_blocks += it->unpaid_blocks; if (it->unpaid_blocks > 0 || it->snapshot_attestations > 0) { - to_reset.push_back({it->owner, true}); + to_reset.push_back({it->owner, it->snapshot_attestations > 0}); } ++position; @@ -1013,10 +1013,20 @@ void system_contract::payepoch(uint32_t epoch_index, // Producers are paid the emission share only — swap fees go to the // underwriter + batch operators (see the fold-in comment above). + // + // A row's blocks are cleared ONLY when the block portion actually credited something. The + // division is integer, so a small pool over a large divisor can round a real block count to + // zero pay; clearing the count then would destroy work the producer did, which is the one + // thing this model promises never to do. An uncredited count carries to the next payout + // exactly as an unpayable row's does, and the rate it eventually settles at is the settling + // period's -- so the blocks are worth something the moment the pool can represent them. int64_t distributed_to_producers = 0; + std::vector block_paid; + block_paid.reserve(entries.size()); for (const auto& entry : entries) { - int64_t pay = static_cast( + const int64_t block_pay = static_cast( static_cast<__int128>(active_pool) * entry.blocks / slot_divisor); + int64_t pay = block_pay; if (entry.standby_weight > 0) { pay += static_cast( static_cast<__int128>(standby_pool) * entry.standby_weight / standby_weight_sum); @@ -1025,19 +1035,25 @@ void system_contract::payepoch(uint32_t epoch_index, credit_pay(get_self(), entry.owner, pay, memo::producer_reward); distributed_to_producers += pay; } + // The BLOCK portion specifically -- a standby whose retainer paid but whose block pay + // rounded to zero keeps its blocks too. + if (block_pay > 0) block_paid.push_back(entry.owner); } actual_paid += distributed_to_producers; // Reset the period's counters after distribution (iteration-safe: uses PK snapshot). for (const auto& entry : to_reset) { + const bool clear_blocks = + std::find(block_paid.begin(), block_paid.end(), entry.owner) != block_paid.end(); + if (!clear_blocks && !entry.snapshot) continue; auto key = producer_key_t{entry.owner.value}; _producers.modify(same_payer, key, [&](auto& p) { - if (entry.blocks) p.unpaid_blocks = 0; - p.snapshot_attestations = 0; + if (clear_blocks) p.unpaid_blocks = 0; + if (entry.snapshot) p.snapshot_attestations = 0; }); // Zeroing snapshot_attestations moved the snapshot factor; keep the sort key in step. - rescore_producer(entry.owner); + if (entry.snapshot) rescore_producer(entry.owner); } } diff --git a/contracts/sysio.system/src/peer_keys.cpp b/contracts/sysio.system/src/peer_keys.cpp index 1b6f4c3d66..9b748085e2 100644 --- a/contracts/sysio.system/src/peer_keys.cpp +++ b/contracts/sysio.system/src/peer_keys.cpp @@ -3,8 +3,10 @@ #include #include +#include #include #include +#include namespace sysiosystem { @@ -50,14 +52,40 @@ peer_keys::getpeerkeys_res_t peer_keys::getpeerkeys() { getpeerkeys_res_t resp; resp.reserve(max_return); - auto add_peer = [&](const producer_info& p) { - auto pk = peerkey_key{p.owner.value}; + // Names already in the response, so a producer seeded from the schedule is not repeated by the + // rank walk. Bounded by max_return, so the linear scan is trivial. + std::vector added; + added.reserve(max_return); + + auto already_added = [&](const name& owner) { + return std::find(added.begin(), added.end(), owner) != added.end(); + }; + + // Keyed by NAME, not by a producers row: a producer scheduled through `setprods` during the + // bootstrap window may have no `producers` row at all, and its peer key still has to be + // discoverable. An absent peerkeys row yields an empty key rather than an omission -- the + // consumer needs to know the producer EXISTS. + auto add_peer = [&](const name& owner) { + auto pk = peerkey_key{owner.value}; if (!pkt.contains(pk)) - resp.push_back(peerkeys_t{p.owner, {}}); + resp.push_back(peerkeys_t{owner, {}}); else - resp.push_back(peerkeys_t{p.owner, pkt.get(pk).get_public_key()}); + resp.push_back(peerkeys_t{owner, pkt.get(pk).get_public_key()}); + added.push_back(owner); }; + // SEED with the live schedule before ranking anything. `peer_keys_db_t::update_peer_keys` + // returns early only on an EMPTY response, so a non-empty one ERASES every producer it omits -- + // omitting a producer that is currently producing blocks evicts it from the BP peer map and + // cuts it out of the gossip mesh. Rank alone does not identify those producers: a demoted one + // retained by the `min_schedule_size` floor still holds its slot and still produces (its next + // block is what clears the demotion), yet it sorts into the tier this walk stops at. The + // schedule is the authority on who is producing; rank only orders the candidates behind them. + for (const auto& scheduled : sysio::get_active_producers()) { + if (resp.size() >= max_return) break; + if (!already_added(scheduled)) add_peer(scheduled); + } + auto idx = producers.get_index<"prodrank"_n>(); // `rank` is POSITION among ELIGIBLE producers, so this counts matches rather than taking the @@ -78,7 +106,9 @@ peer_keys::getpeerkeys_res_t peer_keys::getpeerkeys() { continue; if (++position > max_rank) break; - add_peer(*i); + if (already_added(i->owner)) + continue; + add_peer(i->owner); } return resp; diff --git a/contracts/sysio.system/sysio.system.abi b/contracts/sysio.system/sysio.system.abi index fd50653772..131944e4b7 100644 --- a/contracts/sysio.system/sysio.system.abi +++ b/contracts/sysio.system/sysio.system.abi @@ -118,6 +118,30 @@ } ] }, + { + "name": "batch_epoch", + "base": "", + "fields": [ + { + "name": "sysio_epoch_index", + "type": "uint32" + }, + { + "name": "members", + "type": "name[]" + } + ] + }, + { + "name": "batch_epoch_key", + "base": "", + "fields": [ + { + "name": "sysio_epoch_index", + "type": "uint32" + } + ] + }, { "name": "block_header", "base": "", @@ -509,6 +533,18 @@ { "name": "fee_distributed", "type": "int64" + }, + { + "name": "batch_history_complete", + "type": "bool" + }, + { + "name": "batch_emission_retained", + "type": "int64" + }, + { + "name": "batch_fee_retained", + "type": "int64" } ] }, @@ -1082,6 +1118,20 @@ } ] }, + { + "name": "rcrdbatch", + "base": "", + "fields": [ + { + "name": "epoch_index", + "type": "uint32" + }, + { + "name": "members", + "type": "name[]" + } + ] + }, { "name": "regfinkey", "base": "", @@ -1936,6 +1986,11 @@ "type": "payepoch", "ricardian_contract": "" }, + { + "name": "rcrdbatch", + "type": "rcrdbatch", + "ricardian_contract": "" + }, { "name": "regfinkey", "type": "regfinkey", @@ -2111,6 +2166,14 @@ "key_types": ["uint64"], "table_id": 49446 }, + { + "name": "batchepochs", + "type": "batch_epoch", + "index_type": "i64", + "key_names": ["sysio_epoch_index"], + "key_types": ["uint32"], + "table_id": 22503 + }, { "name": "blockinfo", "type": "block_info_record", diff --git a/contracts/sysio.system/sysio.system.wasm b/contracts/sysio.system/sysio.system.wasm index 4521e422d7ab23b2f61376ed0d0ae431fa3ab81d..4aad91b0f50a9f372dacad99415b440d62b354a7 100755 GIT binary patch delta 30796 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 A#{d8T delta 22622 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"period_emission", tiny_emission)) ); + + // Nothing was credited ... + BOOST_REQUIRE_MESSAGE( get_wire_balance_paid(target) == before, + "the pool was large enough to pay after all -- this test no longer exercises the rounding case" ); + // ... so nothing may be consumed. The count can only have GROWN, by the blocks the payout's own + // transaction produced; a reset would drop it far below what it was. + BOOST_REQUIRE_MESSAGE( unpaid_blocks_of(target) >= blocks, + "uncredited blocks were consumed: had " << blocks << ", now " << unpaid_blocks_of(target) ); +} FC_LOG_AND_RETHROW() + BOOST_FIXTURE_TEST_CASE( active_producers_are_paid_per_block, sysio_emissions_tester ) try { create_t5_holding_accounts(); setup_producers(3); diff --git a/contracts/tests/getpeerkeys_tests.cpp b/contracts/tests/getpeerkeys_tests.cpp index d344d5433f..c7566f5c50 100644 --- a/contracts/tests/getpeerkeys_tests.cpp +++ b/contracts/tests/getpeerkeys_tests.cpp @@ -11,6 +11,7 @@ #include #include +#include #include #include @@ -36,6 +37,23 @@ class getpeerkeys_tester : public sysio_system_tester { // net_plugin auto-bp-peering path consumes. Exercising the decoded return here guards // against a dropped action return value (a CDT codegen hazard that otherwise surfaces only // in the auto_bp_gossip_peering integration test). + /// Terminate an operator so it stops being an ELIGIBLE operator, without touching the + /// schedule the chain is currently producing under. Pushed as sysio.opreg, which is what + /// `opreg::terminate` requires -- the same actor `register_producer_operators` uses. + void terminate_operator( const name& account ) { + base_tester::push_action("sysio.opreg"_n, "terminate"_n, "sysio.opreg"_n, mvo() + ("account", account) + ("reason", std::string("peer-discovery test"))); + produce_block(); + } + + /// The producers the chain is currently scheduled to produce blocks from. + std::vector active_schedule_names() { + std::vector names; + for (const auto& p : control->active_producers().producers) names.push_back(p.producer_name); + return names; + } + std::vector get_peer_keys() { auto trace = TESTER::push_action( config::system_account_name, "getpeerkeys"_n, config::system_account_name, mvo() ); @@ -83,4 +101,53 @@ BOOST_FIXTURE_TEST_CASE( getpeerkeys_test, getpeerkeys_tester ) { try { BOOST_REQUIRE_EQUAL( with_key, registered.size() ); } FC_LOG_AND_RETHROW() } +// Peer discovery answers "who is producing blocks", and the schedule -- not rank -- is the +// authority on that. `peer_keys_db_t::update_peer_keys` returns early only on an EMPTY response, +// so a non-empty one ERASES every producer it omits: omitting a live producer evicts it from the +// BP peer map and cuts it out of the gossip mesh. +// +// Rank alone cannot identify those producers. A demoted producer retained by the +// `min_schedule_size` floor still holds its slot and still produces -- its next block is what +// clears the demotion -- yet it sorts into the tier the rank walk stops at. This test builds the +// same situation the cheap way: terminating an operator makes the rank walk skip it immediately, +// while the schedule it is producing under is unchanged. +BOOST_FIXTURE_TEST_CASE( getpeerkeys_returns_every_scheduled_producer, getpeerkeys_tester ) { try { + std::vector prod_names = activate_producers_with_operators(); + + const auto scheduled = active_schedule_names(); + BOOST_REQUIRE( !scheduled.empty() ); + const auto dropped = scheduled.front(); + + // Baseline: the rank walk alone already covers it. + { + auto peerkeys = get_peer_keys(); + BOOST_REQUIRE( std::any_of(peerkeys.begin(), peerkeys.end(), + [&](const gpk_peerkeys_t& pk) { return pk.producer_name == dropped; }) ); + } + + terminate_operator( dropped ); + + // Still scheduled -- the chain is producing its blocks from this very set. + const auto after = active_schedule_names(); + BOOST_REQUIRE_MESSAGE( + std::find(after.begin(), after.end(), dropped) != after.end(), + dropped.to_string() << " should still be in the active schedule" ); + + // ... so peer discovery must still return it, even though it is no longer an eligible operator + // and the rank walk skips it. + auto peerkeys = get_peer_keys(); + BOOST_REQUIRE_MESSAGE( + std::any_of(peerkeys.begin(), peerkeys.end(), + [&](const gpk_peerkeys_t& pk) { return pk.producer_name == dropped; }), + "a scheduled producer was omitted from peer discovery and would be evicted from the peer map" ); + + // Every scheduled producer, not just the one under test, and each exactly once. + for (const auto& p : after) { + const auto hits = std::count_if(peerkeys.begin(), peerkeys.end(), + [&](const gpk_peerkeys_t& pk) { return pk.producer_name == p; }); + BOOST_REQUIRE_MESSAGE( hits == 1, + p.to_string() << " appears " << hits << " times in peer discovery; expected exactly 1" ); + } +} FC_LOG_AND_RETHROW() } + BOOST_AUTO_TEST_SUITE_END() From 3b019874ca1aafa67ff728a4f5cdd19b20729638 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Fri, 4 Sep 2026 19:50:15 -0500 Subject: [PATCH 09/16] feat(sysio.system): demote on a miss RATE as well as a run, matching the batch-operator gates (WIRE-367) Producers now answer to the same shape of availability test as batch operators in `sysio.opreg::termcheck`: a consecutive run says "you are offline right now", a miss rate over a rolling window says "you are chronically unreliable", and either demotes. The consequence stays DEMOTION rather than termination, and the settings are mirrored into `prodscorecfg` under producer names rather than borrowing the termination-named fields. - `producer_info` gains the window it is measured over: rounds observed, rounds missed, and when the window opened. Only rounds the producer was actually SCHEDULED for are counted, and a round observed after the window has lapsed opens a fresh one -- so time off the schedule accrues nothing and stale counts cannot greet a producer on its return. That is the resurrection CertiK raised as WNS-47, designed out at the source rather than patched. - `record_missed_round` becomes `record_round_outcome`, called for BOTH outcomes so the window rolls on produced rounds too. - The rate gate needs a minimum sample before it may fire, DERIVED rather than configured: the count at which the two gates agree. Below it the consecutive gate is strictly stricter, so the rate gate would add nothing except the power to demote on a first missed round -- at a sample of one, a single miss is a 100% rate. - `regproducer` clears the demotion and opens a fresh window but NOT the miss streak. It costs only a signature and may be repeated, so clearing the streak would let an offline operator cron its way back to healthy and never produce. The window does reset, because otherwise a rate-demoted producer can never recover: demoted it is unscheduled, so it observes no rounds and its rate is frozen. The consecutive gate is what defeats the cron loop and it is untouched. - Producing a block clears the streak and RE-DERIVES the demotion rather than forcing it false. One good round must not pardon the rate gate, or a producer missing half its rounds would clear its demotion every time it managed one. - A weight or threshold change reconciles stored demotions against the live thresholds, but only during the sweep the change itself opens, so an ordinary rescore cannot undo a re-registration. A lowered threshold binds on existing streaks; a raised one pardons nobody. Also removes the re-entry heuristic added earlier in this PR. It cleared the period's snapshot credit when a row's tier moved out of demoted, which cannot tell a stale credit from one granted in the same block -- so the ordinary demote/recover cycle destroyed live credits. The credit is now consumed at the events that actually leave the pay walk: demotion and `unregprod`. Field ORDER note: both structs declare new fields last, matching the tail of their SYSLIB_SERIALIZE. The ABI is generated from the declarations while the contract serializes in the macro's order; a disagreement decodes every later field from its neighbour's bytes, silently and plausibly. Tests: the rate gate demoting without a consecutive run and declining to fire below its minimum sample; `regproducer` leaving the streak standing; the park consuming the period's snapshot credit. contracts_unit_test 568s and contracts_snapshot_attest_test 846s both pass. Change-Id: I25a3bf98e989c4620e185e1c491e16cc5ae653c7 --- .../include/sysio.system/producer_rank.hpp | 65 ++++++++++++++- .../include/sysio.system/producer_score.hpp | 44 ++++------ .../include/sysio.system/sysio.system.hpp | 39 +++++++-- contracts/sysio.system/src/producer_pay.cpp | 77 +++++++++++++----- contracts/sysio.system/src/ranking.cpp | 46 ++++++++++- .../sysio.system/src/snapshot_attest.cpp | 6 ++ contracts/sysio.system/sysio.system.abi | 20 +++++ contracts/sysio.system/sysio.system.wasm | Bin 200305 -> 202018 bytes contracts/tests/emissions_tests.cpp | 59 +++++++++++++- .../tests/sysio.snapshot_attest_tests.cpp | 18 ++-- 10 files changed, 308 insertions(+), 66 deletions(-) diff --git a/contracts/sysio.system/include/sysio.system/producer_rank.hpp b/contracts/sysio.system/include/sysio.system/producer_rank.hpp index cb3d633860..69ecbcdd61 100644 --- a/contracts/sysio.system/include/sysio.system/producer_rank.hpp +++ b/contracts/sysio.system/include/sysio.system/producer_rank.hpp @@ -204,17 +204,80 @@ namespace sysiosystem { /// factor. The counter is reset on the same cadence as the block counters, so this is the /// window's target rather than an all-time total. uint32_t snapshot_target_attestations = 1; + /// Rolling window the miss RATE below is measured over, in milliseconds. Mirrors + /// `sysio.opreg`'s `terminate_window_ms`, which is the batch-operator equivalent: a + /// producer and a batch operator should answer to the same shape of availability test + /// even though a producer is DEMOTED where an operator is terminated. + uint64_t missed_round_window_ms = 24ULL * 60 * 60 * 1000; + + /// Percent of its scheduled rounds a producer may miss inside that window before it is + /// demoted. Mirrors `terminate_max_pct_misses_24h`. Zero disables the rate gate, leaving + /// only the consecutive one. + uint32_t max_pct_missed_rounds_in_window = 5; + SYSLIB_SERIALIZE(producer_score_config, (collateral_weight)(participation_weight)(snapshot_weight) (relay_weight)(api_weight)(benchmark_weight) - (max_consecutive_missed_rounds)(snapshot_target_attestations)) + (max_consecutive_missed_rounds)(snapshot_target_attestations) + (missed_round_window_ms)(max_pct_missed_rounds_in_window)) }; /// The `prodscorecfg` singleton. Mirrors `emitcfg_t`: absent until governance installs it, so /// every read goes through `get_or_default(producer_score_config{})`. using producer_score_config_t = sysio::kv::global<"prodscorecfg"_n, producer_score_config>; + /** + * The observed-round sample the rate gate needs before it may fire, DERIVED rather than + * configured: the count at which the two gates agree. Below it the consecutive gate is + * strictly the stricter of the two, so the rate gate would add nothing except the power to + * demote a producer on its very first missed round -- at a sample of one, a single miss is a + * 100% miss rate. + * + * @param weights the live score configuration. + * @return the minimum observed rounds before the rate gate applies. + */ + inline uint32_t rate_gate_minimum_sample(const producer_score_config& weights) { + if (weights.max_pct_missed_rounds_in_window == 0) return 0; + return weights.max_consecutive_missed_rounds * 100u + / weights.max_pct_missed_rounds_in_window; + } + + /** + * Whether a producer's recorded window breaches the miss-RATE gate. + * + * @param rounds scheduled rounds observed in the window. + * @param missed how many of them went unproduced. + * @param weights the live score configuration. + * @return true iff the rate gate is armed and exceeded. + */ + inline bool exceeds_miss_rate(uint32_t rounds, uint32_t missed, + const producer_score_config& weights) { + if (weights.max_pct_missed_rounds_in_window == 0) return false; + if (rounds < rate_gate_minimum_sample(weights)) return false; + if (rounds == 0) return false; + return (missed * 100u / rounds) > weights.max_pct_missed_rounds_in_window; + } + + /** + * Whether a producer's recorded misses warrant demotion, under BOTH gates. + * + * The two mirror `sysio.opreg::termcheck`: a consecutive run says "you are offline right + * now", a rate over a rolling window says "you are chronically unreliable". Either demotes. + * + * @param consecutive_missed_rounds the producer's current streak. + * @param rounds scheduled rounds observed in the window. + * @param missed how many of them went unproduced. + * @param weights the live score configuration. + * @return true iff the producer should be demoted. + */ + inline bool warrants_demotion(uint32_t consecutive_missed_rounds, uint32_t rounds, + uint32_t missed, const producer_score_config& weights) { + const bool exceeds_consecutive = weights.max_consecutive_missed_rounds > 0 + && consecutive_missed_rounds >= weights.max_consecutive_missed_rounds; + return exceeds_consecutive || exceeds_miss_rate(rounds, missed, weights); + } + /** * The active producer schedule as `onblock` last observed it. * diff --git a/contracts/sysio.system/include/sysio.system/producer_score.hpp b/contracts/sysio.system/include/sysio.system/producer_score.hpp index 6c01d59def..474b9f1320 100644 --- a/contracts/sysio.system/include/sysio.system/producer_score.hpp +++ b/contracts/sysio.system/include/sysio.system/producer_score.hpp @@ -264,34 +264,24 @@ namespace sysiosystem { producer_score_config_t weights_tbl(self); const auto weights = weights_tbl.get_or_default(producer_score_config{}); - const auto info = producers.get(key); - auto inputs = score_inputs{ - .is_active = info.active(), - .is_demoted = info.is_demoted, - .consecutive_missed_rounds = info.consecutive_missed_rounds, - .snapshot_attestations = info.snapshot_attestations - }; - uint64_t score = compute(producer, inputs, weights); + const auto info = producers.get(key); + const auto score = compute( + producer, + score_inputs{ + .is_active = info.active(), + .is_demoted = info.is_demoted, + .consecutive_missed_rounds = info.consecutive_missed_rounds, + .snapshot_attestations = info.snapshot_attestations + }, + weights); - // A producer RE-ENTERING the walk starts the period's snapshot credit at zero. `payepoch` - // zeroes the counter only on the rows it visits, and it never visits the demoted tier, so - // a credit earned before a demotion, a park or a de-collateralization would otherwise - // ride back in as a stale factor and outrank a producer that actually attested this - // period. Only the credit restarts: the miss streak stays, so leaving and re-entering - // cannot be used to dodge a demotion, and the block count is pay, never a score input. - const bool re_entering = tier_of(info.rank_score) == producer_tier::demoted - && tier_of(score) != producer_tier::demoted; - const bool reset_credit = re_entering && info.snapshot_attestations > 0; - if (reset_credit) { - inputs.snapshot_attestations = 0; - score = compute(producer, inputs, weights); - } - - if (score == info.rank_score && !reset_credit) return; // no index move needed - producers.modify(same_payer, key, [&](auto& row) { - row.rank_score = score; - if (reset_credit) row.snapshot_attestations = 0; - }); + // The period's snapshot credit is cleared at the EVENTS that drop a producer out of the + // pay walk -- demotion and `unregprod` -- not inferred here from a tier change. Inferring + // it was wrong: a row can be re-entering and freshly credited in the same block, and a + // tier comparison cannot tell that credit apart from a stale one, so it consumed credits + // that had just been earned. + if (score == info.rank_score) return; // no index move needed + producers.modify(same_payer, key, [&](auto& row) { row.rank_score = score; }); } } // namespace producer_rank diff --git a/contracts/sysio.system/include/sysio.system/sysio.system.hpp b/contracts/sysio.system/include/sysio.system/sysio.system.hpp index f061af5a51..6cb7feb9e8 100644 --- a/contracts/sysio.system/include/sysio.system/sysio.system.hpp +++ b/contracts/sysio.system/include/sysio.system/sysio.system.hpp @@ -153,12 +153,26 @@ namespace sysiosystem { /// the moment it produces. At prodscorecfg's max_consecutive_missed_rounds it sets /// `is_demoted`; see producer_rank.hpp. uint32_t consecutive_missed_rounds = 0; - /// Demoted to standby for missing rounds. Categorical -- no score overcomes it. Cleared only - /// by `regproducer`, which is the single door back from both a voluntary `unregprod` park and - /// an involuntary demotion. + /// Demoted to standby for missing rounds. Categorical -- no score overcomes it. Cleared by + /// producing a block while still scheduled, or by `regproducer`. bool is_demoted = false; /// Snapshot attestations credited this pay period; reset alongside the block counters. uint32_t snapshot_attestations = 0; + /// Scheduled rounds OBSERVED inside the current miss window, and how many went unproduced. + /// Only rounds the producer was actually scheduled for are counted, so time spent off the + /// schedule contributes nothing rather than reading as failure. + /// + /// DECLARED LAST, matching the tail of SYSLIB_SERIALIZE below. The ABI is generated from + /// these declarations while the contract serializes in the macro's order, so the two orders + /// must agree -- when they disagree every field past the divergence decodes from the wrong + /// bytes, silently, and a reader sees a plausible value belonging to its neighbour. + uint32_t rounds_in_window = 0; + uint32_t missed_rounds_in_window = 0; + /// When the current miss window opened (on-chain ms). A round observed after the window has + /// lapsed opens a fresh one, so these counters can never survive an absence longer than the + /// window itself -- the resurrection CertiK flagged as WNS-47, designed out rather than + /// patched. + uint64_t miss_window_open_ms = 0; uint64_t by_rank_score()const { return rank_score; } bool active()const { return is_active; } @@ -169,7 +183,8 @@ namespace sysiosystem { } SYSLIB_SERIALIZE( producer_info, (owner)(producer_key)(rank_score)(is_active)(url)(unpaid_blocks)(last_claim_time)(location)(producer_authority) - (consecutive_missed_rounds)(is_demoted)(snapshot_attestations) ) + (consecutive_missed_rounds)(is_demoted)(snapshot_attestations) + (rounds_in_window)(missed_rounds_in_window)(miss_window_open_ms) ) }; using producers_table = sysio::kv::table< "producers"_n, producer_key_t, producer_info, @@ -803,7 +818,21 @@ namespace sysiosystem { void record_round_participation( const name& current_producer ); /// Charge one producer a missed round, demoting it if that crosses the threshold. - void record_missed_round( const name& producer, uint32_t max_consecutive_missed_rounds ); + /** + * Record one scheduled round for `producer` -- made or missed -- rolling its miss window, + * updating the streak and the window counts, and applying both demotion gates. + * + * @param producer the producer whose round this was. + * @param missed whether the round went unproduced. + * @param weights the live score configuration. + */ + void record_round_outcome( const name& producer, bool missed, + const producer_rank::producer_score_config& weights ); + + /// Re-derive `is_demoted` from the LIVE thresholds, then rescore. Sweep-only: a config + /// change is the one event that can leave a stored demotion flag disagreeing with the + /// rule that produced it. + void reconcile_and_rescore( const name& producer ); /// Restart the rescore cursor from row 0 and flag the sweep pending -- every stored /// score is stale after a weight change (`setscorecfg`) or a collateral-minimum change diff --git a/contracts/sysio.system/src/producer_pay.cpp b/contracts/sysio.system/src/producer_pay.cpp index 2130426409..8302c39d72 100644 --- a/contracts/sysio.system/src/producer_pay.cpp +++ b/contracts/sysio.system/src/producer_pay.cpp @@ -102,7 +102,7 @@ namespace sysiosystem { for( size_t stepped = 0; stepped < active_schedule.size(); ++stepped ) { if( slot == active_schedule.end() ) slot = active_schedule.begin(); if( slot == current ) break; - record_missed_round( *slot, weights.max_consecutive_missed_rounds ); + record_round_outcome( *slot, /*missed*/ true, weights ); ++slot; } } @@ -121,42 +121,81 @@ namespace sysiosystem { // // A demoted producer that is NOT in the active schedule never reaches this path, so recovery // still requires `regproducer` for anyone the schedule has actually dropped. - auto key = producer_key_t{current_producer.value}; - if( _producers.contains(key) ) { - const auto& info = _producers.get(key); - if( info.consecutive_missed_rounds > 0 || info.is_demoted ) { - _producers.modify( same_payer, key, []( auto& p ) { - p.consecutive_missed_rounds = 0; - p.is_demoted = false; - }); - rescore_producer( current_producer ); - } + { + producer_rank::producer_score_config_t weights_tbl( get_self() ); + const auto weights = weights_tbl.get_or_default( producer_rank::producer_score_config{} ); + record_round_outcome( current_producer, /*missed*/ false, weights ); } _global.modify( get_self(), [&]( auto& g ) { g.last_producer = current_producer; }); } - void system_contract::record_missed_round( const name& producer, - uint32_t max_consecutive_missed_rounds ) { + void system_contract::record_round_outcome( const name& producer, bool missed, + const producer_rank::producer_score_config& weights ) { auto key = producer_key_t{producer.value}; if( !_producers.contains(key) ) return; + // On-chain time in ms, matching `missed_round_window_ms`. Microsecond precision from the + // intrinsic, divided down -- the window is measured in hours, so the units are what matter. + const uint64_t now_ms = static_cast( + sysio::current_time_point().time_since_epoch().count() / 1000 ); + _producers.modify( same_payer, key, [&]( auto& p ) { + // Roll the window before recording into it. A producer that was off the schedule observed + // no rounds, so nothing accrued while it was away -- and if it stayed away longer than the + // window, its old counts lapse rather than greeting it on return. That is the WNS-47 + // shape (stale per-period state resurrecting on re-entry) designed out at the source. + if( p.miss_window_open_ms == 0 + || now_ms - p.miss_window_open_ms >= weights.missed_round_window_ms ) { + p.miss_window_open_ms = now_ms; + p.rounds_in_window = 0; + p.missed_rounds_in_window = 0; + } + p.rounds_in_window++; + + if( !missed ) { + // A block is the strongest liveness proof there is: it clears the streak and, when the + // producer is still holding a schedule slot, the demotion with it. Demotion and + // rescheduling are not simultaneous, and the `min_schedule_size` floor can hold that + // gap open indefinitely -- without this those producers would produce for nothing, + // since `payepoch` stops at the demoted tier. The window counts are NOT cleared: one + // good round does not erase a day's record. + p.consecutive_missed_rounds = 0; + // A block clears the STREAK outright, and with it any demotion the consecutive gate + // caused -- that gate asks "are you offline right now", and this is the answer. It + // does NOT pardon the rate gate: one good round cannot erase a bad record, or a + // producer missing half its rounds would clear its demotion every time it managed + // one. So the flag is RE-DERIVED from the record rather than forced false. A producer + // still holding a slot climbs out by producing until its rate falls back under the + // limit; one the schedule has dropped uses `regproducer`, which opens a fresh window. + p.is_demoted = producer_rank::warrants_demotion( 0, p.rounds_in_window, + p.missed_rounds_in_window, weights ); + return; + } + + p.missed_rounds_in_window++; p.consecutive_missed_rounds++; // Demotion is CATEGORICAL: it moves the producer into a tier no score can climb out of. // There is no cooldown and no expiry, and the only two ways back are `regproducer` and // producing a block while still in the active schedule -- the latter covering the window // between a demotion and the rebuild that acts on it, which the schedule-size floor can // hold open indefinitely. - if( !p.is_demoted && max_consecutive_missed_rounds > 0 - && p.consecutive_missed_rounds >= max_consecutive_missed_rounds ) { + if( !p.is_demoted + && producer_rank::warrants_demotion( p.consecutive_missed_rounds, p.rounds_in_window, + p.missed_rounds_in_window, weights ) ) { p.is_demoted = true; + // Leaving the pay walk consumes the period's snapshot credit. `payepoch` zeroes that + // counter only for rows it VISITS, and it stops at the demoted tier -- so a credit + // carried out of the walk would ride back in on return and outrank producers who + // actually attested that period. It is a per-period SERVICE RATING, unlike the block + // count, which is an earned debt and is deliberately kept. + p.snapshot_attestations = 0; } }); - // The miss moved the participation factor, and a demotion moved the tier; either way the - // stored sort key is stale. A demoted producer keeps its block count: it is paid for those - // blocks at the first payepoch after regproducer brings it back into the pay walk. + // The round moved the participation factor, and a demotion or its clearing moved the tier; + // either way the stored sort key is stale. A demoted producer keeps its block count: it is + // paid for those blocks at the first payepoch where it is payable again. rescore_producer( producer ); } @@ -185,7 +224,7 @@ namespace sysiosystem { batch.push_back( it->owner ); } for( const auto& producer : batch ) { - rescore_producer( producer ); + reconcile_and_rescore( producer ); } _global.modify( get_self(), [&]( auto& g ) { diff --git a/contracts/sysio.system/src/ranking.cpp b/contracts/sysio.system/src/ranking.cpp index b7352606bc..ad5884d9ef 100644 --- a/contracts/sysio.system/src/ranking.cpp +++ b/contracts/sysio.system/src/ranking.cpp @@ -65,8 +65,21 @@ namespace sysiosystem { // before it is ready is demoted again within max_consecutive_missed_rounds rounds, // which is self-correcting. A demoted producer that is still in the active schedule // recovers on its own by producing -- see `record_round_participation`. - info.is_demoted = false; - info.consecutive_missed_rounds = 0; + // Clears the DEMOTION, not the record. `regproducer` costs nothing but a signature + // and may be repeated, so clearing the streak here would let an offline operator cron + // its way back to healthy after every second miss and never produce a block -- the + // demotion model would stop meaning anything. The streak survives and clears only by + // PRODUCING, so a producer that returns unready is demoted again on its next missed + // round; until it produces, the participation factor keeps scoring it accordingly. + // + // The miss WINDOW does reset, and that is safe for the same reason: the consecutive + // gate is what defeats a cron loop, and it is untouched here. Without this reset a + // producer whose rate gate tripped could never recover -- demoted, it is not + // scheduled, so it observes no rounds, so its rate can never improve. + info.is_demoted = false; + info.rounds_in_window = 0; + info.missed_rounds_in_window = 0; + info.miss_window_open_ms = 0; }); // The clear above changes the producer's tier, so its sort key is stale until rescored. @@ -77,6 +90,32 @@ namespace sysiosystem { producer_rank::rescore( get_self(), _producers, producer ); } + void system_contract::reconcile_and_rescore( const name& producer ) { + auto key = producer_key_t{producer.value}; + if( !_producers.contains(key) ) return; + + producer_rank::producer_score_config_t weights_tbl( get_self() ); + const auto weights = weights_tbl.get_or_default( producer_rank::producer_score_config{} ); + const auto info = _producers.get(key); + + // A LOWERED threshold has to reach the streaks that already passed it. Demotion is normally + // decided when a round is observed, and a producer that has fallen off the schedule observes + // none -- so without this a lowered threshold would never bind on exactly the producers it + // was lowered to catch. This runs only on the sweep, which the config change itself opens, + // so an ordinary rescore never re-derives the flag. + // + // A RAISED threshold deliberately does NOT un-demote anyone. Demotion is categorical: it + // clears by producing a block while still scheduled, or by `regproducer`. Governance + // widening the tolerance is not a pardon for producers already judged under the old one, and + // either door back is open to them immediately. + if( !info.is_demoted + && producer_rank::warrants_demotion( info.consecutive_missed_rounds, info.rounds_in_window, + info.missed_rounds_in_window, weights ) ) { + _producers.modify( same_payer, key, []( auto& p ) { p.is_demoted = true; }); + } + rescore_producer( producer ); + } + void system_contract::onprocessprod( name account, bool, bool ) { // The eligibility flags are not consulted: rescore_producer reads the operator's live status // and balances, which is the same information after the flip and cannot go stale between the @@ -134,6 +173,9 @@ namespace sysiosystem { _producers.get( key, "producer not found" ); _producers.modify( get_self(), key, [&]( producer_info& info ){ info.deactivate(); + // A park leaves the pay walk exactly as a demotion does, so it consumes the period's + // snapshot credit for the same reason -- see `record_round_outcome`. + info.snapshot_attestations = 0; }); // A parked row scores into the demoted tier, so the sort key is stale until rescored. This diff --git a/contracts/sysio.system/src/snapshot_attest.cpp b/contracts/sysio.system/src/snapshot_attest.cpp index 8cb850b10a..11d9ab37b6 100644 --- a/contracts/sysio.system/src/snapshot_attest.cpp +++ b/contracts/sysio.system/src/snapshot_attest.cpp @@ -101,6 +101,12 @@ void credit_snapshot_attestations(name self, const std::vector& voters) { for (const auto& voter : voters) { auto key = producer_key_t{voter.value}; if (!producers.contains(key)) continue; + // Settle any RE-ENTRY reset BEFORE crediting. `rescore` drops a stale credit when a row + // comes back into the walk, and it recognises that by the tier its stored key moves out of. + // Crediting first would hand that reset this period's credit to consume -- the row is + // re-entering and freshly credited in the same transaction, and the reset cannot tell the + // two apart. Rescoring first spends the reset on the old value, so the increment below is + // the only credit standing when the second rescore records it. producers.modify(same_payer, key, [](auto& row) { row.snapshot_attestations++; }); // The credit moved the snapshot factor, so the stored sort key is stale until rescored. // Without this the factor would reach the index only on the next unrelated rescore. diff --git a/contracts/sysio.system/sysio.system.abi b/contracts/sysio.system/sysio.system.abi index 131944e4b7..1cf1f12752 100644 --- a/contracts/sysio.system/sysio.system.abi +++ b/contracts/sysio.system/sysio.system.abi @@ -1039,6 +1039,18 @@ { "name": "snapshot_attestations", "type": "uint32" + }, + { + "name": "rounds_in_window", + "type": "uint32" + }, + { + "name": "missed_rounds_in_window", + "type": "uint32" + }, + { + "name": "miss_window_open_ms", + "type": "uint64" } ] }, @@ -1115,6 +1127,14 @@ { "name": "snapshot_target_attestations", "type": "uint32" + }, + { + "name": "missed_round_window_ms", + "type": "uint64" + }, + { + "name": "max_pct_missed_rounds_in_window", + "type": "uint32" } ] }, diff --git a/contracts/sysio.system/sysio.system.wasm b/contracts/sysio.system/sysio.system.wasm index 4aad91b0f50a9f372dacad99415b440d62b354a7..7622bea74b18af5dc5c9c5b5e2fa8836edd52e35 100755 GIT binary patch delta 26445 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 W;#RTDVt2i8&jk2yJiTsnx&9xKim<`} delta 25030 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 K-r~zWp8p4m9Gw6F diff --git a/contracts/tests/emissions_tests.cpp b/contracts/tests/emissions_tests.cpp index 413ed7e769..0f9c8a57f6 100644 --- a/contracts/tests/emissions_tests.cpp +++ b/contracts/tests/emissions_tests.cpp @@ -5943,7 +5943,9 @@ struct producer_score_tester : public producer_eligibility_tester { uint32_t participation_weight = 10'000, uint32_t snapshot_weight = 10'000, uint32_t max_consecutive_missed_rounds = 3, - uint32_t snapshot_target_attestations = 1) { + uint32_t snapshot_target_attestations = 1, + uint64_t missed_round_window_ms = 24ULL * 60 * 60 * 1000, + uint32_t max_pct_missed_rounds_in_window = 5) { return push_system_action(config::system_account_name, "setscorecfg"_n, mvo() ("weights", mvo() ("collateral_weight", collateral_weight) @@ -5953,7 +5955,9 @@ struct producer_score_tester : public producer_eligibility_tester { ("api_weight", uint32_t{0}) ("benchmark_weight", uint32_t{0}) ("max_consecutive_missed_rounds", max_consecutive_missed_rounds) - ("snapshot_target_attestations", snapshot_target_attestations))); + ("snapshot_target_attestations", snapshot_target_attestations) + ("missed_round_window_ms", missed_round_window_ms) + ("max_pct_missed_rounds_in_window", max_pct_missed_rounds_in_window))); } /// The packed sort key stored on a producer. @@ -6331,7 +6335,10 @@ BOOST_FIXTURE_TEST_CASE( demotion_fires_at_threshold_and_outweighs_collateral, p } FC_LOG_AND_RETHROW() // `regproducer` is the door back for a producer the schedule has DROPPED, from an involuntary -// demotion as much as from a voluntary park. There is no cooldown and no expiry. +// demotion as much as from a voluntary park. There is no cooldown and no expiry -- but it returns +// ELIGIBILITY, not a clean record: the miss streak survives it and clears only by producing. +// Otherwise an offline operator could cron `regproducer` after every second miss and never produce +// a block, and the demotion model would stop meaning anything. BOOST_FIXTURE_TEST_CASE( regproducer_clears_demotion_immediately, producer_score_tester ) try { auto names = setup_ranked_producers(5); trigger_reschedule(); @@ -6346,8 +6353,52 @@ BOOST_FIXTURE_TEST_CASE( regproducer_clears_demotion_immediately, producer_score produce_blocks(1); BOOST_REQUIRE( !demoted(target) ); - BOOST_REQUIRE_EQUAL( 0u, missed_rounds_of(target) ); BOOST_REQUIRE_EQUAL( tier_bootstrapped, tier_of(rank_score_of(target)) ); + // The penalty stands: `regproducer` returns eligibility, not a clean record. Only producing + // clears the streak, so a cron loop of re-registrations cannot outrun the demotion model. + BOOST_REQUIRE_EQUAL( 3u, missed_rounds_of(target) ); +} FC_LOG_AND_RETHROW() + +// The second demotion gate: a miss RATE over a rolling window, mirroring the shape +// `sysio.opreg::termcheck` applies to batch operators. It catches the producer that is not offline +// long enough to trip the consecutive gate but misses far too often to be carrying a slot. +// +// The thresholds here isolate it deliberately. With a consecutive limit of 2 and a rate limit of +// 40%, alternating miss / produce never lets the streak reach 2, so ONLY the rate gate can fire -- +// and the derived minimum sample (limit * 100 / percent = 5 rounds) means it cannot fire on the +// first miss either, which is the whole reason that minimum exists. +BOOST_FIXTURE_TEST_CASE( miss_rate_over_the_window_demotes_without_a_consecutive_run, producer_score_tester ) try { + auto names = setup_ranked_producers(5); + BOOST_REQUIRE_EQUAL( success(), set_score_config( + /*collateral_weight=*/10'000, /*participation_weight=*/10'000, /*snapshot_weight=*/1'000, + /*max_consecutive_missed_rounds=*/2, /*snapshot_target_attestations=*/1, + /*missed_round_window_ms=*/24ULL * 60 * 60 * 1000, /*max_pct_missed_rounds_in_window=*/40) ); + trigger_reschedule(); + wait_for_active_schedule(names[2]); + + const auto target = names[2]; + const auto miss_then_produce = [&]() { + skip_round_of(target); // one missed round + produce_blocks(names.size() * slots_per_producer); // one produced round + }; + + // One cycle: the rate is already 50%, but the sample is below the derived minimum, so the gate + // stays armed rather than firing. The streak never reaches the consecutive limit either. + miss_then_produce(); + BOOST_REQUIRE_MESSAGE( !demoted(target), + "the rate gate fired on a sample too small to mean anything" ); + BOOST_REQUIRE_LT( missed_rounds_of(target), 2u ); + + // Two more cycles, then one further miss: five observed rounds, three missed. The sample + // clears the minimum and 60% exceeds the 40% limit, so the producer is demoted for its RECORD, + // never for a run. + miss_then_produce(); + skip_round_of(target); + BOOST_REQUIRE_MESSAGE( demoted(target), + "a producer missing half its rounds should be demoted by the rate gate" ); + BOOST_REQUIRE_MESSAGE( missed_rounds_of(target) < 2u, + "the consecutive gate must not be what demoted it -- this test would prove nothing" ); + BOOST_REQUIRE_EQUAL( tier_demoted, tier_of(rank_score_of(target)) ); } FC_LOG_AND_RETHROW() // The OTHER door back, and the one no operator has to walk through: a demoted producer that is diff --git a/contracts/tests/sysio.snapshot_attest_tests.cpp b/contracts/tests/sysio.snapshot_attest_tests.cpp index 5e61e86444..8e034f5750 100644 --- a/contracts/tests/sysio.snapshot_attest_tests.cpp +++ b/contracts/tests/sysio.snapshot_attest_tests.cpp @@ -662,11 +662,12 @@ BOOST_FIXTURE_TEST_CASE(votesnaphash_quorum_credits_voting_producers, snapshot_v BOOST_REQUIRE_EQUAL(key3, key_of("producer3"_n)); } FC_LOG_AND_RETHROW() } -// A credit is a per-period factor, and `payepoch` zeroes it only on the rows it visits -- never -// the demoted tier. A producer that leaves the walk with a credit (a park here; a demotion or a -// de-collateralization take the same path) must therefore restart at zero when it re-enters, -// or a credit earned before it left would outrank a producer that actually attested this period. -BOOST_FIXTURE_TEST_CASE(re_entering_the_walk_restarts_the_snapshot_credit, snapshot_voting_tester) { try { +// A credit is a per-period SERVICE RATING, and `payepoch` zeroes it only on the rows it visits -- +// never the demoted tier. So leaving the pay walk consumes it, at the event that does the leaving: +// a park here, a demotion in `record_round_outcome`. Otherwise a credit earned before the producer +// left would ride back in and outrank producers who actually attested that period. Contrast the +// block count, which is an earned debt and is deliberately carried across the same boundary. +BOOST_FIXTURE_TEST_CASE(leaving_the_walk_consumes_the_snapshot_credit, snapshot_voting_tester) { try { BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer1"_n, "snapprov1"_n)); BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer2"_n, "snapprov2"_n)); BOOST_REQUIRE_EQUAL(success(), setsnpcfg(2)); @@ -686,11 +687,12 @@ BOOST_FIXTURE_TEST_CASE(re_entering_the_walk_restarts_the_snapshot_credit, snaps BOOST_REQUIRE_EQUAL(1u, attestations_of("producer1"_n)); BOOST_REQUIRE_LT(key_of("producer1"_n), uncredited); - // Leaving the walk keeps the counter (nothing visits the row to zero it) ... + // Parking consumes the period's credit at once ... BOOST_REQUIRE_EQUAL(success(), unregproducer("producer1"_n)); - BOOST_REQUIRE_EQUAL(1u, attestations_of("producer1"_n)); + BOOST_REQUIRE_EQUAL(0u, attestations_of("producer1"_n)); - // ... and re-entering restarts it: the key comes back exactly as it was before the credit. + // ... so coming back brings no stale service rating with it: the key is exactly what it was + // before the credit was ever granted. BOOST_REQUIRE_EQUAL(success(), regproducer("producer1"_n)); BOOST_REQUIRE_EQUAL(0u, attestations_of("producer1"_n)); BOOST_REQUIRE_EQUAL(uncredited, key_of("producer1"_n)); From 75e815c874f0082feeb2ba73d2626df13a7bec4e Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Fri, 4 Sep 2026 21:14:17 -0500 Subject: [PATCH 10/16] fix(sysio.system): score against the live collateral minimum; never rank from a half-swept index (WIRE-367) Two PR review findings, plus the documentation they change. - `sysio.opreg::setconfig` rewrites the requirement vectors and re-evaluates nobody: an operator's status is only ever re-derived when its own BALANCE moves. Raising a producer minimum therefore left producers below it ACTIVE, and scoring trusted that status, so they stayed schedulable and payable on a bond the chain no longer accepts. Scoring now tests the live minimum itself. It costs no extra reads. `collateral_factor` is already the ratio of posted bond to required minimum across every required pair, so a value below `score_scale` IS "short on at least one pair" -- the question `meets_role_min` answers, asked of numbers already in hand. Calling opreg's predicate instead would drag its pending-withdraw walk, unbounded per account, onto a path that runs for every scored row, and would reintroduce the `sysio.uwrit` dependency removed earlier in this PR. Bootstrapped producers are exempt, exactly as they are in `meets_role_min`. Only the config case needs catching here; a balance movement already re-evaluates status in opreg and notifies this contract. Convergence is by the sweep `setconfig` opens, so it is bounded rather than immediate. - `update_ranked_producers` no longer publishes from a half-swept index. A weight or minimum change invalidates every stored score at once and the sweep rewrites them a bounded batch per tick, so while it drains the index holds two configurations at once. Order within a tier picks the top 21 and the standby band, so ranking off that mixture proposed a schedule and a finalizer policy matching NEITHER configuration, repeatedly, as the sweep advanced. The throttle stamp still advances when the rebuild is skipped, or `onblock` would re-enter on every block for the sweep's duration. `payepoch` deliberately does not wait: deferring it would withhold a period's pay for a configuration change, and block pay is a per-producer count that does not read the walk order. Only the standby retainer reads position, so the exposure is one period's retainer. Docs: the withdrawal path was wrong -- an outpost bond releases through that outpost's own entry point, while `opreg::withdraw` takes an account and an amount and applies to the WIRE-native balance. Adds the tier table and states that healthy sorts ahead of bootstrapped, which is what makes genesis producers an always-on backup that yields slots as real producers arrive. Describes both demotion gates, why re-registering does not clear the streak, and why the period's snapshot credit does not survive leaving the pay walk. contracts_unit_test and contracts_snapshot_attest_test both pass, run sequentially: this host intermittently fails an unrelated councl case when the two run in parallel, and that case passes in isolation every time. Change-Id: I76710e092f75f8f3ab3c64f4114822b6bdc7df2e --- .../include/sysio.system/producer_score.hpp | 25 ++++++- contracts/sysio.system/src/ranking.cpp | 17 +++++ contracts/sysio.system/sysio.system.wasm | Bin 202018 -> 202103 bytes contracts/tests/emissions_tests.cpp | 43 +++++++++++ docs/becoming-a-block-producer.md | 67 ++++++++++++++---- 5 files changed, 137 insertions(+), 15 deletions(-) diff --git a/contracts/sysio.system/include/sysio.system/producer_score.hpp b/contracts/sysio.system/include/sysio.system/producer_score.hpp index 474b9f1320..b9640f6499 100644 --- a/contracts/sysio.system/include/sysio.system/producer_score.hpp +++ b/contracts/sysio.system/include/sysio.system/producer_score.hpp @@ -226,10 +226,31 @@ namespace sysiosystem { sysio::opreg::opconfig_t opreg_cfg_tbl(opreg_refs::account); const auto opreg_cfg = opreg_cfg_tbl.get_or_default(sysio::opreg::op_config{}); + // The LIVE minimum decides eligibility, not the stored status. `sysio.opreg::setconfig` + // rewrites the requirement vectors and re-evaluates nobody: it is the one event that can + // leave an operator ACTIVE while it no longer meets the bar, and the status it wrote + // under the old minimums would otherwise keep it scheduled and paid indefinitely. + // + // The test costs nothing extra. `collateral_factor` is the ratio of posted bond to the + // required minimum, taken across every required pair, so a value below `score_scale` IS + // "short on at least one pair" -- the same question `meets_role_min` answers, asked of + // the numbers already in hand. Calling opreg's own predicate instead would drag in its + // pending-withdraw walk, which is unbounded per account, on a path that runs for every + // scored row. + // + // Only the config case needs catching here: any BALANCE movement already re-evaluates + // status in opreg and notifies this contract. And the sweep that `setconfig` opens is + // what carries the new minimums across the table, so convergence is bounded rather than + // immediate -- which is all it needs to be. + // + // Bootstrapped producers are exempt, exactly as they are in `meets_role_min`: they are + // ACTIVE by fiat and hold no bond to measure. + const uint64_t collateral_ratio = collateral_factor(op, opreg_cfg); + if (!op.is_bootstrapped && collateral_ratio < score_scale) return unscored(); + // Every term saturates: the collateral factor is uncapped by design, so factor * weight // must not be allowed to wrap. - const uint64_t collateral = - mul_sat(collateral_factor(op, opreg_cfg), weights.collateral_weight); + const uint64_t collateral = mul_sat(collateral_ratio, weights.collateral_weight); const uint64_t participation = mul_sat(participation_factor(inputs.consecutive_missed_rounds, weights.max_consecutive_missed_rounds), diff --git a/contracts/sysio.system/src/ranking.cpp b/contracts/sysio.system/src/ranking.cpp index ad5884d9ef..6139eeaaad 100644 --- a/contracts/sysio.system/src/ranking.cpp +++ b/contracts/sysio.system/src/ranking.cpp @@ -186,8 +186,25 @@ namespace sysiosystem { } void system_contract::update_ranked_producers( const block_timestamp& block_time ) { + // Never publish a schedule out of a HALF-SWEPT index. A weight or minimum change invalidates + // every stored score at once, and the sweep rewrites them a bounded batch at a time -- so + // while it drains, the index holds scores from two different configurations at once. Order + // within a tier is what picks the top 21 and the standby band, so ranking off that mixture + // would propose a schedule and a finalizer policy matching NEITHER configuration, and would + // do it repeatedly as the sweep advanced. Waiting costs at most a few ticks and the + // schedule the chain is already producing under stays in force meanwhile. + // + // `payepoch` deliberately does NOT wait: deferring it would withhold a period's pay for a + // configuration change, and block pay is a per-producer count that does not depend on the + // order of the walk at all. Only the standby retainer reads position, so at worst one + // period's retainer is assigned off a mixed order -- a far smaller cost than not paying. + // The throttle stamp advances FIRST, even when the rebuild below is skipped: it is what + // keeps this to one attempt per tick. Returning before it would leave `onblock` re-entering + // on every single block for as long as the sweep runs. _global.modify( get_self(), [&]( auto& g ) { g.last_producer_schedule_update = block_time; }); + if( _global.get().rescore_pending ) return; + auto idx = _producers.get_index<"prodrank"_n>(); using value_type = std::pair; diff --git a/contracts/sysio.system/sysio.system.wasm b/contracts/sysio.system/sysio.system.wasm index 7622bea74b18af5dc5c9c5b5e2fa8836edd52e35..5a963506d0b08049d137da25b221721ac0efa585 100755 GIT binary patch delta 5319 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 X!B&A2$`yl&*#U~Hmn8iUW{s|H delta 5307 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 diff --git a/contracts/tests/emissions_tests.cpp b/contracts/tests/emissions_tests.cpp index 0f9c8a57f6..1a667e319c 100644 --- a/contracts/tests/emissions_tests.cpp +++ b/contracts/tests/emissions_tests.cpp @@ -6359,6 +6359,49 @@ BOOST_FIXTURE_TEST_CASE( regproducer_clears_demotion_immediately, producer_score BOOST_REQUIRE_EQUAL( 3u, missed_rounds_of(target) ); } FC_LOG_AND_RETHROW() +// Raising a producer collateral minimum has to reach the producers already below it. `setconfig` +// rewrites the requirement vector and re-evaluates nobody -- an operator's status is only ever +// re-derived when its own BALANCE moves -- so a producer left ACTIVE under the old minimum would +// otherwise stay scheduled and paid on a bond the chain no longer accepts. Scoring tests the live +// minimum itself, and the sweep `setconfig` opens carries that across the table. +BOOST_FIXTURE_TEST_CASE( raising_the_collateral_minimum_sinks_producers_now_below_it, producer_score_tester ) try { + auto names = setup_collateralized_producers(5); + const auto target = names[0]; + // Lift everyone EXCEPT the target well clear of the bar, so the raise below catches exactly + // one producer and the others stay put as the control. + for (uint32_t i = 1; i < names.size(); ++i) { + BOOST_REQUIRE_EQUAL( success(), credit_collateral(names[i], base_min_bond * 3) ); + } + produce_blocks(1); + BOOST_REQUIRE_EQUAL( tier_healthy, tier_of(rank_score_of(target)) ); + BOOST_REQUIRE_GT( producer_rank_position(target), 0u ); + + // Raised above the target's bond but below everyone else's. opreg still says ACTIVE -- nothing + // moved this operator's balance. + BOOST_REQUIRE_EQUAL( success(), set_single_pair_collateral(base_min_bond * 2) ); + { + const auto op = get_opreg_operator(target); + BOOST_REQUIRE_EQUAL( "OPERATOR_STATUS_ACTIVE", op["status"].as_string() ); + } + + // The sweep the config change opened carries the new minimum across the table. + trigger_reschedule(); + + BOOST_REQUIRE_MESSAGE( tier_of(rank_score_of(target)) == tier_demoted, + "a producer below the RAISED minimum must stop being schedulable, whatever its stored status says" ); + // Sorted behind every producer still meeting the bar, so every walk stops before reaching it. + BOOST_REQUIRE_EQUAL( names.size(), producer_rank_position(target) ); + for (uint32_t i = 1; i < names.size(); ++i) { + BOOST_REQUIRE_EQUAL( tier_healthy, tier_of(rank_score_of(names[i])) ); + } + + // Topping back up over the new bar restores it -- the deposit re-evaluates status in opreg and + // the score follows. + BOOST_REQUIRE_EQUAL( success(), credit_collateral(target, base_min_bond * 3) ); + produce_blocks(1); + BOOST_REQUIRE_EQUAL( tier_healthy, tier_of(rank_score_of(target)) ); +} FC_LOG_AND_RETHROW() + // The second demotion gate: a miss RATE over a rolling window, mirroring the shape // `sysio.opreg::termcheck` applies to batch operators. It catches the producer that is not offline // long enough to trip the consecutive gate but misses far too often to be carrying a slot. diff --git a/docs/becoming-a-block-producer.md b/docs/becoming-a-block-producer.md index c7b9af1a88..7fc795ce7d 100644 --- a/docs/becoming-a-block-producer.md +++ b/docs/becoming-a-block-producer.md @@ -114,8 +114,23 @@ of them means no position, no pay, and no schedule slot. ## How your rank is scored -The score is a weighted sum of normalised factors, ordered within a tier. Tier always beats score, -so no amount of collateral lifts a demoted producer above a healthy one. +The score is a weighted sum of normalised factors, ordered **within a tier**. Tier always beats +score, so no amount of collateral lifts a producer out of the tier it is in. + +There are three, and they sort in this order: + +| Tier | Who is in it | +|---|---| +| **healthy** | Every qualifying producer that posted its own bond. | +| **bootstrapped** | The genesis producers a chain launches with. | +| **demoted** | Producers currently being penalised for missed rounds, plus anyone not presently eligible at all. | + +Healthy sorting **ahead of** bootstrapped is the whole design of the hand-over. Genesis producers +are the network's always-on backup: they are ACTIVE by fiat, they hold no bond to measure, and the +miss machinery never terminates them, so the chain always has someone able to produce. But any +community producer that qualifies outranks all of them. They fill the schedule only while there are +too few community producers to fill it, and they yield those slots automatically as real producers +arrive. Nobody has to vote them out, and there is no flag day. | Factor | What it measures | |---|---| @@ -129,7 +144,10 @@ of free points. Snapshot service is optional. If you want it, register a snapshot provider account with `regsnapprov` and vote snapshot hashes with `votesnaphash`. Only votes that reach quorum are -credited, so registering alone earns nothing. +credited, so registering alone earns nothing. The credit is a rating of the CURRENT pay period, so +it does not follow you out: leaving the pay walk, whether by demotion or by parking, consumes it, +and you start the next period from zero. Blocks you have already produced behave the opposite way, +because they are earnings rather than a rating. ## Getting paid @@ -152,17 +170,35 @@ Claim what you have earned with `claimpay`. A **round** is your entire slot window. You are charged a missed round only when the whole window goes unproduced, so a brief hiccup that costs you a block or two is not a miss. -Miss three rounds in a row and you are **demoted**. Demotion is categorical: it moves you into a -tier that no amount of collateral climbs out of, and the next rebuild drops you from the schedule. +Two separate tests can demote you, and either is enough. They are the same pair of gates the +network applies to batch operators, so availability means the same thing whatever role you hold. + +| Gate | Asks | Default | +|---|---|---| +| **Consecutive** | Are you offline right now? | three missed rounds in a row | +| **Rate** | Are you chronically unreliable? | more than 5% of your scheduled rounds missed inside a rolling 24 hours | + +The rate gate only applies once it has seen enough of your rounds to mean anything. Below that +sample the consecutive gate is the stricter of the two anyway, so nothing is lost. Only rounds you +were actually scheduled for count, so time spent off the schedule neither helps nor hurts you, and +a gap longer than the window starts your record fresh. + +Demotion is categorical: it moves you into a tier that no amount of collateral climbs out of, and +the next rebuild drops you from the schedule. There are two ways back: -1. **Produce a block while you are still scheduled.** Demotion and rescheduling are separate - events, and the schedule floor can hold that gap open. A block is proof you are alive, so it - clears the demotion outright, with no action on your part beyond being back online. +1. **Produce.** A block clears your consecutive streak immediately, and with it any demotion that + gate caused. It does not wipe your rate: one good round cannot erase a bad day, so if the rate + gate is what demoted you, keep producing and it clears when your record recovers. This works + only while you still hold a slot, which happens more often than you might expect, because + demotion and rescheduling are separate events and the schedule floor can hold that gap open. 2. **Call `regproducer` again.** This is the way back once the schedule has actually dropped you. - It re-supplies your signing key, which makes it a real statement that you are ready rather than - a formality. There is no cooldown and no waiting period. + It re-supplies your signing key, which makes it a real statement of readiness rather than a + formality, and it starts a fresh rate window. It does **not** clear your consecutive streak. + That is deliberate: re-registering costs nothing but a signature and can be repeated, so if it + wiped the streak an absent operator could simply call it on a timer and never produce at all. + There is no cooldown and no waiting period. One subtlety worth planning around: even a single missed round short of demotion lowers your participation factor, and if that drops you below the last scheduled position you stop being @@ -175,9 +211,14 @@ collateral to outrank the producer that displaced you. - **Park** with `unregprod`. Your bond is untouched and your operator status stays `ACTIVE`; you simply hold no schedule position. `regproducer` brings you back at the position your collateral earns. -- **Withdraw** with `sysio.opreg::withdraw`, which queues the request; `cancelwtdw` cancels it - before it flushes. Once your balance falls below the minimum on any required chain you leave - `ACTIVE` and the schedule drops you at the next rebuild. +- **Withdraw** from the chain that holds the bond. An outpost bond is released through that + outpost's own withdrawal entry point, the counterpart of the deposit you made in step 3, which + travels to WIRE and settles against your registry balance. `sysio.opreg::withdraw` is **not** + that path: it takes only an account and an amount and applies to your WIRE-native balance, so + calling it for an Ethereum or Solana bond fails for insufficient balance and leaves the outpost + collateral untouched. Either way the request is queued rather than immediate, and `cancelwtdw` + cancels it before it flushes. Once your balance falls below the minimum on any required chain you + leave `ACTIVE` and the schedule drops you at the next rebuild. - **Slashing** is punitive and permanent. A slashed operator's row is never pruned and the registry refuses to re-register it, so a slashed account cannot come back. From ce099334572433fa7a5823b6cdcb5b6b5dd61bb0 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Fri, 4 Sep 2026 23:16:39 -0500 Subject: [PATCH 11/16] fix(sysio.system): bound the rank walks by making unschedulable rows sort last (WIRE-367) The last PR review finding: the healthy tier did not bound the rank walks. An ACTIVE producer operator stayed in it without a finalizer key, so a walk looking for 21 schedulable producers could skip an arbitrary number of rows that can never qualify -- on `onblock`'s schedule rebuild and INLINE in the epoch payout, where an overrun stalls the chain. `regproducer` is permissionless and the table is unbounded, so nothing capped that. Two mechanisms, deliberately: - A producer with no ACTIVE finalizer key now scores into the demoted tier. It could not take part in finality, so it can never be scheduled, and it has no business sitting in a tier the walks traverse. This is the real bound: the healthy and bootstrapped tiers now hold only rows that could actually be scheduled. - Both walks additionally stop after a fixed number of rows EXAMINED. That is the belt to the braces: a walk running inline in an epoch advance should never depend for its CPU cost on a predicate holding. Stopping early is safe only because of the no-forfeiture rule -- a row the walk never reaches is neither paid nor reset, exactly like an unpayable one, so its blocks carry to the next payout. That reasoning is recorded at the bound, because a later change to the carry would turn a safe cap into silent loss. Peer discovery is deliberately unaffected, and the ordering is load-bearing: it seeds from the ACTIVE SCHEDULE before it ranks anything, so a producer scheduled through `setprods` without a finalizer key stays reachable by BP gossip. Sinking these rows is only safe because that landed first; the two tests point at each other so neither can be changed alone. `regfinkey`, `actfinkey` and `delfinkey` now rescore. Making the finalizer key a scoring input means every event that changes the answer has to move the stored key with it -- otherwise a stale demoted-tier score sits at the front of the index and stops the walks before the producers behind it. That is the third factor in this PR to need its event wired up, after collateral (opreg's notification) and the demotion flag (the sweep). A new scoring factor is not finished when the factor is computed; it is finished when every event that moves it rescores. Test: a producer bonded at TEN times its peers, registered, ACTIVE in opreg, but with no finalizer key holds no rank position and is not scheduled. The tier-ordering test now gives its bootstrapped producer a finalizer key, without which it is unschedulable and the comparison it makes is not the one it names. Both suites pass, run sequentially. Change-Id: I697120e8691953e9ea4a18e408a6df965d1f7899 --- .../include/sysio.system/producer_score.hpp | 20 +++++++- .../include/sysio.system/sysio.system.hpp | 13 +++++ contracts/sysio.system/src/emissions.cpp | 6 +++ contracts/sysio.system/src/finalizer_key.cpp | 18 +++++++ contracts/sysio.system/src/ranking.cpp | 2 + contracts/sysio.system/sysio.system.wasm | Bin 202103 -> 202291 bytes contracts/tests/emissions_tests.cpp | 47 +++++++++++++++++- 7 files changed, 104 insertions(+), 2 deletions(-) diff --git a/contracts/sysio.system/include/sysio.system/producer_score.hpp b/contracts/sysio.system/include/sysio.system/producer_score.hpp index b9640f6499..3c07f4430b 100644 --- a/contracts/sysio.system/include/sysio.system/producer_score.hpp +++ b/contracts/sysio.system/include/sysio.system/producer_score.hpp @@ -198,7 +198,8 @@ namespace sysiosystem { * @param weights the live `prodscorecfg` weights. * @return the packed sort key to store on `producer_info::rank_score`. */ - inline uint64_t compute(const sysio::name& producer, + inline uint64_t compute(const sysio::name& self, + const sysio::name& producer, const score_inputs& inputs, const producer_score_config& weights) { // A producer that is not a live, collateral-backed PRODUCER operator -- parked by @@ -214,6 +215,22 @@ namespace sysiosystem { // key; every consumer still tests the live predicate before counting a position. if (!inputs.is_active) return unscored(); + // No active finalizer key, no schedule position -- so no place above the demoted tier + // either. This is what BOUNDS every rank walk. Registration is permissionless and the + // table is unbounded, so if bonded-but-keyless rows stayed in the healthy tier a walk + // looking for 21 schedulable producers could skip an arbitrary number of rows that can + // never qualify, on `onblock`'s schedule rebuild and inline in the epoch payout. + // + // Peer discovery is deliberately unaffected: it seeds from the ACTIVE SCHEDULE before it + // ranks anything, so a producer scheduled through `setprods` without a finalizer key is + // still discoverable. That ordering is why sinking these rows is safe -- see + // `peer_keys::getpeerkeys`. + finalizers_table finalizers(self); + const auto fin_key = finalizer_key_t{producer.value}; + if (!finalizers.contains(fin_key) || finalizers.get(fin_key).active_key_binary.empty()) { + return unscored(); + } + sysio::opreg::operators_t ops(opreg_refs::account); const auto op_key = sysio::opreg::operator_key{producer.value}; if (!ops.contains(op_key)) return unscored(); @@ -287,6 +304,7 @@ namespace sysiosystem { const auto info = producers.get(key); const auto score = compute( + self, producer, score_inputs{ .is_active = info.active(), diff --git a/contracts/sysio.system/include/sysio.system/sysio.system.hpp b/contracts/sysio.system/include/sysio.system/sysio.system.hpp index 6cb7feb9e8..dc77d88c22 100644 --- a/contracts/sysio.system/include/sysio.system/sysio.system.hpp +++ b/contracts/sysio.system/include/sysio.system/sysio.system.hpp @@ -68,6 +68,19 @@ namespace sysiosystem { /// too few nodes. Raising it trades more aggressive removal of ineligible /// producers for a stronger anti-concentration floor. static constexpr size_t min_schedule_size = 4; + /// Ceiling on rows a rank walk may EXAMINE before giving up on finding more. + /// + /// The demoted tier already bounds these walks: a row that cannot be scheduled scores into it + /// and sorts last. This is the belt to that pair of braces -- `regproducer` is permissionless + /// and the table unbounded, so a walk that runs inline in `onblock` or in the epoch payout + /// should never depend for its CPU cost on a predicate holding. Generous enough that it binds + /// only when something has already gone wrong: the schedule needs `max_producers` matches and + /// the payout `standby_end_rank`, both far below it. + /// + /// Stopping early is SAFE for pay because of the no-forfeiture rule: a row the walk never + /// reaches is neither paid nor reset, exactly like an unpayable one, so its blocks carry to the + /// next payout rather than being lost. + static constexpr uint32_t max_rank_walk_rows = 500; static constexpr uint32_t seconds_per_year = 52 * 7 * 24 * 3600; static constexpr uint32_t seconds_per_day = 24 * 3600; static constexpr uint32_t seconds_per_hour = 3600; diff --git a/contracts/sysio.system/src/emissions.cpp b/contracts/sysio.system/src/emissions.cpp index bd860281b0..722cc14724 100644 --- a/contracts/sysio.system/src/emissions.cpp +++ b/contracts/sysio.system/src/emissions.cpp @@ -984,8 +984,14 @@ void system_contract::payepoch(uint32_t epoch_index, // unpayable row is neither paid nor counted now; when its producer is payable again the // carried blocks are paid at THAT period's rate and counted in THAT period's divisor. uint32_t position = 0; + uint32_t examined = 0; for (auto it = prod_by_rank.begin(); it != prod_by_rank.end(); ++it) { if (producer_rank::tier_of(it->rank_score) == producer_tier::demoted) break; + // Hard ceiling on rows examined. This walk runs INLINE in an epoch advance, where an + // overrun stalls the chain, so its cost may not depend on the demoted tier actually + // bounding it. A row past the ceiling is neither paid nor reset -- the same treatment an + // unpayable row gets -- so its blocks carry rather than vanish. + if (++examined > max_rank_walk_rows) break; // is_schedulable requires an active row, ACTIVE opreg status, and an active finalizer // key: a producer missing any of them can never be scheduled, so it draws neither block diff --git a/contracts/sysio.system/src/finalizer_key.cpp b/contracts/sysio.system/src/finalizer_key.cpp index 21013e5f22..48635e47ba 100644 --- a/contracts/sysio.system/src/finalizer_key.cpp +++ b/contracts/sysio.system/src/finalizer_key.cpp @@ -177,6 +177,12 @@ namespace sysiosystem { ++f.finalizer_key_count; }); } + // Whether a producer HAS an active finalizer key is a scoring input: without one it + // cannot be scheduled, so it sinks below the tier every rank walk traverses. Every + // action that changes that answer has to move the stored key with it, or the producer + // sits at a rank its standing no longer matches -- and a stale demoted key at the front + // of the index stops the walks before the producers behind it. + rescore_producer( finalizer_name ); } /* @@ -236,6 +242,12 @@ namespace sysiosystem { set_proposed_finalizers(std::move(proposed_finalizers)); } + // Whether a producer HAS an active finalizer key is a scoring input: without one it + // cannot be scheduled, so it sinks below the tier every rank walk traverses. Every + // action that changes that answer has to move the stored key with it, or the producer + // sits at a rank its standing no longer matches -- and a stale demoted key at the front + // of the index stops the walks before the producers behind it. + rescore_producer( finalizer_name ); } /* @@ -277,5 +289,11 @@ namespace sysiosystem { // Remove the key from finalizer_keys table idx.erase( std::move(fin_key_itr) ); + // Whether a producer HAS an active finalizer key is a scoring input: without one it + // cannot be scheduled, so it sinks below the tier every rank walk traverses. Every + // action that changes that answer has to move the stored key with it, or the producer + // sits at a rank its standing no longer matches -- and a stale demoted key at the front + // of the index stops the walks before the producers behind it. + rescore_producer( finalizer_name ); } } /// namespace sysiosystem diff --git a/contracts/sysio.system/src/ranking.cpp b/contracts/sysio.system/src/ranking.cpp index 6139eeaaad..6abb32e673 100644 --- a/contracts/sysio.system/src/ranking.cpp +++ b/contracts/sysio.system/src/ranking.cpp @@ -222,8 +222,10 @@ namespace sysiosystem { // The walk is bounded by the demoted tier. `regproducer` is permissionless, so the table is // unbounded -- but producer_rank::compute sinks every non-ACTIVE producer operator into the // demoted tier, which sorts last, so the scan stops before the spam tail. + uint32_t examined = 0; for( auto it = idx.cbegin(); it != idx.cend() && top_producers.size() < max_producers; ++it ) { if( producer_rank::tier_of( it->rank_score ) == producer_tier::demoted ) break; + if( ++examined > max_rank_walk_rows ) break; if( !producer_rank::is_schedulable( *it, _finalizers ) ) continue; proposed_finalizers.emplace_back( _finalizers.get( finalizer_key_t{it->owner.value} ) ); diff --git a/contracts/sysio.system/sysio.system.wasm b/contracts/sysio.system/sysio.system.wasm index 5a963506d0b08049d137da25b221721ac0efa585..d8657e135f08033f6e232df7c36d6c5005b4d1b1 100755 GIT binary patch delta 3078 zcmZWreQX>@6`wb=v)8KM>I- zktl@<3FEb@1}Y&S!k{f8q7g;S7p|PzaHd2`S`-tYjSMtWBr0K`e^dx0BMNn;pzz-A z`3p|En>TOX$M3y)@6Ek;sdehdt*z|SPo4tC82poe@Jeepv{*5IV05YmpRjo?7(dX$KpG>Y75K)otYG|8&i43z8Eg$KBQ{708{h$11u3OK zAql<+gbaKkg_M1!TL|G>LK;8t0nfGy%STDy^jKR&kjo?D&u z1cG7tz>-Uw;qCbQc?&RgJQkxpEL`9xj8$cPhz}{=$5ohIwxaMb zU|6c5*lvCo6!YqN#gx6DtPq4b^jjaB{OiQU>#y87Fd%NO;JE&OIBupmt|N{=t-*0K zUOGr|{8_9CAVcylgyH~st?>m2AthHko(BZKz@qvXe&+aqpfKlgk01%_*v$8W0dWSU z>ZeHp+0W7@<;P--=xa^YFCmTR$Ft>Vwj4%#&t+*#f9F`_Er<-74cE-g&URGSJ!Nz_&lIuUK$bn{5)(N5=Dc^;2cgoYS;&tsv5 z*IA@;byh!f3(G#{!i^Ax_j`%a2uus;i6F$DY1(qGUa6G0-=m-`c00W z=j*UzQn{vc%~I>4#ygFT)-f#YQi^d=yv8llqU?`l2dZU)&PSC!G4>Vh=GBtnd-wJ! zR!I$eDF7@O+9ra^%{sBK zAy+^{2PzZw!LL5EB6C{HW>rsOon>QW8an|?9?NODW`E2e1DE59xlXdnY-KBAPg;OvRnC$8cCIJ4LYt9#yfb8P>dUZ_!N z##oxe$=~+EX8{!lApk!a9fWdsLHQ@b{!^w9!WWFMq{8n?}@xw`o?G{BDd{s1b1NP*dU%NuYeAH@u z<~5k&z>}^UuoIUc?L4cG%{Zn+!)DGk#m{FGg`uXa zO4()?Q^h7*Y1QRT3G9BXP%_(UN^}P$@1Z8=t$bEZoyjv7d7oFxc37Bgk?K&T4NL}_C|t@D15J_yr{qf6O>>!O+I(|4GzOWp zo7{JqAFJaqEKOjkprx~pDPliN)S2=_h%mFoLlwQA!dz<;=clqquoc#$DCXrdKN|al zx5j5|lcKt6n|7Lju3As;Hojg1!o$Uj^iyeB2WK?_FsRt{jJ*J3tne4SM8$h!2q&K9o*2~Hujn>xySw>e3 zkDsD*?V}`VMY=r&nHE|x1ezzl@>J2Y$`t3ctvwp(?YsF^OrGk+x||^b1DvKmT^`+< zM?=pi$&&W-Du;te=gxm`rRw{>rmI-%w(@i?eTJXKQJcfro;`$0Rg+^c@e1@b zMqc8lc-v;`pw=jSoke;}^6yD*z$9H)_!KU0T;UIjEoyVad71wQkYP{rm!T*5*)$(O f1OJ%jUwnA&c4wgy53LTe^?;A_r#F!gnNYuqh@%>mbJoe;1NwumYSAs7k?@L9k_;4{I5 z=+PaFF`qM`{lEw0ZD!m@NndyJO)PMVNCScH1k!nxX%xq6zy%973xO)ZX6`eeRfXUk ze$_WttG$o;7^2X9Vk^2uS)qAvu5Kls^!06s`hRx#1!+(!5){)uZr z3==e_6XxtB;dOdQvnrt@bVNcgAox~WHmCtu2DQEPycoH%?s~d8yjFt z_?D!LA?p&oM@6!5iAMPM5{<$bOL${ zLddSG9p3;nbc;l_N&GC)0cOnKz&A#M*V^R#Yh45EM<`W0W+ae&!aNK;9$?4K{d_}p z(mYV$aDE)fkNs$HI*a#4_V*fzyqheK?yx3VLXzyF&q?CdiaGtOtLRU!qVHcte@e!= zLas6mT9_=~0BvEGHKGbw?OLn~%rrxEF|S6x*i1qae)}5nND(FMT=iwDvjQEb+Ia+P zg|fDC%|R5;v`uFuM{P+$J2DR!9|!T}nU}JgXb~SWLPHLEFJha+OC*wZjnqzD#%R1p zkTQr}>tcIyUc*oxm^UU1hBv7BN)v}APkzu1>tdsrOMjuFISTT}E2O0Go3~t z%E>&@fR?pDPne`~O~LVy5KyiyDfZ- z7C`b!H{8FMh2@nZC>I-tt~Y20;gk8cjZ+)T;h4r)> ze-Rzs8O`BirleR}?0}t2cEiSYmUk8l?!KrCcO_*Z`N|e(k5N9Pl9(Zf%4}`uyD#6B zIi`36$MettY`B2fN01|cRzQAgg?V~4(|{Pke0MP3fJ_OqSilwV5o^cfcVoLx)6n$O z4D39O0%|N9WdrA&d>j|Yic=K3PNOQ~IDMc8e$`^1Xklh+wWP}r!bwP`AA*78y<4y@ z`TIk##k0cL%mNJ;cqCnEC2AB($?y=^wiS{+$g+}sLl7k28iKEZm3%w|UC@@a4nuo~ zCEE^#{o~pS;W^zVg~adYE8*3dtKHH{_Y6Y-zBP&u6 z=u2>M2mCnQIR~+o{vw6lMf-xw7%p0tE?I zE^*ntplR6u49-Z{A0frev$P)9!XOSXv-nv=g%lMHI6+Ba5jrdc5z#d1UW7gaD57w# zp^I{5p+PJq!VEVYwvkDid~*@*7vL>Id;0n!ybf?S9s3P*S?M=^2c2J{A6`lN2k7?n z@+Mk+6kbdJahN{8wR~=85n_(N8EnGB>K9^xza4afr<2q4mp0s&)ZU~+ajWFvB{#we zS8aG*Dn_cML7In1wd4J?9fjt;J|qfPn-X}IOzHu>U@0e;_Wr=6gRl$146Nk6WZu@U zIhS1RAI&!cK}|=xjfeEZ<^CL4>t(xFXUZ{XHfEc&YwGNJ4>u@(D&{C@NgkM?y`G!D zczClp!iKuZkr^sm+#D{h*J-qzWEzRdPiN@b4XyG%?~iCdkIPK85opFGhPt25&?n+M zL7DhX2-A-Cvps`r{M}cC0XKQ! z6x|<}kb>g#md-L*uyon1C`Ya}zp#$92AGFO$$*PX2iM##6gK}UIMO?${jfZ)m2u#N zZ{Y-yea#|^zl>bNE1Mx(gTna5A)7HWvYZXu-ij{ACONgn*ekCXz-###Z$zyrqL#ut zTE8cdmFWEN>2bqXC6qK;kt%0FW_a>LFq@zxJ?VQKQ5n#)mh!NAvb>8fV4Y+q!f)PO zFu}7H*1|Il^31L(8A(@_ia-13ECpLjSsQ*ubteCPn|45Fvf&*pqD}|jq2siT9puFlaq9^X4d2iZnhP2W31{{u+4c$S_4ojiYz4xoYAbM*TUCm-IX U-AR0#K9vmKremr2H|^W-Kl_w(!T Date: Sat, 5 Sep 2026 08:18:25 -0500 Subject: [PATCH 12/16] docs: correct the recovery and eligibility rules in the producer guide (WIRE-367) The guide's recovery list already said `regproducer` does not clear the consecutive streak, but a later paragraph still told operators that calling it clears the miss penalty. That was true before the streak was made to survive re-registration and is not true now: re-registering returns a producer to the healthy tier without restoring its participation factor, and since a producer cannot produce without a slot, collateral is the only lever that works from outside the schedule. Say that, rather than promising a door that is closed. Two eligibility rules had no coverage at all. Losing the finalizer key now costs a position outright however large the bond, because a producer the chain cannot schedule scores into the demoted tier. And the collateral minimum is a governance setting that can be raised after a bond is posted, which leaves the registration ACTIVE and the balance untouched while the producer holds no rank until it tops up -- reaching the table through the background rescore, with no rebuild published until that finishes. Also: the schedule throttle is 120 block SLOTS, not blocks. Change-Id: I47c2b31be507c4bbc91508ebf3a0cf4c59b35d26 --- docs/becoming-a-block-producer.md | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/docs/becoming-a-block-producer.md b/docs/becoming-a-block-producer.md index 7fc795ce7d..07dd852803 100644 --- a/docs/becoming-a-block-producer.md +++ b/docs/becoming-a-block-producer.md @@ -104,14 +104,23 @@ arrives, or it will simply miss the round. Nothing. That is the point. Rank is **position in a score-ordered index**, derived by iteration rather than assigned by any -action. The chain rebuilds the schedule at most once every 120 blocks; at that point the highest -ranked eligible producers become the active schedule, and the finalizer policy is rebuilt to match. -If your score puts you in the top 21 you are scheduled, and you begin producing in your slot. +action. The chain rebuilds the schedule at most once every 120 block slots, roughly a minute; at +that point the highest ranked eligible producers become the active schedule, and the finalizer +policy is rebuilt to match. If your score puts you in the top 21 you are scheduled, and you begin +producing in your slot. To hold a position at all you need three things at once: an active producer row, an `ACTIVE` producer operator registration backed by collateral, and an active finalizer key. Missing any one of them means no position, no pay, and no schedule slot. +Two of those are worth watching after you are already running. A finalizer key that is removed or +deactivated costs you your position immediately, however large your bond — the chain measures a +producer it cannot schedule as one that holds no rank at all. And the collateral requirement is a +governance setting, so it can be raised after you have bonded: if that happens your registration +stays `ACTIVE` and nothing is taken from you, but you hold no rank until you top up to the new +minimum. A raised minimum reaches the table through a background rescore rather than all at once, +and the schedule is not rebuilt until that finishes. + ## How your rank is scored The score is a weighted sum of normalised factors, ordered **within a tier**. Tier always beats @@ -202,9 +211,12 @@ There are two ways back: One subtlety worth planning around: even a single missed round short of demotion lowers your participation factor, and if that drops you below the last scheduled position you stop being -scheduled. The miss counter only clears by producing, so a producer displaced this way holds the -penalty until it acts. Calling `regproducer` clears it, and so does posting enough additional -collateral to outrank the producer that displaced you. +scheduled. The counter behind that factor clears only by producing, and `regproducer` deliberately +does not clear it — so re-registering returns you to the healthy tier but not to your former score. +Until you hold a slot again you are ranked on collateral carrying a reduced participation term, +which makes collateral the lever that works from outside the schedule: post enough to outrank +whoever displaced you and the next rebuild puts you back, and the first block you produce restores +the factor. ## Leaving, voluntarily or otherwise From b569e00425e605302ae59b9f10ba58100e72e3ea Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Sat, 5 Sep 2026 13:45:19 -0500 Subject: [PATCH 13/16] fix(sysio.system): close the review-round defects and demote on short rounds (WIRE-367) The permanent lockout first. `regproducer` deliberately preserves the consecutive streak, so a pardoned producer sits at (is_demoted false, streak >= threshold) -- and the config sweep re-derived BOTH gates on that row, re-demoting it with no new miss. Unscheduled, it could never produce the block that is its only other door back, so every later sweep undid `regproducer` again. The sweep now reconciles the RATE gate only, which is the gate that genuinely cannot self-correct: a producer off the schedule observes no rounds, so its recorded rate never improves. The consecutive gate loses nothing by waiting -- it asks "are you offline right now", which only an observed round answers, and a lowered limit binds on the next miss. Short rounds now count. A round was charged only when the WHOLE window went unproduced, so a producer could deliver one block of twelve, read as fully available, and hold a top-21 slot indefinitely. A round below `min_blocks_per_round` (new, default 6) is charged against the RATE gate, leaving the consecutive gate for rounds that produced nothing -- a total outage stays caught fast while a degraded node gets the window to recover in. The block count comes from the difference between the round's first and last block heights, both already in hand, so the 11-of-12 blocks that do not change producer do no extra work. `onblock` also stopped paying for rescores it did not need: the round path rescored unconditionally, at two cross-contract sysio.opreg reads apiece, even when no scoring input had moved. It now rescores only when the streak, the demotion flag or the snapshot credit actually changed. The schedule no longer waits for a config sweep. Deferring it put both the producer schedule and the finalizer policy behind an unbounded, permissionless table whose RAM this contract pays for -- anyone registering faster than the drain could freeze both indefinitely, during which a slashed or terminated producer keeps its slot and its finality weight. Ranking converges instead; a briefly mixed ordering is a far smaller harm than a schedule that cannot be rebuilt. Also: - `producer_info::set_demoted` makes consuming the snapshot credit a property of the transition rather than of one call site. Two of the three sites that raised the flag did not clear it, so a credit rode back in at full marks after `regproducer` and outranked producers that actually attested. `credit_snapshot_attestations` now skips demoted rows for the same reason, and `rmvproducer` -- the one is_active path this work had left unrescored -- clears it and sinks its key. - `setscorecfg` validates. `max_consecutive_missed_rounds = 0` reads as "disable the consecutive gate" and instead collapsed the rate gate's derived minimum sample to zero, demoting every producer on its first missed slot. - The `snapshot_ranked_producers` and `getpeerkeys` walks got the row ceiling the other two already had. The first is reached from a user-signed write and re-walked per entry; the second runs on the node's main thread. - Blocks carried by an unpayable row no longer inflate a later period's divisor, and the payepoch reset joins on a binary search rather than a linear scan. - `actfinkey`'s rescore moved above the pre-Savanna early return. - The live-collateral gate's rationale corrected: it measures posted balance, like the score, and deliberately not `available` -- withdraw and cancelwtdw are free, so subtracting a queued withdraw would let an operator oscillate its own eligibility. Change-Id: I17b1459a5bb3aa12c9b49daa6c7d201360e282ab --- .../include/sysio.system/producer_rank.hpp | 27 +++- .../include/sysio.system/producer_score.hpp | 23 +++- .../include/sysio.system/sysio.system.hpp | 64 +++++++++- contracts/sysio.system/src/emissions.cpp | 40 +++++- contracts/sysio.system/src/finalizer_key.cpp | 18 ++- contracts/sysio.system/src/peer_keys.cpp | 10 ++ contracts/sysio.system/src/producer_pay.cpp | 100 ++++++++++++--- contracts/sysio.system/src/ranking.cpp | 87 +++++++++---- .../sysio.system/src/snapshot_attest.cpp | 22 +++- contracts/sysio.system/src/sysio.system.cpp | 12 +- contracts/sysio.system/sysio.system.abi | 8 ++ contracts/sysio.system/sysio.system.wasm | Bin 202291 -> 204767 bytes contracts/tests/emissions_tests.cpp | 118 +++++++++++++++++- docs/becoming-a-block-producer.md | 19 ++- 14 files changed, 475 insertions(+), 73 deletions(-) diff --git a/contracts/sysio.system/include/sysio.system/producer_rank.hpp b/contracts/sysio.system/include/sysio.system/producer_rank.hpp index 69ecbcdd61..32fbaa460a 100644 --- a/contracts/sysio.system/include/sysio.system/producer_rank.hpp +++ b/contracts/sysio.system/include/sysio.system/producer_rank.hpp @@ -46,6 +46,14 @@ namespace sysiosystem { /// "100%"; a collateral ratio of exactly the configured minimum bond is also `score_scale`. constexpr uint64_t score_scale = 10'000; + /// Ceiling `setscorecfg` accepts for any single factor weight. + /// + /// Weights are multiplied by factors normalised to `score_scale`, and `mul_sat` already stops + /// one term from wrapping -- but a saturated term carries no ordering information, so a + /// configuration above this bound silently stops ranking rather than ranking differently. + /// A hundred times full scale leaves ample room to make one factor dominant on purpose. + constexpr uint32_t max_factor_weight = static_cast(score_scale) * 100; + /// Bits the packed key reserves for producer_tier (the two high bits). constexpr unsigned tier_bits = 2; @@ -215,12 +223,29 @@ namespace sysiosystem { /// only the consecutive one. uint32_t max_pct_missed_rounds_in_window = 5; + /// Blocks a producer must deliver within its own round for that round to count as served. + /// + /// A round is a contiguous run of slots held by one producer, and delivering a single + /// block in it used to be indistinguishable from delivering all of them: the miss walk + /// charges a round only when the WHOLE window goes unproduced, so a producer could hold a + /// scheduled slot indefinitely while delivering a fraction of it. A short round now counts + /// against the RATE gate -- chronic partial delivery demotes over the window -- while the + /// CONSECUTIVE gate stays reserved for rounds that produced nothing at all, so a total + /// outage is still caught quickly and a degraded node is given the window to recover in. + /// + /// Zero disables the check, leaving the whole-window rule alone. The default is half a + /// standard 12-slot round, matching the threshold the retired per-round pay model used. + /// + /// DECLARED LAST, matching the tail of SYSLIB_SERIALIZE below. + uint32_t min_blocks_per_round = 6; + SYSLIB_SERIALIZE(producer_score_config, (collateral_weight)(participation_weight)(snapshot_weight) (relay_weight)(api_weight)(benchmark_weight) (max_consecutive_missed_rounds)(snapshot_target_attestations) - (missed_round_window_ms)(max_pct_missed_rounds_in_window)) + (missed_round_window_ms)(max_pct_missed_rounds_in_window) + (min_blocks_per_round)) }; /// The `prodscorecfg` singleton. Mirrors `emitcfg_t`: absent until governance installs it, so diff --git a/contracts/sysio.system/include/sysio.system/producer_score.hpp b/contracts/sysio.system/include/sysio.system/producer_score.hpp index 3c07f4430b..2789a66fa2 100644 --- a/contracts/sysio.system/include/sysio.system/producer_score.hpp +++ b/contracts/sysio.system/include/sysio.system/producer_score.hpp @@ -248,12 +248,23 @@ namespace sysiosystem { // leave an operator ACTIVE while it no longer meets the bar, and the status it wrote // under the old minimums would otherwise keep it scheduled and paid indefinitely. // - // The test costs nothing extra. `collateral_factor` is the ratio of posted bond to the - // required minimum, taken across every required pair, so a value below `score_scale` IS - // "short on at least one pair" -- the same question `meets_role_min` answers, asked of - // the numbers already in hand. Calling opreg's own predicate instead would drag in its - // pending-withdraw walk, which is unbounded per account, on a path that runs for every - // scored row. + // The test costs nothing extra: `collateral_factor` is the ratio of POSTED BOND to the + // required minimum across every required pair, so a value below `score_scale` is "short + // on at least one pair" using numbers already in hand. + // + // It is deliberately NOT `meets_role_min`, and not only for cost. That predicate measures + // `available` -- balance minus locks and pending withdraws -- and this one measures the + // balance, for the same reason the SCORE does (see `bonded_balance`): withdraw and + // cancelwtdw are free, uncapped and cooldown-free, so subtracting a queued withdraw would + // let an operator oscillate its own eligibility without moving funds. Calling opreg's + // predicate would also drag in its pending-withdraw walk, unbounded per account, onto a + // path that runs for every scored row. + // + // The gap that leaves is a producer whose queued withdraw puts `available` under a newly + // raised minimum while its balance still clears it. That row keeps its rank until the + // withdraw FLUSHES -- at which point the balance moves, opreg re-evaluates status, and the + // notification sinks it here. Convergence rather than an instant switch, which is the same + // bargain the raised minimum itself is on. // // Only the config case needs catching here: any BALANCE movement already re-evaluates // status in opreg and notifies this contract. And the sweep that `setconfig` opens is diff --git a/contracts/sysio.system/include/sysio.system/sysio.system.hpp b/contracts/sysio.system/include/sysio.system/sysio.system.hpp index dc77d88c22..ba39552c92 100644 --- a/contracts/sysio.system/include/sysio.system/sysio.system.hpp +++ b/contracts/sysio.system/include/sysio.system/sysio.system.hpp @@ -81,6 +81,13 @@ namespace sysiosystem { /// reaches is neither paid nor reset, exactly like an unpayable one, so its blocks carry to the /// next payout rather than being lost. static constexpr uint32_t max_rank_walk_rows = 500; + + /// Ceiling `setscorecfg` accepts for `max_consecutive_missed_rounds`. + /// + /// `rate_gate_minimum_sample` evaluates `max_consecutive * 100` in uint32, so the input has to + /// stay well below the wrap point. A million consecutive missed rounds is already far past any + /// meaningful availability policy -- at 21 producers and half-second slots it is over a year. + static constexpr uint32_t max_consecutive_missed_rounds_limit = 1'000'000; static constexpr uint32_t seconds_per_year = 52 * 7 * 24 * 3600; static constexpr uint32_t seconds_per_day = 24 * 3600; static constexpr uint32_t seconds_per_hour = 3600; @@ -123,13 +130,26 @@ namespace sysiosystem { /// cursor from 0 under the same flag; there is nothing to count. bool rescore_pending = false; + /// Block height at which `last_producer`'s current round began. + /// + /// Every block between that height and the height of the next producer's first block belongs + /// to `last_producer` by construction -- a round is a contiguous run of slots held by one + /// producer -- so the difference IS the block count it delivered, with no per-block counter + /// and no work on the 11-of-12 blocks that do not change producer. + /// + /// DECLARED LAST, matching the tail of SYSLIB_SERIALIZE_DERIVED below. The ABI is generated + /// from the declarations while the wasm serializes in macro order, so a field inserted + /// anywhere but the end makes the two disagree silently. + uint32_t round_start_block = 0; + // explicit serialization macro is not necessary, used here only to improve compilation time SYSLIB_SERIALIZE_DERIVED( sysio_global_state, sysio::blockchain_parameters, (max_ram_size)(total_ram_bytes_reserved) (last_producer_schedule_update)(last_pervote_bucket_fill) (last_producer_schedule_size) (last_producer) - (rescore_cursor)(rescore_pending) ) + (rescore_cursor)(rescore_pending) + (round_start_block) ) }; inline sysio::block_signing_authority convert_to_block_signing_authority( const sysio::public_key& producer_key ) { @@ -191,6 +211,22 @@ namespace sysiosystem { bool active()const { return is_active; } void deactivate() { producer_key = public_key(); producer_authority = sysio::block_signing_authority{}; is_active = false; } + /// Applies a demotion decision, consuming the pay period's snapshot credit on the way OUT. + /// + /// Every site that decides the flag routes through here, because the credit must not survive + /// the transition and three separate sites decide it: the missed-round branch, the produced + /// branch (which RE-DERIVES the flag and can raise it on a block the producer made, via the + /// rate gate), and the config sweep. `payepoch` resets counters only on rows it VISITS and it + /// stops at the demoted tier, so a credit carried out of the walk would ride back in on + /// return and outrank producers that actually attested that period. It is a per-period + /// SERVICE RATING -- unlike `unpaid_blocks`, which is an earned debt and is deliberately + /// kept. Clearing on the false->true edge only is what makes it idempotent: re-deciding + /// "still demoted" must not wipe a credit earned since. + void set_demoted(bool demoted) { + if (demoted && !is_demoted) snapshot_attestations = 0; + is_demoted = demoted; + } + const sysio::block_signing_authority& get_producer_authority()const { return producer_authority; } @@ -828,7 +864,31 @@ namespace sysiosystem { /// Attribute missed rounds to the producers the active schedule skipped, and demote any /// that crossed the threshold. Runs on every block; see producer_pay.cpp. - void record_round_participation( const name& current_producer ); + /// + /// @param current_producer the producer of the block being processed. + /// @param block_height that block's height, used to measure the OUTGOING producer's + /// round length. Derived from the block header already in hand. + void record_round_participation( const name& current_producer, uint32_t block_height ); + + /** + * Amend a round already recorded as SERVED when the producer delivered too few blocks in + * it, counting it against the miss RATE. + * + * A round is charged as missed only when the whole window goes unproduced, which leaves a + * producer free to deliver one block of twelve and read as fully available. This closes + * that gap without touching the consecutive gate: chronic partial delivery accumulates + * against the window and demotes on rate, while a total outage stays the streak's + * business and is caught within `max_consecutive_missed_rounds` rounds. + * + * The round was already counted in `rounds_in_window` when it began, so this adds only to + * `missed_rounds_in_window` -- it amends a recorded round rather than recording a second. + * + * @param producer the producer whose round just ended. + * @param blocks_delivered blocks it produced in that round. + * @param weights the live score configuration. + */ + void record_short_round( const name& producer, uint32_t blocks_delivered, + const producer_rank::producer_score_config& weights ); /// Charge one producer a missed round, demoting it if that crosses the threshold. /** diff --git a/contracts/sysio.system/src/emissions.cpp b/contracts/sysio.system/src/emissions.cpp index 722cc14724..46d0a81d92 100644 --- a/contracts/sysio.system/src/emissions.cpp +++ b/contracts/sysio.system/src/emissions.cpp @@ -40,7 +40,11 @@ namespace { // --------------------------------------------------------------------------- constexpr uint32_t STANDBY_START_RANK = 22; -constexpr uint32_t MAX_STANDBY_END_RANK = 100; // safety cap: bounds the standby credit count in payepoch +constexpr uint32_t MAX_STANDBY_END_RANK = 100; // safety cap: bounds how many STANDBY retainers one + // payepoch can credit. It does NOT bound the pay walk -- + // no-forfeiture means a producer far below the standby + // band still has carried blocks to collect, so the walk + // runs past this and is bounded by max_rank_walk_rows. constexpr int64_t MS_PER_SECOND = 1000; // Basis-point denominator for all category / sub-split ratios. @@ -1015,7 +1019,31 @@ void system_contract::payepoch(uint32_t epoch_index, } } - const uint64_t slot_divisor = std::max(std::max(nominal_slots, produced_blocks), 1); + // Blocks CARRIED from an earlier period must not inflate this period's divisor. A row whose + // block pay rounds to zero keeps its blocks (that promise is what makes the model + // forfeiture-free), but it was counted in `produced_blocks` -- so without this correction the + // same blocks raise the divisor again at the next payout, and every producer that worked a + // full period is paid at a diluted rate to fund someone else's back-pay out of the wrong + // period's pool. + // + // One correction pass is enough and cannot oscillate: removing blocks only LOWERS the + // divisor, which only RAISES each block's pay, so a row that cleared zero on the first pass + // still clears it on the second. A row that crosses back over the threshold is simply paid, + // which is the outcome we want; any residue left by that is a smaller divisor than strictly + // ideal, i.e. it errs toward paying producers rather than withholding. + auto divisor_for = [&](uint64_t blocks) { + return std::max(std::max(nominal_slots, blocks), 1); + }; + uint64_t unpayable_blocks = 0; + { + const uint64_t first_pass = divisor_for(produced_blocks); + for (const auto& entry : entries) { + const bool rounds_to_zero = + static_cast<__int128>(active_pool) * entry.blocks / first_pass == 0; + if (rounds_to_zero) unpayable_blocks += entry.blocks; + } + } + const uint64_t slot_divisor = divisor_for(produced_blocks - unpayable_blocks); // Producers are paid the emission share only — swap fees go to the // underwriter + batch operators (see the fold-in comment above). @@ -1048,10 +1076,16 @@ void system_contract::payepoch(uint32_t epoch_index, actual_paid += distributed_to_producers; + // Sorted so the join below is a binary search. This action runs INLINE in the epoch advance, + // where an overrun stalls the chain, and both vectors are bounded by `max_rank_walk_rows` -- + // a linear scan per reset entry is quadratic in a number an unbounded, permissionless table + // controls. + std::sort(block_paid.begin(), block_paid.end()); + // Reset the period's counters after distribution (iteration-safe: uses PK snapshot). for (const auto& entry : to_reset) { const bool clear_blocks = - std::find(block_paid.begin(), block_paid.end(), entry.owner) != block_paid.end(); + std::binary_search(block_paid.begin(), block_paid.end(), entry.owner); if (!clear_blocks && !entry.snapshot) continue; auto key = producer_key_t{entry.owner.value}; _producers.modify(same_payer, key, [&](auto& p) { diff --git a/contracts/sysio.system/src/finalizer_key.cpp b/contracts/sysio.system/src/finalizer_key.cpp index 48635e47ba..3c381c0568 100644 --- a/contracts/sysio.system/src/finalizer_key.cpp +++ b/contracts/sysio.system/src/finalizer_key.cpp @@ -219,6 +219,18 @@ namespace sysiosystem { f.active_key_binary = new_key_binary; }); + // Whether a producer HAS an active finalizer key is a scoring input: without one it cannot be + // scheduled, so it sinks below the tier every rank walk traverses. Every action that changes + // that answer has to move the stored key with it, or the producer sits at a rank its standing + // no longer matches -- and a stale demoted key at the front of the index stops the walks + // before the producers behind it. + // + // ABOVE the early return, not below it. The activation is already committed at this point, + // and the Savanna check below decides only whether the ACTIVE POLICY needs republishing -- + // a question with no bearing on the producer's score. Rescoring after it would skip every + // pre-Savanna chain and every fresh chain before its first ranked publish. + rescore_producer( finalizer_name ); + const auto& last_proposed_finalizers = get_last_proposed_finalizers(); if( last_proposed_finalizers.empty() ) { // prior to switching to Savanna @@ -242,12 +254,6 @@ namespace sysiosystem { set_proposed_finalizers(std::move(proposed_finalizers)); } - // Whether a producer HAS an active finalizer key is a scoring input: without one it - // cannot be scheduled, so it sinks below the tier every rank walk traverses. Every - // action that changes that answer has to move the stored key with it, or the producer - // sits at a rank its standing no longer matches -- and a stale demoted key at the front - // of the index stops the walks before the producers behind it. - rescore_producer( finalizer_name ); } /* diff --git a/contracts/sysio.system/src/peer_keys.cpp b/contracts/sysio.system/src/peer_keys.cpp index 9b748085e2..f7905e87d7 100644 --- a/contracts/sysio.system/src/peer_keys.cpp +++ b/contracts/sysio.system/src/peer_keys.cpp @@ -98,10 +98,20 @@ peer_keys::getpeerkeys_res_t peer_keys::getpeerkeys() { // finalizer key. A producer scheduled through `setprods` -- the bootstrap window, and every // harness that publishes schedules directly -- produces blocks before it registers one, and a // block producer that `getpeerkeys` hides is a block producer the BP gossip mesh cannot reach. + // + // Bounded on ROWS EXAMINED as well as on matched positions. `position` advances only on a + // match, so the `continue` above it is free to skip an unbounded number of healthy-tier rows + // whose LIVE eligibility no longer matches their CACHED tier -- and each skip costs a + // cross-contract sysio.opreg read. This walk runs on the node's main thread every 120 blocks + // (`peer_keys_db_t::should_update`), so an unbounded scan is a host stall, not just a slow + // query. uint32_t position = 0; + uint32_t examined = 0; for (auto i = idx.cbegin(); i != idx.cend() && resp.size() < max_return; ++i) { if (producer_rank::tier_of(i->rank_score) == producer_tier::demoted) break; + if (++examined > max_rank_walk_rows) + break; if (!producer_rank::is_eligible_operator(*i)) continue; if (++position > max_rank) diff --git a/contracts/sysio.system/src/producer_pay.cpp b/contracts/sysio.system/src/producer_pay.cpp index 8302c39d72..19a231ffe8 100644 --- a/contracts/sysio.system/src/producer_pay.cpp +++ b/contracts/sysio.system/src/producer_pay.cpp @@ -1,4 +1,5 @@ #include +#include #include #include @@ -44,20 +45,24 @@ namespace sysiosystem { // Attribute the rounds nobody produced. This must happen on every block: the count above // records PRESENCE only -- a producer that produces nothing is never visited by onblock at // all, so absence leaves no trace unless the schedule is walked explicitly. - record_round_participation( producer ); + // The height is already implicit in `previous_block_id`, which is deserialized above, so this + // costs four shifts rather than a table read. + record_round_participation( producer, block_info::block_height_from_id(previous_block_id) + 1 ); /// only update block producers once every minute, block_timestamp is in half seconds if( timestamp.slot - _global.get().last_producer_schedule_update.slot > 120 ) { // Drain any pending rescore BEFORE rebuilding, so the rebuild sees the freshest scores it - // can. A sweep spans several ticks; the schedule is proposed from a partially-rescored - // index in the meantime, which is safe because the tiers -- not the composite -- decide - // membership, and a tier only changes on demotion or recovery. + // can. A sweep spans many ticks and the rebuild does NOT wait for it: ranking is allowed + // to converge, and the alternative -- holding the schedule until the sweep finishes -- + // would put both the schedule and the finalizer policy behind an unbounded, permissionless + // table. See `update_ranked_producers`. drain_rescore_cursor(); update_ranked_producers( timestamp ); } } - void system_contract::record_round_participation( const name& current_producer ) { + void system_contract::record_round_participation( const name& current_producer, + uint32_t block_height ) { const auto& state = _global.get(); // Mid-round: the same producer made the previous block, so no slot was skipped and its miss @@ -124,10 +129,56 @@ namespace sysiosystem { { producer_rank::producer_score_config_t weights_tbl( get_self() ); const auto weights = weights_tbl.get_or_default( producer_rank::producer_score_config{} ); + + // The OUTGOING producer's round just ended, and only now is its length known. Every block + // from `round_start_block` up to this one belonged to it -- a round is a contiguous run of + // slots held by one producer -- so the difference is exactly the count it delivered. + // + // Attributed only when the schedule is unchanged, for the same reason the miss walk is: + // across a change the stored height belongs to a round that no longer exists. A zero + // stamp means no round has been measured yet. + if( schedule_unchanged && state.last_producer.value != 0 && state.round_start_block != 0 + && block_height > state.round_start_block ) { + record_short_round( state.last_producer, block_height - state.round_start_block, weights ); + } + record_round_outcome( current_producer, /*missed*/ false, weights ); } - _global.modify( get_self(), [&]( auto& g ) { g.last_producer = current_producer; }); + _global.modify( get_self(), [&]( auto& g ) { + g.last_producer = current_producer; + g.round_start_block = block_height; + }); + } + + void system_contract::record_short_round( const name& producer, uint32_t blocks_delivered, + const producer_rank::producer_score_config& weights ) { + // Zero disables the check, leaving the whole-window rule alone. + if( weights.min_blocks_per_round == 0 ) return; + if( blocks_delivered >= weights.min_blocks_per_round ) return; + + auto key = producer_key_t{producer.value}; + if( !_producers.contains(key) ) return; + + _producers.modify( same_payer, key, [&]( auto& p ) { + // The window is NOT rolled here. This amends a round `record_round_outcome` already + // counted when it began, so rolling would discard the very count being amended. + p.missed_rounds_in_window++; + + // The RATE gate only. The streak is deliberately untouched: a short round is degraded + // service, not an outage, and the consecutive gate exists to catch the latter fast. A + // producer delivering a fraction of every round is caught by the window instead, which + // is the "acceptable up to a point" the threshold encodes. + if( !p.is_demoted + && producer_rank::exceeds_miss_rate( p.rounds_in_window, + p.missed_rounds_in_window, weights ) ) { + p.set_demoted( true ); + } + }); + + // Only a DEMOTION moves the score here -- `missed_rounds_in_window` is not a scoring input -- + // so an ordinary short round costs no rescore, and with it no cross-contract read. + if( _producers.get(key).is_demoted ) rescore_producer( producer ); } void system_contract::record_round_outcome( const name& producer, bool missed, @@ -140,6 +191,16 @@ namespace sysiosystem { const uint64_t now_ms = static_cast( sysio::current_time_point().time_since_epoch().count() / 1000 ); + // Only the three fields below feed the score. `rounds_in_window` / + // `missed_rounds_in_window` do not, so the common case -- a healthy producer serving its + // round with the streak already at zero -- moves nothing and must not pay for a rescore. + // That matters because this runs on every round transition, and a rescore costs two + // CROSS-CONTRACT sysio.opreg reads plus two finalizer reads, inline in `onblock`. + const auto before = _producers.get(key); + const auto before_streak = before.consecutive_missed_rounds; + const auto before_demoted = before.is_demoted; + const auto before_snapshot = before.snapshot_attestations; + _producers.modify( same_payer, key, [&]( auto& p ) { // Roll the window before recording into it. A producer that was off the schedule observed // no rounds, so nothing accrued while it was away -- and if it stayed away longer than the @@ -168,8 +229,8 @@ namespace sysiosystem { // one. So the flag is RE-DERIVED from the record rather than forced false. A producer // still holding a slot climbs out by producing until its rate falls back under the // limit; one the schedule has dropped uses `regproducer`, which opens a fresh window. - p.is_demoted = producer_rank::warrants_demotion( 0, p.rounds_in_window, - p.missed_rounds_in_window, weights ); + p.set_demoted( producer_rank::warrants_demotion( 0, p.rounds_in_window, + p.missed_rounds_in_window, weights ) ); return; } @@ -183,20 +244,21 @@ namespace sysiosystem { if( !p.is_demoted && producer_rank::warrants_demotion( p.consecutive_missed_rounds, p.rounds_in_window, p.missed_rounds_in_window, weights ) ) { - p.is_demoted = true; - // Leaving the pay walk consumes the period's snapshot credit. `payepoch` zeroes that - // counter only for rows it VISITS, and it stops at the demoted tier -- so a credit - // carried out of the walk would ride back in on return and outrank producers who - // actually attested that period. It is a per-period SERVICE RATING, unlike the block - // count, which is an earned debt and is deliberately kept. - p.snapshot_attestations = 0; + // `set_demoted` consumes the period's snapshot credit as the row leaves the walk. + p.set_demoted( true ); } }); - // The round moved the participation factor, and a demotion or its clearing moved the tier; - // either way the stored sort key is stale. A demoted producer keeps its block count: it is - // paid for those blocks at the first payepoch where it is payable again. - rescore_producer( producer ); + // The round moved the participation factor, or a demotion moved the tier, or a demotion + // consumed the snapshot credit -- any of the three leaves the stored sort key stale. None of + // them moved, and there is nothing to restate. A demoted producer keeps its block count: it + // is paid for those blocks at the first payepoch where it is payable again. + const auto after = _producers.get(key); + if( after.consecutive_missed_rounds != before_streak + || after.is_demoted != before_demoted + || after.snapshot_attestations != before_snapshot ) { + rescore_producer( producer ); + } } void system_contract::drain_rescore_cursor() { diff --git a/contracts/sysio.system/src/ranking.cpp b/contracts/sysio.system/src/ranking.cpp index 6abb32e673..05517bc435 100644 --- a/contracts/sysio.system/src/ranking.cpp +++ b/contracts/sysio.system/src/ranking.cpp @@ -98,20 +98,30 @@ namespace sysiosystem { const auto weights = weights_tbl.get_or_default( producer_rank::producer_score_config{} ); const auto info = _producers.get(key); - // A LOWERED threshold has to reach the streaks that already passed it. Demotion is normally - // decided when a round is observed, and a producer that has fallen off the schedule observes - // none -- so without this a lowered threshold would never bind on exactly the producers it - // was lowered to catch. This runs only on the sweep, which the config change itself opens, - // so an ordinary rescore never re-derives the flag. + // A LOWERED RATE threshold has to reach the windows that already breach it. The rate gate is + // the one that cannot self-correct: a producer the schedule has dropped observes no rounds, + // so its recorded rate never improves, and without this a tightened limit would never bind on + // exactly the producers it was tightened to catch. This runs only on the sweep, which the + // config change itself opens, so an ordinary rescore never re-derives the flag. + // + // The CONSECUTIVE gate is deliberately NOT reconciled here, and that is load-bearing rather + // than an omission. `regproducer` clears the demotion and the window but preserves the + // streak, so a pardoned producer sits at (is_demoted == false, streak >= threshold) until it + // produces. Re-deriving the consecutive gate on that row would re-demote it with no new miss, + // and -- being unscheduled -- it could never produce the block that is its only other door + // back, so every subsequent sweep would undo `regproducer` again: a permanent lockout. The + // gate loses nothing by waiting. It asks "are you offline right now", which only an OBSERVED + // round can answer, and `record_round_outcome` evaluates the live threshold on every miss -- + // so a lowered limit binds on the producer's very next missed round. // // A RAISED threshold deliberately does NOT un-demote anyone. Demotion is categorical: it // clears by producing a block while still scheduled, or by `regproducer`. Governance // widening the tolerance is not a pardon for producers already judged under the old one, and // either door back is open to them immediately. if( !info.is_demoted - && producer_rank::warrants_demotion( info.consecutive_missed_rounds, info.rounds_in_window, + && producer_rank::exceeds_miss_rate( info.rounds_in_window, info.missed_rounds_in_window, weights ) ) { - _producers.modify( same_payer, key, []( auto& p ) { p.is_demoted = true; }); + _producers.modify( same_payer, key, []( auto& p ) { p.set_demoted( true ); }); } rescore_producer( producer ); } @@ -126,6 +136,43 @@ namespace sysiosystem { void system_contract::setscorecfg( const producer_rank::producer_score_config& weights ) { require_auth( get_self() ); + // A miss percentage is a percentage. + check( weights.max_pct_missed_rounds_in_window <= 100, + "max_pct_missed_rounds_in_window cannot exceed 100" ); + + // The rate gate's minimum sample is DERIVED from the consecutive threshold + // (max_consecutive * 100 / max_pct), so the two settings are not independent: at + // max_consecutive == 0 the sample floor collapses to zero and the rate gate fires on a + // sample of ONE, where a single missed round is a 100% miss rate. That inverts the field's + // meaning -- zero reads as "disable the consecutive gate" and would instead demote every + // producer on its first missed slot, dropping the schedulable count below + // `min_schedule_size` chain-wide. Disabling a gate is spelled by zeroing THAT gate's own + // percentage, so require the pair to be coherent instead. + check( weights.max_pct_missed_rounds_in_window == 0 || weights.max_consecutive_missed_rounds > 0, + "max_consecutive_missed_rounds must be positive while the rate gate is armed" ); + check( weights.max_pct_missed_rounds_in_window == 0 || weights.missed_round_window_ms > 0, + "missed_round_window_ms must be positive while the rate gate is armed" ); + + // `rate_gate_minimum_sample` computes `max_consecutive * 100`, so cap the input well below + // the point that multiply wraps a uint32. + check( weights.max_consecutive_missed_rounds <= max_consecutive_missed_rounds_limit, + "max_consecutive_missed_rounds is unreasonably large" ); + + // Every factor is multiplied by its weight and summed. `mul_sat` keeps a single term from + // wrapping, but a configuration whose weights cannot be told apart is still useless, so + // bound them at the scale the factors are normalised to. + check( weights.collateral_weight <= producer_rank::max_factor_weight + && weights.participation_weight <= producer_rank::max_factor_weight + && weights.snapshot_weight <= producer_rank::max_factor_weight + && weights.relay_weight <= producer_rank::max_factor_weight + && weights.api_weight <= producer_rank::max_factor_weight + && weights.benchmark_weight <= producer_rank::max_factor_weight, + "factor weight exceeds the maximum" ); + + // A zero target would make the snapshot factor divide by zero. + check( weights.snapshot_target_attestations > 0, + "snapshot_target_attestations must be positive" ); + producer_rank::producer_score_config_t weights_tbl( get_self() ); weights_tbl.set( weights, get_self() ); @@ -186,25 +233,19 @@ namespace sysiosystem { } void system_contract::update_ranked_producers( const block_timestamp& block_time ) { - // Never publish a schedule out of a HALF-SWEPT index. A weight or minimum change invalidates - // every stored score at once, and the sweep rewrites them a bounded batch at a time -- so - // while it drains, the index holds scores from two different configurations at once. Order - // within a tier is what picks the top 21 and the standby band, so ranking off that mixture - // would propose a schedule and a finalizer policy matching NEITHER configuration, and would - // do it repeatedly as the sweep advanced. Waiting costs at most a few ticks and the - // schedule the chain is already producing under stays in force meanwhile. + // A config sweep does NOT hold the schedule back. While it drains, the index carries scores + // from two configurations at once, so a rebuild can order a producer by a score the current + // weights would not give it -- and that is accepted: ranking is allowed to converge rather + // than switch atomically, and it self-corrects within a few ticks as the cursor advances. // - // `payepoch` deliberately does NOT wait: deferring it would withhold a period's pay for a - // configuration change, and block pay is a per-producer count that does not depend on the - // order of the walk at all. Only the standby retainer reads position, so at worst one - // period's retainer is assigned off a mixed order -- a far smaller cost than not paying. - // The throttle stamp advances FIRST, even when the rebuild below is skipped: it is what - // keeps this to one attempt per tick. Returning before it would leave `onblock` re-entering - // on every single block for as long as the sweep runs. + // Deferring instead is what is NOT safe. The sweep's length scales with the table, the table + // is unbounded, and `regproducer` is permissionless with its RAM billed to this contract -- + // so waiting for the sweep would let anyone hold BOTH the producer schedule and the finalizer + // policy frozen for as long as they kept registering, during which a slashed, terminated or + // demoted producer would keep its slot and its finality weight. A briefly mixed ordering is a + // far smaller harm than a schedule that cannot be rebuilt at all. _global.modify( get_self(), [&]( auto& g ) { g.last_producer_schedule_update = block_time; }); - if( _global.get().rescore_pending ) return; - auto idx = _producers.get_index<"prodrank"_n>(); using value_type = std::pair; diff --git a/contracts/sysio.system/src/snapshot_attest.cpp b/contracts/sysio.system/src/snapshot_attest.cpp index 11d9ab37b6..d300d21375 100644 --- a/contracts/sysio.system/src/snapshot_attest.cpp +++ b/contracts/sysio.system/src/snapshot_attest.cpp @@ -61,9 +61,18 @@ std::vector snapshot_ranked_producers(name self) { std::vector ranked; ranked.reserve(max_snap_provider_rank); + // Bounded on ROWS EXAMINED, not just on matches. `rank_score` is a CACHE while `is_schedulable` + // is evaluated LIVE, so healthy-tier rows that no longer qualify are skipped by `continue` and + // would otherwise cost an unbounded scan -- at a cross-contract sysio.opreg read plus two + // finalizer reads apiece. That matters more here than on the other walks: this one is reached + // from `regsnapprov`, a user-signed write, and re-walked once per entry inside the capacity + // prune, so an unbounded scan turns into a transaction that cannot fit its CPU budget and + // `regsnapprov` stops working for everyone. + uint32_t examined = 0; auto idx = producers.get_index<"prodrank"_n>(); for (auto i = idx.cbegin(); i != idx.cend() && ranked.size() < max_snap_provider_rank; ++i) { if (producer_rank::tier_of(i->rank_score) == producer_tier::demoted) break; + if (++examined > max_rank_walk_rows) break; if (!producer_rank::is_schedulable(*i, finalizers)) continue; ranked.push_back(i->owner); } @@ -101,12 +110,13 @@ void credit_snapshot_attestations(name self, const std::vector& voters) { for (const auto& voter : voters) { auto key = producer_key_t{voter.value}; if (!producers.contains(key)) continue; - // Settle any RE-ENTRY reset BEFORE crediting. `rescore` drops a stale credit when a row - // comes back into the walk, and it recognises that by the tier its stored key moves out of. - // Crediting first would hand that reset this period's credit to consume -- the row is - // re-entering and freshly credited in the same transaction, and the reset cannot tell the - // two apart. Rescoring first spends the reset on the old value, so the increment below is - // the only credit standing when the second rescore records it. + // A DEMOTED producer earns no credit. The counter is a rating of the CURRENT pay period, and + // `payepoch` -- which is what resets it -- stops at the demoted tier, so a credit handed to a + // demoted row is never cleared: it accumulates for as long as the producer stays demoted and + // then re-enters at full marks the moment `regproducer` lifts the tier, outranking producers + // that actually served the period it returns into. A demoted producer that keeps voting is + // not a fault to reject, just service that earns no rating, so this skips silently. + if (producers.get(key).is_demoted) continue; producers.modify(same_payer, key, [](auto& row) { row.snapshot_attestations++; }); // The credit moved the snapshot factor, so the stored sort key is stale until rescored. // Without this the factor would reach the index only on the next unrelated rescore. diff --git a/contracts/sysio.system/src/sysio.system.cpp b/contracts/sysio.system/src/sysio.system.cpp index d5ec9b5ac2..143d806f03 100644 --- a/contracts/sysio.system/src/sysio.system.cpp +++ b/contracts/sysio.system/src/sysio.system.cpp @@ -214,9 +214,19 @@ namespace sysiosystem { require_auth( get_self() ); auto key = producer_key_t{producer.value}; check( _producers.contains(key), "producer not found" ); - _producers.modify( same_payer, key, [&](auto& p) { + _producers.modify( get_self(), key, [&](auto& p) { p.deactivate(); + // A removed producer leaves the pay walk exactly as a park does, so it consumes the + // period's snapshot credit for the same reason -- see `producer_info::set_demoted`. + p.snapshot_attestations = 0; }); + + // The deactivation sinks this row to the demoted tier, so its sort key is stale until + // rescored. Skipping this is not cosmetic: every rank walk stops at the first demoted entry, + // and a removed producer left in the healthy tier is VISITED (and skipped) by all of them + // while consuming a position and an examined-row budget slot -- indefinitely, since nothing + // else rescores a row nobody touches. `unregprod` does exactly this; so must this action. + rescore_producer( producer ); } void transfer_ram( const name& from, const name& to, uint64_t bytes ) { diff --git a/contracts/sysio.system/sysio.system.abi b/contracts/sysio.system/sysio.system.abi index 1cf1f12752..cd503126a3 100644 --- a/contracts/sysio.system/sysio.system.abi +++ b/contracts/sysio.system/sysio.system.abi @@ -1135,6 +1135,10 @@ { "name": "max_pct_missed_rounds_in_window", "type": "uint32" + }, + { + "name": "min_blocks_per_round", + "type": "uint32" } ] }, @@ -1577,6 +1581,10 @@ { "name": "rescore_pending", "type": "bool" + }, + { + "name": "round_start_block", + "type": "uint32" } ] }, diff --git a/contracts/sysio.system/sysio.system.wasm b/contracts/sysio.system/sysio.system.wasm index d8657e135f08033f6e232df7c36d6c5005b4d1b1..34ab868985945802639ee2c7caa6d5f1e0356b8e 100755 GIT binary patch delta 18250 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 delta 15775 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 PId8ScO{S;fe8c#Ex?&~p diff --git a/contracts/tests/emissions_tests.cpp b/contracts/tests/emissions_tests.cpp index 6bb1775e88..67f41b94c2 100644 --- a/contracts/tests/emissions_tests.cpp +++ b/contracts/tests/emissions_tests.cpp @@ -5945,7 +5945,8 @@ struct producer_score_tester : public producer_eligibility_tester { uint32_t max_consecutive_missed_rounds = 3, uint32_t snapshot_target_attestations = 1, uint64_t missed_round_window_ms = 24ULL * 60 * 60 * 1000, - uint32_t max_pct_missed_rounds_in_window = 5) { + uint32_t max_pct_missed_rounds_in_window = 5, + uint32_t min_blocks_per_round = 6) { return push_system_action(config::system_account_name, "setscorecfg"_n, mvo() ("weights", mvo() ("collateral_weight", collateral_weight) @@ -5957,7 +5958,8 @@ struct producer_score_tester : public producer_eligibility_tester { ("max_consecutive_missed_rounds", max_consecutive_missed_rounds) ("snapshot_target_attestations", snapshot_target_attestations) ("missed_round_window_ms", missed_round_window_ms) - ("max_pct_missed_rounds_in_window", max_pct_missed_rounds_in_window))); + ("max_pct_missed_rounds_in_window", max_pct_missed_rounds_in_window) + ("min_blocks_per_round", min_blocks_per_round))); } /// The packed sort key stored on a producer. @@ -6417,6 +6419,118 @@ BOOST_FIXTURE_TEST_CASE( raising_the_collateral_minimum_sinks_producers_now_belo // 40%, alternating miss / produce never lets the streak reach 2, so ONLY the rate gate can fire -- // and the derived minimum sample (limit * 100 / percent = 5 rounds) means it cannot fire on the // first miss either, which is the whole reason that minimum exists. +// THE LOCKOUT REGRESSION. `regproducer` deliberately preserves the consecutive streak, so a +// pardoned producer sits at (is_demoted == false, streak >= threshold). The config sweep used to +// re-derive BOTH gates on that row, which re-demoted it with no new miss -- and, unscheduled, it +// could never produce the block that is its only other door back, so every later sweep undid +// `regproducer` again. Permanent lockout, reachable from ordinary governance. +// +// The sweep now reconciles the RATE gate only. The consecutive gate loses nothing by waiting: it +// asks "are you offline right now", which only an observed round answers, and `record_round_outcome` +// tests the live threshold on every miss. +BOOST_FIXTURE_TEST_CASE( a_config_sweep_does_not_undo_regproducer, producer_score_tester ) try { + auto names = setup_ranked_producers(5); + trigger_reschedule(); + wait_for_active_schedule(names[2]); + + const auto target = names[2]; + for (uint32_t miss = 0; miss < 3; ++miss) skip_round_of(target); + BOOST_REQUIRE( demoted(target) ); + + BOOST_REQUIRE_EQUAL( success(), push_system_action(target, "regproducer"_n, mvo() + ("producer", target)("producer_key", get_public_key(target, "active"))("url", "")("location", 0)) ); + produce_blocks(1); + BOOST_REQUIRE( !demoted(target) ); + // The streak survives the pardon -- that is the behaviour this test exists to protect. + BOOST_REQUIRE_EQUAL( 3u, missed_rounds_of(target) ); + + // Any config change opens a sweep over the whole table. + BOOST_REQUIRE_EQUAL( success(), set_score_config() ); + + // Drained a few blocks at a time, because the window this test is about is NARROW: `onblock` + // runs `drain_rescore_cursor` immediately BEFORE `update_ranked_producers`, so the sweep + // reconciles this row while it is still off the schedule -- exactly the state where a + // re-demotion is unrecoverable, since an unscheduled producer can never produce. Checking only + // at the end would let the producer be demoted, rescheduled and cleared again in between, and + // the test would pass over the defect it exists to catch. + for (uint32_t tick = 0; tick < 14; ++tick) { + produce_blocks(10); + BOOST_REQUIRE_MESSAGE( !demoted(target), + "the sweep re-demoted a producer regproducer pardoned, at block batch " << tick + << " -- unscheduled, it could never produce the block that is its only other way back" ); + } + + // The streak is NOT asserted here on purpose: once the pardon puts the producer back in the + // schedule it produces, and producing clears the streak legitimately. What must hold is that it + // was never demoted on the way there. +} FC_LOG_AND_RETHROW() + +// `setscorecfg` writes ten governance-tunable fields and used to validate none of them. The one +// that matters most is the pair below: the rate gate's minimum sample is DERIVED as +// max_consecutive * 100 / max_pct, so at max_consecutive == 0 -- which reads as "disable the +// consecutive gate" -- the sample floor collapses to zero and the rate gate fires on a sample of +// ONE, where a single missed round is a 100% miss rate. Every producer would be demoted on its +// first missed slot and the schedule would fall below its floor chain-wide. +BOOST_FIXTURE_TEST_CASE( setscorecfg_rejects_a_configuration_that_inverts_the_rate_gate, producer_score_tester ) try { + BOOST_REQUIRE_EQUAL( + wasm_assert_msg("max_consecutive_missed_rounds must be positive while the rate gate is armed"), + set_score_config(10'000, 10'000, 10'000, /*max_consecutive=*/0) ); + + // Spelling the DISABLE on the rate gate's own field is the coherent form, and is accepted. + BOOST_REQUIRE_EQUAL( success(), + set_score_config(10'000, 10'000, 10'000, /*max_consecutive=*/0, + /*snapshot_target=*/1, /*window_ms=*/24ULL * 60 * 60 * 1000, + /*max_pct=*/0) ); + + BOOST_REQUIRE_EQUAL( + wasm_assert_msg("max_pct_missed_rounds_in_window cannot exceed 100"), + set_score_config(10'000, 10'000, 10'000, 3, 1, 24ULL * 60 * 60 * 1000, /*max_pct=*/101) ); + + BOOST_REQUIRE_EQUAL( + wasm_assert_msg("missed_round_window_ms must be positive while the rate gate is armed"), + set_score_config(10'000, 10'000, 10'000, 3, 1, /*window_ms=*/0, 5) ); + + BOOST_REQUIRE_EQUAL( + wasm_assert_msg("snapshot_target_attestations must be positive"), + set_score_config(10'000, 10'000, 10'000, 3, /*snapshot_target=*/0) ); + + BOOST_REQUIRE_EQUAL( + wasm_assert_msg("factor weight exceeds the maximum"), + set_score_config(/*collateral_weight=*/1'000'001) ); + + BOOST_REQUIRE_EQUAL( + wasm_assert_msg("max_consecutive_missed_rounds is unreasonably large"), + set_score_config(10'000, 10'000, 10'000, /*max_consecutive=*/1'000'001) ); +} FC_LOG_AND_RETHROW() + +// `rmvproducer` performs the same deactivation `unregprod` does, and was the one is_active path +// this work left unrescored. A removed producer that keeps its healthy-tier sort key is VISITED +// and skipped by every rank walk -- consuming a position and an examined-row budget slot -- for as +// long as nothing else happens to rescore it, which for a removed row is forever. +BOOST_FIXTURE_TEST_CASE( rmvproducer_sinks_the_key_and_consumes_the_credit, producer_score_tester ) try { + auto names = setup_ranked_producers(5); + trigger_reschedule(); + + const auto target = names[1]; + // `setup_ranked_producers` seeds the bootstrapped backstop, not collateral-backed producers. + BOOST_REQUIRE_EQUAL( tier_bootstrapped, tier_of(rank_score_of(target)) ); + + BOOST_REQUIRE_EQUAL( success(), push_system_action(config::system_account_name, "rmvproducer"_n, + mvo()("producer", target)) ); + produce_blocks(1); + + BOOST_REQUIRE_MESSAGE( tier_of(rank_score_of(target)) == tier_demoted, + "a removed producer must sink immediately, or every walk keeps visiting a row it can never use" ); + BOOST_REQUIRE_EQUAL( 0u, get_producer_info(target)["snapshot_attestations"].as() ); + // No position AT ALL, not merely the last one: `deactivate` clears `is_active`, and position is + // counted over active rows only. The remaining producers close ranks over the gap. + BOOST_REQUIRE_EQUAL( 0u, producer_rank_position(target) ); + for (uint32_t i = 0; i < names.size(); ++i) { + if (names[i] == target) continue; + BOOST_REQUIRE_GT( producer_rank_position(names[i]), 0u ); + } +} FC_LOG_AND_RETHROW() + BOOST_FIXTURE_TEST_CASE( miss_rate_over_the_window_demotes_without_a_consecutive_run, producer_score_tester ) try { auto names = setup_ranked_producers(5); BOOST_REQUIRE_EQUAL( success(), set_score_config( diff --git a/docs/becoming-a-block-producer.md b/docs/becoming-a-block-producer.md index 07dd852803..6cc36d7b6c 100644 --- a/docs/becoming-a-block-producer.md +++ b/docs/becoming-a-block-producer.md @@ -176,16 +176,27 @@ Claim what you have earned with `claimpay`. ## Staying in the schedule -A **round** is your entire slot window. You are charged a missed round only when the whole window -goes unproduced, so a brief hiccup that costs you a block or two is not a miss. +A **round** is your entire slot window. Producing nothing at all in one is a missed round. Producing +only a handful of its blocks is a **short** round: a brief hiccup costs you nothing, but a node that +routinely delivers a fraction of its window is not carrying the slot it holds, and past a threshold +those rounds start counting against you too. + +The two are treated differently on purpose. A round that produced nothing says you are offline right +now, and that is caught fast. A short round says you are degraded, which is given the whole window +to recover in — so a bad hour costs you nothing, while a chronic pattern of half-served rounds +demotes you. Two separate tests can demote you, and either is enough. They are the same pair of gates the network applies to batch operators, so availability means the same thing whatever role you hold. | Gate | Asks | Default | |---|---|---| -| **Consecutive** | Are you offline right now? | three missed rounds in a row | -| **Rate** | Are you chronically unreliable? | more than 5% of your scheduled rounds missed inside a rolling 24 hours | +| **Consecutive** | Are you offline right now? | three rounds in a row that produced nothing | +| **Rate** | Are you chronically unreliable? | more than 5% of your scheduled rounds missed inside a rolling 24 hours — counting both rounds that produced nothing and rounds that came up short | + +A round counts as short below **half its blocks** by default (six of a twelve-slot round). Only the +rate gate sees short rounds; the consecutive gate is reserved for rounds that produced nothing, so +delivering even one block keeps you off it. The rate gate only applies once it has seen enough of your rounds to mean anything. Below that sample the consecutive gate is the stricter of the two anyway, so nothing is lost. Only rounds you From 4fce5f49f90ca310e17f877c9f53e93d34096953 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Mon, 7 Sep 2026 10:30:44 -0500 Subject: [PATCH 14/16] fix(sysio.system): close the second review round (WIRE-367) `regproducer` no longer launders the miss RATE. It cleared the window on every upsert, and short rounds made that a full bypass of the gate: a producer delivering a fraction of every round never advances the CONSECUTIVE counter -- a short round feeds the rate gate alone -- so a free, repeatable re-registration erased the only surviving evidence against it. The window now resets only on a genuine demoted-to-recovered transition, which is the case the reset exists for (a demoted producer is unscheduled, observes no rounds, and could otherwise never improve its recorded rate). The zero-rounding correction could over-distribute the pool. Removing a row's blocks from the divisor raises every remaining block's pay, so an excluded row can cross back over the rounding threshold on the second pass -- and paying it there pays for blocks the divisor no longer counts. With nominal_slots=120, active_pool=1 and two rows of 120 blocks, both round to zero at the first divisor, both leave it, the divisor falls to 120, and both would be credited 1: two units out of a one-unit pool. The first pass now DECIDES the payable set and the second only prices it. The pay divisor accrues with the pool. `payepoch` applied the CURRENT epoch duration to every epoch of the period, so a period spanning a duration change mis-sized it -- a 60s epoch followed by a 120s one is 360 slots but computed as 480, paying 75% of the active pool under full production. `t5_state` gains `pending_nominal_slots`, accumulated by `accrueepoch` at each epoch's own duration exactly as `pending_emission_amount` is, and reset with it. Also: - `setscorecfg` bounds `min_blocks_per_round` at the round size. Above it every fully produced round counts as SHORT and the rate gate demotes the network -- the same class of foot-gun the other checks in that action exist to stop, in the field the previous commit introduced. - Snapshot credit gates on the LIVE schedulable predicate rather than `is_demoted`. `unregprod` parks a row by clearing `is_active` and letting the rescore sink it by TIER; the flag stays false, so the old gate credited every parked producer, and losing a finalizer key did the same. - The operator guide drops three claims that are no longer true: that the schedule waits for a rescore sweep (it converges instead), that a minimum change binds existing operators on its own (it binds them on their next balance movement), and that nothing is EVER forfeited (settling held blocks requires being back within the pay walk's reach). `sysio.epoch.wasm` moves with no sysio.epoch source change: it deserializes `t5_state` through `sysio.system/emissions.hpp`, so the new field rebuilds it. Same coupling `standby_bps` had on `emitcfg` earlier in this branch. The field is declared last, so nothing sysio.epoch reads shifts position. Change-Id: Iabfac683c596f21a618cdcb4f7a0ce0393f504f6 --- contracts/sysio.epoch/sysio.epoch.wasm | Bin 79801 -> 79824 bytes .../include/sysio.system/emissions.hpp | 18 ++- contracts/sysio.system/src/emissions.cpp | 45 +++++-- contracts/sysio.system/src/ranking.cpp | 28 ++-- .../sysio.system/src/snapshot_attest.cpp | 22 +-- contracts/sysio.system/sysio.system.abi | 4 + contracts/sysio.system/sysio.system.wasm | Bin 204767 -> 206737 bytes contracts/tests/emissions_tests.cpp | 125 ++++++++++++++++++ docs/becoming-a-block-producer.md | 20 ++- 9 files changed, 231 insertions(+), 31 deletions(-) diff --git a/contracts/sysio.epoch/sysio.epoch.wasm b/contracts/sysio.epoch/sysio.epoch.wasm index 4757eeca426611eeef58c7d35227c2436859d2ca..cac4b434e0091a4d77bf83b2648c6bef1b696782 100755 GIT binary patch delta 711 zcmZuvO>5Lp6n*z5&4humg*QwL27x zrd?7xl*nt~gSRCTtN@~KpM_jdMxdqCw3XrWbMnlJ^nwy)hAfmRFFK(6&;;5q@*L&F z3(pk2@tjylAE~J_RqXsEvXVd(ar)!w(O9(QG{8a84lUSom0L*1y15cxsVM&lOPy7w zr_d1UIfxzQ-ffKg8?)5m>HvXe0kt1{Zk+BvxwwO}Y$e&f`b@Z5I)Jg|Vl`YB!<9ai z4n){*QqnXFn$Mf$9RV`TKGiSJ9!jtOPr6h{0&SO+1xGfNafL_O^vSdB!GV#ed}w6V z;p0Iv|2tVU?~YT2<{j<3uLMkOX?@1I1{88m%7|ZL)|nRfi~Z6n*#3IJ;(6e12?VVuZI7gq?(t4JNY$Bs$|)3JVp56c#oj%r8jg4k9Fw zatLg>ffQRLFtAMw7B+$e0$Yk{k|x%W%*4G<>XwS=)R!Th}%O&s>i>ER&YKWeo(7Z zAWFR{rB0!I4nBBo5#mI_|9L3nl(JmoRjF55W!@zxmvP=usB#_)m6PK(*a{NY=#XP8 zJD7T{=#ygyLYC2zX-lOfKOee=2%_J+{pfDqNAWlW%dAIiYa$%T!wXn?L<7G3s-qhxeZcA%E z(BCa9fjCv8$@s1#@;D)71@EFLs#LkD@130}IuljM!N4Ez0)UWm?hY2#EH;9; diff --git a/contracts/sysio.system/src/emissions.cpp b/contracts/sysio.system/src/emissions.cpp index 46d0a81d92..35522151f6 100644 --- a/contracts/sysio.system/src/emissions.cpp +++ b/contracts/sysio.system/src/emissions.cpp @@ -653,6 +653,13 @@ void system_contract::accrueepoch(uint32_t epoch_index, state.pending_emission_amount = saturating_accrue(state.pending_emission_amount, per_epoch_emission); + // The divisor accrues with the pool, at the duration in force for THIS epoch. Computing it at + // payout from the current duration would apply today's value to epochs that ran under a + // different one, mis-sizing the divisor for any period spanning a duration change. + state.pending_nominal_slots += static_cast(get_epoch_duration_sec()) + * static_cast(MS_PER_SECOND) + / static_cast(sysio::block_timestamp::block_interval_ms); + // Lazy-grow batch_group_epochs to fit batch_group_index. Pre-pay-cadence // chains see length 0 and grow on first epoch under the new schema. if (batch_group_index >= state.batch_group_epochs.size()) { @@ -952,11 +959,16 @@ void system_contract::payepoch(uint32_t epoch_index, // cfg.pay_cadence_epochs, which a mid-period change makes disagree with the accrual -- // at one slot per block interval. uint64: a 30-day epoch times a large cadence overflows // uint32. - const uint64_t nominal_slots = + // Accumulated by `accrueepoch` at each epoch's OWN duration -- every epoch of the period + // including this one, exactly as `pending_emission_amount` is (the equality check above + // pins that). The fallback is the zero-accrual case, not a compatibility path. + const uint64_t this_epoch_slots = static_cast(get_epoch_duration_sec()) - * static_cast(accrued_epochs > 0 ? accrued_epochs : 1) * static_cast(MS_PER_SECOND) / static_cast(sysio::block_timestamp::block_interval_ms); + const uint64_t nominal_slots = state.pending_nominal_slots > 0 + ? state.pending_nominal_slots + : this_epoch_slots * static_cast(accrued_epochs > 0 ? accrued_epochs : 1); // Standby position weights run N at position 22 down to 1 at standby_end_rank; their sum // is the divisor, so a position's share is the same whether or not it is filled. @@ -1026,20 +1038,28 @@ void system_contract::payepoch(uint32_t epoch_index, // full period is paid at a diluted rate to fund someone else's back-pay out of the wrong // period's pool. // - // One correction pass is enough and cannot oscillate: removing blocks only LOWERS the - // divisor, which only RAISES each block's pay, so a row that cleared zero on the first pass - // still clears it on the second. A row that crosses back over the threshold is simply paid, - // which is the outcome we want; any residue left by that is a smaller divisor than strictly - // ideal, i.e. it errs toward paying producers rather than withholding. + // A row excluded from the divisor is excluded from the PAYOUT too, and that pairing is what + // keeps the pool solvent. Removing blocks lowers the divisor, which raises every remaining + // block's pay -- so an excluded row can cross back over the rounding threshold on the second + // pass. Paying it there would be paying for blocks the divisor no longer counts: with + // nominal_slots=120, active_pool=1 and two rows of 120 blocks, both round to zero at the + // first divisor of 240, both leave it, the divisor falls to 120, and both would then be + // credited 1 -- two units out of a one-unit pool. + // + // So the first pass DECIDES the payable set, and the second only prices it. An excluded + // row's blocks carry to the next payout exactly as an unpayable row's do. auto divisor_for = [&](uint64_t blocks) { return std::max(std::max(nominal_slots, blocks), 1); }; + std::vector block_payable; + block_payable.reserve(entries.size()); uint64_t unpayable_blocks = 0; { const uint64_t first_pass = divisor_for(produced_blocks); for (const auto& entry : entries) { const bool rounds_to_zero = static_cast<__int128>(active_pool) * entry.blocks / first_pass == 0; + block_payable.push_back(!rounds_to_zero); if (rounds_to_zero) unpayable_blocks += entry.blocks; } } @@ -1057,9 +1077,13 @@ void system_contract::payepoch(uint32_t epoch_index, int64_t distributed_to_producers = 0; std::vector block_paid; block_paid.reserve(entries.size()); - for (const auto& entry : entries) { - const int64_t block_pay = static_cast( - static_cast<__int128>(active_pool) * entry.blocks / slot_divisor); + for (size_t index = 0; index < entries.size(); ++index) { + const auto& entry = entries[index]; + // Priced only if the first pass admitted it -- see the divisor comment above. + const int64_t block_pay = block_payable[index] + ? static_cast( + static_cast<__int128>(active_pool) * entry.blocks / slot_divisor) + : 0; int64_t pay = block_pay; if (entry.standby_weight > 0) { pay += static_cast( @@ -1204,6 +1228,7 @@ void system_contract::payepoch(uint32_t epoch_index, // Drain accumulator + advance period boundary. state.pending_emission_amount = 0; + state.pending_nominal_slots = 0; std::fill(state.batch_group_epochs.begin(), state.batch_group_epochs.end(), 0); state.period_start_epoch = epoch_index + 1; diff --git a/contracts/sysio.system/src/ranking.cpp b/contracts/sysio.system/src/ranking.cpp index 05517bc435..5b42bf45f6 100644 --- a/contracts/sysio.system/src/ranking.cpp +++ b/contracts/sysio.system/src/ranking.cpp @@ -72,14 +72,20 @@ namespace sysiosystem { // PRODUCING, so a producer that returns unready is demoted again on its next missed // round; until it produces, the participation factor keeps scoring it accordingly. // - // The miss WINDOW does reset, and that is safe for the same reason: the consecutive - // gate is what defeats a cron loop, and it is untouched here. Without this reset a - // producer whose rate gate tripped could never recover -- demoted, it is not - // scheduled, so it observes no rounds, so its rate can never improve. - info.is_demoted = false; - info.rounds_in_window = 0; - info.missed_rounds_in_window = 0; - info.miss_window_open_ms = 0; + // The miss WINDOW resets ONLY on a genuine demoted-to-recovered transition, never on + // an ordinary re-registration. A demoted producer is not scheduled, so it observes no + // rounds and its recorded rate could never improve on its own -- that is what the + // reset is for. An ACTIVE producer has no such problem, and wiping its window would + // hand it the cron loop the streak was protected from: a producer delivering a + // fraction of every round never advances the CONSECUTIVE counter (a short round feeds + // the rate gate alone), so if `regproducer` also cleared the window there would be no + // surviving evidence against it at all, and the rate gate could never fire. + if( info.is_demoted ) { + info.is_demoted = false; + info.rounds_in_window = 0; + info.missed_rounds_in_window = 0; + info.miss_window_open_ms = 0; + } }); // The clear above changes the producer's tier, so its sort key is stale until rescored. @@ -173,6 +179,12 @@ namespace sysiosystem { check( weights.snapshot_target_attestations > 0, "snapshot_target_attestations must be positive" ); + // A round holds `blocks_per_round` slots, so a threshold above it can never be met: every + // fully produced round would count as SHORT and the rate gate would demote the entire + // network. Zero remains the disabled spelling. + check( weights.min_blocks_per_round <= blocks_per_round, + "min_blocks_per_round cannot exceed the round size" ); + producer_rank::producer_score_config_t weights_tbl( get_self() ); weights_tbl.set( weights, get_self() ); diff --git a/contracts/sysio.system/src/snapshot_attest.cpp b/contracts/sysio.system/src/snapshot_attest.cpp index d300d21375..ff369872b0 100644 --- a/contracts/sysio.system/src/snapshot_attest.cpp +++ b/contracts/sysio.system/src/snapshot_attest.cpp @@ -106,17 +106,23 @@ void require_snapshot_producer_eligibility(name self, name producer) { /// finalized, so without this counter there is no attestation history to score. Reset on the same /// `payepoch` cadence as the block counters, which supplies the trailing window. void credit_snapshot_attestations(name self, const std::vector& voters) { - producers_table producers(self); + producers_table producers(self); + finalizers_table finalizers(self); for (const auto& voter : voters) { auto key = producer_key_t{voter.value}; if (!producers.contains(key)) continue; - // A DEMOTED producer earns no credit. The counter is a rating of the CURRENT pay period, and - // `payepoch` -- which is what resets it -- stops at the demoted tier, so a credit handed to a - // demoted row is never cleared: it accumulates for as long as the producer stays demoted and - // then re-enters at full marks the moment `regproducer` lifts the tier, outranking producers - // that actually served the period it returns into. A demoted producer that keeps voting is - // not a fault to reject, just service that earns no rating, so this skips silently. - if (producers.get(key).is_demoted) continue; + // Only a producer currently IN the pay walk earns credit. The counter is a rating of the + // CURRENT pay period and `payepoch` -- the only thing that resets it -- stops at the demoted + // tier, so a credit handed to a row outside the walk is never cleared: it accumulates for as + // long as the producer stays out and then re-enters at full marks, outranking producers that + // actually served the period it returns into. + // + // The test is the LIVE schedulable predicate, not the `is_demoted` flag. `unregprod` parks a + // row by clearing `is_active` and letting the rescore sink it by TIER -- the flag stays + // false throughout -- and losing a finalizer key or opreg eligibility does the same. Gating + // on the flag would credit every one of them. A producer outside the walk that keeps voting + // is not a fault to reject, just service that earns no rating, so this skips silently. + if (!producer_rank::is_schedulable(producers.get(key), finalizers)) continue; producers.modify(same_payer, key, [](auto& row) { row.snapshot_attestations++; }); // The credit moved the snapshot factor, so the stored sort key is stale until rescored. // Without this the factor would reach the index only on the next unrelated rescore. diff --git a/contracts/sysio.system/sysio.system.abi b/contracts/sysio.system/sysio.system.abi index cd503126a3..ba8b4c3d96 100644 --- a/contracts/sysio.system/sysio.system.abi +++ b/contracts/sysio.system/sysio.system.abi @@ -1631,6 +1631,10 @@ { "name": "capital_shortfall_total", "type": "int64" + }, + { + "name": "pending_nominal_slots", + "type": "uint64" } ] }, diff --git a/contracts/sysio.system/sysio.system.wasm b/contracts/sysio.system/sysio.system.wasm index 34ab868985945802639ee2c7caa6d5f1e0356b8e..02d4a6059b2650c9a19c7a4f034af723cecc13ae 100755 GIT binary patch delta 12111 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

O# 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 OCCuU3NeT1eUjGF?1UxDL delta 10577 zcmb7K33wGnw(dH8_nW0j$U+wAZUWrEAh;!f3UrihaA9#lWkzu)C~o)$9pDBBFe+;B zs)Y_BG714%0>QZm5#oqW96^JiL_r7`HDc5_50DvA-hZlZLc}-k`yPHbRb6%J)Ty)A zDHpt-bYN>z)d{bs%>T1*z0c9ZXL)t+*@Ygjo-Sfzg1?E)3QiCkVtay*dAEEV{@GVO zIh5X4?2XMV9?*4fvu0?f=G2K&Qxxmy_SK%HVab}KIgflLKMe`H`e^avLV@QecbwiE zTQhJFE&e)ocwi1a@>R8O(8WP`1pLi>_ zVpNe>T-`Y8Mv=7R69trKdYm1x%SKyCyFYCo*d3cOdW)DJJMZE(_Egs!0XaeXg$(;zVrUq)~9@jjbe>sdsJuN*+0;ZaOxj!@BnJ(AA5RuYz%LbUv`|t(dx`UjqXp6 z_+xj^Ij?$gb&jA%{jq}&bggdK@Ux)D{IN6Vb*(=3*BZjM)vrwu<<&`>cIaq6dA(3D z5tsC!f>`0!k(f7Yd&K0{a@AV|kk@NR_`#SpNB>^Ez3yBlJZV#p>RvlW2E_d8yFT2g zi>B(rkC%Iyvi+R}R(x?_h!0KAFQ)nc@^s6rQ#={?U5yY&W5-%Yh}!DG-{0v0z?!20 zCMACSRh{iSjE(>40^0RS^^-r<`x6s#mRI~1{dc_LB22|o zed1q2yd{GH@q-I8O1u>x9TbxVK3@%qo(u&IOUV+i$rekD6}#kMTU5~cz02!u;mJ0= zC0emw=~FE&LhERQw5NRWBRxeIUAz=e=`Aky(mxNxC-xJ+3yTW5X^?1Bqj5kDM=lLx(??8_yR##0t6peDOP4wzRbZQY*4DvFTd zM29D9z(B?%*B%bZzYP{+Xh%c5^AK@N|Id5a7m5PjW8{S*pZ6Fga^tfv6zc@NdRU$_ z3@1lBahT|aHa>HhNLCZ`hl?&ev3$4~%@aq4izsEBI!Iq_A+?>Ne3ac7OKB(U(62So*x@zeMPbA+BLcE_FP z#A~`(CVzZhTqJhOp^L?AI?*hxT_Q_lyGiW!O#Iq6#5)2MV#^-!-@YkE`^9Ye%s<3J@u}?hu9zj>i?4ZC zobAQvp?dKw{dh1wbEjA;#COu%B}P!~XD;bwyTlHgYQB)>anW5CEu(I7utRzA)yKpG zL3-%m(}h&P&1JL$jqEDua$v4BX6 zcGsL%-xQk)G}$yFsZNhlCaX)*dnw@D9bZD8B*jhHPIrB@myc+c1PSjEIA4!5t?R{ zuz{mD!A^IVkh6+PO~XugXqr>CO)HI%*2$xV%{1qvxLfTF7M;F1dzFn~rAdW)y|-Ae zVzy$_W{-=$+-Ik?;WtaoR8u~74P|-DeE^XwuA$3|7BTa&VwKljvE7$|XX^v*ehGloM^^hOYyX?LOYJueQi{3d>8rb}Xl{JOu(g zO`_yvCj_Y*Z|GrU8i7(MJeK!pl8EkAF3hIt5{eKi*6?`4G+Iq}!>S zTX7fN8NcQZ%B2pYaf}YqY01a2GjCfWmc;kpOCRcR8XNvV?}{z5Y&zwNO1Wq{bnFoF+d_WfjwMe@A+DGzaP!u`~L;>(BH zmh=;MISby?Mk$03H?<8z=het3?IZofblaoG-sXU33Jh2t7M{mBc~ZaI3A;F!$)@}1 z91)XU9-s+*8 z2^fBW&By{*oN>uqmS~)FUoTIoW|3w!fq-@vm099M~eVW(IU-nLIFqx(qHO zrf9$nJ8-p<|Hf~X{4vCc%4i0hJplS!unlD^_?VEO-}J~KACol{bj)Pv1d1b!0??)g zS=oBA-oQ+101t(!8?P&ijnGq|!FA&c<4?_mQqh%KeCI5>mFQW2Y{7e7<%HRE4*GIu z4swQ@6cKSO`i#f$QZXK7sFQa^iv3Szsz zN(c^`<`mmg0F}f6$oNb9+LIGRzU=iJjis0T^2L6XDJ!0%>tG$N&(X9=Ck2V9n1$u7 zLd9Ig494H+%^;73;ULBV#QwHv7OAzBoN36dzf~EeIV4G@5)c3kaS?E(gw} z+$cD!bu_r^O=5e)1%ex>4>yb&me=3?ieOWLkYK9fenxpSU*k9|vt3-dSvbxL(ZivK3d! z5tY;{TJJw)e4Ye@>-dsUh6(77|Cc&g0bO;awe(?@7D$5XRr*bRtdijYHCm!sUMwz0 z!sR(zR3S=$;cTR~4=&l`9A{-K)XT|~-&9h*STDmX=xnh;4p~7%#b0IF3hEcw;J1T- z-6-QLX!ykAP|E~)tv*MyQZNPmZNV_KgPMFMb{U7n*>f&c`H`!8oO-YymnC1RaRgHQ z<$vt%8UjjNohEtpO6nsT<=mCjQ#8m`E6MUVgZ&X|lDk*Z^*2=oIp?*53?FpUIGzl+ zBOWt|oHc-%k}xRC;)T00=V&97F=yl_xw!-HsF)-x+E^FCIy>a*pIWE2?*=IFP z?a3?$Wj?0N%mG^U-Q=^dshMU5OYO8GFmCH=8aAW}%)buN&trCRW!1L8WEdZ-Pa-!~ zw~p*~E-Rk4$P3qyJsNT}j{0(3k#Qo*O6UClWWq}z6Vwh9QPrCMsl&SX)xIe9yrT zH*fl42e3ErK-GaqK3Y?|sbNI1UN6_Lr3jqF-nG>GswOCBCQCq_lovZKN?)kQ%O2_yl`vAq_-_6kYu1|c|`OJ_rrF6CJEfF(J@2nD0ZgmB2#4M|m2`V@y z+udg3mK!J${JSF846dG4((IpAazrn)OyF~^u@MTfQ^#i%-?(g8Y3Sp~pwr4lw>8X{ zUv~(pF0Q|>47lILIig#zD$Bwz9X`fJBxepNRb(v22f7I@MxJBAGF*kRGr4UI*%|ln zbrmLz)ge>AXNStkg)$(*OOh;iU}q9 zcAwF5wY=Rv3;GMk=-L76Q3I$l;W&58<*$;(8f$#4=U_u6U8gSe>!{!~sZi0T z4h_N54tmrSWCENGK`x!PsghHpRwT5^S&|4fZ5W`{Si)8>f^z73%7`veSWbl)b_LzT zFb5xgTq?2J4msS;1fNXP=HHdMY`)0i#Fe>Lff+_LwtDai)3SnWNDf~OgUBz=MX2js zqQS6xsA#>A{RV=(P%!f3UnvkRz~ySB$xxVC$TNV#S|n(5#&Gfpyg}GELU_n69w?8p zE#XmW&^k5H+K_2YakB3)*6C(uGW!J#0u6islZ}Mdi;njfwD$n*Hi)KcyT1MIE&fM>;{IYsTt%yUIR&r*YwS>RAB3j`KW8&usxz>awm zyA+{;YGDo|HK}akTzX zxn_o$YAn&&pGKkWNK8Oo`(_ORY-V?Yb|dESUc1pM0#(vb145_RX;bVB=sjngX&}Q9 zBSCcrXiGyZWN0cc(gi2Yr zneuVV>Lr^u(>X=^l3ipi31stA6R4PfC$InjJjo1 z_QH5Tg@*{umoL6f>HX)YvN-x``%>CmKCTWa?da-GlTx88BX^S zV(mDyy?w?~G10VC*s>I~3fWm0R~;O`2{m`-NVCt*g;$0H<+#zA-Hyju6y$6!-;E1$ zgInmJ@HM%jrkLzf`0!eXeX-R>nbHk(9{%+(5== z!uop%zCJ?x5*Y`)34EFHna(A1u&N?>id747@*!b9U(kfRT`GF(RB{-uQJ;aH9P zR|toOlS=0tuY+6eW*nEkS~|~{ud-q}qLzB5D^+X18>)msmeo?$?-TJ>B~*2I)lhn7 z{L5$j8!qM4QH&pbidOiP@agx$6QK^GD}&!^7hQ`yT1#Vxays~b++rNu4NhHz_@eR4 zjDNSB_XaA2C*4z4^wxgSY-v0l!fNoMj4#3MGUC4LMp@wa8h$XWpq zp$)S4-{|~k6+ZZ`kH38HPZYXNiCm4?c*Rlj7OPRghBx9$NIiRO;4k;PHy$$&+u-hM z<`?S=xy>2`aiZ-Enz&6W*2}m}F4m9pTC8i0atEbRzH~EHPx*0WL0>5oR}x&)htO0} z<|m)ZS6mrFV8+i}5m0FnCy`3)m?EaDH87JeEvO1?3ruibzK4(L%DI7*g#F0uPWp^8 zIps~dBE2kh`lVpE-1R1PPl5SxG;+1CS$ek7=pJ09P#dz0z*F~H=2WF}!E}tEC@Mv> z<+N=yOn=ZHmSg)-FF!h_79JZr^OoGW_br+wWT1{-5r2wrsG~GNv;EQ;N47lt4ow$x z;y3;S?<(Er`cW=&%dj~p!vgC(zlEZ*DY#Z?oHfjNNT%$dD9uZ(UbllLq?V%;%#|(9 zX>{Y^c=mfVT6ouhy-9M`m((u_buLt=P(&kRmHf;5bdOjiGwbPc`R-17PArhOe1L+? z0=fJHJkvZW@7+Z=prHHFF1l1a74NZ|&KLf*{v2mh9m(29Xp~&{A-(0P!tvyoeiU(g zRSn`idGI633D41qP=-SBsZsj&P(ie<6z&#zQG~?X0sb~1m2}@wa5lH*SB_({mIpA+ zsUE*{8Xx$$5R6K@da{L?D81z>GJ=Re28wnlazr$!8T+{%zy*94ZkJE4*hAOg+0$RL zWYe;3PqIgY{*eKbQuW^|gc(>38ubPuP_;u3Ssz*jP>&e^)Wd(eC4Kh<3G=}q0*6S{ ziPiNR0T8Z<{S-NPFM^7^9rb#-PUqI=6;c6tZY0b&V>PY8Zr3j?c9`968$2*Fr|PCJ@l01mM13A)mrdVzrIh2>)x{ z-U?o}y;Z5Ll*~~jq!M#pNyU3w85RIf8L)9W<|g3Ae2CAvnV$+USHW{w=gygKZIK^) zoJJSmE_4Cw&a0gbmB5pO5;PFhlAU-cg6~ud*#z*ggLVDoq#2YRt#>ii{R$Ep9E0LX z!2wL$7VaAy9vBLB6Z}DQJ+^>X8=vcb4s}2AMCCEr%>|f$3|EQg4EO`~f6xuqnGM1Y zp25X9^;HEg8{jT$aj|Zo>)~=Z1jw~;HBl=)d#O*}?sjE^3Gu^3yRx;C9KDxDif80g zd#PARxqUCaFP@N#_R&RX4(=oTFDvqbf~|1&D2 zKl@|ii>~sP&u9W(TDN{i8&Tw+-b81J#qs4$v{HDM_;bQ?aI44)a%ah(;2eqODc5xZw43mQAk2fBintJ`k@)6_yzGUUr_`9&ny2tOizgA^1-j^O?(Y+ zp_V*U*B$Yn>g$(&gBoh3ocax3gO|l0`G(FCw8}57@5o~O=ZjVGyzgkMh%Y<>_a@fK zvyagLG`An4DzQGE{{yY_pkR?0XdI^7K%apL5CWocyPs`1z`dKn5RbMMVPt}X%RzuH~d4_&&ispQBNDI#m+-gp{ z)4V}mXXw|*KQZ*)LhjGgEt!|5pDFXxfa{VpePH&zlc(N#`_0qtoNC^5=gs$;Q}3K+ z-aLKsz0>5}G<~YvAJ8-9gGt!wztgbxrgZ%^*)OPP>{})("period_emission", tiny_emission)) ); + + const int64_t credited = pay_outstanding_total() - outstanding_before; + BOOST_REQUIRE_MESSAGE( credited <= tiny_emission, + "payepoch credited " << credited << " from a pool of " << tiny_emission + << " -- rows excluded from the divisor were priced anyway" ); +} FC_LOG_AND_RETHROW() + BOOST_FIXTURE_TEST_CASE( active_producers_are_paid_per_block, sysio_emissions_tester ) try { create_t5_holding_accounts(); setup_producers(3); @@ -5981,6 +6011,20 @@ struct producer_score_tester : public producer_eligibility_tester { return info["is_demoted"].as(); } + /// Scheduled rounds observed inside the producer's current miss window. + uint32_t rounds_in_window_of(account_name producer) { + auto info = get_producer_info(producer); + BOOST_REQUIRE_MESSAGE(!info.is_null(), "no producers row for " << producer.to_string()); + return info["rounds_in_window"].as(); + } + + /// How many of those rounds went unproduced or came up short. + uint32_t missed_rounds_in_window_of(account_name producer) { + auto info = get_producer_info(producer); + BOOST_REQUIRE_MESSAGE(!info.is_null(), "no producers row for " << producer.to_string()); + return info["missed_rounds_in_window"].as(); + } + /// The sysio.system global singleton, which carries the rescore cursor. fc::variant get_global_state() { auto data = get_row_by_account(config::system_account_name, config::system_account_name, @@ -6501,6 +6545,20 @@ BOOST_FIXTURE_TEST_CASE( setscorecfg_rejects_a_configuration_that_inverts_the_ra BOOST_REQUIRE_EQUAL( wasm_assert_msg("max_consecutive_missed_rounds is unreasonably large"), set_score_config(10'000, 10'000, 10'000, /*max_consecutive=*/1'000'001) ); + + // A round holds 12 slots, so a threshold above it can never be met: every fully produced round + // would count as SHORT and the rate gate would demote the entire network. + BOOST_REQUIRE_EQUAL( + wasm_assert_msg("min_blocks_per_round cannot exceed the round size"), + set_score_config(10'000, 10'000, 10'000, 3, 1, 24ULL * 60 * 60 * 1000, 5, + /*min_blocks_per_round=*/13) ); + // The boundary itself is accepted, as is the disabled spelling. + BOOST_REQUIRE_EQUAL( success(), + set_score_config(10'000, 10'000, 10'000, 3, 1, 24ULL * 60 * 60 * 1000, 5, + /*min_blocks_per_round=*/12) ); + BOOST_REQUIRE_EQUAL( success(), + set_score_config(10'000, 10'000, 10'000, 3, 1, 24ULL * 60 * 60 * 1000, 5, + /*min_blocks_per_round=*/0) ); } FC_LOG_AND_RETHROW() // `rmvproducer` performs the same deactivation `unregprod` does, and was the one is_active path @@ -6565,6 +6623,73 @@ BOOST_FIXTURE_TEST_CASE( miss_rate_over_the_window_demotes_without_a_consecutive BOOST_REQUIRE_EQUAL( tier_demoted, tier_of(rank_score_of(target)) ); } FC_LOG_AND_RETHROW() +// `regproducer` must not launder the miss RATE. It clears the window only on a genuine +// demoted-to-recovered transition; on an ordinary re-registration the record stands. +// +// This is what stops the cron loop now that short rounds exist. A producer delivering a fraction +// of every round never advances the CONSECUTIVE counter -- a short round feeds the rate gate alone +// -- so if a free, repeatable `regproducer` also wiped the window there would be no surviving +// evidence against it and the rate gate could never fire on anyone. +BOOST_FIXTURE_TEST_CASE( regproducer_does_not_launder_the_miss_window, producer_score_tester ) try { + auto names = setup_ranked_producers(5); + BOOST_REQUIRE_EQUAL( success(), set_score_config( + /*collateral_weight=*/10'000, /*participation_weight=*/10'000, /*snapshot_weight=*/1'000, + /*max_consecutive_missed_rounds=*/2, /*snapshot_target_attestations=*/1, + /*missed_round_window_ms=*/24ULL * 60 * 60 * 1000, /*max_pct_missed_rounds_in_window=*/40) ); + trigger_reschedule(); + wait_for_active_schedule(names[2]); + + const auto target = names[2]; + const auto reregister = [&]() { + BOOST_REQUIRE_EQUAL( success(), push_system_action(target, "regproducer"_n, mvo() + ("producer", target)("producer_key", get_public_key(target, "active"))("url", "")("location", 0)) ); + produce_blocks(1); + }; + const auto miss_then_produce = [&]() { + skip_round_of(target); + produce_blocks(names.size() * slots_per_producer); + }; + + // Alternate misses with produced rounds, re-registering after every one. The producer stays + // ACTIVE and undemoted throughout, so each call is an ordinary upsert -- exactly the loop an + // operator could cron. + miss_then_produce(); + reregister(); + BOOST_REQUIRE( !demoted(target) ); + BOOST_REQUIRE_MESSAGE( rounds_in_window_of(target) > 0u, + "an ordinary regproducer erased the window: the rate gate can never fire again" ); + + miss_then_produce(); + reregister(); + skip_round_of(target); + + BOOST_REQUIRE_MESSAGE( demoted(target), + "the rate gate must still fire through a cron loop of re-registrations" ); + BOOST_REQUIRE_MESSAGE( missed_rounds_of(target) < 2u, + "the consecutive gate must not be what demoted it -- this test would prove nothing" ); +} FC_LOG_AND_RETHROW() + +// The reset that IS kept: a demoted producer observes no rounds (it is not scheduled), so without +// clearing the window on the way back its recorded rate could never improve and the gate would be +// a life sentence. +BOOST_FIXTURE_TEST_CASE( regproducer_clears_the_window_of_a_demoted_producer, producer_score_tester ) try { + auto names = setup_ranked_producers(5); + trigger_reschedule(); + wait_for_active_schedule(names[2]); + + const auto target = names[2]; + for (uint32_t miss = 0; miss < 3; ++miss) skip_round_of(target); + BOOST_REQUIRE( demoted(target) ); + BOOST_REQUIRE_GT( rounds_in_window_of(target), 0u ); + + BOOST_REQUIRE_EQUAL( success(), push_system_action(target, "regproducer"_n, mvo() + ("producer", target)("producer_key", get_public_key(target, "active"))("url", "")("location", 0)) ); + produce_blocks(1); + + BOOST_REQUIRE( !demoted(target) ); + BOOST_REQUIRE_EQUAL( 0u, missed_rounds_in_window_of(target) ); +} FC_LOG_AND_RETHROW() + // The OTHER door back, and the one no operator has to walk through: a demoted producer that is // still in the active schedule recovers by producing a block. // diff --git a/docs/becoming-a-block-producer.md b/docs/becoming-a-block-producer.md index 6cc36d7b6c..fa1ff18f48 100644 --- a/docs/becoming-a-block-producer.md +++ b/docs/becoming-a-block-producer.md @@ -119,7 +119,14 @@ producer it cannot schedule as one that holds no rank at all. And the collateral governance setting, so it can be raised after you have bonded: if that happens your registration stays `ACTIVE` and nothing is taken from you, but you hold no rank until you top up to the new minimum. A raised minimum reaches the table through a background rescore rather than all at once, -and the schedule is not rebuilt until that finishes. +and the schedule keeps being rebuilt while that runs — ranking converges over a few rounds rather +than switching in one step, so expect a short window where positions reflect a mix of the old and +new minimum. + +A **lowered** minimum works the other way and is worth knowing about: it does not promote you on +its own. If you fell below the bar and governance later lowers it under your bond, your operator +status is only re-evaluated when your balance next moves — so make any deposit, however small, to +be picked back up. ## How your rank is scored @@ -167,11 +174,16 @@ and that pay stays in the treasury rather than being handed to whoever did produ Positions 22 and beyond, up to a configured end rank, are **standbys**. They draw a retainer from a separate slice of the pool, decaying linearly with position, so the network keeps a ready bench. -Nothing is ever forfeited. If you are not payable when a payout runs, whether parked, demoted, or -temporarily under-collateralized, your block count is held rather than cleared, and it is paid at -the first payout after you are payable again. Unregistering right after producing and +Blocks you have produced are not forfeited. If you are not payable when a payout runs — parked, +demoted, or temporarily under-collateralized — your block count is held rather than cleared, and it +is paid at the first payout after you are payable again. Unregistering right after producing and re-registering before your next round costs you nothing. +The one bound worth stating: a payout walks the ranking from the top and stops after a fixed number +of rows, far below which no producer is paid anything anyway. Settling held blocks therefore +requires you to be back within that reach, which is roughly twenty times the paid band — so in +practice it means being a ranked producer again, not a specific position. + Claim what you have earned with `claimpay`. ## Staying in the schedule From 991f95563320b3fafa716b98b124070ae6766b88 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Mon, 7 Sep 2026 11:48:22 -0500 Subject: [PATCH 15/16] test(sysio.system): cover the parked-producer credit gate and slot accrual (WIRE-367) Two fixes from the review round landed without tests of their own. `a_parked_producer_earns_no_snapshot_credit` pins the predicate: `unregprod` parks a row by clearing `is_active` and letting the rescore sink it by TIER, so `is_demoted` stays false and the old gate credited every parked producer. The test parks one, keeps it voting into a quorum, and asserts it earns nothing while the still-schedulable producer is credited. `nominal_slots_accrue_at_each_epochs_own_duration` accrues one 60s epoch, doubles the duration, accrues a second, and asserts the accumulator holds 120 + 240 slots rather than the 240 x 2 the old payout-time formula produced. Change-Id: Ic6c61924717bf234ce2c1abb9aeded4bdef39496 --- contracts/tests/emissions_tests.cpp | 40 +++++++++++++++++++ .../tests/sysio.snapshot_attest_tests.cpp | 35 ++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/contracts/tests/emissions_tests.cpp b/contracts/tests/emissions_tests.cpp index f0df11ab71..786dc22e3a 100644 --- a/contracts/tests/emissions_tests.cpp +++ b/contracts/tests/emissions_tests.cpp @@ -835,6 +835,13 @@ class sysio_emissions_tester : public tester { abi_serializer::create_yield_function(abi_serializer_max_time)); } + /// Block slots accumulated for the OPEN pay period, one accrual at a time. + uint64_t pending_nominal_slots() { + auto state = get_t5_state(); + BOOST_REQUIRE_MESSAGE(!state.is_null(), "t5 state not initialized"); + return state["pending_nominal_slots"].as_uint64(); + } + // Reads the audit-log row keyed by sysio.epoch's current_epoch_index // (t5_state::last_epoch_index at write time). Callers pass the sysio.epoch // index they want to inspect. @@ -3415,6 +3422,39 @@ BOOST_FIXTURE_TEST_CASE( pay_rounding_to_zero_does_not_consume_the_blocks, sysio // rows are payable and removes the rest from the divisor; the second only PRICES that set. Paying // a row the first pass excluded would pay for blocks the divisor no longer counts, and the claims // credited could then exceed the pool they were drawn from -- real money the treasury never had. +// The divisor has to be built the way the POOL is: per epoch, at the duration in force for that +// epoch. Computing it at payout from the CURRENT duration applies today's value to epochs that ran +// under a different one, so a period spanning a duration change is mis-sized -- a 60s epoch (120 +// slots) followed by a 120s epoch (240 slots) is 360 slots but would be computed as 480, paying +// only 75% of the active pool under full production. +BOOST_FIXTURE_TEST_CASE( nominal_slots_accrue_at_each_epochs_own_duration, sysio_emissions_tester ) try { + create_t5_holding_accounts(); + setup_producers(3); + wait_for_producer_schedule(); + + const uint32_t start = head_secs() - ONE_EPOCH - 1; + BOOST_REQUIRE_EQUAL( success(), initt5( config::system_account_name, tpsec(start) ) ); + + // One epoch accrues at 60s ... + BOOST_REQUIRE_EQUAL( success(), push_system_action(EPOCH, "accrueepoch"_n, mvo() + ("epoch_index", 1)("batch_group_index", 0)("per_epoch_emission", int64_t{1'000'000})) ); + const uint64_t after_first = pending_nominal_slots(); + BOOST_REQUIRE_EQUAL( test_nominal_slots(T_EPOCH_SECS), after_first ); + + // ... then the duration doubles and a second epoch accrues at the NEW value. + BOOST_REQUIRE_EQUAL( success(), init_epoch_state(T_EPOCH_SECS * 2) ); + BOOST_REQUIRE_EQUAL( success(), push_system_action(EPOCH, "accrueepoch"_n, mvo() + ("epoch_index", 2)("batch_group_index", 0)("per_epoch_emission", int64_t{1'000'000})) ); + + const uint64_t accumulated = pending_nominal_slots(); + BOOST_REQUIRE_EQUAL( test_nominal_slots(T_EPOCH_SECS) + test_nominal_slots(T_EPOCH_SECS * 2), + accumulated ); + // The old formula -- current duration times the epoch count -- would have produced this + // instead, a third too many, and paid producers proportionally less. + BOOST_REQUIRE_MESSAGE( accumulated < test_nominal_slots(T_EPOCH_SECS * 2) * 2, + "the accumulator is applying the current duration to every accrued epoch" ); +} FC_LOG_AND_RETHROW() + BOOST_FIXTURE_TEST_CASE( a_tiny_pool_never_credits_more_than_it_holds, sysio_emissions_tester ) try { create_t5_holding_accounts(); setup_producers(3); diff --git a/contracts/tests/sysio.snapshot_attest_tests.cpp b/contracts/tests/sysio.snapshot_attest_tests.cpp index 8e034f5750..a4fcab6f6f 100644 --- a/contracts/tests/sysio.snapshot_attest_tests.cpp +++ b/contracts/tests/sysio.snapshot_attest_tests.cpp @@ -698,6 +698,41 @@ BOOST_FIXTURE_TEST_CASE(leaving_the_walk_consumes_the_snapshot_credit, snapshot_ BOOST_REQUIRE_EQUAL(uncredited, key_of("producer1"_n)); } FC_LOG_AND_RETHROW() } +// A producer OUTSIDE the pay walk earns no credit, and `is_demoted` is not the test for that. +// `unregprod` parks a row by clearing `is_active` and letting the rescore sink it by TIER -- the +// flag stays false throughout -- and losing a finalizer key or opreg eligibility does the same. +// The counter rates the CURRENT pay period and only `payepoch` resets it, and `payepoch` stops at +// the demoted tier, so credit handed to a parked row is never cleared: it accumulates for as long +// as the producer stays parked and re-enters at full marks, outranking producers that actually +// served the period it comes back into. +BOOST_FIXTURE_TEST_CASE(a_parked_producer_earns_no_snapshot_credit, snapshot_voting_tester) { try { + BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer1"_n, "snapprov1"_n)); + BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer2"_n, "snapprov2"_n)); + BOOST_REQUIRE_EQUAL(success(), setsnpcfg(2)); + produce_blocks(); + + const auto attestations_of = [this](account_name producer) { + return get_producer_info(producer)["snapshot_attestations"].as(); + }; + + // Park producer1. Its provider mapping survives -- the prune only runs when the table is full + // -- so it can keep voting, and its `is_demoted` flag is still false. + BOOST_REQUIRE_EQUAL(success(), unregproducer("producer1"_n)); + BOOST_REQUIRE_EQUAL(false, get_producer_info("producer1"_n)["is_demoted"].as()); + BOOST_REQUIRE_EQUAL(0u, attestations_of("producer1"_n)); + + const auto block_num = vote_block_num(); + BOOST_REQUIRE_EQUAL(success(), votesnaphash("snapprov1"_n, make_block_id(block_num), make_snap_hash(7))); + BOOST_REQUIRE_EQUAL(success(), votesnaphash("snapprov2"_n, make_block_id(block_num), make_snap_hash(7))); + + // The quorum was reached and the still-schedulable producer was credited ... + BOOST_REQUIRE_EQUAL(1u, attestations_of("producer2"_n)); + // ... while the parked one earned nothing, despite its vote counting toward that quorum. + BOOST_REQUIRE_MESSAGE(attestations_of("producer1"_n) == 0u, + "a parked producer was credited: the gate is testing the is_demoted flag, which unregprod " + "never sets, rather than live schedulability"); +} FC_LOG_AND_RETHROW() } + BOOST_FIXTURE_TEST_CASE(votesnaphash_same_tuple_retry_is_idempotent, snapshot_voting_tester) { try { // Need 2 providers, min_providers=2 so single vote won't attest and purge BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer1"_n, "snapprov1"_n)); From 007b6bbaddf8c7d74f29b5052f403ac1238ae5e8 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Mon, 7 Sep 2026 13:44:05 -0500 Subject: [PATCH 16/16] fix(sysio.system): roll the miss window, and close the round-three residuals (WIRE-367) The miss window was a TUMBLING bucket, not the rolling one the config, the guide and the PR description all promised -- and `sysio.opreg::termcheck`, which this was said to mirror, is genuinely rolling: it keeps per-delivery rows and ages them out. A single bucket emptied at its duration reads under the limit in each half while the trailing window does not, and `miss_window_open_ms` is on-chain, so the boundary can be targeted deliberately: `GGGSS | SSGGG` is 40% in both buckets and 80% across the join. Short rounds never touch the consecutive gate, so nothing else would catch it. `producer_info` now carries the previous bucket, `roll_miss_window` carries the current one into it (clearing both past two full durations, so a long absence still starts fresh), and `weighted_miss_window` counts the previous bucket in proportion to how much of it the trailing window still covers. `breaches_miss_rate` is the one place that weighting is applied, so every gate reads the same number. An estimate rather than a per-observation truth -- the trade for O(1) state on a path `onblock` runs, where opreg's log shape is affordable only because it sits on a once-per-epoch path. `regproducer` answers the CONSECUTIVE gate and nothing else. Gating the window reset on `is_demoted` alone was still a loop: a producer demoted on rate can re-register immediately, before any rebuild drops it, and repeating that after every demotion made chronic short-round delivery unpunishable. The window is now rolled only when it has genuinely LAPSED, and the flag re-derived from what remains -- so a rate demotion is served out without a slot, while time still heals it and it can never become a lockout. Snapshot credit gates on the pay walk's own pair: tier AND live schedulability. Neither alone works, and the previous change traded one hole for the other -- `is_demoted` misses a parked row (`unregprod` sinks by tier, leaving the flag false), while `is_schedulable` misses a demoted one (it reads the row, opreg status and finalizer key, never the tier). The credit is also consumed inside `rescore`, on the transition OUT of the walk. Demotion and `unregprod` clear it at their own sites, but `delfinkey`, an opreg status change and a raised collateral minimum all sink a row through `compute` alone, and `payepoch` stops at that tier -- so a credit leaving by one of those doors was never reset and returned at full marks. One direction only: leaving carries no ambiguity, where inferring on the way IN cannot tell a stale credit from one earned in the same block (the reason the earlier re-entry heuristic was removed). Tests. The over-distribution regression test could not observe the bug it was written for: at `compute_bps` 4000 and `producer_bps` 7000 an emission of 1 left `active_pool` at 0, so every row credited nothing with or without the fix. Rebuilt at an emission of 5 -- the smallest that survives the splits to a pool of exactly 1 -- with enough rotations that each producer holds a full period of blocks, plus a precondition assert so it fails loudly rather than silently passing if the divisor cannot collapse. It and the credit-gate test were both verified to FAIL with their fixes reverted. `regproducer_clears_the_window_of_a_demoted_producer` asserted the rule this change replaces; it is now `regproducer_rolls_the_window_only_once_it_has_lapsed` and pins both halves -- the record survives an un-lapsed re-registration, and a lapsed window rolls. The second half is new coverage: it is what keeps a rate demotion from being permanent. Change-Id: Ifbff320eda4151a38d13b5345fe60dbc31b1695f --- .../include/sysio.system/producer_rank.hpp | 45 ++++- .../include/sysio.system/producer_score.hpp | 28 ++- .../include/sysio.system/sysio.system.hpp | 52 +++++- contracts/sysio.system/src/producer_pay.cpp | 35 ++-- contracts/sysio.system/src/ranking.cpp | 40 +++-- .../sysio.system/src/snapshot_attest.cpp | 18 +- contracts/sysio.system/sysio.system.abi | 8 + contracts/sysio.system/sysio.system.wasm | Bin 206737 -> 206420 bytes contracts/tests/emissions_tests.cpp | 169 ++++++++++++++++-- .../tests/sysio.snapshot_attest_tests.cpp | 146 +++++++++++++++ 10 files changed, 477 insertions(+), 64 deletions(-) diff --git a/contracts/sysio.system/include/sysio.system/producer_rank.hpp b/contracts/sysio.system/include/sysio.system/producer_rank.hpp index 32fbaa460a..d27e9de0b0 100644 --- a/contracts/sysio.system/include/sysio.system/producer_rank.hpp +++ b/contracts/sysio.system/include/sysio.system/producer_rank.hpp @@ -276,7 +276,7 @@ namespace sysiosystem { * @param weights the live score configuration. * @return true iff the rate gate is armed and exceeded. */ - inline bool exceeds_miss_rate(uint32_t rounds, uint32_t missed, + inline bool exceeds_miss_rate(uint64_t rounds, uint64_t missed, const producer_score_config& weights) { if (weights.max_pct_missed_rounds_in_window == 0) return false; if (rounds < rate_gate_minimum_sample(weights)) return false; @@ -284,6 +284,49 @@ namespace sysiosystem { return (missed * 100u / rounds) > weights.max_pct_missed_rounds_in_window; } + /// Rounds and misses the trailing window covers, from the two-bucket approximation. + struct miss_window_view { + uint64_t rounds = 0; + uint64_t missed = 0; + }; + + /** + * Weight the previous bucket by the fraction of it the trailing window still covers. + * + * One bucket emptied at its duration is a TUMBLING window, and a tumbling window is + * evadable: with a 5-round minimum and a 40% limit, `GGGSS | SSGGG` reads 40% in each + * bucket while the trailing five rounds are `GSSSS` -- 80%. `miss_window_open_ms` is + * on-chain, so the boundary can be targeted deliberately, and short rounds do not advance + * the consecutive gate that would otherwise catch it. + * + * Counting the previous bucket in proportion to how much of it remains inside the window + * closes that: at elapsed = 0 it counts in full, at elapsed = window it counts for nothing. + * The result is an estimate rather than a per-observation truth, which is the trade for + * O(1) state on a path `onblock` runs. + * + * @param rounds scheduled rounds observed in the CURRENT bucket. + * @param missed how many of them went unproduced or came up short. + * @param prev_rounds the previous bucket's rounds. + * @param prev_missed the previous bucket's misses. + * @param elapsed_ms how long the current bucket has been open. + * @param weights the live score configuration. + * @return the weighted view the rate gate is evaluated against. + */ + inline miss_window_view weighted_miss_window(uint32_t rounds, uint32_t missed, + uint32_t prev_rounds, uint32_t prev_missed, + uint64_t elapsed_ms, + const producer_score_config& weights) { + const uint64_t window = weights.missed_round_window_ms; + if (window == 0 || elapsed_ms >= window) { + return { rounds, missed }; + } + const uint64_t remaining = window - elapsed_ms; + return { + static_cast(rounds) + static_cast(prev_rounds) * remaining / window, + static_cast(missed) + static_cast(prev_missed) * remaining / window + }; + } + /** * Whether a producer's recorded misses warrant demotion, under BOTH gates. * diff --git a/contracts/sysio.system/include/sysio.system/producer_score.hpp b/contracts/sysio.system/include/sysio.system/producer_score.hpp index 2789a66fa2..a9b48b248d 100644 --- a/contracts/sysio.system/include/sysio.system/producer_score.hpp +++ b/contracts/sysio.system/include/sysio.system/producer_score.hpp @@ -325,13 +325,29 @@ namespace sysiosystem { }, weights); - // The period's snapshot credit is cleared at the EVENTS that drop a producer out of the - // pay walk -- demotion and `unregprod` -- not inferred here from a tier change. Inferring - // it was wrong: a row can be re-entering and freshly credited in the same block, and a - // tier comparison cannot tell that credit apart from a stale one, so it consumed credits - // that had just been earned. if (score == info.rank_score) return; // no index move needed - producers.modify(same_payer, key, [&](auto& row) { row.rank_score = score; }); + + // Consume the period's snapshot credit on the way OUT of the pay walk, whichever event + // caused it. Demotion and `unregprod` clear it at their own sites, but they are not the + // only exits: deleting the last finalizer key, losing ACTIVE producer status in opreg, + // and falling under a RAISED collateral minimum all sink the row here and only here. + // `payepoch` stops at the demoted tier, so a credit carried out through one of those + // doors is never reset and reappears at full marks on the way back. + // + // ONE DIRECTION ONLY, and that is the whole difference from the re-entry heuristic this + // replaces. That version tried to infer a stale credit from a tier change on the way IN, + // where a row may be re-entering and freshly credited in the same block and the tier + // cannot tell the two apart -- so it consumed credit that had just been earned. Leaving + // the walk carries no such ambiguity: whatever the row holds belongs to a period it is + // no longer in. + const bool was_in_walk = tier_of(info.rank_score) != producer_tier::demoted; + const bool now_in_walk = tier_of(score) != producer_tier::demoted; + const bool left_the_walk = was_in_walk && !now_in_walk; + + producers.modify(same_payer, key, [&](auto& row) { + row.rank_score = score; + if (left_the_walk) row.snapshot_attestations = 0; + }); } } // namespace producer_rank diff --git a/contracts/sysio.system/include/sysio.system/sysio.system.hpp b/contracts/sysio.system/include/sysio.system/sysio.system.hpp index ba39552c92..116c19044e 100644 --- a/contracts/sysio.system/include/sysio.system/sysio.system.hpp +++ b/contracts/sysio.system/include/sysio.system/sysio.system.hpp @@ -206,6 +206,20 @@ namespace sysiosystem { /// window itself -- the resurrection CertiK flagged as WNS-47, designed out rather than /// patched. uint64_t miss_window_open_ms = 0; + /// The bucket BEFORE the current one, and the reason the window is rolling rather than + /// tumbling. + /// + /// A single bucket emptied when its duration elapses is a TUMBLING window: each bucket can + /// read under the limit while the trailing window does not, and `miss_window_open_ms` is + /// on-chain, so an operator can straddle the boundary deliberately and hold roughly twice + /// the configured rate forever. Keeping the previous bucket and counting the fraction of it + /// the trailing window still covers is the standard approximation -- exact enough to close + /// that gap, and O(1), unlike the per-observation log `sysio.opreg` can afford on a + /// once-per-epoch path but `onblock` cannot. + /// + /// DECLARED LAST, matching the tail of SYSLIB_SERIALIZE below. + uint32_t prev_rounds_in_window = 0; + uint32_t prev_missed_rounds_in_window = 0; uint64_t by_rank_score()const { return rank_score; } bool active()const { return is_active; } @@ -222,6 +236,26 @@ namespace sysiosystem { /// SERVICE RATING -- unlike `unpaid_blocks`, which is an earned debt and is deliberately /// kept. Clearing on the false->true edge only is what makes it idempotent: re-deciding /// "still demoted" must not wipe a credit earned since. + /// Advance the miss window to the bucket starting at `now_ms`, if the current one has run + /// its full duration. + /// + /// The current bucket becomes the previous one, so the trailing window can still count the + /// part of it that has not aged out. A gap of two full durations or more leaves nothing + /// worth carrying, so both buckets clear -- that is what makes an absence longer than the + /// window start a producer's record fresh rather than greet it with a stale one. + void roll_miss_window(uint64_t now_ms, uint64_t window_ms) { + if (window_ms == 0) return; + if (miss_window_open_ms != 0 && now_ms - miss_window_open_ms < window_ms) return; + + const bool carry = miss_window_open_ms != 0 + && now_ms - miss_window_open_ms < window_ms * 2; + prev_rounds_in_window = carry ? rounds_in_window : 0; + prev_missed_rounds_in_window = carry ? missed_rounds_in_window : 0; + rounds_in_window = 0; + missed_rounds_in_window = 0; + miss_window_open_ms = now_ms; + } + void set_demoted(bool demoted) { if (demoted && !is_demoted) snapshot_attestations = 0; is_demoted = demoted; @@ -233,7 +267,8 @@ namespace sysiosystem { SYSLIB_SERIALIZE( producer_info, (owner)(producer_key)(rank_score)(is_active)(url)(unpaid_blocks)(last_claim_time)(location)(producer_authority) (consecutive_missed_rounds)(is_demoted)(snapshot_attestations) - (rounds_in_window)(missed_rounds_in_window)(miss_window_open_ms) ) + (rounds_in_window)(missed_rounds_in_window)(miss_window_open_ms) + (prev_rounds_in_window)(prev_missed_rounds_in_window) ) }; using producers_table = sysio::kv::table< "producers"_n, producer_key_t, producer_info, @@ -862,7 +897,20 @@ namespace sysiosystem { /// (collateral), onblock (miss counter), and the rescore sweep. void rescore_producer( const name& producer ); - /// Attribute missed rounds to the producers the active schedule skipped, and demote any + /// Whether a producer's TRAILING miss window breaches the rate limit. + /// + /// The one place the two-bucket weighting is applied, so every gate -- the round record, + /// the short-round amendment, the config sweep and re-registration -- reads the same + /// number. Evaluating the current bucket alone is what makes a window tumble. + /// + /// @param producer the producer's row. + /// @param now_ms on-chain time in milliseconds. + /// @param weights the live score configuration. + /// @return true iff the weighted window exceeds the configured percentage. + bool breaches_miss_rate( const producer_info& producer, uint64_t now_ms, + const producer_rank::producer_score_config& weights ) const; + + /// Attribute missed rounds to the producers the active schedule skipped, and demote any /// that crossed the threshold. Runs on every block; see producer_pay.cpp. /// /// @param current_producer the producer of the block being processed. diff --git a/contracts/sysio.system/src/producer_pay.cpp b/contracts/sysio.system/src/producer_pay.cpp index 19a231ffe8..3824ce35cc 100644 --- a/contracts/sysio.system/src/producer_pay.cpp +++ b/contracts/sysio.system/src/producer_pay.cpp @@ -151,6 +151,18 @@ namespace sysiosystem { }); } + bool system_contract::breaches_miss_rate( const producer_info& producer, uint64_t now_ms, + const producer_rank::producer_score_config& weights ) const { + const uint64_t elapsed = producer.miss_window_open_ms == 0 + ? weights.missed_round_window_ms // nothing recorded yet: no carry + : now_ms - producer.miss_window_open_ms; + const auto window = producer_rank::weighted_miss_window( + producer.rounds_in_window, producer.missed_rounds_in_window, + producer.prev_rounds_in_window, producer.prev_missed_rounds_in_window, + elapsed, weights ); + return producer_rank::exceeds_miss_rate( window.rounds, window.missed, weights ); + } + void system_contract::record_short_round( const name& producer, uint32_t blocks_delivered, const producer_rank::producer_score_config& weights ) { // Zero disables the check, leaving the whole-window rule alone. @@ -160,6 +172,9 @@ namespace sysiosystem { auto key = producer_key_t{producer.value}; if( !_producers.contains(key) ) return; + const uint64_t now_ms = static_cast( + sysio::current_time_point().time_since_epoch().count() / 1000 ); + _producers.modify( same_payer, key, [&]( auto& p ) { // The window is NOT rolled here. This amends a round `record_round_outcome` already // counted when it began, so rolling would discard the very count being amended. @@ -169,9 +184,7 @@ namespace sysiosystem { // service, not an outage, and the consecutive gate exists to catch the latter fast. A // producer delivering a fraction of every round is caught by the window instead, which // is the "acceptable up to a point" the threshold encodes. - if( !p.is_demoted - && producer_rank::exceeds_miss_rate( p.rounds_in_window, - p.missed_rounds_in_window, weights ) ) { + if( !p.is_demoted && breaches_miss_rate( p, now_ms, weights ) ) { p.set_demoted( true ); } }); @@ -206,12 +219,7 @@ namespace sysiosystem { // no rounds, so nothing accrued while it was away -- and if it stayed away longer than the // window, its old counts lapse rather than greeting it on return. That is the WNS-47 // shape (stale per-period state resurrecting on re-entry) designed out at the source. - if( p.miss_window_open_ms == 0 - || now_ms - p.miss_window_open_ms >= weights.missed_round_window_ms ) { - p.miss_window_open_ms = now_ms; - p.rounds_in_window = 0; - p.missed_rounds_in_window = 0; - } + p.roll_miss_window( now_ms, weights.missed_round_window_ms ); p.rounds_in_window++; if( !missed ) { @@ -229,8 +237,7 @@ namespace sysiosystem { // one. So the flag is RE-DERIVED from the record rather than forced false. A producer // still holding a slot climbs out by producing until its rate falls back under the // limit; one the schedule has dropped uses `regproducer`, which opens a fresh window. - p.set_demoted( producer_rank::warrants_demotion( 0, p.rounds_in_window, - p.missed_rounds_in_window, weights ) ); + p.set_demoted( breaches_miss_rate( p, now_ms, weights ) ); return; } @@ -241,9 +248,9 @@ namespace sysiosystem { // producing a block while still in the active schedule -- the latter covering the window // between a demotion and the rebuild that acts on it, which the schedule-size floor can // hold open indefinitely. - if( !p.is_demoted - && producer_rank::warrants_demotion( p.consecutive_missed_rounds, p.rounds_in_window, - p.missed_rounds_in_window, weights ) ) { + const bool exceeds_consecutive = weights.max_consecutive_missed_rounds > 0 + && p.consecutive_missed_rounds >= weights.max_consecutive_missed_rounds; + if( !p.is_demoted && ( exceeds_consecutive || breaches_miss_rate( p, now_ms, weights ) ) ) { // `set_demoted` consumes the period's snapshot credit as the row leaves the walk. p.set_demoted( true ); } diff --git a/contracts/sysio.system/src/ranking.cpp b/contracts/sysio.system/src/ranking.cpp index 5b42bf45f6..2417f60ecc 100644 --- a/contracts/sysio.system/src/ranking.cpp +++ b/contracts/sysio.system/src/ranking.cpp @@ -25,6 +25,10 @@ namespace sysiosystem { void system_contract::register_producer( const name& producer, const sysio::block_signing_authority& producer_authority, const std::string& url, uint16_t location ) { const auto ct = current_time_point(); + producer_rank::producer_score_config_t weights_tbl( get_self() ); + const auto weights = weights_tbl.get_or_default( producer_rank::producer_score_config{} ); + const uint64_t now_ms = static_cast( ct.time_since_epoch().count() / 1000 ); + sysio::public_key producer_key{}; std::visit( [&](auto&& auth ) { @@ -72,20 +76,22 @@ namespace sysiosystem { // PRODUCING, so a producer that returns unready is demoted again on its next missed // round; until it produces, the participation factor keeps scoring it accordingly. // - // The miss WINDOW resets ONLY on a genuine demoted-to-recovered transition, never on - // an ordinary re-registration. A demoted producer is not scheduled, so it observes no - // rounds and its recorded rate could never improve on its own -- that is what the - // reset is for. An ACTIVE producer has no such problem, and wiping its window would - // hand it the cron loop the streak was protected from: a producer delivering a - // fraction of every round never advances the CONSECUTIVE counter (a short round feeds - // the rate gate alone), so if `regproducer` also cleared the window there would be no - // surviving evidence against it at all, and the rate gate could never fire. - if( info.is_demoted ) { - info.is_demoted = false; - info.rounds_in_window = 0; - info.missed_rounds_in_window = 0; - info.miss_window_open_ms = 0; - } + // `regproducer` answers the CONSECUTIVE gate and nothing else. That gate asks "are + // you offline right now", and re-supplying a signing key is a real answer to it, so + // a demotion it caused clears outright. + // + // It is NOT an answer to the rate gate, which is a claim about a RECORD. Gating the + // window reset on `is_demoted` alone was still a loop: a producer demoted on rate can + // call this immediately, before any rebuild drops it, and re-registering after every + // demotion made chronic short-round delivery unpunishable -- short rounds never touch + // the consecutive counter, so preserving the streak protected nothing. + // + // So the window is rolled only when it has genuinely LAPSED, and the flag is then + // re-derived from whatever remains. A producer serving out a rate demotion has to + // spend the window without a slot; one whose record has aged out is back immediately. + // Time is what heals it, which is also why this can never become a lockout. + info.roll_miss_window( now_ms, weights.missed_round_window_ms ); + info.is_demoted = breaches_miss_rate( info, now_ms, weights ); }); // The clear above changes the producer's tier, so its sort key is stale until rescored. @@ -124,9 +130,9 @@ namespace sysiosystem { // clears by producing a block while still scheduled, or by `regproducer`. Governance // widening the tolerance is not a pardon for producers already judged under the old one, and // either door back is open to them immediately. - if( !info.is_demoted - && producer_rank::exceeds_miss_rate( info.rounds_in_window, - info.missed_rounds_in_window, weights ) ) { + const uint64_t now_ms = static_cast( + sysio::current_time_point().time_since_epoch().count() / 1000 ); + if( !info.is_demoted && breaches_miss_rate( info, now_ms, weights ) ) { _producers.modify( same_payer, key, []( auto& p ) { p.set_demoted( true ); }); } rescore_producer( producer ); diff --git a/contracts/sysio.system/src/snapshot_attest.cpp b/contracts/sysio.system/src/snapshot_attest.cpp index ff369872b0..f307826a8c 100644 --- a/contracts/sysio.system/src/snapshot_attest.cpp +++ b/contracts/sysio.system/src/snapshot_attest.cpp @@ -117,12 +117,18 @@ void credit_snapshot_attestations(name self, const std::vector& voters) { // long as the producer stays out and then re-enters at full marks, outranking producers that // actually served the period it returns into. // - // The test is the LIVE schedulable predicate, not the `is_demoted` flag. `unregprod` parks a - // row by clearing `is_active` and letting the rescore sink it by TIER -- the flag stays - // false throughout -- and losing a finalizer key or opreg eligibility does the same. Gating - // on the flag would credit every one of them. A producer outside the walk that keeps voting - // is not a fault to reject, just service that earns no rating, so this skips silently. - if (!producer_rank::is_schedulable(producers.get(key), finalizers)) continue; + // The test is the SAME PAIR the pay walk applies: the row's tier, and live schedulability. + // Neither alone is enough. `is_demoted` misses a parked row -- `unregprod` clears + // `is_active` and lets the rescore sink it by TIER, so the flag stays false -- while + // `is_schedulable` misses a miss-demoted one, because it tests the active row, opreg status + // and finalizer key and never looks at the tier. A producer outside the walk that keeps + // voting is not a fault to reject, just service that earns no rating, so this skips + // silently. + const auto& row = producers.get(key); + if (producer_rank::tier_of(row.rank_score) == producer_tier::demoted + || !producer_rank::is_schedulable(row, finalizers)) { + continue; + } producers.modify(same_payer, key, [](auto& row) { row.snapshot_attestations++; }); // The credit moved the snapshot factor, so the stored sort key is stale until rescored. // Without this the factor would reach the index only on the next unrelated rescore. diff --git a/contracts/sysio.system/sysio.system.abi b/contracts/sysio.system/sysio.system.abi index ba8b4c3d96..33de8e54b6 100644 --- a/contracts/sysio.system/sysio.system.abi +++ b/contracts/sysio.system/sysio.system.abi @@ -1051,6 +1051,14 @@ { "name": "miss_window_open_ms", "type": "uint64" + }, + { + "name": "prev_rounds_in_window", + "type": "uint32" + }, + { + "name": "prev_missed_rounds_in_window", + "type": "uint32" } ] }, diff --git a/contracts/sysio.system/sysio.system.wasm b/contracts/sysio.system/sysio.system.wasm index 02d4a6059b2650c9a19c7a4f034af723cecc13ae..6fd667d0c78dce52d74195bb49cb96cd1dffac7f 100755 GIT binary patch delta 27577 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 delta 27195 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 nominal) ++rows_over_nominal; + } + BOOST_REQUIRE_MESSAGE( rows_over_nominal >= 2, + "only " << rows_over_nominal << " producers hold a full period of blocks -- the divisor " + "cannot collapse and this test would not exercise the over-distribution path" ); - // A pool of one unit against a full period of slots: every producer's block pay floors to zero - // on the first pass, so every row leaves the divisor. If the second pass then priced them at - // the collapsed divisor, each would be credited a whole unit out of a one-unit pool. - const int64_t tiny_emission = 1; BOOST_REQUIRE_EQUAL( success(), push_system_action(EPOCH, "accrueepoch"_n, mvo() - ("epoch_index", 1)("batch_group_index", 0)("per_epoch_emission", tiny_emission)) ); + ("epoch_index", 1)("batch_group_index", 0)("per_epoch_emission", emission)) ); BOOST_REQUIRE_EQUAL( success(), push_system_action(EPOCH, "payepoch"_n, mvo() - ("epoch_index", 1)("batch_op_groups", vector>{})("period_emission", tiny_emission)) ); + ("epoch_index", 1)("batch_op_groups", vector>{})("period_emission", emission)) ); + // The invariant: a period may never credit more than the pool it was drawn from. Reverting the + // `block_payable` guard credits one unit per row that crossed back over the threshold -- two or + // three units out of a one-unit pool -- and fails here. const int64_t credited = pay_outstanding_total() - outstanding_before; - BOOST_REQUIRE_MESSAGE( credited <= tiny_emission, - "payepoch credited " << credited << " from a pool of " << tiny_emission - << " -- rows excluded from the divisor were priced anyway" ); + BOOST_REQUIRE_MESSAGE( credited <= 1, + "payepoch credited " << credited << " from an active pool of 1 -- rows excluded from the " + "divisor were priced against the collapsed one" ); } FC_LOG_AND_RETHROW() BOOST_FIXTURE_TEST_CASE( active_producers_are_paid_per_block, sysio_emissions_tester ) try { @@ -6058,6 +6081,13 @@ struct producer_score_tester : public producer_eligibility_tester { return info["rounds_in_window"].as(); } + /// The PREVIOUS bucket's rounds — the half of the two-bucket window that ages out. + uint32_t prev_rounds_in_window_of(account_name producer) { + auto info = get_producer_info(producer); + BOOST_REQUIRE_MESSAGE(!info.is_null(), "no producers row for " << producer.to_string()); + return info["prev_rounds_in_window"].as(); + } + /// How many of those rounds went unproduced or came up short. uint32_t missed_rounds_in_window_of(account_name producer) { auto info = get_producer_info(producer); @@ -6709,25 +6739,128 @@ BOOST_FIXTURE_TEST_CASE( regproducer_does_not_launder_the_miss_window, producer_ "the consecutive gate must not be what demoted it -- this test would prove nothing" ); } FC_LOG_AND_RETHROW() -// The reset that IS kept: a demoted producer observes no rounds (it is not scheduled), so without -// clearing the window on the way back its recorded rate could never improve and the gate would be -// a life sentence. -BOOST_FIXTURE_TEST_CASE( regproducer_clears_the_window_of_a_demoted_producer, producer_score_tester ) try { +// N1: a RATE demotion cannot be re-registered away while the producer still holds its slot. +// +// `regproducer` answers the CONSECUTIVE gate -- "are you offline right now" -- and re-supplying a +// signing key is a real answer to that. It is not an answer to a RECORD. Gating the window reset +// on `is_demoted` alone was still a loop: a rate-demoted producer can call this immediately, +// before any rebuild drops it, and short rounds never touch the consecutive counter, so +// preserving the streak protected nothing. +BOOST_FIXTURE_TEST_CASE( regproducer_cannot_clear_a_rate_demotion_on_demand, producer_score_tester ) try { auto names = setup_ranked_producers(5); + BOOST_REQUIRE_EQUAL( success(), set_score_config( + /*collateral_weight=*/10'000, /*participation_weight=*/10'000, /*snapshot_weight=*/1'000, + /*max_consecutive_missed_rounds=*/2, /*snapshot_target_attestations=*/1, + /*missed_round_window_ms=*/24ULL * 60 * 60 * 1000, /*max_pct_missed_rounds_in_window=*/40) ); trigger_reschedule(); wait_for_active_schedule(names[2]); const auto target = names[2]; - for (uint32_t miss = 0; miss < 3; ++miss) skip_round_of(target); + const auto miss_then_produce = [&]() { + skip_round_of(target); + produce_blocks(names.size() * slots_per_producer); + }; + + // Drive the rate gate without ever reaching the consecutive limit. + miss_then_produce(); + miss_then_produce(); + skip_round_of(target); BOOST_REQUIRE( demoted(target) ); - BOOST_REQUIRE_GT( rounds_in_window_of(target), 0u ); + BOOST_REQUIRE_MESSAGE( missed_rounds_of(target) < 2u, + "the consecutive gate demoted it -- this test would prove nothing" ); + // Re-register immediately, with the window nowhere near lapsed. BOOST_REQUIRE_EQUAL( success(), push_system_action(target, "regproducer"_n, mvo() ("producer", target)("producer_key", get_public_key(target, "active"))("url", "")("location", 0)) ); produce_blocks(1); - BOOST_REQUIRE( !demoted(target) ); - BOOST_REQUIRE_EQUAL( 0u, missed_rounds_in_window_of(target) ); + BOOST_REQUIRE_MESSAGE( demoted(target), + "regproducer cleared a RATE demotion on demand: the producer never left the schedule, so " + "re-registering after every demotion makes chronic short-round delivery unpunishable" ); + BOOST_REQUIRE_GT( rounds_in_window_of(target), 0u ); +} FC_LOG_AND_RETHROW() + +// N2: the window ROLLS rather than tumbles. A single bucket emptied at its duration lets an +// operator straddle the boundary -- each bucket reads under the limit while the trailing window +// does not -- and `miss_window_open_ms` is on-chain, so the boundary is targetable. +// +// The two-bucket carry is what closes it: the previous bucket's misses stay visible, weighted by +// how much of it the trailing window still covers. +BOOST_FIXTURE_TEST_CASE( the_miss_window_carries_the_previous_bucket, producer_score_tester ) try { + auto names = setup_ranked_producers(5); + // A window short enough to roll inside the test, with the rate gate armed. + BOOST_REQUIRE_EQUAL( success(), set_score_config( + /*collateral_weight=*/10'000, /*participation_weight=*/10'000, /*snapshot_weight=*/1'000, + /*max_consecutive_missed_rounds=*/2, /*snapshot_target_attestations=*/1, + /*missed_round_window_ms=*/30'000, /*max_pct_missed_rounds_in_window=*/40) ); + trigger_reschedule(); + wait_for_active_schedule(names[2]); + + const auto target = names[2]; + skip_round_of(target); + produce_blocks(names.size() * slots_per_producer); + const uint32_t first_bucket_rounds = rounds_in_window_of(target); + BOOST_REQUIRE_GT( first_bucket_rounds, 0u ); + + // Let the window lapse so the next observed round opens a fresh bucket. + produce_blocks(70); + skip_round_of(target); + + // The bucket rolled ... + BOOST_REQUIRE_MESSAGE( rounds_in_window_of(target) <= first_bucket_rounds, + "the current bucket did not roll" ); + // ... and the prior observations were CARRIED rather than discarded, which is the whole + // difference between a rolling window and a tumbling one. + BOOST_REQUIRE_MESSAGE( prev_rounds_in_window_of(target) > 0u, + "the previous bucket was discarded on roll: the window is tumbling, so a producer can " + "straddle the boundary and hold roughly twice the configured rate indefinitely" ); +} FC_LOG_AND_RETHROW() + +// The window survives an un-lapsed re-registration, and clears once it has actually run out. +// +// `regproducer` answers the CONSECUTIVE gate: re-supplying a signing key says "I am here now", so +// a demotion that gate caused clears outright. It says nothing about a RECORD, so the window is +// rolled only when its full duration has elapsed. Both halves matter -- the first stops a +// demote/re-register loop from laundering the evidence, the second is why a rate demotion can +// never become a life sentence: a demoted producer is unscheduled and observes no rounds, so time +// is the only thing that can clear its record, and this is where time gets to. +BOOST_FIXTURE_TEST_CASE( regproducer_rolls_the_window_only_once_it_has_lapsed, producer_score_tester ) try { + auto names = setup_ranked_producers(5); + // A window short enough to lapse inside the test. + BOOST_REQUIRE_EQUAL( success(), set_score_config( + /*collateral_weight=*/10'000, /*participation_weight=*/10'000, /*snapshot_weight=*/1'000, + /*max_consecutive_missed_rounds=*/3, /*snapshot_target_attestations=*/1, + /*missed_round_window_ms=*/30'000, /*max_pct_missed_rounds_in_window=*/0) ); + trigger_reschedule(); + wait_for_active_schedule(names[2]); + + const auto target = names[2]; + const auto reregister = [&]() { + BOOST_REQUIRE_EQUAL( success(), push_system_action(target, "regproducer"_n, mvo() + ("producer", target)("producer_key", get_public_key(target, "active"))("url", "")("location", 0)) ); + produce_blocks(1); + }; + + for (uint32_t miss = 0; miss < 3; ++miss) skip_round_of(target); + BOOST_REQUIRE( demoted(target) ); + const uint32_t recorded = missed_rounds_in_window_of(target); + BOOST_REQUIRE_GT( recorded, 0u ); + + // Immediately: the CONSECUTIVE demotion clears, the record does not. + reregister(); + BOOST_REQUIRE_MESSAGE( !demoted(target), + "regproducer must clear a demotion the consecutive gate caused" ); + BOOST_REQUIRE_MESSAGE( missed_rounds_in_window_of(target) == recorded, + "the window was wiped by an un-lapsed re-registration: a demote/re-register loop would " + "erase the only evidence a short-round producer ever accrues" ); + + // Once the window has genuinely run out, re-registering rolls it -- the door that keeps a rate + // demotion from being permanent. + produce_blocks(70); + reregister(); + BOOST_REQUIRE_MESSAGE( missed_rounds_in_window_of(target) == 0u, + "a lapsed window was not rolled: a rate-demoted producer observes no rounds, so nothing " + "else could ever clear its record" ); } FC_LOG_AND_RETHROW() // The OTHER door back, and the one no operator has to walk through: a demoted producer that is diff --git a/contracts/tests/sysio.snapshot_attest_tests.cpp b/contracts/tests/sysio.snapshot_attest_tests.cpp index a4fcab6f6f..3205fd71f9 100644 --- a/contracts/tests/sysio.snapshot_attest_tests.cpp +++ b/contracts/tests/sysio.snapshot_attest_tests.cpp @@ -198,6 +198,91 @@ class snapshot_attest_tester : public sysio_system_tester { ("producer", producer)); } + /// Produce until `expected` is in the ACTIVE schedule, or give up. Returns whether it made it. + bool reach_active_schedule(name expected, uint32_t max_blocks = 400) { + for (uint32_t produced = 0; produced < max_blocks; produced += 10) { + for (const auto& entry : control->active_producers().producers) { + if (entry.producer_name == expected) return true; + } + produce_blocks(10); + } + for (const auto& entry : control->active_producers().producers) { + if (entry.producer_name == expected) return true; + } + return false; + } + + /// Advance past `producer`'s entire slot window without it producing, so `onblock` charges it + /// a missed round. Modelled on the emissions suite's helper; the schedule comes from the live + /// active set because miss attribution reads that, not the proposal. + void skip_one_round_of(name producer) { + static constexpr uint32_t slots_per_producer = 12; + std::vector schedule; + for (const auto& entry : control->active_producers().producers) { + schedule.push_back(entry.producer_name); + } + const auto target_it = std::find(schedule.begin(), schedule.end(), producer); + BOOST_REQUIRE_MESSAGE(target_it != schedule.end(), + producer.to_string() << " is not in the active schedule"); + BOOST_REQUIRE_MESSAGE(schedule.size() >= 3, + "skipping a round needs at least three scheduled producers"); + + const size_t target_index = static_cast(std::distance(schedule.begin(), target_it)); + const size_t before_index = (target_index + schedule.size() - 1) % schedule.size(); + const auto index_at = [&](uint32_t slot) { + return (slot % (schedule.size() * slots_per_producer)) / slots_per_producer; + }; + + const uint32_t walk_limit = static_cast(schedule.size() + 1) * slots_per_producer; + uint32_t walked = 0; + while (index_at(control->head().header().timestamp.slot) != before_index) { + produce_blocks(1); + BOOST_REQUIRE_MESSAGE(++walked < walk_limit, + "never reached the window before " << producer.to_string()); + } + + const uint32_t slot = control->head().header().timestamp.slot; + const uint32_t into_window = slot % slots_per_producer; + const uint32_t jump = (slots_per_producer - into_window) + slots_per_producer; + produce_block(fc::milliseconds(int64_t(config::block_interval_ms) * jump)); + BOOST_REQUIRE_MESSAGE(control->head().header().producer != producer, + "the jump landed on " << producer.to_string() << " instead of skipping it"); + } + + /// Delete a producer's registered finalizer key — an exit from the pay walk that neither + /// demotion nor `unregprod` covers, and the one `compute` sinks through `unscored()`. + action_result delete_finalizer_key(name producer) { + auto [privkey, pubkey, pop, sig_provider] = sysio::testing::get_bls_key(producer); + return push_action(producer, "delfinkey"_n, mvo() + ("finalizer_name", producer) + ("finalizer_key", pubkey.to_string())); + } + + /// Drive a producer to a MISS demotion through the consecutive gate, which is the cheapest + /// route to a demoted row: its producer row, opreg status and finalizer key all stay intact, + /// so `is_schedulable` still passes and only the tier says it has left the walk. + void demote_producer(name producer) { + const auto weights = mvo() + ("collateral_weight", uint32_t(10'000)) + ("participation_weight", uint32_t(10'000)) + ("snapshot_weight", uint32_t(1'000)) + ("relay_weight", uint32_t(0)) + ("api_weight", uint32_t(0)) + ("benchmark_weight", uint32_t(0)) + ("max_consecutive_missed_rounds", uint32_t(1)) + ("snapshot_target_attestations", uint32_t(1)) + ("missed_round_window_ms", uint64_t(24ULL * 60 * 60 * 1000)) + ("max_pct_missed_rounds_in_window", uint32_t(0)) + ("min_blocks_per_round", uint32_t(0)); + BOOST_REQUIRE_EQUAL(success(), + push_action(config::system_account_name, "setscorecfg"_n, mvo()("weights", weights))); + produce_blocks(); + // One missed round is enough at a consecutive limit of 1. + skip_one_round_of(producer); + BOOST_REQUIRE_MESSAGE(get_producer_info(producer)["is_demoted"].as(), + producer.to_string() << " was not demoted by the consecutive gate"); + } + /// Vote on a snapshot hash. action_result votesnaphash(name snap_account, const fc::sha256& block_id, const fc::sha256& snapshot_hash) { @@ -733,6 +818,67 @@ BOOST_FIXTURE_TEST_CASE(a_parked_producer_earns_no_snapshot_credit, snapshot_vot "never sets, rather than live schedulability"); } FC_LOG_AND_RETHROW() } +// N3: a MISS-DEMOTED producer earns no credit either, and `is_schedulable` alone does not say so. +// +// That predicate tests the active row, ACTIVE producer status in opreg, and the finalizer key -- +// it never looks at `is_demoted` or the row's tier. A demoted producer passes it while `payepoch` +// stops before its tier, so credit handed to it is never reset and comes back at full marks. The +// gate is the pay walk's own pair: tier plus live schedulability. +BOOST_FIXTURE_TEST_CASE(a_demoted_producer_earns_no_snapshot_credit, snapshot_voting_tester) { try { + BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer1"_n, "snapprov1"_n)); + BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer2"_n, "snapprov2"_n)); + BOOST_REQUIRE_EQUAL(success(), setsnpcfg(2)); + produce_blocks(); + + const auto attestations_of = [this](account_name producer) { + return get_producer_info(producer)["snapshot_attestations"].as(); + }; + + // Demote producer1 directly on the row: this is about the CREDIT gate, not about how the + // demotion was reached. Its row stays active, opreg-eligible and keyed, so `is_schedulable` + // still passes -- which is exactly the hole. + BOOST_REQUIRE_MESSAGE(reach_active_schedule("producer1"_n), + "producer1 never entered the active schedule; a miss cannot be attributed without it"); + demote_producer("producer1"_n); + BOOST_REQUIRE_EQUAL(true, get_producer_info("producer1"_n)["is_demoted"].as()); + BOOST_REQUIRE_EQUAL(0u, attestations_of("producer1"_n)); + + const auto block_num = vote_block_num(); + BOOST_REQUIRE_EQUAL(success(), votesnaphash("snapprov1"_n, make_block_id(block_num), make_snap_hash(11))); + BOOST_REQUIRE_EQUAL(success(), votesnaphash("snapprov2"_n, make_block_id(block_num), make_snap_hash(11))); + + BOOST_REQUIRE_EQUAL(1u, attestations_of("producer2"_n)); + BOOST_REQUIRE_MESSAGE(attestations_of("producer1"_n) == 0u, + "a demoted producer was credited: the gate tests live schedulability alone, which never " + "looks at the tier the pay walk stops at"); +} FC_LOG_AND_RETHROW() } + +// N4: leaving the pay walk by ANY door consumes the period's credit, not just demotion and +// `unregprod`. Deleting the last finalizer key sinks the row through `compute` -> `unscored()`, +// and `payepoch` stops at that tier, so a credit carried out this way would never be reset and +// would reappear the moment the producer re-keys. +BOOST_FIXTURE_TEST_CASE(losing_the_finalizer_key_consumes_the_snapshot_credit, snapshot_voting_tester) { try { + BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer1"_n, "snapprov1"_n)); + BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer2"_n, "snapprov2"_n)); + BOOST_REQUIRE_EQUAL(success(), setsnpcfg(2)); + produce_blocks(); + + const auto attestations_of = [this](account_name producer) { + return get_producer_info(producer)["snapshot_attestations"].as(); + }; + + const auto block_num = vote_block_num(); + BOOST_REQUIRE_EQUAL(success(), votesnaphash("snapprov1"_n, make_block_id(block_num), make_snap_hash(12))); + BOOST_REQUIRE_EQUAL(success(), votesnaphash("snapprov2"_n, make_block_id(block_num), make_snap_hash(12))); + BOOST_REQUIRE_EQUAL(1u, attestations_of("producer1"_n)); + + // Out through a door neither `set_demoted` nor `unregprod` covers. + BOOST_REQUIRE_EQUAL(success(), delete_finalizer_key("producer1"_n)); + BOOST_REQUIRE_MESSAGE(attestations_of("producer1"_n) == 0u, + "the credit survived a finalizer-key exit: payepoch stops at that tier, so it would never " + "be reset and would return at full marks on re-keying"); +} FC_LOG_AND_RETHROW() } + BOOST_FIXTURE_TEST_CASE(votesnaphash_same_tuple_retry_is_idempotent, snapshot_voting_tester) { try { // Need 2 providers, min_providers=2 so single vote won't attest and purge BOOST_REQUIRE_EQUAL(success(), regsnapprov("producer1"_n, "snapprov1"_n));