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/4] 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/4] 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 From 93d5687f2f0167ae427fc08bed85a4d9da4f475d Mon Sep 17 00:00:00 2001 From: Huang-Ming Huang Date: Thu, 27 Aug 2026 23:16:25 +0000 Subject: [PATCH 3/4] fix: restore terminal two-way dispute handling Change-Id: I4fc0ebee435dc14bdea536cef33e2d1c7ae7e7f6 --- .../include/sysio.chalg/sysio.chalg.hpp | 13 +- contracts/sysio.chalg/src/sysio.chalg.cpp | 6 +- contracts/sysio.msgch/CMakeLists.txt | 1 + contracts/sysio.msgch/src/sysio.msgch.cpp | 54 ++++++-- contracts/sysio.msgch/sysio.msgch.wasm | Bin 153871 -> 157530 bytes contracts/tests/sysio.dispute_tests.cpp | 4 +- contracts/tests/sysio.msgch_chain_tests.cpp | 131 ++++++++++++++++-- 7 files changed, 179 insertions(+), 30 deletions(-) diff --git a/contracts/sysio.chalg/include/sysio.chalg/sysio.chalg.hpp b/contracts/sysio.chalg/include/sysio.chalg/sysio.chalg.hpp index d6cf2a4e2b..a38fb2a5fa 100644 --- a/contracts/sysio.chalg/include/sysio.chalg/sysio.chalg.hpp +++ b/contracts/sysio.chalg/include/sysio.chalg/sysio.chalg.hpp @@ -48,15 +48,16 @@ namespace sysio { // OPP envelope dispute vote (Tier-1 node-owner resolution) // ----------------------------------------------------------------------- - /// 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. + /// Open an OPP envelope dispute. Called inline by `sysio.msgch::evalcons` for a terminal + /// two-version tie or a post-boundary multi-version no-majority split. msgch owns the + /// consensus boundary, terminality, strict-majority, and electorate-preflight 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 - /// canonical envelope. Rejects opening when no Tier-1 node owner is registered: an - /// empty-electorate dispute could never resolve and would hold the epoch paused forever. + /// canonical envelope. Defensively rejects direct calls when no Tier-1 node owner is + /// registered: an empty-electorate dispute could never resolve and would hold the epoch + /// paused forever. [[sysio::action]] void opendispute(uint64_t chain_code, uint32_t epoch_index, diff --git a/contracts/sysio.chalg/src/sysio.chalg.cpp b/contracts/sysio.chalg/src/sysio.chalg.cpp index d08de1cefc..9631d47bab 100644 --- a/contracts/sysio.chalg/src/sysio.chalg.cpp +++ b/contracts/sysio.chalg/src/sysio.chalg.cpp @@ -269,9 +269,9 @@ void chalg::opendispute(uint64_t chain_code, const uint8_t network_gen = roa::current_network_gen(ROA_ACCOUNT); auto electorate = snapshot_t1_electorate(ROA_ACCOUNT, network_gen); - // An empty electorate could never vote, so the dispute could never resolve and the epoch pause - // below would hold forever. Refuse to open instead -- the conflicting deliveries keep this - // epoch from reaching consensus regardless, and the failure then names the actual problem. + // Defense in depth for direct calls: msgch preflights this invariant and soft-returns so a + // terminal delivery remains retryable, while this assertion keeps every other caller from + // opening an unresolvable, permanently-pausing dispute. check(!electorate.empty(), "cannot open a dispute with no registered tier-1 node owners"); const uint32_t quorum = static_cast(electorate.size()) / 2 + 1; diff --git a/contracts/sysio.msgch/CMakeLists.txt b/contracts/sysio.msgch/CMakeLists.txt index a64258ed57..485b0fc3c5 100644 --- a/contracts/sysio.msgch/CMakeLists.txt +++ b/contracts/sysio.msgch/CMakeLists.txt @@ -39,6 +39,7 @@ if(BUILD_SYSTEM_CONTRACTS) $ $ $ + $ ) target_link_libraries(${target} diff --git a/contracts/sysio.msgch/src/sysio.msgch.cpp b/contracts/sysio.msgch/src/sysio.msgch.cpp index 8c38cb78b0..38ef1d82e4 100644 --- a/contracts/sysio.msgch/src/sysio.msgch.cpp +++ b/contracts/sysio.msgch/src/sysio.msgch.cpp @@ -4,6 +4,7 @@ #include #include // dispute trigger + open-dispute gate (disputes table) #include // operator-status delivery gate (operators table) +#include // authoritative Tier-1 electorate preflight #include #include // to_depot_amount — WSA-028 fail-closed TokenAmount gate #include // parse_wire_account_name — never-throw account-name parse @@ -12,6 +13,7 @@ #include #include #include +#include #include #include @@ -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 { @@ -92,6 +95,16 @@ 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; @@ -1180,12 +1193,12 @@ 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. A -/// 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. +/// 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& seen_checksums, const std::vector& checksum_counts, const std::vector>& checksum_operators) { @@ -1218,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 candidates; candidates.reserve(seen_checksums.size()); for (size_t g = 0; g < seen_checksums.size(); ++g) { @@ -1446,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 terminal - // two-way or an existing multi-version 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 seen_checksums; std::vector checksum_counts; std::vector> checksum_data; @@ -1504,9 +1537,10 @@ void msgch::evalcons(uint64_t chain_code, uint32_t epoch_index) { } if (!consensus_reached) { - // 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, + // 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; } diff --git a/contracts/sysio.msgch/sysio.msgch.wasm b/contracts/sysio.msgch/sysio.msgch.wasm index 2dca9bcfbfc41c9b263974a110d30b80f7b9e85f..b581f28a4f6c2d039c3f9a103e3d3b01ddea705b 100755 GIT binary patch delta 24614 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 one{ candidate(fc::sha256::hash(std::string("a")), {BATCHOP}), }; - BOOST_REQUIRE(opendispute(eth_code(), current_epoch(), one) != success()); + BOOST_REQUIRE_EQUAL( + 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 { diff --git a/contracts/tests/sysio.msgch_chain_tests.cpp b/contracts/tests/sysio.msgch_chain_tests.cpp index 718db3757f..cf6eff07f0 100644 --- a/contracts/tests/sysio.msgch_chain_tests.cpp +++ b/contracts/tests/sysio.msgch_chain_tests.cpp @@ -120,6 +120,7 @@ constexpr const char* EPOCH_INDEX = "epoch_index"; constexpr const char* BATCH_OP_NAME = "batch_op_name"; constexpr const char* WINNING_CHECKSUM = "winning_checksum"; constexpr const char* CHECKSUM = "checksum"; +constexpr const char* CONSENSUS_REACHED = "consensus_reached"; } // namespace msgch_fields /// sysio.chalg table identifiers used by the split-consensus regressions. @@ -159,8 +160,33 @@ constexpr const char* OWNER = "owner"; 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 +/// sysio.roa action identifiers used by the empty-electorate retry regression. +namespace roa_actions { +constexpr name ACTIVATE = "activateroa"_n; +constexpr name FORCE_REGISTER = "forcereg"_n; +} // namespace roa_actions + +/// sysio.system identifiers used while activating the empty ROA fixture. +namespace system_actions { +constexpr name SET_PRIVILEGED = "setpriv"_n; +} // namespace system_actions + +namespace system_fields { +constexpr const char* ACCOUNT = "account"; +constexpr const char* IS_PRIVILEGED = "is_priv"; +} // namespace system_fields + +/// sysio.roa ABI field identifiers used by the empty-electorate retry regression. +namespace roa_fields { +constexpr const char* OWNER = "owner"; +constexpr const char* TIER = "tier"; +constexpr const char* TOTAL_SYSTEM = "total_sys"; +constexpr const char* BYTES_PER_UNIT = "bytes_per_unit"; +} // namespace roa_fields + } // anonymous namespace // --------------------------------------------------------------------------- @@ -178,6 +204,7 @@ class sysio_msgch_chain_tester : public tester { static constexpr auto CHALG_ACCOUNT = "sysio.chalg"_n; static constexpr auto CHAINS_ACCOUNT = "sysio.chains"_n; static constexpr auto UWRIT_ACCOUNT = "sysio.uwrit"_n; + static constexpr auto ROA_ACCOUNT = "sysio.roa"_n; static constexpr auto BATCHOP = "batchop.a"_n; static constexpr auto BATCHOP_B = "batchop.b"_n; static constexpr auto BATCHOP_C = "batchop.c"_n; @@ -190,8 +217,15 @@ class sysio_msgch_chain_tester : public tester { static constexpr uint32_t THREE_TO_THREE_TIE_GROUP_SIZE = 6; static constexpr uint32_t INCOMPLETE_TWO_WAY_SPLIT_GROUP_SIZE = 3; static constexpr uint64_t FIRST_DISPUTE_ID = 1; - - sysio_msgch_chain_tester() { + static constexpr auto TIER_ONE_OWNER = "tierone"_n; + static constexpr const char* EMPTY_ROA_TOTAL_SYSTEM = "75496.0000 SYS"; + static constexpr uint64_t EMPTY_ROA_BYTES_PER_UNIT = 104; + + /// Construct an OPP integration fixture, optionally with an activated ROA that has no + /// registered node owners to exercise the msgch electorate preflight. + explicit sysio_msgch_chain_tester(bool empty_roa = false) + : tester(empty_roa ? setup_policy::full_except_do_not_set_finalizers : setup_policy::full) { + if (empty_roa) activate_empty_roa(); produce_blocks(2); // sysio.* accounts BEFORE sysio.system so they keep unlimited RAM; the payepoch @@ -202,7 +236,7 @@ class sysio_msgch_chain_tester : public tester { 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 - }); + }, false, true, !empty_roa); produce_blocks(2); deploy(CHALG_ACCOUNT, contracts::chalg_wasm(), contracts::chalg_abi(), chalg_abi); @@ -214,6 +248,8 @@ class sysio_msgch_chain_tester : public tester { deploy(TOKEN_ACCOUNT, contracts::token_wasm(), contracts::token_abi(), token_abi); produce_blocks(1); + load_abi(ROA_ACCOUNT, roa_abi); + // sysio.system for the epoch::advance emissions readiness gate; without emitcfg/t5state // the gate returns CONFIG_MISSING and the epoch never advances. set_code(SYSIO_ACCOUNT, contracts::system_wasm()); @@ -254,6 +290,20 @@ class sysio_msgch_chain_tester : public tester { load_abi(account, out_ser); } + /// Activate the ROA registry without the normal genesis Tier-1 nodedaddy registration. + void activate_empty_roa() { + create_account(ROA_ACCOUNT, SYSIO_ACCOUNT, false, true, false, false); + create_account("sysio.acct"_n, SYSIO_ACCOUNT, false, false, false, false); + create_account("sysio.authex"_n, SYSIO_ACCOUNT, false, false, false, false); + set_contract(ROA_ACCOUNT, contracts::roa_wasm(), contracts::roa_abi().data()); + base_tester::push_action(SYSIO_ACCOUNT, system_actions::SET_PRIVILEGED, SYSIO_ACCOUNT, + mvo()(system_fields::ACCOUNT, ROA_ACCOUNT.to_string()) + (system_fields::IS_PRIVILEGED, true)); + base_tester::push_action(ROA_ACCOUNT, roa_actions::ACTIVATE, ROA_ACCOUNT, + mvo()(roa_fields::TOTAL_SYSTEM, asset::from_string(EMPTY_ROA_TOTAL_SYSTEM)) + (roa_fields::BYTES_PER_UNIT, EMPTY_ROA_BYTES_PER_UNIT)); + } + action_result push(name contract, abi_serializer& ser, name signer, name action_name, const fc::variant_object& data) { return sysio_system::test_support::push_contract_action( @@ -408,6 +458,15 @@ class sysio_msgch_chain_tester : public tester { ("data", data)); } + /// Register `owner` as Tier-1 after the empty-ROA path has soft-declined a dispute. + void register_tier_one_node_owner(name owner) { + create_account(owner, SYSIO_ACCOUNT, false, true, false); + const uint8_t tier_one = magic_enum::enum_integer( + opp::types::NodeOwnerTier::NODE_OWNER_TIER_T1); + BOOST_REQUIRE_EQUAL(success(), push(ROA_ACCOUNT, roa_abi, ROA_ACCOUNT, roa_actions::FORCE_REGISTER, + mvo()(roa_fields::OWNER, owner.to_string())(roa_fields::TIER, tier_one))); + } + /// Let the consensus boundary elapse WITHOUT advancing the epoch: evalcons' fallback /// (majority) path opens once `epoch_duration_sec` has passed since the epoch started, /// while `deliver`'s epoch gate keeps accepting envelopes for the still-current epoch. @@ -454,6 +513,13 @@ class sysio_msgch_chain_tester : public tester { abi_serializer::create_yield_function(abi_serializer_max_time)); } + /// Return whether the epoch contract currently holds advancement for an open dispute. + bool epoch_is_paused() { + auto state = read_epoch_state(); + BOOST_REQUIRE(!state.is_null()); + return state[epoch_fields::IS_PAUSED].as(); + } + /// 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); @@ -778,7 +844,7 @@ class sysio_msgch_chain_tester : public tester { produce_blocks(); } - abi_serializer sysio_abi, token_abi, epoch_abi, opreg_abi, msgch_abi, chalg_abi, chains_abi, uwrit_abi; + abi_serializer sysio_abi, token_abi, epoch_abi, opreg_abi, msgch_abi, chalg_abi, chains_abi, uwrit_abi, roa_abi; }; // --------------------------------------------------------------------------- @@ -1725,9 +1791,9 @@ BOOST_FIXTURE_TEST_CASE(chkcons_opens_dispute_for_three_to_three_tie, sysio_msgc resolve_tie_dispute(epoch, fc::sha256::hash(left.data(), left.size())); } FC_LOG_AND_RETHROW() } -/// A 1-1 split of three eligible operators is already a post-boundary anomaly. It must open a -/// Tier-1 dispute even when the third operator remains silent, rather than stalling the epoch. -BOOST_FIXTURE_TEST_CASE(chkcons_opens_dispute_for_incomplete_two_way_split, +/// A 1-1 split with one silent eligible operator is not terminal. `chkcons` must leave it +/// undecided and unpaused so the final delivery can establish a strict majority without a vote. +BOOST_FIXTURE_TEST_CASE(chkcons_waits_for_incomplete_two_way_split, sysio_msgch_chain_tester) { try { bootstrap(INCOMPLETE_TWO_WAY_SPLIT_GROUP_SIZE); @@ -1735,7 +1801,6 @@ BOOST_FIXTURE_TEST_CASE(chkcons_opens_dispute_for_incomplete_two_way_split, 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()); - const auto right_checksum = fc::sha256::hash(right.data(), right.size()); BOOST_REQUIRE_EQUAL(success(), deliver_as(BATCHOP, ETH_OUTPOST_ID, left)); produce_blocks(); @@ -1745,10 +1810,56 @@ BOOST_FIXTURE_TEST_CASE(chkcons_opens_dispute_for_incomplete_two_way_split, elapse_epoch_boundary(); advance_via_consensus(); + BOOST_REQUIRE(get_dispute(FIRST_DISPUTE_ID).is_null()); + BOOST_REQUIRE(!epoch_is_paused()); + auto opc = get_outpcons(ETH_OUTPOST_ID); + BOOST_REQUIRE(opc.is_null() || !opc[msgch_fields::CONSENSUS_REACHED].as()); + + BOOST_REQUIRE_EQUAL(success(), deliver_as(BATCHOP_C, ETH_OUTPOST_ID, left)); + produce_blocks(); + + opc = get_outpcons(ETH_OUTPOST_ID); + BOOST_REQUIRE(!opc.is_null()); + BOOST_REQUIRE_EQUAL(opc[msgch_fields::CONSENSUS_REACHED].as(), true); + BOOST_REQUIRE_EQUAL(opc[msgch_fields::EPOCH_INDEX].as(), epoch); + BOOST_REQUIRE_EQUAL(opc[msgch_fields::WINNING_CHECKSUM].as_string(), left_checksum.str()); +} FC_LOG_AND_RETHROW() } + +/// A terminal tie with no Tier-1 electorate must soft-decline through `chkcons`, leaving the epoch +/// unpaused and retryable. Once a Tier-1 owner registers, the same crank opens the dispute. +struct sysio_msgch_empty_roa_tester : sysio_msgch_chain_tester { + sysio_msgch_empty_roa_tester() : sysio_msgch_chain_tester(/*empty_roa=*/true) {} +}; + +BOOST_FIXTURE_TEST_CASE(chkcons_retries_terminal_tie_after_tier_one_registration, + sysio_msgch_empty_roa_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(); + + // The terminal conflicting delivery arrives after the boundary. The msgch preflight must + // soft-decline instead of reverting this delivery or pausing the epoch. + elapse_epoch_boundary(); + BOOST_REQUIRE_EQUAL(success(), deliver_as(BATCHOP_B, ETH_OUTPOST_ID, right)); + produce_blocks(); + + advance_via_consensus(); + + BOOST_REQUIRE(get_dispute(FIRST_DISPUTE_ID).is_null()); + BOOST_REQUIRE(!epoch_is_paused()); + + register_tier_one_node_owner(TIER_ONE_OWNER); + advance_via_consensus(); + assert_open_tie_dispute(epoch, - {left_checksum, right_checksum}, + {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, 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 From 234abff294a5ddeca2832bc0617bef7d007b90fa Mon Sep 17 00:00:00 2001 From: Huang-Ming Huang Date: Fri, 28 Aug 2026 01:13:26 +0000 Subject: [PATCH 4/4] docs: clarify dispute opening eligibility Change-Id: Id8df1153a715b5ddcd76a85e7c95536c4efe24b1 --- contracts/sysio.chalg/README.md | 10 ++++++---- .../sysio.chalg/include/sysio.chalg/sysio.chalg.hpp | 10 ++++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/contracts/sysio.chalg/README.md b/contracts/sysio.chalg/README.md index 3e56476a18..89a10a1c91 100644 --- a/contracts/sysio.chalg/README.md +++ b/contracts/sysio.chalg/README.md @@ -5,8 +5,9 @@ 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` 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 + consensus rules in `sysio.msgch` see a post-boundary no-strict-majority split for one + (outpost, epoch): a two-version split only after every eligible operator has delivered, or a + three-or-more-version split even when eligible operators are 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,8 +31,9 @@ OPP envelope dispute resolution and slash-execution contract. ## Dispute-vote flow -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 +1. **Open**: `sysio.msgch::evalcons` calls `opendispute` inline for an eligible post-boundary + no-majority split with at least two versions. A two-version split must be terminal (every + eligible operator delivered); a three-or-more-version split may open at the 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 diff --git a/contracts/sysio.chalg/include/sysio.chalg/sysio.chalg.hpp b/contracts/sysio.chalg/include/sysio.chalg/sysio.chalg.hpp index a38fb2a5fa..55b9f43220 100644 --- a/contracts/sysio.chalg/include/sysio.chalg/sysio.chalg.hpp +++ b/contracts/sysio.chalg/include/sysio.chalg/sysio.chalg.hpp @@ -221,10 +221,12 @@ namespace sysio { SYSLIB_SERIALIZE(dispute_key, (id)) }; - /// 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). + /// OPP envelope dispute. Opened for an eligible post-boundary no-majority split with at least + /// two versions for one (outpost, epoch): exactly two versions require every eligible operator + /// to deliver, while a three-or-more-version split may open at the boundary. 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