Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
947440c
feat(sysio.system): derive producer rank from a score, and demote on …
heifner Sep 2, 2026
31e666a
feat(sysio.system): pay producers per block, keep peer discovery key-…
heifner Sep 3, 2026
bcd82cd
fix(sysio.system): open the rescore sweep from opreg's setconfig noti…
heifner Sep 4, 2026
23b52fc
fix(sysio.system): rank keys track live standing; snapshot credit res…
heifner Sep 4, 2026
bd35a87
feat(sysio.system): recover a demotion by producing, and weight snaps…
heifner Sep 4, 2026
07f743c
test(producer_rank): cover demotion and recovery on a live cluster (W…
heifner Sep 4, 2026
f88cb2b
docs: add the operator guide to becoming a block producer (WIRE-367)
heifner Sep 4, 2026
05a25ef
Merge master into feature/wire-367-producer-registration
heifner Sep 4, 2026
4cd31dd
fix(sysio.system): pay rounding to zero must not consume blocks; peer…
heifner Sep 4, 2026
3b01987
feat(sysio.system): demote on a miss RATE as well as a run, matching …
heifner Sep 5, 2026
75e815c
fix(sysio.system): score against the live collateral minimum; never r…
heifner Sep 5, 2026
ce09933
fix(sysio.system): bound the rank walks by making unschedulable rows …
heifner Sep 5, 2026
f6b6950
docs: correct the recovery and eligibility rules in the producer guid…
heifner Sep 5, 2026
b569e00
fix(sysio.system): close the review-round defects and demote on short…
heifner Sep 5, 2026
4fce5f4
fix(sysio.system): close the second review round (WIRE-367)
heifner Sep 7, 2026
991f955
test(sysio.system): cover the parked-producer credit gate and slot ac…
heifner Sep 7, 2026
007b6bb
fix(sysio.system): roll the miss window, and close the round-three re…
heifner Sep 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Binary file modified contracts/sysio.epoch/sysio.epoch.wasm
Binary file not shown.
98 changes: 86 additions & 12 deletions contracts/sysio.opreg/src/sysio.opreg.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Reevaluate existing operators before activating new collateral minima

setconfig writes the new requirement vectors and only asks sysio.system to rescore. It never reevaluates existing operator statuses. Raising a minimum therefore leaves a now-undercollateralized producer ACTIVE; the score sweep lowers its composite but compute and is_schedulable still trust that stale status, so it can remain healthy, scheduled, and payable indefinitely. Lowering a minimum likewise leaves newly qualified UNKNOWN rows excluded. Please stage the config behind a bounded eligibility sweep or make authoritative consumers check the live minima.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Residual on b569e00: the live producer ratio fixes only part of this\n\nA raised producer minimum is now caught by producer_score, but compute checks the stored ACTIVE status before evaluating that ratio. Lowering the minimum therefore cannot promote existing UNKNOWN producers. Batch operators and underwriters have no equivalent live-minimum check, so raising their minima leaves underbonded rows ACTIVE and still eligible for epoch selection or underwriter authorization until some balance mutation happens. The config change still needs a bounded status reconciliation or authoritative live checks for every role.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed on all three counts. The decision for this PR is to accept and document rather than reconcile, so flagging that explicitly rather than marking it fixed.

One correction to the finding, though: the underwriter half is narrower than it looks. sysio.uwrit::try_select_winner refuses any candidate whose LIVE available_via_mirrors does not cover src + dst, so every underwritten leg stays collateral-backed at its real value — the minimum is an eligibility floor, not a capacity control. The exposure that remains is the batch-operator one: an underbonded op keeps serving in groups with a smaller slash-at-risk than governance just set.

Accepted because that exposure is one role in one direction, self-corrects on any balance movement (reevaluate_eligibility fires on deposit, withdraw, withdraw-flush, slash and terminate), and is auditable off-chain by comparing balances against op_config. The operator guide now states that a minimum change binds new registrations immediately and existing operators on their next balance movement, and that a lowered minimum needs a balance touch to take effect.

A bounded reconciliation sweep in opreg following the flushwthdw shape is the right permanent fix for all three roles and both directions — but it is opreg work rather than producer ranking, and deciding what happens to an operator whose status flips mid-epoch deserves its own review.

}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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);
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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) {
Expand All @@ -1060,13 +1080,38 @@ 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,
std::make_tuple(account, was_eligible, is_eligible)
).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

// ---------------------------------------------------------------------------
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Binary file modified contracts/sysio.opreg/sysio.opreg.wasm
Binary file not shown.
25 changes: 23 additions & 2 deletions contracts/sysio.system/include/sysio.system/emissions.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Migrate emitcfg instead of inserting into its serialized layout

standby_bps is inserted before the existing epoch_log_retention_count and pay_cadence_epochs fields. An existing row therefore shifts those two values and is two bytes too short for this decoder. Please use an explicit migration/versioned representation (or an actually compatible tail-extension strategy) before rollout.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing deployed, we do not need an upgrade path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same ruling as the sibling thread — @heifner: nothing deployed, so there is no existing emitcfg row whose fields could shift.


// Audit-log retention. Caps the unbounded `epochlog` table at this many
// rows; payepoch prunes head-first after each insert. There is one row per
Expand All @@ -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))
};

Expand Down Expand Up @@ -330,11 +335,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>;
Expand Down
Loading
Loading