From ba321d5bd14f9dfffa2f6e884f4e08f81ca93ad7 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Fri, 21 Aug 2026 14:04:09 -0500 Subject: [PATCH] batch_operator: crank chkdispute so a resolved dispute can unpause the epoch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sysio.chalg::opendispute` sends `sysio.epoch::pause`, and `chkdispute` is the only action that tallies the Tier-1 votes, dispatches the winning envelope and lifts that pause. Nothing drove it: `chkdispute` appears in no plugin in the tree, and `batch_operator_plugin` knows exactly two action names, `deliver` and `chkcons`. The sibling case shows why that is not survivable here. `chkuwchal` needs no cadence because `sysio.uwrit::chklocks` pokes it from every `sysio.epoch::advance` — which works precisely because an underwriter challenge does NOT pause the chain. An envelope dispute halts `advance` itself, so no inline poke can reach `chkdispute`, and it has no deadline-lapse path either. The result: Tier-1 reaches quorum, the dispute stays OPEN, `open_disputes` never decrements, and epoch advancement is paused until someone hand-cranks it. Crank it from the epoch tick that already runs (`--batch-epoch-poll-ms`, 15s), scanning `sysio.chalg::disputes` for OPEN rows. Gated on `is_active` rather than `is_elected`: the elected operator may be the one offline or the one that delivered the non-canonical envelope — often the reason the dispute exists. Disputes are rare, and `chkdispute` asserts the dispute is still OPEN, so a redundant push from a second operator is a cheap no-op. Also corrects the `chkdispute` comment, which asserted this cadence already existed. Comment-only: no contract artifact changes, and no .wasm/.abi is in this diff. Change-Id: I2495d8d4dadae912336e06d1698168ab34f20366 --- contracts/sysio.chalg/src/sysio.chalg.cpp | 6 +- .../src/batch_operator_plugin.cpp | 76 +++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/contracts/sysio.chalg/src/sysio.chalg.cpp b/contracts/sysio.chalg/src/sysio.chalg.cpp index e897c7f83e..2879c0b901 100644 --- a/contracts/sysio.chalg/src/sysio.chalg.cpp +++ b/contracts/sysio.chalg/src/sysio.chalg.cpp @@ -343,7 +343,11 @@ void chalg::votedispute(name owner, uint64_t dispute_id, checksum256 chosen_chec // chkdispute — tally votes; on resolution dispatch the winner and unpause // --------------------------------------------------------------------------- void chalg::chkdispute(uint64_t dispute_id) { - // Permissionless crank — batch operators call this on their ~15s cadence. + // Permissionless crank, driven by `batch_operator_plugin`'s epoch tick + // (`--batch-epoch-poll-ms`, 15s default) from every ACTIVE batch operator. + // That cadence is this action's ONLY driver: unlike `chkuwchal`, which + // `sysio.uwrit::chklocks` pokes from every `sysio.epoch::advance`, a dispute + // pauses `advance` itself, so no inline poke can reach here. disputes_t disputes(get_self()); auto d_pk = dispute_key{dispute_id}; auto d = disputes.get(d_pk, "dispute not found"); diff --git a/plugins/batch_operator_plugin/src/batch_operator_plugin.cpp b/plugins/batch_operator_plugin/src/batch_operator_plugin.cpp index d8ff24b065..69664d0fed 100644 --- a/plugins/batch_operator_plugin/src/batch_operator_plugin.cpp +++ b/plugins/batch_operator_plugin/src/batch_operator_plugin.cpp @@ -105,6 +105,18 @@ namespace { } } + namespace chalg { + constexpr auto account = "sysio.chalg"; + constexpr auto table_disputes = "disputes"; + constexpr auto action_chkdispute = "chkdispute"; + /// Field names on `dispute_entry` rows, plus the `chkdispute` action arg. + namespace field { + constexpr auto id = "id"; + constexpr auto status = "status"; + constexpr auto dispute_id = "dispute_id"; + } + } + /// v6: chain registry was split out of `sysio.epoch` onto its own /// `sysio.chains` contract. The `outposts` table was replaced by the /// `chains` KV table, keyed by slug_name (uint64 packed). @@ -354,6 +366,70 @@ struct batch_operator_plugin::impl { dlog("batch_operator: chkcons: {}", e.to_string()); } } + + // Tally any OPEN envelope dispute. Deliberately NOT gated on `is_elected` + // — see crank_open_disputes. + try { + crank_open_disputes(); + } FC_LOG_AND_DROP(); + } + + /** + * Crank `sysio.chalg::chkdispute` for every OPEN envelope dispute. + * + * `sysio.chalg::opendispute` sends `sysio.epoch::pause`, and `chkdispute` is + * the ONLY action that tallies the Tier-1 votes, dispatches the winning + * envelope and lifts that pause. Nothing on chain drives it: its sibling + * `chkuwchal` needs no cadence because `sysio.uwrit::chklocks` pokes it from + * every `sysio.epoch::advance` — which works precisely because an + * underwriter challenge does NOT pause the chain. An envelope dispute halts + * `advance` itself, so no inline poke can reach it. Without this crank a + * dispute stays OPEN after Tier-1 has already reached quorum, and epoch + * advancement is paused indefinitely. + * + * Not gated on `is_elected` (unlike the `chkcons` push above): the elected + * operator may be the very one that is offline or delivered the + * non-canonical envelope — often the reason the dispute exists. Disputes are + * rare, so pushing from every ACTIVE operator costs effectively nothing, and + * `chkdispute` asserts the dispute is still OPEN, which makes a redundant + * push a cheap no-op. + * + * The scan is a full-table filter rather than a `byepoch` index lookup: the + * table retains RESOLVED rows as the audit trail, but disputes are rare and + * `poll_own_status` already scans a comparably-sized table each tick. If + * disputes ever become frequent, bound this by `current_epoch` via the + * `byepoch` secondary index. + */ + void crank_open_disputes() { + sysio::chain_apis::read_only::get_table_rows_params p; + p.code = chain::name(chalg::account); + p.scope = chalg::account; + p.table = chalg::table_disputes; + p.all_rows = true; + p.values_only = true; + p.filter = [](const fc::variant& row) { + const auto& obj = row.get_object(); + auto status_it = obj.find(chalg::field::status); + return status_it != obj.end() && + status_it->value().as() == DISPUTE_STATUS_OPEN; + }; + auto rows = read_table(std::move(p)); + + for (const auto& r : rows.rows) { + const auto& obj = r.get_object(); + auto id_it = obj.find(chalg::field::id); + if (id_it == obj.end()) continue; + const uint64_t dispute_id = id_it->value().as_uint64(); + + try { + push_action(chalg::account, chalg::action_chkdispute, operator_account, + fc::mutable_variant_object()(chalg::field::dispute_id, dispute_id)); + } catch (const fc::exception& e) { + // Expected-transient: the dispute resolved between the scan and the push, or + // another operator's crank won the race. + dlog("batch_operator: chkdispute({}): {}", dispute_id, e.to_string()); + } + } } /**