From c412b58e868571488dde90cde48cd54c54b0e868 Mon Sep 17 00:00:00 2001 From: Huang-Ming Huang Date: Mon, 7 Sep 2026 18:59:07 +0000 Subject: [PATCH 01/15] Fix WIRE-385 stale outbound operator rosters Change-Id: I2fe1bac5e738da09019a96deae976fe727638b4f --- .../include/sysio.epoch/sysio.epoch.hpp | 4 + contracts/sysio.epoch/src/sysio.epoch.cpp | 210 ++++++++++-------- contracts/sysio.epoch/sysio.epoch.abi | 19 ++ contracts/sysio.epoch/sysio.epoch.wasm | Bin 79792 -> 83254 bytes contracts/tests/sysio.msgch_chain_tests.cpp | 167 +++++++++++++- 5 files changed, 301 insertions(+), 99 deletions(-) diff --git a/contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp b/contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp index 5a8e6a5154..59a998090b 100644 --- a/contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp +++ b/contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp @@ -31,6 +31,10 @@ namespace sysio { [[sysio::action]] void advance(); + /// Internal continuation after epoch-close operator mutations execute. + [[sysio::action]] + void finishadv(uint32_t epoch_index, int64_t emission_amount); + /// Group assignment — reads AVAILABLE batch ops from sysio.opreg. [[sysio::action]] void schbatchgps(); diff --git a/contracts/sysio.epoch/src/sysio.epoch.cpp b/contracts/sysio.epoch/src/sysio.epoch.cpp index 3f4e9dd23b..7f4ec40d15 100644 --- a/contracts/sysio.epoch/src/sysio.epoch.cpp +++ b/contracts/sysio.epoch/src/sysio.epoch.cpp @@ -19,6 +19,7 @@ #include #include #include +#include namespace sysio { @@ -45,6 +46,7 @@ namespace { constexpr name SYSTEM_ACCOUNT = "sysio"_n; constexpr name TOKEN_ACCOUNT = "sysio.token"_n; +constexpr name FINISH_ADVANCE = "finishadv"_n; /// Action identifiers owned by sysio.chalg and invoked by epoch close. namespace chalg_actions { @@ -594,9 +596,9 @@ void epoch::advance() { // // Invariant — no cross-epoch double slash: opreg::slash THROWS on an already-SLASHED operator, // which would abort advance and stall OPP epoch advancement. These inline slashes execute only - // after advance returns, so the schedule slide below can temporarily place a just-slashed - // operator in its new tail while the operator still reads ACTIVE. That member cannot create a - // later non-canonical observation: sysio.msgch::deliver requires its current sysio.opreg status + // after advance returns. The finishadv continuation waits for those mutations before + // selecting the new tail. A removed member cannot create a later non-canonical + // observation: sysio.msgch::deliver requires its current sysio.opreg status // to be ACTIVE before accepting delivery. Once the slash has executed, the scheduled SLASHED // member cannot deliver or be queued for another non-canonical-delivery slash. The collection // above also deduplicates multiple non-canonical observations for one member in this advance. @@ -642,13 +644,84 @@ void epoch::advance() { // cron tick and trips kv-index-remove on already-evicted buckets. } - const bool had_expiring_group = state.current_epoch_index > 0; - state.current_epoch_index++; state.current_epoch_start = (state.next_epoch_start.sec_since_epoch() == 0) ? now : state.next_epoch_start; state.next_epoch_start = state.current_epoch_start + microseconds(static_cast(cfg.epoch_duration_sec) * 1'000'000); + state_tbl.set(state, ram_payer); + + // Withdrawal flushing can also change eligibility. Its nested callbacks + // must complete before schedule selection and roster serialization. + // Inline siblings execute their complete subtrees in order, atomically. + action( + permission_level{get_self(), "owner"_n}, + OPREG_ACCOUNT, + "flushwtdw"_n, + std::make_tuple(state.current_epoch_index) + ).send(); + + // Keep the refund subtree at its original depth; refundwire can itself + // transfer a fee or sweep expired claims. It needs the new epoch index, + // but not the new schedule, and finishes before roster publication. + // Drain the swap-from-WIRE queue: each row queued via + // `sysio.uwrit::swapfromwire` since the last advance is re-validated + // (target reserve ACTIVE + public, variance) and either becomes a + // PENDING uwreq for the single-leg underwriter race or is refunded. + // Runs before `buildenv` so this epoch's envelopes reflect any state + // the drain produced; never throws (refund-and-drop semantics). + action( + permission_level{get_self(), "owner"_n}, + UWRIT_ACCOUNT, + "drainfwq"_n, + std::make_tuple() + ).send(); + + action( + permission_level{get_self(), "owner"_n}, + get_self(), + FINISH_ADVANCE, + std::make_tuple(state.current_epoch_index, gate.emission_amount) + ).send(); + + // Preserve payout depth: finishadv and its accrual/history descendants + // complete before this sibling executes. + if (gate.is_pay_epoch) { + action( + permission_level{get_self(), "owner"_n}, + SYSTEM_ACCOUNT, + "payepoch"_n, + std::make_tuple( + state.current_epoch_index, + std::vector>{}, + gate.period_emission + ) + ).send(); + } +} + +void epoch::finishadv(uint32_t epoch_index, int64_t emission_amount) { + require_auth(get_self()); + check(get_sender() == get_self(), "finishadv must be sent inline by sysio.epoch"); + epochcfg_t cfg_tbl(get_self()); + const auto cfg = cfg_tbl.get(); + epochstate_t state_tbl(get_self()); + auto state = state_tbl.get(); + check(state.current_epoch_index == epoch_index, "finishadv epoch mismatch"); + const bool had_expiring_group = epoch_index > 1; + + // A seated operator can have lost eligibility since the window was built. + // Preserve healthy members' order and never reuse a resident to fill a gap. + opreg::operators_t current_ops(OPREG_ACCOUNT); + for (auto& group : state.batch_op_groups) { + group.erase(std::remove_if(group.begin(), group.end(), [&](name account) { + const auto key = opreg::operator_key{account.value}; + if (!current_ops.contains(key)) return true; + const auto op = current_ops.get(key); + return op.status != OperatorStatus::OPERATOR_STATUS_ACTIVE || + op.type != OperatorType::OPERATOR_TYPE_BATCH; + }), group.end()); + } // ── Slide the schedule window ─────────────────────────────────────────── // Skip on the genesis advance (0 → 1): schbatchgps just placed @@ -665,6 +738,7 @@ void epoch::advance() { // After: window = [current, current+1, ..., current+N-1], front is // always the active group → current_batch_op_group stays at 0. if (had_expiring_group && !state.batch_op_groups.empty()) { + const auto expired = state.batch_op_groups.front(); state.batch_op_groups.erase(state.batch_op_groups.begin()); // Collect already-resident accounts so the new tail excludes them. @@ -709,6 +783,31 @@ void epoch::advance() { return a.first < b.first; }); + // Repair future seats before selecting the tail. Otherwise a removed + // operator leaves a hole that eventually becomes an empty active group, + // even when a healthy standby could have been announced one epoch ahead. + // Prefer true standbys: recycling the expired group early would shorten + // its duty interval unnecessarily. All selections consume the same pool, + // so repaired groups and the tail remain disjoint. + // Vacancy recovery is an exception to the normal N-epoch duty spacing: + // absence from this window does not prove an operator has never served + // recently, particularly with windows larger than three groups. + // Do not insert a new member into the CURRENT group here: outposts have + // not received this window yet, and their old chunk-slot assignments may + // collide with a replacement's position. That case retains the existing + // incomplete-window withholding behavior and requires roster recovery. + for (size_t g = 1; g < state.batch_op_groups.size(); ++g) { + auto& group = state.batch_op_groups[g]; + while (group.size() < cfg.operators_per_epoch) { + const auto standby = std::find_if(pool.begin(), pool.end(), [&](const auto& candidate) { + return std::find(expired.begin(), expired.end(), candidate.first) == expired.end(); + }); + if (standby == pool.end()) break; + group.push_back(standby->first); + pool.erase(standby); + } + } + std::vector new_tail; new_tail.reserve(cfg.operators_per_epoch); for (size_t i = 0; i < pool.size() && new_tail.size() < cfg.operators_per_epoch; ++i) { @@ -727,9 +826,8 @@ void epoch::advance() { // is an exact half and two competing digests can both tip. // // So the schedule is left as-is and the DECISION is pushed to the emit - // site: an empty active group is never published (see the withhold - // below). Short-but-non-empty is pre-existing behaviour and is not made - // safe here -- it is reported so the roster can be repaired off-chain. + // site: an incomplete window is never published (see the withhold + // below), and is reported so the roster can be repaired off-chain. if (new_tail.size() < cfg.operators_per_epoch) { sysio::print("sysio.epoch::advance: only ", new_tail.size(), " of ", cfg.operators_per_epoch, @@ -756,21 +854,6 @@ void epoch::advance() { state_tbl.set(state, ram_payer); - // Drain matured rows from `sysio.opreg::wtdwqueue`. Operators that queued - // a withdrawal at least WITHDRAW_WAIT_EPOCHS ago are now eligible — opreg - // subtracts from the balance and emits OPERATOR_ACTION(WITHDRAW_REMIT) to - // the matching outpost (or, for WIRE-direct withdraws, CREDITS the operator's - // `sysio.opreg::remitclaims` row, which it pulls with `claimremit` — nothing - // is transferred from this path, precisely because it runs inline from here). - // Slashed-during-the-wait rows are dropped silently inside - // opreg's flushwtdw. See CLAUDE-WIRE-OPERATOR-COLLATERAL-IMPL-PLAN.md §3.3. - action( - permission_level{get_self(), "owner"_n}, - OPREG_ACCOUNT, - "flushwtdw"_n, - std::make_tuple(state.current_epoch_index) - ).send(); - // Queue OPERATORS attestation (full roster with authex chain addresses) for each outpost. // IMPORTANT: Must come before BATCH_OPERATOR_GROUPS so that the ETH outpost's // _handleOperators populates operatorEthAddress before _handleBatchOperatorGroups @@ -900,41 +983,18 @@ void epoch::advance() { const uint32_t next_group_index = next_index < group_count ? next_index : state.current_batch_op_group; - // NEVER publish an empty "next". The index names the group the outpost - // will admit `epoch_in` against and size its quorum from, so an empty - // one is not a degraded roster -- it is an invalid attestation, and - // seating it wedges the outpost permanently (the handler that could - // replace the window runs only past the gate the empty group breaks). - // - // This is the ONE sound guarantee available here. The slide cannot buy - // non-emptiness by backfilling: with an ACTIVE pool smaller than the - // window, N groups that are both FULL and DISJOINT do not exist, and - // both escapes are unsound (see the slide's comment -- re-seating a - // resident breaks Ethereum's chunk-position disjointness; a short group - // lowers the quorum denominator it defines). So the schedule is left - // alone and the decision lands here. Withholding the attestation leaves - // the outpost on its previous window -- the same end state its own - // guards reach, without shipping an invalid payload. - // - // Cost, accepted deliberately: the withheld attestation also carries - // `epoch_duration_sec` and the whole-window resync that - // batch-operator-schedule-window.md wants on every envelope, so both are - // skipped for this epoch too. Shipping the payload with the index pinned - // to the CURRENT group instead would keep them, but it names a group the - // outpost must not treat as next, and the Solana handler refuses a window - // carrying an empty group regardless -- so it buys nothing here. - // - // Withheld by SKIPPING THE QUEUEOUT ONLY -- never by returning from - // `advance`, which still has the epoch's remaining attestations and - // actions to issue after this block. - const bool have_next_group = - next_group_index < group_count && !state.batch_op_groups[next_group_index].empty(); - if (!have_next_group) { - sysio::print("sysio.epoch::advance: no non-empty next group to publish at epoch ", + // Removing ineligible members must not lower an outpost's quorum + // denominator or publish an empty group. Withhold an incomplete window, + // while still sending OPERATORS with the authoritative removal statuses. + // Never duplicate residents to fill it: Ethereum's chunk routing assumes + // disjoint groups. Epoch accounting and envelope construction still run. + const bool have_complete_window = next_group_index < group_count && + std::all_of(state.batch_op_groups.begin(), state.batch_op_groups.end(), + [&](const auto& group) { return group.size() == cfg.operators_per_epoch; }); + if (!have_complete_window) { + sysio::print("sysio.epoch::finishadv: incomplete operator window at epoch ", state.current_epoch_index, - " (groups=", group_count, ", next_index=", next_group_index, - "); withholding BatchOperatorGroups -- outposts retain their " - "previous window\n"); + "; withholding BatchOperatorGroups until the roster is repaired\n"); } attest.active_group_index = zpp::bits::vuint32_t{next_group_index}; attest.epoch_index = zpp::bits::vuint32_t{state.current_epoch_index}; @@ -959,8 +1019,8 @@ void epoch::advance() { auto out = zpp::bits::out{encoded, zpp::bits::no_size{}}; (void)out(attest); - // `have_next_group` gates the QUEUEOUT, not `advance` -- see above. - if (have_next_group) { + // Withhold only the group attestation, never the remaining epoch work. + if (have_complete_window) { sysio::chains::chains_t chains_tbl(CHAINS_ACCOUNT); for (auto it = chains_tbl.begin(); it != chains_tbl.end(); ++it) { if (!is_active_outpost(*it)) continue; @@ -978,19 +1038,6 @@ void epoch::advance() { } } - // Drain the swap-from-WIRE queue: each row queued via - // `sysio.uwrit::swapfromwire` since the last advance is re-validated - // (target reserve ACTIVE + public, variance) and either becomes a - // PENDING uwreq for the single-leg underwriter race or is refunded. - // Runs before `buildenv` so this epoch's envelopes reflect any state - // the drain produced; never throws (refund-and-drop semantics). - action( - permission_level{get_self(), "owner"_n}, - UWRIT_ACCOUNT, - "drainfwq"_n, - std::make_tuple() - ).send(); - // Build outbound envelopes for each outpost { sysio::chains::chains_t chains_tbl(CHAINS_ACCOUNT); @@ -1005,7 +1052,7 @@ void epoch::advance() { } } - // Emissions side. Three inline actions queued in FIFO order: + // Emissions side. Accrual and history precede advance's payout sibling: // 1. accrueepoch: always queued. Records this epoch's per-epoch share // onto t5state (pending_emission_amount + batch_group_epochs[group] // + last_epoch_emission for decay continuity). @@ -1014,7 +1061,7 @@ void epoch::advance() { // 3. payepoch: queued only on pay-epochs. Reads the now-updated t5state // (which already includes this epoch's contribution from step 1), // distributes period_emission, and resets the accumulator. - // Both run after advance() returns; their FIFO ordering guarantees + // The continuation completes before that sibling; this ordering guarantees // payepoch sees the post-accrue roster history and state. std::vector active_batch_op_members; if (state.current_batch_op_group < state.batch_op_groups.size()) { @@ -1028,7 +1075,7 @@ void epoch::advance() { std::make_tuple( state.current_epoch_index, state.current_batch_op_group, - gate.emission_amount + emission_amount ) ).send(); @@ -1039,19 +1086,6 @@ void epoch::advance() { std::make_tuple(state.current_epoch_index, active_batch_op_members) ).send(); - if (gate.is_pay_epoch) { - action( - permission_level{get_self(), "owner"_n}, - SYSTEM_ACCOUNT, - "payepoch"_n, - std::make_tuple( - state.current_epoch_index, - std::vector>{}, - gate.period_emission - ) - ).send(); - } - // Working tables on `sysio.msgch` (`envelopes` / `messages` / // `attestations` / `outenvelopes`) are now drained inline by the // `evalcons` consensus-reach + `buildenv` write paths. The durable diff --git a/contracts/sysio.epoch/sysio.epoch.abi b/contracts/sysio.epoch/sysio.epoch.abi index 7d7ed6b341..2f292b432f 100644 --- a/contracts/sysio.epoch/sysio.epoch.abi +++ b/contracts/sysio.epoch/sysio.epoch.abi @@ -121,6 +121,20 @@ } ] }, + { + "name": "finishadv", + "base": "", + "fields": [ + { + "name": "epoch_index", + "type": "uint32" + }, + { + "name": "emission_amount", + "type": "int64" + } + ] + }, { "name": "pause", "base": "", @@ -169,6 +183,11 @@ "type": "advance", "ricardian_contract": "" }, + { + "name": "finishadv", + "type": "finishadv", + "ricardian_contract": "" + }, { "name": "pause", "type": "pause", diff --git a/contracts/sysio.epoch/sysio.epoch.wasm b/contracts/sysio.epoch/sysio.epoch.wasm index 23a5d0d81266971407d8f33bb7b854608acf1ff5..3f5f0a766af9abaeb85822509ff7323fb5594cb2 100755 GIT binary patch literal 83254 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 FzW}*#TjT%$ diff --git a/contracts/tests/sysio.msgch_chain_tests.cpp b/contracts/tests/sysio.msgch_chain_tests.cpp index 3cb66df44a..0d2c909d5b 100644 --- a/contracts/tests/sysio.msgch_chain_tests.cpp +++ b/contracts/tests/sysio.msgch_chain_tests.cpp @@ -25,6 +25,7 @@ /// oracle == Solidity/Rust (golden vectors) and contract == oracle (round-trip assertions) /// together give contract == outposts. #include +#include #include #include #include @@ -687,6 +688,59 @@ class sysio_msgch_chain_tester : public tester { return groups_attestation; } + /// Inspect the actual emitted envelope, after inline buildenv drained queueout. + /// The final OPERATORS snapshot must match the registry, and any published + /// schedule must follow that snapshot and contain only active batch operators. + void require_fresh_roster(uint64_t chain_code, name account, + opp::types::OperatorStatus expected_status) { + const auto row = find_outbound_envelope(chain_code); + BOOST_REQUIRE(!row.is_null()); + const auto env = decode_envelope(row["raw_envelope"].as>()); + BOOST_REQUIRE_EQUAL(env.messages_size(), 1); + bool found_operator = false; + bool have_operators = false; + for (const auto& att : env.messages(0).payload().attestations()) { + if (att.type() == opp::types::ATTESTATION_TYPE_OPERATORS) { + opp::attestations::Operators roster; + BOOST_REQUIRE(roster.ParseFromString(att.data())); + have_operators = true; + for (const auto& entry : roster.operators()) { + const auto registered = get_operator(name{entry.account().name()}); + BOOST_REQUIRE(!registered.is_null()); + BOOST_REQUIRE_EQUAL(entry.status(), + registered["status"].as()); + if (entry.account().name() == account.to_string()) { + BOOST_REQUIRE_EQUAL(entry.status(), expected_status); + found_operator = true; + } + } + } else if (att.type() == opp::types::ATTESTATION_TYPE_BATCH_OPERATOR_GROUPS) { + BOOST_REQUIRE(have_operators); + opp::attestations::BatchOperatorGroups groups; + BOOST_REQUIRE(groups.ParseFromString(att.data())); + std::set members; + for (const auto& group : groups.groups()) { + for (const auto& address : group.operators()) { + BOOST_REQUIRE(members.insert(address.address()).second); + const auto registered = get_operator(name{address.address()}); + BOOST_REQUIRE(!registered.is_null()); + BOOST_REQUIRE_EQUAL(opp::types::OPERATOR_STATUS_ACTIVE, + registered["status"].as()); + } + } + } + } + BOOST_REQUIRE(found_operator); + if (expected_status != opp::types::OPERATOR_STATUS_ACTIVE) { + const auto state = read_epoch_state(); + for (const auto& group : state["batch_op_groups"].get_array()) { + for (const auto& member : group.get_array()) { + BOOST_REQUIRE(member.as_string() != account.to_string()); + } + } + } + } + /// How many BATCH_OPERATOR_GROUPS attestations the most recent `advance` shipped to /// `chain_code` -- 0 when the depot WITHHELD it. Distinct from /// `shipped_batch_operator_groups`, which fails the test on absence: the withhold path @@ -856,8 +910,10 @@ class sysio_msgch_chain_tester : public tester { /// bootstrap() variant for a real rotation: THREE single-operator groups (so a resident op is on /// duty once per 3-epoch rotation), the SEC-28 percent rail disabled up to its accepted ceiling /// (99, so an anchored run terminates on the CONSECUTIVE rail), and `terminate_window_ms` set by - /// the caller (the exact span bound for this schedule). ETH outpost registered; genesis advance run. - void bootstrap_rotation(uint64_t terminate_window_ms) { + /// the caller (the exact span bound for this schedule). The target is non-bootstrapped + /// by default; healthy-rotation tests may opt into the bootstrap exemption. + /// ETH outpost registered; genesis advance run. + void bootstrap_rotation(uint64_t terminate_window_ms, bool batchop_is_bootstrapped = false) { BOOST_REQUIRE_EQUAL(success(), push(EPOCH_ACCOUNT, epoch_abi, EPOCH_ACCOUNT, "setconfig"_n, mvo() ("epoch_duration_sec", EPOCH_DURATION_SEC) ("operators_per_epoch", 1) @@ -883,13 +939,13 @@ class sysio_msgch_chain_tester : public tester { register_chain(opp::types::ChainKind::CHAIN_KIND_EVM, "ETH", 31337); - // BATCHOP is the termination target: NON-bootstrapped (bootstrapped operators are exempt from + // By default BATCHOP is the termination target: NON-bootstrapped (bootstrapped operators are exempt from // rolling-window termination -- see opreg::termcheck) and collateralized so it activates. // BATCHOP_B / BATCHOP_C are bootstrapped fillers for the other two groups. schbatchgps sorts // non-bootstrapped first, so BATCHOP lands in group 0 (on duty at epochs 1, 4, 7, ...). BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, "regoperator"_n, mvo() ("account", BATCHOP.to_string())("type", opp::types::OperatorType::OPERATOR_TYPE_BATCH) - ("is_bootstrapped", false))); + ("is_bootstrapped", batchop_is_bootstrapped))); BOOST_REQUIRE_EQUAL(success(), depositinle(BATCHOP, "ETH", "ETH", 1)); for (const auto& op : {BATCHOP_B, BATCHOP_C}) { BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, "regoperator"_n, mvo() @@ -899,7 +955,8 @@ class sysio_msgch_chain_tester : public tester { BOOST_REQUIRE(!get_operator(BATCHOP).is_null()); BOOST_REQUIRE(opp::types::OperatorStatus::OPERATOR_STATUS_ACTIVE == get_operator(BATCHOP)["status"].as()); - BOOST_REQUIRE_EQUAL(0, get_operator(BATCHOP)["is_bootstrapped"].as_uint64()); + BOOST_REQUIRE_EQUAL(static_cast(batchop_is_bootstrapped), + get_operator(BATCHOP)["is_bootstrapped"].as_uint64()); BOOST_REQUIRE_EQUAL(success(), push(EPOCH_ACCOUNT, epoch_abi, EPOCH_ACCOUNT, "schbatchgps"_n, mvo())); BOOST_REQUIRE_EQUAL(success(), push(EPOCH_ACCOUNT, epoch_abi, EPOCH_ACCOUNT, "advance"_n, mvo())); @@ -1646,6 +1703,11 @@ BOOST_FIXTURE_TEST_CASE(noncanonical_delivery_slashes_before_termination, sysio_ slash_action_count(BATCHOP, SOL_OUTPOST_ID)); BOOST_REQUIRE_EQUAL(epoch + kEpochAdvanceCount, current_epoch()); BOOST_REQUIRE_EQUAL(kExpectedDeliveredLogCount, delivered_dellog_count(BATCHOP)); + for (const auto chain : {ETH_OUTPOST_ID, SOL_OUTPOST_ID}) { + require_fresh_roster(chain, BATCHOP, opp::types::OPERATOR_STATUS_SLASHED); + // The remaining two members must not be advertised with a reduced quorum. + BOOST_REQUIRE_EQUAL(0, shipped_batch_operator_groups_count(chain)); + } } FC_LOG_AND_RETHROW() } // SEC-28 (huang review): terminate on the CONSECUTIVE-miss rail through the REAL rotation -- a @@ -1703,6 +1765,8 @@ BOOST_FIXTURE_TEST_CASE(terminate_at_duty_rotation_via_advance, sysio_msgch_chai // whereas termination + reason hold either way. BATCHOP delivered exactly once, so exactly // one delivered row must remain. BOOST_REQUIRE_EQUAL(1u, delivered_dellog_count(BATCHOP)); + require_fresh_roster(ETH_OUTPOST_ID, BATCHOP, + opp::types::OPERATOR_STATUS_TERMINATED); } else { // Still ACTIVE: BATCHOP must not terminate before its sixth miss (its 7th duty). BOOST_REQUIRE(status == opp::types::OperatorStatus::OPERATOR_STATUS_ACTIVE); @@ -1998,10 +2062,10 @@ BOOST_FIXTURE_TEST_CASE(slash_after_delivery_does_not_count_toward_consensus, sy /// rotation so every group is promised (and verified) at least once. BOOST_FIXTURE_TEST_CASE(advance_ships_lookahead_batch_operator_group, sysio_msgch_chain_tester) { try { constexpr uint32_t kGroups = 3; - // Termination rails are irrelevant here — a comfortably wide window keeps recorddel/termcheck - // quiet while the rotation is walked (same span shape the SEC-28 fixture derives). + // Use bootstrapped operators so missed deliveries cannot terminate a + // member during this test of healthy rotation and lookahead. constexpr uint64_t kRotationWindowMs = 12ULL * kGroups * EPOCH_DURATION_SEC * 1000ULL; - bootstrap_rotation(kRotationWindowMs); + bootstrap_rotation(kRotationWindowMs, /*batchop_is_bootstrapped=*/true); for (uint32_t round = 0; round < kGroups + 1; ++round) { const auto shipped = shipped_batch_operator_groups(ETH_OUTPOST_ID); @@ -2077,9 +2141,9 @@ BOOST_FIXTURE_TEST_CASE(advance_withholds_batch_operator_groups_when_next_group_ } produce_blocks(); - // The terminated pair still occupy their seats until they slide out, so the sole survivor is - // resident and the residency-excluded pool is empty: every tail from here is empty. Walk the - // window so an empty group reaches the lookahead seat, then hold there. + // Removed operators are pruned from surviving seats immediately. The pool + // cannot fill the window, so group attestations remain withheld while epoch + // accounting and authoritative operator-status publication continue. bool observed_withhold = false; for (uint32_t round = 0; round < kGroups + 1; ++round) { advance_to_next_epoch(); @@ -2096,4 +2160,85 @@ BOOST_FIXTURE_TEST_CASE(advance_withholds_batch_operator_groups_when_next_group_ "starved window never withheld BATCH_OPERATOR_GROUPS -- an empty active group was published"); } FC_LOG_AND_RETHROW() } +// WIRE-385: a removal during this advance must be visible in BOTH emitted +// attestations, with a healthy standby filling the newly selected tail. +BOOST_FIXTURE_TEST_CASE(advance_roster_excludes_same_epoch_termination, + sysio_msgch_chain_tester) { try { + bootstrap(/*n_batch_ops=*/3, /*batchop_is_bootstrapped=*/false); + BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, + "regoperator"_n, mvo()("account", BATCHOP_D.to_string()) + ("type", opp::types::OPERATOR_TYPE_BATCH)("is_bootstrapped", true))); + produce_blocks(); + advance_to_next_epoch(); // missed delivery terminates only the non-bootstrap operator + for (const auto chain : {ETH_OUTPOST_ID, SOL_OUTPOST_ID}) { + require_fresh_roster(chain, BATCHOP, opp::types::OPERATOR_STATUS_TERMINATED); + require_fresh_roster(chain, BATCHOP_B, opp::types::OPERATOR_STATUS_ACTIVE); + const auto groups = shipped_batch_operator_groups(chain); + BOOST_REQUIRE_EQUAL(groups.groups_size(), 1); + BOOST_REQUIRE_EQUAL(groups.groups(0).operators_size(), 3); + BOOST_REQUIRE_EQUAL(groups.groups(0).operators(0).address(), BATCHOP_B.to_string()); + BOOST_REQUIRE_EQUAL(groups.groups(0).operators(1).address(), BATCHOP_C.to_string()); + BOOST_REQUIRE_EQUAL(groups.groups(0).operators(2).address(), BATCHOP_D.to_string()); + } +} FC_LOG_AND_RETHROW() } + +BOOST_FIXTURE_TEST_CASE(advance_removes_inactive_surviving_group_members, + sysio_msgch_chain_tester) { try { + constexpr uint32_t GROUP_COUNT = 3; + constexpr uint64_t WINDOW_MS = 12ULL * GROUP_COUNT * EPOCH_DURATION_SEC * 1000ULL; + bootstrap_rotation(WINDOW_MS); + for (const auto op : {BATCHOP_B, BATCHOP_C}) { + BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, CHALG_ACCOUNT, + "slash"_n, mvo()("account", op.to_string())("reason", "roster regression"))); + } + produce_blocks(); + advance_to_next_epoch(); + for (const auto op : {BATCHOP_B, BATCHOP_C}) { + require_fresh_roster(ETH_OUTPOST_ID, op, opp::types::OPERATOR_STATUS_SLASHED); + } + BOOST_REQUIRE_EQUAL(0, shipped_batch_operator_groups_count(ETH_OUTPOST_ID)); +} FC_LOG_AND_RETHROW() } + +BOOST_FIXTURE_TEST_CASE(advance_repairs_future_group_before_it_becomes_current, + sysio_msgch_chain_tester) { try { + constexpr uint64_t WINDOW_MS = 12ULL * 3 * EPOCH_DURATION_SEC * 1000ULL; + bootstrap_rotation(WINDOW_MS, /*batchop_is_bootstrapped=*/true); + // schbatchgps interleaves the sorted roster: the initial window is [A,C,B]. + // Remove the last group so its vacancy is still in the future after sliding. + const auto initial = shipped_batch_operator_groups(ETH_OUTPOST_ID); + BOOST_REQUIRE_EQUAL(initial.groups_size(), 3); + BOOST_REQUIRE_EQUAL(initial.groups(2).operators(0).address(), BATCHOP_B.to_string()); + BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, + "regoperator"_n, mvo()("account", BATCHOP_D.to_string()) + ("type", opp::types::OPERATOR_TYPE_BATCH)("is_bootstrapped", true))); + BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, + "terminate"_n, mvo()("account", BATCHOP_B.to_string())("reason", "future seat removed"))); + produce_blocks(); + advance_to_next_epoch(); + require_fresh_roster(ETH_OUTPOST_ID, BATCHOP_B, opp::types::OPERATOR_STATUS_TERMINATED); + const auto repaired = shipped_batch_operator_groups(ETH_OUTPOST_ID); + BOOST_REQUIRE_EQUAL(repaired.groups_size(), 3); + for (int i = 0; i < repaired.groups_size(); ++i) { + BOOST_REQUIRE_EQUAL(repaired.groups(i).operators_size(), 1); + } + BOOST_REQUIRE_EQUAL(repaired.groups(0).operators(0).address(), BATCHOP_C.to_string()); + BOOST_REQUIRE_EQUAL(repaired.groups(1).operators(0).address(), BATCHOP_D.to_string()); + BOOST_REQUIRE_EQUAL(repaired.groups(2).operators(0).address(), BATCHOP.to_string()); + advance_to_next_epoch(); + const auto next = shipped_batch_operator_groups(ETH_OUTPOST_ID); + BOOST_REQUIRE_EQUAL(next.groups_size(), 3); + BOOST_REQUIRE_EQUAL(next.groups(0).operators_size(), 1); + BOOST_REQUIRE_EQUAL(next.groups(0).operators(0).address(), BATCHOP_D.to_string()); + require_fresh_roster(ETH_OUTPOST_ID, BATCHOP_B, opp::types::OPERATOR_STATUS_TERMINATED); +} FC_LOG_AND_RETHROW() } + +BOOST_FIXTURE_TEST_CASE(finishadv_rejects_direct_calls, sysio_msgch_chain_tester) { try { + bootstrap(); + const auto args = mvo()("epoch_index", current_epoch())("emission_amount", int64_t{0}); + BOOST_REQUIRE_EQUAL(error("missing authority of sysio.epoch"), + push(EPOCH_ACCOUNT, epoch_abi, BATCHOP, "finishadv"_n, args)); + BOOST_REQUIRE_EQUAL(error("assertion failure with message: finishadv must be sent inline by sysio.epoch"), + push(EPOCH_ACCOUNT, epoch_abi, EPOCH_ACCOUNT, "finishadv"_n, args)); +} FC_LOG_AND_RETHROW() } + BOOST_AUTO_TEST_SUITE_END() From 902073fac7933ff90e30a067ab0b2804bc4452a9 Mon Sep 17 00:00:00 2001 From: Huang-Ming Huang Date: Thu, 10 Sep 2026 15:56:23 +0000 Subject: [PATCH 02/15] Address WIRE-385 review feedback Change-Id: I439be98d6210bfa418560a680c3436d68f9dc266 --- contracts/sysio.epoch/src/sysio.epoch.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/contracts/sysio.epoch/src/sysio.epoch.cpp b/contracts/sysio.epoch/src/sysio.epoch.cpp index 7f4ec40d15..31af42153a 100644 --- a/contracts/sysio.epoch/src/sysio.epoch.cpp +++ b/contracts/sysio.epoch/src/sysio.epoch.cpp @@ -662,9 +662,9 @@ void epoch::advance() { ).send(); // Keep the refund subtree at its original depth; refundwire can itself - // transfer a fee or sweep expired claims. It needs the new epoch index, - // but not the new schedule, and finishes before roster publication. - // Drain the swap-from-WIRE queue: each row queued via + // transfer a fee or sweep expired claims. The state write above makes the + // new epoch index visible before this action runs, and the action finishes + // before roster publication. Each row queued via // `sysio.uwrit::swapfromwire` since the last advance is re-validated // (target reserve ACTIVE + public, variance) and either becomes a // PENDING uwreq for the single-leg underwriter race or is refunded. @@ -829,7 +829,7 @@ void epoch::finishadv(uint32_t epoch_index, int64_t emission_amount) { // site: an incomplete window is never published (see the withhold // below), and is reported so the roster can be repaired off-chain. if (new_tail.size() < cfg.operators_per_epoch) { - sysio::print("sysio.epoch::advance: only ", new_tail.size(), " of ", + sysio::print("sysio.epoch::finishadv: only ", new_tail.size(), " of ", cfg.operators_per_epoch, " eligible batch operators for the new tail group at epoch ", state.current_epoch_index + cfg.batch_op_groups - 1, From 4cf466f27de1064fa483e0a311d35700576bc568 Mon Sep 17 00:00:00 2001 From: Huang-Ming Huang Date: Thu, 10 Sep 2026 16:21:19 +0000 Subject: [PATCH 03/15] Regenerate epoch contract artifact Change-Id: I1eb6737adbf418e80a81783ff327a7b267e9e302 --- contracts/sysio.epoch/sysio.epoch.wasm | Bin 83254 -> 83254 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/contracts/sysio.epoch/sysio.epoch.wasm b/contracts/sysio.epoch/sysio.epoch.wasm index 3f5f0a766af9abaeb85822509ff7323fb5594cb2..f373407af6b526592ddb998f83d8e0b12b540610 100755 GIT binary patch delta 429 zcmdni#k#GFbwi&F4JimEvbTFUj-O~&T z%#J76vK$|QSzB1M9OrIk&1#k6|8s^xKtO@nl<5Mi0KenT%`;nk+!=pves;QDm~r#w zy!*L~jMFC{e6PBh@!=E@Q~ayy=G~8~m>ADbzWr8pa`e+JKuZLkr7;4j^5@T4fJ)-u z`~=F*dTTj-y$K^bnCSqNz5Vtn(16Vc--oh-&2fMjJbC|Hk;wsH*`d6BUnLl)Pp{Nw zRNegND=#zSg3Vk%Q$YIif9bMe)3KeIkx>|^d-_XRMyu)Pq!}%@cX2TC0o6{Q$;Wt< zar*Xne#V`^5ScD1%~%Chw;iaC3ux-}ELleF>HDP_*{5^MGR8p!>VSHHhNS|%G~HjG zv7Pb!_UrPD3%P;9KzG@4f_4JiB>rbTFUj?b8ej z%#J76vK$|PSzB1M9A|H4&1#k6|8<5zKtO@nl<5Mi0Ken*%`;nk+!?=bes;QDm~rFg zy!*L~j8i8ce6PBh@!=E@Q~ayy=G~8~m>ADazWr8pa`e+JKuZLkr7;4j^5@T4fJ)-u z`~=F*dTTj-y$K^bnCSqNz5Vtn(16Vc--oh-&2fMjJbC|Hk;wsH*`d6BUnLl)POsEv zRNegND=#zSyv|^d-_XRMyu)Pq!}%@cX2TC0o6{Q$;Wt< zaq9MXe#V`^5ScD1%~%Chw;iaC3ux-}ELleF>HDP_*{5^MGR8p!>VSHHhNS|%G~HjG zv7Pbk_UrPD3%P;9KzG@4f_qtAo{?cYhYO<~BLL5csO|s& From 22bf9d1c490fc39b0278068e4c34bffb0a2e9e77 Mon Sep 17 00:00:00 2001 From: Huang-Ming Huang Date: Fri, 11 Sep 2026 17:49:19 +0000 Subject: [PATCH 04/15] Fix WIRE-385 withheld schedule recovery Change-Id: I19e4974e0f48b42dec305712c3d6eb3332d8c552 --- contracts/sysio.epoch/src/sysio.epoch.cpp | 172 +++++++++++---- contracts/sysio.epoch/sysio.epoch.wasm | Bin 83254 -> 84196 bytes contracts/tests/sysio.msgch_chain_tests.cpp | 225 ++++++++++++++++---- 3 files changed, 308 insertions(+), 89 deletions(-) diff --git a/contracts/sysio.epoch/src/sysio.epoch.cpp b/contracts/sysio.epoch/src/sysio.epoch.cpp index 31af42153a..0c75d36020 100644 --- a/contracts/sysio.epoch/src/sysio.epoch.cpp +++ b/contracts/sysio.epoch/src/sysio.epoch.cpp @@ -710,16 +710,60 @@ void epoch::finishadv(uint32_t epoch_index, int64_t emission_amount) { check(state.current_epoch_index == epoch_index, "finishadv epoch mismatch"); const bool had_expiring_group = epoch_index > 1; + auto window_is_structurally_complete = [&]() { + return state.batch_op_groups.size() == cfg.batch_op_groups && + std::all_of(state.batch_op_groups.begin(), state.batch_op_groups.end(), + [&](const auto& group) { return group.size() == cfg.operators_per_epoch; }); + }; + + opreg::operators_t current_ops(OPREG_ACCOUNT); + auto is_active_batch_operator = [&](name account) { + const auto key = opreg::operator_key{account.value}; + if (!current_ops.contains(key)) return false; + const auto op = current_ops.get(key); + return op.status == OperatorStatus::OPERATOR_STATUS_ACTIVE && + op.type == OperatorType::OPERATOR_TYPE_BATCH; + }; + + // For rotating schedules, a complete persisted window was published by the + // preceding epoch. An incomplete one was deliberately withheld, because the + // queueout gate below never publishes a short group. That persisted shape is + // therefore the rotation checkpoint: after the first withheld window has + // advanced into the group outposts already know, later epochs must hold that + // group on duty until the future seats can be repaired and announced. A + // single group uses the same structural value but never slides; its separate + // eligibility gate below decides whether its in-place repair may publish. + const bool previous_window_was_published = window_is_structurally_complete(); + const bool single_group_schedule = cfg.batch_op_groups == 1; + + // A single group never rotates: the same positions authorize every epoch. + // Keep that announced vector in place and replace an ineligible seat at its + // exact index only after a standby exists. Until then, the final publication + // gate treats the named but ineligible seat as a vacancy and withholds the + // group. Healthy members therefore retain the chunk positions the outposts + // already know and can deliver the envelope that publishes the repair. + const bool advance_schedule = had_expiring_group && + previous_window_was_published && !state.batch_op_groups.empty() && + !single_group_schedule; + // A seated operator can have lost eligibility since the window was built. // Preserve healthy members' order and never reuse a resident to fill a gap. - opreg::operators_t current_ops(OPREG_ACCOUNT); - for (auto& group : state.batch_op_groups) { + // While a window is held, retain group 0 exactly as it was announced. Its + // remaining eligible members must deliver the recovery envelope using the + // outposts' existing positions; replacing or deleting a seat here would + // change that current group before the outposts can authorize the change. + // On a normal rotating advance, group 1 is the successor already announced + // to outposts. Preserve it exactly before it slides to index 0, even if one + // member just lost eligibility; its healthy members must retain their old + // positions long enough to deliver the repaired lookahead. During a hold, + // group 0 has the same protection. A single-group schedule always protects + // its sole announced group and repairs in place below. + const size_t announced_group_index = advance_schedule ? 1 : state.current_batch_op_group; + for (size_t group_index = 0; group_index < state.batch_op_groups.size(); ++group_index) { + if (group_index == announced_group_index) continue; + auto& group = state.batch_op_groups[group_index]; group.erase(std::remove_if(group.begin(), group.end(), [&](name account) { - const auto key = opreg::operator_key{account.value}; - if (!current_ops.contains(key)) return true; - const auto op = current_ops.get(key); - return op.status != OperatorStatus::OPERATOR_STATUS_ACTIVE || - op.type != OperatorType::OPERATOR_TYPE_BATCH; + return !is_active_batch_operator(account); }), group.end()); } @@ -737,10 +781,19 @@ void epoch::finishadv(uint32_t epoch_index, int64_t emission_amount) { // // After: window = [current, current+1, ..., current+N-1], front is // always the active group → current_batch_op_group stays at 0. - if (had_expiring_group && !state.batch_op_groups.empty()) { - const auto expired = state.batch_op_groups.front(); + std::vector expired; + if (advance_schedule) { + expired = state.batch_op_groups.front(); state.batch_op_groups.erase(state.batch_op_groups.begin()); + } else if (had_expiring_group && !previous_window_was_published) { + sysio::print("sysio.epoch::finishadv: previous operator window was withheld; " + "holding the announced current group at epoch ", + state.current_epoch_index, " while future seats are repaired\n"); + } + // Candidate selection runs for every materialized schedule and after a + // rotating slide. Only an already-absent schedule skips it. + if (advance_schedule || !state.batch_op_groups.empty()) { // Collect already-resident accounts so the new tail excludes them. std::vector resident; resident.reserve(cfg.batch_op_groups * cfg.operators_per_epoch); @@ -783,7 +836,7 @@ void epoch::finishadv(uint32_t epoch_index, int64_t emission_amount) { return a.first < b.first; }); - // Repair future seats before selecting the tail. Otherwise a removed + // Repair seats before selecting a new tail. Otherwise a removed // operator leaves a hole that eventually becomes an empty active group, // even when a healthy standby could have been announced one epoch ahead. // Prefer true standbys: recycling the expired group early would shorten @@ -791,16 +844,36 @@ void epoch::finishadv(uint32_t epoch_index, int64_t emission_amount) { // so repaired groups and the tail remain disjoint. // Vacancy recovery is an exception to the normal N-epoch duty spacing: // absence from this window does not prove an operator has never served - // recently, particularly with windows larger than three groups. - // Do not insert a new member into the CURRENT group here: outposts have + // recently, particularly with windows larger than three groups. When a + // prior window was withheld there is no new tail: fill its existing + // future vacancies in place while group 0 remains the announced duty. + // In a rotating schedule, do not insert a new member into the CURRENT group here: outposts have // not received this window yet, and their old chunk-slot assignments may - // collide with a replacement's position. That case retains the existing - // incomplete-window withholding behavior and requires roster recovery. - for (size_t g = 1; g < state.batch_op_groups.size(); ++g) { + // collide with a replacement's position. During a hold, ineligible + // announced seats remain as placeholders until this group delivers the + // repaired lookahead and expires; OPERATORS still carries their removal. + const size_t first_repair_group = single_group_schedule ? 0 : 1; + for (size_t g = first_repair_group; g < state.batch_op_groups.size(); ++g) { auto& group = state.batch_op_groups[g]; + + // A one-group schedule cannot compact its announced group without + // shifting healthy members' chunk positions. Replace each ineligible + // seat in place, consuming the same disjoint standby pool used for + // future-group repair. If the pool is exhausted, retain the old name + // as an unpublished denominator placeholder until a later advance. + if (single_group_schedule) { + for (auto& member : group) { + if (is_active_batch_operator(member)) continue; + if (pool.empty()) break; + member = pool.front().first; + pool.erase(pool.begin()); + } + } + while (group.size() < cfg.operators_per_epoch) { const auto standby = std::find_if(pool.begin(), pool.end(), [&](const auto& candidate) { - return std::find(expired.begin(), expired.end(), candidate.first) == expired.end(); + return !advance_schedule || + std::find(expired.begin(), expired.end(), candidate.first) == expired.end(); }); if (standby == pool.end()) break; group.push_back(standby->first); @@ -808,36 +881,34 @@ void epoch::finishadv(uint32_t epoch_index, int64_t emission_amount) { } } - std::vector new_tail; - new_tail.reserve(cfg.operators_per_epoch); - for (size_t i = 0; i < pool.size() && new_tail.size() < cfg.operators_per_epoch; ++i) { - new_tail.push_back(pool[i].first); - } + if (advance_schedule) { + std::vector new_tail; + new_tail.reserve(cfg.operators_per_epoch); + for (size_t i = 0; i < pool.size() && new_tail.size() < cfg.operators_per_epoch; ++i) { + new_tail.push_back(pool[i].first); + } - // A tail SHORTER than `operators_per_epoch` means the ACTIVE batch-operator - // roster has fallen below `batch_operator_minimum_active` (the config - // equality at ::setconfig pins that minimum to - // `operators_per_epoch * batch_op_groups`, i.e. exactly this window). The - // depot cannot repair that here: with a pool smaller than the window, N - // groups that are both FULL and DISJOINT do not exist, and both escapes - // are unsound -- re-seating a resident breaks the Ethereum disjointness - // above, while a short group lowers the quorum denominator it defines and - // makes EVEN group sizes reachable, where Ethereum's `(groupSize + 1) / 2` - // is an exact half and two competing digests can both tip. - // - // So the schedule is left as-is and the DECISION is pushed to the emit - // site: an incomplete window is never published (see the withhold - // below), and is reported so the roster can be repaired off-chain. - if (new_tail.size() < cfg.operators_per_epoch) { - sysio::print("sysio.epoch::finishadv: only ", new_tail.size(), " of ", - cfg.operators_per_epoch, - " eligible batch operators for the new tail group at epoch ", - state.current_epoch_index + cfg.batch_op_groups - 1, - "; the ACTIVE roster is below batch_operator_minimum_active " - "-- operator roster needs attention\n"); - } + // A tail SHORTER than `operators_per_epoch` means the ACTIVE batch-operator + // roster has fallen below `batch_operator_minimum_active` (the config + // equality at ::setconfig pins that minimum to + // `operators_per_epoch * batch_op_groups`, i.e. exactly this window). The + // depot cannot repair that here: with a pool smaller than the window, N + // groups that are both FULL and DISJOINT do not exist, and both escapes + // are unsound -- re-seating a resident breaks the Ethereum disjointness + // above, while a short group lowers the quorum denominator it defines. + // The incomplete persisted window records that publication was withheld, + // causing subsequent epochs to hold group 0 until a standby fills it. + if (new_tail.size() < cfg.operators_per_epoch) { + sysio::print("sysio.epoch::finishadv: only ", new_tail.size(), " of ", + cfg.operators_per_epoch, + " eligible batch operators for the new tail group at epoch ", + state.current_epoch_index + cfg.batch_op_groups - 1, + "; the ACTIVE roster is below batch_operator_minimum_active " + "-- holding the announced duty until the roster is repaired\n"); + } - state.batch_op_groups.push_back(std::move(new_tail)); + state.batch_op_groups.push_back(std::move(new_tail)); + } } // Pinned to the FRONT of the sliding window, unconditionally. The window @@ -988,13 +1059,20 @@ void epoch::finishadv(uint32_t epoch_index, int64_t emission_amount) { // while still sending OPERATORS with the authoritative removal statuses. // Never duplicate residents to fill it: Ethereum's chunk routing assumes // disjoint groups. Epoch accounting and envelope construction still run. + // In a rotating window, group 0 is historical by the time this lookahead + // lands and may retain an ineligible positional placeholder. In a + // one-group schedule it is also the active group, so every named seat + // must be eligible before publishing the replacement roster. + const bool single_group_is_eligible = !single_group_schedule || + (group_count == 1 && std::all_of(state.batch_op_groups.front().begin(), + state.batch_op_groups.front().end(), is_active_batch_operator)); const bool have_complete_window = next_group_index < group_count && - std::all_of(state.batch_op_groups.begin(), state.batch_op_groups.end(), - [&](const auto& group) { return group.size() == cfg.operators_per_epoch; }); + window_is_structurally_complete() && single_group_is_eligible; if (!have_complete_window) { sysio::print("sysio.epoch::finishadv: incomplete operator window at epoch ", state.current_epoch_index, - "; withholding BatchOperatorGroups until the roster is repaired\n"); + "; withholding BatchOperatorGroups and holding the announced duty " + "until the roster is repaired\n"); } attest.active_group_index = zpp::bits::vuint32_t{next_group_index}; attest.epoch_index = zpp::bits::vuint32_t{state.current_epoch_index}; diff --git a/contracts/sysio.epoch/sysio.epoch.wasm b/contracts/sysio.epoch/sysio.epoch.wasm index f373407af6b526592ddb998f83d8e0b12b540610..a4323dadefae338cc053eec93ef9cd229c41e5cf 100755 GIT binary patch delta 20242 zcmc(H3zQvInQrZ>Q|H;IyE^H{&%MIGj4Nj2Y4ams-zJFJp zN9S~45Jz2PSMA!5fB)~tKKHmN)xe3*L-P>ezgl zYk1Cec~!`po#y7tJi{~E&ATqkTdq;?nhM^s<$0qM9l;E{72kuuw`Y&rfMKrmdd3X2 z=-y$UXl}Wzw9np`J$~C|nV8|EGXqK6^vzq8{P!Wl8wj2n|FaM8G|icA+}mjuobzX8 zjpzkky{h2M$N~WGS3fiymEor|)VAP>mP_$(OY5nZZc|-GE+td8Vem}M$GrVU0so>y z=6Pno7VX$IWdmubm_wYQ}~JwIq}Ev zyhjys2@Y#F_o#dtJww6QkDKmSrp~H2bvCDTScjCGbk)PRxbcseBdTj8aSQ&t`IgIt z7+)QXh|z8imhp)VEBMqK?<#*Bh(7un+wEp=^o^7HR7eHa0ItuncA9{9P<5IE%8hqz z1PZ2|W$xtj$miCqIb@uL=@Rq9Icau=Yuu=I!HH<{+`&y+SyT9{PZN{6S+ zOPj;b2HqL%Y388_*vIk}8yK*XQ#ro@=T`Hwil4HsHPslg zbB!2+Zw(%{Vh8t~BXgE}YI#08G1mw+0`|lgf@SC8T-5S5n~)bQ)MW8%liN6(sz`>$ z-6pSLaUt*Uh;bK9ET5j8^$ncBqO^cl3@kBV7hKD^aTbs14T0msF7NT57|u;R`U@&2 zDB4eLlyLr68@2+N8%)4PX~d9CCe=~OGv{E4?OAhjNe*f^52(B?W60+t%C&tCOuDHR z*pHe4prvu71`{a5BjDHFHlLNiYHL`zUz4wR-hp#bXP5H4tlQ5iSQ75qbXo>IW` zt}5hgVKrO0im0+F^>Lb{IFUuVVFf+Zjbj8)=%zb?0c;5>lMdB_l~Q zM__Lx?6!+0mQRYnusdXc7#I>$M`FT>E_lz^g2LVdF|5*whSDeFhGRYXICMZh<$h2q<$xJ!OHCS zA(-rWUi8obNHO1r0ccOYxhD@seO>|hWvn$Re6Cw6Fl-T0aU=oO8fsjntftt|H5U%rs&H*s*I(@4r3#z`%F?{c~Vj(E*(9 zw6N|vhzNOMODHOv_zuHaQ3>lrr>zl$t%Kk}!QHArSm>Zokfs{@p_$zHR3n*BuON_? zx5+M$NubJSK`q#`fU&rD!0@4B@o>l}%Mc=TuHK zL=yYUUg6HNWfB|albvP=fiHppP(^^Sx!sd@MDAiZg#$n$v9lr0TI`ZwAOT)W-6r8d z=o<=Mqp%@>1viG`L=JYbP{RUA1DPBaiz3&61`h(ieU-oo}Na=FkAd<8gdG0I|yMHVQV*eveTR~411*9NCjED9mF z(tn?77!k*M!v+V3%;}w0nHl${!TL~4$+}{ysu`CY;jOVM<3%Q5=6r-$+UM}LnN?d^ zUawi%%JSZ-f)Xp4Rhw~bD$wfvG$&~)IcuTEqW`n&kPresq21&8a0xT>!JXROQf?;K zNLirXf`8SF(~7r~!H}ej*H}IgS{0Be896UG41fG`TQWwB?8)bYcwPgz|`kx2oe5kXKzM&%+k~bY5u!_8X zg}yNFpfCKxh`vxr&{-iFNmpv-h?d&BZKRS8-7)U0g(~uWK}rkp3-JVt4t+%57sTbr z!MH6|z z;^6Pdg~L}NUBagi2iz0?W!fdmK3uZKhpSn8KO|LNE+@fF|yV>TE4~dA^sPX3<+(A;swu#Fm1EEy?by1V%|F_PlGycR`{D9fFv0pFovnLLo_C7}g#@at;vaEt0vRd|*h7YEEkbeSU#l(iO>^ z_L9Ol4R(4JFr)Dy!FEam0v9qw3Zr%KXuSBy->6n!K#0jC4P{ct^9=jklthF8Q7c^p zv;a~9TxfW>51au7N7&^}5+n_nD2nPZzSD()ML3Al)1#r0<+Q^X;;e(Casie@WG32O zuNOMG=%?u_e*pW54}pGqtL1*gEOsz&afjcFrFraay0x~j4dV%h6Lh%n-P*u*=%I&y zekXHkXITs_fO5nSo#t9Rd)stO0R0u3YXk|-7s0#mY={R&Bz$Dg2$GjDTi)RqLIwnN zj88#Mi5lQ9G20`N1JEG+&4^+=b(piB)3Gubg36G@l>h}=3jqq0EqdZ`v#hvE!2^R6ed!d0Pm>5tvkbJQ4i@50mL{0U=9p1mw0uvoQZ+yU;6e_~pn)txEOQP`z~P z=%rg_>G~@-6g*l=jppr^B$~Dfz-i_@AY|dvX+qrsjZp)2%Q2RQ1{@n`3nr!-=JJ(uuKH zA+UhVvAJ2~LMT-j_m`mraGQni*MMVW;eOCEFuPq}Aw+yA3ZZ4R)E-N;z=q?hq&8%3 z(K5}R0&bmZ*%~)`X=ZKf@TjO7ClswFz^;{g6@7%!o2eE+M^R=es)c4aSygIb##Z9F z-VUJ@cx2j9Y#1&Ly^s!;t@Wfz=_P;2=STp7yKp+f<9+ty$XXv6g;+l#E|T%OIgx0PW$~11(8dG-8KY z_R+M2NJ83kgk?M_8)$N)0T2#R7|BVRFqKrB?t`GgFt)E6|UkR}gW$ zKN{X2t>T*)?4wqugIH+t3H79NlyMt3nkUDQ{K&RVD9fc^_FY23BvV%NBnxL%GQU-v zqxX*|{dZ~kfQU&)Awu|1nArV8L3b;pW$qq%`73GN9*{F;a^VEv3~6NsV5b zj`n*-i2oj4fwG8*sA{jXEGmcuVSJM`zF;pmo>NDQN`j&i?}7YjSxEzflobecl?@)_ zYzfo@T*QJ27kM_KNSk0}*br=xV<0NTT7q~sTpM^oVDh)p64Bz1HzlC^~?*8GhJ;)5YggR2E7!E5W$f(>Q zLBmvII2Iblz!c;FGh$ihqaY<^W>Nk)*EE zQga;L#Y62618b(?ISbdR(Bt{cp=oJ^P4diD-oa-iy~lf{J!Pi}A*VJ|;+I zD(1Faid?I3Kqc^tU;?CAnESLd55z~xe5ZN8r6H91S!43mL4N2?p5XqwsA=#X<4iTLJ7$BSjA^3HU(~M-! zSC9{LHmEj;z4KLHwQ2s@4hS@n!Jt5ZPT{-*pF_!z&r!vy&*l`PBxAUUa!4Ks{(5nt zhm>n%3?-V$`3Eo`QL&xH3ecibQbgtfCfP>Wu#D^heoq!i*B-JhWftuu&zU(<*$)557NhKWqE|eXdjX;vd&X%fDukqtTS2OZVOfq?K-$O%$71^%x4507Sxq0Vg;L(8QAJY%?CmL+N~wP>@A;Zq=|K6bd>pL#*jM{; zSM|rK#x^qoZ&dwtZSAx>qG@t|?}(}?-J(2;ps;+p+T0)yK*y#^T|m)jbs?(2!b`TYv+_EE6mFs_&SFYqn)q$|9=Gq*9!2Ky)1%U&M#4JJx6SZ1s4U}wIs{{uf zeTxptX1V?<1>Lk(nQ6I?mIlTc3sQpv#yPlI(h#HW)68AvMd_}unzOQTL-q--y1NCS zR1Lw&f)`+#h&O#NL+;sx4ykjuAdyCDf>yqW>UjTj@X-f<`;*7~vn)tm*P6a{PkmF1 zDlT9N(pq-sdH0>uwc%IzeRI|6n-jpWw6XBPzcV|ZO}B55fuBtP@tJcK{!Dtn^BK!WLbxOL%dptof_S& z=v}$?nRXk+b)-KF-f(&KO^TeT7V{zuoOIjG(v9RDPGnzsxAPl&jjF3{!+e7l@+G)M z|rgdbo}mR%t4`Se8OA;1}| z9OKML45ud+oXzmkHDPdBNF;0InEsLvp(^`Js1yO2p}T~eE_mVkfFAS_Hqn>Rc%IO^ zrJl!|+^ChosQw@lgI#Va+ij+TBbh{u8ahe3T<2Hr1691FCOF(*rify5UJBFuiHN1N zc9E?FaKXU40UYIe0ksxzT>ceWRl7*UAMqS%RpP#^7lGBX$ApBkNIzvi?jC)Qj-=4U z*bU9e(+pbL1(GTxaqRa5OT4@Joe%xWaA=^yx%HgDq>qjfAI!B56uv zJS>v(xHQxBl=v0-_ElBRlCF{BDp6DDsY8JXA)AL6FCpUtly`u=n=N#K27eIa^|@HX zhjtUKi6VZ|`MUDyVN>|&?M3$}p^B8;;%cP}2d%@90r-(xC(ta{CsE<_63)GXp>;SB zax;W>Cw9|3bcYaO9sOdE+DAT@1ve>;y0~Dsn3v9!A63_IzlCLjDPqy&FwV7a0qNj1viZgdnWk`RN{N6FF**EvdrtTyZnx`IbW zElM@u*HlfBp{`w8-1IT%5LQi5<3T!{x9}BA0J5`aVl@Sy+HGqJK52jSj>kGoP2q9W zsys|h!ROtb#SfXPUd5-p!x$AG{s5td5Hxai2qB4O5WZ6IsjRHCv}D(GFmz|G9!rMP zkCiC=GxCJ*s^U|DmjWCmlSOoKJ-!wR^lipMLQTb|dQl`+Nx<5Q4{oc~E=_ef*3y5y zick3*Ld!8KK0s4d=6DPoy-yXNGC*Tgd=M~?1xr-%fv$Zw6(4b`V3q!hDn8Vj9*gPF zhvj@eyq0n?9d@ocmxY=hrSE_U*2{EBofSOPu@X$@S0jSDuAck}BH))CRr*3nof#J9 z^CLtYJD>l~V(O25yMe9|ZZJ!V5sNx%ebIls z+yEtY_=|r9C6xuqXl1gJukz7K!Ya#zRRV94u*xEycGGx6zRE#^e3fPfC2b`?NufWiZ_-z#tq!6tF7}XRIpPzebl%E>b(O5uyT1tb=OnFg zKMSN9GiycSz28Zznv@k{!SJqMRAH#fS&6IpmrYsmTDdn?&WfiWGi61*MF}e;030%j zMpsRGMRzd+158VzM}7$;KNdzQCRlp6eN;Z?E6m5}K!%kV7Oog|(_a0T=3^LAyg&Jv zLx}1awhE&&A0w%Hq7bo#Ux{vEZe(1mvxUh$3e^CDo$6e$lxnn4F<(?ARLuJlO_3}m z%d&)l2(;ja*G&M!Z$v&Uyj2O!{1?gqk10$3RO3BofNO;5J<5`=f4}SfMf1PMkR|=o zwkmN#l#gqvpd{i|AwS|(UCOLV-O5jADN;=D>L2ES%#s%64|4R65p~@Lcq#uTR(@#> z9S@%BTletBZ@#$hOk^k-W$Tos&SK(TI)BZNpN?3rDLGEpKF0`C3rx!$JI%>Uz=+)V zLm3J1y#sjHj|};T8Im5PU2yKhqfd^Fa-Ijn0uU|pxOzYiZ?{ ze~O#iRPhp1ofN#>dqHr+){gNHsGR8Ig3C%LMaZooZXF9RTmY z`r+X6zHi`fQ~as#%Jh@45hidr#R%^G{vI5n@R&-fhf9lpaFUs>Tu2B$@jW-#HSirk z7hM0u56vUbmCk?if;j*F#oEE^+5E?#J5FCLP|y#$I)XokamK$qW}f=^)A+$I6A|}Y zIg9NUD+hNF$z`XPf4yQ0ln#3LSkAXf&YlgH`gZBQr`N?)sdUQoXW6Y7lLm-6{x(LQ zkn-#e3g3J)SoW_cs=nZfH^&DL{Oi1Nk76zU`~fSYL_EdF+#bY#u~26(e zzxWZF($z1-E%S}1gQYL_fAl+2|C&xwKdsUHRB-FBCIz4RKfd``^!T;l9}Y}ZKMHmn zxTNh#;sJkqLt6YbkEi%Pso?BZ8=p|E!Raq2_=h-Ncx6g(#mnv1kHJKb9BFI9XkOC6 zEJ~>pJaeQacRSF+bVuguU}K9qVE{OPH4jA_9oBPRq8|8BB+Hu%}^*3W-V zE+z=~AU#Sh2hspHqz`FaInOS(IFGdW>m}74(?f^4hGZycv;sgDj&lUen)AHIsdNk&!Gx8 zH9-QI2kui!`S|Na_~o)WMIZXL@X&{YiEnkPU8PIkns2ICgDrnrZvI{c&i0nl$#2h9 zYTw4?>eL9*8o*yve<(h#)W_BT>u)gCTvOdz{QH=?SlzaDYfLF~hYBuvp{4kJTg^0Y zGm9Hy>bR&?w-ry1s}IE1nVDN7J=Lpr6x$Q(zM%WjkDmM@<`dij{Hzb~nlme80^^j) zQ#_PVClPi3c7pn7aqmR@vp%T~n46#NUzSoAnCj8~M>DF~^nalAnPq163d8{jEQAH) zN*fUx1eWpB!uIU29l(#DH<_4-zp1203`Gz8>qatXI2!X3ABwXY)rU@y#qp~jJ^3&m zr00nAP+(H{!!U4GJsjxnMm6Q~E2_8?K>sr2N}1IX&aUE4*|-X588;n~17Ri5GJrt; zve?w5E&}5EuWC{o%?=rfnbL|%!3-Q*cEquz|2(w)9~`HKEpzbM$Bt8zW2wW0r~&=O z_BM40f9t2%Hc?$VPKE<8neG;x!>S1N+*(bj3)QaT2PUa6s_zukd&k)G-I zT-BnYw}jR;FwoZH}wM#kK8fB@9UM zjdm#e;pd7U>QLQ$Z#qS_7Vqs)OTw3@*$$tgHW6}&@{Q#|>C^O}n@&?Z*m=rSb#mrb zleK9Od+@p9l~Zx9zUPYfPgPwBe1OfpwfM?Z^@;LJ*Xe2@r*1u670~vNKSNDd?3g=E zJ;e48)6{0RcYRoWtCefRQdua{P$}qZL;aVZsj}wrUxHodopYFW$pU5aH1me@WO2=O z)oSlnZJq^QJg(R~UB#(N?|EfPJ{(hpu}4FU0sBLo(!V9IKBxNEbgHLfYG?6+bJf`= zepf;JfJ2<`MGrrS9=;zvJYKx-T-9NIyQP26xoX7rm#yf&?%JiRSGr4=b+1^kYUv91 z`fHagT6(>E{eqQvTy@Pg-HR8U>t3^T@uF*&T%v7&p) zD);IYOII&*7p!u-mn~g*4N!3XHPRyKAi@cQM#W9IqavB4`119mhH6a-9U8qU!TtUIYoBxWnaoV%D{gPB z{aAag|NFVlymnLcp&O#BF0oYBztq~-vP{#z-8#(QY&H3RwDNu;GC$e3#YlUG+rP#C zUgYS#cB&86N9tqMVP0ikV}8%P*1XPKXx`wz5XqYx-}2v&jA?ytwqv;V#d%f8o9*Uv zvt7eATFecz^Oj>2+`58${snoX9S{6T(I&k2zY?9^(2Z%1Javsp81Z;zbU$otV4SEZ7sLWsUL;9(AgMJt+$S z+*h77Y?bD#L(~%giN@LZZ)4MeCofe~j9gNGg2|KdJ-+KP3S3YXdg3Q#Vm)wTvkpuP>;ZV%P`b*;=mN zYW1sp#>q^Xbe)sNi&nfCFVBe!rtI<^zp?Y!s?{2gFVjxu)X}(2bA{O9YKf^=!DdZ( zoV+}rnbHyb-&*4oC+1JubA0Pp1v`>2!Y5YV|57;i!ABbYfpLxB8jA#&{1`NQRZGB-10F2b*pxBm2zTJZU&y?CzyBfy6@_aj(%eTKF18m#BmrX2Qx-FF(=w? zo{k~XZhj4e)oy+j1H=^Maj@7)`V;&Y1ku!6`{>pgwyaV5%U&7NJk=;gMnU49*i^cG z%$W(btYq$Ye9Bz9*+21+77mvlvd+AFvp;eC0T@c>jK4r}{N16~q|6(8O5Z$onx!`R zcTT!l{l-6L@&xsaf8pd`s;B*Z+%Bx?zu+!bTT16lIm1%x{HLd_RO?EYOusEUYLgn} zm~N!tD9DAqS?=sYvynd6KXm$N|6jlSpxXGOS$XsQjehg2>BnwTm>4P8Jsjl&j5a^S zI5HTWf-Uo!gRzBUdY(UV`$7JeSyf#|Ef3fMM)3ORzTOlXar|#pwG3?8@(`zc1;@GQlX~@cd zjq)I2R-3WZbkzkESiI{rsjF$%oPjwh*P4;ba1y|TpvxR5)o%7HC&hWtv3b}sfz1J5 zaz@t3nCUdcn^5YBuRjhdyY2%ZY1`=gqjeGto(eML?M3?)4=h07(0Tz@p5#-dItm&m9zh?N2` zc$u)T#Kp)0ENcS1OFEoI`s!~;S=cdmej%5#JjjZZqNF5=bBd~vr1eb_>y(q2fmKp6 z*GXywoCL^1;e$W93*!_T1>Sd3qFT&u7=RYDCw8tak|IFY1$)~XGt30laxrT%r_^!# z6o*JYg9+wjEyMg~Srj6I@h+hW$*-wz>6cgqHY^JCHF}-VIr)TZU6fC|@RLcGcG^5K zYX+i{aLtSIaZm*RjvwuQ{PD-$#}vdJxQfpl4ci~H&&UB6ihJ!|z0~(SEGAya<}YFt z_=FdofOAehJ0}mBc@YeAvVg)>$AP8!zK7W}okvE`JGRd3w;OWN%T)>_3vprbB49%A!T#{Z+~Xdtn;Db3hKO(o4^nXfqP|NNSUQaT7_)jGxsFnDAlvE!;F-}w6x2VBBEZ#iDF!Pk_hZgL{Nmlc znE?!Wu~SLFl4ybN(^e~GAD~ShJ^@T$4GbP;oDLH5VT*Z?o+?<37yG84N1&(%*`Koa zpte~vvJ9sL2-#V04Eg8N7ITM!C6XX(;1vBH0w%@*3L$3FVxW_YQ0t)*PUK`@7&1j^ zf;V8Lf%m|=%`-YoilSakZ?z=YYANi?iNwH((BQ$69BqK9gmy_3UauVkHBaDL9L;Cu zxRHzUF=}2)G*6uFp1+FXq_x)hZ{p!UndoU9h?A zYGc!UtlhLV5E%`LHip4KV z7sy=@2e=E(&|c5E5nJlYM;aWmg>&6&_8m8&-= zi{hu8dN*@!A)h4*(0fuRHV0zo*c9X}Y>pN$2mk^lEahP<$YdNo(NXvc))<2UBb%W$ zz?^h0g{?tbvNj1_%SHGsq-;;krt)D!Q= zrK7Sq6)j7^BUrNCj8MN2JIn!r%3du|_G%F=OW0k!S^{1z5$F{`syekgI5knyURBvd z6slJ-m=d3s1@pg0_W|<42nS9npVk7MAOUJ!Nh&0IG=}(;i1K?F5D83@xj5i=4L$>z z<8vU)aVJyAB|ts+CfErNfZ9yt#KHg=+>fL%9c@rBK{(Fiy5%4StHkL7;fkS^yK)+P zb;t5H1Otqyr!lJM{VDlrhS5~Mv4M;5?>usUQn+!nAdGa2$PUNlZ7 zXR!kQRhS&FFga5bx#+cnrH6=IGGaG9GZ6$XnOy>x40jt4xIiliZlV&kWCn>^uoV3S zI3y<8ra9wR3|VNq@xC0fc%LX_fjYq$l0M?Gt8Ag?nVD6AjYMRjO(Z!Z;L-+Y|Jfa* zB@i3G6|@!m(<4TuCsuGhFkyAnef$|rA=%xRPPVA$8P=%KZxU)-g; zJqoT-Cz9ebDMm1;6e`39aIj`V9uOQ`(+5}m9a~2d3!}A6rS@6!jPUVweRX#;nA{rLDaZ&}QMXtbqG5i&< zk`BLN1PI?oJpclrp~fT75zK(0ti@l9at0<2hBGC|=mY~okBfXl(?fDT=o|NBFmHN7 zCg1@17xKv)Jwf1x%_T$;Swusf(OD+*7bnfkh7iMIlb%t^8eu`8N8RHWNeUbkPWw1a z286i7wCzz(yLu2|8-#%VoQ8rkx5FUY3$>dD4J8=ma|AEiiQ;=$gz5`OWAzQgwaO>U z^c(Rk`mwlsaKQFE=C*iiuqElUT91}wRiS8`DFEJE5@Gk;eeVl5>L`3ML6wl8-CTrg zm(mHtRJIwClG)eu78dKE0%^pUotMxtukIB1cG2B+RWO$Xqh9m-chHsT?2 zsS84hHskalV!@PeRS12Ao-2vbk&?h@-GklLfkVR1GA1I;`Q{VFE8gx0EI=~|M8Yvk zmUM+oKMZb=kIaZ#z)d7kL^PUjn4WFqrxK*_l}uP=F=S0Ro(=PLJTxcaUUqzTPYlt* zy;H0!^N}ge!sU2?0Lv;kk>#$s*#16fj<^wFBbDDSxG8u_O^n)AZE=0!m74~x+@x3b z23@+o;L-{2x^&X9T@%V0ES)6KjW9hmKqD$15@?824bYw-q-kNh7P9Ox)FTE$Jz|)p zk?Tt2V55#9^&A!zoI1o42%yx(Ff;0c(V~JOygZPTPB7_Y!#Ir?h|>@oquc{OIRGb( z5*AH^TJQ|W286|FQmX+zEJQ7DIi3tkE?GJWy@l{-dyB*h&Hz?C$5?@np^N~4aZ`ty zr=upQHH<6D8^-ZWAKduGc`UbCExK5W7IbLrR3C4}stuCqYBbqIACL@31FgIkNTjEb>n9H<<_=^bRWVK9wm4JJ!# zfmEq#hZ+VhJ%o&k9YTC0ddHfxb_5~*h!$jNGitP;zN`ga+0xO;3}Wd5EpQXN0v{|5 zn^Cj0U0%ApGQHi9D|?>}Tbal~!C;8k^@+k^f;dtYMm86g;!MR@AU*P5xG3ih1uD$j zARH7T#L2)KBGe5e0^RvgE7uqq@6)t0NJB=#!*1~(Anu%wb4pv1W7Gr^{#T+@9 zb`EfS4gU4>8i$lYSdo!KD&jyHWK$^Q>6S~R^!dvm5FfJ(;&5T?>xgt#LJslI%rU(h zuvs1$1nfZITxSS^?5dOy=30Uv4;gIbga_G2<)c|3pt94KD{rt~&O)PSfT5v^X-x`{h`J^ZQ#OM|nX zCxuT@O~%sZe0LenF2IA=o5FH{d8@Fv1>oX?YOg2ajF^qWpGZM9BP_#2Ks7(&|Y zfn{ko@3Az5(iaK}Q^6D@)1U;26U=l{p%xNhK8qkv&$7q^NY`jcgKzHP>TCHmQh&JA z6fn@xfT_Th-|xf`0XjM0AvG2^LFK0$;~6KcMnjL46kI-xOU{Jy;%<$;@9 zs{w@OY)a|?a?oK&;SOXQwF}BNIzfYrHr2d$LLCW!z-ZY&g2a&; z4@Y6w07A8(h&lw~!YEpA6$-C4PRznyi9o>1uxAbqAXQ|MOjnmDA^h(R8gm&(**=ef zNbLb6@@n_&wq#GC9ZFEpr6mB&zFCb5RO+r!Lsio&5Ll=}StQv(uolOF5Q%rJ9AbcT=R;&4yo1x? z5W^9Hob6?z`Zj`P@zl~&P1tALLA2}X$h2d(rDQ_}_tQ0WY})we#kzdgM_Z;l?0{wD@}y_+RCxZF?)u1A$iaN72@ z84GY4rF^JokqXLhZN??lryYFCQyY-YbE}W%SLO~=*S_qjd5H!l3PWWe-1Xs?ZN@6I@}{<*xyUSd z;qR7&zjHfk7hM>B*@8nIRY{hDrI^i->44qQrbEQ^$1Fd)JxFhDZPmo7bhB&0dok+W zkQHS8Pz4wVYB<{s@Rlrw>jES?3QG43X7XSo47~+SuA4z8#fmqV;rt$);-l;3^?EeD zpR>?fZGxz4ZigZn1PA)rk4-UpavDsIRLC(xdf>razyBgU1g!-9bU)$@>>Q|x{S!qO zPha6^a|j2(lY-q3uK4qB9`Ytw$g`$&tX{b8PAHFuBPeJN+RgctK26xa!e5R#L|x7* zNNj)=oD>rvm}D%hPw**YT2xi!I z?jz6Q4uKsCwbI*=dNO(&pg-^#mUZN?4q~mZAH>+G}!j z!M+uNuO{raAoA$nCGw~ZJfu@4ejZ&jP*B2-Z?~8@fq^@&FAXfsX>@UDQ-KdnCI%+w z7#b_#Mqh;8H8{Iy#Thc(2zS?Xw88KM|0LQ#1HKY%^oj;oq757|uEaX4B3 zQUG^_6c53hYN!Dh>VZcjY}p=>*dcpa6E?MnkdWf$wN1=zfJ%2U0aZmD0$7PjSr`VwjE*=o zPtMB`1)ROBG&9SD0TObA0Vh3}3PJAVxFq^n1G||??u0Cv-As`MR@}(|D3QF-?*)EC z)9ghRbTe_r4^lSZh$TV7K{b*24Hn`6_D8y<;e$dumlRVb=K~@8-L$8`Wdlc;1j-c| ztD}G2bcczOZ(hTh6#I6BWm?K?gi(&rLX3qemaCDH!p4FYPxKr;aW-r=x;%^*x9^ZG z7Newzx(9b#i`}$Zhg$Mduh#`-`4EY{buQ0DRk~P~3 zg#mf>iP{@wQLjLRq9TtDq6}Tdeg}EE%!lTu1D~|09Ojb^M1DB>}9%|{J8Hu6|D#=;wU_-l5*u)U2>6O%OThl8UfD5{A!y!}C z&>OHSk*VpGP_AmYKO8PWuf$^^95tH(^KKAM6U!1=RbyJcX}n_4&j47 zr(Owdr?jDA4l9*$h)kso4eiP1>N3g;N*oo|fDmjo>25TeUwIraWo0}}h#~BQ5CWC) z%plF^I(kY}#><2V3gg~qa4vw+ku8;RmYxQ&<{?AwWfKF9-w>v$>?7DL;hN8ha~-h0 z7%3hLsMXJ*-K)2}CY}iF-=}4y2H2=Nu#+CBPFIEMP!;l=ByLd_ier$fP+bO9AwEP^ zD24%5A(0eTg}_8@TkB920_shYbRx73&U{siLp<{p6o>e`j8JiCJN!GB(ZwOJwmO8+ zLDE{5g?9gB)<01e@&tnxQ|dxw=I2rtsuqR3YE=mNU{J*W9E(Dp{15~kjoP};?iGco z2IZ0ve!gL6AuO7Pp<>JMwH9xvV#^O%Y|&YoF18>G3Z(Co6GEf>ybwo@=v4QI*eX1k@o!7-Q@SeZ= zLZ*ex!~Urf{`X1dDCNm}Z@%;8g@>V~iTtnnL+1$=&YJTF*7)s+<(N{;bZ`a|RVwbz z!t3zmOdi!m*2kYiNCCjz#!3n=JxT2Qm~L*zGco=@;~k5UL2+|?(0;Mp9bAT=RZlAS zm0$A3Hl_8e0&5vMEXaPJluoaZW^6}C=eh^}y38v+io)xz=nh+*t)hd%1%d%VM_&?( zT5NtkEH>V5paba&v(0LZf6X1!{eRis7O^g}@AT(xKdkio&RHt<3zb*)^3tI{drGOhN;`i3a z9%3%*DP6nrIHm3>ZMqK&e&4_4{=?Kt|LOZDn~Tii#%49z-~WL_)qS%64G&zV)P1Gs z>Q|L|%I|w{vH$cPZ3ztKJS4)P4M(H51HoQpY^ZWxVL>jeqH9mqOzvG!LlDHt=PEOQl-){ zTNXyt>eBibCPbSshZ>&aPsrp7UDx|d_KV|`deFcB!x8?MUOK+vAuPmSC8Pnn!@pB- zt>5|5ndVQGANl)&uBGO#x#kXk z!r)Q={Z}tHi<|tfzuR28^$$&|uJ+r<{I##0&-aNP&Hh*Z=&4n{{rW!s;y>Mnf1Y|6 zcSoxK^=H0*@~Cyh9wI0s{#wF%-j6BRPpV>PUN!l1`{Vp$2jA^)_W$;Ji|Y2r^^K{+ zWRY9)$#QM~i4Pk6Z}jbFb*oWDr$LQPoFHc)Hf=23(|5Mw_aoluNp64$x$0=c_OCi+ zA3yo#IJKeF`X*@otpDiS`>4nKt#9WD^apS6z$eAqC#YusJ3E^EgZpEl`^G9rcPD=v zEID45Jno%w>bL&4-nn7cbGnWye=Wi_DAu4Z5JSB4e1Py{9D%Tm`124llD2#<6vrrh zD0WOxd-~`9Ay%gN3-1<{`a6XCO!dCMWqYgoptN&)%F+a%6wCtXqsx~ocZ|ek@$`dL zb7|X-tYvog_#b?*LcQt#V&{Hpo4;Xa4(PwVQ+RgzhbO7OL8#`cw|wWLQ<~pa`3T_u z3l%V8;}11N?02d{hOEV~kM_NH|9#AArId%i5M$5Do6Q>O?E%v7`44^6t{yAB_0cR- zy<0p+sSDI!yY5x$8?+l4OC4ASwF-6dc1!(A{h#0Sa$~V`Pjw_t8Fl?`Pc=<-y%klK zQdbw>iK&y~3(WLmri%K{-r*F#6<5Dg_Z7dCP^YQ|#rqR#ubQDT0{UKGd?le?G4E6U zn$CRjPf2y<0WT|M67aQ#KyFU1G7OSgVN`K_O6^ZN_BiUOuKm(#A7wt*(=|1trkd)Z zuIu+u^``fr(wNIv;jc*vuvyp(`IfQFZm?PAj}OKpgK@1oQY z0U!jb%sZlB|AY_{)W`g~4_k|48-z;!=^wgX*EOh9%vPC*4`mcXgAahKYzSQC`LP(g zB2B8-GCMYR{b{5c7fHTHh%$Il++!crk3Y0>%UCsAF9*2fbEjaxr}Fq~M+pc2iR7(0 z?l@H5ae5|TR1u6is`%DeRi_>+emGWLuO2O4Hx7+Tyg%7dUnssYPMsaxT!FC*+}%g1 z?&8}yHJy6jo>vnyv^3OpkZDt~Bd?C-n_uVEm$gOeI$&Sbh>vKU;@T=DXqCq1>Cp`Q zjTikn^#37KUqbaa7jJ4-$EY{E;5cuMsrQP71J#A_CB>HygfCdSrIBKvjc1UgV@q+$cwn_{bMc1p zYDye0;B2oh{&u`Np>or5s5+CM9&)&9D*o(Hb!z#hYmdX!I2GL8U4B}8=Wul$Z!SAR z-QUEmP}H!{%b~f@2Y0$Ae^F&kwWxT)1hv;*AyU2^&;l%evUvFf_yv*(>L|Gat5yz?%cfA&0Q?%8Lbb)j>{{P}0ko&W9g=BA2QO;vjrKbody6uYOX nvBj&WtEt6})74W6!@Z`*NIz*7m&{PPuC5uXRYlHkHH`lU3r~3G diff --git a/contracts/tests/sysio.msgch_chain_tests.cpp b/contracts/tests/sysio.msgch_chain_tests.cpp index 8970d29206..f993b2646e 100644 --- a/contracts/tests/sysio.msgch_chain_tests.cpp +++ b/contracts/tests/sysio.msgch_chain_tests.cpp @@ -690,9 +690,11 @@ class sysio_msgch_chain_tester : public tester { /// Inspect the actual emitted envelope, after inline buildenv drained queueout. /// The final OPERATORS snapshot must match the registry, and any published - /// schedule must follow that snapshot and contain only active batch operators. + /// active/future schedule must follow that snapshot. A held historical seat + /// can intentionally remain as an inactive denominator placeholder. void require_fresh_roster(uint64_t chain_code, name account, - opp::types::OperatorStatus expected_status) { + opp::types::OperatorStatus expected_status, + bool expect_schedule_absence = true) { const auto row = find_outbound_envelope(chain_code); BOOST_REQUIRE(!row.is_null()); const auto env = decode_envelope(row["raw_envelope"].as>()); @@ -731,7 +733,7 @@ class sysio_msgch_chain_tester : public tester { } } BOOST_REQUIRE(found_operator); - if (expected_status != opp::types::OPERATOR_STATUS_ACTIVE) { + if (expected_status != opp::types::OPERATOR_STATUS_ACTIVE && expect_schedule_absence) { const auto state = read_epoch_state(); for (const auto& group : state["batch_op_groups"].get_array()) { for (const auto& member : group.get_array()) { @@ -1704,7 +1706,8 @@ BOOST_FIXTURE_TEST_CASE(noncanonical_delivery_slashes_before_termination, sysio_ BOOST_REQUIRE_EQUAL(epoch + kEpochAdvanceCount, current_epoch()); BOOST_REQUIRE_EQUAL(kExpectedDeliveredLogCount, delivered_dellog_count(BATCHOP)); for (const auto chain : {ETH_OUTPOST_ID, SOL_OUTPOST_ID}) { - require_fresh_roster(chain, BATCHOP, opp::types::OPERATOR_STATUS_SLASHED); + require_fresh_roster(chain, BATCHOP, opp::types::OPERATOR_STATUS_SLASHED, + /*expect_schedule_absence=*/false); // The remaining two members must not be advertised with a reduced quorum. BOOST_REQUIRE_EQUAL(0, shipped_batch_operator_groups_count(chain)); } @@ -2107,6 +2110,98 @@ BOOST_FIXTURE_TEST_CASE(advance_ships_group_index_zero_for_single_group, sysio_m } } FC_LOG_AND_RETHROW() } +/// A one-group schedule has no pre-announced successor to rotate into. If one +/// member loses eligibility at the exact floor, keep the announced vector and +/// withhold it; once a standby appears, replace that member at the same slot so +/// every healthy incumbent keeps the chunk position already known to outposts. +BOOST_FIXTURE_TEST_CASE(advance_repairs_single_group_ineligible_slot_in_place, + sysio_msgch_chain_tester) { try { + bootstrap(/*n_batch_ops=*/3); + const auto initial = shipped_batch_operator_groups(ETH_OUTPOST_ID); + BOOST_REQUIRE_EQUAL(initial.groups_size(), 1); + BOOST_REQUIRE_EQUAL(initial.groups(0).operators_size(), 3); + BOOST_REQUIRE_EQUAL(initial.groups(0).operators(0).address(), BATCHOP.to_string()); + BOOST_REQUIRE_EQUAL(initial.groups(0).operators(1).address(), BATCHOP_C.to_string()); + BOOST_REQUIRE_EQUAL(initial.groups(0).operators(2).address(), BATCHOP_B.to_string()); + + BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, "terminate"_n, + mvo()("account", BATCHOP.to_string())("reason", std::string("starve one group")))); + produce_blocks(); + advance_to_next_epoch(); + + BOOST_REQUIRE_EQUAL(0, shipped_batch_operator_groups_count(ETH_OUTPOST_ID)); + auto held = read_epoch_state()["batch_op_groups"].get_array(); + BOOST_REQUIRE_EQUAL(held.size(), 1u); + BOOST_REQUIRE_EQUAL(held[0].get_array()[0].as_string(), BATCHOP.to_string()); + BOOST_REQUIRE_EQUAL(held[0].get_array()[1].as_string(), BATCHOP_C.to_string()); + BOOST_REQUIRE_EQUAL(held[0].get_array()[2].as_string(), BATCHOP_B.to_string()); + + BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, + "regoperator"_n, mvo()("account", BATCHOP_D.to_string()) + ("type", opp::types::OperatorType::OPERATOR_TYPE_BATCH)("is_bootstrapped", true))); + produce_blocks(); + advance_to_next_epoch(); + + const auto repaired = shipped_batch_operator_groups(ETH_OUTPOST_ID); + BOOST_REQUIRE_EQUAL(repaired.groups_size(), 1); + BOOST_REQUIRE_EQUAL(repaired.active_group_index(), 0u); + BOOST_REQUIRE_EQUAL(repaired.groups(0).operators_size(), 3); + BOOST_REQUIRE_EQUAL(repaired.groups(0).operators(0).address(), BATCHOP_D.to_string()); + BOOST_REQUIRE_EQUAL(repaired.groups(0).operators(1).address(), BATCHOP_C.to_string()); + BOOST_REQUIRE_EQUAL(repaired.groups(0).operators(2).address(), BATCHOP_B.to_string()); +} FC_LOG_AND_RETHROW() } + +/// Depot-state regression for the transition race: the group announced for +/// the next epoch is immutable while it slides to current duty. If its member +/// became ineligible, retain that exact seat as a placeholder and repair only +/// future groups. This one-seat fixture exercises state construction directly; +/// the three-seat end-to-end flow proves that a healthy majority can deliver +/// the recovery envelope across both outposts. +BOOST_FIXTURE_TEST_CASE(advance_preserves_ineligible_announced_successor_position, + sysio_msgch_chain_tester) { try { + constexpr uint32_t kGroups = 3; + constexpr uint64_t kRotationWindowMs = 12ULL * kGroups * EPOCH_DURATION_SEC * 1000ULL; + bootstrap_rotation(kRotationWindowMs); + + const auto initial = shipped_batch_operator_groups(ETH_OUTPOST_ID); + BOOST_REQUIRE_EQUAL(initial.groups_size(), 3); + BOOST_REQUIRE_EQUAL(initial.groups(1).operators(0).address(), BATCHOP_C.to_string()); + + // Remove both the expiring member and its already-announced successor. + // The transition must still slide to C without deleting its known seat. + for (const auto op : {BATCHOP, BATCHOP_C}) { + BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, + "terminate"_n, mvo()("account", op.to_string()) + ("reason", std::string("starve announced successor")))); + } + produce_blocks(); + advance_to_next_epoch(); + BOOST_REQUIRE_EQUAL(duty_member(), BATCHOP_C); + BOOST_REQUIRE_EQUAL(0, shipped_batch_operator_groups_count(ETH_OUTPOST_ID)); + + for (const auto op : {BATCHOP_D, BATCHOP_E}) { + BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, + "regoperator"_n, mvo()("account", op.to_string()) + ("type", opp::types::OperatorType::OPERATOR_TYPE_BATCH)("is_bootstrapped", true))); + } + produce_blocks(); + advance_to_next_epoch(); + + const auto repaired = shipped_batch_operator_groups(ETH_OUTPOST_ID); + BOOST_REQUIRE_EQUAL(repaired.groups_size(), 3); + BOOST_REQUIRE_EQUAL(repaired.active_group_index(), 1u); + BOOST_REQUIRE_EQUAL(repaired.groups(0).operators(0).address(), BATCHOP_C.to_string()); + BOOST_REQUIRE_EQUAL(repaired.groups(1).operators(0).address(), BATCHOP_B.to_string()); + BOOST_REQUIRE_EQUAL(repaired.groups(2).operators(0).address(), BATCHOP_D.to_string()); + + advance_to_next_epoch(); + BOOST_REQUIRE_EQUAL(duty_member(), BATCHOP_B); + const auto resumed = shipped_batch_operator_groups(ETH_OUTPOST_ID); + BOOST_REQUIRE_EQUAL(resumed.groups(0).operators(0).address(), BATCHOP_B.to_string()); + BOOST_REQUIRE_EQUAL(resumed.groups(1).operators(0).address(), BATCHOP_D.to_string()); + BOOST_REQUIRE_EQUAL(resumed.groups(2).operators(0).address(), BATCHOP_E.to_string()); +} FC_LOG_AND_RETHROW() } + /// The depot must NEVER publish an active index that names an EMPTY group: that index selects /// the group an outpost admits against and sizes its quorum from, so an empty one admits nobody, /// can never reach consensus, and wedges the outpost permanently — the handler that could replace @@ -2115,49 +2210,93 @@ BOOST_FIXTURE_TEST_CASE(advance_ships_group_index_zero_for_single_group, sysio_m /// The state is reached by starving an EXISTING window, which is the only way it is reachable: /// `schbatchgps` refuses to build a starved schedule up front ("not enough available batch /// operators for group assignment"), so a pool smaller than the window can only arise AFTER the -/// schedule exists — operators leaving the ACTIVE set. Here two of the three are administratively -/// terminated. The slide then finds no ACTIVE operator outside the surviving groups (residency is -/// what keeps window groups DISJOINT, which Ethereum's `_resolveChunkPosition` depends on, so it -/// is not relaxed to fill the gap), pushes an empty tail, and the lookahead index — `cursor + 1` -/// — names it. +/// schedule exists — operators leaving the ACTIVE set. Here the expiring operator is terminated +/// at the exact configured minimum. The first slide enters the next, already-announced group and +/// produces a short tail. Later advances must keep that announced group current until a new ACTIVE +/// standby fills the tail; otherwise Solana rejects the next duty group before the envelope carrying +/// its authorizing roster can land. /// -/// Asserted here: the BATCH_OPERATOR_GROUPS attestation is absent, AND the envelope still exists -/// carrying other attestations. The second half is the regression guard that matters — withholding -/// is implemented by skipping ONE queueout, and an early `return` from `advance` would also produce -/// a missing roster while silently dropping the rest of the epoch's emissions. -BOOST_FIXTURE_TEST_CASE(advance_withholds_batch_operator_groups_when_next_group_is_empty, +/// Asserted here: the incomplete window is withheld while other attestations continue, duty freezes +/// on the group outposts already know, a new operator repairs the future vacancy in place, and only +/// the advance AFTER that repaired lookahead was published resumes rotation. +BOOST_FIXTURE_TEST_CASE(advance_freezes_and_recovers_withheld_operator_window, sysio_msgch_chain_tester) { try { constexpr uint32_t kGroups = 3; constexpr uint64_t kRotationWindowMs = 12ULL * kGroups * EPOCH_DURATION_SEC * 1000ULL; bootstrap_rotation(kRotationWindowMs); - // A full window ships its roster every epoch — the baseline the withhold is measured against. + // schbatchgps interleaves the sorted roster as [A,C,B]. Epoch 1's envelope + // therefore announces C for epoch 2. BOOST_REQUIRE_EQUAL(1, shipped_batch_operator_groups_count(ETH_OUTPOST_ID)); + const auto initial = shipped_batch_operator_groups(ETH_OUTPOST_ID); + BOOST_REQUIRE_EQUAL(initial.groups_size(), 3); + BOOST_REQUIRE_EQUAL(initial.active_group_index(), 1u); + BOOST_REQUIRE_EQUAL(initial.groups(0).operators(0).address(), BATCHOP.to_string()); + BOOST_REQUIRE_EQUAL(initial.groups(1).operators(0).address(), BATCHOP_C.to_string()); + BOOST_REQUIRE_EQUAL(initial.groups(2).operators(0).address(), BATCHOP_B.to_string()); - // Starve it: terminate two of the three, leaving one ACTIVE operator for a three-seat window. - for (const auto& op : {BATCHOP_B, BATCHOP_C}) { - BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, "terminate"_n, - mvo()("account", op.to_string())("reason", std::string("starve the schedule window")))); - } + // Terminate the expiring group at exactly the three-seat minimum. The next + // group C remains healthy and was already announced by epoch 1. + BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, "terminate"_n, + mvo()("account", BATCHOP.to_string())("reason", std::string("starve the schedule window")))); produce_blocks(); - // Removed operators are pruned from surviving seats immediately. The pool - // cannot fill the window, so group attestations remain withheld while epoch - // accounting and authoritative operator-status publication continue. - bool observed_withhold = false; - for (uint32_t round = 0; round < kGroups + 1; ++round) { - advance_to_next_epoch(); - // `advance` skipped at most ONE queueout, never the rest of its work. - BOOST_REQUIRE_GT(shipped_attestation_count(ETH_OUTPOST_ID), 0); - if (shipped_batch_operator_groups_count(ETH_OUTPOST_ID) == 0) observed_withhold = true; - if (observed_withhold) { - // Once the lookahead seat is empty it stays empty — the roster is never republished - // while starved, and an empty active index is never shipped. - BOOST_REQUIRE_EQUAL(0, shipped_batch_operator_groups_count(ETH_OUTPOST_ID)); - } + advance_to_next_epoch(); + BOOST_REQUIRE_EQUAL(duty_member(), BATCHOP_C); + BOOST_REQUIRE_GT(shipped_attestation_count(ETH_OUTPOST_ID), 0); + BOOST_REQUIRE_EQUAL(0, shipped_batch_operator_groups_count(ETH_OUTPOST_ID)); + require_fresh_roster(ETH_OUTPOST_ID, BATCHOP, + opp::types::OPERATOR_STATUS_TERMINATED); + + // A second epoch while starved must retain C. Sliding to B here would make + // outposts reject B because the roster authorizing it was withheld above. + advance_to_next_epoch(); + BOOST_REQUIRE_EQUAL(duty_member(), BATCHOP_C); + BOOST_REQUIRE_GT(shipped_attestation_count(ETH_OUTPOST_ID), 0); + BOOST_REQUIRE_EQUAL(0, shipped_batch_operator_groups_count(ETH_OUTPOST_ID)); + + // If C loses eligibility while its previously announced group is held, it + // must remain as a positional placeholder long enough for the other current + // members to deliver a repaired lookahead. Replacing or deleting C in group + // zero would change the duty known to the outposts before they receive it. + BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, + "terminate"_n, mvo()("account", BATCHOP_C.to_string()) + ("reason", std::string("remove one held-duty member")))); + produce_blocks(); + advance_to_next_epoch(); + BOOST_REQUIRE_EQUAL(duty_member(), BATCHOP_C); + BOOST_REQUIRE_EQUAL(0, shipped_batch_operator_groups_count(ETH_OUTPOST_ID)); + require_fresh_roster(ETH_OUTPOST_ID, BATCHOP_C, + opp::types::OPERATOR_STATUS_TERMINATED, + /*expect_schedule_absence=*/false); + + // Two ACTIVE standbys restore the active roster minimum. D fills the held + // future vacancy; E remains available to build the next tail after C's + // placeholder group has delivered the repaired lookahead and expires. + for (const auto op : {BATCHOP_D, BATCHOP_E}) { + BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, + "regoperator"_n, mvo()("account", op.to_string()) + ("type", opp::types::OperatorType::OPERATOR_TYPE_BATCH)("is_bootstrapped", true))); } - BOOST_REQUIRE_MESSAGE(observed_withhold, - "starved window never withheld BATCH_OPERATOR_GROUPS -- an empty active group was published"); + produce_blocks(); + advance_to_next_epoch(); + BOOST_REQUIRE_EQUAL(duty_member(), BATCHOP_C); + const auto repaired = shipped_batch_operator_groups(ETH_OUTPOST_ID); + BOOST_REQUIRE_EQUAL(repaired.groups_size(), 3); + BOOST_REQUIRE_EQUAL(repaired.active_group_index(), 1u); + BOOST_REQUIRE_EQUAL(repaired.groups(0).operators(0).address(), BATCHOP_C.to_string()); + BOOST_REQUIRE_EQUAL(repaired.groups(1).operators(0).address(), BATCHOP_B.to_string()); + BOOST_REQUIRE_EQUAL(repaired.groups(2).operators(0).address(), BATCHOP_D.to_string()); + + // Only after the repaired lookahead lands may the schedule rotate to B. + advance_to_next_epoch(); + BOOST_REQUIRE_EQUAL(duty_member(), BATCHOP_B); + const auto resumed = shipped_batch_operator_groups(ETH_OUTPOST_ID); + BOOST_REQUIRE_EQUAL(resumed.groups_size(), 3); + BOOST_REQUIRE_EQUAL(resumed.active_group_index(), 1u); + BOOST_REQUIRE_EQUAL(resumed.groups(0).operators(0).address(), BATCHOP_B.to_string()); + BOOST_REQUIRE_EQUAL(resumed.groups(1).operators(0).address(), BATCHOP_D.to_string()); + BOOST_REQUIRE_EQUAL(resumed.groups(2).operators(0).address(), BATCHOP_E.to_string()); } FC_LOG_AND_RETHROW() } // WIRE-385: a removal during this advance must be visible in BOTH emitted @@ -2176,13 +2315,13 @@ BOOST_FIXTURE_TEST_CASE(advance_roster_excludes_same_epoch_termination, const auto groups = shipped_batch_operator_groups(chain); BOOST_REQUIRE_EQUAL(groups.groups_size(), 1); BOOST_REQUIRE_EQUAL(groups.groups(0).operators_size(), 3); - BOOST_REQUIRE_EQUAL(groups.groups(0).operators(0).address(), BATCHOP_B.to_string()); + BOOST_REQUIRE_EQUAL(groups.groups(0).operators(0).address(), BATCHOP_D.to_string()); BOOST_REQUIRE_EQUAL(groups.groups(0).operators(1).address(), BATCHOP_C.to_string()); - BOOST_REQUIRE_EQUAL(groups.groups(0).operators(2).address(), BATCHOP_D.to_string()); + BOOST_REQUIRE_EQUAL(groups.groups(0).operators(2).address(), BATCHOP_B.to_string()); } } FC_LOG_AND_RETHROW() } -BOOST_FIXTURE_TEST_CASE(advance_removes_inactive_surviving_group_members, +BOOST_FIXTURE_TEST_CASE(advance_preserves_announced_successor_and_prunes_later_inactive_members, sysio_msgch_chain_tester) { try { constexpr uint32_t GROUP_COUNT = 3; constexpr uint64_t WINDOW_MS = 12ULL * GROUP_COUNT * EPOCH_DURATION_SEC * 1000ULL; @@ -2193,9 +2332,11 @@ BOOST_FIXTURE_TEST_CASE(advance_removes_inactive_surviving_group_members, } produce_blocks(); advance_to_next_epoch(); - for (const auto op : {BATCHOP_B, BATCHOP_C}) { - require_fresh_roster(ETH_OUTPOST_ID, op, opp::types::OPERATOR_STATUS_SLASHED); - } + require_fresh_roster(ETH_OUTPOST_ID, BATCHOP_B, + opp::types::OPERATOR_STATUS_SLASHED); + require_fresh_roster(ETH_OUTPOST_ID, BATCHOP_C, + opp::types::OPERATOR_STATUS_SLASHED, + /*expect_schedule_absence=*/false); BOOST_REQUIRE_EQUAL(0, shipped_batch_operator_groups_count(ETH_OUTPOST_ID)); } FC_LOG_AND_RETHROW() } From de54feb9d34fdf1cb20c867e491ed1a6d35d1c3f Mon Sep 17 00:00:00 2001 From: Huang-Ming Huang Date: Fri, 11 Sep 2026 19:53:28 +0000 Subject: [PATCH 05/15] Regenerate dependent SYSIO message channel artifact Change-Id: Icf26c469260273eae00807ee342de882412fa8af --- contracts/sysio.msgch/sysio.msgch.wasm | Bin 157530 -> 160395 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/contracts/sysio.msgch/sysio.msgch.wasm b/contracts/sysio.msgch/sysio.msgch.wasm index b581f28a4f6c2d039c3f9a103e3d3b01ddea705b..e625184a3c4f59032ac298aa1827d24cbc85d885 100755 GIT binary patch delta 24903 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 GY5xNU(m-4Q From 6023cb49fc5bce846614fc79422ad905196d938e Mon Sep 17 00:00:00 2001 From: Huang-Ming Huang Date: Fri, 11 Sep 2026 21:45:55 +0000 Subject: [PATCH 06/15] Simplify WIRE-385 schedule publication and activation Change-Id: Ib910bb73aa5fcb157dfababb4f257d13cab13b3e --- contracts/sysio.chains/sysio.chains.wasm | Bin 35739 -> 35767 bytes .../include/sysio.epoch/sysio.epoch.hpp | 11 +- contracts/sysio.epoch/src/sysio.epoch.cpp | 393 +++++------------- contracts/sysio.epoch/sysio.epoch.abi | 4 + contracts/sysio.epoch/sysio.epoch.wasm | Bin 84196 -> 81868 bytes contracts/sysio.msgch/sysio.msgch.wasm | Bin 160395 -> 160471 bytes contracts/sysio.opreg/sysio.opreg.wasm | Bin 92467 -> 92496 bytes contracts/sysio.reserv/sysio.reserv.wasm | Bin 85200 -> 85229 bytes contracts/sysio.tokens/sysio.tokens.wasm | Bin 26737 -> 26764 bytes contracts/sysio.uwrit/sysio.uwrit.wasm | Bin 159688 -> 159717 bytes .../test_contracts/sendinline/sendinline.wasm | Bin 4346 -> 4346 bytes contracts/tests/sysio.msgch_chain_tests.cpp | 63 ++- contracts/tests/sysio.roa_tests.cpp | 5 +- .../src/group_election.hpp | 10 +- 14 files changed, 173 insertions(+), 313 deletions(-) diff --git a/contracts/sysio.chains/sysio.chains.wasm b/contracts/sysio.chains/sysio.chains.wasm index d3409e3fd52be3e679f7b7235dc4cbf6756c06d6..f16ef713753842999e29f887b50d69f4d3263e01 100755 GIT binary patch delta 180 zcmbO|ooV}YrVSgJ84qmU#Jrn}@$}|AJ_ByH8BAFMj#8UvOL~ehhEIN~t1hOK<-}ml z#Gt_7r~+plVAw3Kx6YXH!Q^9!Ml1@9jvAA{CI*GwV8~Kna+Ju@WM(jDW&jFb01F5p z1WteiG?_WT(jXR4p@RYgP?tiMK#G(CqXLsIgMuT2yA?wMQ1AdlmI}k0;2+xE`x$2gS!<&z~&7}?3w_2JR#2j diff --git a/contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp b/contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp index 4657246172..39c3e57bdb 100644 --- a/contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp +++ b/contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp @@ -81,14 +81,19 @@ namespace sysio { uint32_t current_epoch_index = 0; time_point current_epoch_start{}; time_point next_epoch_start{}; - uint8_t current_batch_op_group = 0; // 0, 1, or 2 - std::vector> batch_op_groups; // 3 groups of 7 + /// Duty within the last activated window; independent of the epoch number. + uint8_t current_batch_op_group = 0; + /// Last activated complete window. Candidate construction never modifies it. + std::vector> batch_op_groups; + /// Complete window published this epoch, to activate on the next advance. + /// Empty when publication was withheld: the current duty continues. + std::vector> next_batch_op_groups; checksum256 last_consensus_hash; bool is_paused = false; SYSLIB_SERIALIZE(epoch_state, (current_epoch_index)(current_epoch_start)(next_epoch_start) - (current_batch_op_group)(batch_op_groups)(last_consensus_hash)(is_paused)) + (current_batch_op_group)(batch_op_groups)(next_batch_op_groups)(last_consensus_hash)(is_paused)) }; using epochstate_t = sysio::kv::global<"epochstate"_n, epoch_state>; diff --git a/contracts/sysio.epoch/src/sysio.epoch.cpp b/contracts/sysio.epoch/src/sysio.epoch.cpp index 0c75d36020..142df93e4f 100644 --- a/contracts/sysio.epoch/src/sysio.epoch.cpp +++ b/contracts/sysio.epoch/src/sysio.epoch.cpp @@ -311,9 +311,8 @@ void epoch::setconfig(uint32_t epoch_duration_sec, } // The materialized rotation schedule (epoch_state.batch_op_groups) is - // sized from batch_op_groups once, at schbatchgps; advance() thereafter - // preserves its length (pop-front / push-back) and never re-reads the - // config to resize it. Every downstream invariant -- advance()'s + // sized from batch_op_groups at schbatchgps; later announcements and + // activations preserve that configured length. Every downstream invariant -- advance()'s // scheduling horizon (current_epoch_index + batch_op_groups - 1) and // sysio.opreg's termination window -- assumes cfg.batch_op_groups equals // the live rotation length, so once a schedule exists the group count is @@ -708,13 +707,18 @@ void epoch::finishadv(uint32_t epoch_index, int64_t emission_amount) { epochstate_t state_tbl(get_self()); auto state = state_tbl.get(); check(state.current_epoch_index == epoch_index, "finishadv epoch mismatch"); - const bool had_expiring_group = epoch_index > 1; - - auto window_is_structurally_complete = [&]() { - return state.batch_op_groups.size() == cfg.batch_op_groups && - std::all_of(state.batch_op_groups.begin(), state.batch_op_groups.end(), - [&](const auto& group) { return group.size() == cfg.operators_per_epoch; }); - }; + // Activate only a window published by the preceding advance. On a hold + // there is no pending announcement, so both membership and positions stay + // unchanged. In particular, one-group replacements cannot serve early. + std::vector expired; + const bool activate_schedule = !state.next_batch_op_groups.empty(); + if (activate_schedule) { + if (cfg.batch_op_groups > 1 && state.current_batch_op_group < state.batch_op_groups.size()) + expired = state.batch_op_groups[state.current_batch_op_group]; + state.batch_op_groups = std::move(state.next_batch_op_groups); + state.next_batch_op_groups.clear(); + state.current_batch_op_group = cfg.batch_op_groups > 1 ? 1 : 0; + } opreg::operators_t current_ops(OPREG_ACCOUNT); auto is_active_batch_operator = [&](name account) { @@ -725,204 +729,78 @@ void epoch::finishadv(uint32_t epoch_index, int64_t emission_amount) { op.type == OperatorType::OPERATOR_TYPE_BATCH; }; - // For rotating schedules, a complete persisted window was published by the - // preceding epoch. An incomplete one was deliberately withheld, because the - // queueout gate below never publishes a short group. That persisted shape is - // therefore the rotation checkpoint: after the first withheld window has - // advanced into the group outposts already know, later epochs must hold that - // group on duty until the future seats can be repaired and announced. A - // single group uses the same structural value but never slides; its separate - // eligibility gate below decides whether its in-place repair may publish. - const bool previous_window_was_published = window_is_structurally_complete(); - const bool single_group_schedule = cfg.batch_op_groups == 1; - - // A single group never rotates: the same positions authorize every epoch. - // Keep that announced vector in place and replace an ineligible seat at its - // exact index only after a standby exists. Until then, the final publication - // gate treats the named but ineligible seat as a vacancy and withholds the - // group. Healthy members therefore retain the chunk positions the outposts - // already know and can deliver the envelope that publishes the repair. - const bool advance_schedule = had_expiring_group && - previous_window_was_published && !state.batch_op_groups.empty() && - !single_group_schedule; - - // A seated operator can have lost eligibility since the window was built. - // Preserve healthy members' order and never reuse a resident to fill a gap. - // While a window is held, retain group 0 exactly as it was announced. Its - // remaining eligible members must deliver the recovery envelope using the - // outposts' existing positions; replacing or deleting a seat here would - // change that current group before the outposts can authorize the change. - // On a normal rotating advance, group 1 is the successor already announced - // to outposts. Preserve it exactly before it slides to index 0, even if one - // member just lost eligibility; its healthy members must retain their old - // positions long enough to deliver the repaired lookahead. During a hold, - // group 0 has the same protection. A single-group schedule always protects - // its sole announced group and repairs in place below. - const size_t announced_group_index = advance_schedule ? 1 : state.current_batch_op_group; - for (size_t group_index = 0; group_index < state.batch_op_groups.size(); ++group_index) { - if (group_index == announced_group_index) continue; - auto& group = state.batch_op_groups[group_index]; - group.erase(std::remove_if(group.begin(), group.end(), [&](name account) { - return !is_active_batch_operator(account); - }), group.end()); - } - - // ── Slide the schedule window ─────────────────────────────────────────── - // Skip on the genesis advance (0 → 1): schbatchgps just placed - // [G1, G2, G3] for epochs 1, 2, 3 and G1 is now the current (front) - // group — popping here would lose it. From the SECOND advance onward - // (1 → 2, 2 → 3, ...), the front group has just expired so we pop - // it and compute a new tail. - // - // Eligibility for the new tail: ACTIVE batch ops, sorted non-bootstrapped - // first (preference rule), MINUS anyone already resident in the N-1 - // surviving groups. The window itself encodes "scheduled in the last - // N-1 epochs" — no separate history table. - // - // After: window = [current, current+1, ..., current+N-1], front is - // always the active group → current_batch_op_group stays at 0. - std::vector expired; - if (advance_schedule) { - expired = state.batch_op_groups.front(); - state.batch_op_groups.erase(state.batch_op_groups.begin()); - } else if (had_expiring_group && !previous_window_was_published) { - sysio::print("sysio.epoch::finishadv: previous operator window was withheld; " - "holding the announced current group at epoch ", - state.current_epoch_index, " while future seats are repaired\n"); - } + // Construct a disposable candidate beginning with this epoch's serving + // group. Multi-group publication names its successor; a single group names + // its repaired self. Only the candidate may acquire replacement members. + std::vector> candidate_groups; + const uint32_t next_group_index = cfg.batch_op_groups > 1 ? 1 : 0; + if (state.current_batch_op_group < state.batch_op_groups.size()) { + candidate_groups.assign(state.batch_op_groups.begin() + state.current_batch_op_group, + state.batch_op_groups.end()); + const size_t retained_groups = candidate_groups.size(); + candidate_groups.resize(cfg.batch_op_groups); + + // Keep healthy seats at their existing positions. For multiple groups, + // candidate group zero is the unchanged delivery group and may contain + // inactive historical placeholders. Every active/future seat must be live. + for (size_t g = next_group_index; g < candidate_groups.size(); ++g) { + auto& group = candidate_groups[g]; + group.resize(cfg.operators_per_epoch); + for (auto& member : group) + if (!is_active_batch_operator(member)) member = name{}; + } - // Candidate selection runs for every materialized schedule and after a - // rotating slide. Only an already-absent schedule skips it. - if (advance_schedule || !state.batch_op_groups.empty()) { - // Collect already-resident accounts so the new tail excludes them. std::vector resident; - resident.reserve(cfg.batch_op_groups * cfg.operators_per_epoch); - for (const auto& g : state.batch_op_groups) { - for (const auto& a : g) resident.push_back(a); - } - auto is_resident = [&](name a) { - for (const auto& r : resident) if (r == a) return true; - return false; - }; + for (const auto& group : candidate_groups) + for (const auto member : group) + if (member.value != 0) resident.push_back(member); - // Pull ACTIVE batch ops, non-bootstrapped first. `exclude_resident` - // applies the load-spreading rule (an operator that served in one of the - // N-1 surviving groups is skipped, which is what makes "at most every - // Nth epoch" hold). One collector for both passes -- the only difference - // between them is whether that rule is enforced. - // The tail is drawn ONLY from operators not already resident in the - // surviving window groups. That residency exclusion is what makes "at - // most every Nth epoch" hold, and -- less obviously -- it is what keeps - // the window's groups DISJOINT, which the Ethereum outpost depends on by - // construction: `OPPInbound._resolveChunkPosition` scans the groups in - // order and returns the FIRST one containing the sender, using the - // sender's index WITHIN that group as its chunk-staging header slot. An - // operator seated in two groups therefore stages against a group it is - // not serving in. Do not "fill" a short tail by re-seating a resident. - opreg::operators_t opreg_ops(OPREG_ACCOUNT); - auto status_idx = opreg_ops.get_index<"bystatus"_n>(); + // One disjoint pool serves every vacancy and the tail. Preserve the + // existing non-bootstrapped preference and deterministic account order. std::vector> pool; + auto status_idx = current_ops.get_index<"bystatus"_n>(); for (auto it = status_idx.lower_bound( magic_enum::enum_integer(OperatorStatus::OPERATOR_STATUS_ACTIVE)); - it != status_idx.end() && - it->status == OperatorStatus::OPERATOR_STATUS_ACTIVE; ++it) { + it != status_idx.end() && it->status == OperatorStatus::OPERATOR_STATUS_ACTIVE; ++it) { if (it->type != OperatorType::OPERATOR_TYPE_BATCH) continue; - if (is_resident(it->account)) continue; + if (std::find(resident.begin(), resident.end(), it->account) != resident.end()) continue; pool.push_back({it->account, it->is_bootstrapped}); } - std::sort(pool.begin(), pool.end(), - [](const auto& a, const auto& b) { - if (a.second != b.second) return !a.second; // non-bootstrapped first - return a.first < b.first; - }); - - // Repair seats before selecting a new tail. Otherwise a removed - // operator leaves a hole that eventually becomes an empty active group, - // even when a healthy standby could have been announced one epoch ahead. - // Prefer true standbys: recycling the expired group early would shorten - // its duty interval unnecessarily. All selections consume the same pool, - // so repaired groups and the tail remain disjoint. - // Vacancy recovery is an exception to the normal N-epoch duty spacing: - // absence from this window does not prove an operator has never served - // recently, particularly with windows larger than three groups. When a - // prior window was withheld there is no new tail: fill its existing - // future vacancies in place while group 0 remains the announced duty. - // In a rotating schedule, do not insert a new member into the CURRENT group here: outposts have - // not received this window yet, and their old chunk-slot assignments may - // collide with a replacement's position. During a hold, ineligible - // announced seats remain as placeholders until this group delivers the - // repaired lookahead and expires; OPERATORS still carries their removal. - const size_t first_repair_group = single_group_schedule ? 0 : 1; - for (size_t g = first_repair_group; g < state.batch_op_groups.size(); ++g) { - auto& group = state.batch_op_groups[g]; - - // A one-group schedule cannot compact its announced group without - // shifting healthy members' chunk positions. Replace each ineligible - // seat in place, consuming the same disjoint standby pool used for - // future-group repair. If the pool is exhausted, retain the old name - // as an unpublished denominator placeholder until a later advance. - if (single_group_schedule) { - for (auto& member : group) { - if (is_active_batch_operator(member)) continue; - if (pool.empty()) break; - member = pool.front().first; - pool.erase(pool.begin()); - } - } + std::sort(pool.begin(), pool.end(), [](const auto& a, const auto& b) { + if (a.second != b.second) return !a.second; + return a.first < b.first; + }); - while (group.size() < cfg.operators_per_epoch) { - const auto standby = std::find_if(pool.begin(), pool.end(), [&](const auto& candidate) { - return !advance_schedule || + for (size_t g = next_group_index; g < candidate_groups.size(); ++g) { + for (auto& member : candidate_groups[g]) { + if (member.value != 0) continue; + // On an ordinary advance, repair existing future groups with true + // standbys before recycling the expired group into the new tail. + // A hold may reuse it sooner to restore a complete disjoint window. + const auto replacement = std::find_if(pool.begin(), pool.end(), [&](const auto& candidate) { + return g >= retained_groups || std::find(expired.begin(), expired.end(), candidate.first) == expired.end(); }); - if (standby == pool.end()) break; - group.push_back(standby->first); - pool.erase(standby); + if (replacement == pool.end()) break; + member = replacement->first; + pool.erase(replacement); } } - - if (advance_schedule) { - std::vector new_tail; - new_tail.reserve(cfg.operators_per_epoch); - for (size_t i = 0; i < pool.size() && new_tail.size() < cfg.operators_per_epoch; ++i) { - new_tail.push_back(pool[i].first); - } - - // A tail SHORTER than `operators_per_epoch` means the ACTIVE batch-operator - // roster has fallen below `batch_operator_minimum_active` (the config - // equality at ::setconfig pins that minimum to - // `operators_per_epoch * batch_op_groups`, i.e. exactly this window). The - // depot cannot repair that here: with a pool smaller than the window, N - // groups that are both FULL and DISJOINT do not exist, and both escapes - // are unsound -- re-seating a resident breaks the Ethereum disjointness - // above, while a short group lowers the quorum denominator it defines. - // The incomplete persisted window records that publication was withheld, - // causing subsequent epochs to hold group 0 until a standby fills it. - if (new_tail.size() < cfg.operators_per_epoch) { - sysio::print("sysio.epoch::finishadv: only ", new_tail.size(), " of ", - cfg.operators_per_epoch, - " eligible batch operators for the new tail group at epoch ", - state.current_epoch_index + cfg.batch_op_groups - 1, - "; the ACTIVE roster is below batch_operator_minimum_active " - "-- holding the announced duty until the roster is repaired\n"); - } - - state.batch_op_groups.push_back(std::move(new_tail)); - } } - // Pinned to the FRONT of the sliding window, unconditionally. The window - // slides (erase-front + push-back), it does not rotate, so the group on duty - // is always at index 0 -- and the next-group lookahead further down derives - // `active_group_index` as `cursor + 1`, which is only the NEXT epoch's group - // because this is 0. A change that gives the cursor any other value must - // revisit that derivation; it is stated here, at the write, because that is - // the only place the invariant can actually be violated. - state.current_batch_op_group = 0; - - // Note: last_elected_epoch tracking is epoch-internal state. - // No operator table writes needed — group membership is in epoch_state.batch_op_groups. - + // Failed candidates never become persistent schedule state. Empty pending + // state explicitly means no new publication and therefore no next activation. + const bool publish_schedule = candidate_groups.size() == cfg.batch_op_groups && + std::all_of(candidate_groups.begin(), candidate_groups.end(), [&](const auto& group) { + return group.size() == cfg.operators_per_epoch && + std::all_of(group.begin(), group.end(), [](name member) { return member.value != 0; }); + }); + if (publish_schedule) { + state.next_batch_op_groups = std::move(candidate_groups); + } else { + sysio::print("sysio.epoch::finishadv: incomplete schedule candidate at epoch ", epoch_index, + "; withholding BatchOperatorGroups and retaining the announced duty\n"); + } state_tbl.set(state, ram_payer); // Queue OPERATORS attestation (full roster with authex chain addresses) for each outpost. @@ -993,87 +871,12 @@ void epoch::finishadv(uint32_t epoch_index, int64_t emission_amount) { } } - // Queue BATCH_OPERATOR_GROUPS attestation for each outpost. - // - // Ships ALL groups, and an active index that points ONE EPOCH AHEAD -- - // at the group that will be on duty for `current_epoch_index + 1`, not the - // one on duty now (SOL-378 / WNS-141). - // - // The lookahead is what makes outpost-side admission possible at all. An - // outpost that scopes `epoch_in` admission to its seated active group must - // already hold epoch N's duty group BEFORE epoch N's envelope arrives -- - // because that envelope's own deliverer is a member of epoch N's group, and - // authorising it is what lets the envelope land. Shipping epoch N's roster - // inside epoch N's envelope is circular: the roster that would authorise the - // delivery is inside the envelope being refused, so the bridge stalls - // permanently at the first rotation. - // - // Emitting the NEXT epoch's group here breaks that circle without loosening - // anything on the outpost: envelope N-1 seats epoch N's group, so when - // envelope N arrives the outpost already knows who is allowed to deliver it. - // - // Only the ATTESTATION looks ahead. The depot's own schedule state is - // untouched -- `current_batch_op_group` still names the group on duty NOW, - // and `advance` still slides the window so the front is the current epoch. - // Nothing that reads `epoch_state` changes meaning. - // - // `epoch_index` stays the epoch this envelope IS for; it identifies the - // envelope, not the roster, and no outpost reads it. - { + // Publish a complete next window using the unchanged OPP lookahead format. + // Current duty is kept separately and cannot change until the next advance. + // Group zero of a rotating candidate is historical when this announcement + // lands; inactive placeholders there preserve the serving group's positions. + if (publish_schedule) { opp::attestations::BatchOperatorGroups attest; - // The window SLIDES; it does not rotate. `advance` erases the front and - // pushes a new tail, and every write to the cursor pins it to 0 (here, - // and `schbatchgps`) -- so the group on duty NEXT is simply the one - // after the cursor. - // - // Deliberately NOT `(cursor + 1) % group_count`. A modulo encodes ring - // semantics this window does not have: on wrap it yields 0, which names - // the group whose duty just STARTED. That ships a stale roster with - // nothing to catch it -- no compile error, no failing test, and the - // outpost cannot distinguish a stale index from a fresh one. - // - // The bound check is also what keeps an EMPTY schedule off a division. - // `group_count == 0` is reachable here: the slide above is guarded by - // `!empty()`, but nothing requires a seated schedule before this block, - // and `% 0` is an `i32.rem_u` trap that would abort `advance` and halt - // epoch advancement chain-wide. - // - // Falling back to the cursor covers the single-group case: the same - // operators serve every epoch, so current IS next. - // - // The cursor is pinned to 0 by every write to it, and the fallback is - // only correct BECAUSE of that -- with a non-zero cursor it would return - // the group whose duty just started, which is the stale-roster outcome - // the modulo was rejected for. The invariant is asserted at the WRITE - // site (`state.current_batch_op_group = 0` earlier in this same call), - // not here: a check at this point is unreachable-by-construction and so - // proves nothing -- it can only ever observe the value assigned a few - // hundred lines above it. - const uint32_t group_count = static_cast(state.batch_op_groups.size()); - const uint32_t next_index = state.current_batch_op_group + 1; - const uint32_t next_group_index = - next_index < group_count ? next_index : state.current_batch_op_group; - - // Removing ineligible members must not lower an outpost's quorum - // denominator or publish an empty group. Withhold an incomplete window, - // while still sending OPERATORS with the authoritative removal statuses. - // Never duplicate residents to fill it: Ethereum's chunk routing assumes - // disjoint groups. Epoch accounting and envelope construction still run. - // In a rotating window, group 0 is historical by the time this lookahead - // lands and may retain an ineligible positional placeholder. In a - // one-group schedule it is also the active group, so every named seat - // must be eligible before publishing the replacement roster. - const bool single_group_is_eligible = !single_group_schedule || - (group_count == 1 && std::all_of(state.batch_op_groups.front().begin(), - state.batch_op_groups.front().end(), is_active_batch_operator)); - const bool have_complete_window = next_group_index < group_count && - window_is_structurally_complete() && single_group_is_eligible; - if (!have_complete_window) { - sysio::print("sysio.epoch::finishadv: incomplete operator window at epoch ", - state.current_epoch_index, - "; withholding BatchOperatorGroups and holding the announced duty " - "until the roster is repaired\n"); - } attest.active_group_index = zpp::bits::vuint32_t{next_group_index}; attest.epoch_index = zpp::bits::vuint32_t{state.current_epoch_index}; // Propagate the depot's minimum epoch duration so the outpost can @@ -1081,7 +884,7 @@ void epoch::finishadv(uint32_t epoch_index, int64_t emission_amount) { // seconds since the current epoch started — see // .claude/rules/opp-consensus.md. attest.epoch_duration_sec = zpp::bits::vuint32_t{cfg.epoch_duration_sec}; - for (auto& group : state.batch_op_groups) { + for (const auto& group : state.next_batch_op_groups) { opp::attestations::BatchOperatorGroup grp; for (auto& op_name : group) { opp::types::ChainAddress addr; @@ -1097,22 +900,19 @@ void epoch::finishadv(uint32_t epoch_index, int64_t emission_amount) { auto out = zpp::bits::out{encoded, zpp::bits::no_size{}}; (void)out(attest); - // Withhold only the group attestation, never the remaining epoch work. - if (have_complete_window) { - sysio::chains::chains_t chains_tbl(CHAINS_ACCOUNT); - for (auto it = chains_tbl.begin(); it != chains_tbl.end(); ++it) { - if (!is_active_outpost(*it)) continue; - action( - permission_level{get_self(), "owner"_n}, - MSGCH_ACCOUNT, - "queueout"_n, - std::make_tuple( - it->code.value, - opp::types::ATTESTATION_TYPE_BATCH_OPERATOR_GROUPS, - encoded - ) - ).send(); - } + sysio::chains::chains_t chains_tbl(CHAINS_ACCOUNT); + for (auto it = chains_tbl.begin(); it != chains_tbl.end(); ++it) { + if (!is_active_outpost(*it)) continue; + action( + permission_level{get_self(), "owner"_n}, + MSGCH_ACCOUNT, + "queueout"_n, + std::make_tuple( + it->code.value, + opp::types::ATTESTATION_TYPE_BATCH_OPERATOR_GROUPS, + encoded + ) + ).send(); } } @@ -1135,7 +935,7 @@ void epoch::finishadv(uint32_t epoch_index, int64_t emission_amount) { // onto t5state (pending_emission_amount + batch_group_epochs[group] // + last_epoch_emission for decay continuity). // 2. rcrdbatch: always queued. Records the immutable roster that accrued - // this epoch after the schedule has slid for the next advance. + // this epoch from the activated serving window. // 3. payepoch: queued only on pay-epochs. Reads the now-updated t5state // (which already includes this epoch's contribution from step 1), // distributes period_emission, and resets the accumulator. @@ -1181,11 +981,9 @@ void epoch::finishadv(uint32_t epoch_index, int64_t emission_amount) { // them into N groups (`cfg.batch_op_groups`). The resulting window is // [epoch_1_group, epoch_2_group, ..., epoch_N_group]. // -// After this, every per-epoch `advance` pops the front group and pushes -// a new tail group, where the tail's members are drawn from the ACTIVE -// pool MINUS anyone still resident in the N-1 surviving groups. The -// window itself encodes "scheduled in the last N-1 epochs"; no separate -// history table is needed. +// Each advance activates the preceding announcement, then proposes a complete +// next window. Candidates retain the current/future groups, repair vacancies, +// and append a disjoint tail. Failed candidates leave the serving window intact. // --------------------------------------------------------------------------- void epoch::schbatchgps() { require_auth(get_self()); @@ -1239,8 +1037,9 @@ void epoch::schbatchgps() { // Store the window; advance picks up from here. epochstate_t state_tbl(get_self()); epoch_state state = state_tbl.get_or_default(epoch_state{}); - state.batch_op_groups = new_groups; - state.current_batch_op_group = 0; // front-of-window is always current + state.batch_op_groups = std::move(new_groups); + state.next_batch_op_groups.clear(); + state.current_batch_op_group = 0; // bootstrap duty precedes the first announcement state_tbl.set(state, ram_payer); } diff --git a/contracts/sysio.epoch/sysio.epoch.abi b/contracts/sysio.epoch/sysio.epoch.abi index 2f292b432f..ef13ba11fc 100644 --- a/contracts/sysio.epoch/sysio.epoch.abi +++ b/contracts/sysio.epoch/sysio.epoch.abi @@ -111,6 +111,10 @@ "name": "batch_op_groups", "type": "B_vector_name_E[]" }, + { + "name": "next_batch_op_groups", + "type": "B_vector_name_E[]" + }, { "name": "last_consensus_hash", "type": "checksum256" diff --git a/contracts/sysio.epoch/sysio.epoch.wasm b/contracts/sysio.epoch/sysio.epoch.wasm index a4323dadefae338cc053eec93ef9cd229c41e5cf..dea0e930ebaa421927a505c6b0006334b208de08 100755 GIT binary patch delta 19158 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 Ypko!7bQu&J8QiTH61LydVp{7C0Aqt@J^%m! delta 275 zcmccql(YLO=Y|Vxj7^&_vULiEg)uVKD>FELV8~WtP+?GDaBA4nv3BZ#T{qm#nKK+2 z6d9Qu6c`*|Fk}h%yYd3%AsolP6AS_Y3Je-dKovkCnJj@oDQ+O+1498SB7SPqz1#V$T3t#OU~9a)P%A zCi_xj diff --git a/contracts/sysio.opreg/sysio.opreg.wasm b/contracts/sysio.opreg/sysio.opreg.wasm index 1cdf33ba6bad6c0b45cc8852d05d25b7f0f0cdcb..233715e6e641160cd98190ea51e4286842172109 100755 GIT binary patch delta 373 zcmYL?F-yZx6ov22OF~qHfFM#TyjZ~o>gofhzK9gj>R<*3hi<}KRa}JjHyEK~*LDyB z84M0mK{SX%s1ErF(*GcMUo85TbMANVc_*1z`NWFXY_T$dnr-M$n1~N~kd#6S> zH6?hq(d3p)P34~NE&|z@sb3%m&){Bgt8T>1vxyZz9i+iO{Xdf-L zk1>tT+E17#{^6F5Cs8NdSZ=5QgaI7n`kxy^aubRSzxr=g6edSm?|)Y@JSsqOL#`;F rfnY#9FM<0HG7Ws@lxywiu*rc^T)7`u;mD0m;1XKf(77M1;_dzqE16$X delta 334 zcmca`iFNZO)(uK*jH@;)v+Z-{Jk7z#QLoJ4cwqA@&lo1%6)cVnifl{{3apMRSh56) zU3nRR%ni(05CO;eM;HW36j(KwHZUu)O|J2gu-?I`GpZ8JJSpih9!05zSW6lg>I_?0=0W~-^fWU!WH{1ovK}vw~lVyB^ z8COg$@IAK|1x5uXT?Pe526rolgsn=9>wEwt2`Ov< delta 145 zcmaDmll8(()(tmU8Ru-i$$CzR=`YXZl@igLc_nACG5StkCa=z|lI6%?#>Ak&;ApV< zv3%`)#`}{K|C>)<@IS!s1VferlcPkICNqOMGlK%7;{mXM07Ae5s6dmM11t+-0o55O gFaR|vWC<){lu}?+VA5q!aAa_|VhGr3&$!M90PF1~MF0Q* diff --git a/contracts/sysio.tokens/sysio.tokens.wasm b/contracts/sysio.tokens/sysio.tokens.wasm index ffb6e6168a5d1a7cca11227042b5c0c07d45ca48..9218e5bcaff79390fc55da3959866df9f1c84e71 100755 GIT binary patch delta 250 zcmex(fwAWzm6qn4%RIkk7*ua>r#Gt~Uz~I!drDN^X1G{dxn=@B9 zGAc4MIVdnV{$R)wuyN%D%0oDgJC894*eWn+Ffk}H0);fP1ni`Mv<#SLbX8>J2J#vh zH*Xhv%fuKyd8wqjm`;`xgE6n8?h)bI%-Ugat`vl!H}iE z3Xfktvr9mv9LIdeuOx#t8E;Isb2ge>?(FY-f+0(R$x$LplbOMsnL&Zk y@c>vr03l!jRG`Vs0hR@^fa(l@j$m?B$P$PH+Ni*!%b?)M;BLhbuvx^VLInVR6*XS~ diff --git a/contracts/sysio.uwrit/sysio.uwrit.wasm b/contracts/sysio.uwrit/sysio.uwrit.wasm index 1e7e3ce5ca307e4326f4227cd3f419e4353aea65..d30353fcec05b599bb6f4de1fc71b4d27eb4331f 100755 GIT binary patch delta 256 zcmX?cpY!Q`&JD-d8J}%F&i*+*Hi?m`UYWtMfiYW&L4`qq!KqQ9(c|m(9Nj8puq%G0~FK966j%+0y1R43~nH+fpIfm z#zaxZh{+3T)x~tOoEXfRK-yK{tOE?qmuk0Ps%6}k!1!W%=RHPq76nE}jp=*tF^2fx zV8~Kna+Ju@WM(jDW&ny`01F5p1WteiG?_WT(jXR4p##u`OpXd!0^6CS6c`nlbQu&J Q8QiTH61KAlsw-TUx9oUJ#sEF%Lw&$2ux1DY#j z5_8B0AQhTP{qp6>uA9cAqyBL8b~uU&(0DZQ)A#A{xPO?Y@#$Ir_3=?mblPeX!<1!$ z#niDGI3@rvCQCKA;1vq`s;VW8& zMV!zce-LScO7&^N%aOq=_>KN_mjVGoYik}~M&SjPFAbKueZw~X6N|W@Poau4{Qy7d zPf!^9@DNkuIRyCC_yLdSA58|*@Y&iaJrdB?SVR&KZ v|1506+Pqub0^cD$BIKW3^xPde@D7gcI&i$SD@D_FJ;G%$^>W_Dl%M|v@@`?L delta 477 zcmYjMO-mb56n*!-m-)y{^d>dYj$bc{(NUowmBxhxriiOn#f>|o=s+_m@ncG%n+Sr~ z?kbnMwo6woveczNK-?6%YQdej7gxTqP`uoG58QLOobyn8D1Lvzzv7$_N1dc#S$|Tk$%_}DAKt-0%P>YUMiFoB*qfJ2uCyw zL|CEu^c{@Sv~$9J?Py@eHb$2*S&}ml{COdz-X2EqCYAZ=<9HJNx9|6@a BVv+y= diff --git a/contracts/tests/sysio.msgch_chain_tests.cpp b/contracts/tests/sysio.msgch_chain_tests.cpp index f993b2646e..583e27162a 100644 --- a/contracts/tests/sysio.msgch_chain_tests.cpp +++ b/contracts/tests/sysio.msgch_chain_tests.cpp @@ -160,6 +160,7 @@ constexpr const char* OWNER = "owner"; /// sysio.epoch ABI field identifiers used by the WNS-16 fixture. namespace epoch_fields { constexpr const char* BATCH_OP_GROUPS = "batch_op_groups"; +constexpr const char* NEXT_BATCH_OP_GROUPS = "next_batch_op_groups"; constexpr const char* CURRENT_BATCH_OP_GROUP = "current_batch_op_group"; constexpr const char* IS_PAUSED = "is_paused"; } // namespace epoch_fields @@ -735,7 +736,7 @@ class sysio_msgch_chain_tester : public tester { BOOST_REQUIRE(found_operator); if (expected_status != opp::types::OPERATOR_STATUS_ACTIVE && expect_schedule_absence) { const auto state = read_epoch_state(); - for (const auto& group : state["batch_op_groups"].get_array()) { + for (const auto& group : state[epoch_fields::NEXT_BATCH_OP_GROUPS].get_array()) { for (const auto& member : group.get_array()) { BOOST_REQUIRE(member.as_string() != account.to_string()); } @@ -2112,8 +2113,8 @@ BOOST_FIXTURE_TEST_CASE(advance_ships_group_index_zero_for_single_group, sysio_m /// A one-group schedule has no pre-announced successor to rotate into. If one /// member loses eligibility at the exact floor, keep the announced vector and -/// withhold it; once a standby appears, replace that member at the same slot so -/// every healthy incumbent keeps the chunk position already known to outposts. +/// withhold the candidate; once a standby appears, announce an in-place replacement +/// for the following epoch. Healthy incumbents keep their known chunk positions. BOOST_FIXTURE_TEST_CASE(advance_repairs_single_group_ineligible_slot_in_place, sysio_msgch_chain_tester) { try { bootstrap(/*n_batch_ops=*/3); @@ -2149,6 +2150,51 @@ BOOST_FIXTURE_TEST_CASE(advance_repairs_single_group_ineligible_slot_in_place, BOOST_REQUIRE_EQUAL(repaired.groups(0).operators(0).address(), BATCHOP_D.to_string()); BOOST_REQUIRE_EQUAL(repaired.groups(0).operators(1).address(), BATCHOP_C.to_string()); BOOST_REQUIRE_EQUAL(repaired.groups(0).operators(2).address(), BATCHOP_B.to_string()); + // Publishing the replacement cannot elect it for the envelope announcing it. + BOOST_REQUIRE_EQUAL(duty_member(), BATCHOP); + const auto pending = read_epoch_state()[epoch_fields::NEXT_BATCH_OP_GROUPS].get_array(); + BOOST_REQUIRE_EQUAL(pending[0].get_array()[0].as_string(), BATCHOP_D.to_string()); + advance_to_next_epoch(); + BOOST_REQUIRE_EQUAL(duty_member(), BATCHOP_D); +} FC_LOG_AND_RETHROW() } + +/// An insufficient candidate must not persist even its successful replacements. +/// The serving window and slot positions survive every retry; activation follows +/// publication only when enough standbys can fill all vacancies together. +BOOST_FIXTURE_TEST_CASE(advance_discards_partial_candidate_and_activates_complete_publication, + sysio_msgch_chain_tester) { try { + bootstrap(/*n_batch_ops=*/3); + for (const auto op : {BATCHOP, BATCHOP_B}) { + BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, "terminate"_n, + mvo()("account", op.to_string())("reason", std::string("two vacant seats")))); + } + BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, "regoperator"_n, + mvo()("account", BATCHOP_D.to_string()) + ("type", opp::types::OPERATOR_TYPE_BATCH)("is_bootstrapped", true))); + produce_blocks(); + for (uint32_t attempt = 0; attempt < 2; ++attempt) { + advance_to_next_epoch(); + const auto state = read_epoch_state(); + BOOST_REQUIRE(state[epoch_fields::NEXT_BATCH_OP_GROUPS].get_array().empty()); + const auto group = state[epoch_fields::BATCH_OP_GROUPS].get_array()[0].get_array(); + BOOST_REQUIRE_EQUAL(group.size(), 3u); + BOOST_REQUIRE_EQUAL(group[0].as_string(), BATCHOP.to_string()); + BOOST_REQUIRE_EQUAL(group[1].as_string(), BATCHOP_C.to_string()); + BOOST_REQUIRE_EQUAL(group[2].as_string(), BATCHOP_B.to_string()); + BOOST_REQUIRE_EQUAL(shipped_batch_operator_groups_count(ETH_OUTPOST_ID), 0); + } + BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, "regoperator"_n, + mvo()("account", BATCHOP_E.to_string()) + ("type", opp::types::OPERATOR_TYPE_BATCH)("is_bootstrapped", true))); + produce_blocks(); + advance_to_next_epoch(); + const auto published = shipped_batch_operator_groups(ETH_OUTPOST_ID); + BOOST_REQUIRE_EQUAL(published.groups(0).operators(0).address(), BATCHOP_D.to_string()); + BOOST_REQUIRE_EQUAL(published.groups(0).operators(1).address(), BATCHOP_C.to_string()); + BOOST_REQUIRE_EQUAL(published.groups(0).operators(2).address(), BATCHOP_E.to_string()); + BOOST_REQUIRE_EQUAL(duty_member(), BATCHOP); + advance_to_next_epoch(); + BOOST_REQUIRE_EQUAL(duty_member(), BATCHOP_D); } FC_LOG_AND_RETHROW() } /// Depot-state regression for the transition race: the group announced for @@ -2212,12 +2258,12 @@ BOOST_FIXTURE_TEST_CASE(advance_preserves_ineligible_announced_successor_positio /// operators for group assignment"), so a pool smaller than the window can only arise AFTER the /// schedule exists — operators leaving the ACTIVE set. Here the expiring operator is terminated /// at the exact configured minimum. The first slide enters the next, already-announced group and -/// produces a short tail. Later advances must keep that announced group current until a new ACTIVE +/// discards a candidate with a short tail. Later advances retain that group until a new ACTIVE /// standby fills the tail; otherwise Solana rejects the next duty group before the envelope carrying /// its authorizing roster can land. /// /// Asserted here: the incomplete window is withheld while other attestations continue, duty freezes -/// on the group outposts already know, a new operator repairs the future vacancy in place, and only +/// on the group outposts already know, a new operator completes a fresh candidate, and only /// the advance AFTER that repaired lookahead was published resumes rotation. BOOST_FIXTURE_TEST_CASE(advance_freezes_and_recovers_withheld_operator_window, sysio_msgch_chain_tester) { try { @@ -2243,6 +2289,11 @@ BOOST_FIXTURE_TEST_CASE(advance_freezes_and_recovers_withheld_operator_window, advance_to_next_epoch(); BOOST_REQUIRE_EQUAL(duty_member(), BATCHOP_C); + const auto held_state = read_epoch_state(); + BOOST_REQUIRE(held_state[epoch_fields::NEXT_BATCH_OP_GROUPS].get_array().empty()); + const auto held_window = held_state[epoch_fields::BATCH_OP_GROUPS].get_array(); + BOOST_REQUIRE_EQUAL(held_window.size(), kGroups); + for (const auto& group : held_window) BOOST_REQUIRE_EQUAL(group.get_array().size(), 1u); BOOST_REQUIRE_GT(shipped_attestation_count(ETH_OUTPOST_ID), 0); BOOST_REQUIRE_EQUAL(0, shipped_batch_operator_groups_count(ETH_OUTPOST_ID)); require_fresh_roster(ETH_OUTPOST_ID, BATCHOP, @@ -2321,7 +2372,7 @@ BOOST_FIXTURE_TEST_CASE(advance_roster_excludes_same_epoch_termination, } } FC_LOG_AND_RETHROW() } -BOOST_FIXTURE_TEST_CASE(advance_preserves_announced_successor_and_prunes_later_inactive_members, +BOOST_FIXTURE_TEST_CASE(advance_preserves_announced_successor_and_withholds_inactive_future_members, sysio_msgch_chain_tester) { try { constexpr uint32_t GROUP_COUNT = 3; constexpr uint64_t WINDOW_MS = 12ULL * GROUP_COUNT * EPOCH_DURATION_SEC * 1000ULL; diff --git a/contracts/tests/sysio.roa_tests.cpp b/contracts/tests/sysio.roa_tests.cpp index 7eb4c8dc20..8830d2a587 100644 --- a/contracts/tests/sysio.roa_tests.cpp +++ b/contracts/tests/sysio.roa_tests.cpp @@ -1828,7 +1828,10 @@ BOOST_FIXTURE_TEST_CASE( setsyscode_redeploy_reclaims_to_sysio, sysio_roa_tester int64_t sysio_q_mid; rlm.get_account_limits("sysio"_n, sysio_q_mid, n, cpu); int64_t alice_u_mid = rlm.get_account_ram_usage("alice"_n); - auto small = test_contracts::noop_wasm(); + // Use a system-contract fixture built with this test target; noop belongs + // to the separate core-unit-test contract build. + auto small = test_contracts::sysio_token_wasm(); + BOOST_REQUIRE_LT(small.size(), big.size()); BOOST_REQUIRE_EQUAL( success(), push_action(config::system_account_name, "setsyscode"_n, mvo() ("account","alice")("vmtype",0)("vmversion",0)("code", bytes(small.begin(), small.end()))) ); diff --git a/plugins/batch_operator_plugin/src/group_election.hpp b/plugins/batch_operator_plugin/src/group_election.hpp index 95c912510e..580da61fa3 100644 --- a/plugins/batch_operator_plugin/src/group_election.hpp +++ b/plugins/batch_operator_plugin/src/group_election.hpp @@ -22,12 +22,10 @@ inline constexpr uint8_t GROUP_NONE = 255; /// This operator's standing against one `sysio.epoch::epochstate` reading. /// /// `current_group` is the group ON DUTY, taken verbatim from -/// `epochstate.current_batch_op_group`. The sliding window keeps the group on -/// duty at the FRONT of `batch_op_groups` — `sysio.epoch::advance` pops the -/// expiring group off — so the on-duty index is NOT a function of the epoch -/// index. Anything reporting the active group reads it from here; deriving it -/// (`epoch_index % groups`) is the static-rotation anti-pattern the sliding -/// window replaced. +/// `epochstate.current_batch_op_group`. A newly published window activates on +/// the following advance; while publication is withheld the cursor and serving +/// window stay fixed. Duty is never derived from the epoch number or from the +/// separate `next_batch_op_groups` announcement. struct group_election { uint8_t my_group = GROUP_NONE; uint8_t current_group = GROUP_NONE; From ba6232d24c5ea7647bd18605f441fcddaf99b215 Mon Sep 17 00:00:00 2001 From: Huang-Ming Huang Date: Fri, 11 Sep 2026 22:26:21 +0000 Subject: [PATCH 07/15] Refresh Solana deposit IDL test fixture Change-Id: I64773f671fdecc91a8b9ef7597547e8a4490ff8d --- tests/fixtures/solana-idl-opp-outpost-stub.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/fixtures/solana-idl-opp-outpost-stub.json b/tests/fixtures/solana-idl-opp-outpost-stub.json index a723d48ab9..dc4d49cb7e 100644 --- a/tests/fixtures/solana-idl-opp-outpost-stub.json +++ b/tests/fixtures/solana-idl-opp-outpost-stub.json @@ -184,8 +184,7 @@ "name": "config" }, { - "name": "operator_registry", - "writable": true + "name": "operator_registry" }, { "name": "outbound_message_buffer", @@ -195,6 +194,10 @@ "name": "vault", "writable": true }, + { + "name": "collateral_position", + "writable": true + }, { "name": "system_program" } From 2ca8d89c45933367d998157b9b40b1811208ed77 Mon Sep 17 00:00:00 2001 From: Huang-Ming Huang Date: Sat, 12 Sep 2026 16:00:16 +0000 Subject: [PATCH 08/15] Simplify schedule repair and test consensus recovery Change-Id: I7d6033d5e3eb607065429fe2b567c97634771652 --- contracts/sysio.epoch/src/sysio.epoch.cpp | 30 ++- contracts/sysio.epoch/sysio.epoch.wasm | Bin 81868 -> 81606 bytes contracts/tests/sysio.msgch_chain_tests.cpp | 209 ++++++++------------ contracts/tests/sysio.roa_tests.cpp | 5 +- 4 files changed, 96 insertions(+), 148 deletions(-) diff --git a/contracts/sysio.epoch/src/sysio.epoch.cpp b/contracts/sysio.epoch/src/sysio.epoch.cpp index 142df93e4f..51681331eb 100644 --- a/contracts/sysio.epoch/src/sysio.epoch.cpp +++ b/contracts/sysio.epoch/src/sysio.epoch.cpp @@ -710,14 +710,12 @@ void epoch::finishadv(uint32_t epoch_index, int64_t emission_amount) { // Activate only a window published by the preceding advance. On a hold // there is no pending announcement, so both membership and positions stay // unchanged. In particular, one-group replacements cannot serve early. - std::vector expired; + const uint32_t serving_group_index = cfg.batch_op_groups > 1 ? 1 : 0; const bool activate_schedule = !state.next_batch_op_groups.empty(); if (activate_schedule) { - if (cfg.batch_op_groups > 1 && state.current_batch_op_group < state.batch_op_groups.size()) - expired = state.batch_op_groups[state.current_batch_op_group]; state.batch_op_groups = std::move(state.next_batch_op_groups); state.next_batch_op_groups.clear(); - state.current_batch_op_group = cfg.batch_op_groups > 1 ? 1 : 0; + state.current_batch_op_group = serving_group_index; } opreg::operators_t current_ops(OPREG_ACCOUNT); @@ -733,17 +731,15 @@ void epoch::finishadv(uint32_t epoch_index, int64_t emission_amount) { // group. Multi-group publication names its successor; a single group names // its repaired self. Only the candidate may acquire replacement members. std::vector> candidate_groups; - const uint32_t next_group_index = cfg.batch_op_groups > 1 ? 1 : 0; if (state.current_batch_op_group < state.batch_op_groups.size()) { candidate_groups.assign(state.batch_op_groups.begin() + state.current_batch_op_group, state.batch_op_groups.end()); - const size_t retained_groups = candidate_groups.size(); candidate_groups.resize(cfg.batch_op_groups); // Keep healthy seats at their existing positions. For multiple groups, // candidate group zero is the unchanged delivery group and may contain // inactive historical placeholders. Every active/future seat must be live. - for (size_t g = next_group_index; g < candidate_groups.size(); ++g) { + for (size_t g = serving_group_index; g < candidate_groups.size(); ++g) { auto& group = candidate_groups[g]; group.resize(cfg.operators_per_epoch); for (auto& member : group) @@ -771,19 +767,12 @@ void epoch::finishadv(uint32_t epoch_index, int64_t emission_amount) { return a.first < b.first; }); - for (size_t g = next_group_index; g < candidate_groups.size(); ++g) { + for (size_t g = serving_group_index; g < candidate_groups.size(); ++g) { for (auto& member : candidate_groups[g]) { if (member.value != 0) continue; - // On an ordinary advance, repair existing future groups with true - // standbys before recycling the expired group into the new tail. - // A hold may reuse it sooner to restore a complete disjoint window. - const auto replacement = std::find_if(pool.begin(), pool.end(), [&](const auto& candidate) { - return g >= retained_groups || - std::find(expired.begin(), expired.end(), candidate.first) == expired.end(); - }); - if (replacement == pool.end()) break; - member = replacement->first; - pool.erase(replacement); + if (pool.empty()) break; + member = pool.front().first; + pool.erase(pool.begin()); } } } @@ -873,11 +862,14 @@ void epoch::finishadv(uint32_t epoch_index, int64_t emission_amount) { // Publish a complete next window using the unchanged OPP lookahead format. // Current duty is kept separately and cannot change until the next advance. + // Outposts authorize an envelope against the group they already know. If we + // switch duty before publishing its lookahead, the roster that would authorise + // the delivery is inside the envelope being refused, so recovery cannot land. // Group zero of a rotating candidate is historical when this announcement // lands; inactive placeholders there preserve the serving group's positions. if (publish_schedule) { opp::attestations::BatchOperatorGroups attest; - attest.active_group_index = zpp::bits::vuint32_t{next_group_index}; + attest.active_group_index = zpp::bits::vuint32_t{serving_group_index}; attest.epoch_index = zpp::bits::vuint32_t{state.current_epoch_index}; // Propagate the depot's minimum epoch duration so the outpost can // evaluate the fallback (path-2) majority consensus after this many diff --git a/contracts/sysio.epoch/sysio.epoch.wasm b/contracts/sysio.epoch/sysio.epoch.wasm index dea0e930ebaa421927a505c6b0006334b208de08..5c939a5401a3c648ed12e7b9768c401118198af2 100755 GIT binary patch delta 5257 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 IV?&4k2Ww|eK>z>% delta 5457 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, + opp::types::OPERATOR_STATUS_ACTIVE); + } + const auto held_epoch = current_epoch(); + const auto blocked = encode_delivery(held_epoch, "cannot authorize recovery", + oracle::digest_bytes(oracle::epoch_digest(decode_envelope(anchor))), delivery_message_id(anchor)); + BOOST_REQUIRE_EQUAL(error("assertion failure with message: delivering operator is not ACTIVE in sysio.opreg"), + deliver_as(BATCHOP_C, ETH_OUTPOST_ID, blocked)); + BOOST_REQUIRE_EQUAL(error("assertion failure with message: caller is not in the active batch operator group"), + deliver_as(BATCHOP_D, ETH_OUTPOST_ID, blocked)); + + for (int retry = 0; retry < 3; ++retry) { + elapse_epoch_boundary(); + // Permissionless chkcons still needs consensus; no privileged advance. + BOOST_REQUIRE_EQUAL(success(), push(MSGCH_ACCOUNT, msgch_abi, BATCHOP_D, + msgch_actions::CHECK_CONSENSUS, mvo())); + produce_blocks(); + BOOST_REQUIRE_EQUAL(current_epoch(), held_epoch); + BOOST_REQUIRE_EQUAL(duty_member(), BATCHOP_C); + BOOST_REQUIRE(read_epoch_state()[epoch_fields::NEXT_BATCH_OP_GROUPS].get_array().empty()); + BOOST_REQUIRE_EQUAL(get_outpcons(ETH_OUTPOST_ID)["epoch_index"].as_uint64(), anchor_epoch); + BOOST_REQUIRE_EQUAL(shipped_batch_operator_groups_count(ETH_OUTPOST_ID), 0); } - produce_blocks(); - advance_to_next_epoch(); - - const auto repaired = shipped_batch_operator_groups(ETH_OUTPOST_ID); - BOOST_REQUIRE_EQUAL(repaired.groups_size(), 3); - BOOST_REQUIRE_EQUAL(repaired.active_group_index(), 1u); - BOOST_REQUIRE_EQUAL(repaired.groups(0).operators(0).address(), BATCHOP_C.to_string()); - BOOST_REQUIRE_EQUAL(repaired.groups(1).operators(0).address(), BATCHOP_B.to_string()); - BOOST_REQUIRE_EQUAL(repaired.groups(2).operators(0).address(), BATCHOP_D.to_string()); - - advance_to_next_epoch(); - BOOST_REQUIRE_EQUAL(duty_member(), BATCHOP_B); - const auto resumed = shipped_batch_operator_groups(ETH_OUTPOST_ID); - BOOST_REQUIRE_EQUAL(resumed.groups(0).operators(0).address(), BATCHOP_B.to_string()); - BOOST_REQUIRE_EQUAL(resumed.groups(1).operators(0).address(), BATCHOP_D.to_string()); - BOOST_REQUIRE_EQUAL(resumed.groups(2).operators(0).address(), BATCHOP_E.to_string()); } FC_LOG_AND_RETHROW() } -/// The depot must NEVER publish an active index that names an EMPTY group: that index selects -/// the group an outpost admits against and sizes its quorum from, so an empty one admits nobody, -/// can never reach consensus, and wedges the outpost permanently — the handler that could replace -/// the window runs only PAST the gate the empty group breaks. -/// -/// The state is reached by starving an EXISTING window, which is the only way it is reachable: -/// `schbatchgps` refuses to build a starved schedule up front ("not enough available batch -/// operators for group assignment"), so a pool smaller than the window can only arise AFTER the -/// schedule exists — operators leaving the ACTIVE set. Here the expiring operator is terminated -/// at the exact configured minimum. The first slide enters the next, already-announced group and -/// discards a candidate with a short tail. Later advances retain that group until a new ACTIVE -/// standby fills the tail; otherwise Solana rejects the next duty group before the envelope carrying -/// its authorizing roster can land. -/// -/// Asserted here: the incomplete window is withheld while other attestations continue, duty freezes -/// on the group outposts already know, a new operator completes a fresh candidate, and only -/// the advance AFTER that repaired lookahead was published resumes rotation. +/// Withholding an incomplete lookahead retains the already-authorized duty. +/// A healthy held signer keeps delivering until a standby completes the window; +/// the repaired successor serves only after its announcement has been published. +/// Every transition after genesis goes through deliver -> chkcons -> advance. BOOST_FIXTURE_TEST_CASE(advance_freezes_and_recovers_withheld_operator_window, sysio_msgch_chain_tester) { try { constexpr uint32_t kGroups = 3; constexpr uint64_t kRotationWindowMs = 12ULL * kGroups * EPOCH_DURATION_SEC * 1000ULL; - bootstrap_rotation(kRotationWindowMs); + bootstrap_rotation(kRotationWindowMs, /*batchop_is_bootstrapped=*/true); - // schbatchgps interleaves the sorted roster as [A,C,B]. Epoch 1's envelope - // therefore announces C for epoch 2. - BOOST_REQUIRE_EQUAL(1, shipped_batch_operator_groups_count(ETH_OUTPOST_ID)); + std::vector previous; + auto deliver_and_advance = [&](name signer) { + const auto epoch = current_epoch(); + const auto envelope = encode_delivery(epoch, "healthy held-group delivery", + previous.empty() ? std::string{} : oracle::digest_bytes(oracle::epoch_digest(decode_envelope(previous))), + previous.empty() ? std::string{} : delivery_message_id(previous)); + BOOST_REQUIRE_EQUAL(success(), deliver_as(signer, ETH_OUTPOST_ID, envelope)); + BOOST_REQUIRE_EQUAL(get_outpcons(ETH_OUTPOST_ID)["epoch_index"].as_uint64(), epoch); + elapse_epoch_boundary(); + advance_via_consensus(); + BOOST_REQUIRE_EQUAL(current_epoch(), epoch + 1); + previous = envelope; + }; + + // The initial [A,C,B] window announces C next. Remove future B while A + // can still deliver the envelope that moves us into C's held duty. const auto initial = shipped_batch_operator_groups(ETH_OUTPOST_ID); - BOOST_REQUIRE_EQUAL(initial.groups_size(), 3); + BOOST_REQUIRE_EQUAL(initial.groups_size(), kGroups); BOOST_REQUIRE_EQUAL(initial.active_group_index(), 1u); BOOST_REQUIRE_EQUAL(initial.groups(0).operators(0).address(), BATCHOP.to_string()); BOOST_REQUIRE_EQUAL(initial.groups(1).operators(0).address(), BATCHOP_C.to_string()); BOOST_REQUIRE_EQUAL(initial.groups(2).operators(0).address(), BATCHOP_B.to_string()); - - // Terminate the expiring group at exactly the three-seat minimum. The next - // group C remains healthy and was already announced by epoch 1. BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, "terminate"_n, - mvo()("account", BATCHOP.to_string())("reason", std::string("starve the schedule window")))); - produce_blocks(); - - advance_to_next_epoch(); - BOOST_REQUIRE_EQUAL(duty_member(), BATCHOP_C); - const auto held_state = read_epoch_state(); - BOOST_REQUIRE(held_state[epoch_fields::NEXT_BATCH_OP_GROUPS].get_array().empty()); - const auto held_window = held_state[epoch_fields::BATCH_OP_GROUPS].get_array(); - BOOST_REQUIRE_EQUAL(held_window.size(), kGroups); - for (const auto& group : held_window) BOOST_REQUIRE_EQUAL(group.get_array().size(), 1u); - BOOST_REQUIRE_GT(shipped_attestation_count(ETH_OUTPOST_ID), 0); - BOOST_REQUIRE_EQUAL(0, shipped_batch_operator_groups_count(ETH_OUTPOST_ID)); - require_fresh_roster(ETH_OUTPOST_ID, BATCHOP, - opp::types::OPERATOR_STATUS_TERMINATED); + mvo()("account", BATCHOP_B.to_string())("reason", "starve the schedule window"))); + deliver_and_advance(BATCHOP); - // A second epoch while starved must retain C. Sliding to B here would make - // outposts reject B because the roster authorizing it was withheld above. - advance_to_next_epoch(); - BOOST_REQUIRE_EQUAL(duty_member(), BATCHOP_C); - BOOST_REQUIRE_GT(shipped_attestation_count(ETH_OUTPOST_ID), 0); - BOOST_REQUIRE_EQUAL(0, shipped_batch_operator_groups_count(ETH_OUTPOST_ID)); + for (int held = 0; held < 2; ++held) { + BOOST_REQUIRE_EQUAL(duty_member(), BATCHOP_C); + BOOST_REQUIRE(read_epoch_state()[epoch_fields::NEXT_BATCH_OP_GROUPS].get_array().empty()); + BOOST_REQUIRE_GT(shipped_attestation_count(ETH_OUTPOST_ID), 0); + BOOST_REQUIRE_EQUAL(shipped_batch_operator_groups_count(ETH_OUTPOST_ID), 0); + require_fresh_roster(ETH_OUTPOST_ID, BATCHOP_B, opp::types::OPERATOR_STATUS_TERMINATED); + deliver_and_advance(BATCHOP_C); + } - // If C loses eligibility while its previously announced group is held, it - // must remain as a positional placeholder long enough for the other current - // members to deliver a repaired lookahead. Replacing or deleting C in group - // zero would change the duty known to the outposts before they receive it. BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, - "terminate"_n, mvo()("account", BATCHOP_C.to_string()) - ("reason", std::string("remove one held-duty member")))); - produce_blocks(); - advance_to_next_epoch(); - BOOST_REQUIRE_EQUAL(duty_member(), BATCHOP_C); - BOOST_REQUIRE_EQUAL(0, shipped_batch_operator_groups_count(ETH_OUTPOST_ID)); - require_fresh_roster(ETH_OUTPOST_ID, BATCHOP_C, - opp::types::OPERATOR_STATUS_TERMINATED, - /*expect_schedule_absence=*/false); - - // Two ACTIVE standbys restore the active roster minimum. D fills the held - // future vacancy; E remains available to build the next tail after C's - // placeholder group has delivered the repaired lookahead and expires. - for (const auto op : {BATCHOP_D, BATCHOP_E}) { - BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, - "regoperator"_n, mvo()("account", op.to_string()) - ("type", opp::types::OperatorType::OPERATOR_TYPE_BATCH)("is_bootstrapped", true))); - } - produce_blocks(); - advance_to_next_epoch(); + "regoperator"_n, mvo()("account", BATCHOP_D.to_string()) + ("type", opp::types::OPERATOR_TYPE_BATCH)("is_bootstrapped", true))); + deliver_and_advance(BATCHOP_C); BOOST_REQUIRE_EQUAL(duty_member(), BATCHOP_C); const auto repaired = shipped_batch_operator_groups(ETH_OUTPOST_ID); - BOOST_REQUIRE_EQUAL(repaired.groups_size(), 3); + BOOST_REQUIRE_EQUAL(repaired.groups_size(), kGroups); BOOST_REQUIRE_EQUAL(repaired.active_group_index(), 1u); BOOST_REQUIRE_EQUAL(repaired.groups(0).operators(0).address(), BATCHOP_C.to_string()); - BOOST_REQUIRE_EQUAL(repaired.groups(1).operators(0).address(), BATCHOP_B.to_string()); + BOOST_REQUIRE_EQUAL(repaired.groups(1).operators(0).address(), BATCHOP.to_string()); BOOST_REQUIRE_EQUAL(repaired.groups(2).operators(0).address(), BATCHOP_D.to_string()); - // Only after the repaired lookahead lands may the schedule rotate to B. - advance_to_next_epoch(); - BOOST_REQUIRE_EQUAL(duty_member(), BATCHOP_B); - const auto resumed = shipped_batch_operator_groups(ETH_OUTPOST_ID); - BOOST_REQUIRE_EQUAL(resumed.groups_size(), 3); - BOOST_REQUIRE_EQUAL(resumed.active_group_index(), 1u); - BOOST_REQUIRE_EQUAL(resumed.groups(0).operators(0).address(), BATCHOP_B.to_string()); - BOOST_REQUIRE_EQUAL(resumed.groups(1).operators(0).address(), BATCHOP_D.to_string()); - BOOST_REQUIRE_EQUAL(resumed.groups(2).operators(0).address(), BATCHOP_E.to_string()); + deliver_and_advance(BATCHOP_C); + BOOST_REQUIRE_EQUAL(duty_member(), BATCHOP); + deliver_and_advance(BATCHOP); + BOOST_REQUIRE_EQUAL(duty_member(), BATCHOP_D); } FC_LOG_AND_RETHROW() } // WIRE-385: a removal during this advance must be visible in BOTH emitted @@ -2414,13 +2373,13 @@ BOOST_FIXTURE_TEST_CASE(advance_repairs_future_group_before_it_becomes_current, BOOST_REQUIRE_EQUAL(repaired.groups(i).operators_size(), 1); } BOOST_REQUIRE_EQUAL(repaired.groups(0).operators(0).address(), BATCHOP_C.to_string()); - BOOST_REQUIRE_EQUAL(repaired.groups(1).operators(0).address(), BATCHOP_D.to_string()); - BOOST_REQUIRE_EQUAL(repaired.groups(2).operators(0).address(), BATCHOP.to_string()); + BOOST_REQUIRE_EQUAL(repaired.groups(1).operators(0).address(), BATCHOP.to_string()); + BOOST_REQUIRE_EQUAL(repaired.groups(2).operators(0).address(), BATCHOP_D.to_string()); advance_to_next_epoch(); const auto next = shipped_batch_operator_groups(ETH_OUTPOST_ID); BOOST_REQUIRE_EQUAL(next.groups_size(), 3); BOOST_REQUIRE_EQUAL(next.groups(0).operators_size(), 1); - BOOST_REQUIRE_EQUAL(next.groups(0).operators(0).address(), BATCHOP_D.to_string()); + BOOST_REQUIRE_EQUAL(next.groups(0).operators(0).address(), BATCHOP.to_string()); require_fresh_roster(ETH_OUTPOST_ID, BATCHOP_B, opp::types::OPERATOR_STATUS_TERMINATED); } FC_LOG_AND_RETHROW() } diff --git a/contracts/tests/sysio.roa_tests.cpp b/contracts/tests/sysio.roa_tests.cpp index 8830d2a587..7eb4c8dc20 100644 --- a/contracts/tests/sysio.roa_tests.cpp +++ b/contracts/tests/sysio.roa_tests.cpp @@ -1828,10 +1828,7 @@ BOOST_FIXTURE_TEST_CASE( setsyscode_redeploy_reclaims_to_sysio, sysio_roa_tester int64_t sysio_q_mid; rlm.get_account_limits("sysio"_n, sysio_q_mid, n, cpu); int64_t alice_u_mid = rlm.get_account_ram_usage("alice"_n); - // Use a system-contract fixture built with this test target; noop belongs - // to the separate core-unit-test contract build. - auto small = test_contracts::sysio_token_wasm(); - BOOST_REQUIRE_LT(small.size(), big.size()); + auto small = test_contracts::noop_wasm(); BOOST_REQUIRE_EQUAL( success(), push_action(config::system_account_name, "setsyscode"_n, mvo() ("account","alice")("vmtype",0)("vmversion",0)("code", bytes(small.begin(), small.end()))) ); From 9bd714026a8f324a416469ab28c44a0e9163148a Mon Sep 17 00:00:00 2001 From: Huang-Ming Huang Date: Tue, 15 Sep 2026 14:33:31 +0000 Subject: [PATCH 09/15] Address PR review feedback Change-Id: I887a051908fae75b95e4e97394cbaf1d81d67005 --- contracts/sysio.epoch/src/sysio.epoch.cpp | 22 ++++--- contracts/sysio.epoch/sysio.epoch.wasm | Bin 81606 -> 81621 bytes .../include/sysio.opreg/sysio.opreg.hpp | 18 ++++++ contracts/sysio.opreg/src/sysio.opreg.cpp | 32 ++++++++++ contracts/sysio.opreg/sysio.opreg.abi | 32 ++++++++++ contracts/sysio.opreg/sysio.opreg.wasm | Bin 92496 -> 94313 bytes contracts/tests/sysio.msgch_chain_tests.cpp | 59 ++++++++++++++++++ 7 files changed, 154 insertions(+), 9 deletions(-) diff --git a/contracts/sysio.epoch/src/sysio.epoch.cpp b/contracts/sysio.epoch/src/sysio.epoch.cpp index 51681331eb..6f8acb87f1 100644 --- a/contracts/sysio.epoch/src/sysio.epoch.cpp +++ b/contracts/sysio.epoch/src/sysio.epoch.cpp @@ -613,9 +613,11 @@ void epoch::advance() { ).send(); } - // Preserve the delivery history after slashing. A non-canonical operator is already - // SLASHED here, so opreg::termcheck returns without converting the punitive outcome into a - // termination/remit. Other group members retain their normal delivery accounting. + // Preserve the delivery history after slashing and while duty is held. + // opreg marks held-epoch observations as audit-only so a later rotating + // epoch cannot retroactively count the accelerated held-duty misses. + // A non-canonical operator is already SLASHED here, so termcheck safely + // skips that operator without converting the punitive outcome into a remit. for (const auto& observation : observations) { action( permission_level{get_self(), "owner"_n}, @@ -623,12 +625,14 @@ void epoch::advance() { opreg_actions::RECORD_DELIVERY, std::make_tuple(observation.member, state.current_epoch_index, observation.did_deliver) ).send(); - action( - permission_level{get_self(), "owner"_n}, - OPREG_ACCOUNT, - opreg_actions::TERMINATION_CHECK, - std::make_tuple(observation.member) - ).send(); + if (!state.next_batch_op_groups.empty()) { + action( + permission_level{get_self(), "owner"_n}, + OPREG_ACCOUNT, + opreg_actions::TERMINATION_CHECK, + std::make_tuple(observation.member) + ).send(); + } } // NOTE: we intentionally do NOT erase the per-batch-op envelope diff --git a/contracts/sysio.epoch/sysio.epoch.wasm b/contracts/sysio.epoch/sysio.epoch.wasm index 5c939a5401a3c648ed12e7b9768c401118198af2..10e08b77a0e4d78dc6e0a554aa44d639a1ae7f5d 100755 GIT binary patch delta 54 zcmX^1m*whTmJMH67?*DT$}&ZoasTEnh2y+}Ob!Z+8caKwK;#}KH(rLxfqJW&|LSf3 JtH*ef9{^bt6cGRb delta 40 ycmV+@0N4N3{RGDS1hDi30hY7%1e6;Au(OIF#|@KWE~=9^E*gU*FSjHw0kIE> >; + /// Epochs whose announced duty remained held. The delivery rows stay in + /// dellog for audit, but neither termination rail counts those rows. + /// Keeping the marker separate leaves the existing dellog row format + /// and its account/timestamp index unchanged. + struct held_epoch_key { + uint64_t epoch; + uint64_t primary_key() const { return epoch; } + SYSLIB_SERIALIZE(held_epoch_key, (epoch)) + }; + + struct [[sysio::table("heldepochs")]] held_epoch_entry { + uint64_t epoch = 0; + uint64_t ts_ms = 0; + SYSLIB_SERIALIZE(held_epoch_entry, (epoch)(ts_ms)) + }; + + using heldepochs_t = sysio::kv::table<"heldepochs"_n, held_epoch_key, held_epoch_entry>; + /// Claimable WIRE collateral owed to an operator by a WIRE-chain remit: a withdraw /// flush (`flushwtdw`), a deferred lock release on a TERMINATED operator, or the /// termination payout itself. diff --git a/contracts/sysio.opreg/src/sysio.opreg.cpp b/contracts/sysio.opreg/src/sysio.opreg.cpp index 1ae13ea288..e1d06d173e 100644 --- a/contracts/sysio.opreg/src/sysio.opreg.cpp +++ b/contracts/sysio.opreg/src/sysio.opreg.cpp @@ -634,6 +634,17 @@ void prune_dellog(uint64_t window_open_ms, uint32_t max_rows) { it = log.erase(std::move(it)); ++removed; } + + // Held-epoch markers are retained for the same window as their audit + // observations. Epoch order is timestamp order, so this is oldest-first + // and bounded by the same per-write/per-crank cap. + opreg::heldepochs_t held(name{"sysio.opreg"_n}); + removed = 0; + for (auto it = held.begin(); + it != held.end() && removed < max_rows && it->ts_ms < window_open_ms; ) { + it = held.erase(std::move(it)); + ++removed; + } } /// Get the current epoch index from sysio.epoch's epochstate singleton. @@ -1714,6 +1725,22 @@ void opreg::recorddel(name account, uint32_t epoch, bool delivered) { const auto cfg = cfg_tbl.get_or_default(op_config{}); prune_dellog(termination_window_open_ms(now_ms, cfg), MAX_DELLOG_PRUNE_PER_WRITE); + // A withheld schedule keeps the same group on duty every epoch instead of + // rotating it through the configured number of groups. Retain real misses + // in dellog, but mark this epoch once so termcheck cannot interpret those + // accelerated observations as ordinary rotating-duty misses later. + sysio::epoch::epochstate_t epoch_tbl(EPOCH_ACCOUNT); + if (epoch_tbl.exists()) { + const auto state = epoch_tbl.get(); + if (state.current_epoch_index == epoch && state.next_batch_op_groups.empty()) { + heldepochs_t held(get_self()); + const held_epoch_key key{epoch}; + if (!held.contains(key)) { + held.emplace(ram_payer, key, held_epoch_entry{.epoch = epoch, .ts_ms = now_ms}); + } + } + } + dellog_t log(get_self()); uint64_t id = next_dellog_id(); log.emplace(ram_payer, delivery_key{id}, delivery_log_entry{ @@ -1766,6 +1793,7 @@ void opreg::termcheck(name account) { uint64_t window_open = termination_window_open_ms(now_ms, cfg); dellog_t log(get_self()); + heldepochs_t held(get_self()); auto idx = log.get_index<"byaccountts"_n>(); uint128_t lower_key = (static_cast(account.value) << 64) | window_open; uint128_t upper_key = (static_cast(account.value) << 64) | std::numeric_limits::max(); @@ -1776,6 +1804,10 @@ void opreg::termcheck(name account) { uint32_t total_in_window = 0; for (auto it = idx.lower_bound(lower_key); it != idx.end() && it->by_account_ts() <= upper_key; ++it) { if (it->account != account) break; + if (held.contains(held_epoch_key{it->epoch})) { + consecutive_misses = 0; + continue; + } total_in_window++; if (!it->delivered) total_misses++; diff --git a/contracts/sysio.opreg/sysio.opreg.abi b/contracts/sysio.opreg/sysio.opreg.abi index ab3bef11c9..616fa8eef2 100644 --- a/contracts/sysio.opreg/sysio.opreg.abi +++ b/contracts/sysio.opreg/sysio.opreg.abi @@ -284,6 +284,30 @@ } ] }, + { + "name": "held_epoch_entry", + "base": "", + "fields": [ + { + "name": "epoch", + "type": "uint64" + }, + { + "name": "ts_ms", + "type": "uint64" + } + ] + }, + { + "name": "held_epoch_key", + "base": "", + "fields": [ + { + "name": "epoch", + "type": "uint64" + } + ] + }, { "name": "op_config", "base": "", @@ -857,6 +881,14 @@ } ] }, + { + "name": "heldepochs", + "type": "held_epoch_entry", + "index_type": "i64", + "key_names": ["epoch"], + "key_types": ["uint64"], + "table_id": 10706 + }, { "name": "opconfig", "type": "op_config", diff --git a/contracts/sysio.opreg/sysio.opreg.wasm b/contracts/sysio.opreg/sysio.opreg.wasm index 233715e6e641160cd98190ea51e4286842172109..e806ed58d39071a64e8e072b27ce604e69c5e802 100755 GIT binary patch delta 8120 zcmai334B!5)qm%{36o?JdF&wx$;%LM0)zw_wy>BNRs|s<0wPOsDRscDYO7zX6Dmru zqQQ$^v?$P)f)@G-qYi>fa77CO31UDtStKe!D6$Lmf9{(J1N!yj2XpSb=Pu{|&$(y0 zY_3lDW^cl4gFNOTaZq0Csr030W(qSEe^WE%F3%9zEum}6WJ)&8)C@{ZO%!71vAa0_GCEZYjT>-&{;Z1Khk;X_`NtP@{fssi{s)4aZ;QTr$zF%wFw8q?Hd)f zq_@JiWR54=C|9+)5x*%}L&O&(Hx+hC3mCRxleJ0M7cCc zRYYG)Z}C6S4i|H~u6UlF*9E`f-q-P*-VQAZm-M-n=z!dI&1_lNuauU^l0Kcoi~1Fz zGra8@o9ImVR7p_KA$e?OGkGzXl1R32JV9$=cvk=ZL>J}80j)*32-gkhN8&LdZ}>%? zOd4{nd~wh<@yC&U2Yz#jeK(rnhllh@$y`IJhGW>mBA+kA5GA$|5XQh%=+b&uZW%S3 z-jb7Usuwdvc=+h|Oxi1(j2l9K3XdCi!lb8U*~B+!wrn)%YI;I;o-~eThG$KhA?U2e zD)-h0z4VoQ@y;%^Pj0w#1-&L84Ym_sZIO$Ez3DmmRj^A+9i`jR1Dy+JO`T8l7w)IO z%KB-K(BH#T?;6<}o4)(Rtw11W=0LGm8=HJ#=A-nFa9YL1L|Q7-=Jco4;qh}mYwD^- zJ)Ta`jarya!|0T%Pp7M0?Wj%})K6Z$_*>s{kwQM(YXx#lxoC0wrY730)9nPOQUq=O zST0BX{Zsm<%w95?{#Q<4GMruuZ(ibUD^6?)Z~yce56uhTx#@0V)7!rJJkfdiMfFI! zAUo7#LnqhOJSfUdxvr)lw8SgSka)DM;VcwW{hnN-wEXpo<c0za22^TgGiYdK_KaC&D@w#I&7XlW5Wu=GCl14 z9!v*?Q+zNT9lTc}#<_N)74zD+%U@wT>S4~q(xosP-hwG_!IZTCV!0c_G5RslH6iSV z<~%lc^hnRP+z`vJdoB|XcKSP4aD6@;1S+=8Vj~wsbQd;=yA>Y}TgA@B$KrG99rejx zXSjjlQu1pb<8hWj3ZuJVcas;2TIc)*?!q`OAP9)c7>g&(C7#7tEfm3A!*?dZY!sqL zDTKRL>6z*mx$tWYTLt7SEx&|TIli58GswU2q;IfPBHsvAm}~;XKbj96g`X1SS@h%uq=Vg^=VV9L?YyHo(V3bGr)*90s9 z4cn%*2wqS)Wd=BUKVXLSJ^B}x`6i~u0Y2|5862M}z+ z@-Z@AG(nPpis_=VFp5f&?PV7if>TL?CE8y6XrIU5@M>QCM8AI)Kqw`KUn0X7MeTCX zATVOtTbBd2ItEys@Oy68*E}9Ej|+zfaJVS`+V8o1kjEc4#uZ{9^laL6Ev&+Hi0#Ca zYWVa5PMrq2)fGU)CfQ1FR(2X?$-(Vfo7FXjx;c%q>2CFMPfC;b*XD@o8u@%}Amwdx zV-Y4aFo!mKC$5)72-#LxZmGSEUYFgrXN!~7a`g65P2PY}K{jMnZYuPqT(f z4!}zE*@myoogEQ=VKs*AhEr$A%C2o>+V;%QD&g~05uz4x)Uv1g&0NF1A&HzTI47$O zWD7Vw)MT-bFds*Y63YHJ=xLHg$<<=deLw_{U={hr+Vv3OYsE3`|W>Y(} zOMwfCgW!9+o_&iO5URKdH@wPZRXJ7KyR9;#X@hX~YM;{3?dAN{g=0SIYAOmt?asUT;XQHo>04399B!>hZ=E6-H9k9Sgpu! z(BqT~`RdNWMQhp{v%UcIYL2y1i`5A`rH zTc7bLq61C}7sWBQVNvbA^Nl~`BCY=0863;Y|J(QpFroy`!dO3vQZmXhU)U9HBg7dc5- zNkJV{=;X3vb!QsLL4 z31=DfYjQ+3-Ei?~#`Dv7C8iJ9IPo`@N{5OOKYM#Jd|=HsosBB;P#xC2a~ndrVRE z=+1F2ImaL8l5_d-*wO2c51}ox_~bU3a-tlo%{g_G+;?IOZ3!2j9L=TroKq(zZ-M_e zow>L&rt=hz8UM}MZ~Qm%9jiKS{Ex8Zc>d`tzBx;%mWNKa>06^`!O#8DHsKb;*oHk( zfmjXPQ7(5_19t;+!_JvuB<9wr5~5sH7NF)y-l00;%&n1|&z7WamDldf|hGc(Dp%SWB2T~B8cSoL2rCH)~>3o=oT?uC?}1Ei$4lACyf@E(?zHDbUbhTz{AlT2WvS{71=s6vy#)vvg9kzW;3A|)=t zR`AVB)r77zS0#6)!71p9KJ8jdBj|1~=#|KfBDkXdG0(q?ur|zj3sY#To+F9u>6Oq`N$*A8D&~62^j(9DTTZa9_G4J?u8wLa<{R8&r4E9E9st zUH4&=jP3#jB8C8nQ~Ie(k50KHn}v4?@PT+%y4_mWk~~^hYDnd9F0K|k%4<5d^P=?S z>R3Kq$V|ZI)25WTwCO!nm_-@%z8aQArSyS%CX4z&m zoy?}5jXvhIe!VI}c}br}rr8uE`UFq^E!5#0s;7Uc9e&u~hDcId>PsU>x<*>Vf&+6+ zaS%;$EA&Be5KUnSnifXy819<>#((%|%6G1sRAm63-;D~YH0J3QJpE7`?vCNR^0Me} z>{_S#^rL@LP2{_NG@0&*DQbIExUC#6wU=t}1dX*Pu(V0RSAWj9+fnf#wq7AlT>Z^r z>RZpTZkt+B0-KJdu@_4yGaljXF2Xz9;5Nazoco2#t>u8$pT1=Mv`}XqDs8*d71hfo zp|jw814T2O)>w$DLsylWREqSkUOiZfq-C$HKj&`~JFuoOD@}n*HTjm3>gOlFO6pIk zv`e+>PvdB}`b~c-ylW318i7kYP#EQIh6`Y5*f2W^8#tWF;#%gJc;K`f1YsYiFR1Qk z4QT=62NyPUs>TDTg)i!pdC5q@0Qy}l_03fq2GSb(N>vQP|K;q9d_IW2HjA!2)-K|( z$6f;W9u3$X8$G*)=DJ8uu@0&4ucJ3{Gnx!1M;#hUS0QyhITUv$+BIz$y1r4rA4d0P zF~ovvd*S5ZWkeTsPJYn(77yfBy-K>CBz>n|x}Msyp5MEkhP$hbpu=kNaJ}a9!)X?D zVUK_=j;I?)&>&=eFOI-P*`YoeL3h{}cn^zzxmwII`R#+u5(tg@kweh%GV11yj*2{4 zMjsNLh?I?_zJe}9o*qShDu@LZX!gfwV1cbZi@@UX{pu%fYQEZY6Xhbp*?df4F=gXmR`lKz!jR(6kD&1W-9qc^DEOu zzJigZHzUO19bC;CL_@{nW@OtS>O*S8c=`hX-!Ps6bX=VnPsLQL3MWu^fH-9WT}#K* ziV4(>4yr>FsD%El0u!lc=m0f_)5ewGA8ULWYwV0QzKu1$i#5KFHIBp@n`4cmvBp=i z#y;J+^uHK4aN<)+mfz<~ReL5)%^kNR jG;P>y_zs(qSyN~LnN#j~K&_iXt<~*QsHxicYkKTIvW4Rh delta 6756 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 E0me@F&Hw-a diff --git a/contracts/tests/sysio.msgch_chain_tests.cpp b/contracts/tests/sysio.msgch_chain_tests.cpp index 1a5a87e926..cbdfae2361 100644 --- a/contracts/tests/sysio.msgch_chain_tests.cpp +++ b/contracts/tests/sysio.msgch_chain_tests.cpp @@ -2309,6 +2309,65 @@ BOOST_FIXTURE_TEST_CASE(advance_freezes_and_recovers_withheld_operator_window, BOOST_REQUIRE_EQUAL(duty_member(), BATCHOP_D); } FC_LOG_AND_RETHROW() } +/// A held group can accrue a real audit miss on every epoch without rotating +/// through the configured schedule. Neither a check during the hold nor a +/// later check after publication resumes may count those accelerated rows. +BOOST_FIXTURE_TEST_CASE(held_duty_misses_are_audited_without_accelerating_termination, + sysio_msgch_chain_tester) { try { + bootstrap(/*n_batch_ops=*/3, /*batchop_is_bootstrapped=*/false); + set_termination_thresholds(/*max_consecutive_misses=*/1, + /*max_percent_misses=*/49); + + // Keep A's normal-duty history clean before the window is withheld. + BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, CHALG_ACCOUNT, + "slash"_n, mvo()("account", BATCHOP_B.to_string()) + ("reason", "hold the one-group window"))); + const auto normal = encode_delivery(current_epoch(), "normal-duty hit"); + for (const auto chain : {ETH_OUTPOST_ID, SOL_OUTPOST_ID}) + BOOST_REQUIRE_EQUAL(success(), deliver_as(BATCHOP, chain, normal)); + advance_to_next_epoch(); + BOOST_REQUIRE(read_epoch_state()[epoch_fields::NEXT_BATCH_OP_GROUPS].get_array().empty()); + BOOST_REQUIRE_EQUAL(get_operator(BATCHOP)[opreg_fields::STATUS].as(), + opp::types::OPERATOR_STATUS_ACTIVE); + + const auto first_held_epoch = current_epoch(); + for (uint32_t held = 0; held < 2; ++held) { + const auto expiring_epoch = current_epoch(); + advance_to_next_epoch(); // accounting-policy test; no quorum is simulated here + BOOST_REQUIRE_EQUAL(get_operator(BATCHOP)[opreg_fields::STATUS].as(), + opp::types::OPERATOR_STATUS_ACTIVE); + BOOST_REQUIRE(!get_row_by_account(OPREG_ACCOUNT, OPREG_ACCOUNT, + "heldepochs"_n, name{expiring_epoch}).empty()); + BOOST_REQUIRE(read_epoch_state()[epoch_fields::NEXT_BATCH_OP_GROUPS].get_array().empty()); + } + + uint32_t held_audit_misses = 0; + for (uint64_t id = 0; id < TABLE_SCAN_LIMIT; ++id) { + const auto data = get_row_by_account(OPREG_ACCOUNT, OPREG_ACCOUNT, "dellog"_n, name{id}); + if (data.empty()) continue; + const auto row = opreg_abi.binary_to_variant("delivery_log_entry", data, + abi_serializer::create_yield_function(abi_serializer_max_time)); + if (row["account"].as_string() == BATCHOP.to_string() && + row["epoch"].as_uint64() >= first_held_epoch && + !row["delivered"].as_bool()) ++held_audit_misses; + } + BOOST_REQUIRE_EQUAL(held_audit_misses, 4u); // two held epochs x two outposts + + BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, + "regoperator"_n, mvo()("account", BATCHOP_D.to_string()) + ("type", opp::types::OPERATOR_TYPE_BATCH)("is_bootstrapped", true))); + advance_to_next_epoch(); // publishes the repaired candidate while duty is still held + BOOST_REQUIRE(!read_epoch_state()[epoch_fields::NEXT_BATCH_OP_GROUPS].get_array().empty()); + const auto resumed = encode_delivery(current_epoch(), "resumed-duty hit"); + for (const auto chain : {ETH_OUTPOST_ID, SOL_OUTPOST_ID}) + BOOST_REQUIRE_EQUAL(success(), deliver_as(BATCHOP, chain, resumed)); + advance_to_next_epoch(); + BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, EPOCH_ACCOUNT, + "termcheck"_n, mvo()("account", BATCHOP.to_string()))); + BOOST_REQUIRE_EQUAL(get_operator(BATCHOP)[opreg_fields::STATUS].as(), + opp::types::OPERATOR_STATUS_ACTIVE); +} FC_LOG_AND_RETHROW() } + // WIRE-385: a removal during this advance must be visible in BOTH emitted // attestations, with a healthy standby filling the newly selected tail. BOOST_FIXTURE_TEST_CASE(advance_roster_excludes_same_epoch_termination, From 5754ab76c8203b90cdae4514a02789086a52eb66 Mon Sep 17 00:00:00 2001 From: Huang-Ming Huang Date: Tue, 15 Sep 2026 14:59:51 +0000 Subject: [PATCH 10/15] Fix held-duty audit marker timing Change-Id: I4f087057908d9eb7c5e3624540e7f0066aaf13fc --- contracts/sysio.opreg/src/sysio.opreg.cpp | 6 +++++- contracts/sysio.opreg/sysio.opreg.wasm | Bin 94313 -> 94323 bytes contracts/tests/sysio.msgch_chain_tests.cpp | 3 +++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/contracts/sysio.opreg/src/sysio.opreg.cpp b/contracts/sysio.opreg/src/sysio.opreg.cpp index e1d06d173e..c4a2c1d9d6 100644 --- a/contracts/sysio.opreg/src/sysio.opreg.cpp +++ b/contracts/sysio.opreg/src/sysio.opreg.cpp @@ -1732,7 +1732,11 @@ void opreg::recorddel(name account, uint32_t epoch, bool delivered) { sysio::epoch::epochstate_t epoch_tbl(EPOCH_ACCOUNT); if (epoch_tbl.exists()) { const auto state = epoch_tbl.get(); - if (state.current_epoch_index == epoch && state.next_batch_op_groups.empty()) { + // `advance` writes the incremented state before queued `recorddel` + // actions execute, while `finishadv` has not changed the publication yet. + if (state.current_epoch_index > 0 && + state.current_epoch_index - 1 == epoch && + state.next_batch_op_groups.empty()) { heldepochs_t held(get_self()); const held_epoch_key key{epoch}; if (!held.contains(key)) { diff --git a/contracts/sysio.opreg/sysio.opreg.wasm b/contracts/sysio.opreg/sysio.opreg.wasm index e806ed58d39071a64e8e072b27ce604e69c5e802..61cd7af67dec6e8c0d7338cb403a137abfdf611d 100755 GIT binary patch delta 39 xcmV+?0NDTO;05#G1+Xjz0pPPO2F=X@v9tTr=K%^L1w{=2AO%5xYO~_ihni~P5n=!U delta 28 mcmV+%0OSAj;05X61+Xjz0oJoE2F=X@r?dOh=K-_q)rXqZR1by# diff --git a/contracts/tests/sysio.msgch_chain_tests.cpp b/contracts/tests/sysio.msgch_chain_tests.cpp index cbdfae2361..e2acbee859 100644 --- a/contracts/tests/sysio.msgch_chain_tests.cpp +++ b/contracts/tests/sysio.msgch_chain_tests.cpp @@ -2325,6 +2325,7 @@ BOOST_FIXTURE_TEST_CASE(held_duty_misses_are_audited_without_accelerating_termin const auto normal = encode_delivery(current_epoch(), "normal-duty hit"); for (const auto chain : {ETH_OUTPOST_ID, SOL_OUTPOST_ID}) BOOST_REQUIRE_EQUAL(success(), deliver_as(BATCHOP, chain, normal)); + produce_blocks(); // commit pending actions before jumping to the next epoch advance_to_next_epoch(); BOOST_REQUIRE(read_epoch_state()[epoch_fields::NEXT_BATCH_OP_GROUPS].get_array().empty()); BOOST_REQUIRE_EQUAL(get_operator(BATCHOP)[opreg_fields::STATUS].as(), @@ -2356,11 +2357,13 @@ BOOST_FIXTURE_TEST_CASE(held_duty_misses_are_audited_without_accelerating_termin BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, "regoperator"_n, mvo()("account", BATCHOP_D.to_string()) ("type", opp::types::OPERATOR_TYPE_BATCH)("is_bootstrapped", true))); + produce_blocks(); advance_to_next_epoch(); // publishes the repaired candidate while duty is still held BOOST_REQUIRE(!read_epoch_state()[epoch_fields::NEXT_BATCH_OP_GROUPS].get_array().empty()); const auto resumed = encode_delivery(current_epoch(), "resumed-duty hit"); for (const auto chain : {ETH_OUTPOST_ID, SOL_OUTPOST_ID}) BOOST_REQUIRE_EQUAL(success(), deliver_as(BATCHOP, chain, resumed)); + produce_blocks(); advance_to_next_epoch(); BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, EPOCH_ACCOUNT, "termcheck"_n, mvo()("account", BATCHOP.to_string()))); From 813957f6ce4aac44d7e58583124793e8440db576 Mon Sep 17 00:00:00 2001 From: Huang-Ming Huang Date: Mon, 21 Sep 2026 18:53:27 +0000 Subject: [PATCH 11/15] Re-anchor held operator duty on outposts Change-Id: I406eeeadd32a75db1c60458a26d952a40a2b4fed --- contracts/sysio.epoch/src/sysio.epoch.cpp | 24 ++++++-- contracts/sysio.epoch/sysio.epoch.wasm | Bin 81621 -> 82444 bytes contracts/tests/sysio.msgch_chain_tests.cpp | 62 +++++++++++--------- 3 files changed, 54 insertions(+), 32 deletions(-) diff --git a/contracts/sysio.epoch/src/sysio.epoch.cpp b/contracts/sysio.epoch/src/sysio.epoch.cpp index 6f8acb87f1..64406a1549 100644 --- a/contracts/sysio.epoch/src/sysio.epoch.cpp +++ b/contracts/sysio.epoch/src/sysio.epoch.cpp @@ -781,8 +781,8 @@ void epoch::finishadv(uint32_t epoch_index, int64_t emission_amount) { } } - // Failed candidates never become persistent schedule state. Empty pending - // state explicitly means no new publication and therefore no next activation. + // Failed candidates never become persistent successor state. Empty pending + // state explicitly means no new activation on the following advance. const bool publish_schedule = candidate_groups.size() == cfg.batch_op_groups && std::all_of(candidate_groups.begin(), candidate_groups.end(), [&](const auto& group) { return group.size() == cfg.operators_per_epoch && @@ -792,7 +792,7 @@ void epoch::finishadv(uint32_t epoch_index, int64_t emission_amount) { state.next_batch_op_groups = std::move(candidate_groups); } else { sysio::print("sysio.epoch::finishadv: incomplete schedule candidate at epoch ", epoch_index, - "; withholding BatchOperatorGroups and retaining the announced duty\n"); + "; withholding the successor and re-announcing the held duty\n"); } state_tbl.set(state, ram_payer); @@ -871,16 +871,30 @@ void epoch::finishadv(uint32_t epoch_index, int64_t emission_amount) { // the delivery is inside the envelope being refused, so recovery cannot land. // Group zero of a rotating candidate is historical when this announcement // lands; inactive placeholders there preserve the serving group's positions. + // + // When the candidate is incomplete, publish a one-group lease containing + // only the duty that just delivered this envelope. Re-anchoring that exact + // group on every held epoch is required by epoch-indexed outposts: merely + // omitting BATCH_OPERATOR_GROUPS would make them advance through the old + // resident window while the depot intentionally keeps this group in duty. + std::vector> announced_groups; + uint32_t announced_active_group = 0; if (publish_schedule) { + announced_groups = state.next_batch_op_groups; + announced_active_group = serving_group_index; + } else if (state.current_batch_op_group < state.batch_op_groups.size()) { + announced_groups.push_back(state.batch_op_groups[state.current_batch_op_group]); + } + if (!announced_groups.empty()) { opp::attestations::BatchOperatorGroups attest; - attest.active_group_index = zpp::bits::vuint32_t{serving_group_index}; + attest.active_group_index = zpp::bits::vuint32_t{announced_active_group}; attest.epoch_index = zpp::bits::vuint32_t{state.current_epoch_index}; // Propagate the depot's minimum epoch duration so the outpost can // evaluate the fallback (path-2) majority consensus after this many // seconds since the current epoch started — see // .claude/rules/opp-consensus.md. attest.epoch_duration_sec = zpp::bits::vuint32_t{cfg.epoch_duration_sec}; - for (const auto& group : state.next_batch_op_groups) { + for (const auto& group : announced_groups) { opp::attestations::BatchOperatorGroup grp; for (auto& op_name : group) { opp::types::ChainAddress addr; diff --git a/contracts/sysio.epoch/sysio.epoch.wasm b/contracts/sysio.epoch/sysio.epoch.wasm index 10e08b77a0e4d78dc6e0a554aa44d639a1ae7f5d..40bf10ac40635ad9b7152eea75ee08c932954691 100755 GIT binary patch delta 6064 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 LhU9ARsW9h17sULn diff --git a/contracts/tests/sysio.msgch_chain_tests.cpp b/contracts/tests/sysio.msgch_chain_tests.cpp index e2acbee859..2720adc9a4 100644 --- a/contracts/tests/sysio.msgch_chain_tests.cpp +++ b/contracts/tests/sysio.msgch_chain_tests.cpp @@ -162,6 +162,7 @@ namespace epoch_fields { constexpr const char* BATCH_OP_GROUPS = "batch_op_groups"; constexpr const char* NEXT_BATCH_OP_GROUPS = "next_batch_op_groups"; constexpr const char* CURRENT_BATCH_OP_GROUP = "current_batch_op_group"; +constexpr const char* CURRENT_EPOCH_INDEX = "current_epoch_index"; constexpr const char* IS_PAUSED = "is_paused"; } // namespace epoch_fields @@ -691,8 +692,9 @@ class sysio_msgch_chain_tester : public tester { /// Inspect the actual emitted envelope, after inline buildenv drained queueout. /// The final OPERATORS snapshot must match the registry, and any published - /// active/future schedule must follow that snapshot. A held historical seat - /// can intentionally remain as an inactive denominator placeholder. + /// active/future schedule must follow that snapshot. A one-group held-duty + /// lease may intentionally retain an inactive incumbent as a denominator + /// placeholder. void require_fresh_roster(uint64_t chain_code, name account, opp::types::OperatorStatus expected_status, bool expect_schedule_absence = true) { @@ -721,14 +723,19 @@ class sysio_msgch_chain_tester : public tester { BOOST_REQUIRE(have_operators); opp::attestations::BatchOperatorGroups groups; BOOST_REQUIRE(groups.ParseFromString(att.data())); + const auto state = read_epoch_state(); + const auto held = state[epoch_fields::NEXT_BATCH_OP_GROUPS].get_array().empty() && + groups.groups_size() == 1 && groups.active_group_index() == 0; std::set members; for (const auto& group : groups.groups()) { for (const auto& address : group.operators()) { BOOST_REQUIRE(members.insert(address.address()).second); const auto registered = get_operator(name{address.address()}); BOOST_REQUIRE(!registered.is_null()); - BOOST_REQUIRE_EQUAL(opp::types::OPERATOR_STATUS_ACTIVE, - registered["status"].as()); + if (!held) { + BOOST_REQUIRE_EQUAL(opp::types::OPERATOR_STATUS_ACTIVE, + registered["status"].as()); + } } } } @@ -744,22 +751,23 @@ class sysio_msgch_chain_tester : public tester { } } - /// How many BATCH_OPERATOR_GROUPS attestations the most recent `advance` shipped to - /// `chain_code` -- 0 when the depot WITHHELD it. Distinct from - /// `shipped_batch_operator_groups`, which fails the test on absence: the withhold path - /// needs to assert absence while still proving the envelope itself was built (i.e. that - /// `advance` skipped only this queueout and went on to emit the epoch's other - /// attestations, rather than returning early). - int shipped_batch_operator_groups_count(uint64_t chain_code) { - auto row = find_outbound_envelope(chain_code); - BOOST_REQUIRE(!row.is_null()); - auto env = decode_envelope(row["raw_envelope"].as>()); - BOOST_REQUIRE_EQUAL(env.messages_size(), 1); - int count = 0; - for (const auto& att : env.messages(0).payload().attestations()) { - if (att.type() == sysio::opp::types::ATTESTATION_TYPE_BATCH_OPERATOR_GROUPS) ++count; - } - return count; + /// Require the incomplete-candidate path to re-anchor exactly the group the + /// depot keeps in duty. The lease is intentionally one group wide: it says + /// nothing about an incomplete future window. + sysio::opp::attestations::BatchOperatorGroups require_held_group_announcement( + uint64_t chain_code) { + const auto state = read_epoch_state(); + const auto current_index = state[epoch_fields::CURRENT_BATCH_OP_GROUP].as_uint64(); + const auto current = state[epoch_fields::BATCH_OP_GROUPS].get_array()[current_index].get_array(); + const auto groups = shipped_batch_operator_groups(chain_code); + BOOST_REQUIRE_EQUAL(groups.groups_size(), 1); + BOOST_REQUIRE_EQUAL(groups.active_group_index(), 0u); + BOOST_REQUIRE_EQUAL(groups.epoch_index(), state[epoch_fields::CURRENT_EPOCH_INDEX].as_uint64()); + BOOST_REQUIRE_EQUAL(groups.groups(0).operators_size(), current.size()); + for (size_t i = 0; i < current.size(); ++i) + BOOST_REQUIRE_EQUAL(groups.groups(0).operators(static_cast(i)).address(), + current[i].as_string()); + return groups; } /// Total attestations in the most recent outbound envelope for `chain_code`. @@ -1710,7 +1718,7 @@ BOOST_FIXTURE_TEST_CASE(noncanonical_delivery_slashes_before_termination, sysio_ require_fresh_roster(chain, BATCHOP, opp::types::OPERATOR_STATUS_SLASHED, /*expect_schedule_absence=*/false); // The remaining two members must not be advertised with a reduced quorum. - BOOST_REQUIRE_EQUAL(0, shipped_batch_operator_groups_count(chain)); + require_held_group_announcement(chain); } } FC_LOG_AND_RETHROW() } @@ -2130,7 +2138,7 @@ BOOST_FIXTURE_TEST_CASE(advance_repairs_single_group_ineligible_slot_in_place, produce_blocks(); advance_to_next_epoch(); - BOOST_REQUIRE_EQUAL(0, shipped_batch_operator_groups_count(ETH_OUTPOST_ID)); + require_held_group_announcement(ETH_OUTPOST_ID); auto held = read_epoch_state()["batch_op_groups"].get_array(); BOOST_REQUIRE_EQUAL(held.size(), 1u); BOOST_REQUIRE_EQUAL(held[0].get_array()[0].as_string(), BATCHOP.to_string()); @@ -2181,7 +2189,7 @@ BOOST_FIXTURE_TEST_CASE(advance_discards_partial_candidate_and_activates_complet BOOST_REQUIRE_EQUAL(group[0].as_string(), BATCHOP.to_string()); BOOST_REQUIRE_EQUAL(group[1].as_string(), BATCHOP_C.to_string()); BOOST_REQUIRE_EQUAL(group[2].as_string(), BATCHOP_B.to_string()); - BOOST_REQUIRE_EQUAL(shipped_batch_operator_groups_count(ETH_OUTPOST_ID), 0); + require_held_group_announcement(ETH_OUTPOST_ID); } BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, "regoperator"_n, mvo()("account", BATCHOP_E.to_string()) @@ -2213,7 +2221,7 @@ BOOST_FIXTURE_TEST_CASE(chkcons_cannot_recover_without_a_live_held_group_signer, advance_via_consensus(); BOOST_REQUIRE_EQUAL(current_epoch(), anchor_epoch + 1); BOOST_REQUIRE_EQUAL(duty_member(), BATCHOP_C); - BOOST_REQUIRE_EQUAL(shipped_batch_operator_groups_count(ETH_OUTPOST_ID), 0); + require_held_group_announcement(ETH_OUTPOST_ID); BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, "terminate"_n, mvo()("account", BATCHOP_C.to_string())("reason", "lose every held signer"))); @@ -2242,7 +2250,7 @@ BOOST_FIXTURE_TEST_CASE(chkcons_cannot_recover_without_a_live_held_group_signer, BOOST_REQUIRE_EQUAL(duty_member(), BATCHOP_C); BOOST_REQUIRE(read_epoch_state()[epoch_fields::NEXT_BATCH_OP_GROUPS].get_array().empty()); BOOST_REQUIRE_EQUAL(get_outpcons(ETH_OUTPOST_ID)["epoch_index"].as_uint64(), anchor_epoch); - BOOST_REQUIRE_EQUAL(shipped_batch_operator_groups_count(ETH_OUTPOST_ID), 0); + require_held_group_announcement(ETH_OUTPOST_ID); } } FC_LOG_AND_RETHROW() } @@ -2286,7 +2294,7 @@ BOOST_FIXTURE_TEST_CASE(advance_freezes_and_recovers_withheld_operator_window, BOOST_REQUIRE_EQUAL(duty_member(), BATCHOP_C); BOOST_REQUIRE(read_epoch_state()[epoch_fields::NEXT_BATCH_OP_GROUPS].get_array().empty()); BOOST_REQUIRE_GT(shipped_attestation_count(ETH_OUTPOST_ID), 0); - BOOST_REQUIRE_EQUAL(shipped_batch_operator_groups_count(ETH_OUTPOST_ID), 0); + require_held_group_announcement(ETH_OUTPOST_ID); require_fresh_roster(ETH_OUTPOST_ID, BATCHOP_B, opp::types::OPERATOR_STATUS_TERMINATED); deliver_and_advance(BATCHOP_C); } @@ -2409,7 +2417,7 @@ BOOST_FIXTURE_TEST_CASE(advance_preserves_announced_successor_and_withholds_inac require_fresh_roster(ETH_OUTPOST_ID, BATCHOP_C, opp::types::OPERATOR_STATUS_SLASHED, /*expect_schedule_absence=*/false); - BOOST_REQUIRE_EQUAL(0, shipped_batch_operator_groups_count(ETH_OUTPOST_ID)); + require_held_group_announcement(ETH_OUTPOST_ID); } FC_LOG_AND_RETHROW() } BOOST_FIXTURE_TEST_CASE(advance_repairs_future_group_before_it_becomes_current, From a9a4c51f5e73de547289428748b1344328d2fb82 Mon Sep 17 00:00:00 2001 From: Huang-Ming Huang Date: Tue, 22 Sep 2026 20:48:04 +0000 Subject: [PATCH 12/15] Restore ordinary held-duty termination accounting Change-Id: I87ef44d4d9eea670e995afb2fd5ba5ae66be5ede --- contracts/sysio.epoch/src/sysio.epoch.cpp | 20 ++-- contracts/sysio.epoch/sysio.epoch.wasm | Bin 82444 -> 82429 bytes contracts/sysio.msgch/sysio.msgch.wasm | Bin 161245 -> 161321 bytes .../include/sysio.opreg/sysio.opreg.hpp | 18 ---- contracts/sysio.opreg/src/sysio.opreg.cpp | 36 ------- contracts/sysio.opreg/sysio.opreg.abi | 32 ------ contracts/sysio.opreg/sysio.opreg.wasm | Bin 94323 -> 92496 bytes contracts/tests/sysio.msgch_chain_tests.cpp | 97 ++++++++---------- 8 files changed, 50 insertions(+), 153 deletions(-) diff --git a/contracts/sysio.epoch/src/sysio.epoch.cpp b/contracts/sysio.epoch/src/sysio.epoch.cpp index 64406a1549..6a90fa3929 100644 --- a/contracts/sysio.epoch/src/sysio.epoch.cpp +++ b/contracts/sysio.epoch/src/sysio.epoch.cpp @@ -613,9 +613,9 @@ void epoch::advance() { ).send(); } - // Preserve the delivery history after slashing and while duty is held. - // opreg marks held-epoch observations as audit-only so a later rotating - // epoch cannot retroactively count the accelerated held-duty misses. + // Preserve delivery history and ordinary termination accounting even + // while the incumbent group remains on duty. Holding the schedule does + // not exempt an operator from its delivery obligations. // A non-canonical operator is already SLASHED here, so termcheck safely // skips that operator without converting the punitive outcome into a remit. for (const auto& observation : observations) { @@ -625,14 +625,12 @@ void epoch::advance() { opreg_actions::RECORD_DELIVERY, std::make_tuple(observation.member, state.current_epoch_index, observation.did_deliver) ).send(); - if (!state.next_batch_op_groups.empty()) { - action( - permission_level{get_self(), "owner"_n}, - OPREG_ACCOUNT, - opreg_actions::TERMINATION_CHECK, - std::make_tuple(observation.member) - ).send(); - } + action( + permission_level{get_self(), "owner"_n}, + OPREG_ACCOUNT, + opreg_actions::TERMINATION_CHECK, + std::make_tuple(observation.member) + ).send(); } // NOTE: we intentionally do NOT erase the per-batch-op envelope diff --git a/contracts/sysio.epoch/sysio.epoch.wasm b/contracts/sysio.epoch/sysio.epoch.wasm index 40bf10ac40635ad9b7152eea75ee08c932954691..c096fc0517ce8d4a182a827437b8c22e94a69c44 100755 GIT binary patch delta 40 ycmV+@0N4MFga!SB1+eu50o1eh1e6;Au(OLG#|@KXE~=9_E*gU+FSjKx0p}8E;}8`9 delta 54 zcmey{%-Yk!y5TDe+4{NN-j1KfUe$ J^cepO0RS;j6N>-< diff --git a/contracts/sysio.msgch/sysio.msgch.wasm b/contracts/sysio.msgch/sysio.msgch.wasm index 22043a782e6934d3ed583064039d4c57cb25b4e5..669bd9e2f96880c1709563d48bf4ea79b9d7589f 100755 GIT binary patch delta 407 zcmccnnsenF&JAza7~gDu%ho9v8OO*}ugu`sz?iMXpu(WQ;MB0CW9`%fyKcCfGgmk= zC^9lRC@?twV8{~icjX1jLpY9oCl~|*6c{v^fGU7O8d(B?Qb1Y;L~{cf4UC(QORQsL zjF?;|r7otE<-}ml1k$SlXB}YJyh3V4&n zXYevOGB~!MVGv;CR$v4PPOkHwB*+ML0}IF@j3E0ZeU2J1{@Q%MhJ}Ug1QW<%n;$e> z;NnnVb_8-2g(q+5E@cr03Qtz)*#uK@xkqj%&u{24j!F}|2C;LjvF zJwlgBi34Z>(8ouar+?673NXLHkfp%nD3JwpwmCD<-HsQ)0s;tu6CeRiW)844hy_&W RFinq1oH1d$gdWpTcL4pEYaRdq delta 272 zcmZ4ahV$-g&JAza7#D1Q%ho9v7{STrMQ8N4-A{nORQsL^qpKM zrOvIA<;Y;h1kz)$dAHOQ1D^YgOf2<`bzlo8M|(@jWB`q4gc#4u;K<6&|DLrqXvw(H~*?(VcE>yc$RDOo$k`fVLcl+3-n6wWW2gv$BogO zb9;k7BkMiJ`_tQWnarmj)ny8prod!5{h=$92xou-lj8w~EER_7jb=<*+o$O> >; - /// Epochs whose announced duty remained held. The delivery rows stay in - /// dellog for audit, but neither termination rail counts those rows. - /// Keeping the marker separate leaves the existing dellog row format - /// and its account/timestamp index unchanged. - struct held_epoch_key { - uint64_t epoch; - uint64_t primary_key() const { return epoch; } - SYSLIB_SERIALIZE(held_epoch_key, (epoch)) - }; - - struct [[sysio::table("heldepochs")]] held_epoch_entry { - uint64_t epoch = 0; - uint64_t ts_ms = 0; - SYSLIB_SERIALIZE(held_epoch_entry, (epoch)(ts_ms)) - }; - - using heldepochs_t = sysio::kv::table<"heldepochs"_n, held_epoch_key, held_epoch_entry>; - /// Claimable WIRE collateral owed to an operator by a WIRE-chain remit: a withdraw /// flush (`flushwtdw`), a deferred lock release on a TERMINATED operator, or the /// termination payout itself. diff --git a/contracts/sysio.opreg/src/sysio.opreg.cpp b/contracts/sysio.opreg/src/sysio.opreg.cpp index c4a2c1d9d6..1ae13ea288 100644 --- a/contracts/sysio.opreg/src/sysio.opreg.cpp +++ b/contracts/sysio.opreg/src/sysio.opreg.cpp @@ -634,17 +634,6 @@ void prune_dellog(uint64_t window_open_ms, uint32_t max_rows) { it = log.erase(std::move(it)); ++removed; } - - // Held-epoch markers are retained for the same window as their audit - // observations. Epoch order is timestamp order, so this is oldest-first - // and bounded by the same per-write/per-crank cap. - opreg::heldepochs_t held(name{"sysio.opreg"_n}); - removed = 0; - for (auto it = held.begin(); - it != held.end() && removed < max_rows && it->ts_ms < window_open_ms; ) { - it = held.erase(std::move(it)); - ++removed; - } } /// Get the current epoch index from sysio.epoch's epochstate singleton. @@ -1725,26 +1714,6 @@ void opreg::recorddel(name account, uint32_t epoch, bool delivered) { const auto cfg = cfg_tbl.get_or_default(op_config{}); prune_dellog(termination_window_open_ms(now_ms, cfg), MAX_DELLOG_PRUNE_PER_WRITE); - // A withheld schedule keeps the same group on duty every epoch instead of - // rotating it through the configured number of groups. Retain real misses - // in dellog, but mark this epoch once so termcheck cannot interpret those - // accelerated observations as ordinary rotating-duty misses later. - sysio::epoch::epochstate_t epoch_tbl(EPOCH_ACCOUNT); - if (epoch_tbl.exists()) { - const auto state = epoch_tbl.get(); - // `advance` writes the incremented state before queued `recorddel` - // actions execute, while `finishadv` has not changed the publication yet. - if (state.current_epoch_index > 0 && - state.current_epoch_index - 1 == epoch && - state.next_batch_op_groups.empty()) { - heldepochs_t held(get_self()); - const held_epoch_key key{epoch}; - if (!held.contains(key)) { - held.emplace(ram_payer, key, held_epoch_entry{.epoch = epoch, .ts_ms = now_ms}); - } - } - } - dellog_t log(get_self()); uint64_t id = next_dellog_id(); log.emplace(ram_payer, delivery_key{id}, delivery_log_entry{ @@ -1797,7 +1766,6 @@ void opreg::termcheck(name account) { uint64_t window_open = termination_window_open_ms(now_ms, cfg); dellog_t log(get_self()); - heldepochs_t held(get_self()); auto idx = log.get_index<"byaccountts"_n>(); uint128_t lower_key = (static_cast(account.value) << 64) | window_open; uint128_t upper_key = (static_cast(account.value) << 64) | std::numeric_limits::max(); @@ -1808,10 +1776,6 @@ void opreg::termcheck(name account) { uint32_t total_in_window = 0; for (auto it = idx.lower_bound(lower_key); it != idx.end() && it->by_account_ts() <= upper_key; ++it) { if (it->account != account) break; - if (held.contains(held_epoch_key{it->epoch})) { - consecutive_misses = 0; - continue; - } total_in_window++; if (!it->delivered) total_misses++; diff --git a/contracts/sysio.opreg/sysio.opreg.abi b/contracts/sysio.opreg/sysio.opreg.abi index 616fa8eef2..ab3bef11c9 100644 --- a/contracts/sysio.opreg/sysio.opreg.abi +++ b/contracts/sysio.opreg/sysio.opreg.abi @@ -284,30 +284,6 @@ } ] }, - { - "name": "held_epoch_entry", - "base": "", - "fields": [ - { - "name": "epoch", - "type": "uint64" - }, - { - "name": "ts_ms", - "type": "uint64" - } - ] - }, - { - "name": "held_epoch_key", - "base": "", - "fields": [ - { - "name": "epoch", - "type": "uint64" - } - ] - }, { "name": "op_config", "base": "", @@ -881,14 +857,6 @@ } ] }, - { - "name": "heldepochs", - "type": "held_epoch_entry", - "index_type": "i64", - "key_names": ["epoch"], - "key_types": ["uint64"], - "table_id": 10706 - }, { "name": "opconfig", "type": "op_config", diff --git a/contracts/sysio.opreg/sysio.opreg.wasm b/contracts/sysio.opreg/sysio.opreg.wasm index 61cd7af67dec6e8c0d7338cb403a137abfdf611d..233715e6e641160cd98190ea51e4286842172109 100755 GIT binary patch delta 6859 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 delta 8130 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 diff --git a/contracts/tests/sysio.msgch_chain_tests.cpp b/contracts/tests/sysio.msgch_chain_tests.cpp index 2720adc9a4..1e6425f23e 100644 --- a/contracts/tests/sysio.msgch_chain_tests.cpp +++ b/contracts/tests/sysio.msgch_chain_tests.cpp @@ -2317,66 +2317,51 @@ BOOST_FIXTURE_TEST_CASE(advance_freezes_and_recovers_withheld_operator_window, BOOST_REQUIRE_EQUAL(duty_member(), BATCHOP_D); } FC_LOG_AND_RETHROW() } -/// A held group can accrue a real audit miss on every epoch without rotating -/// through the configured schedule. Neither a check during the hold nor a -/// later check after publication resumes may count those accelerated rows. -BOOST_FIXTURE_TEST_CASE(held_duty_misses_are_audited_without_accelerating_termination, +/// Held-duty delivery observations retain the ordinary consecutive-miss and +/// rolling-percentage termination rules. This isolates accounting with the +/// privileged advance fixture; quorum-loss recovery is tested separately. +BOOST_FIXTURE_TEST_CASE(held_duty_misses_follow_ordinary_termination_rules, sysio_msgch_chain_tester) { try { - bootstrap(/*n_batch_ops=*/3, /*batchop_is_bootstrapped=*/false); - set_termination_thresholds(/*max_consecutive_misses=*/1, - /*max_percent_misses=*/49); - - // Keep A's normal-duty history clean before the window is withheld. - BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, CHALG_ACCOUNT, - "slash"_n, mvo()("account", BATCHOP_B.to_string()) - ("reason", "hold the one-group window"))); - const auto normal = encode_delivery(current_epoch(), "normal-duty hit"); - for (const auto chain : {ETH_OUTPOST_ID, SOL_OUTPOST_ID}) - BOOST_REQUIRE_EQUAL(success(), deliver_as(BATCHOP, chain, normal)); - produce_blocks(); // commit pending actions before jumping to the next epoch - advance_to_next_epoch(); - BOOST_REQUIRE(read_epoch_state()[epoch_fields::NEXT_BATCH_OP_GROUPS].get_array().empty()); - BOOST_REQUIRE_EQUAL(get_operator(BATCHOP)[opreg_fields::STATUS].as(), - opp::types::OPERATOR_STATUS_ACTIVE); - - const auto first_held_epoch = current_epoch(); - for (uint32_t held = 0; held < 2; ++held) { - const auto expiring_epoch = current_epoch(); - advance_to_next_epoch(); // accounting-policy test; no quorum is simulated here - BOOST_REQUIRE_EQUAL(get_operator(BATCHOP)[opreg_fields::STATUS].as(), + struct termination_limits { + uint32_t consecutive; + uint32_t percent; + }; + for (const auto limits : {termination_limits{1, 99}, termination_limits{5, 49}}) { + sysio_msgch_chain_tester tester; + tester.bootstrap(/*n_batch_ops=*/3, /*batchop_is_bootstrapped=*/false); + tester.set_termination_thresholds(limits.consecutive, limits.percent); + + BOOST_REQUIRE_EQUAL(success(), tester.push(OPREG_ACCOUNT, tester.opreg_abi, CHALG_ACCOUNT, + "slash"_n, mvo()("account", BATCHOP_B.to_string()) + ("reason", "hold the one-group window"))); + const auto normal = tester.encode_delivery(tester.current_epoch(), "normal-duty hit"); + for (const auto chain : {ETH_OUTPOST_ID, SOL_OUTPOST_ID}) + BOOST_REQUIRE_EQUAL(success(), tester.deliver_as(BATCHOP, chain, normal)); + tester.produce_blocks(); + tester.advance_to_next_epoch(); + BOOST_REQUIRE(tester.read_epoch_state()[epoch_fields::NEXT_BATCH_OP_GROUPS].get_array().empty()); + BOOST_REQUIRE_EQUAL(tester.get_operator(BATCHOP)[opreg_fields::STATUS].as(), opp::types::OPERATOR_STATUS_ACTIVE); - BOOST_REQUIRE(!get_row_by_account(OPREG_ACCOUNT, OPREG_ACCOUNT, - "heldepochs"_n, name{expiring_epoch}).empty()); - BOOST_REQUIRE(read_epoch_state()[epoch_fields::NEXT_BATCH_OP_GROUPS].get_array().empty()); - } - uint32_t held_audit_misses = 0; - for (uint64_t id = 0; id < TABLE_SCAN_LIMIT; ++id) { - const auto data = get_row_by_account(OPREG_ACCOUNT, OPREG_ACCOUNT, "dellog"_n, name{id}); - if (data.empty()) continue; - const auto row = opreg_abi.binary_to_variant("delivery_log_entry", data, - abi_serializer::create_yield_function(abi_serializer_max_time)); - if (row["account"].as_string() == BATCHOP.to_string() && - row["epoch"].as_uint64() >= first_held_epoch && - !row["delivered"].as_bool()) ++held_audit_misses; - } - BOOST_REQUIRE_EQUAL(held_audit_misses, 4u); // two held epochs x two outposts + const auto held_epoch = tester.current_epoch(); + tester.advance_to_next_epoch(); + BOOST_REQUIRE_EQUAL(tester.get_operator(BATCHOP)[opreg_fields::STATUS].as(), + opp::types::OPERATOR_STATUS_TERMINATED); - BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, - "regoperator"_n, mvo()("account", BATCHOP_D.to_string()) - ("type", opp::types::OPERATOR_TYPE_BATCH)("is_bootstrapped", true))); - produce_blocks(); - advance_to_next_epoch(); // publishes the repaired candidate while duty is still held - BOOST_REQUIRE(!read_epoch_state()[epoch_fields::NEXT_BATCH_OP_GROUPS].get_array().empty()); - const auto resumed = encode_delivery(current_epoch(), "resumed-duty hit"); - for (const auto chain : {ETH_OUTPOST_ID, SOL_OUTPOST_ID}) - BOOST_REQUIRE_EQUAL(success(), deliver_as(BATCHOP, chain, resumed)); - produce_blocks(); - advance_to_next_epoch(); - BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, EPOCH_ACCOUNT, - "termcheck"_n, mvo()("account", BATCHOP.to_string()))); - BOOST_REQUIRE_EQUAL(get_operator(BATCHOP)[opreg_fields::STATUS].as(), - opp::types::OPERATOR_STATUS_ACTIVE); + uint32_t held_misses = 0; + for (uint64_t id = 0; id < TABLE_SCAN_LIMIT; ++id) { + const auto data = tester.get_row_by_account(OPREG_ACCOUNT, OPREG_ACCOUNT, "dellog"_n, name{id}); + if (data.empty()) continue; + const auto row = tester.opreg_abi.binary_to_variant("delivery_log_entry", data, + abi_serializer::create_yield_function(abi_serializer_max_time)); + if (row["account"].as_string() == BATCHOP.to_string() && + row["epoch"].as_uint64() == held_epoch && !row["delivered"].as_bool()) ++held_misses; + } + BOOST_REQUIRE_EQUAL(held_misses, 2u); // one held epoch, two required outposts + for (const auto chain : {ETH_OUTPOST_ID, SOL_OUTPOST_ID}) + tester.require_fresh_roster(chain, BATCHOP, opp::types::OPERATOR_STATUS_TERMINATED, + /*expect_schedule_absence=*/false); + } } FC_LOG_AND_RETHROW() } // WIRE-385: a removal during this advance must be visible in BOTH emitted From 83b662d127c9db97cf28ae08fac60a14a6b5a276 Mon Sep 17 00:00:00 2001 From: Huang-Ming Huang Date: Tue, 22 Sep 2026 21:55:07 +0000 Subject: [PATCH 13/15] Limit WIRE-385 to post-mutation roster publication Change-Id: I37064cb952eb7ec9af71f5c603a54245c1f08bfc --- contracts/sysio.chains/sysio.chains.wasm | Bin 35767 -> 35739 bytes .../include/sysio.epoch/sysio.epoch.hpp | 11 +- contracts/sysio.epoch/src/sysio.epoch.cpp | 340 +++++++++----- contracts/sysio.epoch/sysio.epoch.abi | 4 - contracts/sysio.epoch/sysio.epoch.wasm | Bin 82429 -> 80467 bytes contracts/sysio.msgch/sysio.msgch.wasm | Bin 161321 -> 161245 bytes contracts/sysio.opreg/sysio.opreg.wasm | Bin 92496 -> 92467 bytes contracts/sysio.reserv/sysio.reserv.wasm | Bin 85229 -> 85200 bytes contracts/sysio.tokens/sysio.tokens.wasm | Bin 26764 -> 26737 bytes contracts/sysio.uwrit/sysio.uwrit.wasm | Bin 159717 -> 159688 bytes contracts/tests/sysio.msgch_chain_tests.cpp | 439 ++++-------------- .../src/group_election.hpp | 10 +- 12 files changed, 309 insertions(+), 495 deletions(-) diff --git a/contracts/sysio.chains/sysio.chains.wasm b/contracts/sysio.chains/sysio.chains.wasm index f16ef713753842999e29f887b50d69f4d3263e01..d3409e3fd52be3e679f7b7235dc4cbf6756c06d6 100755 GIT binary patch delta 144 zcmdl!ooV)TrVSgJ8JBF{#Jrn}@zCZxJ_GK}%O%}J7=0$c(^cnI$#P^cV`5NXa5UJg zt+&pY@y6s^iAIx!ll*;8Fk~q(IZ9+{GBcPnGbk`R9smmnAOtLc3N)EHz_K6~P@RDS e15lGfmOzS>0;2+xE`x$2gS!<&z~&7}?3w_2JR#2j delta 180 zcmbO|ooV}YrVSgJ84qmU#Jrn}@$}|AJ_ByH8BAFMj#8UvOL~ehhEIN~t1hOK<-}ml z#Gt_7r~+plVAw3Kx6YXH!Q^9!Ml1@9jvAA{CI*GwV8~Kna+Ju@WM(jDW&jFb01F5p z1WteiG?_WT(jXR4p@RYgP?tiMK#G(CqXLsIgMuT2yA?wMQ1AdlmI}k> batch_op_groups; - /// Complete window published this epoch, to activate on the next advance. - /// Empty when publication was withheld: the current duty continues. - std::vector> next_batch_op_groups; + uint8_t current_batch_op_group = 0; // 0, 1, or 2 + std::vector> batch_op_groups; // 3 groups of 7 checksum256 last_consensus_hash; bool is_paused = false; SYSLIB_SERIALIZE(epoch_state, (current_epoch_index)(current_epoch_start)(next_epoch_start) - (current_batch_op_group)(batch_op_groups)(next_batch_op_groups)(last_consensus_hash)(is_paused)) + (current_batch_op_group)(batch_op_groups)(last_consensus_hash)(is_paused)) }; using epochstate_t = sysio::kv::global<"epochstate"_n, epoch_state>; diff --git a/contracts/sysio.epoch/src/sysio.epoch.cpp b/contracts/sysio.epoch/src/sysio.epoch.cpp index 6a90fa3929..bc27f3ea38 100644 --- a/contracts/sysio.epoch/src/sysio.epoch.cpp +++ b/contracts/sysio.epoch/src/sysio.epoch.cpp @@ -311,8 +311,9 @@ void epoch::setconfig(uint32_t epoch_duration_sec, } // The materialized rotation schedule (epoch_state.batch_op_groups) is - // sized from batch_op_groups at schbatchgps; later announcements and - // activations preserve that configured length. Every downstream invariant -- advance()'s + // sized from batch_op_groups once, at schbatchgps; advance() thereafter + // preserves its length (pop-front / push-back) and never re-reads the + // config to resize it. Every downstream invariant -- advance()'s // scheduling horizon (current_epoch_index + batch_op_groups - 1) and // sysio.opreg's termination window -- assumes cfg.batch_op_groups equals // the live rotation length, so once a schedule exists the group count is @@ -613,11 +614,9 @@ void epoch::advance() { ).send(); } - // Preserve delivery history and ordinary termination accounting even - // while the incumbent group remains on duty. Holding the schedule does - // not exempt an operator from its delivery obligations. - // A non-canonical operator is already SLASHED here, so termcheck safely - // skips that operator without converting the punitive outcome into a remit. + // Preserve delivery history and ordinary termination accounting. Inline + // slashing completes before these actions, and all mutations complete + // before finishadv reads the registry for outbound attestations. for (const auto& observation : observations) { action( permission_level{get_self(), "owner"_n}, @@ -709,89 +708,112 @@ void epoch::finishadv(uint32_t epoch_index, int64_t emission_amount) { epochstate_t state_tbl(get_self()); auto state = state_tbl.get(); check(state.current_epoch_index == epoch_index, "finishadv epoch mismatch"); - // Activate only a window published by the preceding advance. On a hold - // there is no pending announcement, so both membership and positions stay - // unchanged. In particular, one-group replacements cannot serve early. - const uint32_t serving_group_index = cfg.batch_op_groups > 1 ? 1 : 0; - const bool activate_schedule = !state.next_batch_op_groups.empty(); - if (activate_schedule) { - state.batch_op_groups = std::move(state.next_batch_op_groups); - state.next_batch_op_groups.clear(); - state.current_batch_op_group = serving_group_index; - } - - opreg::operators_t current_ops(OPREG_ACCOUNT); - auto is_active_batch_operator = [&](name account) { - const auto key = opreg::operator_key{account.value}; - if (!current_ops.contains(key)) return false; - const auto op = current_ops.get(key); - return op.status == OperatorStatus::OPERATOR_STATUS_ACTIVE && - op.type == OperatorType::OPERATOR_TYPE_BATCH; - }; - - // Construct a disposable candidate beginning with this epoch's serving - // group. Multi-group publication names its successor; a single group names - // its repaired self. Only the candidate may acquire replacement members. - std::vector> candidate_groups; - if (state.current_batch_op_group < state.batch_op_groups.size()) { - candidate_groups.assign(state.batch_op_groups.begin() + state.current_batch_op_group, - state.batch_op_groups.end()); - candidate_groups.resize(cfg.batch_op_groups); - - // Keep healthy seats at their existing positions. For multiple groups, - // candidate group zero is the unchanged delivery group and may contain - // inactive historical placeholders. Every active/future seat must be live. - for (size_t g = serving_group_index; g < candidate_groups.size(); ++g) { - auto& group = candidate_groups[g]; - group.resize(cfg.operators_per_epoch); - for (auto& member : group) - if (!is_active_batch_operator(member)) member = name{}; - } + const bool had_expiring_group = epoch_index > 1; + + // ── Slide the schedule window ─────────────────────────────────────────── + // Skip on the genesis advance (0 → 1): schbatchgps just placed + // [G1, G2, G3] for epochs 1, 2, 3 and G1 is now the current (front) + // group — popping here would lose it. From the SECOND advance onward + // (1 → 2, 2 → 3, ...), the front group has just expired so we pop + // it and compute a new tail. + // + // Eligibility for the new tail: ACTIVE batch ops, sorted non-bootstrapped + // first (preference rule), MINUS anyone already resident in the N-1 + // surviving groups. The window itself encodes "scheduled in the last + // N-1 epochs" — no separate history table. + // + // After: window = [current, current+1, ..., current+N-1], front is + // always the active group → current_batch_op_group stays at 0. + if (had_expiring_group && !state.batch_op_groups.empty()) { + state.batch_op_groups.erase(state.batch_op_groups.begin()); + // Collect already-resident accounts so the new tail excludes them. std::vector resident; - for (const auto& group : candidate_groups) - for (const auto member : group) - if (member.value != 0) resident.push_back(member); + resident.reserve(cfg.batch_op_groups * cfg.operators_per_epoch); + for (const auto& g : state.batch_op_groups) { + for (const auto& a : g) resident.push_back(a); + } + auto is_resident = [&](name a) { + for (const auto& r : resident) if (r == a) return true; + return false; + }; - // One disjoint pool serves every vacancy and the tail. Preserve the - // existing non-bootstrapped preference and deterministic account order. + // Pull ACTIVE batch ops, non-bootstrapped first. `exclude_resident` + // applies the load-spreading rule (an operator that served in one of the + // N-1 surviving groups is skipped, which is what makes "at most every + // Nth epoch" hold). One collector for both passes -- the only difference + // between them is whether that rule is enforced. + // The tail is drawn ONLY from operators not already resident in the + // surviving window groups. That residency exclusion is what makes "at + // most every Nth epoch" hold, and -- less obviously -- it is what keeps + // the window's groups DISJOINT, which the Ethereum outpost depends on by + // construction: `OPPInbound._resolveChunkPosition` scans the groups in + // order and returns the FIRST one containing the sender, using the + // sender's index WITHIN that group as its chunk-staging header slot. An + // operator seated in two groups therefore stages against a group it is + // not serving in. Do not "fill" a short tail by re-seating a resident. + opreg::operators_t opreg_ops(OPREG_ACCOUNT); + auto status_idx = opreg_ops.get_index<"bystatus"_n>(); std::vector> pool; - auto status_idx = current_ops.get_index<"bystatus"_n>(); for (auto it = status_idx.lower_bound( magic_enum::enum_integer(OperatorStatus::OPERATOR_STATUS_ACTIVE)); - it != status_idx.end() && it->status == OperatorStatus::OPERATOR_STATUS_ACTIVE; ++it) { + it != status_idx.end() && + it->status == OperatorStatus::OPERATOR_STATUS_ACTIVE; ++it) { if (it->type != OperatorType::OPERATOR_TYPE_BATCH) continue; - if (std::find(resident.begin(), resident.end(), it->account) != resident.end()) continue; + if (is_resident(it->account)) continue; pool.push_back({it->account, it->is_bootstrapped}); } - std::sort(pool.begin(), pool.end(), [](const auto& a, const auto& b) { - if (a.second != b.second) return !a.second; - return a.first < b.first; - }); + std::sort(pool.begin(), pool.end(), + [](const auto& a, const auto& b) { + if (a.second != b.second) return !a.second; // non-bootstrapped first + return a.first < b.first; + }); + + std::vector new_tail; + new_tail.reserve(cfg.operators_per_epoch); + for (size_t i = 0; i < pool.size() && new_tail.size() < cfg.operators_per_epoch; ++i) { + new_tail.push_back(pool[i].first); + } - for (size_t g = serving_group_index; g < candidate_groups.size(); ++g) { - for (auto& member : candidate_groups[g]) { - if (member.value != 0) continue; - if (pool.empty()) break; - member = pool.front().first; - pool.erase(pool.begin()); - } + // A tail SHORTER than `operators_per_epoch` means the ACTIVE batch-operator + // roster has fallen below `batch_operator_minimum_active` (the config + // equality at ::setconfig pins that minimum to + // `operators_per_epoch * batch_op_groups`, i.e. exactly this window). The + // depot cannot repair that here: with a pool smaller than the window, N + // groups that are both FULL and DISJOINT do not exist, and both escapes + // are unsound -- re-seating a resident breaks the Ethereum disjointness + // above, while a short group lowers the quorum denominator it defines and + // makes EVEN group sizes reachable, where Ethereum's `(groupSize + 1) / 2` + // is an exact half and two competing digests can both tip. + // + // So the schedule is left as-is and the DECISION is pushed to the emit + // site: an empty active group is never published (see the withhold + // below). Short-but-non-empty is pre-existing behaviour and is not made + // safe here -- it is reported so the roster can be repaired off-chain. + if (new_tail.size() < cfg.operators_per_epoch) { + sysio::print("sysio.epoch::advance: only ", new_tail.size(), " of ", + cfg.operators_per_epoch, + " eligible batch operators for the new tail group at epoch ", + state.current_epoch_index + cfg.batch_op_groups - 1, + "; the ACTIVE roster is below batch_operator_minimum_active " + "-- operator roster needs attention\n"); } - } - // Failed candidates never become persistent successor state. Empty pending - // state explicitly means no new activation on the following advance. - const bool publish_schedule = candidate_groups.size() == cfg.batch_op_groups && - std::all_of(candidate_groups.begin(), candidate_groups.end(), [&](const auto& group) { - return group.size() == cfg.operators_per_epoch && - std::all_of(group.begin(), group.end(), [](name member) { return member.value != 0; }); - }); - if (publish_schedule) { - state.next_batch_op_groups = std::move(candidate_groups); - } else { - sysio::print("sysio.epoch::finishadv: incomplete schedule candidate at epoch ", epoch_index, - "; withholding the successor and re-announcing the held duty\n"); + state.batch_op_groups.push_back(std::move(new_tail)); } + + // Pinned to the FRONT of the sliding window, unconditionally. The window + // slides (erase-front + push-back), it does not rotate, so the group on duty + // is always at index 0 -- and the next-group lookahead further down derives + // `active_group_index` as `cursor + 1`, which is only the NEXT epoch's group + // because this is 0. A change that gives the cursor any other value must + // revisit that derivation; it is stated here, at the write, because that is + // the only place the invariant can actually be violated. + state.current_batch_op_group = 0; + + // Note: last_elected_epoch tracking is epoch-internal state. + // No operator table writes needed — group membership is in epoch_state.batch_op_groups. + state_tbl.set(state, ram_payer); // Queue OPERATORS attestation (full roster with authex chain addresses) for each outpost. @@ -862,37 +884,111 @@ void epoch::finishadv(uint32_t epoch_index, int64_t emission_amount) { } } - // Publish a complete next window using the unchanged OPP lookahead format. - // Current duty is kept separately and cannot change until the next advance. - // Outposts authorize an envelope against the group they already know. If we - // switch duty before publishing its lookahead, the roster that would authorise - // the delivery is inside the envelope being refused, so recovery cannot land. - // Group zero of a rotating candidate is historical when this announcement - // lands; inactive placeholders there preserve the serving group's positions. + // Queue BATCH_OPERATOR_GROUPS attestation for each outpost. // - // When the candidate is incomplete, publish a one-group lease containing - // only the duty that just delivered this envelope. Re-anchoring that exact - // group on every held epoch is required by epoch-indexed outposts: merely - // omitting BATCH_OPERATOR_GROUPS would make them advance through the old - // resident window while the depot intentionally keeps this group in duty. - std::vector> announced_groups; - uint32_t announced_active_group = 0; - if (publish_schedule) { - announced_groups = state.next_batch_op_groups; - announced_active_group = serving_group_index; - } else if (state.current_batch_op_group < state.batch_op_groups.size()) { - announced_groups.push_back(state.batch_op_groups[state.current_batch_op_group]); - } - if (!announced_groups.empty()) { + // Ships ALL groups, and an active index that points ONE EPOCH AHEAD -- + // at the group that will be on duty for `current_epoch_index + 1`, not the + // one on duty now (SOL-378 / WNS-141). + // + // The lookahead is what makes outpost-side admission possible at all. An + // outpost that scopes `epoch_in` admission to its seated active group must + // already hold epoch N's duty group BEFORE epoch N's envelope arrives -- + // because that envelope's own deliverer is a member of epoch N's group, and + // authorising it is what lets the envelope land. Shipping epoch N's roster + // inside epoch N's envelope is circular: the roster that would authorise the + // delivery is inside the envelope being refused, so the bridge stalls + // permanently at the first rotation. + // + // Emitting the NEXT epoch's group here breaks that circle without loosening + // anything on the outpost: envelope N-1 seats epoch N's group, so when + // envelope N arrives the outpost already knows who is allowed to deliver it. + // + // Only the ATTESTATION looks ahead. The depot's own schedule state is + // untouched -- `current_batch_op_group` still names the group on duty NOW, + // and `advance` still slides the window so the front is the current epoch. + // Nothing that reads `epoch_state` changes meaning. + // + // `epoch_index` stays the epoch this envelope IS for; it identifies the + // envelope, not the roster, and no outpost reads it. + { opp::attestations::BatchOperatorGroups attest; - attest.active_group_index = zpp::bits::vuint32_t{announced_active_group}; + // The window SLIDES; it does not rotate. `advance` erases the front and + // pushes a new tail, and every write to the cursor pins it to 0 (here, + // and `schbatchgps`) -- so the group on duty NEXT is simply the one + // after the cursor. + // + // Deliberately NOT `(cursor + 1) % group_count`. A modulo encodes ring + // semantics this window does not have: on wrap it yields 0, which names + // the group whose duty just STARTED. That ships a stale roster with + // nothing to catch it -- no compile error, no failing test, and the + // outpost cannot distinguish a stale index from a fresh one. + // + // The bound check is also what keeps an EMPTY schedule off a division. + // `group_count == 0` is reachable here: the slide above is guarded by + // `!empty()`, but nothing requires a seated schedule before this block, + // and `% 0` is an `i32.rem_u` trap that would abort `advance` and halt + // epoch advancement chain-wide. + // + // Falling back to the cursor covers the single-group case: the same + // operators serve every epoch, so current IS next. + // + // The cursor is pinned to 0 by every write to it, and the fallback is + // only correct BECAUSE of that -- with a non-zero cursor it would return + // the group whose duty just started, which is the stale-roster outcome + // the modulo was rejected for. The invariant is asserted at the WRITE + // site (`state.current_batch_op_group = 0` earlier in this same call), + // not here: a check at this point is unreachable-by-construction and so + // proves nothing -- it can only ever observe the value assigned a few + // hundred lines above it. + const uint32_t group_count = static_cast(state.batch_op_groups.size()); + const uint32_t next_index = state.current_batch_op_group + 1; + const uint32_t next_group_index = + next_index < group_count ? next_index : state.current_batch_op_group; + + // NEVER publish an empty "next". The index names the group the outpost + // will admit `epoch_in` against and size its quorum from, so an empty + // one is not a degraded roster -- it is an invalid attestation, and + // seating it wedges the outpost permanently (the handler that could + // replace the window runs only past the gate the empty group breaks). + // + // This is the ONE sound guarantee available here. The slide cannot buy + // non-emptiness by backfilling: with an ACTIVE pool smaller than the + // window, N groups that are both FULL and DISJOINT do not exist, and + // both escapes are unsound (see the slide's comment -- re-seating a + // resident breaks Ethereum's chunk-position disjointness; a short group + // lowers the quorum denominator it defines). So the schedule is left + // alone and the decision lands here. Withholding the attestation leaves + // the outpost on its previous window -- the same end state its own + // guards reach, without shipping an invalid payload. + // + // Cost, accepted deliberately: the withheld attestation also carries + // `epoch_duration_sec` and the whole-window resync that + // batch-operator-schedule-window.md wants on every envelope, so both are + // skipped for this epoch too. Shipping the payload with the index pinned + // to the CURRENT group instead would keep them, but it names a group the + // outpost must not treat as next, and the Solana handler refuses a window + // carrying an empty group regardless -- so it buys nothing here. + // + // Withheld by SKIPPING THE QUEUEOUT ONLY -- never by returning from + // `advance`, which still has the epoch's remaining attestations and + // actions to issue after this block. + const bool have_next_group = + next_group_index < group_count && !state.batch_op_groups[next_group_index].empty(); + if (!have_next_group) { + sysio::print("sysio.epoch::advance: no non-empty next group to publish at epoch ", + state.current_epoch_index, + " (groups=", group_count, ", next_index=", next_group_index, + "); withholding BatchOperatorGroups -- outposts retain their " + "previous window\n"); + } + attest.active_group_index = zpp::bits::vuint32_t{next_group_index}; attest.epoch_index = zpp::bits::vuint32_t{state.current_epoch_index}; // Propagate the depot's minimum epoch duration so the outpost can // evaluate the fallback (path-2) majority consensus after this many // seconds since the current epoch started — see // .claude/rules/opp-consensus.md. attest.epoch_duration_sec = zpp::bits::vuint32_t{cfg.epoch_duration_sec}; - for (const auto& group : announced_groups) { + for (auto& group : state.batch_op_groups) { opp::attestations::BatchOperatorGroup grp; for (auto& op_name : group) { opp::types::ChainAddress addr; @@ -908,19 +1004,22 @@ void epoch::finishadv(uint32_t epoch_index, int64_t emission_amount) { auto out = zpp::bits::out{encoded, zpp::bits::no_size{}}; (void)out(attest); - sysio::chains::chains_t chains_tbl(CHAINS_ACCOUNT); - for (auto it = chains_tbl.begin(); it != chains_tbl.end(); ++it) { - if (!is_active_outpost(*it)) continue; - action( - permission_level{get_self(), "owner"_n}, - MSGCH_ACCOUNT, - "queueout"_n, - std::make_tuple( - it->code.value, - opp::types::ATTESTATION_TYPE_BATCH_OPERATOR_GROUPS, - encoded - ) - ).send(); + // `have_next_group` gates the QUEUEOUT, not `advance` -- see above. + if (have_next_group) { + sysio::chains::chains_t chains_tbl(CHAINS_ACCOUNT); + for (auto it = chains_tbl.begin(); it != chains_tbl.end(); ++it) { + if (!is_active_outpost(*it)) continue; + action( + permission_level{get_self(), "owner"_n}, + MSGCH_ACCOUNT, + "queueout"_n, + std::make_tuple( + it->code.value, + opp::types::ATTESTATION_TYPE_BATCH_OPERATOR_GROUPS, + encoded + ) + ).send(); + } } } @@ -943,7 +1042,7 @@ void epoch::finishadv(uint32_t epoch_index, int64_t emission_amount) { // onto t5state (pending_emission_amount + batch_group_epochs[group] // + last_epoch_emission for decay continuity). // 2. rcrdbatch: always queued. Records the immutable roster that accrued - // this epoch from the activated serving window. + // this epoch after the schedule has slid for the next advance. // 3. payepoch: queued only on pay-epochs. Reads the now-updated t5state // (which already includes this epoch's contribution from step 1), // distributes period_emission, and resets the accumulator. @@ -989,9 +1088,11 @@ void epoch::finishadv(uint32_t epoch_index, int64_t emission_amount) { // them into N groups (`cfg.batch_op_groups`). The resulting window is // [epoch_1_group, epoch_2_group, ..., epoch_N_group]. // -// Each advance activates the preceding announcement, then proposes a complete -// next window. Candidates retain the current/future groups, repair vacancies, -// and append a disjoint tail. Failed candidates leave the serving window intact. +// After this, every per-epoch `advance` pops the front group and pushes +// a new tail group, where the tail's members are drawn from the ACTIVE +// pool MINUS anyone still resident in the N-1 surviving groups. The +// window itself encodes "scheduled in the last N-1 epochs"; no separate +// history table is needed. // --------------------------------------------------------------------------- void epoch::schbatchgps() { require_auth(get_self()); @@ -1045,9 +1146,8 @@ void epoch::schbatchgps() { // Store the window; advance picks up from here. epochstate_t state_tbl(get_self()); epoch_state state = state_tbl.get_or_default(epoch_state{}); - state.batch_op_groups = std::move(new_groups); - state.next_batch_op_groups.clear(); - state.current_batch_op_group = 0; // bootstrap duty precedes the first announcement + state.batch_op_groups = new_groups; + state.current_batch_op_group = 0; // front-of-window is always current state_tbl.set(state, ram_payer); } diff --git a/contracts/sysio.epoch/sysio.epoch.abi b/contracts/sysio.epoch/sysio.epoch.abi index ef13ba11fc..2f292b432f 100644 --- a/contracts/sysio.epoch/sysio.epoch.abi +++ b/contracts/sysio.epoch/sysio.epoch.abi @@ -111,10 +111,6 @@ "name": "batch_op_groups", "type": "B_vector_name_E[]" }, - { - "name": "next_batch_op_groups", - "type": "B_vector_name_E[]" - }, { "name": "last_consensus_hash", "type": "checksum256" diff --git a/contracts/sysio.epoch/sysio.epoch.wasm b/contracts/sysio.epoch/sysio.epoch.wasm index c096fc0517ce8d4a182a827437b8c22e94a69c44..3eb389c32b05149b82ef5ff9f3b775ba96a045d9 100755 GIT binary patch delta 26868 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⁣p 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 eWo4cmsea&`H)URCe_xHvO!w6z delta 28017 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 jnzSTrMQ8N4-A{nORQsL^qpKM zrOvIA<;Y;h1kz)$dAHOQ1D^YgOf2<`bzlo8M|(@jWB`q4gc#4u;K<6&|DLrqXvw(H~*?(VcE>yc$RDOo$k`fVLcl+3-n6wWW2gv$BogO zb9;k7BkMiJ`_tQWnarmj)ny8prod!5{h=$92xou-lj8w~EER_7jb=<*+o$O4&n zXYevOGB~!MVGv;CR$v4PPOkHwB*+ML0}IF@j3E0ZeU2J1{@Q%MhJ}Ug1QW<%n;$e> z;NnnVb_8-2g(q+5E@cr03Qtz)*#uK@xkqj%&u{24j!F}|2C;LjvF zJwlgBi34Z>(8ouar+?673NXLHkfp%nD3JwpwmCD<-HsQ)0s;tu6CeRiW)844hy_&W RFinq1oH1d$gdWpTcL4pEYaRdq diff --git a/contracts/sysio.opreg/sysio.opreg.wasm b/contracts/sysio.opreg/sysio.opreg.wasm index 233715e6e641160cd98190ea51e4286842172109..1cdf33ba6bad6c0b45cc8852d05d25b7f0f0cdcb 100755 GIT binary patch delta 334 zcmca`iFNZO)(uK*jH@;)v+Z-{Jk7z#QLoJ4cwqA@&lo1%6)cVnifl{{3apMRSh56) zU3nRR%ni(05CO;eM;HW36j(KwHZUu)O|J2gu-?I`GpZ8JJSpih9!05zSW6lg>I_?0=0W~-^fWU!WH{1ovK}vw~lVyB^ z8COg$@IAgofhzK9gj>R<*3hi<}KRa}JjHyEK~*LDyB z84M0mK{SX%s1ErF(*GcMUo85TbMANVc_*1z`NWFXY_T$dnr-M$n1~N~kd#6S> zH6?hq(d3p)P34~NE&|z@sb3%m&){Bgt8T>1vxyZz9i+iO{Xdf-L zk1>tT+E17#{^6F5Cs8NdSZ=5QgaI7n`kxy^aubRSzxr=g6edSm?|)Y@JSsqOL#`;F rfnY#9FM<0HG7Ws@lxywiu*rc^T)7`u;mD0m;1XKf(77M1;_dzqE16$X diff --git a/contracts/sysio.reserv/sysio.reserv.wasm b/contracts/sysio.reserv/sysio.reserv.wasm index 67209d72deb43885a8dd9c99ce2d06463feced0b..300949cfbd67b09132b985574be367175515c095 100755 GIT binary patch delta 145 zcmaDmll8(()(tmU8Ru-i$$CzR=`YXZl@igLc_nACG5StkCa=z|lI6%?#>Ak&;ApV< zv3%`)#`}{K|C>)<@IS!s1VferlcPkICNqOMGlK%7;{mXM07Ae5s6dmM11t+-0o55O gFaR|vWC<){lu}?+VA5q!aAa_|VhGr3&$!M90PF1~MF0Q* delta 169 zcmcaGllARP)(tmU8Fy~J$$CzRsf~B?N{MK;4(2QYAF0iZlC#+uBPP$4R~OUCa$+!N zVo+dkRDrV&Fl;_AUwfbN#bn?A<}3<~jvA92{)hPAV8~Kna+Ju@WM(jDW&jFb01F5p u1WteiG?_WT(jXR4p@RYgP?tiMz#>K|1x5uXT?Pe526rolgsn=9>wEwt2`Ov< diff --git a/contracts/sysio.tokens/sysio.tokens.wasm b/contracts/sysio.tokens/sysio.tokens.wasm index 9218e5bcaff79390fc55da3959866df9f1c84e71..ffb6e6168a5d1a7cca11227042b5c0c07d45ca48 100755 GIT binary patch delta 225 zcmeCV$oTOBeuOx#t8E;Isb2ge>?(FY-f+0(R$x$LplbOMsnL&Zk y@c>vr03l!jRG`Vs0hR@^fa(l@j$m?B$P$PH+Ni*!%b?)M;BLhbuvx^VLInVR6*XS~ delta 250 zcmex(fwAWzm6qn4%RIkk7*ua>r#Gt~Uz~I!drDN^X1G{dxn=@B9 zGAc4MIVdnV{$R)wuyN%D%0oDgJC894*eWn+Ffk}H0);fP1ni`Mv<#SLbX8>J2J#vh zH*Xhv%fuKyd8wqjm`;`xgE6n8?h)bI%-Ugat`vl!H}iE z3Xfktvr9mv9LIdQ9(c|m(9Nj8puq%G0~FK966j%+0y1R43~nH+fpIfm z#zaxZh{+3T)x~tOoEXfRK-yK{tOE?qmuk0Ps%6}k!1!W%=RHPq76nE}jp=*tF^2fx zV8~Kna+Ju@WM(jDW&ny`01F5p1WteiG?_WT(jXR4p##u`OpXd!0^6CS6c`nlbQu&J Q8QiTH61K -#include #include #include #include @@ -160,9 +159,7 @@ constexpr const char* OWNER = "owner"; /// sysio.epoch ABI field identifiers used by the WNS-16 fixture. namespace epoch_fields { constexpr const char* BATCH_OP_GROUPS = "batch_op_groups"; -constexpr const char* NEXT_BATCH_OP_GROUPS = "next_batch_op_groups"; constexpr const char* CURRENT_BATCH_OP_GROUP = "current_batch_op_group"; -constexpr const char* CURRENT_EPOCH_INDEX = "current_epoch_index"; constexpr const char* IS_PAUSED = "is_paused"; } // namespace epoch_fields @@ -690,14 +687,11 @@ class sysio_msgch_chain_tester : public tester { return groups_attestation; } - /// Inspect the actual emitted envelope, after inline buildenv drained queueout. - /// The final OPERATORS snapshot must match the registry, and any published - /// active/future schedule must follow that snapshot. A one-group held-duty - /// lease may intentionally retain an inactive incumbent as a denominator - /// placeholder. + /// Verify the actual same-epoch OPERATORS snapshot after inline mutations. + /// Existing schedule seats may persist until normal rotation removes them; + /// the authoritative status must nevertheless revoke them immediately. void require_fresh_roster(uint64_t chain_code, name account, - opp::types::OperatorStatus expected_status, - bool expect_schedule_absence = true) { + opp::types::OperatorStatus expected_status) { const auto row = find_outbound_envelope(chain_code); BOOST_REQUIRE(!row.is_null()); const auto env = decode_envelope(row["raw_envelope"].as>()); @@ -712,8 +706,7 @@ class sysio_msgch_chain_tester : public tester { for (const auto& entry : roster.operators()) { const auto registered = get_operator(name{entry.account().name()}); BOOST_REQUIRE(!registered.is_null()); - BOOST_REQUIRE_EQUAL(entry.status(), - registered["status"].as()); + BOOST_REQUIRE_EQUAL(entry.status(), registered["status"].as()); if (entry.account().name() == account.to_string()) { BOOST_REQUIRE_EQUAL(entry.status(), expected_status); found_operator = true; @@ -721,53 +714,27 @@ class sysio_msgch_chain_tester : public tester { } } else if (att.type() == opp::types::ATTESTATION_TYPE_BATCH_OPERATOR_GROUPS) { BOOST_REQUIRE(have_operators); - opp::attestations::BatchOperatorGroups groups; - BOOST_REQUIRE(groups.ParseFromString(att.data())); - const auto state = read_epoch_state(); - const auto held = state[epoch_fields::NEXT_BATCH_OP_GROUPS].get_array().empty() && - groups.groups_size() == 1 && groups.active_group_index() == 0; - std::set members; - for (const auto& group : groups.groups()) { - for (const auto& address : group.operators()) { - BOOST_REQUIRE(members.insert(address.address()).second); - const auto registered = get_operator(name{address.address()}); - BOOST_REQUIRE(!registered.is_null()); - if (!held) { - BOOST_REQUIRE_EQUAL(opp::types::OPERATOR_STATUS_ACTIVE, - registered["status"].as()); - } - } - } } } BOOST_REQUIRE(found_operator); - if (expected_status != opp::types::OPERATOR_STATUS_ACTIVE && expect_schedule_absence) { - const auto state = read_epoch_state(); - for (const auto& group : state[epoch_fields::NEXT_BATCH_OP_GROUPS].get_array()) { - for (const auto& member : group.get_array()) { - BOOST_REQUIRE(member.as_string() != account.to_string()); - } - } - } } - /// Require the incomplete-candidate path to re-anchor exactly the group the - /// depot keeps in duty. The lease is intentionally one group wide: it says - /// nothing about an incomplete future window. - sysio::opp::attestations::BatchOperatorGroups require_held_group_announcement( - uint64_t chain_code) { - const auto state = read_epoch_state(); - const auto current_index = state[epoch_fields::CURRENT_BATCH_OP_GROUP].as_uint64(); - const auto current = state[epoch_fields::BATCH_OP_GROUPS].get_array()[current_index].get_array(); - const auto groups = shipped_batch_operator_groups(chain_code); - BOOST_REQUIRE_EQUAL(groups.groups_size(), 1); - BOOST_REQUIRE_EQUAL(groups.active_group_index(), 0u); - BOOST_REQUIRE_EQUAL(groups.epoch_index(), state[epoch_fields::CURRENT_EPOCH_INDEX].as_uint64()); - BOOST_REQUIRE_EQUAL(groups.groups(0).operators_size(), current.size()); - for (size_t i = 0; i < current.size(); ++i) - BOOST_REQUIRE_EQUAL(groups.groups(0).operators(static_cast(i)).address(), - current[i].as_string()); - return groups; + /// How many BATCH_OPERATOR_GROUPS attestations the most recent `advance` shipped to + /// `chain_code` -- 0 when the depot WITHHELD it. Distinct from + /// `shipped_batch_operator_groups`, which fails the test on absence: the withhold path + /// needs to assert absence while still proving the envelope itself was built (i.e. that + /// `advance` skipped only this queueout and went on to emit the epoch's other + /// attestations, rather than returning early). + int shipped_batch_operator_groups_count(uint64_t chain_code) { + auto row = find_outbound_envelope(chain_code); + BOOST_REQUIRE(!row.is_null()); + auto env = decode_envelope(row["raw_envelope"].as>()); + BOOST_REQUIRE_EQUAL(env.messages_size(), 1); + int count = 0; + for (const auto& att : env.messages(0).payload().attestations()) { + if (att.type() == sysio::opp::types::ATTESTATION_TYPE_BATCH_OPERATOR_GROUPS) ++count; + } + return count; } /// Total attestations in the most recent outbound envelope for `chain_code`. @@ -921,9 +888,7 @@ class sysio_msgch_chain_tester : public tester { /// bootstrap() variant for a real rotation: THREE single-operator groups (so a resident op is on /// duty once per 3-epoch rotation), the SEC-28 percent rail disabled up to its accepted ceiling /// (99, so an anchored run terminates on the CONSECUTIVE rail), and `terminate_window_ms` set by - /// the caller (the exact span bound for this schedule). The target is non-bootstrapped - /// by default; healthy-rotation tests may opt into the bootstrap exemption. - /// ETH outpost registered; genesis advance run. + /// the caller (the exact span bound for this schedule). ETH outpost registered; genesis advance run. void bootstrap_rotation(uint64_t terminate_window_ms, bool batchop_is_bootstrapped = false) { BOOST_REQUIRE_EQUAL(success(), push(EPOCH_ACCOUNT, epoch_abi, EPOCH_ACCOUNT, "setconfig"_n, mvo() ("epoch_duration_sec", EPOCH_DURATION_SEC) @@ -950,7 +915,7 @@ class sysio_msgch_chain_tester : public tester { register_chain(opp::types::ChainKind::CHAIN_KIND_EVM, "ETH", 31337); - // By default BATCHOP is the termination target: NON-bootstrapped (bootstrapped operators are exempt from + // BATCHOP is the termination target: NON-bootstrapped (bootstrapped operators are exempt from // rolling-window termination -- see opreg::termcheck) and collateralized so it activates. // BATCHOP_B / BATCHOP_C are bootstrapped fillers for the other two groups. schbatchgps sorts // non-bootstrapped first, so BATCHOP lands in group 0 (on duty at epochs 1, 4, 7, ...). @@ -1714,12 +1679,8 @@ BOOST_FIXTURE_TEST_CASE(noncanonical_delivery_slashes_before_termination, sysio_ slash_action_count(BATCHOP, SOL_OUTPOST_ID)); BOOST_REQUIRE_EQUAL(epoch + kEpochAdvanceCount, current_epoch()); BOOST_REQUIRE_EQUAL(kExpectedDeliveredLogCount, delivered_dellog_count(BATCHOP)); - for (const auto chain : {ETH_OUTPOST_ID, SOL_OUTPOST_ID}) { - require_fresh_roster(chain, BATCHOP, opp::types::OPERATOR_STATUS_SLASHED, - /*expect_schedule_absence=*/false); - // The remaining two members must not be advertised with a reduced quorum. - require_held_group_announcement(chain); - } + for (const auto chain : {ETH_OUTPOST_ID, SOL_OUTPOST_ID}) + require_fresh_roster(chain, BATCHOP, opp::types::OPERATOR_STATUS_SLASHED); } FC_LOG_AND_RETHROW() } // SEC-28 (huang review): terminate on the CONSECUTIVE-miss rail through the REAL rotation -- a @@ -1777,8 +1738,7 @@ BOOST_FIXTURE_TEST_CASE(terminate_at_duty_rotation_via_advance, sysio_msgch_chai // whereas termination + reason hold either way. BATCHOP delivered exactly once, so exactly // one delivered row must remain. BOOST_REQUIRE_EQUAL(1u, delivered_dellog_count(BATCHOP)); - require_fresh_roster(ETH_OUTPOST_ID, BATCHOP, - opp::types::OPERATOR_STATUS_TERMINATED); + require_fresh_roster(ETH_OUTPOST_ID, BATCHOP, opp::types::OPERATOR_STATUS_TERMINATED); } else { // Still ACTIVE: BATCHOP must not terminate before its sixth miss (its 7th duty). BOOST_REQUIRE(status == opp::types::OperatorStatus::OPERATOR_STATUS_ACTIVE); @@ -2074,8 +2034,7 @@ BOOST_FIXTURE_TEST_CASE(slash_after_delivery_does_not_count_toward_consensus, sy /// rotation so every group is promised (and verified) at least once. BOOST_FIXTURE_TEST_CASE(advance_ships_lookahead_batch_operator_group, sysio_msgch_chain_tester) { try { constexpr uint32_t kGroups = 3; - // Use bootstrapped operators so missed deliveries cannot terminate a - // member during this test of healthy rotation and lookahead. + // Use bootstrap-exempt operators so this test only exercises healthy rotation. constexpr uint64_t kRotationWindowMs = 12ULL * kGroups * EPOCH_DURATION_SEC * 1000ULL; bootstrap_rotation(kRotationWindowMs, /*batchop_is_bootstrapped=*/true); @@ -2119,253 +2078,61 @@ BOOST_FIXTURE_TEST_CASE(advance_ships_group_index_zero_for_single_group, sysio_m } } FC_LOG_AND_RETHROW() } -/// A one-group schedule has no pre-announced successor to rotate into. If one -/// member loses eligibility at the exact floor, keep the announced vector and -/// withhold the candidate; once a standby appears, announce an in-place replacement -/// for the following epoch. Healthy incumbents keep their known chunk positions. -BOOST_FIXTURE_TEST_CASE(advance_repairs_single_group_ineligible_slot_in_place, +/// The depot must NEVER publish an active index that names an EMPTY group: that index selects +/// the group an outpost admits against and sizes its quorum from, so an empty one admits nobody, +/// can never reach consensus, and wedges the outpost permanently — the handler that could replace +/// the window runs only PAST the gate the empty group breaks. +/// +/// The state is reached by starving an EXISTING window, which is the only way it is reachable: +/// `schbatchgps` refuses to build a starved schedule up front ("not enough available batch +/// operators for group assignment"), so a pool smaller than the window can only arise AFTER the +/// schedule exists — operators leaving the ACTIVE set. Here two of the three are administratively +/// terminated. The slide then finds no ACTIVE operator outside the surviving groups (residency is +/// what keeps window groups DISJOINT, which Ethereum's `_resolveChunkPosition` depends on, so it +/// is not relaxed to fill the gap), pushes an empty tail, and the lookahead index — `cursor + 1` +/// — names it. +/// +/// Asserted here: the BATCH_OPERATOR_GROUPS attestation is absent, AND the envelope still exists +/// carrying other attestations. The second half is the regression guard that matters — withholding +/// is implemented by skipping ONE queueout, and an early `return` from `advance` would also produce +/// a missing roster while silently dropping the rest of the epoch's emissions. +BOOST_FIXTURE_TEST_CASE(advance_withholds_batch_operator_groups_when_next_group_is_empty, sysio_msgch_chain_tester) { try { - bootstrap(/*n_batch_ops=*/3); - const auto initial = shipped_batch_operator_groups(ETH_OUTPOST_ID); - BOOST_REQUIRE_EQUAL(initial.groups_size(), 1); - BOOST_REQUIRE_EQUAL(initial.groups(0).operators_size(), 3); - BOOST_REQUIRE_EQUAL(initial.groups(0).operators(0).address(), BATCHOP.to_string()); - BOOST_REQUIRE_EQUAL(initial.groups(0).operators(1).address(), BATCHOP_C.to_string()); - BOOST_REQUIRE_EQUAL(initial.groups(0).operators(2).address(), BATCHOP_B.to_string()); - - BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, "terminate"_n, - mvo()("account", BATCHOP.to_string())("reason", std::string("starve one group")))); - produce_blocks(); - advance_to_next_epoch(); - - require_held_group_announcement(ETH_OUTPOST_ID); - auto held = read_epoch_state()["batch_op_groups"].get_array(); - BOOST_REQUIRE_EQUAL(held.size(), 1u); - BOOST_REQUIRE_EQUAL(held[0].get_array()[0].as_string(), BATCHOP.to_string()); - BOOST_REQUIRE_EQUAL(held[0].get_array()[1].as_string(), BATCHOP_C.to_string()); - BOOST_REQUIRE_EQUAL(held[0].get_array()[2].as_string(), BATCHOP_B.to_string()); - - BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, - "regoperator"_n, mvo()("account", BATCHOP_D.to_string()) - ("type", opp::types::OperatorType::OPERATOR_TYPE_BATCH)("is_bootstrapped", true))); - produce_blocks(); - advance_to_next_epoch(); + constexpr uint32_t kGroups = 3; + constexpr uint64_t kRotationWindowMs = 12ULL * kGroups * EPOCH_DURATION_SEC * 1000ULL; + bootstrap_rotation(kRotationWindowMs); - const auto repaired = shipped_batch_operator_groups(ETH_OUTPOST_ID); - BOOST_REQUIRE_EQUAL(repaired.groups_size(), 1); - BOOST_REQUIRE_EQUAL(repaired.active_group_index(), 0u); - BOOST_REQUIRE_EQUAL(repaired.groups(0).operators_size(), 3); - BOOST_REQUIRE_EQUAL(repaired.groups(0).operators(0).address(), BATCHOP_D.to_string()); - BOOST_REQUIRE_EQUAL(repaired.groups(0).operators(1).address(), BATCHOP_C.to_string()); - BOOST_REQUIRE_EQUAL(repaired.groups(0).operators(2).address(), BATCHOP_B.to_string()); - // Publishing the replacement cannot elect it for the envelope announcing it. - BOOST_REQUIRE_EQUAL(duty_member(), BATCHOP); - const auto pending = read_epoch_state()[epoch_fields::NEXT_BATCH_OP_GROUPS].get_array(); - BOOST_REQUIRE_EQUAL(pending[0].get_array()[0].as_string(), BATCHOP_D.to_string()); - advance_to_next_epoch(); - BOOST_REQUIRE_EQUAL(duty_member(), BATCHOP_D); -} FC_LOG_AND_RETHROW() } + // A full window ships its roster every epoch — the baseline the withhold is measured against. + BOOST_REQUIRE_EQUAL(1, shipped_batch_operator_groups_count(ETH_OUTPOST_ID)); -/// An insufficient candidate must not persist even its successful replacements. -/// The serving window and slot positions survive every retry; activation follows -/// publication only when enough standbys can fill all vacancies together. -BOOST_FIXTURE_TEST_CASE(advance_discards_partial_candidate_and_activates_complete_publication, - sysio_msgch_chain_tester) { try { - bootstrap(/*n_batch_ops=*/3); - for (const auto op : {BATCHOP, BATCHOP_B}) { + // Starve it: terminate two of the three, leaving one ACTIVE operator for a three-seat window. + for (const auto& op : {BATCHOP_B, BATCHOP_C}) { BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, "terminate"_n, - mvo()("account", op.to_string())("reason", std::string("two vacant seats")))); + mvo()("account", op.to_string())("reason", std::string("starve the schedule window")))); } - BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, "regoperator"_n, - mvo()("account", BATCHOP_D.to_string()) - ("type", opp::types::OPERATOR_TYPE_BATCH)("is_bootstrapped", true))); produce_blocks(); - for (uint32_t attempt = 0; attempt < 2; ++attempt) { - advance_to_next_epoch(); - const auto state = read_epoch_state(); - BOOST_REQUIRE(state[epoch_fields::NEXT_BATCH_OP_GROUPS].get_array().empty()); - const auto group = state[epoch_fields::BATCH_OP_GROUPS].get_array()[0].get_array(); - BOOST_REQUIRE_EQUAL(group.size(), 3u); - BOOST_REQUIRE_EQUAL(group[0].as_string(), BATCHOP.to_string()); - BOOST_REQUIRE_EQUAL(group[1].as_string(), BATCHOP_C.to_string()); - BOOST_REQUIRE_EQUAL(group[2].as_string(), BATCHOP_B.to_string()); - require_held_group_announcement(ETH_OUTPOST_ID); - } - BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, "regoperator"_n, - mvo()("account", BATCHOP_E.to_string()) - ("type", opp::types::OPERATOR_TYPE_BATCH)("is_bootstrapped", true))); - produce_blocks(); - advance_to_next_epoch(); - const auto published = shipped_batch_operator_groups(ETH_OUTPOST_ID); - BOOST_REQUIRE_EQUAL(published.groups(0).operators(0).address(), BATCHOP_D.to_string()); - BOOST_REQUIRE_EQUAL(published.groups(0).operators(1).address(), BATCHOP_C.to_string()); - BOOST_REQUIRE_EQUAL(published.groups(0).operators(2).address(), BATCHOP_E.to_string()); - BOOST_REQUIRE_EQUAL(duty_member(), BATCHOP); - advance_to_next_epoch(); - BOOST_REQUIRE_EQUAL(duty_member(), BATCHOP_D); -} FC_LOG_AND_RETHROW() } -/// Losing every held-group signer blocks the production consensus gate even -/// after standbys restore the ACTIVE count. Registration cannot authorize a new -/// delivery group before the old group delivers its replacement announcement. -BOOST_FIXTURE_TEST_CASE(chkcons_cannot_recover_without_a_live_held_group_signer, - sysio_msgch_chain_tester) { try { - constexpr uint64_t WINDOW_MS = 12ULL * 3 * EPOCH_DURATION_SEC * 1000ULL; - bootstrap_rotation(WINDOW_MS, /*batchop_is_bootstrapped=*/true); - BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, - "terminate"_n, mvo()("account", BATCHOP_B.to_string())("reason", "starve future window"))); - const auto anchor = encode_delivery(current_epoch(), "enter held duty"); - BOOST_REQUIRE_EQUAL(success(), deliver_as(BATCHOP, ETH_OUTPOST_ID, anchor)); - const auto anchor_epoch = current_epoch(); - elapse_epoch_boundary(); - advance_via_consensus(); - BOOST_REQUIRE_EQUAL(current_epoch(), anchor_epoch + 1); - BOOST_REQUIRE_EQUAL(duty_member(), BATCHOP_C); - require_held_group_announcement(ETH_OUTPOST_ID); - - BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, - "terminate"_n, mvo()("account", BATCHOP_C.to_string())("reason", "lose every held signer"))); - for (const auto op : {BATCHOP_D, BATCHOP_E}) { - BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, - "regoperator"_n, mvo()("account", op.to_string()) - ("type", opp::types::OPERATOR_TYPE_BATCH)("is_bootstrapped", true))); - BOOST_REQUIRE_EQUAL(get_operator(op)[opreg_fields::STATUS].as(), - opp::types::OPERATOR_STATUS_ACTIVE); - } - const auto held_epoch = current_epoch(); - const auto blocked = encode_delivery(held_epoch, "cannot authorize recovery", - oracle::digest_bytes(oracle::epoch_digest(decode_envelope(anchor))), delivery_message_id(anchor)); - BOOST_REQUIRE_EQUAL(error("assertion failure with message: delivering operator is not ACTIVE in sysio.opreg"), - deliver_as(BATCHOP_C, ETH_OUTPOST_ID, blocked)); - BOOST_REQUIRE_EQUAL(error("assertion failure with message: caller is not in the active batch operator group"), - deliver_as(BATCHOP_D, ETH_OUTPOST_ID, blocked)); - - for (int retry = 0; retry < 3; ++retry) { - elapse_epoch_boundary(); - // Permissionless chkcons still needs consensus; no privileged advance. - BOOST_REQUIRE_EQUAL(success(), push(MSGCH_ACCOUNT, msgch_abi, BATCHOP_D, - msgch_actions::CHECK_CONSENSUS, mvo())); - produce_blocks(); - BOOST_REQUIRE_EQUAL(current_epoch(), held_epoch); - BOOST_REQUIRE_EQUAL(duty_member(), BATCHOP_C); - BOOST_REQUIRE(read_epoch_state()[epoch_fields::NEXT_BATCH_OP_GROUPS].get_array().empty()); - BOOST_REQUIRE_EQUAL(get_outpcons(ETH_OUTPOST_ID)["epoch_index"].as_uint64(), anchor_epoch); - require_held_group_announcement(ETH_OUTPOST_ID); - } -} FC_LOG_AND_RETHROW() } - -/// Withholding an incomplete lookahead retains the already-authorized duty. -/// A healthy held signer keeps delivering until a standby completes the window; -/// the repaired successor serves only after its announcement has been published. -/// Every transition after genesis goes through deliver -> chkcons -> advance. -BOOST_FIXTURE_TEST_CASE(advance_freezes_and_recovers_withheld_operator_window, - sysio_msgch_chain_tester) { try { - constexpr uint32_t kGroups = 3; - constexpr uint64_t kRotationWindowMs = 12ULL * kGroups * EPOCH_DURATION_SEC * 1000ULL; - bootstrap_rotation(kRotationWindowMs, /*batchop_is_bootstrapped=*/true); - - std::vector previous; - auto deliver_and_advance = [&](name signer) { - const auto epoch = current_epoch(); - const auto envelope = encode_delivery(epoch, "healthy held-group delivery", - previous.empty() ? std::string{} : oracle::digest_bytes(oracle::epoch_digest(decode_envelope(previous))), - previous.empty() ? std::string{} : delivery_message_id(previous)); - BOOST_REQUIRE_EQUAL(success(), deliver_as(signer, ETH_OUTPOST_ID, envelope)); - BOOST_REQUIRE_EQUAL(get_outpcons(ETH_OUTPOST_ID)["epoch_index"].as_uint64(), epoch); - elapse_epoch_boundary(); - advance_via_consensus(); - BOOST_REQUIRE_EQUAL(current_epoch(), epoch + 1); - previous = envelope; - }; - - // The initial [A,C,B] window announces C next. Remove future B while A - // can still deliver the envelope that moves us into C's held duty. - const auto initial = shipped_batch_operator_groups(ETH_OUTPOST_ID); - BOOST_REQUIRE_EQUAL(initial.groups_size(), kGroups); - BOOST_REQUIRE_EQUAL(initial.active_group_index(), 1u); - BOOST_REQUIRE_EQUAL(initial.groups(0).operators(0).address(), BATCHOP.to_string()); - BOOST_REQUIRE_EQUAL(initial.groups(1).operators(0).address(), BATCHOP_C.to_string()); - BOOST_REQUIRE_EQUAL(initial.groups(2).operators(0).address(), BATCHOP_B.to_string()); - BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, "terminate"_n, - mvo()("account", BATCHOP_B.to_string())("reason", "starve the schedule window"))); - deliver_and_advance(BATCHOP); - - for (int held = 0; held < 2; ++held) { - BOOST_REQUIRE_EQUAL(duty_member(), BATCHOP_C); - BOOST_REQUIRE(read_epoch_state()[epoch_fields::NEXT_BATCH_OP_GROUPS].get_array().empty()); + // The terminated pair still occupy their seats until they slide out, so the sole survivor is + // resident and the residency-excluded pool is empty: every tail from here is empty. Walk the + // window so an empty group reaches the lookahead seat, then hold there. + bool observed_withhold = false; + for (uint32_t round = 0; round < kGroups + 1; ++round) { + advance_to_next_epoch(); + // `advance` skipped at most ONE queueout, never the rest of its work. BOOST_REQUIRE_GT(shipped_attestation_count(ETH_OUTPOST_ID), 0); - require_held_group_announcement(ETH_OUTPOST_ID); - require_fresh_roster(ETH_OUTPOST_ID, BATCHOP_B, opp::types::OPERATOR_STATUS_TERMINATED); - deliver_and_advance(BATCHOP_C); - } - - BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, - "regoperator"_n, mvo()("account", BATCHOP_D.to_string()) - ("type", opp::types::OPERATOR_TYPE_BATCH)("is_bootstrapped", true))); - deliver_and_advance(BATCHOP_C); - BOOST_REQUIRE_EQUAL(duty_member(), BATCHOP_C); - const auto repaired = shipped_batch_operator_groups(ETH_OUTPOST_ID); - BOOST_REQUIRE_EQUAL(repaired.groups_size(), kGroups); - BOOST_REQUIRE_EQUAL(repaired.active_group_index(), 1u); - BOOST_REQUIRE_EQUAL(repaired.groups(0).operators(0).address(), BATCHOP_C.to_string()); - BOOST_REQUIRE_EQUAL(repaired.groups(1).operators(0).address(), BATCHOP.to_string()); - BOOST_REQUIRE_EQUAL(repaired.groups(2).operators(0).address(), BATCHOP_D.to_string()); - - deliver_and_advance(BATCHOP_C); - BOOST_REQUIRE_EQUAL(duty_member(), BATCHOP); - deliver_and_advance(BATCHOP); - BOOST_REQUIRE_EQUAL(duty_member(), BATCHOP_D); -} FC_LOG_AND_RETHROW() } - -/// Held-duty delivery observations retain the ordinary consecutive-miss and -/// rolling-percentage termination rules. This isolates accounting with the -/// privileged advance fixture; quorum-loss recovery is tested separately. -BOOST_FIXTURE_TEST_CASE(held_duty_misses_follow_ordinary_termination_rules, - sysio_msgch_chain_tester) { try { - struct termination_limits { - uint32_t consecutive; - uint32_t percent; - }; - for (const auto limits : {termination_limits{1, 99}, termination_limits{5, 49}}) { - sysio_msgch_chain_tester tester; - tester.bootstrap(/*n_batch_ops=*/3, /*batchop_is_bootstrapped=*/false); - tester.set_termination_thresholds(limits.consecutive, limits.percent); - - BOOST_REQUIRE_EQUAL(success(), tester.push(OPREG_ACCOUNT, tester.opreg_abi, CHALG_ACCOUNT, - "slash"_n, mvo()("account", BATCHOP_B.to_string()) - ("reason", "hold the one-group window"))); - const auto normal = tester.encode_delivery(tester.current_epoch(), "normal-duty hit"); - for (const auto chain : {ETH_OUTPOST_ID, SOL_OUTPOST_ID}) - BOOST_REQUIRE_EQUAL(success(), tester.deliver_as(BATCHOP, chain, normal)); - tester.produce_blocks(); - tester.advance_to_next_epoch(); - BOOST_REQUIRE(tester.read_epoch_state()[epoch_fields::NEXT_BATCH_OP_GROUPS].get_array().empty()); - BOOST_REQUIRE_EQUAL(tester.get_operator(BATCHOP)[opreg_fields::STATUS].as(), - opp::types::OPERATOR_STATUS_ACTIVE); - - const auto held_epoch = tester.current_epoch(); - tester.advance_to_next_epoch(); - BOOST_REQUIRE_EQUAL(tester.get_operator(BATCHOP)[opreg_fields::STATUS].as(), - opp::types::OPERATOR_STATUS_TERMINATED); - - uint32_t held_misses = 0; - for (uint64_t id = 0; id < TABLE_SCAN_LIMIT; ++id) { - const auto data = tester.get_row_by_account(OPREG_ACCOUNT, OPREG_ACCOUNT, "dellog"_n, name{id}); - if (data.empty()) continue; - const auto row = tester.opreg_abi.binary_to_variant("delivery_log_entry", data, - abi_serializer::create_yield_function(abi_serializer_max_time)); - if (row["account"].as_string() == BATCHOP.to_string() && - row["epoch"].as_uint64() == held_epoch && !row["delivered"].as_bool()) ++held_misses; + if (shipped_batch_operator_groups_count(ETH_OUTPOST_ID) == 0) observed_withhold = true; + if (observed_withhold) { + // Once the lookahead seat is empty it stays empty — the roster is never republished + // while starved, and an empty active index is never shipped. + BOOST_REQUIRE_EQUAL(0, shipped_batch_operator_groups_count(ETH_OUTPOST_ID)); } - BOOST_REQUIRE_EQUAL(held_misses, 2u); // one held epoch, two required outposts - for (const auto chain : {ETH_OUTPOST_ID, SOL_OUTPOST_ID}) - tester.require_fresh_roster(chain, BATCHOP, opp::types::OPERATOR_STATUS_TERMINATED, - /*expect_schedule_absence=*/false); } + BOOST_REQUIRE_MESSAGE(observed_withhold, + "starved window never withheld BATCH_OPERATOR_GROUPS -- an empty active group was published"); } FC_LOG_AND_RETHROW() } -// WIRE-385: a removal during this advance must be visible in BOTH emitted -// attestations, with a healthy standby filling the newly selected tail. +/// Same-epoch termination must be visible in both outposts' authoritative +/// rosters and in normal tail selection. This does not repair existing seats. BOOST_FIXTURE_TEST_CASE(advance_roster_excludes_same_epoch_termination, sysio_msgch_chain_tester) { try { bootstrap(/*n_batch_ops=*/3, /*batchop_is_bootstrapped=*/false); @@ -2373,71 +2140,25 @@ BOOST_FIXTURE_TEST_CASE(advance_roster_excludes_same_epoch_termination, "regoperator"_n, mvo()("account", BATCHOP_D.to_string()) ("type", opp::types::OPERATOR_TYPE_BATCH)("is_bootstrapped", true))); produce_blocks(); - advance_to_next_epoch(); // missed delivery terminates only the non-bootstrap operator + advance_to_next_epoch(); for (const auto chain : {ETH_OUTPOST_ID, SOL_OUTPOST_ID}) { require_fresh_roster(chain, BATCHOP, opp::types::OPERATOR_STATUS_TERMINATED); require_fresh_roster(chain, BATCHOP_B, opp::types::OPERATOR_STATUS_ACTIVE); const auto groups = shipped_batch_operator_groups(chain); BOOST_REQUIRE_EQUAL(groups.groups_size(), 1); BOOST_REQUIRE_EQUAL(groups.groups(0).operators_size(), 3); - BOOST_REQUIRE_EQUAL(groups.groups(0).operators(0).address(), BATCHOP_D.to_string()); - BOOST_REQUIRE_EQUAL(groups.groups(0).operators(1).address(), BATCHOP_C.to_string()); - BOOST_REQUIRE_EQUAL(groups.groups(0).operators(2).address(), BATCHOP_B.to_string()); - } -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(advance_preserves_announced_successor_and_withholds_inactive_future_members, - sysio_msgch_chain_tester) { try { - constexpr uint32_t GROUP_COUNT = 3; - constexpr uint64_t WINDOW_MS = 12ULL * GROUP_COUNT * EPOCH_DURATION_SEC * 1000ULL; - bootstrap_rotation(WINDOW_MS); - for (const auto op : {BATCHOP_B, BATCHOP_C}) { - BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, CHALG_ACCOUNT, - "slash"_n, mvo()("account", op.to_string())("reason", "roster regression"))); - } - produce_blocks(); - advance_to_next_epoch(); - require_fresh_roster(ETH_OUTPOST_ID, BATCHOP_B, - opp::types::OPERATOR_STATUS_SLASHED); - require_fresh_roster(ETH_OUTPOST_ID, BATCHOP_C, - opp::types::OPERATOR_STATUS_SLASHED, - /*expect_schedule_absence=*/false); - require_held_group_announcement(ETH_OUTPOST_ID); -} FC_LOG_AND_RETHROW() } - -BOOST_FIXTURE_TEST_CASE(advance_repairs_future_group_before_it_becomes_current, - sysio_msgch_chain_tester) { try { - constexpr uint64_t WINDOW_MS = 12ULL * 3 * EPOCH_DURATION_SEC * 1000ULL; - bootstrap_rotation(WINDOW_MS, /*batchop_is_bootstrapped=*/true); - // schbatchgps interleaves the sorted roster: the initial window is [A,C,B]. - // Remove the last group so its vacancy is still in the future after sliding. - const auto initial = shipped_batch_operator_groups(ETH_OUTPOST_ID); - BOOST_REQUIRE_EQUAL(initial.groups_size(), 3); - BOOST_REQUIRE_EQUAL(initial.groups(2).operators(0).address(), BATCHOP_B.to_string()); - BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, - "regoperator"_n, mvo()("account", BATCHOP_D.to_string()) - ("type", opp::types::OPERATOR_TYPE_BATCH)("is_bootstrapped", true))); - BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, - "terminate"_n, mvo()("account", BATCHOP_B.to_string())("reason", "future seat removed"))); - produce_blocks(); - advance_to_next_epoch(); - require_fresh_roster(ETH_OUTPOST_ID, BATCHOP_B, opp::types::OPERATOR_STATUS_TERMINATED); - const auto repaired = shipped_batch_operator_groups(ETH_OUTPOST_ID); - BOOST_REQUIRE_EQUAL(repaired.groups_size(), 3); - for (int i = 0; i < repaired.groups_size(); ++i) { - BOOST_REQUIRE_EQUAL(repaired.groups(i).operators_size(), 1); + bool found_standby = false; + for (const auto& member : groups.groups(0).operators()) { + BOOST_REQUIRE_NE(member.address(), BATCHOP.to_string()); + BOOST_REQUIRE_EQUAL(get_operator(name{member.address()})["status"].as(), + opp::types::OPERATOR_STATUS_ACTIVE); + if (member.address() == BATCHOP_D.to_string()) found_standby = true; + } + BOOST_REQUIRE(found_standby); } - BOOST_REQUIRE_EQUAL(repaired.groups(0).operators(0).address(), BATCHOP_C.to_string()); - BOOST_REQUIRE_EQUAL(repaired.groups(1).operators(0).address(), BATCHOP.to_string()); - BOOST_REQUIRE_EQUAL(repaired.groups(2).operators(0).address(), BATCHOP_D.to_string()); - advance_to_next_epoch(); - const auto next = shipped_batch_operator_groups(ETH_OUTPOST_ID); - BOOST_REQUIRE_EQUAL(next.groups_size(), 3); - BOOST_REQUIRE_EQUAL(next.groups(0).operators_size(), 1); - BOOST_REQUIRE_EQUAL(next.groups(0).operators(0).address(), BATCHOP.to_string()); - require_fresh_roster(ETH_OUTPOST_ID, BATCHOP_B, opp::types::OPERATOR_STATUS_TERMINATED); } FC_LOG_AND_RETHROW() } +/// Even sysio.epoch authority cannot invoke the continuation as a top-level action. BOOST_FIXTURE_TEST_CASE(finishadv_rejects_direct_calls, sysio_msgch_chain_tester) { try { bootstrap(); const auto args = mvo()("epoch_index", current_epoch())("emission_amount", int64_t{0}); diff --git a/plugins/batch_operator_plugin/src/group_election.hpp b/plugins/batch_operator_plugin/src/group_election.hpp index 580da61fa3..95c912510e 100644 --- a/plugins/batch_operator_plugin/src/group_election.hpp +++ b/plugins/batch_operator_plugin/src/group_election.hpp @@ -22,10 +22,12 @@ inline constexpr uint8_t GROUP_NONE = 255; /// This operator's standing against one `sysio.epoch::epochstate` reading. /// /// `current_group` is the group ON DUTY, taken verbatim from -/// `epochstate.current_batch_op_group`. A newly published window activates on -/// the following advance; while publication is withheld the cursor and serving -/// window stay fixed. Duty is never derived from the epoch number or from the -/// separate `next_batch_op_groups` announcement. +/// `epochstate.current_batch_op_group`. The sliding window keeps the group on +/// duty at the FRONT of `batch_op_groups` — `sysio.epoch::advance` pops the +/// expiring group off — so the on-duty index is NOT a function of the epoch +/// index. Anything reporting the active group reads it from here; deriving it +/// (`epoch_index % groups`) is the static-rotation anti-pattern the sliding +/// window replaced. struct group_election { uint8_t my_group = GROUP_NONE; uint8_t current_group = GROUP_NONE; From fce632110e210e8b3c173aa4b71105ed114f195f Mon Sep 17 00:00:00 2001 From: Huang-Ming Huang Date: Thu, 24 Sep 2026 15:41:44 +0000 Subject: [PATCH 14/15] Correct epoch continuation diagnostics and comments Change-Id: I367666bb68d649234e30caede0ca3d1f79e6ea86 --- contracts/sysio.epoch/src/sysio.epoch.cpp | 16 ++++++++-------- contracts/tests/sysio.msgch_chain_tests.cpp | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/contracts/sysio.epoch/src/sysio.epoch.cpp b/contracts/sysio.epoch/src/sysio.epoch.cpp index bc27f3ea38..0bf64d2e83 100644 --- a/contracts/sysio.epoch/src/sysio.epoch.cpp +++ b/contracts/sysio.epoch/src/sysio.epoch.cpp @@ -791,7 +791,7 @@ void epoch::finishadv(uint32_t epoch_index, int64_t emission_amount) { // below). Short-but-non-empty is pre-existing behaviour and is not made // safe here -- it is reported so the roster can be repaired off-chain. if (new_tail.size() < cfg.operators_per_epoch) { - sysio::print("sysio.epoch::advance: only ", new_tail.size(), " of ", + sysio::print("sysio.epoch::finishadv: only ", new_tail.size(), " of ", cfg.operators_per_epoch, " eligible batch operators for the new tail group at epoch ", state.current_epoch_index + cfg.batch_op_groups - 1, @@ -905,14 +905,14 @@ void epoch::finishadv(uint32_t epoch_index, int64_t emission_amount) { // // Only the ATTESTATION looks ahead. The depot's own schedule state is // untouched -- `current_batch_op_group` still names the group on duty NOW, - // and `advance` still slides the window so the front is the current epoch. + // and `finishadv` still slides the window so the front is the current epoch. // Nothing that reads `epoch_state` changes meaning. // // `epoch_index` stays the epoch this envelope IS for; it identifies the // envelope, not the roster, and no outpost reads it. { opp::attestations::BatchOperatorGroups attest; - // The window SLIDES; it does not rotate. `advance` erases the front and + // The window SLIDES; it does not rotate. `finishadv` erases the front and // pushes a new tail, and every write to the cursor pins it to 0 (here, // and `schbatchgps`) -- so the group on duty NEXT is simply the one // after the cursor. @@ -926,7 +926,7 @@ void epoch::finishadv(uint32_t epoch_index, int64_t emission_amount) { // The bound check is also what keeps an EMPTY schedule off a division. // `group_count == 0` is reachable here: the slide above is guarded by // `!empty()`, but nothing requires a seated schedule before this block, - // and `% 0` is an `i32.rem_u` trap that would abort `advance` and halt + // and `% 0` is an `i32.rem_u` trap that would abort `finishadv` and halt // epoch advancement chain-wide. // // Falling back to the cursor covers the single-group case: the same @@ -970,12 +970,12 @@ void epoch::finishadv(uint32_t epoch_index, int64_t emission_amount) { // carrying an empty group regardless -- so it buys nothing here. // // Withheld by SKIPPING THE QUEUEOUT ONLY -- never by returning from - // `advance`, which still has the epoch's remaining attestations and + // `finishadv`, which still has the epoch's remaining attestations and // actions to issue after this block. const bool have_next_group = next_group_index < group_count && !state.batch_op_groups[next_group_index].empty(); if (!have_next_group) { - sysio::print("sysio.epoch::advance: no non-empty next group to publish at epoch ", + sysio::print("sysio.epoch::finishadv: no non-empty next group to publish at epoch ", state.current_epoch_index, " (groups=", group_count, ", next_index=", next_group_index, "); withholding BatchOperatorGroups -- outposts retain their " @@ -1004,7 +1004,7 @@ void epoch::finishadv(uint32_t epoch_index, int64_t emission_amount) { auto out = zpp::bits::out{encoded, zpp::bits::no_size{}}; (void)out(attest); - // `have_next_group` gates the QUEUEOUT, not `advance` -- see above. + // `have_next_group` gates the QUEUEOUT, not `finishadv` -- see above. if (have_next_group) { sysio::chains::chains_t chains_tbl(CHAINS_ACCOUNT); for (auto it = chains_tbl.begin(); it != chains_tbl.end(); ++it) { @@ -1088,7 +1088,7 @@ void epoch::finishadv(uint32_t epoch_index, int64_t emission_amount) { // them into N groups (`cfg.batch_op_groups`). The resulting window is // [epoch_1_group, epoch_2_group, ..., epoch_N_group]. // -// After this, every per-epoch `advance` pops the front group and pushes +// After this, every per-epoch `finishadv` pops the front group and pushes // a new tail group, where the tail's members are drawn from the ACTIVE // pool MINUS anyone still resident in the N-1 surviving groups. The // window itself encodes "scheduled in the last N-1 epochs"; no separate diff --git a/contracts/tests/sysio.msgch_chain_tests.cpp b/contracts/tests/sysio.msgch_chain_tests.cpp index fbfbd41b9f..85172cd2e9 100644 --- a/contracts/tests/sysio.msgch_chain_tests.cpp +++ b/contracts/tests/sysio.msgch_chain_tests.cpp @@ -2094,7 +2094,7 @@ BOOST_FIXTURE_TEST_CASE(advance_ships_group_index_zero_for_single_group, sysio_m /// /// Asserted here: the BATCH_OPERATOR_GROUPS attestation is absent, AND the envelope still exists /// carrying other attestations. The second half is the regression guard that matters — withholding -/// is implemented by skipping ONE queueout, and an early `return` from `advance` would also produce +/// is implemented by skipping ONE queueout, and an early `return` from `finishadv` would also produce /// a missing roster while silently dropping the rest of the epoch's emissions. BOOST_FIXTURE_TEST_CASE(advance_withholds_batch_operator_groups_when_next_group_is_empty, sysio_msgch_chain_tester) { try { From dce1c13b8fb22e9b2dc15eb845e6b3473f668c04 Mon Sep 17 00:00:00 2001 From: Huang-Ming Huang Date: Thu, 24 Sep 2026 15:47:21 +0000 Subject: [PATCH 15/15] Rebuild epoch artifact with corrected continuation diagnostics Change-Id: I1f198c1d4db6961958b2d2b787bb95d0df71ece6 --- contracts/sysio.epoch/sysio.epoch.wasm | Bin 80467 -> 80471 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/contracts/sysio.epoch/sysio.epoch.wasm b/contracts/sysio.epoch/sysio.epoch.wasm index 3eb389c32b05149b82ef5ff9f3b775ba96a045d9..682803c4d2ac2e44a74173a5f5972b562ee0d83d 100755 GIT binary patch delta 972 zcmYLHSx8i26#l<^?~G$+ZWX0u*QqJ3EPKe(9`c9C%z9CR2txz2n$i|?3?Eu(N`t8A z8n?1V3F;xCk$>Q(Ml@j&l`$f;*&>mn^N^BgBu&e?Gf6Ms`OZ1tS^lMWOnE(~cn&JI zOT8@SDC5mqopND~yI5sB-erf=E@eyZV$L{AmqJB$S!GTh=j8hj*jY9Oq&!vP@rP7a z7)ui3aFSjtwB#rJD55}E#Mv6MZtbRJ0UE?J%j{U37he*;*)S|#rFatYRI6W_LD;sm z3w@evSvvZ}jb#TxCULoY3Oa+j-X}84I+Yn8o-rTsaLodux z4=qma>o%D=>Uo%XDfU*R99eA+4Qg3_RoY5JD#9l)OVIxX|FFn^ZeT$rTsfOGTG4N$2AqQWeXlDUHe zR4>$Ev#IfYy+c?z9F&grXj?{_(JCfJ&PXIUHQG)_%Nu`5PTcn!R(QnSP^(!)YZ%9Y zCNm%ReTiP6paQpbkAgai_`X)_*DdHlmyReb)|;%@5#dL|*<7Q8!nCIzu%R9yqbeGj z9BHei4o>*2g&>x~Q?L`S93XRlaOS`&;4`t*nh?K^f zLdt4if&^ForAe*Ou!34ck=p1gp=%qY7#7<7%x?Sgo$s9Uoy-5vKi)}sa8h<1l`V~f zLij0xHL8{Jh0LmaMPShmE9_P=O{~gi0uz$NsXVKs2zC}_XNQkig)|C?S&GP_k1Ik> z1WAa)PI@)Rv^i!nj{*S^6EewGsZP^URP(2%1rey{!?6<sBQbwy)}i zS9PpTf|s|fJ_<6iYpQ4B4%ap~Or(xv+rr`GyK?IzF~vuA=El++&=7_rt{hCr66#DRlCNhi(x!`Nl`}v#Fr(RVmP|Dt;R; zeBETw+OIYq_qI~Rl$tSWQ#;?g>0S?gC;^$5L!&ZD9*j&=cG{P&x_n^)mPN^o(GjZW zivPNy>G^V-uyo8X{_0nEj9Q zM*63&44Ne z+o}npb1YKeNwVOa{SinF3nujkbFh2{Jeq$Y=+SAj6KJeQ`