diff --git a/contracts/sysio.epoch/src/sysio.epoch.cpp b/contracts/sysio.epoch/src/sysio.epoch.cpp index 3f4e9dd23b..e8711f0935 100644 --- a/contracts/sysio.epoch/src/sysio.epoch.cpp +++ b/contracts/sysio.epoch/src/sysio.epoch.cpp @@ -630,16 +630,8 @@ void epoch::advance() { ).send(); } - // NOTE: we intentionally do NOT erase the per-batch-op envelope - // metadata rows here. `evalcons` already cleared their heavy - // `raw_data` (1-2 KB → 0 bytes) at consensus reach, so the residual - // weight is just the tuple `(id, chain_code, epoch_index, - // batch_op_name, checksum, ...)` — small and bounded by group - // membership × outposts × retained-epochs. A dedicated bounded- - // retention sweep belongs in a separate periodic ix; trying to - // erase here races with the permissionless `chkcons` → - // inline-`advance` pattern that fires from every batchop every - // cron tick and trips kv-index-remove on already-evicted buckets. + // The envelope rows read above are left in place: `sysio.msgch::deliver` + // erases them once they fall out of its retention window. } const bool had_expiring_group = state.current_epoch_index > 0; @@ -1052,12 +1044,11 @@ void epoch::advance() { ).send(); } - // Working tables on `sysio.msgch` (`envelopes` / `messages` / - // `attestations` / `outenvelopes`) are now drained inline by the - // `evalcons` consensus-reach + `buildenv` write paths. The durable - // audit trail lives in the `envelope_log` table on the same contract, - // capped at `active_outposts * 2 * cfg.epoch_retention_envelope_log_count` - // and pruned head-first on overflow. No scheduled cleanup needed. + // No scheduled cleanup of `sysio.msgch::envelopes` is needed here: + // `deliver` prunes rows older than the previous epoch. The durable audit + // trail lives in the `envelope_log` table on the same contract, capped at + // `active_outposts * 2 * cfg.epoch_retention_envelope_log_count` + // and pruned head-first on overflow. } // --------------------------------------------------------------------------- diff --git a/contracts/sysio.msgch/include/sysio.msgch/sysio.msgch.hpp b/contracts/sysio.msgch/include/sysio.msgch/sysio.msgch.hpp index 07edff6ebc..dfdd17c2d1 100644 --- a/contracts/sysio.msgch/include/sysio.msgch/sysio.msgch.hpp +++ b/contracts/sysio.msgch/include/sysio.msgch/sysio.msgch.hpp @@ -109,20 +109,25 @@ namespace sysio { /// Inbound envelope delivery — one row per batch-op per outpost per epoch. /// Consensus is evaluated by comparing checksums across operators. + /// + /// Working state, not an audit trail (that is `envlog`). `deliver` prunes rows older than the + /// current and previous epoch (`INBOUND_ENVELOPE_RETENTION_EPOCHS` in `src/sysio.msgch.cpp`); + /// every on-chain reader only touches the current epoch. struct [[sysio::table("envelopes")]] envelope_entry { uint64_t id; uint64_t chain_code; uint32_t epoch_index; name batch_op_name; opp::types::ChainKind chain_kind; - checksum256 checksum; ///< sha256(raw_data) + checksum256 checksum; ///< sha256 of the delivered bytes + /// The delivered bytes. Cleared on the rows present when the epoch's winner is accepted and + /// never stored for a late confirmation of it; the retention prune bounds any others. std::vector raw_data; time_point received_at{}; uint128_t by_outpost_epoch() const { return opp::outpost_epoch_key(chain_code, epoch_index); } - uint64_t by_batch_op() const { return batch_op_name.value; } SYSLIB_SERIALIZE(envelope_entry, (id)(chain_code)(epoch_index)(batch_op_name)(chain_kind) @@ -131,9 +136,7 @@ namespace sysio { using envelopes_t = sysio::kv::table<"envelopes"_n, id_key, envelope_entry, sysio::kv::index<"byoutepoch"_n, - sysio::const_mem_fun>, - sysio::kv::index<"bybatchop"_n, - sysio::const_mem_fun> + sysio::const_mem_fun> >; /// Individual message extracted from a consensus-verified envelope. diff --git a/contracts/sysio.msgch/src/sysio.msgch.cpp b/contracts/sysio.msgch/src/sysio.msgch.cpp index cc2b095c89..efcfeeb01b 100644 --- a/contracts/sysio.msgch/src/sysio.msgch.cpp +++ b/contracts/sysio.msgch/src/sysio.msgch.cpp @@ -90,6 +90,18 @@ constexpr size_t ATTESTATION_OVERHEAD_BYTES = 24; /// + payload preamble, and a safety margin for `zpp::bits` length prefixes. constexpr size_t ENVELOPE_BASELINE_BYTES = 512; +/// Epochs of inbound `envelopes` rows kept: the current epoch, which every on-chain reader uses, and +/// the previous one, so a batch operator whose cached epoch lags by one still finds its delivery. +constexpr uint32_t INBOUND_ENVELOPE_RETENTION_EPOCHS = 2; + +/// Maximum expired `envelopes` rows a single `deliver` erases. Each delivery adds one row, so a +/// budget above one outpaces inserts and clears an epoch with few deliveries after a full one. +constexpr uint32_t ENVELOPE_PRUNE_BUDGET = 4; + +static_assert(INBOUND_ENVELOPE_RETENTION_EPOCHS >= 1, + "the prune must never reach the current epoch, which every reader depends on"); +static_assert(ENVELOPE_PRUNE_BUDGET > 1, "the prune must outpace the one row each delivery adds"); + /// Stable audit marker for a UIC rejected before it can reach `rcrdcommit`. constexpr const char* UIC_DISPATCH_REJECTED_LOG_PREFIX = "UIC_DISPATCH_REJECTED"; @@ -249,6 +261,24 @@ void write_envelope_log(name self, } } +/// Erase up to `ENVELOPE_PRUNE_BUDGET` inbound `envelopes` rows that fell out of the retention +/// window, oldest first. +/// +/// Primary-key order is epoch order: ids come from `available_primary_key()`, `deliver` only +/// accepts current-epoch envelopes, and rows leave only from the head, so the newest id is never +/// erased and ids never restart. The walk stops at the first retained row, so a call is O(budget). +void prune_expired_envelopes(name self, uint32_t current_epoch) { + if (current_epoch < INBOUND_ENVELOPE_RETENTION_EPOCHS) return; + const uint32_t newest_expired_epoch = current_epoch - INBOUND_ENVELOPE_RETENTION_EPOCHS; + + msgch::envelopes_t envs(self); + auto it = envs.begin(); + for (uint32_t erased = 0; erased < ENVELOPE_PRUNE_BUDGET && it != envs.end() && + it->epoch_index <= newest_expired_epoch; ++erased) { + it = envs.erase(std::move(it)); + } +} + /// Resolve `op_address` (chain-kind + raw pubkey bytes) to the operator's /// WIRE account name via `sysio.authex::links`'s `bypubkey` index. Returns /// `name{}` (zero) on miss — caller treats that as "operator not linked, @@ -1270,6 +1300,7 @@ void dispatch_attestation(name self, uint64_t attestation_id, // Drop heavy raw_data from each per-batch-op envelope row but KEEP the metadata tuple so // sysio.epoch::advance can still read per-op checksums + delivery for slash classification. + // `deliver` erases the rows once they leave the retention window. msgch::envelopes_t envs(self); std::vector ids_to_clear; auto modify_idx = envs.get_index<"byoutepoch"_n>(); @@ -1515,25 +1546,23 @@ void msgch::deliver(name batch_op_name, uint64_t chain_code, std::vector d // re-validation. Anything else -- including a divergent envelope arriving after acceptance -- // still validates against the advanced tip and reverts (fail closed: post-acceptance divergence // cannot open a dispute, so there is nothing to record it for). + bool late_confirmation = false; { - bool late_confirmation = false; - { - msgch::outpost_consensus_t opcons(get_self()); - auto opc_pk = msgch::outpost_consensus_key{chain_code}; - if (opcons.contains(opc_pk)) { - const auto row = opcons.get(opc_pk); - late_confirmation = row.epoch_index == epoch && row.consensus_reached && - row.winning_checksum == cs; - } - } - if (!late_confirmation) { - checksum256 ingress_digest{}; - checksum256 ingress_message_tip{}; - check(inbound_envelope_valid(get_self(), env_check, chain_code, epoch, ingress_digest, - ingress_message_tip), - "delivered envelope failed inbound-chain or semantic-header validation"); + msgch::outpost_consensus_t opcons(get_self()); + auto opc_pk = msgch::outpost_consensus_key{chain_code}; + if (opcons.contains(opc_pk)) { + const auto row = opcons.get(opc_pk); + late_confirmation = row.epoch_index == epoch && row.consensus_reached && + row.winning_checksum == cs; } } + if (!late_confirmation) { + checksum256 ingress_digest{}; + checksum256 ingress_message_tip{}; + check(inbound_envelope_valid(get_self(), env_check, chain_code, epoch, ingress_digest, + ingress_message_tip), + "delivered envelope failed inbound-chain or semantic-header validation"); + } // Store envelope uint64_t env_id = std::max(1, envs.available_primary_key()); @@ -1550,10 +1579,16 @@ void msgch::deliver(name batch_op_name, uint64_t chain_code, std::vector d // is authoritative; this is just the cached projection. .chain_kind = op_row.kind, .checksum = cs, - .raw_data = data, + // A late confirmation repeats this epoch's already-applied winner. `outpcons` records that + // acceptance, so `apply_consensus` never decodes this bucket's bytes again; `advance` needs + // only the metadata. + .raw_data = late_confirmation ? std::vector{} : std::move(data), .received_at = current_time_point(), }); + // After the emplace, so the newest id always survives (see prune_expired_envelopes). + prune_expired_envelopes(get_self(), epoch); + // Evaluate consensus inline action( permission_level{get_self(), "active"_n}, @@ -1785,7 +1820,8 @@ void msgch::resolvedisp(uint64_t chain_code, uint32_t epoch_index, checksum256 w // Locate the winning envelope's raw bytes among this (outpost, epoch)'s deliveries. The dispute // path never cleared raw_data (evalcons returned early before the consensus cleanup), so the - // bytes are still on file. Copy them out before apply_consensus drains the rows. + // bytes are still on file. Copy them out before apply_consensus drains the rows. `deliver`'s + // retention prune never reaches them: an open dispute pauses the epoch, so it is still current. envelopes_t envs(get_self()); auto oe_idx = envs.get_index<"byoutepoch"_n>(); uint128_t composite = opp::outpost_epoch_key(chain_code, epoch_index); diff --git a/contracts/sysio.msgch/sysio.msgch.abi b/contracts/sysio.msgch/sysio.msgch.abi index d273339ee9..d54a6586cf 100644 --- a/contracts/sysio.msgch/sysio.msgch.abi +++ b/contracts/sysio.msgch/sysio.msgch.abi @@ -478,11 +478,6 @@ "name": "byoutepoch", "key_type": "uint128", "table_id": 57096 - }, - { - "name": "bybatchop", - "key_type": "uint64", - "table_id": 5961 } ] }, diff --git a/contracts/sysio.msgch/sysio.msgch.wasm b/contracts/sysio.msgch/sysio.msgch.wasm index b63cd3859d..b09bf4cc69 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.msgch_chain_tests.cpp b/contracts/tests/sysio.msgch_chain_tests.cpp index 9b92d34645..6e06e2b82c 100644 --- a/contracts/tests/sysio.msgch_chain_tests.cpp +++ b/contracts/tests/sysio.msgch_chain_tests.cpp @@ -27,6 +27,7 @@ #include #include #include +#include // kv_index / kv_index_index for the envelopes retention walk #include #include @@ -65,6 +66,11 @@ 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"; +/// Mirrors of the inbound `envelopes` retention constants in `sysio.msgch.cpp` (contract headers +/// are not host-compilable). +constexpr uint32_t INBOUND_ENVELOPE_RETENTION_EPOCHS = 2; +constexpr uint32_t ENVELOPE_PRUNE_BUDGET = 4; + /// sysio.opreg action identifiers used by the WNS-16 fixture. namespace opreg_actions { constexpr name SET_CONFIG = "setconfig"_n; @@ -104,6 +110,11 @@ namespace msgch_tables { constexpr name ENVELOPES = "envelopes"_n; } // namespace msgch_tables +/// sysio.msgch secondary-index identifiers used by the envelopes retention tests. +namespace msgch_indexes { +constexpr name BY_OUTPOST_EPOCH = "byoutepoch"_n; +} // namespace msgch_indexes + /// sysio.msgch ABI type identifiers used by the WNS-16 fixture. namespace msgch_abi_types { constexpr const char* ENVELOPE_ENTRY = "envelope_entry"; @@ -111,6 +122,8 @@ constexpr const char* ENVELOPE_ENTRY = "envelope_entry"; /// sysio.msgch ABI field identifiers used by the WNS-16 fixture. namespace msgch_fields { +constexpr const char* ID = "id"; +constexpr const char* RAW_DATA = "raw_data"; constexpr const char* CHAIN_CODE = "chain_code"; constexpr const char* EPOCH_INDEX = "epoch_index"; constexpr const char* BATCH_OP_NAME = "batch_op_name"; @@ -590,6 +603,88 @@ class sysio_msgch_chain_tester : public tester { return fc::variant{}; } + /// Every inbound `envelopes` row in primary-key order, read straight from chainbase so the walk + /// does not depend on ids staying inside a fixed probe range. + std::vector envelope_rows() { + const auto table_id = compute_table_id(msgch_tables::ENVELOPES.to_uint64_t()); + const auto& kv_idx = control->db().get_index(); + std::vector rows; + for (auto itr = kv_idx.lower_bound(boost::make_tuple(MSGCH_ACCOUNT, table_id, std::string_view{})); + itr != kv_idx.end() && itr->code == MSGCH_ACCOUNT && itr->table_id == table_id; ++itr) { + std::vector raw(itr->value.size()); + if (!raw.empty()) std::memcpy(raw.data(), itr->value.data(), raw.size()); + rows.push_back(msgch_abi.binary_to_variant( + msgch_abi_types::ENVELOPE_ENTRY, raw, + abi_serializer::create_yield_function(abi_serializer_max_time))); + } + return rows; + } + + /// `envelopes` rows recorded for `epoch_index`, across outposts and operators. + uint32_t envelope_rows_for_epoch(uint32_t epoch_index) { + uint32_t n = 0; + for (const auto& row : envelope_rows()) { + if (row[msgch_fields::EPOCH_INDEX].as() == epoch_index) ++n; + } + return n; + } + + /// Stored `byoutepoch` secondary entries of `envelopes`; equals the row count unless an erase + /// left an orphan behind. + size_t envelope_index_entries() { + const auto table_id = compute_sec_table_id(msgch_tables::ENVELOPES.to_uint64_t(), + msgch_indexes::BY_OUTPOST_EPOCH.to_uint64_t()); + const auto& idx = control->db().get_index(); + size_t n = 0; + for (auto itr = idx.lower_bound(boost::make_tuple(MSGCH_ACCOUNT, table_id)); + itr != idx.end() && itr->code == MSGCH_ACCOUNT && itr->table_id == table_id; ++itr) { + ++n; + } + return n; + } + + /// Chain tip of the inbound stream when a test feeds both outposts identical envelopes: the next + /// delivery continues from the last accepted envelope's digest and message id (raw 32-byte + /// strings, empty at stream genesis). + struct inbound_stream_tip { + std::string envelope_digest; + std::string message_id; + }; + + /// Current-epoch envelope carrying `payload`, chained from `tip`. + std::vector encode_chained_delivery(std::string_view payload, const inbound_stream_tip& tip) { + return encode_delivery(current_epoch(), std::string(payload), tip.envelope_digest, + tip.message_id); + } + + /// `tip` after `envelope` is accepted. + inbound_stream_tip next_stream_tip(const std::vector& envelope) { + return {oracle::digest_bytes(oracle::epoch_digest(decode_envelope(envelope))), + delivery_message_id(envelope)}; + } + + /// Every operator in `ops` delivers the same chained envelope to both outposts, so each outpost + /// reaches unanimous consensus for the current epoch; `tip` moves to the delivered envelope. + void deliver_unanimous_epoch(const std::vector& ops, std::string_view payload, + inbound_stream_tip& tip) { + const auto envelope = encode_chained_delivery(payload, tip); + for (const uint64_t outpost : {ETH_OUTPOST_ID, SOL_OUTPOST_ID}) { + for (const auto& op : ops) { + BOOST_REQUIRE_EQUAL(success(), deliver_as(op, outpost, envelope)); + } + } + produce_blocks(); + tip = next_stream_tip(envelope); + } + + /// Cross the epoch boundary and advance through the production `chkcons -> advance` route. + void close_epoch() { + const uint32_t epoch = current_epoch(); + elapse_epoch_boundary(); + advance_via_consensus(); + BOOST_REQUIRE_EQUAL(epoch + 1, current_epoch()); + } + /// Count attestation rows recorded for (`chain_code`, `epoch_index`); the observable effect /// of an ACCEPTED inbound envelope (rows are emplaced before dispatch, even for types /// dispatch drops as out of scope). @@ -1465,6 +1560,10 @@ BOOST_FIXTURE_TEST_CASE(late_confirmation_after_consensus_recorded, sysio_msgch_ { auto opc = get_outpcons(ETH_OUTPOST_ID); BOOST_REQUIRE(opc.is_null() || !opc["consensus_reached"].as()); + // Until a winner is accepted the delivered bytes are held for the consensus decode. + auto row = find_inbound_delivery(ETH_OUTPOST_ID, epoch, BATCHOP); + BOOST_REQUIRE(!row.is_null()); + BOOST_REQUIRE(row[msgch_fields::RAW_DATA].as>() == winner); } // Second delivery after the boundary: majority (2 of 3) tips consensus and advances the tip @@ -1500,6 +1599,16 @@ BOOST_FIXTURE_TEST_CASE(late_confirmation_after_consensus_recorded, sysio_msgch_ error("assertion failure with message: operator already delivered for this outpost+epoch"), deliver_as(BATCHOP_C, ETH_OUTPOST_ID, winner)); + // The confirmation row keeps only what advance() classifies: its checksum matches the winner, + // and the bytes, which duplicate the already-applied winner, are not stored. + { + auto row = find_inbound_delivery(ETH_OUTPOST_ID, epoch, BATCHOP_C); + BOOST_REQUIRE(!row.is_null()); + BOOST_REQUIRE_EQUAL(row[msgch_fields::CHECKSUM].as_string(), + fc::sha256::hash(winner.data(), winner.size()).str()); + BOOST_REQUIRE(row[msgch_fields::RAW_DATA].as>().empty()); + } + // Acceptance state is untouched by the late confirmation: same tip, same epoch, attestations // dispatched exactly once. { @@ -2092,4 +2201,137 @@ BOOST_FIXTURE_TEST_CASE(advance_withholds_batch_operator_groups_when_next_group_ "starved window never withheld BATCH_OPERATOR_GROUPS -- an empty active group was published"); } FC_LOG_AND_RETHROW() } +// --------------------------------------------------------------------------- +// Inbound `envelopes` retention: `deliver` prunes rows older than the +// previous epoch, a bounded number per call. +// --------------------------------------------------------------------------- + +/// Real `deliver -> chkcons -> advance` cycles leave exactly the retained epochs' rows: nothing +/// older than the window, every row of the previous epoch (the window's lower edge), ids that keep +/// increasing across epochs, and no orphaned `byoutepoch` entries. +BOOST_FIXTURE_TEST_CASE(envelopes_pruned_beyond_retention_window, sysio_msgch_chain_tester) { try { + constexpr uint32_t kBatchOperatorCount = 3; + constexpr uint32_t kOutpostCount = 2; + constexpr uint32_t kRowsPerEpoch = kBatchOperatorCount * kOutpostCount; + constexpr uint32_t kEpochsDriven = INBOUND_ENVELOPE_RETENTION_EPOCHS + 2; + const std::vector ops{BATCHOP, BATCHOP_B, BATCHOP_C}; + + bootstrap(kBatchOperatorCount); + const uint32_t first_epoch = current_epoch(); + inbound_stream_tip tip; + uint64_t newest_id = 0; + for (uint32_t driven = 1; driven <= kEpochsDriven; ++driven) { + const uint32_t epoch = current_epoch(); + deliver_unanimous_epoch(ops, "retention-" + std::to_string(epoch), tip); + + const auto rows = envelope_rows(); + const uint32_t retained_epochs = std::min(driven, INBOUND_ENVELOPE_RETENTION_EPOCHS); + BOOST_REQUIRE_EQUAL(rows.size(), retained_epochs * kRowsPerEpoch); + BOOST_REQUIRE_EQUAL(envelope_index_entries(), rows.size()); + for (const auto& row : rows) { + BOOST_REQUIRE_GT(row[msgch_fields::EPOCH_INDEX].as() + + INBOUND_ENVELOPE_RETENTION_EPOCHS, epoch); + } + if (epoch > first_epoch) BOOST_REQUIRE_EQUAL(envelope_rows_for_epoch(epoch - 1), kRowsPerEpoch); + + // Primary-key order is epoch order and ids never restart after a prune. + uint64_t previous_id = 0; + for (const auto& row : rows) { + BOOST_REQUIRE_GT(row[msgch_fields::ID].as_uint64(), previous_id); + previous_id = row[msgch_fields::ID].as_uint64(); + } + BOOST_REQUIRE_GT(previous_id, newest_id); + newest_id = previous_id; + + close_epoch(); + } +} FC_LOG_AND_RETHROW() } + +/// One delivery erases at most `ENVELOPE_PRUNE_BUDGET` expired rows, oldest first, and never a +/// retained one. With more expired rows than one budget, the first delivery of an epoch leaves the +/// newest expired rows for the next delivery, which clears them without touching the previous epoch. +BOOST_FIXTURE_TEST_CASE(envelope_prune_bounded_per_delivery, sysio_msgch_chain_tester) { try { + constexpr uint32_t kBatchOperatorCount = 3; + constexpr uint32_t kOutpostCount = 2; + constexpr uint32_t kRowsPerEpoch = kBatchOperatorCount * kOutpostCount; + static_assert(kRowsPerEpoch > ENVELOPE_PRUNE_BUDGET && kRowsPerEpoch <= 2 * ENVELOPE_PRUNE_BUDGET, + "the expired epoch must take exactly two deliveries to clear"); + const std::vector ops{BATCHOP, BATCHOP_B, BATCHOP_C}; + + bootstrap(kBatchOperatorCount); + const uint32_t expiring_epoch = current_epoch(); + inbound_stream_tip tip; + for (uint32_t i = 0; i < INBOUND_ENVELOPE_RETENTION_EPOCHS; ++i) { + deliver_unanimous_epoch(ops, "budget-" + std::to_string(current_epoch()), tip); + close_epoch(); + } + const uint32_t epoch = current_epoch(); + BOOST_REQUIRE_EQUAL(expiring_epoch + INBOUND_ENVELOPE_RETENTION_EPOCHS, epoch); + + std::vector expiring_ids; + for (const auto& row : envelope_rows()) { + if (row[msgch_fields::EPOCH_INDEX].as() == expiring_epoch) { + expiring_ids.push_back(row[msgch_fields::ID].as_uint64()); + } + } + BOOST_REQUIRE_EQUAL(expiring_ids.size(), kRowsPerEpoch); + + const auto envelope = encode_chained_delivery("budget-" + std::to_string(epoch), tip); + BOOST_REQUIRE_EQUAL(success(), deliver_as(BATCHOP, ETH_OUTPOST_ID, envelope)); + produce_blocks(); + BOOST_REQUIRE_EQUAL(envelope_rows_for_epoch(expiring_epoch), kRowsPerEpoch - ENVELOPE_PRUNE_BUDGET); + BOOST_REQUIRE_EQUAL(envelope_rows().front()[msgch_fields::ID].as_uint64(), + expiring_ids[ENVELOPE_PRUNE_BUDGET]); + BOOST_REQUIRE_EQUAL(envelope_rows_for_epoch(expiring_epoch + 1), kRowsPerEpoch); + + BOOST_REQUIRE_EQUAL(success(), deliver_as(BATCHOP_B, ETH_OUTPOST_ID, envelope)); + produce_blocks(); + BOOST_REQUIRE_EQUAL(envelope_rows_for_epoch(expiring_epoch), 0u); + BOOST_REQUIRE_EQUAL(envelope_rows_for_epoch(expiring_epoch + 1), kRowsPerEpoch); + BOOST_REQUIRE_EQUAL(envelope_rows_for_epoch(epoch), 2u); + BOOST_REQUIRE_EQUAL(envelope_index_entries(), envelope_rows().size()); +} FC_LOG_AND_RETHROW() } + +/// Dispute resolution still works once the prune is live. Two clean epochs run first, so the +/// disputed epoch's deliveries prune the oldest epoch; ETH then splits 1-1, a Tier-1 vote resolves +/// it from the retained rows (`opendispute` pauses the epoch, so they stay current), and `advance` +/// slashes the losing deliverer. +BOOST_FIXTURE_TEST_CASE(dispute_resolves_while_envelopes_are_pruned, sysio_msgch_chain_tester) { try { + const std::vector ops{BATCHOP, BATCHOP_B}; + bootstrap(ONE_TO_ONE_TIE_GROUP_SIZE); + const uint32_t first_epoch = current_epoch(); + inbound_stream_tip tip; + for (uint32_t i = 0; i < INBOUND_ENVELOPE_RETENTION_EPOCHS; ++i) { + deliver_unanimous_epoch(ops, "pre-dispute-" + std::to_string(current_epoch()), tip); + close_epoch(); + } + const uint32_t epoch = current_epoch(); + + const auto left = encode_chained_delivery(ONE_TO_ONE_LEFT_PAYLOAD, tip); + const auto right = encode_chained_delivery(ONE_TO_ONE_RIGHT_PAYLOAD, tip); + const auto left_checksum = fc::sha256::hash(left.data(), left.size()); + const auto right_checksum = fc::sha256::hash(right.data(), right.size()); + for (const auto& op : ops) { + BOOST_REQUIRE_EQUAL(success(), deliver_as(op, SOL_OUTPOST_ID, left)); + } + BOOST_REQUIRE_EQUAL(success(), deliver_as(BATCHOP, ETH_OUTPOST_ID, left)); + BOOST_REQUIRE_EQUAL(success(), deliver_as(BATCHOP_B, ETH_OUTPOST_ID, right)); + produce_blocks(); + BOOST_REQUIRE_EQUAL(envelope_rows_for_epoch(first_epoch), 0u); + + elapse_epoch_boundary(); + advance_via_consensus(); + assert_open_tie_dispute(epoch, {left_checksum, right_checksum}, + {ONE_OPERATOR_PER_TIED_VERSION, ONE_OPERATOR_PER_TIED_VERSION}); + BOOST_REQUIRE(epoch_is_paused()); + + resolve_tie_dispute(epoch, left_checksum); + advance_via_consensus(); + BOOST_REQUIRE_EQUAL(epoch + 1, current_epoch()); + BOOST_REQUIRE_EQUAL(opp::types::OperatorStatus::OPERATOR_STATUS_SLASHED, + get_operator(BATCHOP_B)[opreg_fields::STATUS].as()); + BOOST_REQUIRE_EQUAL(opp::types::OperatorStatus::OPERATOR_STATUS_ACTIVE, + get_operator(BATCHOP)[opreg_fields::STATUS].as()); +} FC_LOG_AND_RETHROW() } + BOOST_AUTO_TEST_SUITE_END()