diff --git a/OVERVIEW.md b/OVERVIEW.md index 9418056a58..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 | 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, 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 | diff --git a/contracts/sysio.epoch/README.md b/contracts/sysio.epoch/README.md index d7f76ee02b..ad625225c0 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 | @@ -46,8 +46,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 c656644edc..1889dedd6c 100644 --- a/contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp +++ b/contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp @@ -155,41 +155,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 in - /// `advance` (opreg::recorddel + opreg::termcheck) plus a payclaims - /// credit 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 two inline actions in `advance` (opreg::recorddel + + /// opreg::termcheck) and a payclaims credit in sysio.system::payepoch. + 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 d2b896f0b7..94acb5199d 100644 --- a/contracts/sysio.epoch/src/sysio.epoch.cpp +++ b/contracts/sysio.epoch/src/sysio.epoch.cpp @@ -250,22 +250,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"); @@ -735,7 +734,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. @@ -745,61 +748,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 cfc441e614..8255b10efe 100755 Binary files a/contracts/sysio.epoch/sysio.epoch.wasm and b/contracts/sysio.epoch/sysio.epoch.wasm differ 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.msgch/include/sysio.msgch/sysio.msgch.hpp b/contracts/sysio.msgch/include/sysio.msgch/sysio.msgch.hpp index 357e5f277b..576b704e34 100644 --- a/contracts/sysio.msgch/include/sysio.msgch/sysio.msgch.hpp +++ b/contracts/sysio.msgch/include/sysio.msgch/sysio.msgch.hpp @@ -17,6 +17,29 @@ namespace sysio { public: using contract::contract; + // ----------------------------------------------------------------------- + // Envelope sizing + // ----------------------------------------------------------------------- + + /// Hard cap on an encoded envelope in both directions, mirroring the Solana + /// (`opp_outpost::MAX_ENVELOPE_BYTES`) and Ethereum (`OPP.MAX_ENVELOPE_BYTES`) caps. Ethereum is + /// the binding constraint: a cold `emitOutboundEnvelope` of a near-64-KiB envelope exceeds the + /// EIP-7825 per-transaction gas cap. Inbound, `deliver` rejects anything larger. + static constexpr size_t MAX_ENVELOPE_BYTES = 32'768; + + /// Per-attestation framing `buildenv` budgets for (tags, length prefixes, type and data-size + /// fields). Over-counts the real encoding by a few bytes. + static constexpr size_t ATTESTATION_OVERHEAD_BYTES = 24; + + /// Envelope and message header `buildenv` budgets for, with margin for length prefixes. + /// Over-counts the real encoding. + static constexpr size_t ENVELOPE_BASELINE_BYTES = 512; + + /// Largest attestation `data` an otherwise empty envelope can carry. `queueout` rejects anything + /// larger, so every READY row can ship. + static constexpr size_t MAX_ATTESTATION_DATA_BYTES = + MAX_ENVELOPE_BYTES - ENVELOPE_BASELINE_BYTES - ATTESTATION_OVERHEAD_BYTES; + // ----------------------------------------------------------------------- // Actions // ----------------------------------------------------------------------- @@ -60,7 +83,8 @@ namespace sysio { void resolvedisp(uint64_t chain_code, uint32_t epoch_index, checksum256 winning_checksum); /// Queue an outbound attestation for an outpost. - /// Writes to the attestations table with status READY. + /// Writes to the attestations table with status READY. Rejects `data` larger than + /// MAX_ATTESTATION_DATA_BYTES, so every READY row fits an envelope. /// /// `chain_code` is the destination outpost's chain slug_name value /// (uint64). Called by sibling system contracts that need to send diff --git a/contracts/sysio.msgch/src/sysio.msgch.cpp b/contracts/sysio.msgch/src/sysio.msgch.cpp index b46b2b5337..941554ca3c 100644 --- a/contracts/sysio.msgch/src/sysio.msgch.cpp +++ b/contracts/sysio.msgch/src/sysio.msgch.cpp @@ -74,34 +74,6 @@ constexpr uint32_t WIRE_CHAIN_ID = 1; /// binds inbound registrations to this exact outpost — see the WSA-005 note there. constexpr sysio::slug_name NODE_OWNER_SRC_CHAIN = "ETHEREUM"_s; -/// Hard cap on the encoded envelope size in BOTH directions, mirroring the -/// Solana (`opp_outpost::MAX_ENVELOPE_BYTES`) and Ethereum (`OPP.MAX_ENVELOPE_BYTES`) -/// caps. 32 KiB is the e2e-supported maximum across WIRE / Ethereum / Solana. -/// Solana's 256 KiB BPF heap divided by ~3.3× envelope-size peak heap usage -/// during the finalising chunk's `Envelope::decode + keccak::hash + clone` -/// tolerates more, but Ethereum is the binding constraint: a cold -/// `emitOutboundEnvelope` of a near-64-KiB envelope costs ~45 M gas, ~2.7× the -/// EIP-7825 per-transaction cap of 16 777 216, so the platform cap is 32 768. -/// Outbound, the `buildenv` packing loop uses this to decide how many READY -/// attestations to bundle into the current epoch's envelope; any that don't fit -/// stay in the `attestations` table with status READY for the next epoch's -/// `buildenv` call. Inbound, `deliver` rejects anything larger before hashing -/// or storing it. -constexpr size_t MAX_ENVELOPE_BYTES = 32'768; - -/// Conservative per-attestation byte budget used by the `buildenv` packing -/// loop: protobuf tags + length prefixes + the attestation type/data-size -/// fields. Over-counts by a few bytes per attestation versus the actual -/// `zpp::bits` encoded size, which keeps the loop O(N) and always errs on the -/// side of leaving a gap. The trailing `packed.size()` check after final -/// serialisation is the hard backstop. -constexpr size_t ATTESTATION_OVERHEAD_BYTES = 24; - -/// Conservative envelope/message header budget for the packing loop — -/// covers the `Envelope` header fields, the wrapping `Message`, its header -/// + payload preamble, and a safety margin for `zpp::bits` length prefixes. -constexpr size_t ENVELOPE_BASELINE_BYTES = 512; - /// Epochs of inbound `envelopes` rows kept: the current epoch, which every on-chain reader uses, and /// the previous one, so a batch operator whose cached epoch lags by one still finds its delivery. constexpr uint32_t INBOUND_ENVELOPE_RETENTION_EPOCHS = 2; @@ -1453,6 +1425,12 @@ void msgch::queueout(uint64_t chain_code, has_auth(synd::account) || has_auth(get_self()), "queueout: caller not authorized to queue outbound attestations"); + // A row no envelope can carry would abort every later `buildenv`, and with it `sysio.epoch::advance`. + // Rejecting it here reverts only the producer's transaction, including the state change it queued + // the row for. + check(data.size() <= MAX_ATTESTATION_DATA_BYTES, + "sysio.msgch::queueout: attestation exceeds MAX_ATTESTATION_DATA_BYTES"); + // The chains registry is the ONLY authority on which chain codes exist. // // This is not an attack gate -- the authorization check above is. It is an @@ -1589,10 +1567,8 @@ void msgch::buildenv(uint64_t chain_code) { ++included_count; } - // First-attestation-too-big guard. The estimator picks zero only when the first candidate alone - // overshoots the envelope; the trim loop below would surface the same condition, but aborting - // upfront avoids building anything in the doomed case. Never expected at protocol level because - // every valid current attestation should fit by itself. + // 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"); @@ -1694,6 +1670,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 8a4ee9ce58..cb7d13c961 100755 Binary files a/contracts/sysio.msgch/sysio.msgch.wasm and b/contracts/sysio.msgch/sysio.msgch.wasm differ 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 3205614ed1..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 @@ -115,12 +115,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 bfe4581565..03460f8eaf 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 47bedc0ffa..cdb5461572 100644 --- a/contracts/tests/emissions_tests.cpp +++ b/contracts/tests/emissions_tests.cpp @@ -2811,53 +2811,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 89e4fd9473..e03fc9a5b7 100644 --- a/contracts/tests/sysio.msgch_chain_tests.cpp +++ b/contracts/tests/sysio.msgch_chain_tests.cpp @@ -39,6 +39,7 @@ #include #include +#include #include "contracts.hpp" #include "contract_test_support.hpp" @@ -78,6 +79,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; @@ -400,8 +405,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) @@ -460,8 +466,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())); @@ -840,36 +848,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 @@ -1192,8 +1207,9 @@ class sysio_msgch_chain_tester : public tester { // whereas termination + reason hold either way. BATCHOP reached every outpost exactly // once, so exactly one delivered row must remain. BOOST_REQUIRE_EQUAL(1u, delivered_dellog_count(BATCHOP)); + // Its group expired in the same advance, so the roster no longer lists it. for (const uint64_t outpost : outposts) { - require_fresh_roster(outpost, BATCHOP, opp::types::OPERATOR_STATUS_TERMINATED); + BOOST_REQUIRE(!require_roster_is_window(outpost).count(BATCHOP.to_string())); } } else { // Still ACTIVE: BATCHOP must not terminate before its terminating duty. @@ -2033,8 +2049,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() } /// With two active outposts, advance() writes exactly one delivery-log row per expiring-group member @@ -2519,8 +2536,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); @@ -2529,9 +2546,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); @@ -2546,6 +2566,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 30eec01aeb..98d7e64ebd 100644 --- a/contracts/tests/sysio.msgch_tests.cpp +++ b/contracts/tests/sysio.msgch_tests.cpp @@ -365,6 +365,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 @@ -563,7 +571,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 @@ -588,7 +595,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. @@ -610,13 +617,57 @@ BOOST_FIXTURE_TEST_CASE(buildenv_packs_until_cap_then_leaves_remainder, BOOST_REQUIRE_NE(emitted_id, emitted_row_2["id"].as_uint64()); 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 af41eda19c..3a892a14d0 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.