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 23a5d0d812..cac4b434e0 100755 Binary files a/contracts/sysio.epoch/sysio.epoch.wasm and b/contracts/sysio.epoch/sysio.epoch.wasm differ diff --git a/contracts/sysio.opreg/src/sysio.opreg.cpp b/contracts/sysio.opreg/src/sysio.opreg.cpp index bfdcc527d4..83db7e392b 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); + // System-owned rows bill to the sysio RAM pool, not this contract account (privileged-contract // model, as sysio.token uses): the account stays finite at code+abi size; growth draws from the pool. constexpr name ram_payer = "sysio"_n; @@ -265,6 +272,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); } // --------------------------------------------------------------------------- @@ -352,6 +364,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); } // --------------------------------------------------------------------------- @@ -1044,14 +1062,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) { @@ -1060,6 +1080,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, @@ -1067,6 +1096,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 // --------------------------------------------------------------------------- @@ -1090,6 +1135,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 @@ -1349,22 +1400,31 @@ 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; }); - if (notify_system) { - require_recipient(opreg::SYSTEM_ACCOUNT); - } - } 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 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); + } } } // anonymous namespace @@ -1434,6 +1494,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); } // --------------------------------------------------------------------------- @@ -1578,6 +1640,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 @@ -1661,9 +1725,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 0fee03be7d..949bd74fd8 100755 Binary files a/contracts/sysio.opreg/sysio.opreg.wasm and b/contracts/sysio.opreg/sysio.opreg.wasm differ diff --git a/contracts/sysio.system/include/sysio.system/emissions.hpp b/contracts/sysio.system/include/sysio.system/emissions.hpp index 84290c20cd..7a09910be6 100644 --- a/contracts/sysio.system/include/sysio.system/emissions.hpp +++ b/contracts/sysio.system/include/sysio.system/emissions.hpp @@ -118,6 +118,11 @@ struct [[sysio::table("emitcfg"), sysio::contract("sysio.system")]] emission_con // Producer config uint32_t standby_end_rank; // last standby rank (default 28) + // Share of the producer pool reserved for the standby retainer (basis points, <= 10000). The + // rest funds the per-block rate active producers are paid at. Each standby POSITION + // (22..standby_end_rank) holds a fixed, linearly decaying share of this slice; a vacant + // position's share stays in the treasury rather than flowing to the standbys present. + uint16_t standby_bps; // Audit-log retention. Caps the unbounded `epochlog` table at this many // rows; payepoch prunes head-first after each insert. There is one row per @@ -142,7 +147,7 @@ struct [[sysio::table("emitcfg"), sysio::contract("sysio.system")]] emission_con (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) (pay_cadence_epochs)) }; @@ -224,8 +229,9 @@ struct node_claim_result { // // Rows do not expire: this is earned pay, held until claimed. // -// The recipient set is bounded only CONCURRENTLY (ranks 1..standby_end_rank, plus batch-op group -// members) — not across this table's lifetime. Producers and batch +// The recipient set is bounded only CONCURRENTLY (the schedulable rows one payepoch walk reaches, +// capped at max_rank_walk_rows, plus batch-op group members) — not across this table's lifetime. +// It is NOT `standby_end_rank`: no-forfeiture pays carried blocks well below the standby band. Producers and batch // operators churn, and every departed account that never calls `claimpay` leaves a row billed to // the sysio RAM pool forever, so system-funded claim storage grows with historical participants // rather than with the live set. That is a known, accepted cost here: expiring earned pay is an @@ -330,11 +336,27 @@ struct [[sysio::table("t5state"), sysio::contract("sysio.system")]] t5_state { // visible without breaking the OPP-handler never-throw contract. int64_t capital_shortfall_total = 0; + /// Block slots the open pay period is entitled to, accumulated as each epoch accrues. + /// + /// The DIVISOR has to be built the same way the POOL is. `pending_emission_amount` above adds + /// each epoch's share at the moment that epoch accrues; computing the slot count at payout + /// instead -- current duration times the epoch count -- applies today's duration to epochs that + /// ran under a different one. A period spanning a duration change then mis-sizes the divisor: a + /// 60s epoch (120 slots) followed by a 120s epoch (240 slots) is 360 slots, but is computed as + /// 480, paying 75% of the active pool under full production. + /// + /// Reset with `pending_emission_amount` at each payout. + /// + /// DECLARED LAST, matching the tail of SYSLIB_SERIALIZE 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. + uint64_t pending_nominal_slots = 0; + SYSLIB_SERIALIZE(t5_state, (start_time)(epoch_count)(last_epoch_index) (last_epoch_time)(last_epoch_emission)(total_distributed) (pending_emission_amount)(period_start_epoch)(batch_group_epochs) - (capital_shortfall_total)) + (capital_shortfall_total)(pending_nominal_slots)) }; using t5state_t = sysio::kv::global<"t5state"_n, t5_state>; 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..b86751e313 --- /dev/null +++ b/contracts/sysio.system/include/sysio.system/producer_rank.hpp @@ -0,0 +1,285 @@ +#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; + + /// 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; + + /// 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. + * + * 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]. + */ + 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. 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. + 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. 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 + /// 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; + /// Blocks a producer must deliver within its own round for that round to count as SERVED. + /// + /// This is the whole round verdict. A round is a contiguous run of slots held by one + /// producer; delivering this many or more clears the miss streak, anything less -- + /// including nothing at all -- increments it. Without a threshold, one block of twelve was + /// indistinguishable from twelve, so a producer could hold a scheduled slot indefinitely + /// while delivering a fraction of it. + /// + /// Zero disables the check, leaving only a wholly unproduced round as a miss. 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) + (min_blocks_per_round)) + }; + + /// 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>; + + /** + * Whether a producer's miss streak warrants demotion. + * + * One gate, one question: has it failed to serve its round this many times running? A round + * is served at `min_blocks_per_round` blocks or more, so a producer delivering a fraction of + * every round accrues the streak exactly as one that produces nothing does. + * + * @param consecutive_missed_rounds rounds in a row it failed to serve. + * @param weights the live score configuration. + * @return true iff the streak has reached the configured limit. + */ + inline bool warrants_demotion(uint32_t consecutive_missed_rounds, + const producer_score_config& weights) { + return weights.max_consecutive_missed_rounds > 0 + && consecutive_missed_rounds >= weights.max_consecutive_missed_rounds; + } + + /** + * 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..91adbcdf8c --- /dev/null +++ b/contracts/sysio.system/include/sysio.system/producer_score.hpp @@ -0,0 +1,369 @@ +#pragma once + +#include +#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 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 { + + /** + * The bond a producer has posted on one (chain, token) pair, read from its sysio.opreg row. + * + * 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 the pair's balance, or 0 when the operator holds no row for it. + */ + 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) { + return entry.balance; + } + } + return 0; + } + + /** + * Collateral factor: the MINIMUM, across every (chain, token) pair in `req_prod_collat`, of + * 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 + * 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(); + bool measured = false; + for (const auto& req : cfg.req_prod_collat) { + // A zero minimum constrains nothing, so skip it rather than divide by zero. NOT a + // `check`: this runs under `onblock`, where a throw silently costs the chain block + // counting, miss attribution and every schedule rebuild. setconfig's + // require_positive_min_bond (SEC-22) is what actually prevents the value. + if (req.min_bond == 0) continue; + measured = true; + + // uint128 intermediate is mandatory: a bond runs to 2^62 and multiplying by + // score_scale overflows uint64. + const unsigned __int128 scaled = + 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) + ? composite_max + : static_cast(ratio); + if (bounded < lowest) lowest = bounded; + } + // Every requirement was zero: nothing to measure, same as an empty vector. + return measured ? lowest : 0; + } + + /** + * 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 operator half of the schedulable predicate: an active `producers` row whose owner is + * an ACTIVE OPERATOR_TYPE_PRODUCER operator in sysio.opreg. + * + * This is the predicate PEER DISCOVERY walks (`getpeerkeys`), and it deliberately stops + * short of the finalizer-key requirement. A producer scheduled through `setprods` -- the + * bootstrap window, and every harness that publishes schedules directly -- produces blocks + * and needs the BP gossip mesh whether or not it has registered a finalizer key yet; hiding + * it from `getpeerkeys` cuts a live block producer out of that mesh. Ranking, pay and + * snapshot-provider eligibility walk `is_schedulable` below. + * + * @param producer the producer row under consideration. + * @return true iff the producer is a live PRODUCER operator. + */ + inline bool is_eligible_operator(const producer_info& producer) { + return producer.active() + && is_op_active(producer.owner, sysio::opp::types::OperatorType::OPERATOR_TYPE_PRODUCER); + } + + /** + * The ONE schedulable predicate ranking, pay and snapshot eligibility walk: + * `is_eligible_operator` plus an active finalizer key. + * + * `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 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. + */ + /** + * The producer's finalizer row if it holds an active key, else nullopt. + * + * One table read. Callers that need the ROW (the schedule rebuild, which proposes it as a + * finalizer) use this instead of testing `is_schedulable` and fetching again. + * + * @param producer the producer's account name. + * @param finalizers the finalizers table. + * @return the active finalizer row, or nullopt. + */ + inline std::optional active_finalizer(const sysio::name& producer, + finalizers_table& finalizers) { + auto row = finalizers.try_get(finalizer_key_t{producer.value}); + if (!row || row->active_key_binary.empty()) return std::nullopt; + return row; + } + + inline bool is_schedulable(const producer_info& producer, finalizers_table& finalizers) { + return is_eligible_operator(producer) && active_finalizer(producer.owner, finalizers).has_value(); + } + + /** + * 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 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. + 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& 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 + // `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(); + + // 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(); + const auto op = ops.get(op_key); + if (op.status != sysio::opp::types::OperatorStatus::OPERATOR_STATUS_ACTIVE + || op.type != sysio::opp::types::OperatorType::OPERATOR_TYPE_PRODUCER) { + return unscored(); + } + + 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 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 + // 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_ratio, 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); + } + + /** + * The pay period now open, which is what a snapshot credit is stamped against. + * + * @param self the system account. + * @return the open period's start epoch, or 0 before T5 emissions are initialised. + */ + inline uint32_t current_pay_period(const sysio::name& self) { + emissions::t5state_t t5s(self); + return t5s.exists() ? t5s.get().period_start_epoch : 0; + } + + /** + * Recompute one producer's packed `rank_score` from its live standing and store it if it + * moved. + * + * The ONE write path for the key. Every event that moves a scoring input ends here: a + * collateral change (via the opreg notification), a missed or produced round, a snapshot + * attestation credit, the pay-period counter reset, and the rescore sweep a weight or + * collateral-minimum change opens. A factor whose event does not reach this function never + * reaches the index. + * + * @param self the sysio.system contract account. + * @param producers the producers table. + * @param producer the producer to rescore; a name with no row is ignored. + */ + inline void rescore(const sysio::name& self, producers_table& producers, const sysio::name& producer) { + const auto key = producer_key_t{producer.value}; + if (!producers.contains(key)) return; + + 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( + self, + producer, + score_inputs{ + .is_active = info.active(), + .is_demoted = info.is_demoted, + .consecutive_missed_rounds = info.consecutive_missed_rounds, + // Stale credit does not count -- see `producer_info::snapshot_period`. + .snapshot_attestations = info.snapshot_period == current_pay_period(self) + ? info.snapshot_attestations : 0 + }, + weights); + + if (score == info.rank_score) return; // no index move needed + + producers.modify(same_payer, key, [&](auto& row) { row.rank_score = score; }); + } + + } // 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 bbdcb0e315..f893bc6313 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 @@ -67,6 +68,20 @@ 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; @@ -75,8 +90,6 @@ namespace sysiosystem { static constexpr int64_t useconds_per_hour = int64_t(seconds_per_hour) * 1000'000ll; static constexpr uint32_t blocks_per_day = 2 * seconds_per_day; // half seconds per day static constexpr uint32_t blocks_per_round = 12; // sysio::chain::config::producer_repetitions - static constexpr uint32_t min_blocks_per_round_for_pay = 6; - static constexpr uint32_t no_prev_block = std::numeric_limits::max(); // sentinel: no previous block // All fields (including max_action_return_value_size, KV limits) are now // in the base sysio::blockchain_parameters struct. @@ -91,15 +104,46 @@ 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: + /// 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_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; + /// 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; + + /// 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) - (total_unpaid_blocks) - (last_producer_schedule_size) ) + (last_producer_schedule_size) + (last_producer) + (rescore_cursor)(rescore_pending) + (round_start_block) ) }; inline sysio::block_signing_authority convert_to_block_signing_authority( const sysio::public_key& producer_key ) { @@ -115,31 +159,58 @@ 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; + /// 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) - - 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 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; + /// The pay period `snapshot_attestations` was earned in; `compute` ignores the count when + /// this is not the current one. Staleness decided at READ time, so no exit from the pay walk + /// has to consume the credit. + /// + /// Inert until T5 is initialised: `current_pay_period` is 0 before then, so every stamp + /// matches. Bounded -- there is no pay pre-T5, and the snapshot factor saturates. + /// + /// DECLARED LAST, matching the tail of SYSLIB_SERIALIZE below. + uint32_t snapshot_period = 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; } + /// The block-signing authority this producer is scheduled with. const sysio::block_signing_authority& get_producer_authority()const { 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) + (consecutive_missed_rounds)(is_demoted)(snapshot_attestations) + (snapshot_period) ) }; 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 +493,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 flags a rescore sweep on the + * global 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 +622,31 @@ 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 ); + + /** + * Open a rescore sweep when sysio.opreg's producer collateral minimums change. + * + * The collateral factor is a RATIO against those minimums, so `sysio.opreg::setconfig` + * invalidates every stored score at once. opreg notifies this contract from that action + * on the same channel as `processprod`. The handler declares none of the action's fields + * -- the dispatcher unpacks only what a handler declares -- because the sweep re-reads + * the live config per row; the notification is the trigger, not the payload. + */ + [[sysio::on_notify("sysio.opreg::setconfig")]] + void onsetconfig(); + // ---- Emissions actions (defined in emissions.cpp) ---- /** @@ -733,8 +830,43 @@ 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. + /// + /// @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 ); + + /** + * Record one scheduled round for `producer`, scored by how much of it was delivered. + * + * A round counts as SERVED at `min_blocks_per_round` blocks or more and clears the miss + * streak; anything less -- including nothing at all -- increments it, and the streak + * reaching `max_consecutive_missed_rounds` demotes. ONE threshold, applied ONCE per + * round, at the transition where the block count is finally known. + * + * @param producer the producer whose round this was. + * @param blocks_delivered blocks it produced in that round. + * @param weights the live score configuration. + */ + void record_round_outcome( const name& producer, uint32_t blocks_delivered, + const producer_rank::producer_score_config& weights ); + + /// 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 + /// (`onsetconfig`). + void open_rescore_sweep(); + + /// 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 be0ea546e2..5a703b6640 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 @@ -38,11 +39,13 @@ namespace { // Compile-time constants (not user-configurable) // --------------------------------------------------------------------------- -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 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. constexpr int64_t BPS_DENOMINATOR = 10000; @@ -258,7 +261,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); @@ -266,6 +269,12 @@ uint32_t get_epoch_duration_sec() { return cfg_tbl.get().epoch_duration_sec; } +// Same value for callers that may not throw -- see accrueepoch. +uint32_t epoch_duration_sec_or_zero() { + sysio::epoch::epochcfg_t cfg_tbl(epoch_refs::account); + return cfg_tbl.exists() ? cfg_tbl.get().epoch_duration_sec : 0; +} + } // anonymous namespace // =========================================================================== @@ -316,6 +325,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, @@ -648,6 +659,16 @@ 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. + // Read WITHOUT asserting the epoch config exists: this action is inline from + // sysio.epoch::advance and must not throw. A 0 duration accrues no slots and payout falls + // through to the produced-block divisor. + state.pending_nominal_slots += static_cast(epoch_duration_sec_or_zero()) + * 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()) { @@ -911,133 +932,172 @@ 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 -- 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>(); - // 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. + // 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(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. + 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; + }; + struct reset_entry { + name owner; + bool snapshot; // this payout consumed the row's attestation credit }; - 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. + 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 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 + // 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 (it->rank > cfg.standby_end_rank) 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) { - to_reset.push_back(it->owner); + 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 + // 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, true}); + continue; } - if (!it->is_active) continue; - // opreg filter: skip slashed / terminated / unknown - if (!is_op_active(it->owner, OperatorType::OPERATOR_TYPE_PRODUCER)) continue; - - uint32_t w = 0; - bool standby = false; - uint32_t rounds = 0; - - if (it->rank >= 1 && it->rank <= 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; - standby = true; + produced_blocks += it->unpaid_blocks; + if (it->unpaid_blocks > 0 || it->snapshot_attestations > 0) { + to_reset.push_back({it->owner, it->snapshot_attestations > 0}); } - 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}); } } + // `nominal_slots` is the period's entitlement; `produced_blocks` raises it when a period ran + // long, so no producer's rate exceeds its slice. + // + // Carried blocks are NOT subtracted back out. A row whose pay rounds to zero keeps its blocks + // (that promise is what makes the model forfeiture-free) and they are counted again in the + // period that settles them -- a hair of dilution, and only when the per-block rate is under + // one subunit. Correcting it took a second pass that could overdraw the pool. + 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). + // + // 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; - 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; - } + 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); + int64_t pay = block_pay; + 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; } + // 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 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}; + // 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::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) { - 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 (clear_blocks) p.unpaid_blocks = 0; + if (entry.snapshot) 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. + if (entry.snapshot) rescore_producer(entry.owner); } } @@ -1148,6 +1208,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/finalizer_key.cpp b/contracts/sysio.system/src/finalizer_key.cpp index 21013e5f22..3c381c0568 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 ); } /* @@ -213,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 @@ -277,5 +295,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/peer_keys.cpp b/contracts/sysio.system/src/peer_keys.cpp index 64e882beea..2910518d14 100644 --- a/contracts/sysio.system/src/peer_keys.cpp +++ b/contracts/sysio.system/src/peer_keys.cpp @@ -1,9 +1,12 @@ #include +#include #include #include +#include #include #include +#include namespace sysiosystem { @@ -49,20 +52,80 @@ 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 + // 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 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. + // + // Inside THIS walk the two predicates cannot actually differ: `compute` sinks a keyless + // producer into the demoted tier, so it is already behind the `break` above. The SEED is what + // carries keyless producers, and it is the reason this is correct -- every keyless producer + // that matters is one the chain is currently scheduling. `is_eligible_operator` stands here + // because the walk must not RE-ADD the finalizer requirement the seed was built to sidestep: + // if the tier rule ever stops sinking keyless rows, this loop keeps returning them. + // + // 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 (i->rank > max_rank) + 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) break; - add_peer(*i); + if (already_added(i->owner)) + continue; + add_peer(i->owner); } return resp; diff --git a/contracts/sysio.system/src/producer_pay.cpp b/contracts/sysio.system/src/producer_pay.cpp index 92ed84a3cc..af3e83abff 100644 --- a/contracts/sysio.system/src/producer_pay.cpp +++ b/contracts/sysio.system/src/producer_pay.cpp @@ -1,6 +1,11 @@ #include +#include +#include #include +#include +#include + namespace sysiosystem { using sysio::current_time_point; @@ -30,47 +35,175 @@ 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 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. + // 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 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, + 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 + // 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. 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(); + + 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; + // Held a slot and delivered nothing. + record_round_outcome( *slot, /*blocks_delivered*/ 0, weights ); + ++slot; + } + } + } + + // The outgoing producer's round just ended and its length is only known now, so a round is + // scored once, here. Every block from `round_start_block` to this one was its own, so the + // difference IS the count -- no per-block counter. Skipped across a schedule change, where + // the stored height belongs to a round that no longer exists. + if( schedule_unchanged && state.last_producer.value != 0 && state.round_start_block != 0 + && block_height > state.round_start_block ) { + 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( state.last_producer, block_height - state.round_start_block, weights ); + } + + _global.modify( get_self(), [&]( auto& g ) { + g.last_producer = current_producer; + g.round_start_block = block_height; + }); + } + + void system_contract::record_round_outcome( const name& producer, uint32_t blocks_delivered, + const producer_rank::producer_score_config& weights ) { + auto key = producer_key_t{producer.value}; + if( !_producers.contains(key) ) return; + + // ONE verdict: SERVED at `min_blocks_per_round` or more, otherwise counted against the + // producer. Zero keeps only the wholly-unproduced case. + const bool served = weights.min_blocks_per_round == 0 + ? blocks_delivered > 0 + : blocks_delivered >= weights.min_blocks_per_round; + + const auto before = _producers.get(key); + const auto before_streak = before.consecutive_missed_rounds; + const auto before_demoted = before.is_demoted; + + _producers.modify( same_payer, key, [&]( auto& p ) { + if( served ) { + // Serving clears the streak and the demotion. Demotion and rescheduling are not + // simultaneous -- the `min_schedule_size` floor can keep a demoted producer in the + // schedule -- so without this it would produce indefinitely for nothing. + p.consecutive_missed_rounds = 0; + p.is_demoted = false; + return; + } + + p.consecutive_missed_rounds++; + // Categorical: a tier no score climbs out of. Back via `regproducer`, or by serving a + // round while still scheduled. + if( producer_rank::warrants_demotion( p.consecutive_missed_rounds, weights ) ) { + p.is_demoted = true; + } + }); + + // Only these two feed the score, and a rescore costs two cross-contract opreg reads on an + // `onblock` path -- so a round that moved neither pays nothing. + const auto after = _producers.get(key); + if( after.consecutive_missed_rounds != before_streak || after.is_demoted != before_demoted ) { + rescore_producer( producer ); + } + } + + void system_contract::drain_rescore_cursor() { + const auto& state = _global.get(); + 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. + 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_pending = !done; + }); + } + } //namespace sysiosystem diff --git a/contracts/sysio.system/src/ranking.cpp b/contracts/sysio.system/src/ranking.cpp index a8450d5c82..aa77128a09 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,73 @@ namespace sysiosystem { info.producer_authority = producer_authority; if ( info.last_claim_time == time_point() ) info.last_claim_time = ct; + // The door back for a producer the schedule dropped. Clears the DEMOTION, not the + // streak: `regproducer` costs only a signature and may be repeated, so clearing the + // streak would let an offline operator cron its way back and never serve a round. The + // streak clears by SERVING one. No cooldown -- a producer that returns unready is + // demoted again on its next unserved round. + info.is_demoted = false; }); + + // 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 ) { + producer_rank::rescore( get_self(), _producers, 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 + // notification and this handler. + rescore_producer( account ); + } + + void system_contract::setscorecfg( const producer_rank::producer_score_config& weights ) { + require_auth( get_self() ); + + // 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" ); + + // 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() ); + + // 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; + }); } void system_contract::regproducer( const name& producer, const sysio::public_key& producer_key, const std::string& url, uint16_t location ) { @@ -84,9 +151,26 @@ 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 ) { + // 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. + // + // 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; }); auto idx = _producers.get_index<"prodrank"_n>(); @@ -97,43 +181,25 @@ 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. + uint32_t examined = 0; 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; - } - - proposed_finalizers.emplace_back(finalizer); + if( producer_rank::tier_of( it->rank_score ) == producer_tier::demoted ) break; + if( ++examined > max_rank_walk_rows ) break; + if( !producer_rank::is_eligible_operator( *it ) ) continue; + // One finalizer read for both the predicate and the row proposed below. + auto finalizer = producer_rank::active_finalizer( it->owner, _finalizers ); + if( !finalizer ) continue; + + proposed_finalizers.emplace_back( *finalizer ); 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..ab9ee06b09 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,90 @@ 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 ACTION -- `regsnapprov` walks it here and threads the result through both the +/// eligibility check and the capacity prune -- then tested for membership rather than re-walked per +/// producer, which would make the prune's max_snap_providers entries 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); + + // 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 a + // finalizer read apiece. That matters more here than on the other walks: this one is reached + // from `regsnapprov`, a user-signed write, 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); + } + 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) { +/// +/// Takes `ranked` rather than walking for it: `regsnapprov` also needs the list for the capacity +/// prune, and this walk is the expensive part of that user-signed action. +void require_snapshot_producer_eligibility(name self, name producer, const std::vector& ranked) { + 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, ranked); 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); + const uint32_t current_period = producer_rank::current_pay_period(self); + for (const auto& voter : voters) { + auto key = producer_key_t{voter.value}; + if (!producers.contains(key)) continue; + // Stamped with its pay period; `compute` ignores a stale one. No exit has to consume it. + producers.modify(same_payer, key, [&](auto& row) { + if (row.snapshot_period != current_period) { + row.snapshot_period = current_period; + row.snapshot_attestations = 0; + } + 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); + } +} + /// Counts provider mappings for the bounded registration-capacity check. uint32_t count_snapshot_providers(const snap_providers_table& providers) { uint32_t provider_count = 0; @@ -70,17 +137,18 @@ uint32_t count_snapshot_providers(const snap_providers_table& providers) { } /// Removes stale mappings only when capacity would otherwise reject a new registration. -void prune_stale_snapshot_providers_if_full(name self, snap_providers_table& providers) { +void prune_stale_snapshot_providers_if_full(name self, snap_providers_table& providers, + const std::vector& ranked) { if (count_snapshot_providers(providers) < max_snap_providers) { return; } - producers_table producers(self); - auto provider_itr = providers.begin(); + producers_table 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)); @@ -130,8 +198,10 @@ void finalize_snapshot_vote(name self, uint32_t block_num, const checksum256& bl void snapshot_attest::regsnapprov(name producer, name snap_account) { require_auth(producer); - producers_table producers(get_self()); - require_snapshot_producer_eligibility(producers, producer); + // Walked ONCE and shared with the capacity prune below -- the walk is bounded but expensive, + // and this is a user-signed action. + const auto ranked = snapshot_ranked_producers(get_self()); + require_snapshot_producer_eligibility(get_self(), producer, ranked); snap_providers_table providers(get_self()); const auto provider_itr = providers.find(snap_provider_key_t{snap_account.value}); @@ -145,7 +215,7 @@ void snapshot_attest::regsnapprov(name producer, name snap_account) { if (producer_itr != by_producer.end()) { by_producer.erase(std::move(producer_itr)); } else { - prune_stale_snapshot_providers_if_full(get_self(), providers); + prune_stale_snapshot_providers_if_full(get_self(), providers, ranked); } check(count_snapshot_providers(providers) < max_snap_providers, provider_capacity_error); @@ -188,13 +258,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 +276,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 +288,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 +298,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..aa50223792 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 ); } @@ -262,9 +214,16 @@ 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(); }); + + // 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 d221881ba3..4a1d780ccd 100644 --- a/contracts/sysio.system/sysio.system.abi +++ b/contracts/sysio.system/sysio.system.abi @@ -436,6 +436,10 @@ "name": "standby_end_rank", "type": "uint32" }, + { + "name": "standby_bps", + "type": "uint16" + }, { "name": "epoch_log_retention_count", "type": "uint32" @@ -866,6 +870,16 @@ } ] }, + { + "name": "observed_schedule", + "base": "", + "fields": [ + { + "name": "producers", + "type": "name[]" + } + ] + }, { "name": "onblock", "base": "", @@ -987,8 +1001,8 @@ "type": "public_key" }, { - "name": "rank", - "type": "uint32" + "name": "rank_score", + "type": "uint64" }, { "name": "is_active", @@ -1015,15 +1029,19 @@ "type": "block_signing_authority" }, { - "name": "last_block_num", + "name": "consecutive_missed_rounds", "type": "uint32" }, { - "name": "current_round_blocks", - "type": "uint16" + "name": "is_demoted", + "type": "bool" }, { - "name": "eligible_rounds", + "name": "snapshot_attestations", + "type": "uint32" + }, + { + "name": "snapshot_period", "type": "uint32" } ] @@ -1066,6 +1084,48 @@ } ] }, + { + "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": "min_blocks_per_round", + "type": "uint32" + } + ] + }, { "name": "rcrdbatch", "base": "", @@ -1349,16 +1409,12 @@ ] }, { - "name": "setrank", + "name": "setscorecfg", "base": "", "fields": [ { - "name": "producer", - "type": "name" - }, - { - "name": "rank", - "type": "uint32" + "name": "weights", + "type": "producer_score_config" } ] }, @@ -1494,13 +1550,25 @@ "name": "last_pervote_bucket_fill", "type": "time_point" }, - { - "name": "total_unpaid_blocks", - "type": "uint32" - }, { "name": "last_producer_schedule_size", "type": "uint16" + }, + { + "name": "last_producer", + "type": "name" + }, + { + "name": "rescore_cursor", + "type": "uint64" + }, + { + "name": "rescore_pending", + "type": "bool" + }, + { + "name": "round_start_block", + "type": "uint32" } ] }, @@ -1547,6 +1615,10 @@ { "name": "capital_shortfall_total", "type": "int64" + }, + { + "name": "pending_nominal_slots", + "type": "uint64" } ] }, @@ -2026,8 +2098,8 @@ "ricardian_contract": "" }, { - "name": "setrank", - "type": "setrank", + "name": "setscorecfg", + "type": "setscorecfg", "ricardian_contract": "" }, { @@ -2241,6 +2313,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 d70d9f584c..0d91dad598 100755 Binary files a/contracts/sysio.system/sysio.system.wasm and b/contracts/sysio.system/sysio.system.wasm differ 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 4db291abc1..12f3f74f71 100644 --- a/contracts/tests/emissions_tests.cpp +++ b/contracts/tests/emissions_tests.cpp @@ -29,11 +29,15 @@ #include +#include + #include #include #include #include "sysio.system_tester.hpp" +#include + #include "finalizer_test_keys.hpp" #include @@ -135,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, @@ -145,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) { @@ -223,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 @@ -238,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() { @@ -621,6 +662,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", epoch_log_retention_count) ("pay_cadence_epochs", cadence); } @@ -793,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. @@ -842,6 +891,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 // ----------------------------- @@ -869,6 +928,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) { @@ -903,6 +1015,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); @@ -2093,7 +2218,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); @@ -2113,7 +2238,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); @@ -2131,7 +2256,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); @@ -2149,7 +2274,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); @@ -2169,7 +2294,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)); }; @@ -2203,7 +2328,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)); }; @@ -2233,7 +2358,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); @@ -2252,7 +2377,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); @@ -2260,6 +2385,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. @@ -2289,7 +2422,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) ); @@ -2328,6 +2461,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 @@ -2464,7 +2598,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) ); @@ -2478,6 +2612,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 @@ -2880,7 +3015,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); @@ -2923,7 +3058,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); @@ -3020,7 +3155,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())) ); @@ -3240,31 +3375,178 @@ 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. +// Integer division is the one way the no-forfeiture rule could be broken silently: a real block +// count over a pool too small to represent it pays zero, and consuming the count then would destroy +// work that was actually done. The blocks wait instead, for a period whose pool can pay them. +BOOST_FIXTURE_TEST_CASE( pay_rounding_to_zero_does_not_consume_the_blocks, sysio_emissions_tester ) try { + create_t5_holding_accounts(); + setup_producers(3); + wait_for_producer_schedule(); + produce_complete_cycles(3, 1); + + const uint32_t start = head_secs() - ONE_EPOCH - 1; + BOOST_REQUIRE_EQUAL( success(), initt5( config::system_account_name, tpsec(start) ) ); + + const auto target = "producera"_n; + const asset before = get_wire_balance_paid(target); + + // A period whose entire producer pool is one unit. active_pool * blocks / slot_divisor is + // integer division, so every producer's block pay floors to zero. + const int64_t tiny_emission = 2; + BOOST_REQUIRE_EQUAL( success(), push_system_action(EPOCH, "accrueepoch"_n, mvo() + ("epoch_index", 1)("batch_group_index", 0)("per_epoch_emission", tiny_emission)) ); + + // Read the count LAST, immediately before the payout: every push_system_action closes a block, + // and the target keeps producing into its own counter while the setup runs. + const uint32_t blocks = unpaid_blocks_of(target); + BOOST_REQUIRE_GT( blocks, 0u ); + // Fewer blocks than the period's nominal slots, so the divisor is the nominal count. + BOOST_REQUIRE_LT( uint64_t(blocks), test_nominal_slots(T_EPOCH_SECS) ); + + BOOST_REQUIRE_EQUAL( success(), push_system_action(EPOCH, "payepoch"_n, mvo() + ("epoch_index", 1)("batch_op_groups", vector>{})("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() + +// The solvency invariant the two-pass divisor exists to preserve. The first pass decides which +// 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); + wait_for_producer_schedule(); + + // Enough rotations that EVERY producer holds at least a full period's nominal slots. That is + // what lets the divisor collapse: the first pass sees produced_blocks (~3x nominal) and rounds + // every row to zero, all of them leave the divisor, and it falls back to the nominal count -- + // at which point each row's blocks alone would price at a whole unit. + produce_complete_cycles(3, 12); + + const uint32_t start = head_secs() - ONE_EPOCH - 1; + BOOST_REQUIRE_EQUAL( success(), initt5( config::system_account_name, tpsec(start) ) ); + + // 5 subunits is the smallest emission that survives the bps splits to a NONZERO active pool: + // compute 5*4000/10000 = 2, producer 2*7000/10000 = 1, standby 1*800/10000 = 0, active = 1. + // At 1 the whole pool is a single subunit, so any second row credited is money that does not + // exist. An emission of 1 leaves active_pool at 0 and the test can observe nothing at all. + const int64_t emission = 5; + BOOST_REQUIRE_EQUAL( 1, test_active_pool(test_split_bps(emission, COMPUTE_BPS)) ); + + const int64_t outstanding_before = pay_outstanding_total(); + const uint64_t nominal = test_nominal_slots(T_EPOCH_SECS); + + // Precondition: at least two rows individually clear the nominal count, so a collapsed divisor + // would price each of them at a full unit. Without this the path is unreachable and the test + // passes for the wrong reason. + uint32_t rows_over_nominal = 0; + for (const auto& producer : { "producera"_n, "producerb"_n, "producerc"_n }) { + if (uint64_t(unpaid_blocks_of(producer)) >= 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" ); + + BOOST_REQUIRE_EQUAL( success(), push_system_action(EPOCH, "accrueepoch"_n, mvo() + ("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", 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 <= 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 { 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. @@ -3305,8 +3587,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(), @@ -3429,10 +3711,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) ) ); @@ -3443,34 +3725,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() ); @@ -3478,13 +3755,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 { @@ -3494,61 +3769,57 @@ 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 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() ); - // 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; @@ -3565,45 +3836,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() // --------------------------------------------------------------------------- @@ -3612,7 +3880,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 @@ -3620,8 +3888,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)); @@ -3640,7 +3917,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); @@ -3651,22 +3928,22 @@ 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 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 - - // 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) ); + 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 + // 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); @@ -3674,20 +3951,19 @@ 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 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() BOOST_FIXTURE_TEST_CASE( producer_replaced_mid_epoch, sysio_emissions_tester ) try { @@ -3723,7 +3999,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 ); @@ -3738,6 +4014,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 // --------------------------------------------------------------------------- @@ -3878,17 +4236,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); @@ -3897,20 +4255,21 @@ 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) 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 ); } } 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(); @@ -3919,20 +4278,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 @@ -3956,6 +4312,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 @@ -3974,9 +4331,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: this fixture has no non-empty // rotation group, so the bucket stays in reserv for a future payable period. @@ -3989,11 +4348,12 @@ BOOST_FIXTURE_TEST_CASE( payepoch_folds_swap_fee_rewards, sysio_emissions_tester BOOST_REQUIRE_EQUAL( reserv_reward_balance(), fee_total ); - // 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 @@ -4036,6 +4396,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() ); @@ -4067,7 +4428,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. @@ -4116,6 +4479,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 ); @@ -4134,16 +4498,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( reserv_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) @@ -4158,6 +4526,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); @@ -4172,77 +4549,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() // --------------------------------------------------------------------------- @@ -4373,10 +4749,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() @@ -4399,7 +4775,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 } @@ -4442,28 +4818,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(); @@ -4513,6 +4867,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); @@ -4526,9 +4882,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 { @@ -4854,7 +5212,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); @@ -4880,7 +5238,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) ); @@ -5149,7 +5507,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) ); @@ -5297,7 +5655,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 ) ); @@ -5379,34 +5737,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) { @@ -5419,9 +5761,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; @@ -5504,7 +5843,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(); @@ -5532,7 +5870,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(); @@ -5582,3 +5919,956 @@ 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, + uint32_t min_blocks_per_round = 6) { + 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) + ("min_blocks_per_round", min_blocks_per_round))); + } + + /// 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)); + } + + bool rescore_pending() { + auto g = get_global_state(); + BOOST_REQUIRE(!g.is_null()); + 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 + /// 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()); + } + + // Finish the PREVIOUS producer's window before jumping. Cutting it short would leave it a + // partial round, which now counts against it -- the helper would demote a bystander. + while (control->head().header().timestamp.slot % slots_per_producer != slots_per_producer - 1) { + produce_blocks(1); + } + // From its last slot, +1 enters the target's window and +slots_per_producer clears it, so + // the next block is the following producer's first. + produce_block(fc::milliseconds(int64_t(config::block_interval_ms) * (slots_per_producer + 1))); + + 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( !rescore_pending() ); + + BOOST_REQUIRE_EQUAL( success(), set_score_config(/*collateral_weight=*/5'000) ); + BOOST_REQUIRE( rescore_pending() ); + + trigger_reschedule(); + 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( !rescore_pending() ); +} FC_LOG_AND_RETHROW() + +// 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 -- 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 + 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() + +// --------------------------------------------------------------------------- +// 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. It still needs a + // finalizer key -- without one it could not take part in finality, so it would be unschedulable + // and sink below both tiers, and this test would be comparing something else entirely. + 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) ); + { + auto [privkey, pubkey, pop, sig_provider] = sysio::testing::get_bls_key(bootstrap); + BOOST_REQUIRE_EQUAL( success(), + register_finalizer_key(bootstrap, pubkey.to_string(), pop.to_string()) ); + } + 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) ); + 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, + 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]) ); + 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 +// 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); + + // 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) ); + 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 + + // 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 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 -- 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(); + 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( 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() + +// 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() + +// `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. +// The whole demotion rule, in one test: a round is SERVED at `min_blocks_per_round` or more, and +// a short round counts against the producer exactly as an unproduced one does. +// +// Without a threshold, one block of twelve was indistinguishable from twelve -- the miss walk only +// charged a round that produced NOTHING -- so a producer could hold a scheduled slot indefinitely +// while delivering a fraction of it. This is what closed that, and it is now the only gate: no +// rolling window, no rate, no second mechanism to keep in step. +BOOST_FIXTURE_TEST_CASE( a_short_round_counts_against_the_producer, producer_score_tester ) try { + auto names = setup_ranked_producers(5); + // Demote on two unserved rounds, and demand the full round to serve one, so the test can drive + // the threshold without depending on how many blocks a partial round happens to land. + 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, + /*min_blocks_per_round=*/slots_per_producer) ); + trigger_reschedule(); + wait_for_active_schedule(names[2]); + + const auto target = names[2]; + BOOST_REQUIRE( !demoted(target) ); + + // Two rounds it never appears for: unserved by definition. + skip_round_of(target); + BOOST_REQUIRE_EQUAL( 1u, missed_rounds_of(target) ); + skip_round_of(target); + + BOOST_REQUIRE_MESSAGE( demoted(target), + "two unserved rounds in a row must demote at a limit of two" ); + BOOST_REQUIRE_EQUAL( tier_demoted, tier_of(rank_score_of(target)) ); + + // Serving a round clears both the streak and the demotion -- the door back that needs no + // action from the operator, and the one a mass outage depends on. + produce_blocks(names.size() * slots_per_producer * 2); + BOOST_REQUIRE_MESSAGE( !demoted(target), + "serving rounds must clear a demotion while the producer still holds its slot" ); + BOOST_REQUIRE_EQUAL( 0u, missed_rounds_of(target) ); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE( setscorecfg_bounds_every_field_it_accepts, producer_score_tester ) try { + // A round holds 12 slots, so a threshold above it can never be met: every fully served round + // would count against the producer and the streak 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, /*min_blocks_per_round=*/13) ); + // The boundary and the disabled spelling are both accepted. + BOOST_REQUIRE_EQUAL( success(), set_score_config(10'000, 10'000, 10'000, 3, 1, 12) ); + BOOST_REQUIRE_EQUAL( success(), set_score_config(10'000, 10'000, 10'000, 3, 1, 0) ); + + 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) ); +} 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() + +// 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 { + 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])) ); + // The park rescores at once: a parked row is not a live producer, so its key sinks to the + // demoted tier where no walk visits it -- not left in the healthy tier until some unrelated + // event happened to rescore it. + BOOST_REQUIRE_EQUAL( tier_demoted, tier_of(rank_score_of(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)) ); + BOOST_REQUIRE_EQUAL( tier_healthy, tier_of(rank_score_of(names[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() + +// A slash or a termination ends a producer's standing, and its rank key has to say so at once: +// opreg dispatches the same `processprod` notification it uses for balance changes, and the +// rescore sinks the key to the demoted tier in the same transaction. Before this, a slashed +// position-1 producer stayed first in the index -- skipped by every walk, but visited by every +// one of them -- until an unrelated event rescored it. +BOOST_FIXTURE_TEST_CASE( slash_and_termination_sink_the_key_at_once, producer_score_tester ) try { + auto names = setup_collateralized_producers(5); + for (const auto& p : names) BOOST_REQUIRE_EQUAL( tier_healthy, tier_of(rank_score_of(p)) ); + + BOOST_REQUIRE_EQUAL( success(), slash_operator(names[4]) ); + BOOST_REQUIRE_EQUAL( tier_demoted, tier_of(rank_score_of(names[4])) ); + + BOOST_REQUIRE_EQUAL( success(), terminate_operator(names[3]) ); + BOOST_REQUIRE_EQUAL( tier_demoted, tier_of(rank_score_of(names[3])) ); + + // The others are untouched, and the two sunk rows now sort BEHIND every one of them in index + // order (equal demoted keys fall back to name order), so a walk stops before reaching either. + for (uint32_t i = 0; i < 3; ++i) BOOST_REQUIRE_EQUAL( tier_healthy, tier_of(rank_score_of(names[i])) ); + BOOST_REQUIRE_EQUAL( 3u, producer_rank_position(names[2]) ); + BOOST_REQUIRE_EQUAL( 4u, producer_rank_position(names[3]) ); + BOOST_REQUIRE_EQUAL( 5u, producer_rank_position(names[4]) ); +} 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. +// A producer with a bond but no active finalizer key can never be scheduled -- it could not take +// part in finality -- so it must not sit in a tier the rank walks traverse. That is what BOUNDS +// them: `regproducer` is permissionless and the table unbounded, so if these rows ranked among the +// healthy the schedule rebuild and the inline epoch payout could skip an arbitrary number of rows +// that can never qualify. +// +// Peer discovery is deliberately NOT affected, and that 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. See `getpeerkeys_returns_every_scheduled_producer`. +BOOST_FIXTURE_TEST_CASE( a_bonded_producer_without_a_finalizer_key_holds_no_rank, producer_score_tester ) try { + auto names = setup_collateralized_producers(5); + + // Bonded exactly like the others, registered as a producer, but never `regfinkey`. + const auto keyless = producer_name_at(5); + create_producer_accounts({keyless}); + BOOST_REQUIRE_EQUAL( success(), push_system_action(keyless, "regproducer"_n, mvo() + ("producer", keyless)("producer_key", get_public_key(keyless, "active"))("url", "")("location", 0)) ); + BOOST_REQUIRE_EQUAL( success(), + register_operator(keyless, OperatorType::OPERATOR_TYPE_PRODUCER, false) ); + BOOST_REQUIRE_EQUAL( success(), credit_collateral(keyless, base_min_bond * 10) ); + produce_blocks(1); + + // Ten times the bond of anyone else buys it nothing: without a key it cannot be scheduled, so + // it sorts behind every producer that can be. + { + const auto op = get_opreg_operator(keyless); + BOOST_REQUIRE_EQUAL( "OPERATOR_STATUS_ACTIVE", op["status"].as_string() ); + } + BOOST_REQUIRE_EQUAL( tier_demoted, tier_of(rank_score_of(keyless)) ); + 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(keyless) ); + BOOST_REQUIRE( is_scheduled(names[0]) ); +} FC_LOG_AND_RETHROW() + +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() + +// --------------------------------------------------------------------------- +// 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 07e5d8a192..38dd437bba 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,42 @@ 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(); + } + + /// Register an active finalizer key for each name. + /// + /// Required to reach the RANK WALK at all: `producer_rank::compute` sinks a keyless producer + /// into the demoted tier, which is the tier the walk breaks at. Keys come from `get_bls_key`, + /// whose private half this tester holds -- update_ranked_producers proposes a policy from them + /// and a policy the node cannot sign freezes LIB. + void register_finalizer_keys( const std::vector& names ) { + std::vector registered; + 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())) ); + registered.push_back(p); + } + set_node_finalizers(registered); + 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() ); @@ -47,7 +84,11 @@ 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 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(); // Register peer keys for the even-indexed producers; the odd ones stay keyless. std::map registered; @@ -59,9 +100,9 @@ BOOST_FIXTURE_TEST_CASE( getpeerkeys_test, getpeerkeys_tester ) { try { } } - // getpeerkeys returns every ranked producer (rank <= 30); a registered producer carries its - // peer key, an unregistered one an empty optional. A dropped return value decodes to an empty - // vector and fails the size check below. + // getpeerkeys returns every producer; a registered one carries its peer key, an unregistered + // one an empty optional. All 21 are in the active schedule, so this covers the SEED. A dropped + // return value decodes to an empty vector and fails the size check below. auto peerkeys = get_peer_keys(); BOOST_REQUIRE_EQUAL( peerkeys.size(), prod_names.size() ); @@ -79,4 +120,86 @@ 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: it is returned before the termination. These producers hold no finalizer key, so + // they are all demoted-tier and the rank walk breaks immediately -- the SEED is what covers + // them here, which is exactly the property under test. The rank walk has its own case below. + { + 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() } + +// The RANK WALK half of getpeerkeys: producers past the active schedule. +// +// The other two cases register no finalizer key, so every row is demoted-tier and the walk breaks +// on its first iteration -- they cover the seed and nothing else. Here all 24 hold an active key, +// so the 3 that the 21-slot schedule left out are reachable ONLY by walking the rank index. That +// is also what the dedupe between the seed and the walk is exercised by: the 21 scheduled +// producers are added by the seed first and must not appear twice. +BOOST_FIXTURE_TEST_CASE( getpeerkeys_walks_past_the_active_schedule, getpeerkeys_tester ) { try { + constexpr size_t total = 24; // 21 scheduled + 3 that only the rank walk can reach + std::vector prod_names = activate_producers_with_operators( total ); + register_finalizer_keys( prod_names ); + + const auto scheduled = active_schedule_names(); + BOOST_REQUIRE_EQUAL( scheduled.size(), 21u ); + + auto peerkeys = get_peer_keys(); + + // Precondition: without the walk this is 21. Asserted so a regression that breaks the walk + // fails HERE rather than silently reducing this case to the seed. + BOOST_REQUIRE_MESSAGE( peerkeys.size() == total, + "peer discovery returned " << peerkeys.size() << " of " << total + << "; the rank walk did not reach the unscheduled producers" ); + + for (const auto& p : prod_names) { + 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() diff --git a/contracts/tests/sysio.dispatch_tests.cpp b/contracts/tests/sysio.dispatch_tests.cpp index 1c70bf2734..1b6d72e458 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.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.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 028536db60..6bb5edb367 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. */ @@ -89,7 +198,6 @@ class snapshot_attest_tester : public sysio_system_tester { ("producer", producer)); } - /// Vote on a snapshot hash. action_result votesnaphash(name snap_account, const fc::sha256& block_id, const fc::sha256& snapshot_hash) { return push_action(snap_account, "votesnaphash"_n, mvo() @@ -136,23 +244,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 +307,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 +351,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 +405,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 +422,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 +463,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 +505,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 +513,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 +530,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 +549,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 +570,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 +589,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 +613,55 @@ 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(); + }; + 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); + 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)); + + // 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 { // 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 +678,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 +695,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 +705,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 +722,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 +740,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 +756,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 +783,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 +805,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 +834,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 +845,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 +857,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 +882,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 +909,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 +935,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 +971,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 +986,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..12fbda1a68 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" @@ -45,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)); } @@ -355,6 +357,65 @@ 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`. + /// + /// TWIN: `unittests/sysio_system_tester.hpp` carries the same recipe for the `unit_test` tree. + /// The trees cannot share a header -- they resolve the wasm through different accessors + /// (`contracts::` vs `test_contracts::`) -- so a change to the grants, the privilege step or + /// the regoperator shape must be made in BOTH. + 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 +468,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/docs/becoming-a-block-producer.md b/docs/becoming-a-block-producer.md new file mode 100644 index 0000000000..fa1ff18f48 --- /dev/null +++ b/docs/becoming-a-block-producer.md @@ -0,0 +1,265 @@ +# 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 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 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 + +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 | +|---|---| +| 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. 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 + +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. + +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 + +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 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 +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 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 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 +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 + +- **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** 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. + +## 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. 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/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]) diff --git a/tests/auto_bp_gossip_peering_test.py b/tests/auto_bp_gossip_peering_test.py index d6fc76cbd2..82186a3d4a 100755 --- a/tests/auto_bp_gossip_peering_test.py +++ b/tests/auto_bp_gossip_peering_test.py @@ -155,7 +155,7 @@ def getHostName(nodeId): cluster.getNode(nodeId).waitForBlock(blockNum) # return the peer names (defproducera) of the connected peers in the v1/net/connections JSON - def connectedPeers(nodeId, connectionsJson): + def connectedPeers(nodeId, connectionsJson, bpOnly=True): peers = [] for conn in connectionsJson["payload"]: if conn["is_socket_open"] is False: @@ -168,7 +168,7 @@ def connectedPeers(nodeId, connectionsJson): if not peer_addr: continue if peer_names[peer_addr] != "bios" and peer_addr != getHostName(nodeId): - if conn["is_bp_peer"]: + if not bpOnly or conn["is_bp_peer"]: peers.append(peer_names[peer_addr]) return peers @@ -239,13 +239,13 @@ def newScheduleActive(): "Timed out waiting for new schedule gossip connections" Print("Verify manual connection still connected and stale gossip peer disconnected") - # After schedule change, defproducerh may still have an incoming gossip connection to - # node_19 from the old schedule. Multiple connection-cleanup-period cycles (5s each) may - # be needed before the stale gossip peer is fully pruned. def checkNode19Connections(): connections = cluster.nodes[19].processUrllibRequest("net", "connections") if Utils.Debug: Utils.Print(f"v1/net/connections: {connections}") - found = connectedPeers(19, connections) + # Not filtered on is_bp_peer: defproducere is out of the schedule, so getpeerkeys no + # longer returns its peer key and a gossip message can no longer mark the connection. + # The manual connection is still held open as a supplied peer. + found = connectedPeers(19, connections, bpOnly=False) Print(f"Found connections of Node_19: {found}") return "defproducere" in found and "defproducerh" not in found assert Utils.waitForBool(checkNode19Connections, timeout=60), \ diff --git a/tests/producer_rank_test.py b/tests/producer_rank_test.py index a25c8b49c7..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 @@ -14,12 +16,22 @@ # 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) +# 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. # ############################################################### @@ -30,7 +42,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 +77,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 +127,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 +147,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 +167,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 +181,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 +212,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 @@ -252,36 +246,117 @@ assert f["weight"] == 1, f"Finalizer weight should be 1, got {f['weight']}" # ---------------------------------------------------------------- - # Phase 7: Test setrank action + # 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 # ---------------------------------------------------------------- - 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") + # 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 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..3cb178aac6 100644 --- a/unittests/snapshot_attest_fixture.hpp +++ b/unittests/snapshot_attest_fixture.hpp @@ -31,19 +31,6 @@ inline constexpr uint32_t single_provider_minimum = 1; /// Distance from a scheduled height to the immediately preceding block. 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. */ inline fc::sha256 to_contract_snapshot_hash(const fc::crypto::blake3& snapshot_root) { static_assert(fc::sha256::byte_size == fc::crypto::blake3::byte_size); @@ -68,12 +55,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..324f69f51c 100644 --- a/unittests/sysio_system_tester.hpp +++ b/unittests/sysio_system_tester.hpp @@ -2,6 +2,7 @@ #include #include +#include #include @@ -144,6 +145,48 @@ 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. + /// + /// TWIN: `contracts/tests/sysio.system_tester.hpp` carries the same recipe for the + /// `contracts_unit_test` tree. The trees cannot share a header -- they resolve the wasm through + /// different accessors (`test_contracts::` vs `contracts::`) -- so a change to the grants, the + /// privilege step or the regoperator shape must be made in BOTH. + 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)