diff --git a/contracts/sysio.chalg/README.md b/contracts/sysio.chalg/README.md index cdd4addb25..89a10a1c91 100644 --- a/contracts/sysio.chalg/README.md +++ b/contracts/sysio.chalg/README.md @@ -5,7 +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` cannot (a 3+-way split with no majority for one (outpost, epoch)) + 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 @@ -29,16 +31,18 @@ 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 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 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 07821ec9f3..55b9f43220 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,13 +48,16 @@ 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 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, @@ -212,10 +221,12 @@ 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 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 diff --git a/contracts/sysio.chalg/src/sysio.chalg.cpp b/contracts/sysio.chalg/src/sysio.chalg.cpp index 2879c0b901..9631d47bab 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()); @@ -265,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.chalg/sysio.chalg.wasm b/contracts/sysio.chalg/sysio.chalg.wasm index 9035e88c4a..651fb21d30 100755 Binary files a/contracts/sysio.chalg/sysio.chalg.wasm and b/contracts/sysio.chalg/sysio.chalg.wasm differ 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 01a0e5a3dd..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 { @@ -87,6 +90,21 @@ constexpr size_t ENVELOPE_BASELINE_BYTES = 512; constexpr const char* UIC_DISPATCH_REJECTED_LOG_PREFIX = "UIC_DISPATCH_REJECTED"; +/// Diagnostic for a split with fewer competing versions than a Tier-1 vote can adjudicate. +constexpr const char* DISPUTE_TOO_FEW_CANDIDATES_LOG = + "msgch::maybe_open_dispute: no dispute for (chain=%llu, epoch=%u): " + "%u distinct version(s), a vote needs >=%u\n"; + +/// Diagnostic for a two-version split that can still acquire a strict majority from silent operators. +constexpr const char* DISPUTE_INCOMPLETE_TWO_WAY_LOG = + "msgch::maybe_open_dispute: no dispute for (chain=%llu, epoch=%u): " + "two versions but only %u of %u eligible operators delivered\n"; + +/// Diagnostic for a terminal split that cannot be voted on until Tier-1 registration exists. +constexpr const char* DISPUTE_NO_TIER_ONE_ELECTORATE_LOG = + "msgch::maybe_open_dispute: no dispute for (chain=%llu, epoch=%u): " + "no registered tier-1 node owners\n"; + uint32_t current_epoch_index() { epoch::epochstate_t tbl(EPOCH_ACCOUNT); return tbl.exists() ? tbl.get().current_epoch_index : 0; @@ -1175,21 +1193,22 @@ void dispatch_attestation(name self, uint64_t attestation_id, return true; } -/// Evaluate the dispute trigger and, if met, open a Tier-1 dispute vote via sysio.chalg. Trigger: -/// the epoch boundary has passed, 3+ distinct envelope versions exist, and no version holds a -/// majority of the operator group. A majority — even within a 3+-way split — resolves without a -/// vote, so it is not a trigger; a sub-3-way or pre-boundary split just waits for more deliveries. +/// Evaluate the dispute trigger and, if met, open a Tier-1 dispute vote via sysio.chalg. A two-way +/// split is actionable only after every eligible operator has delivered, preserving the chance for +/// silent operators to create a strict majority. An existing three-or-more-version split remains +/// actionable at the boundary. A strict majority, one-version split, or pre-boundary split waits. void maybe_open_dispute(name self, uint64_t chain_code, uint32_t epoch_index, - uint32_t group_size, + uint32_t group_size, uint32_t total_deliveries, const std::vector& 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; } @@ -1212,6 +1231,26 @@ void maybe_open_dispute(name self, uint64_t chain_code, uint32_t epoch_index, return; } + // A two-way split is not terminal while an eligible operator remains silent: that operator can + // still establish a strict majority. Three-or-more candidate splits intentionally retain the + // existing post-boundary dispute behavior. + if (seen_checksums.size() == chalg_limits::minimum_dispute_candidate_versions && + total_deliveries != group_size) { + sysio::print_f(DISPUTE_INCOMPLETE_TWO_WAY_LOG, + chain_code, epoch_index, total_deliveries, group_size); + return; + } + + // `opendispute` defensively asserts this invariant, but this user-triggered evalcons route + // must remain retryable: a missing Tier-1 electorate must not revert the terminal delivery or + // pause the epoch before a node owner has registered. + const uint8_t network_gen = roa::current_network_gen(ROA_ACCOUNT); + const uint8_t tier_one = magic_enum::enum_integer(NodeOwnerTier::NODE_OWNER_TIER_T1); + if (roa::nodeowner_count(ROA_ACCOUNT, network_gen, tier_one) == 0) { + sysio::print_f(DISPUTE_NO_TIER_ONE_ELECTORATE_LOG, chain_code, epoch_index); + return; + } + std::vector candidates; candidates.reserve(seen_checksums.size()); for (size_t g = 0; g < seen_checksums.size(); ++g) { @@ -1440,8 +1479,8 @@ void msgch::evalcons(uint64_t chain_code, uint32_t epoch_index) { }; // Group envelopes by checksum, tracking the operators that delivered each version (CDT-compatible - // parallel vectors). The per-version operator lists become the dispute candidates on a 3+-way - // split. + // parallel vectors). The per-version operator lists become dispute candidates only for an + // all-delivered two-way tie or an existing multi-version split. std::vector seen_checksums; std::vector checksum_counts; std::vector> checksum_data; @@ -1498,9 +1537,10 @@ void msgch::evalcons(uint64_t chain_code, uint32_t epoch_index) { } if (!consensus_reached) { - // No automatic consensus. On a 3+-way no-majority split past the epoch boundary, open a - // Tier-1 dispute vote; a smaller or pre-boundary split just waits for more deliveries. - maybe_open_dispute(get_self(), chain_code, epoch_index, group_size, + // No automatic consensus. A terminal two-way tie or a three-or-more-version no-majority + // split past the epoch boundary opens a Tier-1 dispute vote; all other cases wait for more + // deliveries. + maybe_open_dispute(get_self(), chain_code, epoch_index, group_size, total_deliveries, seen_checksums, checksum_counts, checksum_operators); return; } diff --git a/contracts/sysio.msgch/sysio.msgch.wasm b/contracts/sysio.msgch/sysio.msgch.wasm index 53b337ec29..b581f28a4f 100755 Binary files a/contracts/sysio.msgch/sysio.msgch.wasm and b/contracts/sysio.msgch/sysio.msgch.wasm differ diff --git a/contracts/tests/sysio.dispute_tests.cpp b/contracts/tests/sysio.dispute_tests.cpp index 981c4aa088..5a221122fa 100644 --- a/contracts/tests/sysio.dispute_tests.cpp +++ b/contracts/tests/sysio.dispute_tests.cpp @@ -1,7 +1,7 @@ /// Contract tests for the OPP envelope dispute vote (sysio.chalg dispute-vote flow). /// /// Covers the new chalg actions in isolation and against a minimally-bootstrapped OPP stack: -/// * opendispute -- auth (sysio.msgch), >=3 candidates, no duplicate (outpost,epoch), pauses +/// * opendispute -- auth (sysio.msgch), >=2 candidates, no duplicate (outpost,epoch), pauses /// epoch, snapshots the Tier-1 electorate + quorum from sysio.roa::nodeowners /// (rejecting an empty electorate) /// * votedispute -- electorate-snapshot eligibility (the Tier-1 set frozen at open; later @@ -479,14 +479,24 @@ BOOST_FIXTURE_TEST_CASE(opendispute_requires_msgch_auth, sysio_dispute_tester) { opendispute(eth_code(), current_epoch(), cands, /*signer=*/"voter1"_n)); } FC_LOG_AND_RETHROW() } -BOOST_FIXTURE_TEST_CASE(opendispute_requires_three_candidates, sysio_dispute_tester) { try { +/// A two-version tie has no automatic majority, so chalg must accept it as an adjudicable dispute. +BOOST_FIXTURE_TEST_CASE(opendispute_accepts_two_candidates, sysio_dispute_tester) { try { std::vector two{ candidate(fc::sha256::hash(std::string("a")), {BATCHOP}), candidate(fc::sha256::hash(std::string("b")), {"voter1"_n}), }; + BOOST_REQUIRE_EQUAL(success(), opendispute(eth_code(), current_epoch(), two)); + BOOST_REQUIRE_EQUAL(two.size(), get_dispute(1)["candidates"].get_array().size()); +} FC_LOG_AND_RETHROW() } + +/// One envelope version has no competing candidate, so chalg must retain the two-version floor. +BOOST_FIXTURE_TEST_CASE(opendispute_rejects_one_candidate, sysio_dispute_tester) { try { + std::vector one{ + candidate(fc::sha256::hash(std::string("a")), {BATCHOP}), + }; BOOST_REQUIRE_EQUAL( - error("assertion failure with message: a dispute requires at least 3 candidate envelope versions"), - opendispute(eth_code(), current_epoch(), two)); + error("assertion failure with message: a dispute requires at least two candidate envelope versions"), + opendispute(eth_code(), current_epoch(), one)); } FC_LOG_AND_RETHROW() } BOOST_FIXTURE_TEST_CASE(opendispute_rejects_duplicate, sysio_dispute_tester) { try { diff --git a/contracts/tests/sysio.msgch_chain_tests.cpp b/contracts/tests/sysio.msgch_chain_tests.cpp index 2e4a21eac5..49f0144484 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 { @@ -114,14 +120,73 @@ 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. +namespace chalg_tables { +constexpr name DISPUTES = "disputes"_n; +} // namespace chalg_tables + +/// sysio.chalg action identifiers used by the two-version split 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 two-version split 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 +/// 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 // --------------------------------------------------------------------------- @@ -139,13 +204,28 @@ 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; + 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; - - sysio_msgch_chain_tester() { + static constexpr uint32_t ONE_TO_ONE_TIE_GROUP_SIZE = 2; + 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; + 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 @@ -153,9 +233,10 @@ 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 - }); + }, false, true, !empty_roa); produce_blocks(2); deploy(CHALG_ACCOUNT, contracts::chalg_wasm(), contracts::chalg_abi(), chalg_abi); @@ -167,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()); @@ -207,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( @@ -242,9 +339,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 +381,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() @@ -363,6 +459,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. @@ -409,6 +514,69 @@ 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); + 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 two-version split'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()); + } + /// 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, @@ -677,7 +845,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; }; // --------------------------------------------------------------------------- @@ -1569,6 +1737,132 @@ 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() } + +/// 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); + + 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_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, + {fc::sha256::hash(left.data(), left.size()), + fc::sha256::hash(right.data(), right.size())}, + {ONE_OPERATOR_PER_TIED_VERSION, ONE_OPERATOR_PER_TIED_VERSION}); +} 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: