Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions contracts/sysio.chalg/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
29 changes: 20 additions & 9 deletions contracts/sysio.chalg/include/sysio.chalg/sysio.chalg.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
14 changes: 9 additions & 5 deletions contracts/sysio.chalg/src/sysio.chalg.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -244,8 +248,8 @@ void chalg::opendispute(uint64_t chain_code,
uint32_t epoch_index,
std::vector<dispute_candidate> 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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM — an empty Tier-1 electorate now reverts the enclosing delivery transaction.

opendispute is an inline action of evalcons, which is itself inline from deliver and from chkcons. So check(!electorate.empty(), ...) does not just decline the dispute — it reverts the whole enclosing transaction.

The in-place comment justifies the assert on the grounds that "the conflicting deliveries keep this epoch from reaching consensus regardless." That held under the 3-candidate floor, where a dispute-eligible split was already unresolvable. It no longer holds: with the floor at 2, a dispute can now open on a split that a further delivery would have resolved into a clean majority. Once that state exists and the electorate is empty — e.g. during a roa generation rotation before Tier-1 owners re-register — every subsequent deliver and chkcons for that bucket reverts, including the delivery that would have produced the majority.

Suggest checking the electorate on the msgch side in maybe_open_dispute and printing-and-returning, matching the silent-return diagnostic convention the other branches in this function already use.

DISPUTE_REQUIRES_TWO_CANDIDATES);

disputes_t disputes(get_self());

Expand All @@ -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<uint32_t>(electorate.size()) / 2 + 1;

Expand Down
Binary file modified contracts/sysio.chalg/sysio.chalg.wasm
Binary file not shown.
1 change: 1 addition & 0 deletions contracts/sysio.msgch/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ if(BUILD_SYSTEM_CONTRACTS)
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/../sysio.system/include>
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/../sysio.chalg/include>
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/../sysio.opreg/include>
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/../sysio.roa>
)

target_link_libraries(${target}
Expand Down
66 changes: 53 additions & 13 deletions contracts/sysio.msgch/src/sysio.msgch.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#include <sysio.chains/sysio.chains.hpp>
#include <sysio.chalg/sysio.chalg.hpp> // dispute trigger + open-dispute gate (disputes table)
#include <sysio.opreg/sysio.opreg.hpp> // operator-status delivery gate (operators table)
#include <sysio.roa.hpp> // authoritative Tier-1 electorate preflight
#include <sysio.opp.common/slug_name.hpp>
#include <sysio.opp.common/safe_ops.hpp> // to_depot_amount — WSA-028 fail-closed TokenAmount gate
#include <sysio.opp.common/name_ops.hpp> // parse_wire_account_name — never-throw account-name parse
Expand All @@ -12,6 +13,7 @@
#include <sysio/opp/opp.pb.hpp>
#include <sysio/opp/attestations/attestations.pb.hpp>
#include <zpp_bits.h>
#include <magic_enum/magic_enum.hpp>
#include <algorithm>
#include <optional>

Expand All @@ -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 {

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1175,21 +1193,22 @@ void dispatch_attestation(name self, uint64_t attestation_id,
return true;
}

/// Evaluate the dispute trigger and, if met, open a Tier-1 dispute vote via sysio.chalg. Trigger:
/// the epoch boundary has passed, 3+ distinct envelope versions exist, and no version holds a
/// majority of the operator group. A majority — even within a 3+-way split — resolves without a
/// vote, so it is not a trigger; a sub-3-way or pre-boundary split just waits for more deliveries.
/// Evaluate the dispute trigger and, if met, open a Tier-1 dispute vote via sysio.chalg. A two-way
/// split is actionable only after every eligible operator has delivered, preserving the chance for
/// silent operators to create a strict majority. An existing three-or-more-version split remains
/// actionable at the boundary. A strict majority, one-version split, or pre-boundary split waits.
void maybe_open_dispute(name self, uint64_t chain_code, uint32_t epoch_index,
uint32_t group_size,
uint32_t group_size, uint32_t total_deliveries,
const std::vector<checksum256>& seen_checksums,
const std::vector<uint32_t>& checksum_counts,
const std::vector<std::vector<name>>& checksum_operators) {
// OPP silent-return diagnostics: each branch below silently declines to open a
// dispute. Logged (visible under --contracts-console) so "the dispute never
// opened" is greppable instead of a black hole.
if (seen_checksums.size() < 3) {
sysio::print_f("msgch::maybe_open_dispute: no dispute for (chain=%llu, epoch=%u): %u distinct version(s), a vote needs >=3\n",
chain_code, epoch_index, (uint32_t)seen_checksums.size());
if (seen_checksums.size() < chalg_limits::minimum_dispute_candidate_versions) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM — the candidate set freezes before the eligible group has finished delivering, and the right envelope can be excluded from its own ballot.

The gate here is candidate-count + past-boundary + no-majority. Nothing checks that the outstanding eligible operators have delivered, and total_deliveries is not even passed in.

That matters because the ballot is final once it opens. evalcons returns immediately when a dispute row exists for the (outpost, epoch)"a late delivery cannot re-open the dispute or re-dispatch the envelope" — so the candidate list is frozen at whatever had arrived.

Concretely, with a 5-operator group: two lagging or faulty operators deliver X and Y shortly past the boundary, before the honest three deliver Z. Any chkcons crank in that window opens a dispute on {X, Y}. Tier-1 is now asked to choose between two versions, neither of which is the one an honest majority was about to produce, and Z can never be added. The three honest operators then hold a checksum that does not match the winner.

This is not new — the same shape was reachable at the 3-candidate floor with three divergent early deliveries — but the floor of 2 makes it reachable with two, and deliver has no boundary cutoff, so the window is real rather than theoretical.

The ticket's total_deliveries == group_size condition closes it: the ballot cannot be frozen while an eligible operator can still change the outcome. If the intent is instead that a post-boundary two-way split is anomalous enough to adjudicate even with outstanding deliverers, that is a defensible call — but it is the opposite of the plan recorded in WIRE-362, and the ticket should say so rather than leaving the superseded version standing.

sysio::print_f(DISPUTE_TOO_FEW_CANDIDATES_LOG,
chain_code, epoch_index, (uint32_t)seen_checksums.size(),
chalg_limits::minimum_dispute_candidate_versions);
return;
}

Expand All @@ -1212,6 +1231,26 @@ void maybe_open_dispute(name self, uint64_t chain_code, uint32_t epoch_index,
return;
}

// A two-way split is not terminal while an eligible operator remains silent: that operator can
// still establish a strict majority. Three-or-more candidate splits intentionally retain the
// existing post-boundary dispute behavior.
if (seen_checksums.size() == chalg_limits::minimum_dispute_candidate_versions &&
total_deliveries != group_size) {
sysio::print_f(DISPUTE_INCOMPLETE_TWO_WAY_LOG,
chain_code, epoch_index, total_deliveries, group_size);
return;
}

// `opendispute` defensively asserts this invariant, but this user-triggered evalcons route
// must remain retryable: a missing Tier-1 electorate must not revert the terminal delivery or
// pause the epoch before a node owner has registered.
const uint8_t network_gen = roa::current_network_gen(ROA_ACCOUNT);
const uint8_t tier_one = magic_enum::enum_integer(NodeOwnerTier::NODE_OWNER_TIER_T1);
if (roa::nodeowner_count(ROA_ACCOUNT, network_gen, tier_one) == 0) {
sysio::print_f(DISPUTE_NO_TIER_ONE_ELECTORATE_LOG, chain_code, epoch_index);
return;
}

std::vector<chalg::dispute_candidate> candidates;
candidates.reserve(seen_checksums.size());
for (size_t g = 0; g < seen_checksums.size(); ++g) {
Expand Down Expand Up @@ -1440,8 +1479,8 @@ void msgch::evalcons(uint64_t chain_code, uint32_t epoch_index) {
};

// Group envelopes by checksum, tracking the operators that delivered each version (CDT-compatible
// parallel vectors). The per-version operator lists become the dispute candidates on a 3+-way
// split.
// parallel vectors). The per-version operator lists become dispute candidates only for an
// all-delivered two-way tie or an existing multi-version split.
std::vector<checksum256> seen_checksums;
std::vector<uint32_t> checksum_counts;
std::vector<std::vector<char>> checksum_data;
Expand Down Expand Up @@ -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;
}
Expand Down
Binary file modified contracts/sysio.msgch/sysio.msgch.wasm
Binary file not shown.
18 changes: 14 additions & 4 deletions contracts/tests/sysio.dispute_tests.cpp
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/// Contract tests for the OPP envelope dispute vote (sysio.chalg dispute-vote flow).
///
/// Covers the new chalg actions in isolation and against a minimally-bootstrapped OPP stack:
/// * opendispute -- auth (sysio.msgch), >=3 candidates, no duplicate (outpost,epoch), pauses
/// * opendispute -- auth (sysio.msgch), >=2 candidates, no duplicate (outpost,epoch), pauses
/// epoch, snapshots the Tier-1 electorate + quorum from sysio.roa::nodeowners
/// (rejecting an empty electorate)
/// * votedispute -- electorate-snapshot eligibility (the Tier-1 set frozen at open; later
Expand Down Expand Up @@ -479,14 +479,24 @@ BOOST_FIXTURE_TEST_CASE(opendispute_requires_msgch_auth, sysio_dispute_tester) {
opendispute(eth_code(), current_epoch(), cands, /*signer=*/"voter1"_n));
} FC_LOG_AND_RETHROW() }

BOOST_FIXTURE_TEST_CASE(opendispute_requires_three_candidates, sysio_dispute_tester) { try {
/// A two-version tie has no automatic majority, so chalg must accept it as an adjudicable dispute.
BOOST_FIXTURE_TEST_CASE(opendispute_accepts_two_candidates, sysio_dispute_tester) { try {
std::vector<fc::variant> two{
candidate(fc::sha256::hash(std::string("a")), {BATCHOP}),
candidate(fc::sha256::hash(std::string("b")), {"voter1"_n}),
};
BOOST_REQUIRE_EQUAL(success(), opendispute(eth_code(), current_epoch(), two));
BOOST_REQUIRE_EQUAL(two.size(), get_dispute(1)["candidates"].get_array().size());
} FC_LOG_AND_RETHROW() }

/// One envelope version has no competing candidate, so chalg must retain the two-version floor.
BOOST_FIXTURE_TEST_CASE(opendispute_rejects_one_candidate, sysio_dispute_tester) { try {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW — this test no longer pins the invariant it exists for.

The replacement asserts opendispute(...) != success(), which passes for any failure: a wrong assertion message, an auth failure, a duplicate-dispute rejection, or a fixture regression that makes the push fail for an unrelated reason. The previous version asserted the exact message, which is what made it a test of the candidate floor specifically.

Since the floor is the invariant this PR changes, it is worth keeping it pinned — assert the exact new message the way the old test asserted the old one.

std::vector<fc::variant> one{
candidate(fc::sha256::hash(std::string("a")), {BATCHOP}),
};
BOOST_REQUIRE_EQUAL(
error("assertion failure with message: a dispute requires at least 3 candidate envelope versions"),
opendispute(eth_code(), current_epoch(), two));
error("assertion failure with message: a dispute requires at least two candidate envelope versions"),
opendispute(eth_code(), current_epoch(), one));
} FC_LOG_AND_RETHROW() }

BOOST_FIXTURE_TEST_CASE(opendispute_rejects_duplicate, sysio_dispute_tester) { try {
Expand Down
Loading
Loading