From c4c06a1ebbbb658346d331366a28ad65db6a3e5e Mon Sep 17 00:00:00 2001 From: Huang-Ming Huang Date: Fri, 21 Aug 2026 17:05:12 +0000 Subject: [PATCH 1/2] fix: resolve terminal two-candidate consensus ties Change-Id: I073d15645a2334b4cf0075d8e3f1ecba3a49d0db --- contracts/sysio.chalg/README.md | 11 +- .../include/sysio.chalg/sysio.chalg.hpp | 22 +- contracts/sysio.chalg/src/sysio.chalg.cpp | 8 +- contracts/sysio.chalg/sysio.chalg.wasm | Bin 67555 -> 67555 bytes contracts/sysio.msgch/src/sysio.msgch.cpp | 50 +++-- contracts/sysio.msgch/sysio.msgch.wasm | Bin 154010 -> 154018 bytes contracts/tests/sysio.dispute_tests.cpp | 18 +- contracts/tests/sysio.msgch_chain_tests.cpp | 212 +++++++++++++++++- 8 files changed, 280 insertions(+), 41 deletions(-) diff --git a/contracts/sysio.chalg/README.md b/contracts/sysio.chalg/README.md index cdd4addb25..43e7315905 100644 --- a/contracts/sysio.chalg/README.md +++ b/contracts/sysio.chalg/README.md @@ -5,7 +5,8 @@ OPP envelope dispute resolution and slash-execution contract. ## Responsibility - Resolves conflicting OPP outpost envelopes via a Tier-1 node-owner vote when the automatic - consensus rules in `sysio.msgch` cannot (a 3+-way split with no majority for one (outpost, epoch)) + consensus rules in `sysio.msgch` cannot: a terminal two-version tie, or an existing 3+-version + split with no majority for one (outpost, epoch) - Pauses epoch advancement while a dispute is open and releases it on resolution - Dispatches the winning envelope (via `sysio.msgch::resolvedisp`) once a checksum wins - Executes slashing of operators through `sysio.opreg` -- the single slashing chokepoint that holds @@ -29,10 +30,10 @@ OPP envelope dispute resolution and slash-execution contract. ## Dispute-vote flow -1. **Open**: `sysio.msgch::evalcons` sees the active batch operators deliver 3+ distinct envelope - versions for one (outpost, epoch) with no majority, and calls `opendispute` inline. The dispute - records the candidate checksums, snapshots the active ROA generation's Tier-1 electorate and - fixed quorum, and pauses `sysio.epoch`. +1. **Open**: `sysio.msgch::evalcons` calls `opendispute` inline for either a terminal two-version + tie (all live eligible batch operators delivered) or an existing 3+-version no-majority split + past the epoch boundary. The dispute records the candidate checksums, snapshots the active ROA + generation's Tier-1 electorate and fixed quorum, and pauses `sysio.epoch`. 2. **Vote**: owners in the dispute's frozen Tier-1 electorate call `votedispute` with one of the candidate checksums. Later ROA registrations cannot join an in-flight dispute. One vote per owner. 3. **Tally**: anyone cranks `chkdispute`. With `N` equal to the snapshotted electorate size and diff --git a/contracts/sysio.chalg/include/sysio.chalg/sysio.chalg.hpp b/contracts/sysio.chalg/include/sysio.chalg/sysio.chalg.hpp index 07821ec9f3..56c01f3286 100644 --- a/contracts/sysio.chalg/include/sysio.chalg/sysio.chalg.hpp +++ b/contracts/sysio.chalg/include/sysio.chalg/sysio.chalg.hpp @@ -12,6 +12,12 @@ namespace sysio { + namespace chalg_limits { + /// Minimum number of distinct envelope versions required to make a consensus split + /// adjudicable by the Tier-1 dispute vote. + inline constexpr uint32_t minimum_dispute_candidate_versions = 2; + } // namespace chalg_limits + class [[sysio::contract("sysio.chalg")]] chalg : public contract { public: using contract::contract; @@ -42,9 +48,11 @@ namespace sysio { // OPP envelope dispute vote (Tier-1 node-owner resolution) // ----------------------------------------------------------------------- - /// Open an OPP envelope dispute. Called inline by `sysio.msgch::evalcons` when the active - /// batch operators delivered 3+ distinct envelope versions for one (outpost, epoch) with no - /// majority. Records the candidate checksums, snapshots the Tier-1 electorate (the Tier-1 + /// Open an OPP envelope dispute. Called inline by `sysio.msgch::evalcons` for a terminal + /// two-version tie or an existing multi-version no-majority split. It accepts at least + /// `chalg_limits::minimum_dispute_candidate_versions` distinct versions; msgch owns the + /// terminal-delivery check because it alone has the live eligible group and delivery tally. + /// Records the candidate checksums, snapshots the Tier-1 electorate (the Tier-1 /// rows of `sysio.roa::nodeowners` for the current network generation) together with its /// quorum, and pauses epoch advancement until a Tier-1 node-owner vote resolves the /// canonical envelope. Rejects opening when no Tier-1 node owner is registered: an @@ -212,10 +220,10 @@ namespace sysio { SYSLIB_SERIALIZE(dispute_key, (id)) }; - /// OPP envelope dispute. Opened on a 3+-way no-majority split for one (outpost, epoch); - /// resolved by a Tier-1 node-owner vote on the canonical checksum. The row is retained after - /// resolution as the audit record (and as the guard that prevents re-opening the same - /// (outpost, epoch) dispute). + /// OPP envelope dispute. Opened for a terminal two-version tie or for an existing 3+-version + /// post-boundary no-majority split for one (outpost, epoch); resolved by a Tier-1 node-owner + /// vote on the canonical checksum. The row is retained after resolution as the audit record + /// (and as the guard that prevents re-opening the same (outpost, epoch) dispute). struct [[sysio::table("disputes")]] dispute_entry { uint64_t id; uint64_t chain_code; ///< outpost slug_name value diff --git a/contracts/sysio.chalg/src/sysio.chalg.cpp b/contracts/sysio.chalg/src/sysio.chalg.cpp index e897c7f83e..8eeb4aa16e 100644 --- a/contracts/sysio.chalg/src/sysio.chalg.cpp +++ b/contracts/sysio.chalg/src/sysio.chalg.cpp @@ -23,6 +23,10 @@ constexpr name ram_payer = "sysio"_n; namespace { +/// Rejection text for a dispute without enough competing envelope versions to adjudicate. +constexpr const char* DISPUTE_REQUIRES_TWO_CANDIDATES = + "a dispute requires at least two candidate envelope versions"; + /// WIRE asset symbol for the challenge-bond escrow + payouts (9 decimals — mirrors /// `sysio.reserv`'s WIRE_SYMBOL; deliberately NOT opreg's CORE_SYM). constexpr sysio::symbol WIRE_SYMBOL{"WIRE", 9}; @@ -244,8 +248,8 @@ void chalg::opendispute(uint64_t chain_code, uint32_t epoch_index, std::vector candidates) { require_auth(MSGCH_ACCOUNT); - check(candidates.size() >= 3, - "a dispute requires at least 3 candidate envelope versions"); + check(candidates.size() >= chalg_limits::minimum_dispute_candidate_versions, + DISPUTE_REQUIRES_TWO_CANDIDATES); disputes_t disputes(get_self()); diff --git a/contracts/sysio.chalg/sysio.chalg.wasm b/contracts/sysio.chalg/sysio.chalg.wasm index 9035e88c4ab3ba338d62628922cd298da7fcc7bc..651fb21d3027b26d76b50af3aefcb02f540d2dd6 100755 GIT binary patch delta 897 zcmZuvYe-a45WaKoU7x92xxNbA%*DqVX+B7$^qPo;lvI=iQc6AzML;(8USO zA8wn5Mv$5ZQKF}oxt7l*DEnb*Sb|wZKKhY0ceU(Ce`e;)`M#NN=A4NNaiT(OIj91_ z6jdvHKu0$tTw{0K!T<)@sCxzP^7$2~1V=TfLGn+R%2=`26A|vR?cREERc#-q42a-e z)q+&Z`Zf+IR1;mCavvY77XA5r2@;$*iIZmWUO)+4#?Zh-cz_oILtz?Q0<)=sw{~=e z!u#{bS14;kv?L@ZBwGRPtSMrfJv_(h7&S~Xr`W>)HJBxJK_`a9Y2h2D#@&KO83?mp49}U-8R{SxZnhzx`+QK*v?(QRN+%r$MD%sa_ zyq_wlg#e*N+(3hE;syn4iE(C0KB|Jp7_JL;dvE5HrLZ*Ti9e#h<9VGgTtSmA5!!J| z7Y$RWNeiP^htk%F?teKnr~y_;`V1`~YtHZln$(;*Adss$bq1gVU214iwY(GJ|)Q&m&tzcre^)+^&$L=}} zOS@XcgPx6*rvhbS42|%k{-Z&TjXhD!r}6-WtO(bO8jU(~ z=xjOyH!;T)0sXAO)GM=3*X<`pcijyEdQsPqMjxcVA>P^ALxaj;6_%TAmQJjwt<&~D z7fHh|TQycTDR1|w(JEk*3yEN|TmCF|x4OL&$t|IS9pFUVnGi}!g4PAci)952~r PP<7O(J;)`Fm^||;u7*>bB^(c)#=%; znK`#Z8K(5zVQnlj?$Gbq$f#uH*V35IjCkI=$RGZBp6B~~KcDCMJkR&rS0VOQh;{4K zz~g##J-o)SNC#B1%aLgSEv$P=1t0$538#WE4QN1$FOZJ0LsJ_?xWX3Au!`f)G;^v1 zJs+VFq%*8}R;xnY+r+6>^H~}(p5HCOk_nu|Nnh}Kf(4FaYT{C8z=MgY@E+?Di>Sd2 zUH2G;kLS0JQ6{J8NJwT%kpkT8nttIpXu^+~8hFDdWNiefLX*@4jhK?HgCWe%z5o}I zU)&${@}aDj1Oz1r$uVKs+$z6>tCcy)@C;LO7J!|Za<=f)ynV??F!W&Cie|Eol|Hie zyx8cT7OJ2T5`;Q&CJi>5YZLq>dRS-PW;NW#bVG9FD=()y3V}Jd{So~c4;W(M1Ud~% z!Hw?>3t#}X#x!cR!8l2r@|Qz{+F*=iE!4p&R$CYiG^uw-t3dAb&QgFI7;dgtv_Ixl z>3G;2!Mea`vhc1#Q^Wp=EQpdP!>Fwoy-d`t!#XWD8C-HUdkynyw`$rtB?Yl$Ek&_Jj@5)l_Ra)FX z7&RK*8q6nZ=%u\n"; + +/// Diagnostic for a non-terminal two-version split whose outstanding operator may form a majority. +constexpr const char* DISPUTE_TWO_WAY_SPLIT_INCOMPLETE_LOG = + "msgch::maybe_open_dispute: no dispute for (chain=%llu, epoch=%u): " + "two-way split has %u of %u eligible deliveries\n"; + uint32_t current_epoch_index() { epoch::epochstate_t tbl(EPOCH_ACCOUNT); return tbl.exists() ? tbl.get().current_epoch_index : 0; @@ -1175,21 +1185,23 @@ 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 +/// post-boundary no-majority split with three or more versions retains the existing behavior. A +/// two-version split is disputable only when every live eligible operator has delivered, so an +/// outstanding operator can still break a partial split by forming a strict majority. A majority +/// always resolves without a vote; a one-version or pre-boundary split waits for more deliveries. 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& seen_checksums, const std::vector& checksum_counts, const std::vector>& 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) { + sysio::print_f(DISPUTE_TOO_FEW_CANDIDATES_LOG, + chain_code, epoch_index, (uint32_t)seen_checksums.size(), + chalg_limits::minimum_dispute_candidate_versions); return; } @@ -1200,6 +1212,17 @@ void maybe_open_dispute(name self, uint64_t chain_code, uint32_t epoch_index, return; } + // A partial two-way split has eligible operators that can still deliver a legitimate strict + // majority. Escalating it to a Tier-1 vote would pause the whole epoch prematurely, so only the + // terminal form — every eligible operator already delivered — becomes a dispute. Multi-version + // splits deliberately retain their existing post-boundary behavior. + if (seen_checksums.size() == chalg_limits::minimum_dispute_candidate_versions && + total_deliveries != group_size) { + sysio::print_f(DISPUTE_TWO_WAY_SPLIT_INCOMPLETE_LOG, + chain_code, epoch_index, total_deliveries, group_size); + return; + } + uint32_t max_count = 0; for (auto c : checksum_counts) { if (c > max_count) max_count = c; @@ -1440,8 +1463,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 the dispute candidates on a terminal + // two-way or an existing multi-version split. std::vector seen_checksums; std::vector checksum_counts; std::vector> checksum_data; @@ -1498,9 +1521,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 or an existing multi-version no-majority split + // past the epoch boundary opens a Tier-1 dispute vote; a one-version or pre-boundary split + // waits for more deliveries. + maybe_open_dispute(get_self(), chain_code, epoch_index, group_size, total_deliveries, seen_checksums, checksum_counts, checksum_operators); return; } diff --git a/contracts/sysio.msgch/sysio.msgch.wasm b/contracts/sysio.msgch/sysio.msgch.wasm index 53b337ec29753151b9c38bdbe09f54f86668a100..8f8807e0e3ffd5e3b5a50f93d7ce023fc3dd4056 100755 GIT binary patch delta 1682 zcmZ8hZBUd|6u#%|7t68;h^VFZ!ay$KqBbglm|TNJM@>^HYqVxn%-#GTT@l7{1R-@y zjOoR$8PiOfgJ9TNs{FpJLY|NAe77-R`6m(3?3L6;h-etAy&wb80&vVXs?>%St zfM!0ZnQPwDV-@RL@AjeP=x2360q5+<(qa6LBCUb7<0gm zYKQu9rUOrQXkD2+7BBf(@-~RUOYO|D4dS%MRbz+Yg+%S)mKp>4ed>wgG67n~b`waP zUDAgVXYSY@@XznG>eQCq@jAumP#^5B9B!a6$4Y3|FGK+a6$8Zx6~gpt`kq(el3KCH zH9^tYaJ)QhZ^v3NL$QifD^V=;49OOmC$0=;itu3Oz@8=P74TFTNU-X)zV_t2+hb1;FX;i(ZQt(Kl z7Jj93VBY5@-D2~MPcK)LWmWTdlzqr`12Y}c8 z+))e~N~6C7PK=O}>dL?h^7EAt8(?}q0hbEV_3mCUQhcPH6A`;Ih9f6feqib4;*nu!NtqT|tZrT|zM!4zzYpk{bIP@~N7vcnz zdBqD?H8Jjm(^%KU3L0S^)->_&8zJ8$XEdV?W=bq+69O@#%yvkYGxD^y4@dF+RzCJ`IBvq@ZTq}fi09h)0>CRMe3qccT8TbAt`!6m z#7O2e;8LkNZNLGYym)amrn0}xxItc2tU1zv1~i$2A!=EWlz2;8CM&FUICjm1p|~QGlxY@#T$=x zlT&c|Kg|hI4_#*pu zAvz~`Hm|pDb`~ld^VYaL%4+9EC1I1Yew93P*SOwtEnkzXtQ1y$?gm%x#=%TJW)UU< zo4XiKg%EnNnOI6gSZEIBvF$mShD+`IR1PlDnFzh?Cl(Kjc@6Wd>patm_zSS;dnXd| E4~VjHZU6uP delta 1638 zcmaJ?YfO_@7(VaY8>LiR1qZ0}qX7W{Z<`p{oPG?NZN!nGOid=%MVC;cgT*<`vRMRm zkwQq0dKTuCiF3p&`XxKYY^gt-Va9l+rO2iA5(ml@FB`g2cBotzKUXF^|K6A@3hG{G4rNNA*yYi1KbYG#hWgbs3Ze> zTn?Q=-0EnzT3O{Fxq_f!V}gj&VWV?QP^2jlRwZ)<3cAFUhW##WooVZ69CB&T#x><& zOPGFB=)`kj?M5L_Ne+jI4JHICKRYORZv>8t!9#UxGsme3YJm+7#{C=WPZY z3escM^#Zhcn@k|_?V4efSg>VN#6Ppusxv88#pq12(kt6i>0jG+nN2Ddi>5a&QKMI- z>0T?%@>r~`i^TYhA$(q`08i=i`jZov~B+R2?Y z8QAEjbA7Y%N58h-S8JxO9hbGI?H`PTT5UwR?;{^8foK8qiN9X&j3hXVk0ylyP487Hdw-o76E5b!>n0rR;iG0j2FAZQv8jvLjv%Ycg-N_(HoT-)8pBc1 z^f{#3P-G=SKVi1(Bhod7dGl* zvUtb|DR!V77K7g?>x1R+2I62ZPh1YaNQHUmL#Vb}n)~Dl#8bBcTlN_waL-yOjQyW& z;$oZr0sJ$>`#hkbG-wSROMIc^;tLDN%XdLaB(r)qROoTIlMU{LN{QtyP=XIT`Oy|A z0!b{Jg{eIGd#I2e-@G4Q#u570R#=fW@l-bv$BlEV?GeM~Rv$!+M7P@7!`|rt4jzVn za0p2A3l&ZTScxBe*dAcE0L;fz0lq8%>r7I|&R!^xIKEFL2pxO+V5-zHE(A;Ul2~;E zu1VtEoATM3J9)w_*bdm;$qxPow`2zG7c+uE_H(}|joyIS2cT32R}O$vl7|OigY+|Q z!v%>?Zo}76K9%!3P%W30<==%GiN3pVMB>Nygtn)G?7MrS%b_42xCbEjkPQxjJBmrX z>OPE*uniC3;B%HOeE46lu7_}1kbKW07)IE{*syqUUpMO?2E}scDoK%FJ+<{J8~Ydz z;GS;odklw6_){;d(qqz&E$G6IUKSQM7qd^|^9XyQ$2fLfhxJirbu{l&fqanxpX%fV z?fIC;8qD~KbcE`ud~z)AGvf9TKWD?@ksMwck0m3XtjOaDiFn>5>*P`}vx4KJPBE`i z&^!|2&FMJSAY;xPoWercxH9T|AyFJ*4#%mu!Wi}Pm>4#fgyyY!iZN^t(+5Y$NP4<2%F6WsGFbj|n zwqPOpSaKOw#t`*=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 @@ -478,14 +478,22 @@ 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 two{ candidate(fc::sha256::hash(std::string("a")), {BATCHOP}), candidate(fc::sha256::hash(std::string("b")), {"voter1"_n}), }; - BOOST_REQUIRE_EQUAL( - error("assertion failure with message: a dispute requires at least 3 candidate envelope versions"), - opendispute(eth_code(), current_epoch(), two)); + 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 { + std::vector one{ + candidate(fc::sha256::hash(std::string("a")), {BATCHOP}), + }; + BOOST_REQUIRE(opendispute(eth_code(), current_epoch(), one) != success()); } FC_LOG_AND_RETHROW() } BOOST_FIXTURE_TEST_CASE(opendispute_rejects_duplicate, sysio_dispute_tester) { try { diff --git a/contracts/tests/sysio.msgch_chain_tests.cpp b/contracts/tests/sysio.msgch_chain_tests.cpp index e107e0cfbc..d872350f09 100644 --- a/contracts/tests/sysio.msgch_chain_tests.cpp +++ b/contracts/tests/sysio.msgch_chain_tests.cpp @@ -62,6 +62,12 @@ constexpr std::string_view ETH_CHAIN_CODE = "ETH"; constexpr std::string_view SOL_CHAIN_CODE = "SOL"; constexpr uint64_t BATCH_OPERATOR_MINIMUM_COLLATERAL = 1; constexpr uint64_t TABLE_SCAN_LIMIT = 64; +constexpr uint32_t ONE_OPERATOR_PER_TIED_VERSION = 1; +constexpr uint32_t THREE_OPERATORS_PER_TIED_VERSION = 3; +constexpr std::string_view ONE_TO_ONE_LEFT_PAYLOAD = "one-to-one-left"; +constexpr std::string_view ONE_TO_ONE_RIGHT_PAYLOAD = "one-to-one-right"; +constexpr std::string_view THREE_TO_THREE_LEFT_PAYLOAD = "three-to-three-left"; +constexpr std::string_view THREE_TO_THREE_RIGHT_PAYLOAD = "three-to-three-right"; /// sysio.opreg action identifiers used by the WNS-16 fixture. namespace opreg_actions { @@ -116,10 +122,44 @@ constexpr const char* WINNING_CHECKSUM = "winning_checksum"; constexpr const char* CHECKSUM = "checksum"; } // namespace msgch_fields +/// sysio.chalg table identifiers used by the split-consensus regressions. +namespace chalg_tables { +constexpr name DISPUTES = "disputes"_n; +} // namespace chalg_tables + +/// sysio.chalg action identifiers used by the terminal-tie regressions. +namespace chalg_actions { +constexpr name CHECK_DISPUTE = "chkdispute"_n; +constexpr name VOTE_DISPUTE = "votedispute"_n; +} // namespace chalg_actions + +/// sysio.chalg ABI type identifiers used by the split-consensus regressions. +namespace chalg_abi_types { +constexpr const char* DISPUTE_ENTRY = "dispute_entry"; +} // namespace chalg_abi_types + +/// sysio.chalg ABI field identifiers used by the split-consensus regressions. +namespace chalg_fields { +constexpr const char* CANDIDATES = "candidates"; +constexpr const char* CHAIN_CODE = "chain_code"; +constexpr const char* CHECKSUM = "checksum"; +constexpr const char* EPOCH_INDEX = "epoch_index"; +constexpr const char* OPERATORS = "operators"; +constexpr const char* STATUS = "status"; +} // namespace chalg_fields + +/// sysio.chalg vote-action ABI field identifiers used by the terminal-tie regressions. +namespace chalg_vote_fields { +constexpr const char* CHOSEN_CHECKSUM = "chosen_checksum"; +constexpr const char* DISPUTE_ID = "dispute_id"; +constexpr const char* OWNER = "owner"; +} // namespace chalg_vote_fields + /// 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* CURRENT_BATCH_OP_GROUP = "current_batch_op_group"; +constexpr const char* IS_PAUSED = "is_paused"; } // namespace epoch_fields } // anonymous namespace @@ -142,8 +182,14 @@ class sysio_msgch_chain_tester : public tester { static constexpr auto BATCHOP = "batchop.a"_n; static constexpr auto BATCHOP_B = "batchop.b"_n; static constexpr auto BATCHOP_C = "batchop.c"_n; + static constexpr auto BATCHOP_D = "batchop.d"_n; + static constexpr auto BATCHOP_E = "batchop.e"_n; + static constexpr auto BATCHOP_F = "batchop.f"_n; static constexpr uint32_t EPOCH_DURATION_SEC = 60; + static constexpr uint32_t ONE_TO_ONE_TIE_GROUP_SIZE = 2; + static constexpr uint32_t THREE_TO_THREE_TIE_GROUP_SIZE = 6; + static constexpr uint64_t FIRST_DISPUTE_ID = 1; sysio_msgch_chain_tester() { produce_blocks(2); @@ -153,7 +199,8 @@ class sysio_msgch_chain_tester : public tester { // pay-epoch transfers. Same bootstrap rationale as sysio_epoch_flushwtdw_tester. create_accounts({ TOKEN_ACCOUNT, EPOCH_ACCOUNT, OPREG_ACCOUNT, MSGCH_ACCOUNT, - CHALG_ACCOUNT, CHAINS_ACCOUNT, UWRIT_ACCOUNT, BATCHOP, BATCHOP_B, BATCHOP_C, + CHALG_ACCOUNT, CHAINS_ACCOUNT, UWRIT_ACCOUNT, + BATCHOP, BATCHOP_B, BATCHOP_C, BATCHOP_D, BATCHOP_E, BATCHOP_F, "sysio.dclaim"_n, "sysio.gov"_n, "sysio.ops"_n }); produce_blocks(2); @@ -242,9 +289,8 @@ class sysio_msgch_chain_tester : public tester { ("pay_cadence_epochs", uint16_t(1)))); } - /// Epoch + opreg config, a configurable `BATCHOP` plus bootstrapped `BATCHOP_B`/`BATCHOP_C` when - /// `n_batch_ops` is 3 (a single group of three, so consensus needs more than one delivery), ETH + - /// SOL chain rows, group schedule, and genesis advance. + /// Configure one test cohort of up to six batch operators, the ETH/SOL outpost rows, its one-group + /// schedule, and the genesis advance. The configurable cohort supports even split regressions. void bootstrap(uint32_t n_batch_ops = 1, bool batchop_is_bootstrapped = true) { BOOST_REQUIRE_EQUAL(success(), push(EPOCH_ACCOUNT, epoch_abi, EPOCH_ACCOUNT, "setconfig"_n, mvo() @@ -285,11 +331,11 @@ class sysio_msgch_chain_tester : public tester { BATCH_OPERATOR_MINIMUM_COLLATERAL) }) ("req_uw_collat", fc::variants{}))); - std::vector batch_ops{BATCHOP}; - if (n_batch_ops == 3) { - batch_ops.push_back(BATCHOP_B); - batch_ops.push_back(BATCHOP_C); - } + const std::vector available_batch_ops{ + BATCHOP, BATCHOP_B, BATCHOP_C, BATCHOP_D, BATCHOP_E, BATCHOP_F}; + BOOST_REQUIRE(n_batch_ops > 0 && n_batch_ops <= available_batch_ops.size()); + std::vector batch_ops(available_batch_ops.begin(), + available_batch_ops.begin() + n_batch_ops); for (const auto& op : batch_ops) { BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, "regoperator"_n, mvo() @@ -408,6 +454,68 @@ class sysio_msgch_chain_tester : public tester { abi_serializer::create_yield_function(abi_serializer_max_time)); } + /// Return the dispute row by id, or null when the consensus path did not open one. + fc::variant get_dispute(uint64_t dispute_id) { + auto data = get_row_by_id(CHALG_ACCOUNT, CHALG_ACCOUNT, chalg_tables::DISPUTES, dispute_id); + return data.empty() ? fc::variant() : chalg_abi.binary_to_variant( + chalg_abi_types::DISPUTE_ENTRY, data, + abi_serializer::create_yield_function(abi_serializer_max_time)); + } + + /// Assert that the full `chkcons -> evalcons -> opendispute` route recorded the exact split. + void assert_open_tie_dispute(uint32_t expected_epoch, + const std::vector& expected_checksums, + const std::vector& expected_operator_counts) { + BOOST_REQUIRE_EQUAL(expected_checksums.size(), expected_operator_counts.size()); + const auto dispute = get_dispute(FIRST_DISPUTE_ID); + BOOST_REQUIRE(!dispute.is_null()); + BOOST_REQUIRE_EQUAL( + dispute[chalg_fields::STATUS].as(), + opp::types::DisputeStatus::DISPUTE_STATUS_OPEN); + BOOST_REQUIRE_EQUAL(dispute[chalg_fields::CHAIN_CODE].as_uint64(), ETH_OUTPOST_ID); + BOOST_REQUIRE_EQUAL(dispute[chalg_fields::EPOCH_INDEX].as(), expected_epoch); + + const auto candidates = dispute[chalg_fields::CANDIDATES].get_array(); + BOOST_REQUIRE_EQUAL(candidates.size(), expected_checksums.size()); + for (size_t index = 0; index < candidates.size(); ++index) { + BOOST_REQUIRE_EQUAL(candidates[index][chalg_fields::CHECKSUM].as_string(), + expected_checksums[index].str()); + BOOST_REQUIRE_EQUAL(candidates[index][chalg_fields::OPERATORS].get_array().size(), + expected_operator_counts[index]); + } + } + + /// Cast the sole seeded Tier-1 vote for a terminal tie's expected winning envelope. + action_result vote_dispute(uint64_t dispute_id, const fc::sha256& chosen_checksum) { + return push(CHALG_ACCOUNT, chalg_abi, NODE_DADDY, chalg_actions::VOTE_DISPUTE, mvo() + (chalg_vote_fields::OWNER, NODE_DADDY.to_string()) + (chalg_vote_fields::DISPUTE_ID, dispute_id) + (chalg_vote_fields::CHOSEN_CHECKSUM, chosen_checksum)); + } + + /// Resolve the first dispute and verify that its selected envelope is durably accepted by msgch. + void resolve_tie_dispute(uint32_t expected_epoch, const fc::sha256& expected_winner) { + BOOST_REQUIRE_EQUAL(success(), vote_dispute(FIRST_DISPUTE_ID, expected_winner)); + BOOST_REQUIRE_EQUAL(success(), push(CHALG_ACCOUNT, chalg_abi, BATCHOP, + chalg_actions::CHECK_DISPUTE, + mvo()(chalg_vote_fields::DISPUTE_ID, FIRST_DISPUTE_ID))); + + const auto dispute = get_dispute(FIRST_DISPUTE_ID); + BOOST_REQUIRE_EQUAL( + dispute[chalg_fields::STATUS].as(), + opp::types::DisputeStatus::DISPUTE_STATUS_RESOLVED); + const auto consensus = get_outpcons(ETH_OUTPOST_ID); + BOOST_REQUIRE(!consensus.is_null()); + BOOST_REQUIRE_EQUAL(consensus[msgch_fields::EPOCH_INDEX].as(), expected_epoch); + BOOST_REQUIRE_EQUAL(consensus[msgch_fields::WINNING_CHECKSUM].as_string(), expected_winner.str()); + } + + /// Return whether the epoch is paused by one or more unresolved disputes. + bool epoch_paused() { + const auto state = read_epoch_state(); + return !state.is_null() && state[epoch_fields::IS_PAUSED].as_bool(); + } + /// Inbound delivery metadata for one (outpost, epoch, batch operator), or null when absent. /// Consensus deliberately clears only raw_data, leaving this row for advance() to classify. fc::variant find_inbound_delivery(uint64_t chain_code, uint32_t epoch_index, name batch_op, @@ -1568,6 +1676,92 @@ BOOST_FIXTURE_TEST_CASE(pre_boundary_majority_finalized_by_chkcons_crank, sysio_ BOOST_REQUIRE_EQUAL(opc["envelope_digest"].as_string(), digest.str()); } FC_LOG_AND_RETHROW() } +/// A 1–1 split leaves no strict majority. The deliveries deliberately land before the epoch +/// boundary, so only the permissionless `chkcons` crank can re-drive evalcons and open the dispute. +BOOST_FIXTURE_TEST_CASE(chkcons_opens_dispute_for_one_to_one_tie, sysio_msgch_chain_tester) { try { + bootstrap(ONE_TO_ONE_TIE_GROUP_SIZE); + + const uint32_t epoch = current_epoch(); + const auto left = encode_delivery(epoch, std::string(ONE_TO_ONE_LEFT_PAYLOAD)); + const auto right = encode_delivery(epoch, std::string(ONE_TO_ONE_RIGHT_PAYLOAD)); + + BOOST_REQUIRE_EQUAL(success(), deliver_as(BATCHOP, ETH_OUTPOST_ID, left)); + produce_blocks(); + BOOST_REQUIRE_EQUAL(success(), deliver_as(BATCHOP_B, ETH_OUTPOST_ID, right)); + produce_blocks(); + BOOST_REQUIRE(get_dispute(FIRST_DISPUTE_ID).is_null()); + + elapse_epoch_boundary(); + advance_via_consensus(); + + assert_open_tie_dispute(epoch, + {fc::sha256::hash(left.data(), left.size()), + fc::sha256::hash(right.data(), right.size())}, + {ONE_OPERATOR_PER_TIED_VERSION, ONE_OPERATOR_PER_TIED_VERSION}); + resolve_tie_dispute(epoch, fc::sha256::hash(left.data(), left.size())); +} FC_LOG_AND_RETHROW() } + +/// The same `chkcons` route must preserve the full candidate tallies for a larger even 3–3 split. +BOOST_FIXTURE_TEST_CASE(chkcons_opens_dispute_for_three_to_three_tie, sysio_msgch_chain_tester) { try { + bootstrap(THREE_TO_THREE_TIE_GROUP_SIZE); + + const uint32_t epoch = current_epoch(); + const auto left = encode_delivery(epoch, std::string(THREE_TO_THREE_LEFT_PAYLOAD)); + const auto right = encode_delivery(epoch, std::string(THREE_TO_THREE_RIGHT_PAYLOAD)); + const std::vector left_operators{BATCHOP, BATCHOP_B, BATCHOP_C}; + const std::vector right_operators{BATCHOP_D, BATCHOP_E, BATCHOP_F}; + + for (const auto& operator_name : left_operators) { + BOOST_REQUIRE_EQUAL(success(), deliver_as(operator_name, ETH_OUTPOST_ID, left)); + produce_blocks(); + } + for (const auto& operator_name : right_operators) { + BOOST_REQUIRE_EQUAL(success(), deliver_as(operator_name, ETH_OUTPOST_ID, right)); + produce_blocks(); + } + BOOST_REQUIRE(get_dispute(FIRST_DISPUTE_ID).is_null()); + + elapse_epoch_boundary(); + advance_via_consensus(); + + assert_open_tie_dispute(epoch, + {fc::sha256::hash(left.data(), left.size()), + fc::sha256::hash(right.data(), right.size())}, + {THREE_OPERATORS_PER_TIED_VERSION, THREE_OPERATORS_PER_TIED_VERSION}); + resolve_tie_dispute(epoch, fc::sha256::hash(left.data(), left.size())); +} FC_LOG_AND_RETHROW() } + +/// Two conflicting deliveries must not pause the epoch while a third eligible operator can form a +/// strict majority. Its later matching delivery proves the partial split was not terminal. +BOOST_FIXTURE_TEST_CASE(chkcons_waits_for_an_outstanding_operator_in_a_two_way_split, + sysio_msgch_chain_tester) { try { + bootstrap(/*n_batch_ops=*/3); + + const uint32_t epoch = current_epoch(); + const auto left = encode_delivery(epoch, std::string(ONE_TO_ONE_LEFT_PAYLOAD)); + const auto right = encode_delivery(epoch, std::string(ONE_TO_ONE_RIGHT_PAYLOAD)); + const auto left_checksum = fc::sha256::hash(left.data(), left.size()); + + BOOST_REQUIRE_EQUAL(success(), deliver_as(BATCHOP, ETH_OUTPOST_ID, left)); + produce_blocks(); + BOOST_REQUIRE_EQUAL(success(), deliver_as(BATCHOP_B, ETH_OUTPOST_ID, right)); + produce_blocks(); + + elapse_epoch_boundary(); + advance_via_consensus(); + + BOOST_REQUIRE(get_dispute(FIRST_DISPUTE_ID).is_null()); + BOOST_REQUIRE(!epoch_paused()); + + BOOST_REQUIRE_EQUAL(success(), deliver_as(BATCHOP_C, ETH_OUTPOST_ID, left)); + produce_blocks(); + + const auto consensus = get_outpcons(ETH_OUTPOST_ID); + BOOST_REQUIRE(!consensus.is_null()); + BOOST_REQUIRE_EQUAL(consensus[msgch_fields::EPOCH_INDEX].as(), epoch); + BOOST_REQUIRE_EQUAL(consensus[msgch_fields::WINNING_CHECKSUM].as_string(), left_checksum.str()); +} FC_LOG_AND_RETHROW() } + // Review follow-up on WNS-15(a): the consensus tally and the threshold must be drawn from the SAME // population. Sizing the group to the live set while still counting every delivery row lets a // slashed operator's pre-slash vote carry a threshold it is no longer part of: From d9ac657dc6e2ca35c96299213b6225661c8ad0a0 Mon Sep 17 00:00:00 2001 From: Huang-Ming Huang Date: Fri, 21 Aug 2026 20:34:32 +0000 Subject: [PATCH 2/2] fix: dispute incomplete two-way consensus splits Change-Id: Id0cbaa4fa450435cdc928b501a90222f65c2f38e --- contracts/sysio.chalg/README.md | 15 +++---- .../include/sysio.chalg/sysio.chalg.hpp | 16 ++++---- contracts/sysio.msgch/src/sysio.msgch.cpp | 32 ++++----------- contracts/sysio.msgch/sysio.msgch.wasm | Bin 154018 -> 153871 bytes contracts/tests/sysio.msgch_chain_tests.cpp | 37 ++++++------------ 5 files changed, 36 insertions(+), 64 deletions(-) diff --git a/contracts/sysio.chalg/README.md b/contracts/sysio.chalg/README.md index 43e7315905..3e56476a18 100644 --- a/contracts/sysio.chalg/README.md +++ b/contracts/sysio.chalg/README.md @@ -5,8 +5,8 @@ OPP envelope dispute resolution and slash-execution contract. ## Responsibility - Resolves conflicting OPP outpost envelopes via a Tier-1 node-owner vote when the automatic - consensus rules in `sysio.msgch` cannot: a terminal two-version tie, or an existing 3+-version - split with no majority for one (outpost, epoch) + consensus rules in `sysio.msgch` see two or more versions with no strict majority after the epoch + boundary for one (outpost, epoch), including when an otherwise eligible operator was silent - Pauses epoch advancement while a dispute is open and releases it on resolution - Dispatches the winning envelope (via `sysio.msgch::resolvedisp`) once a checksum wins - Executes slashing of operators through `sysio.opreg` -- the single slashing chokepoint that holds @@ -30,16 +30,17 @@ OPP envelope dispute resolution and slash-execution contract. ## Dispute-vote flow -1. **Open**: `sysio.msgch::evalcons` calls `opendispute` inline for either a terminal two-version - tie (all live eligible batch operators delivered) or an existing 3+-version no-majority split - past the epoch boundary. The dispute records the candidate checksums, snapshots the active ROA - generation's Tier-1 electorate and fixed quorum, and pauses `sysio.epoch`. +1. **Open**: `sysio.msgch::evalcons` calls `opendispute` inline for a post-boundary no-majority + split with at least two versions, regardless of whether every eligible operator delivered. The + dispute records the candidate checksums, snapshots the active ROA generation's Tier-1 electorate + and fixed quorum, and pauses `sysio.epoch`. 2. **Vote**: owners in the dispute's frozen Tier-1 electorate call `votedispute` with one of the candidate checksums. Later ROA registrations cannot join an in-flight dispute. One vote per owner. 3. **Tally**: anyone cranks `chkdispute`. With `N` equal to the snapshotted electorate size and fixed `Q = floor(N/2)+1`, a checksum reaching `Q` votes wins at any time (fast path); after the 24h deadline the bar relaxes to a quorum of cast votes (`cast >= Q`) plus a strict majority of cast - (`2*votes > cast`). No plurality / tie-break -- an undecided tally keeps waiting for votes. + (`2*votes > cast`). No plurality / tie-break -- an undecided tally remains open and keeps the + epoch paused until Tier-1 supplies a resolvable vote. 4. **Resolve**: the winning checksum is recorded and dispatched via `sysio.msgch::resolvedisp`. `sysio.epoch` is unpaused when the final open dispute resolves. The next `sysio.epoch::advance` then slashes every operator that delivered a non-canonical checksum for diff --git a/contracts/sysio.chalg/include/sysio.chalg/sysio.chalg.hpp b/contracts/sysio.chalg/include/sysio.chalg/sysio.chalg.hpp index 56c01f3286..d6cf2a4e2b 100644 --- a/contracts/sysio.chalg/include/sysio.chalg/sysio.chalg.hpp +++ b/contracts/sysio.chalg/include/sysio.chalg/sysio.chalg.hpp @@ -48,10 +48,10 @@ namespace sysio { // OPP envelope dispute vote (Tier-1 node-owner resolution) // ----------------------------------------------------------------------- - /// Open an OPP envelope dispute. Called inline by `sysio.msgch::evalcons` for a terminal - /// two-version tie or an existing multi-version no-majority split. It accepts at least - /// `chalg_limits::minimum_dispute_candidate_versions` distinct versions; msgch owns the - /// terminal-delivery check because it alone has the live eligible group and delivery tally. + /// Open an OPP envelope dispute. Called inline by `sysio.msgch::evalcons` for a post-boundary + /// no-majority split with at least `chalg_limits::minimum_dispute_candidate_versions` distinct + /// versions. msgch owns the consensus boundary and strict-majority checks because it alone has + /// the live eligible group and delivery tally. /// Records the candidate checksums, snapshots the Tier-1 electorate (the Tier-1 /// rows of `sysio.roa::nodeowners` for the current network generation) together with its /// quorum, and pauses epoch advancement until a Tier-1 node-owner vote resolves the @@ -220,10 +220,10 @@ namespace sysio { SYSLIB_SERIALIZE(dispute_key, (id)) }; - /// OPP envelope dispute. Opened for a terminal two-version tie or for an existing 3+-version - /// post-boundary no-majority split for one (outpost, epoch); resolved by a Tier-1 node-owner - /// vote on the canonical checksum. The row is retained after resolution as the audit record - /// (and as the guard that prevents re-opening the same (outpost, epoch) dispute). + /// OPP envelope dispute. Opened for a post-boundary no-majority split with at least two + /// versions for one (outpost, epoch); resolved by a Tier-1 node-owner vote on the canonical + /// checksum. The row is retained after resolution as the audit record (and as the guard that + /// prevents re-opening the same (outpost, epoch) dispute). struct [[sysio::table("disputes")]] dispute_entry { uint64_t id; uint64_t chain_code; ///< outpost slug_name value diff --git a/contracts/sysio.msgch/src/sysio.msgch.cpp b/contracts/sysio.msgch/src/sysio.msgch.cpp index 3dc43139a1..8c38cb78b0 100644 --- a/contracts/sysio.msgch/src/sysio.msgch.cpp +++ b/contracts/sysio.msgch/src/sysio.msgch.cpp @@ -92,11 +92,6 @@ 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 non-terminal two-version split whose outstanding operator may form a majority. -constexpr const char* DISPUTE_TWO_WAY_SPLIT_INCOMPLETE_LOG = - "msgch::maybe_open_dispute: no dispute for (chain=%llu, epoch=%u): " - "two-way split has %u of %u eligible deliveries\n"; - uint32_t current_epoch_index() { epoch::epochstate_t tbl(EPOCH_ACCOUNT); return tbl.exists() ? tbl.get().current_epoch_index : 0; @@ -1186,12 +1181,11 @@ void dispatch_attestation(name self, uint64_t attestation_id, } /// Evaluate the dispute trigger and, if met, open a Tier-1 dispute vote via sysio.chalg. A -/// post-boundary no-majority split with three or more versions retains the existing behavior. A -/// two-version split is disputable only when every live eligible operator has delivered, so an -/// outstanding operator can still break a partial split by forming a strict majority. A majority -/// always resolves without a vote; a one-version or pre-boundary split waits for more deliveries. +/// post-boundary no-majority split with at least two versions is anomalous enough to require Tier-1 +/// adjudication, including when an eligible operator was silent. A strict majority always resolves +/// without a vote; a one-version or pre-boundary split waits for more deliveries. void maybe_open_dispute(name self, uint64_t chain_code, uint32_t epoch_index, - uint32_t group_size, uint32_t total_deliveries, + uint32_t group_size, const std::vector& seen_checksums, const std::vector& checksum_counts, const std::vector>& checksum_operators) { @@ -1212,17 +1206,6 @@ void maybe_open_dispute(name self, uint64_t chain_code, uint32_t epoch_index, return; } - // A partial two-way split has eligible operators that can still deliver a legitimate strict - // majority. Escalating it to a Tier-1 vote would pause the whole epoch prematurely, so only the - // terminal form — every eligible operator already delivered — becomes a dispute. Multi-version - // splits deliberately retain their existing post-boundary behavior. - if (seen_checksums.size() == chalg_limits::minimum_dispute_candidate_versions && - total_deliveries != group_size) { - sysio::print_f(DISPUTE_TWO_WAY_SPLIT_INCOMPLETE_LOG, - chain_code, epoch_index, total_deliveries, group_size); - return; - } - uint32_t max_count = 0; for (auto c : checksum_counts) { if (c > max_count) max_count = c; @@ -1521,10 +1504,9 @@ void msgch::evalcons(uint64_t chain_code, uint32_t epoch_index) { } if (!consensus_reached) { - // No automatic consensus. A terminal two-way or an existing multi-version no-majority split - // past the epoch boundary opens a Tier-1 dispute vote; a one-version or pre-boundary split - // waits for more deliveries. - maybe_open_dispute(get_self(), chain_code, epoch_index, group_size, total_deliveries, + // No automatic consensus. A two-or-more-version no-majority split past the epoch boundary + // opens a Tier-1 dispute vote; a one-version or pre-boundary split waits for more deliveries. + maybe_open_dispute(get_self(), chain_code, epoch_index, group_size, seen_checksums, checksum_counts, checksum_operators); return; } diff --git a/contracts/sysio.msgch/sysio.msgch.wasm b/contracts/sysio.msgch/sysio.msgch.wasm index 8f8807e0e3ffd5e3b5a50f93d7ce023fc3dd4056..2dca9bcfbfc41c9b263974a110d30b80f7b9e85f 100755 GIT binary patch delta 1490 zcmaJ>Z%k8H6o2Qmw3LDrDas!=9}Qp;aJo!P*%&@jnr&tyLCEF@p<8qbH9A-r(=40( z8M1&dmZ!UmZoH`B7gcs;y{Uj7>nDYySJ+(eA$=xJLh+P z=brn{Ik_hl!>2ul^D(;cMO82!6r>tCL05wl_40Aun)FGhC`hJi3%D$pjgwAEkOV7^ zIxQMea7ocFsiM*%WHUlmb#W{%2Xu~=J|+``tR&ceNB#NNsG`} zF;iq8>mFrOOQQjQ98@lTUT?+EJaV3U3sR4=->t5N24#o89I?YIU%KI7phQlTM4LVt z7No<&f~b=&=v`9Juv|HB#@~mP#(8N4KJX}49-CL<;3!pN;50xpZH|R~t5`PuI~~%2 z8)+G^6Y$g+X~=}Vj3D`&u!n6b?RXw~c;jfX1p77Erjh|Wtl>CghXjuL0@%oq z+6v$RaDo{b)bD`6YM$pSg%=TL#%TPT@C)xT^}hwRF~*K@URl&g=Yf>H4{@}n3hWX8 zGfv9J`3UfVpN`jng4~enp*wzA%GG5XKuf=biQu< z6K-(gwMpK#jv*R%3yuJe43RT`!Y!_lr&tZ2kNiBvQbT`0Vy2;p2X{|{gOlG+!vXGF zZ^LgKAKr#9LVOCPcc7LpDapAD^&H)I;T*?zX4y=8edOy|cFI{Fot_2Y`$uNxz!ky- zS~(9%LALcCoO!~M;)Vat8eV`)jHK=NA%IXvNPujf`Rs+7v9X9n8nihelba=#1`%SoQNkfawSh(cz3>%G$#{oUh<4ZvdA|{2LZM0HE z!%~QLti#wa9&=>jdg9N--67{gj_eA#6gS{5UC6T$RxFRG&moUfcKWly-Q#zq{n?ni zv~uJb8`?u>ttag^3@$Cx$(#)C>XjVyML{d=dqj9DV(DXWtI@PEnQ@vl7sc51Yt6+*~QeYObAG>b&wcm) z&f9X>Qf*jj;!QCfA!c36v@U&!sT4ohpzkmxrPsSurp1-m!DGj4taoWlV`=!a%dV)* zqxJJ>-eq={EeSnn8Yp!=Y;yKLFU!;jtFeg^B|Y6d9Q`h%#9ZDFgD&IYpzRLSL;8+< zCms(Om-9vP6BmOl4y-syg>`LC+88&&EcCnowTLX5ZaY5=pxL|a@R~wDNX1#XZ zW(mfm^=2S^xh#x?c^|HinW3V`jRpK zj@B1D8jU><6Z+$sHgO^z_5=JYiU+`wL9&_fr$ahWpk%;oz^wsZmp?&lZ?6Zt1Z;1#TC5)&Gs z#7v%j+Xi`r_uFKF^z3K=Mv`Zp0hk?Sscd4NG-UC}wF*e)-pg>5#EdKSa+Q8@_b=EA zc)`y%UW2Q&-PULKiI^jLZ2h3+B+pr0wTTxFIO^ z#o?4*IY*32!0D6;Sv)C*cF_dztHbcgo@9!crlQ5%D~Z*qI3Sv`ryckVR};|8f6T-= zJ3Bs7ReJ zqmrWK?xGdq)fu=H`03a1hZv^);%C+kF?{Vz^z!gb9FK1}#B&8WOEEL;_aL+S_{X!* SYg^)bp2(), epoch); - BOOST_REQUIRE_EQUAL(consensus[msgch_fields::WINNING_CHECKSUM].as_string(), left_checksum.str()); + assert_open_tie_dispute(epoch, + {left_checksum, right_checksum}, + {ONE_OPERATOR_PER_TIED_VERSION, ONE_OPERATOR_PER_TIED_VERSION}); + resolve_tie_dispute(epoch, left_checksum); } FC_LOG_AND_RETHROW() } // Review follow-up on WNS-15(a): the consensus tally and the threshold must be drawn from the SAME