From 823a29029a95ba9d7a2908aabf1ddfe64e097fcf Mon Sep 17 00:00:00 2001 From: Kevin Heifner Date: Thu, 22 Oct 2026 07:50:39 -0400 Subject: [PATCH 1/6] OPP: bound the operator roster to the schedule window and cap queueout The OPERATORS roster listed every sysio.opreg row, and regoperator is self-authorized, so anyone with accounts could grow it until it no longer fit an envelope. buildenv then aborted inside sysio.epoch::advance, and every later advance failed the same way (CertiK WNS-56, WIRE-406). Outposts read the roster only to map the window's members to the keys they deliver with, so finishadv now lists just the operators the window seats, each with its current status. setconfig caps the window at Solana's limits, 8 groups of 16, which bounds the roster at 128 entries. queueout rejects data larger than an otherwise empty envelope can carry, so the producer's transaction fails instead of a later buildenv. buildenv's single-attestation checks are now unreachable invariants. --- .../include/sysio.epoch/sysio.epoch.hpp | 50 ++---- contracts/sysio.epoch/src/sysio.epoch.cpp | 130 ++++++++------- contracts/sysio.epoch/sysio.epoch.wasm | Bin 78152 -> 76265 bytes .../include/sysio.msgch/sysio.msgch.hpp | 26 ++- contracts/sysio.msgch/src/sysio.msgch.cpp | 42 ++--- contracts/sysio.msgch/sysio.msgch.wasm | Bin 143328 -> 143429 bytes contracts/tests/emissions_tests.cpp | 10 +- contracts/tests/sysio.epoch_tests.cpp | 45 +++-- contracts/tests/sysio.msgch_chain_tests.cpp | 155 ++++++++++++++---- contracts/tests/sysio.msgch_tests.cpp | 57 ++++++- 10 files changed, 327 insertions(+), 188 deletions(-) diff --git a/contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp b/contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp index b3b4b492c6..4d2c910d19 100644 --- a/contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp +++ b/contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp @@ -153,41 +153,21 @@ namespace sysio { static constexpr uint32_t MIN_EPOCH_DURATION_SEC = 60; static constexpr uint32_t MAX_EPOCH_DURATION_SEC = 30u * 24u * 60u * 60u; - /// Upper bound on the number of batch-operator groups. The active group is - /// carried on-chain and in the batch_operator_plugin as a `uint8_t`, which - /// reserves 255 as its "not in any group" sentinel. Groups are indexed - /// 0..batch_op_groups-1, so capping at 255 keeps the largest index (254) - /// below the sentinel and inside the byte, preventing index truncation or a - /// collision that would miselect batch operators. - static constexpr uint32_t MAX_BATCH_OP_GROUPS = 255; - - /// Upper bound on `operators_per_epoch` (the size of one schedule group). - /// Every member of the expiring group costs two inline actions per active - /// outpost in `advance` (opreg::recorddel + opreg::termcheck) plus an inline - /// transfer in sysio.system::payepoch, so group size directly scales the - /// epoch-boundary transaction; an absurd value (which the product equality - /// alone cannot reject -- UINT32_MAX groups of one is internally consistent) - /// would abort `advance` on its vector reserves and halt epoch advancement - /// chain-wide. 100 mirrors sysio.system's producer-pay safety cap - /// (MAX_STANDBY_END_RANK) and is an order of magnitude above any practical - /// group size. - static constexpr uint32_t MAX_OPERATORS_PER_EPOCH = 100; - - /// Upper bound on `batch_operator_minimum_active`, which the product - /// equality pins to the total resident schedule window - /// (operators_per_epoch * batch_op_groups). The full window is held in the - /// `epochstate` singleton row, flattened into a resident vector on every - /// `advance`, and serialized into the per-outpost BatchOperatorGroups - /// attestation (~20 bytes per member against the 32 KiB OPP envelope cap). - /// The ceiling is unchanged by the platform cap reduction 65 536 -> 32 768, - /// but the headroom it leaves is materially tighter: at the ceiling the - /// roster attestation alone is ~20 KB, about 62 % of a 32 KiB envelope - /// (it was ~31 % of 64 KiB). 1000 members still keeps all three bounded -- - /// the resident window vector, the epoch-boundary transaction, and the - /// attestation -- while far exceeding any practical roster; a roster that - /// large would leave little room for value-bearing attestations in the same - /// envelope, and `buildenv` would carry the remainder to the next epoch. - static constexpr uint32_t MAX_SCHEDULED_BATCH_OPERATORS = 1000; + /// Upper bound on the number of batch-operator groups: Solana's operator registry seats at most + /// `MAX_OPERATOR_GROUPS` (8), and a window an outpost cannot seat stalls that outpost. + static constexpr uint32_t MAX_BATCH_OP_GROUPS = 8; + + /// Upper bound on `operators_per_epoch` (the size of one schedule group): Solana seats at most + /// `MAX_GROUP_MEMBERS` (16) per group. Each member of the expiring group also costs inline actions + /// in `advance` (opreg::recorddel + opreg::termcheck per outpost, and a sysio.system::payepoch + /// transfer). + static constexpr uint32_t MAX_OPERATORS_PER_EPOCH = 16; + + /// Largest schedule window (operators_per_epoch * batch_op_groups, which the product equality pins + /// `batch_operator_minimum_active` to), matching Solana's `MAX_OPERATORS` (128) registry. The OPERATORS + /// roster and the BatchOperatorGroups attestation list only the window, so this also bounds both + /// (about 13 KB of roster at the ceiling). + static constexpr uint32_t MAX_SCHEDULED_BATCH_OPERATORS = MAX_BATCH_OP_GROUPS * MAX_OPERATORS_PER_EPOCH; private: diff --git a/contracts/sysio.epoch/src/sysio.epoch.cpp b/contracts/sysio.epoch/src/sysio.epoch.cpp index caa08a5624..01e13f8b53 100644 --- a/contracts/sysio.epoch/src/sysio.epoch.cpp +++ b/contracts/sysio.epoch/src/sysio.epoch.cpp @@ -247,22 +247,21 @@ void epoch::setconfig(uint32_t epoch_duration_sec, check(operators_per_epoch > 0, "operators_per_epoch must be positive"); check(batch_op_groups > 0, "batch_op_groups must be positive"); check(batch_op_groups <= MAX_BATCH_OP_GROUPS, - "batch_op_groups exceeds the uint8 group-index ceiling (255)"); + "batch_op_groups exceeds the outpost group ceiling (8)"); // Widen the product to uint64 so a large operators_per_epoch cannot wrap the // uint32 multiply into a small value that spuriously matches a small // batch_operator_minimum_active; an honest product beyond uint32 simply fails // the equality against the uint32 field, which is the intended rejection. check(static_cast(operators_per_epoch) * batch_op_groups == batch_operator_minimum_active, "batch_operator_minimum_active must equal operators_per_epoch * batch_op_groups"); - // Magnitude bounds, checked after the relationship above so an inconsistent + // Magnitude bound, checked after the relationship above so an inconsistent // triple reports the equality error. The equality alone cannot reject an - // internally consistent but absurd size (UINT32_MAX groups of one), which - // would later abort advance() on its schedule-window vector reserves and - // halt epoch advancement chain-wide. + // internally consistent but absurd size (UINT32_MAX operators in one group), + // which would later abort advance() on its schedule-window vector reserves and + // halt epoch advancement chain-wide. With both factors bounded, the window + // cannot exceed MAX_SCHEDULED_BATCH_OPERATORS. check(operators_per_epoch <= MAX_OPERATORS_PER_EPOCH, - "operators_per_epoch exceeds the per-epoch schedule ceiling (100)"); - check(batch_operator_minimum_active <= MAX_SCHEDULED_BATCH_OPERATORS, - "batch_operator_minimum_active exceeds the schedule-window ceiling (1000)"); + "operators_per_epoch exceeds the outpost group-size ceiling (16)"); check(epoch_retention_envelope_log_count > 0, "epoch_retention_envelope_log_count must be positive"); @@ -720,7 +719,11 @@ void epoch::finishadv(uint32_t epoch_index, int64_t emission_amount) { state_tbl.set(state, ram_payer); - // Queue OPERATORS attestation (full roster with authex chain addresses) for each outpost. + // Queue the OPERATORS roster for each outpost: every operator seated in the window, with its current + // status and authex chain addresses. Outposts read the roster only to map the window's members to + // the keys they deliver with (Solana also refuses a window that seats an ineligible member), so the + // window is all they need, and the roster stays within MAX_SCHEDULED_BATCH_OPERATORS however many + // operators register. A member whose opreg row was pruned is left out. // IMPORTANT: Must come before BATCH_OPERATOR_GROUPS so that the ETH outpost's // _handleOperators populates operatorEthAddress before _handleBatchOperatorGroups // looks up those addresses. @@ -730,61 +733,70 @@ void epoch::finishadv(uint32_t epoch_index, int64_t emission_amount) { authex::links_t authex_links(AUTHEX_ACCOUNT); auto links_by_name = authex_links.get_index<"byname"_n>(); - for (auto it = opreg_ops.begin(); it != opreg_ops.end(); ++it) { - opp::attestations::OperatorEntry entry; - entry.account.name = it->account.to_string(); - entry.type = it->type; - entry.status = it->status; - - // Collect all authex-linked chain addresses for this operator. - // Store raw public key bytes from the variant (33 bytes for EM/secp256k1, - // 32 bytes for ED/Ed25519). - auto link_it = links_by_name.lower_bound(it->account.value); - while (link_it != links_by_name.end() && link_it->username == it->account) { - opp::types::ChainAddress chain_addr; - chain_addr.kind = link_it->chain_kind; - - std::visit([&](const auto& key_data) { - using T = std::decay_t; - if constexpr (std::is_same_v) { - // EM (secp256k1 compressed) — 33 bytes in key.key - chain_addr.address.assign(key_data.key.begin(), key_data.key.end()); - } else if constexpr (std::is_same_v) { - // ED (Ed25519) — 32 bytes - chain_addr.address.assign( - reinterpret_cast(key_data.data()), - reinterpret_cast(key_data.data() + key_data.size())); - } else if constexpr (std::is_same_v) { - // K1/R1 (secp256k1/P-256 compressed) — 33 bytes - chain_addr.address.assign(key_data.begin(), key_data.end()); - } - // Skip BLS keys — not used for chain address linking - }, link_it->pub_key); + for (const auto& group : state.batch_op_groups) { + for (const auto& member : group) { + const auto op = opreg_ops.try_get(opreg::operator_key{member.value}); + if (!op) continue; + + opp::attestations::OperatorEntry entry; + entry.account.name = member.to_string(); + entry.type = op->type; + entry.status = op->status; + + // Collect all authex-linked chain addresses for this operator. + // Store raw public key bytes from the variant (33 bytes for EM/secp256k1, + // 32 bytes for ED/Ed25519). + auto link_it = links_by_name.lower_bound(member.value); + while (link_it != links_by_name.end() && link_it->username == member) { + opp::types::ChainAddress chain_addr; + chain_addr.kind = link_it->chain_kind; + + std::visit([&](const auto& key_data) { + using T = std::decay_t; + if constexpr (std::is_same_v) { + // EM (secp256k1 compressed) — 33 bytes in key.key + chain_addr.address.assign(key_data.key.begin(), key_data.key.end()); + } else if constexpr (std::is_same_v) { + // ED (Ed25519) — 32 bytes + chain_addr.address.assign( + reinterpret_cast(key_data.data()), + reinterpret_cast(key_data.data() + key_data.size())); + } else if constexpr (std::is_same_v) { + // K1/R1 (secp256k1/P-256 compressed) — 33 bytes + chain_addr.address.assign(key_data.begin(), key_data.end()); + } + // Skip BLS keys — not used for chain address linking + }, link_it->pub_key); + + entry.addresses.push_back(std::move(chain_addr)); + ++link_it; + } - entry.addresses.push_back(std::move(chain_addr)); - ++link_it; + ops_attest.operators.push_back(std::move(entry)); } - - ops_attest.operators.push_back(std::move(entry)); } - std::vector encoded; - auto out = zpp::bits::out{encoded, zpp::bits::no_size{}}; - (void)out(ops_attest); + // An empty roster tells an outpost nothing (Solana skips it as an anomaly), so an empty window + // queues none; the BatchOperatorGroups withhold below reports that state. + if (!ops_attest.operators.empty()) { + std::vector encoded; + auto out = zpp::bits::out{encoded, zpp::bits::no_size{}}; + (void)out(ops_attest); - sysio::chains::chains_t chains_tbl(CHAINS_ACCOUNT); - for (auto it = chains_tbl.begin(); it != chains_tbl.end(); ++it) { - if (!is_active_outpost(*it)) continue; - action( - permission_level{get_self(), "owner"_n}, - MSGCH_ACCOUNT, - "queueout"_n, - std::make_tuple( - it->code.value, - opp::types::ATTESTATION_TYPE_OPERATORS, - encoded - ) - ).send(); + sysio::chains::chains_t chains_tbl(CHAINS_ACCOUNT); + for (auto it = chains_tbl.begin(); it != chains_tbl.end(); ++it) { + if (!is_active_outpost(*it)) continue; + action( + permission_level{get_self(), "owner"_n}, + MSGCH_ACCOUNT, + "queueout"_n, + std::make_tuple( + it->code.value, + opp::types::ATTESTATION_TYPE_OPERATORS, + encoded + ) + ).send(); + } } } diff --git a/contracts/sysio.epoch/sysio.epoch.wasm b/contracts/sysio.epoch/sysio.epoch.wasm index 5d1c4bb9d2e2ec8fa573ff008098fa9e221bbc5a..0bcb58498f1ea6fcf99f1293dd1f280ed64b5415 100755 GIT binary patch delta 15675 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 delta 16625 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 0, "sysio.msgch::buildenv: a single READY attestation exceeds the outbound envelope"); @@ -1693,6 +1669,8 @@ void msgch::buildenv(uint64_t chain_code) { // converges so a popped entry never needs a status revert. std::vector packed = build_packed(entries); while (packed.size() > MAX_ENVELOPE_BYTES) { + // Invariant: the estimate over-counts the real encoding, so one row within the `queueout` cap + // always packs and the loop never pops the last entry. check(entries.size() > 1, "sysio.msgch::buildenv: a single READY attestation exceeds " "MAX_ENVELOPE_BYTES -- cannot pack into an envelope"); diff --git a/contracts/sysio.msgch/sysio.msgch.wasm b/contracts/sysio.msgch/sysio.msgch.wasm index e5e9b295dd07b0942ebf3c85f862aa81d48f4ca1..821f80a73c24f52b1cba1e0e4b5d1215c4fe7ce2 100755 GIT binary patch delta 1946 zcmYjRYjjlA6+Yj-GnpZiWCm(V0Fz$EffbN|p)xkMf!vscBH^*xEX7($FbTrYrL;y@ zv`QKv2Ba0%y2=q0tF=E=)Jku|K`ULMfu%(flF*U|A(^}=gg{nWe4^5QMoBY2&bJ@m z-e>Q9_MK>t_gK{XPEwq=eL(oCgcY^F8&`=2duLo~&e1YcSb;}_hy*E2N6Rc>iFA6u zENGY_V)-N1#$N_Sm<^O;k`<^h#nMEku#AZ2+rx1MlMZp18Dm*u5hI)PJTvg26Y(Sk z(Cj=md36SD9L$ukp{n#cFc~OI}C9nBCfT(%yUjfpLcVlg{0xi{bk}Wx#p8tag0NyUq5G)@~jb zQ{7@8Uwi*JLv6G3g9jc3&dZheVluzRpcicraPUY@zWWtA^VQc7H8F;6_HsaZZo0@3GkWj+?eRIPM;CXY4${{p)M`e zdn0^C6C!~(5j3nFXYGd13aR>xGx77q@n})^7+4X1tBx|PgoxE069N%y8%G;fO2qnG zmk1mqA1h`g#Lw+}8E{uw`+8(oBGS_udc<^Col$0+u1JOumKm5IbjM1zI%O~k8QxEO zgx_bq7gcvBAs;*n`H~;GjH=I%Jzg})CsL4#Cbc&Oue$kF)S@)B8El^%MCy%Ats=lJ zB11$eQSg4V^cqY22{g%-L1Z%VtD7)Ky&c4Ia2i(TPDH(&vjEFem~W($OCMr`b>S+O z#3C=sM;74wbl|vrV*!r9kiQ7yj$};=8u^LBbaA_;5hm_S9Svi(OP3Y;Ar=y@$kJ@o zU{GDiMvh({b@L)@1J!+`>T-}7px64;`IUGeer!J#@8>n%P5W?UvZ}fdFA;iGS_$e> zrkwBB&FQ9Q8O{7cxm7=j)puSES0bo+{$FE*i~iUuH@<_Ae7OpRTDe?>75G{${0$1A z_0cAT)Em`UuQipt1>d7F^UrEv6aC?Y9H~V+qx#1-ltT-*JdFb(`faQ1e;fNWzxxt? zC9nJmS=WQ*ArIX!!4vy4u{bjqaTo~~+e9EUOgHdWTvS$r** znRO5|Xmf`uImmO?>7F};Rg7xn5c&yM)v3QCa4MDY}6$bEyT6PMxL`Qq&>3`udbg7l6u`$Nl*UnkbwV_VuVC4m!M2WECx0vAj6XDzb`m?_q;_KSS1@_RXRKb<1p;O*aY=oh!s7A;fXa9$xUtFTB{e1bW%;dS+#791Z&E22NXW_ z`k$n5+R9}SziCySMzyu3So;ip_MI8Ou2$~bvF>i{G~)_W@SZifFgBXb*IUnR-I73` zHJG-_;ndb(rB$vbs%Wx~9+`a`_FE~Qi!x`HC?%XN zqF!Q-y)t3Ml}2XVMQpehcDv#7+cP4jOD{alWK502&`TPXb;xPRf7osKb|awaM;gt_ zD_L~#l-1*}3DH2hOpH8ZAKDfy5Bb>V2>PG-@pSNBsoi)nb)QN-YlkiFdniLe7hGJ z62&<$wm1>u3u6(7kk}fFV!P~$NRLOICi%Vr#N8gMWj=XDB(X5XFge-M+9@qL{2~5G z04Xx?`ald89|bT8avSL+_dqcpJPeaKABJT5`DZQR6q<9YVcC@nZ!pu@S(!TPNTe2UvUa1S2F|67XZTUxMGIfq&i&quYQKsrJ?vxvSwCxYiOQBxO{#eTn2 z6yWK<;DBViS};O9|0}8;ic-B*jp)s5yrdZM+8>zHwIsa%U`V9xV{ahUt`u%NCGra{ za`4?YnsJj^q)>HOd|{`bBh`@|NLMFSoe;M=sUR{-GA57ril&GlGFau&WE`~RID5oiPUd%GGjd%V;@NLN<1jhBk^dm dXc$a+;EyNMLk_(*%v{?v@lG-o!R8yn*uVU7yH@}J diff --git a/contracts/tests/emissions_tests.cpp b/contracts/tests/emissions_tests.cpp index 322aa509c1..faf102bb29 100644 --- a/contracts/tests/emissions_tests.cpp +++ b/contracts/tests/emissions_tests.cpp @@ -2746,12 +2746,14 @@ BOOST_FIXTURE_TEST_CASE( setemitcfg_caps_batch_roster_history, sysio_emissions_t BOOST_FIXTURE_TEST_CASE( emission_config_boundaries_cap_batch_payout_credit_work, sysio_emissions_tester ) try { // setemitcfg owns cadence changes. With the epoch roster at its maximum of - // 100, cadence two would permit 200 distinct recipient credits and must be - // rejected at that boundary. + // 16, cadence six permits 96 distinct recipient credits and fits; cadence + // seven would permit 112 and must be rejected at that boundary. BOOST_REQUIRE_EQUAL(success(), setemitcfg_defaults(config::system_account_name)); - BOOST_REQUIRE_EQUAL(success(), init_epoch_state(60, /*operators_per_epoch*/100, + BOOST_REQUIRE_EQUAL(success(), init_epoch_state(60, /*operators_per_epoch*/16, /*batch_op_groups_count*/3)); - auto r = setemitcfg_with_cadence(config::system_account_name, uint16_t(2)); + BOOST_REQUIRE_EQUAL(success(), + setemitcfg_with_cadence(config::system_account_name, uint16_t(6))); + auto r = setemitcfg_with_cadence(config::system_account_name, uint16_t(7)); BOOST_REQUIRE(r != success()); require_substr(r, "pay_cadence_epochs x operators_per_epoch exceeds the batch payout credit safety cap (100)"); diff --git a/contracts/tests/sysio.epoch_tests.cpp b/contracts/tests/sysio.epoch_tests.cpp index 13dc5f9f10..e5b02d6edc 100644 --- a/contracts/tests/sysio.epoch_tests.cpp +++ b/contracts/tests/sysio.epoch_tests.cpp @@ -175,52 +175,45 @@ BOOST_FIXTURE_TEST_CASE(setconfig_validates_total, sysio_epoch_tester) { try { } FC_LOG_AND_RETHROW() } BOOST_FIXTURE_TEST_CASE(setconfig_rejects_excess_batch_op_groups, sysio_epoch_tester) { try { - // 255 groups (indices 0..254) stay clear of the batch_operator_plugin uint8 - // group sentinel (255) and are accepted; 256 is rejected. minimum_active must - // equal operators_per_epoch * batch_op_groups, so 1 * 255 == 255 here. - BOOST_REQUIRE_EQUAL(success(), setconfig(360, 1, 255, 255)); + // Solana seats at most 8 groups. minimum_active must equal + // operators_per_epoch * batch_op_groups, so 1 * 8 == 8 here. + BOOST_REQUIRE_EQUAL(success(), setconfig(360, 1, 8, 8)); BOOST_REQUIRE_EQUAL( - error("assertion failure with message: batch_op_groups exceeds the uint8 group-index ceiling (255)"), - setconfig(360, 1, 256, 256) + error("assertion failure with message: batch_op_groups exceeds the outpost group ceiling (8)"), + setconfig(360, 1, 9, 9) ); } FC_LOG_AND_RETHROW() } BOOST_FIXTURE_TEST_CASE(setconfig_total_check_survives_uint32_wrap, sysio_epoch_tester) { try { - // 16'843'010 * 255 == 4'294'967'550, which wraps a uint32 multiply to 254. The - // widened uint64 product must reject a minimum_active of 254 rather than + // 536'870'913 * 8 == 4'294'967'304, which wraps a uint32 multiply to 8. The + // widened uint64 product must reject a minimum_active of 8 rather than // spuriously matching the wrapped value. BOOST_REQUIRE_EQUAL( error("assertion failure with message: batch_operator_minimum_active must equal operators_per_epoch * batch_op_groups"), - setconfig(360, 16'843'010u, 254, 255) + setconfig(360, 536'870'913u, 8, 8) ); } FC_LOG_AND_RETHROW() } BOOST_FIXTURE_TEST_CASE(setconfig_rejects_oversized_operators_per_epoch, sysio_epoch_tester) { try { // Group size drives advance()'s per-epoch inline fanout and vector reserves, - // so it carries a magnitude bound independent of the product equality: a - // window of UINT32_MAX operators in one group is internally consistent - // (UINT32_MAX * 1 == UINT32_MAX) yet must be rejected. 100 is the ceiling. - BOOST_REQUIRE_EQUAL(success(), setconfig(360, 100, 100, 1)); + // and Solana seats at most 16 per group, so it carries a bound independent of + // the product equality: a window of UINT32_MAX operators in one group is + // internally consistent (UINT32_MAX * 1 == UINT32_MAX) yet must be rejected. + BOOST_REQUIRE_EQUAL(success(), setconfig(360, 16, 16, 1)); BOOST_REQUIRE_EQUAL( - error("assertion failure with message: operators_per_epoch exceeds the per-epoch schedule ceiling (100)"), - setconfig(360, 101, 101, 1) + error("assertion failure with message: operators_per_epoch exceeds the outpost group-size ceiling (16)"), + setconfig(360, 17, 17, 1) ); BOOST_REQUIRE_EQUAL( - error("assertion failure with message: operators_per_epoch exceeds the per-epoch schedule ceiling (100)"), + error("assertion failure with message: operators_per_epoch exceeds the outpost group-size ceiling (16)"), setconfig(360, 0xFFFF'FFFFu, 0xFFFF'FFFFu, 1) ); } FC_LOG_AND_RETHROW() } -BOOST_FIXTURE_TEST_CASE(setconfig_rejects_oversized_schedule_window, sysio_epoch_tester) { try { - // The window total (batch_operator_minimum_active == operators_per_epoch * - // batch_op_groups) is carried in the epochstate row and in every per-outpost - // BatchOperatorGroups attestation, so it has its own ceiling (1000) even when - // the per-group size and group count are individually acceptable. - BOOST_REQUIRE_EQUAL(success(), setconfig(360, 100, 1000, 10)); - BOOST_REQUIRE_EQUAL( - error("assertion failure with message: batch_operator_minimum_active exceeds the schedule-window ceiling (1000)"), - setconfig(360, 100, 1100, 11) - ); +BOOST_FIXTURE_TEST_CASE(setconfig_accepts_the_largest_schedule_window, sysio_epoch_tester) { try { + // 8 groups of 16 is the largest window an outpost seats (Solana's 128-operator + // registry). The two per-factor ceilings bound the window, so it needs none of its own. + BOOST_REQUIRE_EQUAL(success(), setconfig(360, 16, 128, 8)); } FC_LOG_AND_RETHROW() } BOOST_FIXTURE_TEST_CASE(regchain_basic, sysio_epoch_tester) { try { diff --git a/contracts/tests/sysio.msgch_chain_tests.cpp b/contracts/tests/sysio.msgch_chain_tests.cpp index be5b437fb1..38abc267bb 100644 --- a/contracts/tests/sysio.msgch_chain_tests.cpp +++ b/contracts/tests/sysio.msgch_chain_tests.cpp @@ -37,6 +37,9 @@ #include +#include +#include + #include "contracts.hpp" #include "contract_test_support.hpp" @@ -73,6 +76,10 @@ constexpr std::string_view THREE_TO_THREE_RIGHT_PAYLOAD = "three-to-three-right" constexpr uint32_t INBOUND_ENVELOPE_RETENTION_EPOCHS = 2; constexpr uint32_t ENVELOPE_PRUNE_BUDGET = 4; +/// Unbonded registrations the roster tests add: `UNBONDED_REGISTRANT_PREFIX` plus one letter each. +constexpr std::string_view UNBONDED_REGISTRANT_PREFIX = "rosterop."; +constexpr uint32_t UNBONDED_REGISTRANTS = 16; + /// sysio.opreg action identifiers used by the WNS-16 fixture. namespace opreg_actions { constexpr name SET_CONFIG = "setconfig"_n; @@ -349,8 +356,9 @@ class sysio_msgch_chain_tester : public tester { } /// 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) { + /// schedule, and the genesis advance. The configurable cohort supports even split regressions; + /// `build_schedule = false` skips `schbatchgps`, leaving the window empty. + void bootstrap(uint32_t n_batch_ops = 1, bool batchop_is_bootstrapped = true, bool build_schedule = true) { BOOST_REQUIRE_EQUAL(success(), push(EPOCH_ACCOUNT, epoch_abi, EPOCH_ACCOUNT, "setconfig"_n, mvo() ("epoch_duration_sec", EPOCH_DURATION_SEC) @@ -409,8 +417,10 @@ class sysio_msgch_chain_tester : public tester { BOOST_REQUIRE_EQUAL(success(), fund_and_bond(BATCHOP, BATCH_OPERATOR_MINIMUM_COLLATERAL)); } - BOOST_REQUIRE_EQUAL(success(), push(EPOCH_ACCOUNT, epoch_abi, EPOCH_ACCOUNT, - "schbatchgps"_n, mvo())); + if (build_schedule) { + BOOST_REQUIRE_EQUAL(success(), push(EPOCH_ACCOUNT, epoch_abi, EPOCH_ACCOUNT, + "schbatchgps"_n, mvo())); + } BOOST_REQUIRE_EQUAL(success(), push(EPOCH_ACCOUNT, epoch_abi, EPOCH_ACCOUNT, "advance"_n, mvo())); @@ -771,36 +781,43 @@ class sysio_msgch_chain_tester : public tester { return groups_attestation; } - /// Verify the actual same-epoch OPERATORS snapshot after inline mutations. - /// Existing schedule seats may persist until normal rotation removes them; - /// the authoritative status must nevertheless revoke them immediately. - void require_fresh_roster(uint64_t chain_code, name account, - opp::types::OperatorStatus expected_status) { + /// The OPERATORS roster the most recent envelope for `chain_code` carries, checked against the + /// schedule after inline mutations: it lists exactly the window's members that have an opreg row, + /// each with its current status, ahead of BATCH_OPERATOR_GROUPS. Returns account name -> status. + std::map require_roster_is_window(uint64_t chain_code) { const auto row = find_outbound_envelope(chain_code); BOOST_REQUIRE(!row.is_null()); const auto env = decode_envelope(row["raw_envelope"].as>()); BOOST_REQUIRE_EQUAL(env.messages_size(), 1); - bool found_operator = false; + std::map roster; bool have_operators = false; for (const auto& att : env.messages(0).payload().attestations()) { if (att.type() == opp::types::ATTESTATION_TYPE_OPERATORS) { - opp::attestations::Operators roster; - BOOST_REQUIRE(roster.ParseFromString(att.data())); + opp::attestations::Operators operators; + BOOST_REQUIRE(operators.ParseFromString(att.data())); have_operators = true; - for (const auto& entry : roster.operators()) { - const auto registered = get_operator(name{entry.account().name()}); - BOOST_REQUIRE(!registered.is_null()); - BOOST_REQUIRE_EQUAL(entry.status(), registered["status"].as()); - if (entry.account().name() == account.to_string()) { - BOOST_REQUIRE_EQUAL(entry.status(), expected_status); - found_operator = true; - } - } + roster.clear(); + for (const auto& entry : operators.operators()) roster[entry.account().name()] = entry.status(); } else if (att.type() == opp::types::ATTESTATION_TYPE_BATCH_OPERATOR_GROUPS) { BOOST_REQUIRE(have_operators); } } - BOOST_REQUIRE(found_operator); + BOOST_REQUIRE(have_operators); + + // Held in a local: a range-for over a member of the temporary would read freed memory. + const auto state = read_epoch_state(); + std::set window; + for (const auto& group : state[epoch_fields::BATCH_OP_GROUPS].get_array()) { + for (const auto& member : group.get_array()) { + if (!get_operator(name{member.as_string()}).is_null()) window.insert(member.as_string()); + } + } + BOOST_REQUIRE_EQUAL(roster.size(), window.size()); + for (const auto& [account, status] : roster) { + BOOST_REQUIRE_MESSAGE(window.count(account), account << " is in the roster but not seated"); + BOOST_REQUIRE_EQUAL(status, get_operator(name{account})["status"].as()); + } + return roster; } /// How many BATCH_OPERATOR_GROUPS attestations the most recent `advance` shipped to @@ -1774,8 +1791,9 @@ BOOST_FIXTURE_TEST_CASE(noncanonical_delivery_slashes_before_termination, sysio_ slash_action_count(BATCHOP, SOL_OUTPOST_ID)); BOOST_REQUIRE_EQUAL(epoch + kEpochAdvanceCount, current_epoch()); BOOST_REQUIRE_EQUAL(kExpectedDeliveredLogCount, delivered_dellog_count(BATCHOP)); + // The slide reseated the group without BATCHOP, so the roster no longer lists it. for (const auto chain : {ETH_OUTPOST_ID, SOL_OUTPOST_ID}) - require_fresh_roster(chain, BATCHOP, opp::types::OPERATOR_STATUS_SLASHED); + BOOST_REQUIRE(!require_roster_is_window(chain).count(BATCHOP.to_string())); } FC_LOG_AND_RETHROW() } // SEC-28 (huang review): terminate on the CONSECUTIVE-miss rail through the REAL rotation -- a @@ -1833,7 +1851,8 @@ BOOST_FIXTURE_TEST_CASE(terminate_at_duty_rotation_via_advance, sysio_msgch_chai // whereas termination + reason hold either way. BATCHOP delivered exactly once, so exactly // one delivered row must remain. BOOST_REQUIRE_EQUAL(1u, delivered_dellog_count(BATCHOP)); - require_fresh_roster(ETH_OUTPOST_ID, BATCHOP, opp::types::OPERATOR_STATUS_TERMINATED); + // Its group expired in the same advance, so the roster no longer lists it. + BOOST_REQUIRE(!require_roster_is_window(ETH_OUTPOST_ID).count(BATCHOP.to_string())); } else { // Still ACTIVE: BATCHOP must not terminate before its sixth miss (its 7th duty). BOOST_REQUIRE(status == opp::types::OperatorStatus::OPERATOR_STATUS_ACTIVE); @@ -2226,8 +2245,8 @@ 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() } -/// Same-epoch termination must be visible in both outposts' authoritative -/// rosters and in normal tail selection. This does not repair existing seats. +/// An operator terminated in the same epoch drops out of both outposts' rosters +/// and out of normal tail selection. This does not repair existing seats. BOOST_FIXTURE_TEST_CASE(advance_roster_excludes_same_epoch_termination, sysio_msgch_chain_tester) { try { bootstrap(/*n_batch_ops=*/3, /*batchop_is_bootstrapped=*/false); @@ -2236,9 +2255,12 @@ BOOST_FIXTURE_TEST_CASE(advance_roster_excludes_same_epoch_termination, ("type", opp::types::OPERATOR_TYPE_BATCH)("is_bootstrapped", true))); produce_blocks(); advance_to_next_epoch(); + BOOST_REQUIRE(get_operator(BATCHOP)["status"].as() == + opp::types::OPERATOR_STATUS_TERMINATED); for (const auto chain : {ETH_OUTPOST_ID, SOL_OUTPOST_ID}) { - require_fresh_roster(chain, BATCHOP, opp::types::OPERATOR_STATUS_TERMINATED); - require_fresh_roster(chain, BATCHOP_B, opp::types::OPERATOR_STATUS_ACTIVE); + const auto roster = require_roster_is_window(chain); + BOOST_REQUIRE(!roster.count(BATCHOP.to_string())); + BOOST_REQUIRE_EQUAL(roster.at(BATCHOP_B.to_string()), opp::types::OPERATOR_STATUS_ACTIVE); const auto groups = shipped_batch_operator_groups(chain); BOOST_REQUIRE_EQUAL(groups.groups_size(), 1); BOOST_REQUIRE_EQUAL(groups.groups(0).operators_size(), 3); @@ -2253,6 +2275,83 @@ BOOST_FIXTURE_TEST_CASE(advance_roster_excludes_same_epoch_termination, } } FC_LOG_AND_RETHROW() } +/// WNS-56: the OPERATORS roster lists exactly the operators the window seats, so registrations cannot +/// grow it. Unbonded registrations, an ACTIVE batch operator the window does not seat, and an ACTIVE +/// challenger are all left out. +BOOST_FIXTURE_TEST_CASE(advance_roster_lists_only_the_window, sysio_msgch_chain_tester) { try { + bootstrap(/*n_batch_ops=*/3); + // The one group of three seats batchop.a-c; batchop.d sorts after them, so it stays unseated. + BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, "regoperator"_n, + mvo()("account", BATCHOP_D.to_string())("type", opp::types::OPERATOR_TYPE_BATCH)("is_bootstrapped", true))); + BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, "regoperator"_n, + mvo()("account", BATCHOP_E.to_string())("type", opp::types::OPERATOR_TYPE_CHALLENGER)("is_bootstrapped", true))); + std::vector registrants; + for (uint32_t i = 0; i < UNBONDED_REGISTRANTS; ++i) { + registrants.emplace_back(std::string(UNBONDED_REGISTRANT_PREFIX) + static_cast('a' + i)); + } + create_accounts(registrants); + for (const auto& registrant : registrants) { + BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, "regoperator"_n, + mvo()("account", registrant.to_string())("type", opp::types::OPERATOR_TYPE_BATCH)("is_bootstrapped", false))); + } + produce_blocks(); + BOOST_REQUIRE(get_operator(BATCHOP_D)["status"].as() == + opp::types::OPERATOR_STATUS_ACTIVE); + BOOST_REQUIRE(get_operator(registrants.front())["status"].as() == + opp::types::OPERATOR_STATUS_UNKNOWN); + + advance_to_next_epoch(); + for (const auto chain : {ETH_OUTPOST_ID, SOL_OUTPOST_ID}) { + const auto roster = require_roster_is_window(chain); + BOOST_REQUIRE_EQUAL(roster.size(), 3u); + BOOST_REQUIRE(!roster.count(BATCHOP_D.to_string())); + BOOST_REQUIRE(!roster.count(BATCHOP_E.to_string())); + for (const auto& registrant : registrants) BOOST_REQUIRE(!roster.count(registrant.to_string())); + } +} FC_LOG_AND_RETHROW() } + +/// A slashed member keeps its roster entry, with SLASHED status, while the window still seats it, so +/// Solana keeps refusing that window instead of seating a smaller group. It drops out once its group +/// slides off. +BOOST_FIXTURE_TEST_CASE(advance_roster_keeps_a_seated_slashed_member_until_it_leaves, + sysio_msgch_chain_tester) { try { + constexpr uint32_t kGroups = 3; + constexpr uint64_t kRotationWindowMs = 12ULL * kGroups * EPOCH_DURATION_SEC * 1000ULL; + bootstrap_rotation(kRotationWindowMs); + // A spare so the slide can refill the slashed member's seat. + BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, "regoperator"_n, + mvo()("account", BATCHOP_D.to_string())("type", opp::types::OPERATOR_TYPE_BATCH)("is_bootstrapped", true))); + BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, CHALG_ACCOUNT, "slash"_n, + mvo()("account", BATCHOP_C.to_string())("reason", std::string("seated slash")))); + produce_blocks(); + + // batchop.c sits in the last group, so it stays seated for two more slides. + bool listed_while_seated = false; + for (uint32_t round = 0; round < kGroups; ++round) { + advance_to_next_epoch(); + const auto roster = require_roster_is_window(ETH_OUTPOST_ID); + const auto slashed = roster.find(BATCHOP_C.to_string()); + if (slashed != roster.end()) { + BOOST_REQUIRE_EQUAL(slashed->second, opp::types::OPERATOR_STATUS_SLASHED); + listed_while_seated = true; + } + } + BOOST_REQUIRE(listed_while_seated); + BOOST_REQUIRE(!require_roster_is_window(ETH_OUTPOST_ID).count(BATCHOP_C.to_string())); +} FC_LOG_AND_RETHROW() } + +/// An empty window has no members to resolve, and Solana skips an empty roster as an anomaly, so +/// `advance` queues no OPERATORS attestation even with operators registered. +BOOST_FIXTURE_TEST_CASE(advance_queues_no_roster_for_an_empty_window, sysio_msgch_chain_tester) { try { + bootstrap(/*n_batch_ops=*/1, /*batchop_is_bootstrapped=*/true, /*build_schedule=*/false); + BOOST_REQUIRE(!get_operator(BATCHOP).is_null()); + BOOST_REQUIRE(read_epoch_state()[epoch_fields::BATCH_OP_GROUPS].get_array().empty()); + // Nothing else was queued either, so neither outpost gets an envelope this epoch. + for (const auto chain : {ETH_OUTPOST_ID, SOL_OUTPOST_ID}) { + BOOST_REQUIRE(find_outbound_envelope(chain).is_null()); + } +} FC_LOG_AND_RETHROW() } + /// Even sysio.epoch authority cannot invoke the continuation as a top-level action. BOOST_FIXTURE_TEST_CASE(finishadv_rejects_direct_calls, sysio_msgch_chain_tester) { try { bootstrap(); diff --git a/contracts/tests/sysio.msgch_tests.cpp b/contracts/tests/sysio.msgch_tests.cpp index f0d5d74b20..9ff0e44c71 100644 --- a/contracts/tests/sysio.msgch_tests.cpp +++ b/contracts/tests/sysio.msgch_tests.cpp @@ -355,6 +355,14 @@ constexpr uint64_t ETH_OUTPOST_ID = "ETH"_s.value; constexpr auto EVM_TEST_ATTESTATION_TYPE = opp::types::ATTESTATION_TYPE_OPERATORS; +/// Mirrors of the envelope sizing in `sysio.msgch.hpp` (contract headers are not host-compilable). +constexpr size_t MAX_ENVELOPE_BYTES = 32'768; +constexpr size_t ENVELOPE_BASELINE_BYTES = 512; +constexpr size_t ATTESTATION_OVERHEAD_BYTES = 24; +/// The largest attestation `data` `queueout` accepts: what an otherwise empty envelope carries. +constexpr size_t MAX_ATTESTATION_DATA_BYTES = + MAX_ENVELOPE_BYTES - ENVELOPE_BASELINE_BYTES - ATTESTATION_OVERHEAD_BYTES; + } // anonymous namespace /// Smoke: queueout + buildenv writes one row to `envlog` with the @@ -547,7 +555,6 @@ BOOST_FIXTURE_TEST_CASE(buildenv_packs_until_cap_then_leaves_remainder, // it at the 32 KiB cap — invariant 3 requires an empty queue after emit#2. constexpr size_t PER_ATTEST_BYTES = 8 * 1024; constexpr uint32_t TOTAL_ATTESTATIONS = 6; - constexpr size_t MAX_ENV_BYTES = 32'768; // Vary one byte per attestation so each tx has a distinct payload — // identical transactions in the same block collapse to a single @@ -582,7 +589,7 @@ BOOST_FIXTURE_TEST_CASE(buildenv_packs_until_cap_then_leaves_remainder, // ── Invariant 1: emitted envelope is at or under the cross-chain cap. const auto& raw = emitted_row["raw_envelope"].as>(); BOOST_TEST_MESSAGE("emit#1 envelope size = " << raw.size() << " bytes"); - BOOST_REQUIRE_LE(raw.size(), MAX_ENV_BYTES); + BOOST_REQUIRE_LE(raw.size(), MAX_ENVELOPE_BYTES); // ── Invariant 2: NOT every attestation made it into this envelope — // the packing loop genuinely dropped some onto the next epoch. @@ -612,13 +619,57 @@ BOOST_FIXTURE_TEST_CASE(buildenv_packs_until_cap_then_leaves_remainder, BOOST_REQUIRE(!emitted_row_2.is_null()); const auto& raw2 = emitted_row_2["raw_envelope"].as>(); BOOST_TEST_MESSAGE("emit#2 envelope size = " << raw2.size() << " bytes"); - BOOST_REQUIRE_LE(raw2.size(), MAX_ENV_BYTES); + BOOST_REQUIRE_LE(raw2.size(), MAX_ENVELOPE_BYTES); uint32_t still_ready_after_emit2 = count_ready_attestations(/*chain_code=*/ETH_OUTPOST_ID, /*scan_until=*/TOTAL_ATTESTATIONS + 4); BOOST_REQUIRE_EQUAL(still_ready_after_emit2, 0u); } FC_LOG_AND_RETHROW() } +/// WNS-56: a row no envelope can carry would abort every later `buildenv`, and with it +/// `sysio.epoch::advance`. `queueout` refuses it, so only the producer's own transaction reverts. +BOOST_FIXTURE_TEST_CASE(queueout_rejects_an_attestation_no_envelope_can_carry, + sysio_msgch_envlog_tester) { try { + bootstrap_epoch_config(/*retention=*/200); + register_outpost(opp::types::CHAIN_KIND_EVM, 31337); + produce_blocks(); + + const std::vector oversized(MAX_ATTESTATION_DATA_BYTES + 1, 0x42); + BOOST_REQUIRE_EQUAL( + error("assertion failure with message: sysio.msgch::queueout: attestation exceeds MAX_ATTESTATION_DATA_BYTES"), + queueout_with_data(ETH_OUTPOST_ID, EVM_TEST_ATTESTATION_TYPE, oversized)); + BOOST_REQUIRE_EQUAL(0u, count_ready_attestations(ETH_OUTPOST_ID, /*scan_until=*/4)); +} FC_LOG_AND_RETHROW() } + +/// The largest row `queueout` accepts fits an otherwise empty envelope, which is what keeps +/// `buildenv`'s single-attestation guards unreachable: it ships alone, and the row behind it waits. +BOOST_FIXTURE_TEST_CASE(buildenv_ships_an_attestation_at_the_cap_alone, sysio_msgch_envlog_tester) { try { + bootstrap_epoch_config(/*retention=*/200); + register_outpost(opp::types::CHAIN_KIND_EVM, 31337); + produce_blocks(); + + const std::vector at_cap(MAX_ATTESTATION_DATA_BYTES, 0x42); + const std::vector next_row(1, 0x01); + BOOST_REQUIRE_EQUAL(success(), queueout_with_data(ETH_OUTPOST_ID, EVM_TEST_ATTESTATION_TYPE, at_cap)); + BOOST_REQUIRE_EQUAL(success(), queueout_with_data(ETH_OUTPOST_ID, EVM_TEST_ATTESTATION_TYPE, next_row)); + produce_blocks(); + + BOOST_REQUIRE_EQUAL(success(), buildenv(ETH_OUTPOST_ID)); + produce_blocks(); + + const auto row = find_outbound_envelope(); + BOOST_REQUIRE(!row.is_null()); + const auto raw = row["raw_envelope"].as>(); + BOOST_TEST_MESSAGE("at-cap envelope size = " << raw.size() << " bytes"); + BOOST_REQUIRE_LE(raw.size(), MAX_ENVELOPE_BYTES); + opp::Envelope env; + BOOST_REQUIRE(env.ParseFromArray(raw.data(), static_cast(raw.size()))); + BOOST_REQUIRE_EQUAL(env.messages_size(), 1); + BOOST_REQUIRE_EQUAL(env.messages(0).payload().attestations_size(), 1); + BOOST_REQUIRE_EQUAL(env.messages(0).payload().attestations(0).data().size(), MAX_ATTESTATION_DATA_BYTES); + BOOST_REQUIRE_EQUAL(1u, count_ready_attestations(ETH_OUTPOST_ID, /*scan_until=*/4)); +} FC_LOG_AND_RETHROW() } + // queueout carries no ABI-level auth. Without the depot-contract gate, any account could call it // directly and inject a forged READY attestation that buildenv() then packs into the depot's // group-signed outbound envelope (a forged DESYNDICATE_LIQ the outpost executes). From 2dcac2f4c794340ba23b214ddeead1fb28efb030 Mon Sep 17 00:00:00 2001 From: Kevin Heifner Date: Thu, 22 Oct 2026 12:41:32 -0400 Subject: [PATCH 2/6] OPP: update docs for the window-only roster and 8x16 ceilings The docs still described OPERATORS as the full roster and cited the old 100/255/1000 setconfig ceilings. They now describe the roster as the operators the window seats, the ceilings of 8 groups of 16, and queueout's size cap. The pay cadence notes assumed a 21-operator group, which setconfig now rejects. With the default 7-operator groups the 10-epoch history bound limits the cadence; a 16-operator group is held to 6 by the 100-credit cap. Comments and docs only; the rebuilt contracts are byte-identical. --- OVERVIEW.md | 2 +- contracts/sysio.epoch/README.md | 7 ++++--- contracts/sysio.msgch/README.md | 2 +- contracts/sysio.system/EMISSIONS.md | 12 ++++++------ .../include/sysio.system/emissions.hpp | 19 ++++++++++--------- docs/becoming-a-block-producer.md | 3 +-- docs/contract-upgrade-order.md | 7 ++++--- docs/retired-opp-carriers.md | 3 ++- 8 files changed, 29 insertions(+), 26 deletions(-) diff --git a/OVERVIEW.md b/OVERVIEW.md index 9418056a58..082cbff7cd 100644 --- a/OVERVIEW.md +++ b/OVERVIEW.md @@ -66,7 +66,7 @@ This allows a single unified message format across all supported chains. | Category | Types | Direction | Purpose | |----------|-------|-----------|---------| -| **Operator Rosters** | `OPERATORS`, `BATCH_OPERATOR_GROUPS` | depot → outpost | Full operator roster and the next epoch's batch-operator group, sent every epoch | +| **Operator Rosters** | `OPERATORS`, `BATCH_OPERATOR_GROUPS` | depot → outpost | The operators the schedule window seats, with their chain addresses, and the next epoch's batch-operator group, sent every epoch | | **Liquidity Syndication** | `SYNDICATE_LIQ`, `LIQ_YIELD` | outpost → depot | A user syndicating liq tokens into outpost custody; realized custody yield | | **Liquidity Desyndication** | `DESYNDICATE_LIQ` | depot → outpost | Return syndicated liq tokens to a holder on the outpost | | **Node Owners** | `NODE_OWNER_REG` | outpost → depot | Node-owner NFT committed on Ethereum, registering the owner on the depot | diff --git a/contracts/sysio.epoch/README.md b/contracts/sysio.epoch/README.md index 011fbdec97..62e2a850b6 100644 --- a/contracts/sysio.epoch/README.md +++ b/contracts/sysio.epoch/README.md @@ -28,7 +28,7 @@ Operator registration, collateral and status live on `sysio.opreg`; the outpost | Action | Auth | Description | |--------|------|-------------| -| `setconfig` | `sysio.epoch` | Set the epoch configuration. Requires `operators_per_epoch * batch_op_groups == batch_operator_minimum_active` and enforces the size caps (`MAX_OPERATORS_PER_EPOCH` 100, `MAX_BATCH_OP_GROUPS` 255, `MAX_SCHEDULED_BATCH_OPERATORS` 1000) | +| `setconfig` | `sysio.epoch` | Set the epoch configuration. Requires `operators_per_epoch * batch_op_groups == batch_operator_minimum_active` and enforces Solana's seating limits (`MAX_OPERATORS_PER_EPOCH` 16, `MAX_BATCH_OP_GROUPS` 8), so the window holds at most `MAX_SCHEDULED_BATCH_OPERATORS` (128) | | `schbatchgps` | `sysio.epoch` | Build the initial window from the `ACTIVE` batch operators on `sysio.opreg` (non-bootstrapped first, then bootstrapped, trimmed to `batch_operator_minimum_active`) | | `advance` | permissionless at genesis (epoch 0 → 1); afterwards `sysio.msgch` or `sysio.epoch` | Advance the epoch once the wall-clock boundary has passed and the contract is not paused | | `pause` | `sysio.chalg` | Set the global pause | @@ -45,8 +45,9 @@ Operator registration, collateral and status live on `sysio.opreg`; the outpost The resident groups stay disjoint, so an operator serves at most once every `batch_op_groups` epochs. 4. `sysio.opreg::flushwtdw` matures queued collateral withdrawals. -5. Queue `OPERATORS` and `BATCH_OPERATOR_GROUPS` to every active outpost through - `sysio.msgch::queueout` (an empty group is withheld), then `sysio.msgch::buildenv` per outpost. +5. Queue `OPERATORS` (the operators the window seats, with their status and authex links; none for + an empty window) and `BATCH_OPERATOR_GROUPS` (withheld when the next group is empty) to every + active outpost through `sysio.msgch::queueout`, then `sysio.msgch::buildenv` per outpost. 6. `sysio.system::accrueepoch` and `rcrdbatch`, plus `payepoch` on pay epochs. ## Dependencies diff --git a/contracts/sysio.msgch/README.md b/contracts/sysio.msgch/README.md index f17d887b36..b835b5dee5 100644 --- a/contracts/sysio.msgch/README.md +++ b/contracts/sysio.msgch/README.md @@ -37,7 +37,7 @@ Inbound/outbound OPP message chain management and consensus tracking contract. | `evalcons` | `sysio.msgch` | Evaluate consensus for an (outpost, epoch); on consensus store and dispatch the attestations; on an eligible split open a `sysio.chalg` dispute | | `chkcons` | permissionless | If no dispute is open, every active outpost has consensus and the boundary has passed, call `sysio.epoch::advance` | | `resolvedisp` | `sysio.chalg` | Dispatch the winning envelope of a resolved dispute | -| `queueout` | `sysio.epoch`, `sysio.opreg`, `sysio.synd`, or `sysio.msgch` | Queue an outbound attestation for an outpost | +| `queueout` | `sysio.epoch`, `sysio.opreg`, `sysio.synd`, or `sysio.msgch` | Queue an outbound attestation for an outpost. Rejects `data` larger than `MAX_ATTESTATION_DATA_BYTES`, so every queued row fits an envelope | | `buildenv` | `sysio.epoch` | Pack `READY` attestations into the outpost's outbound envelope for the epoch | ## Attestation routing diff --git a/contracts/sysio.system/EMISSIONS.md b/contracts/sysio.system/EMISSIONS.md index d23fbed159..ed97b1d4c5 100644 --- a/contracts/sysio.system/EMISSIONS.md +++ b/contracts/sysio.system/EMISSIONS.md @@ -100,12 +100,12 @@ rotation cannot reassign an earlier epoch to the current front group. `setemitcfg` accepts a payment cadence only in `[1, 10]`, which bounds an ordinary payment period to at most ten immutable roster snapshots. It also -validates `pay_cadence_epochs * operators_per_epoch <= 100`, preserving the -prior one-roster ceiling on expensive per-recipient credits; `sysio.epoch` -enforces the same constraint when its operator count changes. At the intended -production topology of 21 operators per epoch, the maximum accepted cadence is -4 (about 24 minutes at six-minute epochs); the conservative 100-credit ceiling -limits peak work in the mandatory `advance` transaction. When +validates `pay_cadence_epochs * operators_per_epoch <= 100`, a conservative +ceiling on expensive per-recipient credits that limits peak work in the +mandatory `advance` transaction; `sysio.epoch` enforces the same constraint when +its operator count changes. With the default 7-operator groups the ten-snapshot +bound is the limit (an hour at six-minute epochs); the largest group `setconfig` +accepts, 16 operators, allows a cadence of at most 6. When `sysio.epoch` reads an older serialized configuration during an upgrade, it effectively shortens the cadence to satisfy both bounds; the stored value is not rewritten until a normal `setemitcfg` update. `payepoch` independently refuses diff --git a/contracts/sysio.system/include/sysio.system/emissions.hpp b/contracts/sysio.system/include/sysio.system/emissions.hpp index 686af15537..9098ac6c17 100644 --- a/contracts/sysio.system/include/sysio.system/emissions.hpp +++ b/contracts/sysio.system/include/sysio.system/emissions.hpp @@ -34,14 +34,14 @@ inline constexpr uint32_t T1_MAX_NODE_OWNERS = 21; inline constexpr uint32_t T2_MAX_NODE_OWNERS = 84; inline constexpr uint32_t T3_MAX_NODE_OWNERS = 1000; -// Each recorded roster can contain at most sysio.epoch's 100 scheduled -// operators. Keeping at most ten epochs of history bounds roster retention and -// coalescing work. The joint cadence x roster-size guard below separately keeps -// the expensive per-recipient payout work at the prior one-roster ceiling. At -// the intended production topology of 21 operators per epoch, this permits a -// maximum cadence of 4 (about 24 minutes at six-minute epochs). Raising the -// credit cap requires separate evidence that a larger mandatory advance fits -// the transaction CPU/KV budget. +// Each recorded roster is the group on duty for one epoch, at most sysio.epoch's +// MAX_OPERATORS_PER_EPOCH (16) operators. Keeping at most ten epochs of history +// bounds roster retention and coalescing work. The joint cadence x roster-size +// guard below separately caps the expensive per-recipient payout work at 100 +// credits. With the default 7-operator groups the history bound is the limit; a +// 16-operator group permits a cadence of at most 6. Raising the credit cap +// requires separate evidence that a larger mandatory advance fits the +// transaction CPU/KV budget. inline constexpr uint16_t MAX_PAY_CADENCE_EPOCHS = 10; inline constexpr uint32_t MAX_BATCH_PAYOUT_CREDITS = 100; // payepoch removes at most this many roster-history rows per payment. Normal @@ -131,7 +131,8 @@ struct [[sysio::table("emitcfg"), sysio::contract("sysio.system")]] emission_con // every epoch (matches the original emissions behavior). The roster history // retained for a period is explicitly bounded at 10 epochs, while cadence x // operators_per_epoch is bounded separately to at most 100 recipient credits - // per payout. At 21 operators this makes 4 the maximum production cadence. + // per payout. At the 16-operator group ceiling this makes 6 the maximum + // cadence; the default 7-operator groups are held to 10 by the history bound. // The period aggregate and per-recipient share-by-rounds math // remain equivalent to summing per-epoch results. Must be in // [1, MAX_PAY_CADENCE_EPOCHS]. diff --git a/docs/becoming-a-block-producer.md b/docs/becoming-a-block-producer.md index caf87df47a..8f829e00ce 100644 --- a/docs/becoming-a-block-producer.md +++ b/docs/becoming-a-block-producer.md @@ -32,8 +32,7 @@ sysio.authex::createlink(chain_kind, account, sig, pub_key, nonce) ``` Sign this once per active outpost chain, with your Ethereum and Solana keys respectively. -`regoperator` refuses an account that lacks a link for any active outpost chain, and the links are -what the depot publishes for you in the `OPERATORS` roster it sends each outpost. +`regoperator` refuses an account that lacks a link for any active outpost chain. The `nonce` is a millisecond timestamp and is rejected if it is more than ten minutes old, so generate it at signing time. diff --git a/docs/contract-upgrade-order.md b/docs/contract-upgrade-order.md index 3e5555a818..af76464915 100644 --- a/docs/contract-upgrade-order.md +++ b/docs/contract-upgrade-order.md @@ -180,9 +180,10 @@ pre-launch procedure because a complete immutable roster history is required to credit the batch-operator payout: 1. Set `pay_cadence_epochs` to a value in `[1, 10]` that also satisfies - `pay_cadence_epochs * operators_per_epoch <= 100` before the window. At the - intended 21-operator topology, the maximum is 4. An older stored value - outside the accepted bounds is read with an effective clamp until rewritten. + `pay_cadence_epochs * operators_per_epoch <= 100` before the window. With the + default 7-operator groups the maximum is 10; at the 16-operator ceiling it is 6. + An older stored value outside the accepted bounds is read with an effective + clamp until rewritten. 2. Prefer a completed payment period and verify that `t5state` has `pending_emission_amount == 0`, all `batch_group_epochs` counters are zero, and `batchepochs` is empty. diff --git a/docs/retired-opp-carriers.md b/docs/retired-opp-carriers.md index 6dc954a222..61e0c8fd09 100644 --- a/docs/retired-opp-carriers.md +++ b/docs/retired-opp-carriers.md @@ -36,7 +36,8 @@ Existing active discriminants and payload field numbers keep their values. - `OperatorAction` and `OperatorActionLog` record native collateral changes in `sysio.opreg`. They are audit records: the depot queues no OPERATOR_ACTION - attestation, and outposts learn a slashed status from the OPERATORS roster. + attestation, and outposts learn a seated operator's slashed status from the + OPERATORS roster, which lists every operator the schedule window seats. Removing external custody does not remove this audit/eligibility protocol. The existing `reserve_code` field stays zero in depot-native audit records; changing the live table payload is outside this carrier cleanup. From cf35675584cc7912cf14408daa22d728e9b1e7cb Mon Sep 17 00:00:00 2001 From: Kevin Heifner Date: Thu, 22 Oct 2026 15:02:29 -0400 Subject: [PATCH 3/6] OPP: bound the outbound attestation queue buildenv walked every READY attestation of every outpost, and every desyndicate adds one paid by sysio with no limit on the count. A large enough backlog could keep the rosters out of the envelope or exhaust sysio.epoch::advance, which runs buildenv inline (WIRE-415). A byqueue index orders the attestations table by outpost, status and lane, replacing the status, type and epoch indexes. OPERATORS and BATCH_OPERATOR_GROUPS form a schedule lane that ships first; every other type keeps its queueout order. buildenv walks only its outpost's READY rows and stops at MAX_ENVELOPE_BYTES or MAX_ENVELOPE_ATTESTATIONS (64), erasing what it ships. Its inbound cleanup reads only that outpost's rows. queueout caps a general-lane row at what fits beside a full schedule lane (7,656 bytes) and refuses one once the outpost already holds 512, counted in a new outqueue table. A refused desyndicate reverts whole, burn included. Schedule rows are never refused, since advance queues them. The index change needs fresh state; docs/contract-upgrade-order.md says so. --- OVERVIEW.md | 4 +- contracts/sysio.msgch/README.md | 17 +- .../include/sysio.msgch/sysio.msgch.hpp | 102 ++++-- contracts/sysio.msgch/src/sysio.msgch.cpp | 193 ++++++------ contracts/sysio.msgch/sysio.msgch.abi | 48 ++- contracts/sysio.msgch/sysio.msgch.wasm | Bin 143429 -> 143319 bytes contracts/tests/sysio.msgch_chain_tests.cpp | 2 +- contracts/tests/sysio.msgch_tests.cpp | 293 ++++++++++++++++++ contracts/tests/sysio.synd_tests.cpp | 26 ++ docs/contract-upgrade-order.md | 8 + docs/sysio-synd.md | 5 +- 11 files changed, 563 insertions(+), 135 deletions(-) diff --git a/OVERVIEW.md b/OVERVIEW.md index 082cbff7cd..44fb9d95df 100644 --- a/OVERVIEW.md +++ b/OVERVIEW.md @@ -55,8 +55,8 @@ This allows a single unified message format across all supported chains. ### Outbound (Wire → External Chain) -1. System contracts queue attestations for a target outpost via `sysio.msgch::queueout()`. -2. When the epoch advances, `buildenv()` packs the `READY` attestations into a protobuf-encoded outbound envelope; anything that does not fit stays queued for the next epoch. +1. System contracts queue attestations for a target outpost via `sysio.msgch::queueout()`. Once an outpost has 512 non-roster attestations waiting, `queueout` refuses another. +2. When the epoch advances, `buildenv()` packs the outpost's `READY` attestations, rosters first, into a protobuf-encoded outbound envelope of at most 32,768 bytes and 64 attestations; the rest stays queued for the next epoch. 3. Batch operators read the outbound envelope and submit it to the target chain's Outpost — chunked on both chains (`OPPInbound.epochIn` on Ethereum, `epoch_in` on Solana). 4. The Outpost verifies the envelope chain and group consensus, then routes attestations to their registered handlers. An unhandled type is skipped, never reverted. diff --git a/contracts/sysio.msgch/README.md b/contracts/sysio.msgch/README.md index b835b5dee5..bff4c68217 100644 --- a/contracts/sysio.msgch/README.md +++ b/contracts/sysio.msgch/README.md @@ -11,8 +11,8 @@ Inbound/outbound OPP message chain management and consensus tracking contract. post-boundary split with no strict majority opens a `sysio.chalg` dispute - Validates the per-outpost envelope chain and message sequence, stores the accepted messages and attestations, and routes each attestation to its depot handler -- Queues outbound attestations and packs them into one OPP envelope per outpost per epoch; whatever - does not fit under the envelope cap stays queued for the next epoch +- Queues outbound attestations and packs them into one OPP envelope per outpost per epoch, schedule + attestations first; whatever does not fit under the envelope caps stays queued for the next epoch - Drives the epoch: `chkcons` advances `sysio.epoch` once every active outpost has reached consensus and the wall-clock boundary has passed @@ -22,7 +22,8 @@ Inbound/outbound OPP message chain management and consensus tracking contract. |-------|------|-------------| | `envelopes` | `kv::table` | Inbound deliveries, one row per batch operator per outpost per epoch (working state, pruned to the current and previous epoch) | | `messages` | `kv::table` | Individual OPP messages extracted from consensus-verified envelopes | -| `attestations` | `kv::table` | Attestations extracted inbound, and outbound attestations queued (`READY`) for envelope packing | +| `attestations` | `kv::table` | Attestations extracted inbound, and outbound attestations queued (`READY`) for envelope packing. The `byqueue` index orders them by outpost, status and lane | +| `outqueue` | `kv::table` | Per-outpost count of queued general-lane attestations, capped at `MAX_QUEUED_ATTESTATIONS` | | `outenvelopes` | `kv::table` | Built outbound envelopes awaiting relay, with their canonical epoch digest | | `outpcons` | `kv::table` | Per-outpost consensus record: accepted envelope digest and inbound message-chain tip | | `attseq` | `kv::table` | Attestation sequence counter | @@ -37,8 +38,8 @@ Inbound/outbound OPP message chain management and consensus tracking contract. | `evalcons` | `sysio.msgch` | Evaluate consensus for an (outpost, epoch); on consensus store and dispatch the attestations; on an eligible split open a `sysio.chalg` dispute | | `chkcons` | permissionless | If no dispute is open, every active outpost has consensus and the boundary has passed, call `sysio.epoch::advance` | | `resolvedisp` | `sysio.chalg` | Dispatch the winning envelope of a resolved dispute | -| `queueout` | `sysio.epoch`, `sysio.opreg`, `sysio.synd`, or `sysio.msgch` | Queue an outbound attestation for an outpost. Rejects `data` larger than `MAX_ATTESTATION_DATA_BYTES`, so every queued row fits an envelope | -| `buildenv` | `sysio.epoch` | Pack `READY` attestations into the outpost's outbound envelope for the epoch | +| `queueout` | `sysio.epoch`, `sysio.opreg`, `sysio.synd`, or `sysio.msgch` | Queue an outbound attestation for an outpost. Rejects `data` larger than `MAX_ATTESTATION_DATA_BYTES`, or `MAX_GENERAL_ATTESTATION_DATA_BYTES` (7,656) for a general-lane row, which must fit beside a full schedule lane, so every queued row ships. Also rejects a general-lane row once the outpost already holds `MAX_QUEUED_ATTESTATIONS` (512) of them | +| `buildenv` | `sysio.epoch` | Pack the outpost's `READY` attestations into its outbound envelope for the epoch: the schedule lane (`OPERATORS`, `BATCH_OPERATOR_GROUPS`) first, then the general lane in `queueout` order, up to `MAX_ENVELOPE_BYTES` and `MAX_ENVELOPE_ATTESTATIONS` (64). It reads only that outpost's rows | ## Attestation routing @@ -55,8 +56,10 @@ Inbound (outpost → depot): Outbound (depot → outpost), queued through `queueout`: -- `OPERATORS` and `BATCH_OPERATOR_GROUPS` from `sysio.epoch::advance`, to every active outpost -- `DESYNDICATE_LIQ` from `sysio.synd::desyndicate` +- `OPERATORS` and `BATCH_OPERATOR_GROUPS` from `sysio.epoch::advance`, to every active outpost. They + form the schedule lane, which ships first and which the queue cap never refuses. +- `DESYNDICATE_LIQ` from `sysio.synd::desyndicate`. A full queue refuses it, and the whole + `desyndicate` reverts. ## Dependencies diff --git a/contracts/sysio.msgch/include/sysio.msgch/sysio.msgch.hpp b/contracts/sysio.msgch/include/sysio.msgch/sysio.msgch.hpp index 153a7f0063..bf936471f2 100644 --- a/contracts/sysio.msgch/include/sysio.msgch/sysio.msgch.hpp +++ b/contracts/sysio.msgch/include/sysio.msgch/sysio.msgch.hpp @@ -9,6 +9,7 @@ #include #include #include +#include namespace sysio { @@ -40,6 +41,54 @@ namespace sysio { static constexpr size_t MAX_ATTESTATION_DATA_BYTES = MAX_ENVELOPE_BYTES - ENVELOPE_BASELINE_BYTES - ATTESTATION_OVERHEAD_BYTES; + /// Most attestations one envelope carries, so the rows `buildenv` reads and erases per outpost + /// per epoch stay fixed whatever their sizes. Neither outpost needs it: both resume dispatch + /// across calls. + static constexpr size_t MAX_ENVELOPE_ATTESTATIONS = 64; + + // ----------------------------------------------------------------------- + // Outbound queue + // ----------------------------------------------------------------------- + + /// Most general-lane attestations an outpost's READY queue holds. `queueout` refuses another + /// until `buildenv` ships some, so the producer's transaction fails instead of the queue growing + /// without bound. Schedule-lane rows are exempt: `sysio.epoch` queues them inside `advance`. + static constexpr uint32_t MAX_QUEUED_ATTESTATIONS = 512; + + /// Lane of an outbound attestation. `buildenv` ships the schedule lane first, so a backlog never + /// delays the rosters an outpost needs to admit the next epoch's deliveries. + enum class queue_lane : uint8_t { + schedule = 0, ///< OPERATORS and BATCH_OPERATOR_GROUPS + general = 1, ///< every other type, in `queueout` order + }; + + /// The lane `type` queues in. + static constexpr queue_lane lane_of(opp::types::AttestationType type) { + return type == opp::types::ATTESTATION_TYPE_OPERATORS || + type == opp::types::ATTESTATION_TYPE_BATCH_OPERATOR_GROUPS + ? queue_lane::schedule + : queue_lane::general; + } + + /// Most envelope bytes one epoch's schedule-lane rows take, counted as `buildenv` estimates them + /// (`data` plus ATTESTATION_OVERHEAD_BYTES each). The largest window's OPERATORS and + /// BATCH_OPERATOR_GROUPS come to about 15.4 KB; 24 KiB also matches Solana's OPERATORS payload cap. + static constexpr size_t SCHEDULE_LANE_BYTES = 24 * 1024; + + /// Largest general-lane `data` that fits beside a full schedule lane, which every envelope carries + /// first. `queueout` rejects anything larger, so no general row can stall the rows behind it. + static constexpr size_t MAX_GENERAL_ATTESTATION_DATA_BYTES = + MAX_ENVELOPE_BYTES - ENVELOPE_BASELINE_BYTES - SCHEDULE_LANE_BYTES - ATTESTATION_OVERHEAD_BYTES; + + /// `byqueue` key: outpost, then status, then lane. Rows with equal keys sort by id, so each lane + /// is FIFO. 128 bits keep the full slug range of `chain_code` (see `opp::outpost_epoch_key`). + static constexpr uint128_t queue_key(uint64_t chain_code, opp::types::AttestationStatus status, + queue_lane lane) { + return (static_cast(chain_code) << 16) | + (static_cast(magic_enum::enum_integer(status)) << 8) | + static_cast(magic_enum::enum_integer(lane)); + } + // ----------------------------------------------------------------------- // Actions // ----------------------------------------------------------------------- @@ -84,7 +133,9 @@ namespace sysio { /// Queue an outbound attestation for an outpost. /// Writes to the attestations table with status READY. Rejects `data` larger than - /// MAX_ATTESTATION_DATA_BYTES, so every READY row fits an envelope. + /// MAX_ATTESTATION_DATA_BYTES (MAX_GENERAL_ATTESTATION_DATA_BYTES for a general-lane row), so every + /// READY row ships, and a general-lane row once the outpost already holds MAX_QUEUED_ATTESTATIONS + /// of them. /// /// `chain_code` is the destination outpost's chain slug_name value /// (uint64). Called by sibling system contracts that need to send @@ -107,7 +158,9 @@ namespace sysio { std::vector data); /// Build outbound envelope from READY attestations for an outpost. - /// Collects attestations, packs into OPP Envelope, stores in outenvelopes. + /// Walks only this outpost's READY rows, schedule lane first, and stops at MAX_ENVELOPE_BYTES or + /// MAX_ENVELOPE_ATTESTATIONS, so its cost does not grow with the backlog. Packs the rows into an + /// OPP Envelope, stores it in outenvelopes and erases them. /// Stamps the envelope's route endpoints (start = WIRE, end = the /// destination's `sysio.chains` `{kind, external_chain_id}`) so the /// receiving outpost can reject an envelope a misconfigured relay @@ -202,9 +255,7 @@ namespace sysio { uint64_t ready_timestamp; uint64_t processed_timestamp; - uint64_t by_status() const { return static_cast(status); } - uint64_t by_type() const { return static_cast(type); } - uint64_t by_epoch() const { return epoch_index; } + uint128_t by_queue() const { return queue_key(chain_code, status, lane_of(type)); } SYSLIB_SERIALIZE(attestation_entry, (id)(chain_code)(epoch_index)(type)(status)(data) @@ -212,12 +263,8 @@ namespace sysio { }; using attestations_t = sysio::kv::table<"attestations"_n, id_key, attestation_entry, - sysio::kv::index<"bystatus"_n, - sysio::const_mem_fun>, - sysio::kv::index<"bytype"_n, - sysio::const_mem_fun>, - sysio::kv::index<"byepoch"_n, - sysio::const_mem_fun> + sysio::kv::index<"byqueue"_n, + sysio::const_mem_fun> >; /// Outbound envelope table. One-deep per outpost: `buildenv` erases every older row for the @@ -305,16 +352,33 @@ namespace sysio { using outpost_consensus_t = sysio::kv::table<"outpcons"_n, outpost_consensus_key, outpost_consensus_entry>; + /// Per-outpost outbound queue primary key. + struct outbound_queue_key { + uint64_t chain_code; + SYSLIB_SERIALIZE(outbound_queue_key, (chain_code)) + }; + + /// The general-lane READY attestations queued for an outpost. `queueout` counts them in and + /// refuses one past MAX_QUEUED_ATTESTATIONS; `buildenv` counts the shipped ones out. + struct [[sysio::table("outqueue")]] outbound_queue_entry { + uint64_t chain_code; + uint32_t queued; + + SYSLIB_SERIALIZE(outbound_queue_entry, (chain_code)(queued)) + }; + + using outqueue_t = sysio::kv::table<"outqueue"_n, outbound_queue_key, outbound_queue_entry>; + /// Singleton holding the monotonic next-attestation-id counter. /// - /// Why a singleton instead of `atts.available_primary_key()`: the - /// `buildenv` outbound-bundle cleanup at the bottom of this file - /// erases the just-bundled `ATTESTATION_STATUS_PROCESSED` rows for - /// the destination `chain_code`. Inbound `deliver()`-inserted rows - /// also carry `status = PROCESSED` (they go straight to dispatch), - /// so the cleanup drains both. Once the atts table is empty, - /// `available_primary_key()` resets to 0, our `std::max(1, ...)` - /// floor bumps it to 1, reusing a prior attestation's audit identity. + /// Why a singleton instead of `atts.available_primary_key()`: + /// `buildenv` erases the rows it ships for the destination + /// `chain_code`, and that outpost's inbound `deliver()`-inserted rows, + /// which carry `status = PROCESSED` (they go straight to dispatch). + /// Once the atts table is empty, `available_primary_key()` resets to + /// 0, our `std::max(1, ...)` floor bumps it to 1, reusing a prior + /// attestation's audit identity. Monotonic ids also keep each + /// `byqueue` lane FIFO. /// /// `att_seq` is a one-row table holding `next` — the next id to /// mint. `mint_att_id()` reads + bumps it atomically. Cleanup diff --git a/contracts/sysio.msgch/src/sysio.msgch.cpp b/contracts/sysio.msgch/src/sysio.msgch.cpp index d5b410b83c..595d943e3a 100644 --- a/contracts/sysio.msgch/src/sysio.msgch.cpp +++ b/contracts/sysio.msgch/src/sysio.msgch.cpp @@ -107,11 +107,11 @@ constexpr const char* DISPUTE_NO_TIER_ONE_ELECTORATE_LOG = /// Mint the next attestation id from the `attseq` singleton. /// /// Replaces a `std::max(1, atts.available_primary_key())` call -/// at every `attestations_t` insertion site. The `attseq` singleton survives the -/// `buildenv` cleanup of `ATTESTATION_STATUS_PROCESSED` rows, so the -/// monotonic counter keeps advancing across phases even when the atts -/// table is drained. Reusing an erased row's id would give a later attestation -/// the same identity as an earlier audit record. +/// at every `attestations_t` insertion site. The `attseq` singleton survives +/// `buildenv` erasing shipped and inbound rows, so the monotonic counter +/// keeps advancing across phases even when the atts table is drained. +/// Reusing an erased row's id would give a later attestation the same +/// identity as an earlier audit record. /// /// First call materialises the row at `next = 2` and returns `1`. /// Subsequent calls return the current `next` and post-increment. @@ -128,6 +128,12 @@ uint64_t mint_att_id(name self) { return out; } +/// The outpost's `outqueue` row, or an empty one before its first general-lane attestation. +msgch::outbound_queue_entry outbound_queue_of(const msgch::outqueue_t& queues, uint64_t chain_code) { + return queues.try_get(msgch::outbound_queue_key{chain_code}) + .value_or(msgch::outbound_queue_entry{.chain_code = chain_code, .queued = 0}); +} + /// Size of the ACTIVE batch-operator group -- the set that can actually deliver for the current /// epoch. Every consensus threshold derives from THIS, never from the configured /// `operators_per_epoch`. @@ -1429,6 +1435,11 @@ void msgch::queueout(uint64_t chain_code, // the row for. check(data.size() <= MAX_ATTESTATION_DATA_BYTES, "sysio.msgch::queueout: attestation exceeds MAX_ATTESTATION_DATA_BYTES"); + // A general-lane row must also fit beside the schedule lane every envelope carries first, or it + // would stall the general lane behind it for good. + const queue_lane lane = lane_of(attest_type); + check(lane == queue_lane::schedule || data.size() <= MAX_GENERAL_ATTESTATION_DATA_BYTES, + "sysio.msgch::queueout: attestation exceeds MAX_GENERAL_ATTESTATION_DATA_BYTES"); // The chains registry is the ONLY authority on which chain codes exist. // @@ -1437,10 +1448,8 @@ void msgch::queueout(uint64_t chain_code, // sysio.chains, but a governance msig holding one of those authorities can // call queueout directly, and a typo'd code used to abort here. Without it // the row is created READY and is then unreachable forever: epoch::advance - // only fans buildenv to registered active outposts, the PROCESSED drain - // skips mismatched codes, and no prune action exists -- so it is permanent - // sysio-pool RAM, re-walked by the Phase-1 bystatus scan on every buildenv, - // for every outpost, every epoch. + // only fans buildenv to registered active outposts and no prune action + // exists -- so it is permanent sysio-pool RAM. // // The SVM terminal-account estimator that used to live beside this lookup // was deliberately deleted (it made the depot model another chain's packet @@ -1450,6 +1459,17 @@ void msgch::queueout(uint64_t chain_code, chains_tbl.get(sysio::chains::chain_key{sysio::slug_name{chain_code}}, "sysio.msgch::queueout: chain_code is not registered"); + // Backpressure: past the cap, a general-lane row fails its producer's transaction (a `desyndicate` + // reverts whole, burn included) instead of growing the queue. Schedule rows are exempt because + // `sysio.epoch` queues them inside `advance`, which must not fail. + if (lane == queue_lane::general) { + outqueue_t queues(get_self()); + auto queue = outbound_queue_of(queues, chain_code); + check(queue.queued < MAX_QUEUED_ATTESTATIONS, "sysio.msgch::queueout: outbound queue is full for this outpost"); + ++queue.queued; + queues.set(ram_payer, outbound_queue_key{chain_code}, queue); + } + auto now_sec = static_cast(current_time_point().sec_since_epoch()); attestations_t atts(get_self()); @@ -1471,11 +1491,12 @@ void msgch::queueout(uint64_t chain_code, // --------------------------------------------------------------------------- // buildenv — build outbound envelope from READY attestations // -// Packs as many READY attestations as fit under MAX_ENVELOPE_BYTES into a -// single outbound envelope; any that don't fit stay in the table with -// status = READY and ride the next epoch's `buildenv` call. Mirrors the -// Solana (`emit_outbound_inner`) and Ethereum (`emitOutboundEnvelope`) -// packing-loop pattern — never drop, never refuse, always emit what fits. +// Packs this outpost's READY attestations in queue order (schedule lane +// first, FIFO within a lane) into a single outbound envelope, up to +// MAX_ENVELOPE_BYTES and MAX_ENVELOPE_ATTESTATIONS; the rest stay READY and +// ride the next epoch's `buildenv` call. Mirrors the Solana +// (`emit_outbound_inner`) and Ethereum (`emitOutboundEnvelope`) packing-loop +// pattern — never drop, never refuse, always emit what fits. // // Each envelope chains from its predecessor on the same outpost stream: // `previous_envelope_hash` carries the prior emit's canonical epoch digest @@ -1496,36 +1517,57 @@ void msgch::buildenv(uint64_t chain_code) { uint32_t epoch = epoch::current_epoch_index(); attestations_t atts(get_self()); - auto now_sec = static_cast(current_time_point().sec_since_epoch()); // OPP wire timestamps (`MessageHeader.timestamp`, `Envelope.epoch_timestamp`) are milliseconds // since the Unix epoch (opp.proto). WIRE's half-second blocks make sub-second resolution - // meaningful, so emit real milliseconds; `now_sec` stays for the seconds-unit table columns. + // meaningful, so emit real milliseconds. const uint64_t now_ms = static_cast(current_time_point().time_since_epoch().count()) / 1000; - // ── Phase 1: collect candidate READY attestations for this outpost. - // Order is the secondary index's natural order, which is stable across - // epochs — preserves cross-epoch attestation ordering for the receiving - // chain. - std::vector candidate_entries; - std::vector candidate_ids; + // Phase 1: take this outpost's READY rows in queue order, stopping at the first one that would push + // the estimate past MAX_ENVELOPE_BYTES, or at MAX_ENVELOPE_ATTESTATIONS. The walk reads nothing it + // does not ship, so its cost does not grow with the backlog, and the rows it leaves keep their place. + // + // The estimate is conservative. The trim loop below is the source of truth for the encoded size + // invariant. + // + // This pass deliberately does NOT bound the envelope by any destination chain's transaction + // capacity. Solana dispatch is resumable: the outpost settles `[cursor, cursor + dispatch_limit)` + // per terminal call and the relay loops until the cursor drains, so an envelope that needs more + // effect accounts than one Solana transaction can carry is settled across several calls rather + // than refused here. Sizing a WIRE consensus envelope against another chain's packet limit put + // that chain's MTU inside depot consensus; the cursor removes the need. + auto queue = atts.get_index<"byqueue"_n>(); + const auto in_ready_queue = [&](const auto& row) { + return row != queue.end() && row->chain_code == chain_code && + row->status == AttestationStatus::ATTESTATION_STATUS_READY; + }; - auto status_idx = atts.get_index<"bystatus"_n>(); - for (auto it = status_idx.lower_bound( - static_cast(AttestationStatus::ATTESTATION_STATUS_READY)); - it != status_idx.end() && - it->status == AttestationStatus::ATTESTATION_STATUS_READY; ++it) { - if (it->chain_code != chain_code) continue; + auto ready = queue.lower_bound( + queue_key(chain_code, AttestationStatus::ATTESTATION_STATUS_READY, queue_lane::schedule)); + if (!in_ready_queue(ready)) return; + + std::vector entries; + std::vector included_ids; + size_t estimated_bytes = ENVELOPE_BASELINE_BYTES; + for (; in_ready_queue(ready) && entries.size() < MAX_ENVELOPE_ATTESTATIONS; ++ready) { + const size_t entry_bytes = ATTESTATION_OVERHEAD_BYTES + ready->data.size(); + if (estimated_bytes + entry_bytes > MAX_ENVELOPE_BYTES) { + break; + } + estimated_bytes += entry_bytes; opp::AttestationEntry entry; - entry.type = it->type; - entry.data_size = zpp::bits::vuint32_t{static_cast(it->data.size())}; - entry.data = it->data; - candidate_entries.push_back(std::move(entry)); - candidate_ids.push_back(it->id); + entry.type = ready->type; + entry.data_size = zpp::bits::vuint32_t{static_cast(ready->data.size())}; + entry.data = ready->data; + entries.push_back(std::move(entry)); + included_ids.push_back(ready->id); } - if (candidate_entries.empty()) return; + // Invariant, not a reachable failure: `queueout` caps every row at MAX_ATTESTATION_DATA_BYTES, which + // is what the estimate leaves an otherwise empty envelope, so the first row always fits. + check(!entries.empty(), + "sysio.msgch::buildenv: a single READY attestation exceeds the outbound envelope"); const auto op_row = [&]() { sysio::chains::chains_t chains_tbl(CHAINS_ACCOUNT); @@ -1545,39 +1587,6 @@ void msgch::buildenv(uint64_t chain_code) { route_endpoints.end.kind = op_row.kind; route_endpoints.end.id = op_row.external_chain_id; - // Phase 2: estimator-based initial pick. Walk candidates in order, accumulating a conservative byte - // estimate; stop once the next one would push the envelope over MAX_ENVELOPE_BYTES. The trim loop - // below is the source of truth for the encoded size invariant. - // - // This pass deliberately does NOT bound the envelope by any destination chain's transaction - // capacity. Solana dispatch is resumable: the outpost settles `[cursor, cursor + dispatch_limit)` - // per terminal call and the relay loops until the cursor drains, so an envelope that needs more - // effect accounts than one Solana transaction can carry is settled across several calls rather - // than refused here. Sizing a WIRE consensus envelope against another chain's packet limit put - // that chain's MTU inside depot consensus; the cursor removes the need. - size_t included_count = 0; - size_t estimated_bytes = ENVELOPE_BASELINE_BYTES; - for (const auto& entry : candidate_entries) { - const size_t entry_bytes = ATTESTATION_OVERHEAD_BYTES + entry.data.size(); - if (estimated_bytes + entry_bytes > MAX_ENVELOPE_BYTES) { - break; - } - estimated_bytes += entry_bytes; - ++included_count; - } - - // Invariant, not a reachable failure: `queueout` caps every row at MAX_ATTESTATION_DATA_BYTES, which - // is what the estimate leaves an otherwise empty envelope, so the first candidate always fits. - check(included_count > 0, - "sysio.msgch::buildenv: a single READY attestation exceeds the outbound envelope"); - - std::vector entries( - std::make_move_iterator(candidate_entries.begin()), - std::make_move_iterator(candidate_entries.begin() + included_count)); - std::vector included_ids( - candidate_ids.begin(), - candidate_ids.begin() + included_count); - // Chain links: the previous envelope emitted for this outpost. `outenvelopes` is one-deep per // outpost (see the cleanup below), so the single surviving row is the previous emit; its // `envelope_hash` is that envelope's epoch digest and its `last_message_id` is this outpost's @@ -1663,10 +1672,10 @@ void msgch::buildenv(uint64_t chain_code) { // under-counted relative to the actual zpp::bits encoding for this candidate set), pop the last // entry and retry. This loop -- not the estimator -- is the sole authority on the size invariant. It // replaces the previous post-build hard abort, which left this action in a dead-letter loop on - // estimator drift: the PROCESSED markings would roll back with the aborted tx, the same set would - // re-pack against the same estimator next epoch, and the same check would trip again. Popped entries - // stay READY and ride the next `buildenv` call. PROCESSED marking is deferred until after the loop - // converges so a popped entry never needs a status revert. + // estimator drift: the erasures would roll back with the aborted tx, the same set would re-pack + // against the same estimator next epoch, and the same check would trip again. Popped entries stay + // READY and ride the next `buildenv` call. Erasure waits until the loop converges, so a popped entry + // is never touched. std::vector packed = build_packed(entries); while (packed.size() > MAX_ENVELOPE_BYTES) { // Invariant: the estimate over-counts the real encoding, so one row within the `queueout` cap @@ -1679,16 +1688,20 @@ void msgch::buildenv(uint64_t chain_code) { packed = build_packed(entries); } - // Mark the surviving attestations as PROCESSED. Remaining candidates (popped by the trim loop or - // never picked by the estimator) stay READY for the next epoch's `buildenv` call. - for (uint64_t aid : included_ids) { - auto att_pk = id_key{aid}; - if (atts.contains(att_pk)) { - atts.modify(same_payer, att_pk, [&](auto& a) { - a.status = AttestationStatus::ATTESTATION_STATUS_PROCESSED; - a.processed_timestamp = now_sec; - }); - } + // The shipped rows' bytes are in `packed` now: erase them, and count the general-lane ones out of the + // outpost's queue. Rows the walk stopped before, or the trim loop popped, stay READY for the next + // epoch's `buildenv` call. + uint32_t general_shipped = 0; + for (size_t i = 0; i < included_ids.size(); ++i) { + if (lane_of(entries[i].type) == queue_lane::general) ++general_shipped; + atts.erase(id_key{included_ids[i]}); + } + if (general_shipped > 0) { + outqueue_t queues(get_self()); + auto queue = outbound_queue_of(queues, chain_code); + // Saturating: the count only bounds `queueout`, and a short one must not abort `advance`. + queue.queued -= std::min(queue.queued, general_shipped); + queues.set(ram_payer, outbound_queue_key{chain_code}, queue); } // This envelope's canonical epoch digest: keccak256 over the packed bytes, which equal the @@ -1715,8 +1728,7 @@ void msgch::buildenv(uint64_t chain_code) { // Audit-log row mirrors the outbound emit (WIRE → outpost). Followed // by inline drains of the previous-epoch outenvelopes row (one-deep // retention; the batch op only ever reads the most-recent emit) and - // the just-PROCESSED attestations for this outpost (their bytes are - // now baked into `packed` above). + // this outpost's inbound attestations, already dispatched. { // Same endpoints the wire envelope carries (derived above from the // destination's `sysio.chains` row). @@ -1730,16 +1742,13 @@ void msgch::buildenv(uint64_t chain_code) { it = by_outpost.erase(std::move(it)); } - // Drop the attestations we just consumed (status PROCESSED rows - // for this outpost). They've been bundled into `packed`; the - // bytes are dead weight on chain. - auto processed_idx = atts.get_index<"bystatus"_n>(); - for (auto it = processed_idx.lower_bound( - static_cast(AttestationStatus::ATTESTATION_STATUS_PROCESSED)); - it != processed_idx.end() && + // Drop this outpost's inbound attestations: `evalcons` stores them PROCESSED and dispatches them + // inline, so their bytes are dead weight on chain. The range holds this outpost's rows only. + for (auto it = queue.lower_bound( + queue_key(chain_code, AttestationStatus::ATTESTATION_STATUS_PROCESSED, queue_lane::schedule)); + it != queue.end() && it->chain_code == chain_code && it->status == AttestationStatus::ATTESTATION_STATUS_PROCESSED; ) { - if (it->chain_code != chain_code) { ++it; continue; } - it = processed_idx.erase(std::move(it)); + it = queue.erase(std::move(it)); } } } diff --git a/contracts/sysio.msgch/sysio.msgch.abi b/contracts/sysio.msgch/sysio.msgch.abi index 91c63abca7..0951fc3e09 100644 --- a/contracts/sysio.msgch/sysio.msgch.abi +++ b/contracts/sysio.msgch/sysio.msgch.abi @@ -308,6 +308,30 @@ } ] }, + { + "name": "outbound_queue_entry", + "base": "", + "fields": [ + { + "name": "chain_code", + "type": "uint64" + }, + { + "name": "queued", + "type": "uint32" + } + ] + }, + { + "name": "outbound_queue_key", + "base": "", + "fields": [ + { + "name": "chain_code", + "type": "uint64" + } + ] + }, { "name": "outpost_consensus_entry", "base": "", @@ -442,19 +466,9 @@ "table_id": 4978, "secondary_indexes": [ { - "name": "bystatus", - "key_type": "uint64", - "table_id": 15348 - }, - { - "name": "bytype", - "key_type": "uint64", - "table_id": 31638 - }, - { - "name": "byepoch", - "key_type": "uint64", - "table_id": 21249 + "name": "byqueue", + "key_type": "uint128", + "table_id": 11308 } ] }, @@ -541,6 +555,14 @@ "key_names": ["chain_code"], "key_types": ["uint64"], "table_id": 51836 + }, + { + "name": "outqueue", + "type": "outbound_queue_entry", + "index_type": "i64", + "key_names": ["chain_code"], + "key_types": ["uint64"], + "table_id": 52512 } ], "ricardian_clauses": [], diff --git a/contracts/sysio.msgch/sysio.msgch.wasm b/contracts/sysio.msgch/sysio.msgch.wasm index 821f80a73c24f52b1cba1e0e4b5d1215c4fe7ce2..b41c9410d60702af65b81922684e58c3fd3ffab8 100755 GIT binary patch delta 13422 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 NR-%d=kG9ja{{t~eHst^S diff --git a/contracts/tests/sysio.msgch_chain_tests.cpp b/contracts/tests/sysio.msgch_chain_tests.cpp index 38abc267bb..507decae5a 100644 --- a/contracts/tests/sysio.msgch_chain_tests.cpp +++ b/contracts/tests/sysio.msgch_chain_tests.cpp @@ -751,7 +751,7 @@ class sysio_msgch_chain_tester : public tester { /// `chain_code`. Read from the surviving `outenvelopes` row, NOT the msgch `attestations` /// working table — `advance` queues its schedule attestations via `queueout` and then fans out /// `buildenv` inline in the SAME transaction, and `buildenv` packs the READY rows into the - /// outbound envelope, marks them PROCESSED, and ERASES them before the transaction ends. The + /// outbound envelope and ERASES them before the transaction ends. The /// packed envelope (one-deep per outpost) is therefore the only durable copy of what `advance` /// emitted; scanning `attestations` after an advance always finds zero rows. sysio::opp::attestations::BatchOperatorGroups shipped_batch_operator_groups(uint64_t chain_code) { diff --git a/contracts/tests/sysio.msgch_tests.cpp b/contracts/tests/sysio.msgch_tests.cpp index 9ff0e44c71..4f5abd5596 100644 --- a/contracts/tests/sysio.msgch_tests.cpp +++ b/contracts/tests/sysio.msgch_tests.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -19,6 +20,20 @@ using mvo = fc::mutable_variant_object; namespace { +/// A one-of-a-kind attestation payload carrying `index`, so repeated `queueout`s never collapse as +/// duplicate transactions and a shipped row can be traced back to its `queueout`. +std::vector indexed_payload(uint32_t index) { + return {static_cast(index >> 24), static_cast(index >> 16), + static_cast(index >> 8), static_cast(index)}; +} + +/// The index an `indexed_payload` carries. +uint32_t payload_index(const std::string& data) { + BOOST_REQUIRE_EQUAL(data.size(), 4u); + return (uint32_t{static_cast(data[0])} << 24) | (uint32_t{static_cast(data[1])} << 16) | + (uint32_t{static_cast(data[2])} << 8) | uint32_t{static_cast(data[3])}; +} + } // anonymous namespace class sysio_msgch_tester : public tester { @@ -317,6 +332,45 @@ class sysio_msgch_envlog_tester : public tester { ); } + /// Queue `count` general-lane rows for `chain_code`, with payloads `indexed_payload(first)` onward, + /// producing a block every `per_block` so the pending block stays inside its limits. + void queue_general(uint64_t chain_code, uint32_t first, uint32_t count, uint32_t per_block = 20) { + for (uint32_t i = 0; i < count; ++i) { + BOOST_REQUIRE_EQUAL(success(), queueout_with_data(chain_code, opp::types::ATTESTATION_TYPE_DESYNDICATE_LIQ, + indexed_payload(first + i))); + if ((i + 1) % per_block == 0) produce_block(); + } + produce_block(); + } + + /// The outpost's general-lane READY count in `outqueue`; 0 before its first such row. + uint32_t queued_attestations(uint64_t chain_code) { + const auto data = get_row_by_id(MSGCH_ACCOUNT, MSGCH_ACCOUNT, "outqueue"_n, chain_code); + if (data.empty()) return 0; + const auto row = msgch_abi.binary_to_variant( + "outbound_queue_entry", data, abi_serializer::create_yield_function(abi_serializer_max_time)); + return row["queued"].as(); + } + + /// The attestations of the envelope `buildenv` last built for `chain_code`, in shipped order. + std::vector shipped_attestations(uint64_t chain_code, uint64_t scan_until = 16) { + for (uint64_t id = 0; id < scan_until; ++id) { + const auto data = get_row_by_id(MSGCH_ACCOUNT, MSGCH_ACCOUNT, "outenvelopes"_n, id); + if (data.empty()) continue; + const auto row = msgch_abi.binary_to_variant( + "outbound_envelope", data, abi_serializer::create_yield_function(abi_serializer_max_time)); + if (row["chain_code"].as_uint64() != chain_code) continue; + const auto raw = row["raw_envelope"].as>(); + opp::Envelope env; + BOOST_REQUIRE(env.ParseFromArray(raw.data(), static_cast(raw.size()))); + BOOST_REQUIRE_EQUAL(env.messages_size(), 1); + const auto& shipped = env.messages(0).payload().attestations(); + return {shipped.begin(), shipped.end()}; + } + BOOST_FAIL("no outbound envelope for the outpost"); + return {}; + } + /// Return the first outbound envelope row found in a small id scan. fc::variant find_outbound_envelope(uint64_t scan_until = 16) { for (uint64_t id = 0; id < scan_until; ++id) { @@ -363,6 +417,26 @@ constexpr size_t ATTESTATION_OVERHEAD_BYTES = 24; constexpr size_t MAX_ATTESTATION_DATA_BYTES = MAX_ENVELOPE_BYTES - ENVELOPE_BASELINE_BYTES - ATTESTATION_OVERHEAD_BYTES; +/// Mirrors of the outbound queue bounds in `sysio.msgch.hpp`. +constexpr size_t MAX_ENVELOPE_ATTESTATIONS = 64; +constexpr uint32_t MAX_QUEUED_ATTESTATIONS = 512; +constexpr size_t SCHEDULE_LANE_BYTES = 24 * 1024; +constexpr size_t MAX_GENERAL_ATTESTATION_DATA_BYTES = + MAX_ENVELOPE_BYTES - ENVELOPE_BASELINE_BYTES - SCHEDULE_LANE_BYTES - ATTESTATION_OVERHEAD_BYTES; + +/// Mirrors of the window ceilings `sysio.epoch::setconfig` enforces in `sysio.epoch.hpp`. +constexpr uint32_t MAX_BATCH_OP_GROUPS = 8; +constexpr uint32_t MAX_OPERATORS_PER_EPOCH = 16; + +/// `register_outpost(CHAIN_KIND_SVM, ...)` registers the spelling `"SOL"`. +constexpr uint64_t SOL_OUTPOST_ID = "SOL"_s.value; + +/// A general-lane type (anything but OPERATORS and BATCH_OPERATOR_GROUPS), as `queue_general` queues. +constexpr auto GENERAL_LANE_TYPE = opp::types::ATTESTATION_TYPE_DESYNDICATE_LIQ; + +constexpr auto QUEUE_FULL_ERROR = + "assertion failure with message: sysio.msgch::queueout: outbound queue is full for this outpost"; + } // anonymous namespace /// Smoke: queueout + buildenv writes one row to `envlog` with the @@ -670,6 +744,225 @@ BOOST_FIXTURE_TEST_CASE(buildenv_ships_an_attestation_at_the_cap_alone, sysio_ms BOOST_REQUIRE_EQUAL(1u, count_ready_attestations(ETH_OUTPOST_ID, /*scan_until=*/4)); } FC_LOG_AND_RETHROW() } +/// WIRE-415: OPERATORS and BATCH_OPERATOR_GROUPS ship ahead of a general-lane backlog queued before +/// them, so the rosters an outpost needs for the next epoch never wait behind it. The backlog keeps +/// its `queueout` order. +BOOST_FIXTURE_TEST_CASE(buildenv_ships_the_schedule_lane_ahead_of_a_backlog, sysio_msgch_envlog_tester) { try { + bootstrap_epoch_config(/*retention=*/200); + register_outpost(opp::types::CHAIN_KIND_EVM, 31337); + produce_blocks(); + + constexpr uint32_t BACKLOG = 4; + queue_general(ETH_OUTPOST_ID, /*first=*/0, BACKLOG); + BOOST_REQUIRE_EQUAL(success(), queueout_with_data(ETH_OUTPOST_ID, opp::types::ATTESTATION_TYPE_OPERATORS, + indexed_payload(100))); + BOOST_REQUIRE_EQUAL(success(), queueout_with_data(ETH_OUTPOST_ID, + opp::types::ATTESTATION_TYPE_BATCH_OPERATOR_GROUPS, + indexed_payload(101))); + produce_blocks(); + + BOOST_REQUIRE_EQUAL(success(), buildenv(ETH_OUTPOST_ID)); + produce_blocks(); + + const auto shipped = shipped_attestations(ETH_OUTPOST_ID); + BOOST_REQUIRE_EQUAL(shipped.size(), BACKLOG + 2); + BOOST_REQUIRE_EQUAL(shipped[0].type(), opp::types::ATTESTATION_TYPE_OPERATORS); + BOOST_REQUIRE_EQUAL(shipped[1].type(), opp::types::ATTESTATION_TYPE_BATCH_OPERATOR_GROUPS); + for (uint32_t i = 0; i < BACKLOG; ++i) { + BOOST_REQUIRE_EQUAL(shipped[2 + i].type(), GENERAL_LANE_TYPE); + BOOST_REQUIRE_EQUAL(payload_index(shipped[2 + i].data()), i); + } + BOOST_REQUIRE_EQUAL(0u, queued_attestations(ETH_OUTPOST_ID)); +} FC_LOG_AND_RETHROW() } + +/// WIRE-415: an envelope carries at most MAX_ENVELOPE_ATTESTATIONS rows however small they are, so +/// `buildenv`'s work per outpost stays fixed. The rest ship next time, in order. +BOOST_FIXTURE_TEST_CASE(buildenv_stops_at_the_attestation_cap, sysio_msgch_envlog_tester) { try { + bootstrap_epoch_config(/*retention=*/200); + register_outpost(opp::types::CHAIN_KIND_EVM, 31337); + produce_blocks(); + + constexpr uint32_t LEFT_OVER = 3; + constexpr uint32_t QUEUED = MAX_ENVELOPE_ATTESTATIONS + LEFT_OVER; + queue_general(ETH_OUTPOST_ID, /*first=*/0, QUEUED); + BOOST_REQUIRE_EQUAL(QUEUED, queued_attestations(ETH_OUTPOST_ID)); + + BOOST_REQUIRE_EQUAL(success(), buildenv(ETH_OUTPOST_ID)); + produce_blocks(); + const auto first = shipped_attestations(ETH_OUTPOST_ID); + BOOST_REQUIRE_EQUAL(first.size(), MAX_ENVELOPE_ATTESTATIONS); + for (uint32_t i = 0; i < first.size(); ++i) { + BOOST_REQUIRE_EQUAL(payload_index(first[i].data()), i); + } + BOOST_REQUIRE_EQUAL(LEFT_OVER, count_ready_attestations(ETH_OUTPOST_ID, /*scan_until=*/QUEUED + 2)); + BOOST_REQUIRE_EQUAL(LEFT_OVER, queued_attestations(ETH_OUTPOST_ID)); + + BOOST_REQUIRE_EQUAL(success(), buildenv(ETH_OUTPOST_ID)); + produce_blocks(); + const auto second = shipped_attestations(ETH_OUTPOST_ID); + BOOST_REQUIRE_EQUAL(second.size(), LEFT_OVER); + for (uint32_t i = 0; i < second.size(); ++i) { + BOOST_REQUIRE_EQUAL(payload_index(second[i].data()), MAX_ENVELOPE_ATTESTATIONS + i); + } + BOOST_REQUIRE_EQUAL(0u, count_ready_attestations(ETH_OUTPOST_ID, /*scan_until=*/QUEUED + 2)); + BOOST_REQUIRE_EQUAL(0u, queued_attestations(ETH_OUTPOST_ID)); +} FC_LOG_AND_RETHROW() } + +/// WIRE-415: `buildenv` takes only its own outpost's rows; another outpost's queue and count are +/// untouched until its own `buildenv`. +BOOST_FIXTURE_TEST_CASE(buildenv_ships_only_its_own_outposts_rows, sysio_msgch_envlog_tester) { try { + bootstrap_epoch_config(/*retention=*/200); + register_outpost(opp::types::CHAIN_KIND_EVM, 31337); + register_outpost(opp::types::CHAIN_KIND_SVM, 1); + produce_blocks(); + + queue_general(SOL_OUTPOST_ID, /*first=*/0, /*count=*/2); + queue_general(ETH_OUTPOST_ID, /*first=*/2, /*count=*/1); + queue_general(SOL_OUTPOST_ID, /*first=*/3, /*count=*/1); + + BOOST_REQUIRE_EQUAL(success(), buildenv(ETH_OUTPOST_ID)); + produce_blocks(); + const auto eth = shipped_attestations(ETH_OUTPOST_ID); + BOOST_REQUIRE_EQUAL(eth.size(), 1u); + BOOST_REQUIRE_EQUAL(payload_index(eth[0].data()), 2u); + BOOST_REQUIRE_EQUAL(3u, count_ready_attestations(SOL_OUTPOST_ID, /*scan_until=*/8)); + BOOST_REQUIRE_EQUAL(3u, queued_attestations(SOL_OUTPOST_ID)); + BOOST_REQUIRE_EQUAL(0u, queued_attestations(ETH_OUTPOST_ID)); + + BOOST_REQUIRE_EQUAL(success(), buildenv(SOL_OUTPOST_ID)); + produce_blocks(); + const auto sol = shipped_attestations(SOL_OUTPOST_ID); + BOOST_REQUIRE_EQUAL(sol.size(), 3u); + BOOST_REQUIRE_EQUAL(payload_index(sol[0].data()), 0u); + BOOST_REQUIRE_EQUAL(payload_index(sol[1].data()), 1u); + BOOST_REQUIRE_EQUAL(payload_index(sol[2].data()), 3u); + BOOST_REQUIRE_EQUAL(0u, queued_attestations(SOL_OUTPOST_ID)); +} FC_LOG_AND_RETHROW() } + +/// WIRE-415: the largest window's schedule rows fit SCHEDULE_LANE_BYTES, so a general row at +/// MAX_GENERAL_ATTESTATION_DATA_BYTES always ships beside them. Built at the extremes `sysio.epoch` allows: +/// 8 groups of 16, 13-character names, and an EVM and an SVM link on every operator. +BOOST_AUTO_TEST_CASE(the_largest_windows_schedule_fits_its_lane_budget) { try { + constexpr size_t EVM_KEY_BYTES = 33; // a compressed EM key, as authex stores it + constexpr size_t SVM_KEY_BYTES = 32; // an ED key + const std::string longest_name = "zzzzzzzzzzzzj"; + + opp::attestations::Operators roster; + opp::attestations::BatchOperatorGroups groups; + groups.set_active_group_index(MAX_BATCH_OP_GROUPS - 1); + groups.set_epoch_index(std::numeric_limits::max()); + groups.set_epoch_duration_sec(std::numeric_limits::max()); + for (uint32_t g = 0; g < MAX_BATCH_OP_GROUPS; ++g) { + auto* group = groups.add_groups(); + for (uint32_t m = 0; m < MAX_OPERATORS_PER_EPOCH; ++m) { + auto* member = group->add_operators(); + member->set_kind(opp::types::CHAIN_KIND_WIRE); + member->set_address(longest_name); + + auto* entry = roster.add_operators(); + entry->mutable_account()->set_name(longest_name); + entry->set_type(opp::types::OPERATOR_TYPE_BATCH); + entry->set_status(opp::types::OPERATOR_STATUS_SLASHED); // the widest status varint + auto* evm = entry->add_addresses(); + evm->set_kind(opp::types::CHAIN_KIND_EVM); + evm->set_address(std::string(EVM_KEY_BYTES, '\xff')); + auto* svm = entry->add_addresses(); + svm->set_kind(opp::types::CHAIN_KIND_SVM); + svm->set_address(std::string(SVM_KEY_BYTES, '\xff')); + } + } + + const size_t schedule_bytes = + 2 * ATTESTATION_OVERHEAD_BYTES + roster.ByteSizeLong() + groups.ByteSizeLong(); + BOOST_TEST_MESSAGE("largest window's schedule lane = " << schedule_bytes << " bytes"); + BOOST_REQUIRE_LE(schedule_bytes, SCHEDULE_LANE_BYTES); +} FC_LOG_AND_RETHROW() } + +/// WIRE-415: a general-lane row may carry at most MAX_GENERAL_ATTESTATION_DATA_BYTES, what fits beside a +/// full schedule lane. A schedule row keeps the whole-envelope cap. +BOOST_FIXTURE_TEST_CASE(queueout_caps_general_rows_to_fit_beside_the_schedule, sysio_msgch_envlog_tester) { try { + bootstrap_epoch_config(/*retention=*/200); + register_outpost(opp::types::CHAIN_KIND_EVM, 31337); + produce_blocks(); + + const std::vector over_general_cap(MAX_GENERAL_ATTESTATION_DATA_BYTES + 1, 0x42); + BOOST_REQUIRE_EQUAL( + error("assertion failure with message: " + "sysio.msgch::queueout: attestation exceeds MAX_GENERAL_ATTESTATION_DATA_BYTES"), + queueout_with_data(ETH_OUTPOST_ID, GENERAL_LANE_TYPE, over_general_cap)); + BOOST_REQUIRE_EQUAL(success(), queueout_with_data(ETH_OUTPOST_ID, GENERAL_LANE_TYPE, + std::vector(MAX_GENERAL_ATTESTATION_DATA_BYTES, 0x42))); + BOOST_REQUIRE_EQUAL(success(), queueout_with_data(ETH_OUTPOST_ID, opp::types::ATTESTATION_TYPE_OPERATORS, + over_general_cap)); +} FC_LOG_AND_RETHROW() } + +/// WIRE-415: a general row at its cap ships in the same envelope as a schedule lane at its budget, so +/// the general lane moves every epoch. +BOOST_FIXTURE_TEST_CASE(buildenv_ships_a_general_row_at_its_cap_beside_a_full_schedule, + sysio_msgch_envlog_tester) { try { + bootstrap_epoch_config(/*retention=*/200); + register_outpost(opp::types::CHAIN_KIND_EVM, 31337); + produce_blocks(); + + // The schedule budget split between the two schedule rows, each charged its overhead. + constexpr size_t BATCH_OPERATOR_GROUPS_BYTES = 4 * 1024; + constexpr size_t OPERATORS_BYTES = + SCHEDULE_LANE_BYTES - 2 * ATTESTATION_OVERHEAD_BYTES - BATCH_OPERATOR_GROUPS_BYTES; + BOOST_REQUIRE_EQUAL(success(), queueout_with_data(ETH_OUTPOST_ID, GENERAL_LANE_TYPE, + std::vector(MAX_GENERAL_ATTESTATION_DATA_BYTES, 0x42))); + BOOST_REQUIRE_EQUAL(success(), queueout_with_data(ETH_OUTPOST_ID, opp::types::ATTESTATION_TYPE_OPERATORS, + std::vector(OPERATORS_BYTES, 0x43))); + BOOST_REQUIRE_EQUAL(success(), queueout_with_data(ETH_OUTPOST_ID, + opp::types::ATTESTATION_TYPE_BATCH_OPERATOR_GROUPS, + std::vector(BATCH_OPERATOR_GROUPS_BYTES, 0x44))); + produce_blocks(); + + BOOST_REQUIRE_EQUAL(success(), buildenv(ETH_OUTPOST_ID)); + produce_blocks(); + + const auto shipped = shipped_attestations(ETH_OUTPOST_ID); + BOOST_REQUIRE_EQUAL(shipped.size(), 3u); + BOOST_REQUIRE_EQUAL(shipped[0].type(), opp::types::ATTESTATION_TYPE_OPERATORS); + BOOST_REQUIRE_EQUAL(shipped[1].type(), opp::types::ATTESTATION_TYPE_BATCH_OPERATOR_GROUPS); + BOOST_REQUIRE_EQUAL(shipped[2].type(), GENERAL_LANE_TYPE); + BOOST_REQUIRE_EQUAL(shipped[2].data().size(), MAX_GENERAL_ATTESTATION_DATA_BYTES); + BOOST_REQUIRE_EQUAL(0u, count_ready_attestations(ETH_OUTPOST_ID, /*scan_until=*/6)); +} FC_LOG_AND_RETHROW() } + +/// WIRE-415: an outpost holds at most MAX_QUEUED_ATTESTATIONS general-lane rows; the next one fails its +/// producer's transaction. Schedule rows and other outposts are unaffected, and shipping rows frees +/// exactly that many places. +BOOST_FIXTURE_TEST_CASE(queueout_refuses_general_rows_past_the_queue_cap, sysio_msgch_envlog_tester) { try { + bootstrap_epoch_config(/*retention=*/200); + register_outpost(opp::types::CHAIN_KIND_EVM, 31337); + register_outpost(opp::types::CHAIN_KIND_SVM, 1); + produce_blocks(); + + uint32_t next_index = 0; + queue_general(ETH_OUTPOST_ID, next_index, MAX_QUEUED_ATTESTATIONS); + next_index += MAX_QUEUED_ATTESTATIONS; + BOOST_REQUIRE_EQUAL(MAX_QUEUED_ATTESTATIONS, queued_attestations(ETH_OUTPOST_ID)); + + BOOST_REQUIRE_EQUAL(error(QUEUE_FULL_ERROR), + queueout_with_data(ETH_OUTPOST_ID, GENERAL_LANE_TYPE, indexed_payload(next_index++))); + BOOST_REQUIRE_EQUAL(success(), queueout_with_data(ETH_OUTPOST_ID, opp::types::ATTESTATION_TYPE_OPERATORS, + indexed_payload(next_index++))); + queue_general(SOL_OUTPOST_ID, next_index++, /*count=*/1); + BOOST_REQUIRE_EQUAL(MAX_QUEUED_ATTESTATIONS, queued_attestations(ETH_OUTPOST_ID)); + + // The OPERATORS row ships first, then MAX_ENVELOPE_ATTESTATIONS - 1 general rows. + BOOST_REQUIRE_EQUAL(success(), buildenv(ETH_OUTPOST_ID)); + produce_blocks(); + constexpr uint32_t FREED = MAX_ENVELOPE_ATTESTATIONS - 1; + BOOST_REQUIRE_EQUAL(MAX_QUEUED_ATTESTATIONS - FREED, queued_attestations(ETH_OUTPOST_ID)); + + queue_general(ETH_OUTPOST_ID, next_index, FREED); + next_index += FREED; + BOOST_REQUIRE_EQUAL(MAX_QUEUED_ATTESTATIONS, queued_attestations(ETH_OUTPOST_ID)); + BOOST_REQUIRE_EQUAL(error(QUEUE_FULL_ERROR), + queueout_with_data(ETH_OUTPOST_ID, GENERAL_LANE_TYPE, indexed_payload(next_index++))); +} FC_LOG_AND_RETHROW() } + // queueout carries no ABI-level auth. Without the depot-contract gate, any account could call it // directly and inject a forged READY attestation that buildenv() then packs into the depot's // group-signed outbound envelope (a forged DESYNDICATE_LIQ the outpost executes). diff --git a/contracts/tests/sysio.synd_tests.cpp b/contracts/tests/sysio.synd_tests.cpp index 0432011017..c383951c55 100644 --- a/contracts/tests/sysio.synd_tests.cpp +++ b/contracts/tests/sysio.synd_tests.cpp @@ -1472,6 +1472,32 @@ BOOST_FIXTURE_TEST_CASE(desyndicate_burns_and_queues_the_attestation, sysio_synd BOOST_REQUIRE_EQUAL(static_cast(150 * UNIT), supply()); } FC_LOG_AND_RETHROW() +// WIRE-415: once the outpost's outbound queue is full, `queueout` refuses the DESYNDICATE_LIQ and the +// whole `desyndicate` reverts: the holder keeps the shadow, nothing burns, and no `returns` row is +// written. The holder retries once `buildenv` frees a place. +BOOST_FIXTURE_TEST_CASE(desyndicate_reverts_whole_when_the_outbound_queue_is_full, sysio_synd_tester) try { + constexpr uint32_t MAX_QUEUED_ATTESTATIONS = 512; // mirrors sysio.msgch.hpp + constexpr uint32_t QUEUEOUTS_PER_BLOCK = 20; + BOOST_REQUIRE_EQUAL(success(), liq_mint("alice"_n, LIQSOL, 100 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {})); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, ed_key())); + + for (uint32_t i = 0; i < MAX_QUEUED_ATTESTATIONS; ++i) { + BOOST_REQUIRE_EQUAL(success(), push(MSGCH_ACCOUNT, msgch_abi_ser, MSGCH_ACCOUNT, "queueout"_n, mvo() + ("chain_code", slug_value(SOLANA)) + ("attest_type", static_cast(sysio::opp::types::ATTESTATION_TYPE_DESYNDICATE_LIQ)) + ("data", std::vector{static_cast(i >> 8), static_cast(i)}))); + if ((i + 1) % QUEUEOUTS_PER_BLOCK == 0) produce_block(); + } + produce_block(); + + BOOST_REQUIRE_EQUAL(wasm_assert_msg("sysio.msgch::queueout: outbound queue is full for this outpost"), + desyndicate("alice"_n, 40 * UNIT)); + BOOST_REQUIRE_EQUAL(static_cast(100 * UNIT), liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(static_cast(100 * UNIT), supply()); + BOOST_REQUIRE(return_row(1).is_null()); +} FC_LOG_AND_RETHROW() + /// Outstanding returns retain the original beneficiary and exact obligation, without lifetime totals. BOOST_FIXTURE_TEST_CASE(outstanding_returns_record_the_original_obligation, sysio_synd_tester) try { BOOST_REQUIRE_EQUAL(success(), liq_mint("alice"_n, LIQSOL, 100 * UNIT)); diff --git a/docs/contract-upgrade-order.md b/docs/contract-upgrade-order.md index af76464915..cd9bf3094f 100644 --- a/docs/contract-upgrade-order.md +++ b/docs/contract-upgrade-order.md @@ -336,3 +336,11 @@ LIQ earned-yield distribution and their normal claim actions remain. `sysio.synd::setconfig` requires `min_desyndicate` as an ordinary uint64, and `syndconfig` stores it as an ordinary field. Bootstrap schema version 1 contains chains, tokens, LIQ pools and syndications; it has no external-reserve or underwriting configuration. + +## WIRE-415 pre-launch deployment + +Deploy this `sysio.msgch` on fresh state and regenerate the SDK contract types with it. The +`attestations` table's `bystatus`, `bytype` and `byepoch` indexes are replaced by `byqueue`, and a row +written before the change has no `byqueue` entry, so `buildenv` never finds it: a queued +`DESYNDICATE_LIQ` would never ship, and an inbound row would never be erased. There is no migration +path. diff --git a/docs/sysio-synd.md b/docs/sysio-synd.md index b9f4109858..fa72602d0e 100644 --- a/docs/sysio-synd.md +++ b/docs/sysio-synd.md @@ -154,6 +154,8 @@ banked, and a later delivery pays it. Parked funds have already paid the fee and the next request id from `syndcounters`. `returns` retains the original holder, destination key, outpost, token and exact net amount until its external outcome is established. The attestation carries `total_syndicated`, the live outstanding shadow after the burn; no historical total is stored. + While the outpost already has `sysio.msgch`'s `MAX_QUEUED_ATTESTATIONS` (512) outbound attestations + waiting, `queueout` refuses it and the whole `desyndicate` reverts, burn included. The burn is final. An outpost either pays the release, stores it and pays it later, or skips it: @@ -526,7 +528,8 @@ desyndication. The row must exist, with the buckets set, before the pair carries delivered, and `sysio.liq::claim` pays it. - `desyndicate` takes the whole quantity from the holder's balance and releases the quantity less the desyndication fee on the outpost. It is refused when the pair's desyndication budget is too low now; - it refills every depot epoch. + it refills every depot epoch. It is also refused while the outpost's outbound queue is full, which + drains every depot epoch. ## What an operator sees From 06ceae39007ee3fc5be3aa4912de8ae685f56f1b Mon Sep 17 00:00:00 2001 From: Kevin Heifner Date: Thu, 22 Oct 2026 17:29:37 -0400 Subject: [PATCH 4/6] OPP: cap the batch-operator group at Ethereum's 7 `sysio.epoch::setconfig` now takes at most 7 operators per group, Ethereum's `OPERATORS_PER_GROUP`, instead of Solana's 16. Ethereum applies the OPERATORS roster in one handler call, and at 8 x 16 that call runs out of gas (`OPP_HandlerGasExhausted`, HM's review). At 8 x 7 the roster is at most 56 entries, and on wire-ethereum `next` a roster with 49 operators it has never seen dispatches in one transaction: 9.3M gas, 12.4M with the outbound queue full, of EIP-7825's 16,777,216 (WIRE-342). At most 7 operators over 10 epochs is 70 payout credits, so the history bound is the cadence limit and the 100-credit cap stays only as a backstop. The emissions tests now cover that bound and the group-size ceiling, and the legacy-cadence recovery runs at 7 per group. --- contracts/sysio.epoch/README.md | 2 +- .../include/sysio.epoch/sysio.epoch.hpp | 18 ++++--- contracts/sysio.epoch/src/sysio.epoch.cpp | 2 +- contracts/sysio.epoch/sysio.epoch.wasm | Bin 76265 -> 76261 bytes contracts/sysio.system/EMISSIONS.md | 6 +-- .../include/sysio.system/emissions.hpp | 13 +++-- contracts/tests/emissions_tests.cpp | 51 +++++++----------- contracts/tests/sysio.epoch_tests.cpp | 16 +++--- docs/contract-upgrade-order.md | 7 ++- 9 files changed, 51 insertions(+), 64 deletions(-) diff --git a/contracts/sysio.epoch/README.md b/contracts/sysio.epoch/README.md index 62e2a850b6..4833557a7b 100644 --- a/contracts/sysio.epoch/README.md +++ b/contracts/sysio.epoch/README.md @@ -28,7 +28,7 @@ Operator registration, collateral and status live on `sysio.opreg`; the outpost | Action | Auth | Description | |--------|------|-------------| -| `setconfig` | `sysio.epoch` | Set the epoch configuration. Requires `operators_per_epoch * batch_op_groups == batch_operator_minimum_active` and enforces Solana's seating limits (`MAX_OPERATORS_PER_EPOCH` 16, `MAX_BATCH_OP_GROUPS` 8), so the window holds at most `MAX_SCHEDULED_BATCH_OPERATORS` (128) | +| `setconfig` | `sysio.epoch` | Set the epoch configuration. Requires `operators_per_epoch * batch_op_groups == batch_operator_minimum_active` and enforces the outposts' limits (`MAX_OPERATORS_PER_EPOCH` 7, Ethereum's group size; `MAX_BATCH_OP_GROUPS` 8, Solana's group count), so the window holds at most `MAX_SCHEDULED_BATCH_OPERATORS` (56) | | `schbatchgps` | `sysio.epoch` | Build the initial window from the `ACTIVE` batch operators on `sysio.opreg` (non-bootstrapped first, then bootstrapped, trimmed to `batch_operator_minimum_active`) | | `advance` | permissionless at genesis (epoch 0 → 1); afterwards `sysio.msgch` or `sysio.epoch` | Advance the epoch once the wall-clock boundary has passed and the contract is not paused | | `pause` | `sysio.chalg` | Set the global pause | diff --git a/contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp b/contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp index 4d2c910d19..36a8853473 100644 --- a/contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp +++ b/contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp @@ -157,16 +157,18 @@ namespace sysio { /// `MAX_OPERATOR_GROUPS` (8), and a window an outpost cannot seat stalls that outpost. static constexpr uint32_t MAX_BATCH_OP_GROUPS = 8; - /// Upper bound on `operators_per_epoch` (the size of one schedule group): Solana seats at most - /// `MAX_GROUP_MEMBERS` (16) per group. Each member of the expiring group also costs inline actions - /// in `advance` (opreg::recorddel + opreg::termcheck per outpost, and a sysio.system::payepoch - /// transfer). - static constexpr uint32_t MAX_OPERATORS_PER_EPOCH = 16; + /// Upper bound on `operators_per_epoch` (the size of one schedule group): Ethereum's OPPInbound is + /// sized and tested for `OPERATORS_PER_GROUP` (7), under Solana's `MAX_GROUP_MEMBERS` (16). Each + /// member of the expiring group also costs inline actions in `advance` (opreg::recorddel + + /// opreg::termcheck per outpost, and a sysio.system::payepoch transfer). + static constexpr uint32_t MAX_OPERATORS_PER_EPOCH = 7; /// Largest schedule window (operators_per_epoch * batch_op_groups, which the product equality pins - /// `batch_operator_minimum_active` to), matching Solana's `MAX_OPERATORS` (128) registry. The OPERATORS - /// roster and the BatchOperatorGroups attestation list only the window, so this also bounds both - /// (about 13 KB of roster at the ceiling). + /// `batch_operator_minimum_active` to). The OPERATORS roster and the BatchOperatorGroups attestation + /// list only the window, so this also bounds both (about 5.6 KB of roster at the ceiling). Ethereum + /// applies the roster in one handler call, so it must fit one transaction: at 8 x 7, with 49 operators + /// Ethereum has never seen, it takes about 9.3M gas (12.4M with the outbound queue full) of EIP-7825's + /// 16,777,216, while 8 x 16 runs the handler out of gas (WIRE-342). static constexpr uint32_t MAX_SCHEDULED_BATCH_OPERATORS = MAX_BATCH_OP_GROUPS * MAX_OPERATORS_PER_EPOCH; private: diff --git a/contracts/sysio.epoch/src/sysio.epoch.cpp b/contracts/sysio.epoch/src/sysio.epoch.cpp index 01e13f8b53..1c50a8c167 100644 --- a/contracts/sysio.epoch/src/sysio.epoch.cpp +++ b/contracts/sysio.epoch/src/sysio.epoch.cpp @@ -261,7 +261,7 @@ void epoch::setconfig(uint32_t epoch_duration_sec, // halt epoch advancement chain-wide. With both factors bounded, the window // cannot exceed MAX_SCHEDULED_BATCH_OPERATORS. check(operators_per_epoch <= MAX_OPERATORS_PER_EPOCH, - "operators_per_epoch exceeds the outpost group-size ceiling (16)"); + "operators_per_epoch exceeds the outpost group-size ceiling (7)"); check(epoch_retention_envelope_log_count > 0, "epoch_retention_envelope_log_count must be positive"); diff --git a/contracts/sysio.epoch/sysio.epoch.wasm b/contracts/sysio.epoch/sysio.epoch.wasm index 0bcb58498f1ea6fcf99f1293dd1f280ed64b5415..fa3f253feb8ca5dabb0d4a7f542ea4910c86e338 100755 GIT binary patch delta 1195 zcmYLHZ%kWN6u;-bR|=Lwp)E*;+k>sFtgxUGs&1LQFQRcK3Ln@9M_86%_7cs2g=~yW zkR>IGS><9%qMOc4BC z-r}S@KPiXmuN) z3Oc|PQx1ff*Ho2t4xJS7hWIREh@CPO!4Uu2lxKyDESlY)oBDzk^Qfmshz%^XQZX6o z413q^1wRYhTcDrqc07svuwxZ^ndU4;-s>!f6*h{}&;E06hhbinbJPU4`A3_qvg2Gt zBvU{=DQPJCBu(|R;VrH3J=>-1fLYd{G-KfJ%G2QIj)Dsmme|(5Yd#WhH8aH=;bF49H4Y4B2yV(D;(x4r)th|I4Ce#%8;6<^*E zkQWL=H;ab`AD(W+*x$5qx=+8F#j!dE#fWQz1slUbDM`S_a69=Bg)4mSLYLik>gV(#JN>JR-yiE0)HUbgm&Q9%knWmXg;?P2JaDlOrX1{zDf2^h6U4Ya zHNPGl!JFw*F80oEL-<@d-2e!mUA-t{lKN{wlwIGhA0vT z!(?uq8Rcr)WOJJ)=oagS+`6Y(mN^4iTiGmyFkOV=2&7~&of`)L*O24G3UUq$58{A-9l}#|g8kH6d{9Snoyxd-Ng+h`=)(=9GcW;=au*{n_ zE=ZXFfU6WGa}9up?J0ADhj*4W1HxQ+JI3&)ibE;D*o=F;@W)%kav;+v+L0!Terc4B zf~F>Uyy}LGy`9_Eo&f>A?zP!xAjZ;bHTaqxsx5laJ4h_M6x?E|M5?5OtVn8ytf5TE z*f<(h+>7L-9kK$i8tWO0hymGft-|lu?v}8UBjZ@6=UWJ;UgsNaZ9-jR+6l zvgZeRZ8AbIZ-{E@$(w>LVuc=qIW_ecp1HS420uTxZy%){QGRd#_AFe0(p&+H{LSWe zSQ{7;QP`dOz+f2l$RNjY&=2{aE#28q0xor>o1Q3%6D$+X@%Gb65Sicc{hW`;+~3|4 zP|!CB-7MbM|MYYN#{O;_r|Z2uj$?K8ixL0rFIpWA%1HuNhuh496vp_}l@m5g_pdA0 z$0H>0$Mo`};d+2DJ2U3sk0Yl9bxb+2lMxDKN=*-Vca4(b98&9}zCTxgH3xP4jKY8zx%Vvk!-n9bIs-=zJAxNEt3mFj*Op2s=#nwLIq6vHs?S!4C$>oK*d>o zZ8p#l^%)yf8V`lJa8Ve-x$v35xAR~?IF#qZTNGkC?%kFO5&d%o_EWmWt<&{z9@2^0 m86p|)r#0)BH^3t)hY$kzsa;W$(Mt&>ME~9iB_QuECFFlNzQFnb diff --git a/contracts/sysio.system/EMISSIONS.md b/contracts/sysio.system/EMISSIONS.md index ed97b1d4c5..2d86fe24f7 100644 --- a/contracts/sysio.system/EMISSIONS.md +++ b/contracts/sysio.system/EMISSIONS.md @@ -103,9 +103,9 @@ ordinary payment period to at most ten immutable roster snapshots. It also validates `pay_cadence_epochs * operators_per_epoch <= 100`, a conservative ceiling on expensive per-recipient credits that limits peak work in the mandatory `advance` transaction; `sysio.epoch` enforces the same constraint when -its operator count changes. With the default 7-operator groups the ten-snapshot -bound is the limit (an hour at six-minute epochs); the largest group `setconfig` -accepts, 16 operators, allows a cadence of at most 6. When +its operator count changes. The largest group `setconfig` accepts is 7 operators, +so the ten-snapshot bound is the limit (an hour at six-minute epochs, 70 +credits). When `sysio.epoch` reads an older serialized configuration during an upgrade, it effectively shortens the cadence to satisfy both bounds; the stored value is not rewritten until a normal `setemitcfg` update. `payepoch` independently refuses diff --git a/contracts/sysio.system/include/sysio.system/emissions.hpp b/contracts/sysio.system/include/sysio.system/emissions.hpp index 9098ac6c17..92c37e892d 100644 --- a/contracts/sysio.system/include/sysio.system/emissions.hpp +++ b/contracts/sysio.system/include/sysio.system/emissions.hpp @@ -35,13 +35,12 @@ inline constexpr uint32_t T2_MAX_NODE_OWNERS = 84; inline constexpr uint32_t T3_MAX_NODE_OWNERS = 1000; // Each recorded roster is the group on duty for one epoch, at most sysio.epoch's -// MAX_OPERATORS_PER_EPOCH (16) operators. Keeping at most ten epochs of history +// MAX_OPERATORS_PER_EPOCH (7) operators. Keeping at most ten epochs of history // bounds roster retention and coalescing work. The joint cadence x roster-size // guard below separately caps the expensive per-recipient payout work at 100 -// credits. With the default 7-operator groups the history bound is the limit; a -// 16-operator group permits a cadence of at most 6. Raising the credit cap -// requires separate evidence that a larger mandatory advance fits the -// transaction CPU/KV budget. +// credits; at most 7 operators over 10 epochs is 70, so today the history bound +// is the limit. Raising the credit cap requires separate evidence that a larger +// mandatory advance fits the transaction CPU/KV budget. inline constexpr uint16_t MAX_PAY_CADENCE_EPOCHS = 10; inline constexpr uint32_t MAX_BATCH_PAYOUT_CREDITS = 100; // payepoch removes at most this many roster-history rows per payment. Normal @@ -131,8 +130,8 @@ struct [[sysio::table("emitcfg"), sysio::contract("sysio.system")]] emission_con // every epoch (matches the original emissions behavior). The roster history // retained for a period is explicitly bounded at 10 epochs, while cadence x // operators_per_epoch is bounded separately to at most 100 recipient credits - // per payout. At the 16-operator group ceiling this makes 6 the maximum - // cadence; the default 7-operator groups are held to 10 by the history bound. + // per payout. Groups are at most 7 operators, so the history bound is the + // limit: 10 epochs is 70 credits. // The period aggregate and per-recipient share-by-rounds math // remain equivalent to summing per-epoch results. Must be in // [1, MAX_PAY_CADENCE_EPOCHS]. diff --git a/contracts/tests/emissions_tests.cpp b/contracts/tests/emissions_tests.cpp index faf102bb29..f9ddc352c5 100644 --- a/contracts/tests/emissions_tests.cpp +++ b/contracts/tests/emissions_tests.cpp @@ -2745,55 +2745,42 @@ BOOST_FIXTURE_TEST_CASE( setemitcfg_caps_batch_roster_history, sysio_emissions_t BOOST_FIXTURE_TEST_CASE( emission_config_boundaries_cap_batch_payout_credit_work, sysio_emissions_tester ) try { - // setemitcfg owns cadence changes. With the epoch roster at its maximum of - // 16, cadence six permits 96 distinct recipient credits and fits; cadence - // seven would permit 112 and must be rejected at that boundary. + // The largest group sysio.epoch accepts (7) over the longest pay period + // (10 epochs) is 70 recipient credits, inside the 100-credit cap, so the + // bounds that bind are the history bound and the group-size ceiling. BOOST_REQUIRE_EQUAL(success(), setemitcfg_defaults(config::system_account_name)); - BOOST_REQUIRE_EQUAL(success(), init_epoch_state(60, /*operators_per_epoch*/16, - /*batch_op_groups_count*/3)); - BOOST_REQUIRE_EQUAL(success(), - setemitcfg_with_cadence(config::system_account_name, uint16_t(6))); - auto r = setemitcfg_with_cadence(config::system_account_name, uint16_t(7)); - BOOST_REQUIRE(r != success()); - require_substr(r, "pay_cadence_epochs x operators_per_epoch exceeds the batch payout credit safety cap (100)"); - - // sysio.epoch::setconfig owns roster-size changes and enforces the same - // bound. Ten epochs at the normal seven-member roster fit; raising the - // roster to eleven would permit 110 credits and is rejected. BOOST_REQUIRE_EQUAL(success(), init_epoch_state(60, /*operators_per_epoch*/7, /*batch_op_groups_count*/3)); BOOST_REQUIRE_EQUAL(success(), setemitcfg_with_cadence(config::system_account_name, uint16_t(10))); - r = init_epoch_state(60, /*operators_per_epoch*/11, /*batch_op_groups_count*/3); + auto r = setemitcfg_with_cadence(config::system_account_name, uint16_t(11)); + BOOST_REQUIRE(r != success()); + require_substr(r, "pay_cadence_epochs exceeds batch roster history safety cap"); + + r = init_epoch_state(60, /*operators_per_epoch*/8, /*batch_op_groups_count*/3); BOOST_REQUIRE(r != success()); - require_substr(r, "pay_cadence_epochs x operators_per_epoch exceeds the batch payout credit safety cap (100)"); + require_substr(r, "operators_per_epoch exceeds the outpost group-size ceiling (7)"); } FC_LOG_AND_RETHROW() -BOOST_FIXTURE_TEST_CASE( epoch_setconfig_names_prebootstrap_stored_cadence_in_work_bound_error, +BOOST_FIXTURE_TEST_CASE( epoch_setconfig_fits_the_largest_group_to_a_prebootstrap_stored_cadence, sysio_emissions_tester ) try { // setemitcfg cannot validate the joint bound before sysio.epoch has a - // configuration. The first epoch setconfig must reject an incompatible - // roster size and point operators back to the already-stored system cadence. + // configuration, so the first epoch setconfig checks it against the + // already-stored system cadence. The largest group fits the longest cadence. BOOST_REQUIRE_EQUAL(success(), setemitcfg_with_cadence(config::system_account_name, uint16_t(10))); - - auto r = init_epoch_state(60, /*operators_per_epoch*/11, - /*batch_op_groups_count*/3); - BOOST_REQUIRE(r != success()); - require_substr( - r, - "stored sysio.system pay_cadence_epochs x operators_per_epoch exceeds the batch payout credit safety cap (100)"); + BOOST_REQUIRE_EQUAL(success(), init_epoch_state(60, /*operators_per_epoch*/7, + /*batch_op_groups_count*/3)); } FC_LOG_AND_RETHROW() -BOOST_FIXTURE_TEST_CASE( epoch_setconfig_recovers_legacy_cadence_with_runtime_work_bound, +BOOST_FIXTURE_TEST_CASE( epoch_setconfig_recovers_legacy_cadence_with_runtime_clamp, sysio_emissions_tester ) try { - // A pre-bound cadence above today's history cap is shortened at runtime. - // setconfig must validate that same effective value, while the preceding - // boundary test continues to reject an unsafe roster change for a current, - // otherwise-valid stored cadence. + // A pre-bound cadence above today's history cap is shortened at runtime to + // ten epochs. setconfig must validate that same effective value: the raw + // 1000 epochs x 7 operators would exceed the 100-credit cap. set_legacy_emitcfg_cadence_raw(uint16_t(1000)); BOOST_REQUIRE_EQUAL(success(), init_epoch_state( - 60, /*operators_per_epoch*/11, /*batch_op_groups_count*/3)); + 60, /*operators_per_epoch*/7, /*batch_op_groups_count*/3)); } FC_LOG_AND_RETHROW() BOOST_FIXTURE_TEST_CASE( payepoch_recovers_when_legacy_rosters_exceed_credit_budget, diff --git a/contracts/tests/sysio.epoch_tests.cpp b/contracts/tests/sysio.epoch_tests.cpp index e5b02d6edc..255fa7e0a8 100644 --- a/contracts/tests/sysio.epoch_tests.cpp +++ b/contracts/tests/sysio.epoch_tests.cpp @@ -196,24 +196,24 @@ BOOST_FIXTURE_TEST_CASE(setconfig_total_check_survives_uint32_wrap, sysio_epoch_ BOOST_FIXTURE_TEST_CASE(setconfig_rejects_oversized_operators_per_epoch, sysio_epoch_tester) { try { // Group size drives advance()'s per-epoch inline fanout and vector reserves, - // and Solana seats at most 16 per group, so it carries a bound independent of + // and Ethereum is sized for 7 per group, so it carries a bound independent of // the product equality: a window of UINT32_MAX operators in one group is // internally consistent (UINT32_MAX * 1 == UINT32_MAX) yet must be rejected. - BOOST_REQUIRE_EQUAL(success(), setconfig(360, 16, 16, 1)); + BOOST_REQUIRE_EQUAL(success(), setconfig(360, 7, 7, 1)); BOOST_REQUIRE_EQUAL( - error("assertion failure with message: operators_per_epoch exceeds the outpost group-size ceiling (16)"), - setconfig(360, 17, 17, 1) + error("assertion failure with message: operators_per_epoch exceeds the outpost group-size ceiling (7)"), + setconfig(360, 8, 8, 1) ); BOOST_REQUIRE_EQUAL( - error("assertion failure with message: operators_per_epoch exceeds the outpost group-size ceiling (16)"), + error("assertion failure with message: operators_per_epoch exceeds the outpost group-size ceiling (7)"), setconfig(360, 0xFFFF'FFFFu, 0xFFFF'FFFFu, 1) ); } FC_LOG_AND_RETHROW() } BOOST_FIXTURE_TEST_CASE(setconfig_accepts_the_largest_schedule_window, sysio_epoch_tester) { try { - // 8 groups of 16 is the largest window an outpost seats (Solana's 128-operator - // registry). The two per-factor ceilings bound the window, so it needs none of its own. - BOOST_REQUIRE_EQUAL(success(), setconfig(360, 16, 128, 8)); + // 8 groups of 7 is the largest window the outposts take. The two per-factor ceilings + // bound the window, so it needs none of its own. + BOOST_REQUIRE_EQUAL(success(), setconfig(360, 7, 56, 8)); } FC_LOG_AND_RETHROW() } BOOST_FIXTURE_TEST_CASE(regchain_basic, sysio_epoch_tester) { try { diff --git a/docs/contract-upgrade-order.md b/docs/contract-upgrade-order.md index af76464915..49aebaafcf 100644 --- a/docs/contract-upgrade-order.md +++ b/docs/contract-upgrade-order.md @@ -180,10 +180,9 @@ pre-launch procedure because a complete immutable roster history is required to credit the batch-operator payout: 1. Set `pay_cadence_epochs` to a value in `[1, 10]` that also satisfies - `pay_cadence_epochs * operators_per_epoch <= 100` before the window. With the - default 7-operator groups the maximum is 10; at the 16-operator ceiling it is 6. - An older stored value outside the accepted bounds is read with an effective - clamp until rewritten. + `pay_cadence_epochs * operators_per_epoch <= 100` before the window. Groups are + at most 7 operators, so any cadence up to 10 qualifies. An older stored value + outside the accepted bounds is read with an effective clamp until rewritten. 2. Prefer a completed payment period and verify that `t5state` has `pending_emission_amount == 0`, all `batch_group_epochs` counters are zero, and `batchepochs` is empty. From 43e2956105ddd35200ca5ee9e04d661dd6b9b328 Mon Sep 17 00:00:00 2001 From: Kevin Heifner Date: Thu, 22 Oct 2026 19:00:27 -0400 Subject: [PATCH 5/6] OPP: measure the schedule lane at #692's 8 x 7 ceiling #692 caps groups at 7, so the lane test now mirrors 7 and builds the largest window at 8 x 7: 6,806 bytes, down from 15,446 at 8 x 16. `SCHEDULE_LANE_BYTES` stays 24 KiB, which still holds a window at Solana's 16 per group. --- contracts/sysio.msgch/include/sysio.msgch/sysio.msgch.hpp | 3 ++- contracts/tests/sysio.msgch_tests.cpp | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/contracts/sysio.msgch/include/sysio.msgch/sysio.msgch.hpp b/contracts/sysio.msgch/include/sysio.msgch/sysio.msgch.hpp index bf936471f2..7dcb1ec65a 100644 --- a/contracts/sysio.msgch/include/sysio.msgch/sysio.msgch.hpp +++ b/contracts/sysio.msgch/include/sysio.msgch/sysio.msgch.hpp @@ -72,7 +72,8 @@ namespace sysio { /// Most envelope bytes one epoch's schedule-lane rows take, counted as `buildenv` estimates them /// (`data` plus ATTESTATION_OVERHEAD_BYTES each). The largest window's OPERATORS and - /// BATCH_OPERATOR_GROUPS come to about 15.4 KB; 24 KiB also matches Solana's OPERATORS payload cap. + /// BATCH_OPERATOR_GROUPS come to about 6.8 KB, and 15.4 KB even at Solana's 16 per group; 24 KiB also + /// matches Solana's OPERATORS payload cap. static constexpr size_t SCHEDULE_LANE_BYTES = 24 * 1024; /// Largest general-lane `data` that fits beside a full schedule lane, which every envelope carries diff --git a/contracts/tests/sysio.msgch_tests.cpp b/contracts/tests/sysio.msgch_tests.cpp index 4f5abd5596..4f6a60cc5b 100644 --- a/contracts/tests/sysio.msgch_tests.cpp +++ b/contracts/tests/sysio.msgch_tests.cpp @@ -426,7 +426,7 @@ constexpr size_t MAX_GENERAL_ATTESTATION_DATA_BYTES = /// Mirrors of the window ceilings `sysio.epoch::setconfig` enforces in `sysio.epoch.hpp`. constexpr uint32_t MAX_BATCH_OP_GROUPS = 8; -constexpr uint32_t MAX_OPERATORS_PER_EPOCH = 16; +constexpr uint32_t MAX_OPERATORS_PER_EPOCH = 7; /// `register_outpost(CHAIN_KIND_SVM, ...)` registers the spelling `"SOL"`. constexpr uint64_t SOL_OUTPOST_ID = "SOL"_s.value; @@ -841,7 +841,7 @@ BOOST_FIXTURE_TEST_CASE(buildenv_ships_only_its_own_outposts_rows, sysio_msgch_e /// WIRE-415: the largest window's schedule rows fit SCHEDULE_LANE_BYTES, so a general row at /// MAX_GENERAL_ATTESTATION_DATA_BYTES always ships beside them. Built at the extremes `sysio.epoch` allows: -/// 8 groups of 16, 13-character names, and an EVM and an SVM link on every operator. +/// 8 groups of 7, 13-character names, and an EVM and an SVM link on every operator. BOOST_AUTO_TEST_CASE(the_largest_windows_schedule_fits_its_lane_budget) { try { constexpr size_t EVM_KEY_BYTES = 33; // a compressed EM key, as authex stores it constexpr size_t SVM_KEY_BYTES = 32; // an ED key From 01b58803de57617bf18c992825b4d6bfff3123eb Mon Sep 17 00:00:00 2001 From: Kevin Heifner Date: Mon, 26 Oct 2026 10:52:46 -0400 Subject: [PATCH 6/6] OPP: test that buildenv erases only its own outpost's inbound rows HM asked for an on-chain regression of the PROCESSED range. Both outposts accept an envelope with one attestation per lane; ETH's `buildenv` erases ETH's two inbound rows and leaves SOL's, and SOL's own build through `advance` then erases SOL's. Change-Id: Id5722a6d44d6d446496119403d7484c7748983df --- contracts/tests/sysio.msgch_chain_tests.cpp | 103 +++++++++++++++++--- 1 file changed, 92 insertions(+), 11 deletions(-) diff --git a/contracts/tests/sysio.msgch_chain_tests.cpp b/contracts/tests/sysio.msgch_chain_tests.cpp index 9fa10ba689..ddb557868f 100644 --- a/contracts/tests/sysio.msgch_chain_tests.cpp +++ b/contracts/tests/sysio.msgch_chain_tests.cpp @@ -141,6 +141,7 @@ constexpr name CHECK_CONSENSUS = "chkcons"_n; /// sysio.msgch table identifiers used by the WNS-16 fixture. namespace msgch_tables { +constexpr name ATTESTATIONS = "attestations"_n; constexpr name ENVELOPES = "envelopes"_n; constexpr name OUTBOUND_ENVELOPES = "outenvelopes"_n; } // namespace msgch_tables @@ -152,6 +153,7 @@ constexpr name BY_OUTPOST_EPOCH = "byoutepoch"_n; /// sysio.msgch ABI type identifiers used by the WNS-16 fixture. namespace msgch_abi_types { +constexpr const char* ATTESTATION_ENTRY = "attestation_entry"; constexpr const char* ENVELOPE_ENTRY = "envelope_entry"; constexpr const char* OUTBOUND_ENVELOPE = "outbound_envelope"; } // namespace msgch_abi_types @@ -166,6 +168,7 @@ 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"; +constexpr const char* STATUS = "status"; } // namespace msgch_fields /// sysio.chalg table identifiers used by the split-consensus regressions. @@ -773,15 +776,31 @@ class sysio_msgch_chain_tester : public tester { return n; } + /// Attestation rows for `chain_code` with `status`, over the whole table. + uint32_t attestation_status_count(uint64_t chain_code, opp::types::AttestationStatus status) { + uint32_t n = 0; + for (const auto& row : table_rows(MSGCH_ACCOUNT, msgch_tables::ATTESTATIONS, msgch_abi, + msgch_abi_types::ATTESTATION_ENTRY)) { + if (row[msgch_fields::CHAIN_CODE].as_uint64() == chain_code && + row[msgch_fields::STATUS].as() == status) ++n; + } + return n; + } + // -- Inbound envelope builder -- - /// Encode a deliverable envelope carrying one OPERATORS roster echo (dispatch drops - /// the attestation silently; acceptance is still fully observable via `outpcons` and the - /// stored attestation row). The semantic header is derived per the spec — `apply_consensus` - /// drops envelopes whose header fields do not recompute or whose message does not continue the - /// per-outpost message chain. `prev` (previous_envelope_hash), `prev_message_id`, and - /// `env_hash` are raw 32-byte strings (or empty for stream genesis). - std::vector encode_delivery(uint32_t epoch_index, const std::string& att_data, + /// One attestation of an inbound envelope. + struct inbound_attestation { + sysio::opp::types::AttestationType type; + std::string data; + }; + + /// Encode a deliverable envelope carrying `attestations` in one message. The semantic header is + /// derived per the spec — `apply_consensus` drops envelopes whose header fields do not recompute + /// or whose message does not continue the per-outpost message chain. `prev` + /// (previous_envelope_hash), `prev_message_id`, and `env_hash` are raw 32-byte strings (or empty + /// for stream genesis). + std::vector encode_delivery(uint32_t epoch_index, const std::vector& attestations, const std::string& prev = {}, const std::string& prev_message_id = {}, const std::string& env_hash = {}) { @@ -791,16 +810,31 @@ class sysio_msgch_chain_tester : public tester { env.set_epoch_timestamp(1'775'612'516'983ULL); if (!prev.empty()) env.set_previous_envelope_hash(prev); if (!env_hash.empty()) env.set_envelope_hash(env_hash); - auto* att = env.add_messages()->mutable_payload()->add_attestations(); - att->set_type(sysio::opp::types::ATTESTATION_TYPE_OPERATORS); - att->set_data(att_data); - att->set_data_size(static_cast(att_data.size())); + auto* payload = env.add_messages()->mutable_payload(); + for (const auto& attestation : attestations) { + auto* att = payload->add_attestations(); + att->set_type(attestation.type); + att->set_data(attestation.data); + att->set_data_size(static_cast(attestation.data.size())); + } oracle::finalize_header(*env.mutable_messages(0), prev_message_id, 1'775'612'516'983ULL); std::vector out(env.ByteSizeLong()); env.SerializeToArray(out.data(), static_cast(out.size())); return out; } + /// Encode a deliverable envelope carrying one OPERATORS roster echo (dispatch drops + /// the attestation silently; acceptance is still fully observable via `outpcons` and the + /// stored attestation row). + std::vector encode_delivery(uint32_t epoch_index, const std::string& att_data, + const std::string& prev = {}, + const std::string& prev_message_id = {}, + const std::string& env_hash = {}) { + const std::vector attestations{ + {sysio::opp::types::ATTESTATION_TYPE_OPERATORS, att_data}}; + return encode_delivery(epoch_index, attestations, prev, prev_message_id, env_hash); + } + /// Decode the raw bytes back into the pb Envelope (asserts success). sysio::opp::Envelope decode_envelope(const std::vector& raw) { sysio::opp::Envelope env; @@ -1514,6 +1548,53 @@ BOOST_FIXTURE_TEST_CASE(buildenv_handles_unacknowledged_backlog, sysio_msgch_cha BOOST_REQUIRE(!outbound_envelope_for_epoch(ETH_OUTPOST_ID, next_epoch).is_null()); } FC_LOG_AND_RETHROW() } +// --------------------------------------------------------------------------- +// Inbound cleanup: buildenv erases only its own outpost's dispatched rows. +// --------------------------------------------------------------------------- + +/// WIRE-415: `evalcons` stores every inbound attestation PROCESSED, and `buildenv` erases the +/// destination outpost's PROCESSED rows through the `byqueue` range for that outpost. Each envelope +/// carries one attestation per lane, so a range that misses a lane or reads another status leaves a +/// row behind. Another outpost's rows, PROCESSED or READY, survive until its own `buildenv`. +BOOST_FIXTURE_TEST_CASE(buildenv_erases_only_its_outposts_processed_rows, sysio_msgch_chain_tester) { try { + constexpr auto processed = opp::types::AttestationStatus::ATTESTATION_STATUS_PROCESSED; + constexpr auto ready = opp::types::AttestationStatus::ATTESTATION_STATUS_READY; + bootstrap(); + + // Each outpost's first envelope reaches consensus on the single bootstrapped operator's delivery. + // OPERATORS is a schedule-lane type and OPERATOR_ACTION a general-lane one; dispatch drops both. + const uint32_t epoch = current_epoch(); + const std::vector attestations{ + {opp::types::ATTESTATION_TYPE_OPERATORS, "schedule-lane"}, + {opp::types::ATTESTATION_TYPE_OPERATOR_ACTION, "general-lane"}}; + for (const uint64_t outpost : {ETH_OUTPOST_ID, SOL_OUTPOST_ID}) { + BOOST_REQUIRE_EQUAL(success(), deliver(outpost, encode_delivery(epoch, attestations))); + } + produce_blocks(); + for (const uint64_t outpost : {ETH_OUTPOST_ID, SOL_OUTPOST_ID}) { + BOOST_REQUIRE_EQUAL(attestations.size(), attestation_status_count(outpost, processed)); + // A READY row on each outpost: `buildenv` builds nothing without one. + BOOST_REQUIRE_EQUAL(success(), queueout(outpost, opp::types::ATTESTATION_TYPE_DESYNDICATE_LIQ, + std::vector(8, '\x01'))); + } + produce_blocks(); + + // ETH's build ships ETH's READY row and erases both of its inbound rows. SOL's are untouched. + BOOST_REQUIRE_EQUAL(success(), buildenv(ETH_OUTPOST_ID)); + produce_blocks(); + BOOST_REQUIRE_EQUAL(0u, attestation_status_count(ETH_OUTPOST_ID, processed)); + BOOST_REQUIRE_EQUAL(0u, attestation_status_count(ETH_OUTPOST_ID, ready)); + BOOST_REQUIRE_EQUAL(attestations.size(), attestation_status_count(SOL_OUTPOST_ID, processed)); + BOOST_REQUIRE_EQUAL(1u, attestation_status_count(SOL_OUTPOST_ID, ready)); + + // The real advance path builds SOL's envelope, which erases SOL's inbound rows in turn. + advance_one_epoch(); + for (const uint64_t outpost : {ETH_OUTPOST_ID, SOL_OUTPOST_ID}) { + BOOST_REQUIRE_EQUAL(0u, attestation_status_count(outpost, processed)); + BOOST_REQUIRE_EQUAL(0u, attestation_status_count(outpost, ready)); + } +} FC_LOG_AND_RETHROW() } + // --------------------------------------------------------------------------- // Inbound: apply_consensus records and verifies the per-outpost chain tip. // ---------------------------------------------------------------------------