-
Notifications
You must be signed in to change notification settings - Fork 11
fix: dispute two-candidate consensus splits #581
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c4c06a1
d9ac657
d9b885c
93d5687
0bb40b5
234abff
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ | |
| #include <sysio.chains/sysio.chains.hpp> | ||
| #include <sysio.chalg/sysio.chalg.hpp> // dispute trigger + open-dispute gate (disputes table) | ||
| #include <sysio.opreg/sysio.opreg.hpp> // operator-status delivery gate (operators table) | ||
| #include <sysio.roa.hpp> // authoritative Tier-1 electorate preflight | ||
| #include <sysio.opp.common/slug_name.hpp> | ||
| #include <sysio.opp.common/safe_ops.hpp> // to_depot_amount — WSA-028 fail-closed TokenAmount gate | ||
| #include <sysio.opp.common/name_ops.hpp> // parse_wire_account_name — never-throw account-name parse | ||
|
|
@@ -12,6 +13,7 @@ | |
| #include <sysio/opp/opp.pb.hpp> | ||
| #include <sysio/opp/attestations/attestations.pb.hpp> | ||
| #include <zpp_bits.h> | ||
| #include <magic_enum/magic_enum.hpp> | ||
| #include <algorithm> | ||
| #include <optional> | ||
|
|
||
|
|
@@ -24,6 +26,7 @@ using opp::types::MessageStatus; | |
| using opp::types::EnvelopeStatus; | ||
| using opp::types::AttestationType; | ||
| using opp::types::AttestationStatus; | ||
| using opp::types::NodeOwnerTier; | ||
|
|
||
| namespace { | ||
|
|
||
|
|
@@ -87,6 +90,21 @@ constexpr size_t ENVELOPE_BASELINE_BYTES = 512; | |
| constexpr const char* UIC_DISPATCH_REJECTED_LOG_PREFIX = | ||
| "UIC_DISPATCH_REJECTED"; | ||
|
|
||
| /// Diagnostic for a split with fewer competing versions than a Tier-1 vote can adjudicate. | ||
| constexpr const char* DISPUTE_TOO_FEW_CANDIDATES_LOG = | ||
| "msgch::maybe_open_dispute: no dispute for (chain=%llu, epoch=%u): " | ||
| "%u distinct version(s), a vote needs >=%u\n"; | ||
|
|
||
| /// Diagnostic for a two-version split that can still acquire a strict majority from silent operators. | ||
| constexpr const char* DISPUTE_INCOMPLETE_TWO_WAY_LOG = | ||
| "msgch::maybe_open_dispute: no dispute for (chain=%llu, epoch=%u): " | ||
| "two versions but only %u of %u eligible operators delivered\n"; | ||
|
|
||
| /// Diagnostic for a terminal split that cannot be voted on until Tier-1 registration exists. | ||
| constexpr const char* DISPUTE_NO_TIER_ONE_ELECTORATE_LOG = | ||
| "msgch::maybe_open_dispute: no dispute for (chain=%llu, epoch=%u): " | ||
| "no registered tier-1 node owners\n"; | ||
|
|
||
| uint32_t current_epoch_index() { | ||
| epoch::epochstate_t tbl(EPOCH_ACCOUNT); | ||
| return tbl.exists() ? tbl.get().current_epoch_index : 0; | ||
|
|
@@ -1175,21 +1193,22 @@ void dispatch_attestation(name self, uint64_t attestation_id, | |
| return true; | ||
| } | ||
|
|
||
| /// Evaluate the dispute trigger and, if met, open a Tier-1 dispute vote via sysio.chalg. Trigger: | ||
| /// the epoch boundary has passed, 3+ distinct envelope versions exist, and no version holds a | ||
| /// majority of the operator group. A majority — even within a 3+-way split — resolves without a | ||
| /// vote, so it is not a trigger; a sub-3-way or pre-boundary split just waits for more deliveries. | ||
| /// Evaluate the dispute trigger and, if met, open a Tier-1 dispute vote via sysio.chalg. A two-way | ||
| /// split is actionable only after every eligible operator has delivered, preserving the chance for | ||
| /// silent operators to create a strict majority. An existing three-or-more-version split remains | ||
| /// actionable at the boundary. A strict majority, one-version split, or pre-boundary split waits. | ||
| void maybe_open_dispute(name self, uint64_t chain_code, uint32_t epoch_index, | ||
| uint32_t group_size, | ||
| uint32_t group_size, uint32_t total_deliveries, | ||
| const std::vector<checksum256>& seen_checksums, | ||
| const std::vector<uint32_t>& checksum_counts, | ||
| const std::vector<std::vector<name>>& checksum_operators) { | ||
| // OPP silent-return diagnostics: each branch below silently declines to open a | ||
| // dispute. Logged (visible under --contracts-console) so "the dispute never | ||
| // opened" is greppable instead of a black hole. | ||
| if (seen_checksums.size() < 3) { | ||
| sysio::print_f("msgch::maybe_open_dispute: no dispute for (chain=%llu, epoch=%u): %u distinct version(s), a vote needs >=3\n", | ||
| chain_code, epoch_index, (uint32_t)seen_checksums.size()); | ||
| if (seen_checksums.size() < chalg_limits::minimum_dispute_candidate_versions) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. MEDIUM — the candidate set freezes before the eligible group has finished delivering, and the right envelope can be excluded from its own ballot. The gate here is candidate-count + past-boundary + no-majority. Nothing checks that the outstanding eligible operators have delivered, and That matters because the ballot is final once it opens. Concretely, with a 5-operator group: two lagging or faulty operators deliver This is not new — the same shape was reachable at the 3-candidate floor with three divergent early deliveries — but the floor of 2 makes it reachable with two, and The ticket's |
||
| sysio::print_f(DISPUTE_TOO_FEW_CANDIDATES_LOG, | ||
| chain_code, epoch_index, (uint32_t)seen_checksums.size(), | ||
| chalg_limits::minimum_dispute_candidate_versions); | ||
| return; | ||
| } | ||
|
|
||
|
|
@@ -1212,6 +1231,26 @@ void maybe_open_dispute(name self, uint64_t chain_code, uint32_t epoch_index, | |
| return; | ||
| } | ||
|
|
||
| // A two-way split is not terminal while an eligible operator remains silent: that operator can | ||
| // still establish a strict majority. Three-or-more candidate splits intentionally retain the | ||
| // existing post-boundary dispute behavior. | ||
| if (seen_checksums.size() == chalg_limits::minimum_dispute_candidate_versions && | ||
| total_deliveries != group_size) { | ||
| sysio::print_f(DISPUTE_INCOMPLETE_TWO_WAY_LOG, | ||
| chain_code, epoch_index, total_deliveries, group_size); | ||
| return; | ||
| } | ||
|
|
||
| // `opendispute` defensively asserts this invariant, but this user-triggered evalcons route | ||
| // must remain retryable: a missing Tier-1 electorate must not revert the terminal delivery or | ||
| // pause the epoch before a node owner has registered. | ||
| const uint8_t network_gen = roa::current_network_gen(ROA_ACCOUNT); | ||
| const uint8_t tier_one = magic_enum::enum_integer(NodeOwnerTier::NODE_OWNER_TIER_T1); | ||
| if (roa::nodeowner_count(ROA_ACCOUNT, network_gen, tier_one) == 0) { | ||
| sysio::print_f(DISPUTE_NO_TIER_ONE_ELECTORATE_LOG, chain_code, epoch_index); | ||
| return; | ||
| } | ||
|
|
||
| std::vector<chalg::dispute_candidate> candidates; | ||
| candidates.reserve(seen_checksums.size()); | ||
| for (size_t g = 0; g < seen_checksums.size(); ++g) { | ||
|
|
@@ -1440,8 +1479,8 @@ void msgch::evalcons(uint64_t chain_code, uint32_t epoch_index) { | |
| }; | ||
|
|
||
| // Group envelopes by checksum, tracking the operators that delivered each version (CDT-compatible | ||
| // parallel vectors). The per-version operator lists become the dispute candidates on a 3+-way | ||
| // split. | ||
| // parallel vectors). The per-version operator lists become dispute candidates only for an | ||
| // all-delivered two-way tie or an existing multi-version split. | ||
| std::vector<checksum256> seen_checksums; | ||
| std::vector<uint32_t> checksum_counts; | ||
| std::vector<std::vector<char>> checksum_data; | ||
|
|
@@ -1498,9 +1537,10 @@ void msgch::evalcons(uint64_t chain_code, uint32_t epoch_index) { | |
| } | ||
|
|
||
| if (!consensus_reached) { | ||
| // No automatic consensus. On a 3+-way no-majority split past the epoch boundary, open a | ||
| // Tier-1 dispute vote; a smaller or pre-boundary split just waits for more deliveries. | ||
| maybe_open_dispute(get_self(), chain_code, epoch_index, group_size, | ||
| // No automatic consensus. A terminal two-way tie or a three-or-more-version no-majority | ||
| // split past the epoch boundary opens a Tier-1 dispute vote; all other cases wait for more | ||
| // deliveries. | ||
| maybe_open_dispute(get_self(), chain_code, epoch_index, group_size, total_deliveries, | ||
| seen_checksums, checksum_counts, checksum_operators); | ||
| return; | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,7 @@ | ||
| /// Contract tests for the OPP envelope dispute vote (sysio.chalg dispute-vote flow). | ||
| /// | ||
| /// Covers the new chalg actions in isolation and against a minimally-bootstrapped OPP stack: | ||
| /// * opendispute -- auth (sysio.msgch), >=3 candidates, no duplicate (outpost,epoch), pauses | ||
| /// * opendispute -- auth (sysio.msgch), >=2 candidates, no duplicate (outpost,epoch), pauses | ||
| /// epoch, snapshots the Tier-1 electorate + quorum from sysio.roa::nodeowners | ||
| /// (rejecting an empty electorate) | ||
| /// * votedispute -- electorate-snapshot eligibility (the Tier-1 set frozen at open; later | ||
|
|
@@ -479,14 +479,24 @@ BOOST_FIXTURE_TEST_CASE(opendispute_requires_msgch_auth, sysio_dispute_tester) { | |
| opendispute(eth_code(), current_epoch(), cands, /*signer=*/"voter1"_n)); | ||
| } FC_LOG_AND_RETHROW() } | ||
|
|
||
| BOOST_FIXTURE_TEST_CASE(opendispute_requires_three_candidates, sysio_dispute_tester) { try { | ||
| /// A two-version tie has no automatic majority, so chalg must accept it as an adjudicable dispute. | ||
| BOOST_FIXTURE_TEST_CASE(opendispute_accepts_two_candidates, sysio_dispute_tester) { try { | ||
| std::vector<fc::variant> two{ | ||
| candidate(fc::sha256::hash(std::string("a")), {BATCHOP}), | ||
| candidate(fc::sha256::hash(std::string("b")), {"voter1"_n}), | ||
| }; | ||
| BOOST_REQUIRE_EQUAL(success(), opendispute(eth_code(), current_epoch(), two)); | ||
| BOOST_REQUIRE_EQUAL(two.size(), get_dispute(1)["candidates"].get_array().size()); | ||
| } FC_LOG_AND_RETHROW() } | ||
|
|
||
| /// One envelope version has no competing candidate, so chalg must retain the two-version floor. | ||
| BOOST_FIXTURE_TEST_CASE(opendispute_rejects_one_candidate, sysio_dispute_tester) { try { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. LOW — this test no longer pins the invariant it exists for. The replacement asserts Since the floor is the invariant this PR changes, it is worth keeping it pinned — assert the exact new message the way the old test asserted the old one. |
||
| std::vector<fc::variant> one{ | ||
| candidate(fc::sha256::hash(std::string("a")), {BATCHOP}), | ||
| }; | ||
| BOOST_REQUIRE_EQUAL( | ||
| error("assertion failure with message: a dispute requires at least 3 candidate envelope versions"), | ||
| opendispute(eth_code(), current_epoch(), two)); | ||
| error("assertion failure with message: a dispute requires at least two candidate envelope versions"), | ||
| opendispute(eth_code(), current_epoch(), one)); | ||
| } FC_LOG_AND_RETHROW() } | ||
|
|
||
| BOOST_FIXTURE_TEST_CASE(opendispute_rejects_duplicate, sysio_dispute_tester) { try { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
MEDIUM — an empty Tier-1 electorate now reverts the enclosing delivery transaction.
opendisputeis an inline action ofevalcons, which is itself inline fromdeliverand fromchkcons. Socheck(!electorate.empty(), ...)does not just decline the dispute — it reverts the whole enclosing transaction.The in-place comment justifies the assert on the grounds that "the conflicting deliveries keep this epoch from reaching consensus regardless." That held under the 3-candidate floor, where a dispute-eligible split was already unresolvable. It no longer holds: with the floor at 2, a dispute can now open on a split that a further delivery would have resolved into a clean majority. Once that state exists and the electorate is empty — e.g. during a
roageneration rotation before Tier-1 owners re-register — every subsequentdeliverandchkconsfor that bucket reverts, including the delivery that would have produced the majority.Suggest checking the electorate on the msgch side in
maybe_open_disputeand printing-and-returning, matching the silent-return diagnostic convention the other branches in this function already use.