From 0e82d29c10aa0f7d6b00c64544a0a77a3bb290e9 Mon Sep 17 00:00:00 2001 From: Kevin Heifner Date: Fri, 23 Oct 2026 16:31:02 -0400 Subject: [PATCH 1/3] 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). Well before that, a roster with enough operators Ethereum had never seen ran its OPERATORS handler out of gas (WIRE-342). 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 the outposts' limits, groups of at most 7 (Ethereum's OPERATORS_PER_GROUP) and at most 8 of them (Solana's MAX_OPERATOR_GROUPS), so the roster holds at most 56 entries. On wire-ethereum next, a 56-entry roster with 49 operators Ethereum has never seen dispatches in one transaction: 9.3M gas, 12.4M with the outbound queue full, of EIP-7825's 16,777,216. 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, and the envelope size constants moved into the msgch header. The docs describe the window's roster and the 7 x 8 ceilings. At most 7 operators over 10 epochs is 70 payout credits, so the 10-epoch history bound is the cadence limit and the 100-credit cap stays only as a backstop. --- OVERVIEW.md | 2 +- contracts/sysio.epoch/README.md | 7 +- .../include/sysio.epoch/sysio.epoch.hpp | 52 ++---- contracts/sysio.epoch/src/sysio.epoch.cpp | 130 ++++++++------- contracts/sysio.epoch/sysio.epoch.wasm | Bin 78152 -> 76261 bytes contracts/sysio.msgch/README.md | 2 +- .../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/sysio.opreg/src/sysio.opreg.cpp | 6 +- contracts/sysio.system/EMISSIONS.md | 12 +- .../include/sysio.system/emissions.hpp | 18 +- contracts/tests/emissions_tests.cpp | 49 +++--- contracts/tests/sysio.epoch_tests.cpp | 45 +++-- contracts/tests/sysio.msgch_chain_tests.cpp | 155 ++++++++++++++---- contracts/tests/sysio.msgch_tests.cpp | 57 ++++++- docs/becoming-a-block-producer.md | 3 +- docs/contract-upgrade-order.md | 4 +- docs/retired-opp-carriers.md | 3 +- 19 files changed, 372 insertions(+), 241 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..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 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 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 | @@ -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.epoch/include/sysio.epoch/sysio.epoch.hpp b/contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp index b3b4b492c6..36a8853473 100644 --- a/contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp +++ b/contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp @@ -153,41 +153,23 @@ 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): 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). 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 caa08a5624..1c50a8c167 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 (7)"); 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..fa3f253feb8ca5dabb0d4a7f542ea4910c86e338 100755 GIT binary patch delta 15697 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 delta 16633 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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/sysio.opreg/src/sysio.opreg.cpp b/contracts/sysio.opreg/src/sysio.opreg.cpp index 17269056c3..e647e00e73 100644 --- a/contracts/sysio.opreg/src/sysio.opreg.cpp +++ b/contracts/sysio.opreg/src/sysio.opreg.cpp @@ -1289,8 +1289,10 @@ void opreg::slash(name account, std::string reason) { // Record one SLASH entry per (chain_code, token_code) with non-zero slashable // in the operator's recent_actions log (success=true since the slash itself - // was applied). Nothing is sent to an outpost: they learn the SLASHED status - // from the next OPERATORS roster. + // was applied). Nothing is sent to an outpost: each OPERATORS roster lists the + // operator as SLASHED while the schedule window seats it and leaves it out once + // its group has left, which revokes it on each outpost that applies that roster + // (an empty window queues none). for (const auto& sp : to_slash) { auto slash_action = build_slash_action(op.account, op.type, sp.chain_code, sp.token_code, sp.amount, diff --git a/contracts/sysio.system/EMISSIONS.md b/contracts/sysio.system/EMISSIONS.md index 9d0279c383..83299468f7 100644 --- a/contracts/sysio.system/EMISSIONS.md +++ b/contracts/sysio.system/EMISSIONS.md @@ -103,12 +103,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. 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 686af15537..92c37e892d 100644 --- a/contracts/sysio.system/include/sysio.system/emissions.hpp +++ b/contracts/sysio.system/include/sysio.system/emissions.hpp @@ -34,14 +34,13 @@ 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 (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; 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,7 +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 21 operators this makes 4 the maximum production cadence. + // 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 4540aecb66..cd96582a30 100644 --- a/contracts/tests/emissions_tests.cpp +++ b/contracts/tests/emissions_tests.cpp @@ -2745,53 +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 - // 100, cadence two would permit 200 distinct recipient credits 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*/100, - /*batch_op_groups_count*/3)); - auto r = setemitcfg_with_cadence(config::system_account_name, uint16_t(2)); - 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 x operators_per_epoch exceeds the batch payout credit safety cap (100)"); + 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, "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 7a3e14bf8b..e5b734a018 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 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, 7, 7, 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 (7)"), + setconfig(360, 8, 8, 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 (7)"), 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 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/contracts/tests/sysio.msgch_chain_tests.cpp b/contracts/tests/sysio.msgch_chain_tests.cpp index d607f92d53..99f748d8b8 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; @@ -344,8 +351,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) @@ -404,8 +412,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())); @@ -766,36 +776,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 @@ -1769,8 +1786,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 @@ -1828,7 +1846,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); @@ -2221,8 +2240,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); @@ -2231,9 +2250,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); @@ -2248,6 +2270,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 b4fe0a11a3..6723abfc6f 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). diff --git a/docs/becoming-a-block-producer.md b/docs/becoming-a-block-producer.md index 1bea6f891c..0b9980db86 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 99034ac4ca..5af5d5bddc 100644 --- a/docs/contract-upgrade-order.md +++ b/docs/contract-upgrade-order.md @@ -201,8 +201,8 @@ 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 + `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, 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 fa823e2ccbbb0f59db3e685b61a627b6adcf37cc Mon Sep 17 00:00:00 2001 From: Kevin Heifner Date: Fri, 23 Oct 2026 16:57:51 -0400 Subject: [PATCH 2/3] OPP: say when the overview's roster attestations are not sent The overview said OPERATORS and BATCH_OPERATOR_GROUPS are sent every epoch, but an empty window sends no roster and an empty next group withholds BATCH_OPERATOR_GROUPS (HM's review). --- OVERVIEW.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OVERVIEW.md b/OVERVIEW.md index 082cbff7cd..592b8d5af9 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 | The operators the schedule window seats, with their chain addresses, 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, except that an empty window sends no roster and an empty next group withholds `BATCH_OPERATOR_GROUPS` | | **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 | From 8594a73b3d4a21160251ba1b119f0a1d0432ec43 Mon Sep 17 00:00:00 2001 From: Kevin Heifner Date: Sat, 24 Oct 2026 08:00:12 -0400 Subject: [PATCH 3/3] emissions: say setemitcfg checks t5_distributable, not the budget The budget is t5_distributable - t5_floor, so "refuses a budget below t5_floor + total_distributed + pending_emission_amount" counted the floor twice; setemitcfg refuses a t5_distributable below that sum (HM's review). --- contracts/sysio.system/EMISSIONS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/sysio.system/EMISSIONS.md b/contracts/sysio.system/EMISSIONS.md index cbcbe19ca0..59abd27f92 100644 --- a/contracts/sysio.system/EMISSIONS.md +++ b/contracts/sysio.system/EMISSIONS.md @@ -25,7 +25,7 @@ The curve decays from `annual_initial_emission` toward `annual_min_emission` The T5 budget is `t5_distributable - t5_floor`. Accruals are sized against the part not yet paid out or accrued (`net_headroom` in `emissions.hpp`), so -`total_distributed + pending_emission_amount` never exceeds the budget. `setemitcfg` refuses a budget below +`total_distributed + pending_emission_amount` never exceeds the budget. `setemitcfg` refuses a `t5_distributable` below `t5_floor + total_distributed + pending_emission_amount`. Once the open pay period has accrued the rest of the budget, its remaining