diff --git a/contracts/sysio.chains/include/sysio.chains/sysio.chains.hpp b/contracts/sysio.chains/include/sysio.chains/sysio.chains.hpp index f001677332..47b6a221e8 100644 --- a/contracts/sysio.chains/include/sysio.chains/sysio.chains.hpp +++ b/contracts/sysio.chains/include/sysio.chains/sysio.chains.hpp @@ -104,6 +104,30 @@ namespace sysio { sysio::kv::index<"byextid"_n, sysio::const_mem_fun>, sysio::kv::index<"byactive"_n, sysio::const_mem_fun> >; + + /// True when a chains row represents an active OUTPOST — i.e. it is active and + /// is not the depot self-row (which carries no outpost). + /// + /// Shared because three call sites need the identical predicate: `sysio.epoch`'s + /// envelope fan-out loops, `sysio.opreg::regoperator`'s authex-link requirement, + /// and the roster-ceiling derivation. Divergence between them would mean the + /// depot sizes a roster against one outpost set while fanning it to another. + static inline bool is_active_outpost(const chain_row& row) { + return row.active && !row.is_depot; + } + + /// Number of active outposts. This is the number of chain addresses a fully + /// bonded operator carries, since every operator type must bond on every + /// registered outpost (see `outpost-three-concerns.md`), which is what makes it + /// the correct multiplier for the OPERATORS roster's bytes-per-operator. + static inline uint32_t active_outpost_count() { + chains_t chains_tbl(name{"sysio.chains"_n}); + uint32_t count = 0; + for (auto it = chains_tbl.begin(); it != chains_tbl.end(); ++it) { + if (is_active_outpost(*it)) ++count; + } + return count; + } }; } // namespace sysio diff --git a/contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp b/contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp index 5a8e6a5154..25adf694cf 100644 --- a/contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp +++ b/contracts/sysio.epoch/include/sysio.epoch/sysio.epoch.hpp @@ -8,6 +8,11 @@ #include #include #include +// For the OPP envelope budget — the roster ceiling below derives from it rather than +// restating a consensus-visible cap in a second place. Deliberately the shared +// opp.common header and NOT `sysio.msgch.hpp`: several contracts include this header +// without msgch on their include path. +#include namespace sysio { @@ -118,6 +123,45 @@ namespace sysio { using blocklog_t = sysio::kv::table<"blocklog"_n, blocklog_key, blocklog_entry>; + /// Digest of the last OPERATORS attestation queued for one outpost. + /// + /// `advance` re-derives the roster every epoch but queues it only when this + /// digest changes, so a static roster costs zero envelope bytes. The roster + /// changes on registration / activation / slash — rarely — while `advance` + /// runs every epoch, and re-sending it bought nothing: an outpost cannot miss + /// an envelope and continue (both outposts enforce strict epoch sequencing, so + /// a missed envelope stalls rather than diverges), and an outpost that DROPS a + /// roster does so on conditions that are pure functions of the payload, which a + /// byte-identical re-send reproduces exactly. + /// + /// Per-outpost rather than global because a newly-activated outpost has never + /// received the roster: the ABSENCE of a row is what makes its first `advance` + /// send unconditionally. Rows are reused, never erased, mirroring + /// `msgch::outpost_consensus_entry`. + /// + /// There is deliberately no periodic re-send. Every case in which an outpost + /// could want a roster it was already sent is an explicit operational act that + /// needs depot-side coordination regardless: an outpost re-init resets its epoch + /// cursor alongside its registry, so it rejects every envelope until it is + /// re-bootstrapped, and raising an outpost's own roster ceiling is a program + /// upgrade with a runbook. A timer would serve those late while costing a send + /// forever. + struct roster_digest_key { + uint64_t chain_code; + uint64_t primary_key() const { return chain_code; } + SYSLIB_SERIALIZE(roster_digest_key, (chain_code)) + }; + + struct [[sysio::table("rosterdig")]] roster_digest_entry { + uint64_t chain_code = 0; + checksum256 digest = {}; + uint32_t sent_at_epoch = 0; + + SYSLIB_SERIALIZE(roster_digest_entry, (chain_code)(digest)(sent_at_epoch)) + }; + + using rosterdig_t = sysio::kv::table<"rosterdig"_n, roster_digest_key, roster_digest_entry>; + // Well-known accounts static constexpr name CHALG_ACCOUNT = "sysio.chalg"_n; static constexpr name MSGCH_ACCOUNT = "sysio.msgch"_n; @@ -172,6 +216,86 @@ namespace sysio { /// envelope, and `buildenv` would carry the remainder to the next epoch. static constexpr uint32_t MAX_SCHEDULED_BATCH_OPERATORS = 1000; + /// Upper bound on the authex-linked chain addresses carried for ONE operator + /// in the OPERATORS attestation. + /// + /// The per-operator address walk is otherwise unbounded — `authex::links` has + /// no cap on how many keys one account may link — so a single account could + /// inflate one roster entry without limit and push the attestation past the + /// envelope on its own. An operator legitimately needs one address per + /// registered outpost chain (two today: EVM + SVM), so 8 is generous headroom + /// while still bounding the entry. It also matches what the Solana outpost + /// already assumes when sizing its own pre-decode payload gate. + static constexpr uint32_t MAX_OPERATOR_CHAIN_ADDRESSES = 8; + + // ----------------------------------------------------------------------- + // OPERATORS roster ceiling + // ----------------------------------------------------------------------- + // + // `MAX_SCHEDULED_BATCH_OPERATORS` above does this arithmetic for the sibling + // BATCH_OPERATOR_GROUPS attestation; it was never done for OPERATORS, which is + // ~5x fatter per member and drawn from an UNBOUNDED registry rather than a + // bounded schedule window. `sysio.opreg::setconfig` validates its summed + // `max_available_*` ceilings against the result, so a governance change cannot + // raise the registry past what an envelope can carry. + + /// Encoded bytes of one `ChainAddress`: 1 B tag + 1 B length + 2 B `kind` varint + /// + 2 B inner tag/length + a 33-byte compressed secp256k1 key (Ed25519 is 32, + /// so 33 is the worst case). + static constexpr uint32_t ROSTER_BYTES_PER_ADDRESS = 39; + + /// Encoded bytes of one `OperatorEntry` carrying `addresses` chain addresses: + /// a maximum-length WIRE account name (4 B framing + 13 B) + its addresses + /// + 2 B `type` + 3 B `status` (SLASHED is 241, a two-byte varint) + 2 B for the + /// repeated-field framing in `Operators`. + /// + /// Parameterised on the address count rather than pinned to today's outpost set: + /// an operator carries one address per registered outpost, so registering a third + /// outpost makes every entry ~39 B fatter. A fixed constant would silently + /// overstate capacity the moment that happens. + static constexpr uint32_t roster_bytes_per_operator(uint32_t addresses) { + return 17 + addresses * ROSTER_BYTES_PER_ADDRESS + 2 + 3 + 2; + } + + /// The share of one envelope the roster may claim. + /// + /// Sizing the ceiling against the WHOLE budget would let a legal configuration + /// produce a roster that fills an envelope by itself — the exact hazard + /// `MAX_SCHEDULED_BATCH_OPERATORS` names ("little room for value-bearing + /// attestations in the same envelope"). Half leaves the remainder for + /// BATCH_OPERATOR_GROUPS and the SWAP_REMIT / WITHDRAW_REMIT / RESERVE_READY + /// traffic that settles user value, which must not be deferred behind the roster. + static constexpr uint32_t ROSTER_ENVELOPE_SHARE_DIVISOR = 2; + + /// Strictest OUTPOST-side ceiling on roster ENTRIES. + /// + /// The depot's envelope arithmetic is necessary but not sufficient: an outpost + /// that cannot seat the roster it receives skips the WHOLE attestation and keeps + /// its previous one, so the roster silently stops tracking the depot. The Solana + /// outpost's `OperatorRegistry` is a fixed-size PDA — 32 entries today, raised to + /// 128 by wire-solana#442 (SOL-385), which explicitly defers the depot-side number + /// to WIRE-342. Ethereum applies no count cap, so Solana is the binding one. + /// + /// Keep in lock-step with `liqsol-core`'s `MAX_OPERATORS`; this bound is only + /// meaningful while it is the smaller of the two. + static constexpr uint32_t MAX_OUTPOST_ROSTER_ENTRIES = 128; + + /// Roster ceiling for a network carrying `outpost_count` active outposts: the + /// smaller of what half an envelope can carry and what the strictest outpost can + /// seat. Both bounds are real and neither implies the other — bytes gate what the + /// depot can SEND, entries gate what an outpost can STORE. + static constexpr uint32_t max_roster_operators(uint32_t outpost_count) { + const uint32_t addresses = + outpost_count == 0 ? 1 + : (outpost_count > MAX_OPERATOR_CHAIN_ADDRESSES + ? MAX_OPERATOR_CHAIN_ADDRESSES : outpost_count); + const uint32_t by_bytes = + static_cast(sysio::opp::SINGLE_ATTESTATION_BUDGET_BYTES + / ROSTER_ENVELOPE_SHARE_DIVISOR + / roster_bytes_per_operator(addresses)); + return by_bytes < MAX_OUTPOST_ROSTER_ENTRIES ? by_bytes : MAX_OUTPOST_ROSTER_ENTRIES; + } + private: // Namespace alias for OPP protobuf enum types diff --git a/contracts/sysio.epoch/src/sysio.epoch.cpp b/contracts/sysio.epoch/src/sysio.epoch.cpp index b796376345..2b642e849a 100644 --- a/contracts/sysio.epoch/src/sysio.epoch.cpp +++ b/contracts/sysio.epoch/src/sysio.epoch.cpp @@ -18,6 +18,7 @@ #include #include #include +#include namespace sysio { @@ -58,6 +59,53 @@ inline bool is_active_outpost(const sysio::chains::chain_row& row) { return row.active && !row.is_depot; } +/// Build one OPERATORS-roster entry for `op`, collecting its authex-linked chain +/// addresses from the `byname` index. +/// +/// The address walk is bounded at `epoch::MAX_OPERATOR_CHAIN_ADDRESSES`: `authex::links` +/// places no cap on how many keys one account may link, so an unbounded walk lets a +/// single account inflate one roster entry without limit — and the roster is the one +/// attestation that grows with adoption, so an oversized entry can push it past the +/// outbound envelope on its own. +template +opp::attestations::OperatorEntry build_operator_entry(const opreg::operator_entry& op, + LinksByNameIndex& links_by_name) { + opp::attestations::OperatorEntry entry; + entry.account.name = op.account.to_string(); + entry.type = op.type; + entry.status = op.status; + + // 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(op.account.value); + while (link_it != links_by_name.end() && link_it->username == op.account && + entry.addresses.size() < epoch::MAX_OPERATOR_CHAIN_ADDRESSES) { + opp::types::ChainAddress chain_addr; + chain_addr.kind = static_cast(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; + } + return entry; +} + struct emissions_gate_result { bool ready = false; bool is_pay_epoch = false; // true on the period-boundary epoch where payepoch fires @@ -683,61 +731,62 @@ void epoch::advance() { std::make_tuple(state.current_epoch_index) ).send(); - // Queue OPERATORS attestation (full roster with authex chain addresses) for each outpost. + // Queue the OPERATORS attestation for each outpost — but ONLY when the roster's + // content changed since that outpost's last send (see `epoch::roster_digest_entry`). + // // IMPORTANT: Must come before BATCH_OPERATOR_GROUPS so that the ETH outpost's // _handleOperators populates operatorEthAddress before _handleBatchOperatorGroups - // looks up those addresses. + // looks up those addresses. Skipping an UNCHANGED roster cannot break that ordering: + // both outposts retain the roster they already hold (ETH never prunes its address + // map; SOL only ever replaces it wholesale), and any newly-scheduled operator is by + // definition an ACTIVE batch operator, so its arrival IS a roster change and sends in + // the very epoch it is first scheduled. { opp::attestations::Operators ops_attest; opreg::operators_t opreg_ops(OPREG_ACCOUNT); authex::links_t authex_links(AUTHEX_ACCOUNT); auto links_by_name = authex_links.get_index<"byname"_n>(); + auto status_idx = opreg_ops.get_index<"bystatus"_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 = static_cast(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; + // ACTIVE and SLASHED only — never the full table. + // + // UNKNOWN (registered but never bonded) has no consumer on either outpost: it + // cannot be scheduled and cannot commit. Excluding it is what makes a never-bonded + // registration cost zero envelope bytes, so growing the broadcast requires bonding + // collateral per operator rather than being free. TERMINATED rows are settled + // history and are never erased, so including them would make the roster grow + // monotonically over the network's lifetime even under perfect operator churn. + // + // SLASHED MUST stay in: `opreg::slash` flips the row to SLASHED *before* it emits + // OPERATOR_ACTION(SLASH), and the Solana outpost resolves a slash's target through + // this roster — so an ACTIVE-only roster would silently no-op every slash. + for (const auto status : { OperatorStatus::OPERATOR_STATUS_ACTIVE, + OperatorStatus::OPERATOR_STATUS_SLASHED }) { + for (auto it = status_idx.lower_bound(magic_enum::enum_integer(status)); + it != status_idx.end() && it->status == status; ++it) { + ops_attest.operators.push_back(build_operator_entry(*it, links_by_name)); } - - 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); + // Content digest over the exact bytes each outpost would receive. Every outpost is + // sent the identical payload, so one digest compares against every per-outpost row. + const checksum256 roster_digest = sysio::keccak(encoded.data(), encoded.size()); + + epoch::rosterdig_t rosterdigs(get_self()); 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; + + const auto digest_pk = epoch::roster_digest_key{it->code.value}; + const bool seen = rosterdigs.contains(digest_pk); + if (seen && rosterdigs.get(digest_pk).digest == roster_digest) { + continue; // unchanged since this outpost's last send — nothing to say + } + action( permission_level{get_self(), "owner"_n}, MSGCH_ACCOUNT, @@ -748,6 +797,19 @@ void epoch::advance() { encoded ) ).send(); + + if (seen) { + rosterdigs.modify(ram_payer, digest_pk, [&](auto& row) { + row.digest = roster_digest; + row.sent_at_epoch = state.current_epoch_index; + }); + } else { + rosterdigs.emplace(ram_payer, digest_pk, epoch::roster_digest_entry{ + .chain_code = it->code.value, + .digest = roster_digest, + .sent_at_epoch = state.current_epoch_index, + }); + } } } diff --git a/contracts/sysio.epoch/sysio.epoch.abi b/contracts/sysio.epoch/sysio.epoch.abi index 7d7ed6b341..5c90973995 100644 --- a/contracts/sysio.epoch/sysio.epoch.abi +++ b/contracts/sysio.epoch/sysio.epoch.abi @@ -126,6 +126,34 @@ "base": "", "fields": [] }, + { + "name": "roster_digest_entry", + "base": "", + "fields": [ + { + "name": "chain_code", + "type": "uint64" + }, + { + "name": "digest", + "type": "checksum256" + }, + { + "name": "sent_at_epoch", + "type": "uint32" + } + ] + }, + { + "name": "roster_digest_key", + "base": "", + "fields": [ + { + "name": "chain_code", + "type": "uint64" + } + ] + }, { "name": "schbatchgps", "base": "", @@ -214,6 +242,14 @@ "key_names": ["name"], "key_types": ["name"], "table_id": 59387 + }, + { + "name": "rosterdig", + "type": "roster_digest_entry", + "index_type": "i64", + "key_names": ["chain_code"], + "key_types": ["uint64"], + "table_id": 29555 } ], "ricardian_clauses": [], diff --git a/contracts/sysio.epoch/sysio.epoch.wasm b/contracts/sysio.epoch/sysio.epoch.wasm index 61f97c5bc9..d532e02951 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/include/sysio.msgch/sysio.msgch.hpp b/contracts/sysio.msgch/include/sysio.msgch/sysio.msgch.hpp index 7a3e8b9aab..e3a7b9a198 100644 --- a/contracts/sysio.msgch/include/sysio.msgch/sysio.msgch.hpp +++ b/contracts/sysio.msgch/include/sysio.msgch/sysio.msgch.hpp @@ -9,6 +9,7 @@ #include #include #include +#include namespace sysio { @@ -96,6 +97,19 @@ namespace sysio { [[sysio::action]] void buildenv(uint64_t chain_code); + // ----------------------------------------------------------------------- + // Envelope budget + // ----------------------------------------------------------------------- + // + // ALIASES of the canonical values in `sysio.opp.common/opp_envelope_budget.hpp`, so the + // packing loop below can read them unqualified. They are not copies: the budget bounds + // what any attestation PRODUCER may emit, so `sysio.epoch` derives its roster ceiling + // from the same header rather than from this contract. + + static constexpr size_t MAX_ENVELOPE_BYTES = opp::MAX_ENVELOPE_BYTES; + static constexpr size_t ATTESTATION_OVERHEAD_BYTES = opp::ATTESTATION_OVERHEAD_BYTES; + static constexpr size_t ENVELOPE_BASELINE_BYTES = opp::ENVELOPE_BASELINE_BYTES; + // ----------------------------------------------------------------------- // Tables // ----------------------------------------------------------------------- diff --git a/contracts/sysio.msgch/src/sysio.msgch.cpp b/contracts/sysio.msgch/src/sysio.msgch.cpp index 919b049381..e779370330 100644 --- a/contracts/sysio.msgch/src/sysio.msgch.cpp +++ b/contracts/sysio.msgch/src/sysio.msgch.cpp @@ -55,33 +55,11 @@ using sysio::slug_name_literals::operator""_s; /// 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; +// MAX_ENVELOPE_BYTES / ATTESTATION_OVERHEAD_BYTES / ENVELOPE_BASELINE_BYTES are declared +// on the `msgch` contract class in sysio.msgch.hpp — the envelope budget bounds what any +// attestation producer may emit, so `sysio.epoch` derives its roster ceiling from them +// instead of restating the numbers. Every use below is inside a `msgch` member function +// and so still resolves unqualified. /// Stable audit marker for a UIC rejected before it can reach `rcrdcommit`. constexpr const char* UIC_DISPATCH_REJECTED_LOG_PREFIX = @@ -1723,10 +1701,25 @@ void msgch::buildenv(uint64_t chain_code) { // 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. - check(included_count > 0, - "sysio.msgch::buildenv: a single READY attestation exceeds the outbound envelope"); + // upfront avoids building anything in the doomed case. + // + // This abort is far more severe than "this outpost's envelope is skipped": `buildenv` is + // inline-sent from `sysio.epoch::advance`, so it takes the whole epoch-advance transaction with + // it and epoch advancement stops CHAIN-WIDE. The head-of-line `break` above makes it permanent + // rather than probabilistic — the same oversized candidate re-packs identically every epoch, and + // everything queued behind it never ships either. + // + // So the message carries the offending row's id and the arithmetic that rejected it, rather than + // being an anonymous overflow that has to be reconstructed from table state afterwards. The + // attestation type is readable from the `attestations` row the id names. + if (included_count == 0) { + const size_t head_bytes = ATTESTATION_OVERHEAD_BYTES + candidate_entries.front().data.size(); + check(false, + "sysio.msgch::buildenv: a single READY attestation exceeds the outbound envelope" + " -- att_id=" + std::to_string(candidate_ids.front()) + + " bytes=" + std::to_string(head_bytes) + + " budget=" + std::to_string(opp::SINGLE_ATTESTATION_BUDGET_BYTES)); + } std::vector entries( std::make_move_iterator(candidate_entries.begin()), diff --git a/contracts/sysio.msgch/sysio.msgch.wasm b/contracts/sysio.msgch/sysio.msgch.wasm index a5d866613a..57a7c92f31 100755 Binary files a/contracts/sysio.msgch/sysio.msgch.wasm and b/contracts/sysio.msgch/sysio.msgch.wasm differ diff --git a/contracts/sysio.opp.common/include/sysio.opp.common/opp_envelope_budget.hpp b/contracts/sysio.opp.common/include/sysio.opp.common/opp_envelope_budget.hpp new file mode 100644 index 0000000000..b381796651 --- /dev/null +++ b/contracts/sysio.opp.common/include/sysio.opp.common/opp_envelope_budget.hpp @@ -0,0 +1,52 @@ +#pragma once + +#include + +/// OPP outbound-envelope budget. +/// +/// These live in `sysio.opp.common` rather than on the `sysio.msgch` contract that enforces +/// them because the budget bounds what any PRODUCER of an attestation may emit, not just the +/// packer that assembles the envelope. `sysio.epoch` derives its OPERATORS-roster ceiling from +/// them, and `sysio.opreg::setconfig` validates the registry ceilings against that — three +/// contracts, one set of numbers. A second copy of a consensus-visible cap is a drift hazard, +/// and the whole point of the cap is that every side agrees on it. +/// +/// `sysio.msgch` re-exposes them as members of its contract class so its own packing loop reads +/// them unqualified; those are aliases of these, never copies. +namespace sysio::opp { + +/// 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. +inline 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. +inline 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. +inline constexpr size_t ENVELOPE_BASELINE_BYTES = 512; + +/// Bytes available to a SINGLE attestation that must ride an envelope alone — +/// the envelope cap less the header baseline and that attestation's own framing. +/// This is the figure `buildenv`'s first-attestation-too-big guard rejects against. +inline constexpr size_t SINGLE_ATTESTATION_BUDGET_BYTES = + MAX_ENVELOPE_BYTES - ENVELOPE_BASELINE_BYTES - ATTESTATION_OVERHEAD_BYTES; + +} // namespace sysio::opp diff --git a/contracts/sysio.opreg/src/sysio.opreg.cpp b/contracts/sysio.opreg/src/sysio.opreg.cpp index d1ad8284db..d87a7b417b 100644 --- a/contracts/sysio.opreg/src/sysio.opreg.cpp +++ b/contracts/sysio.opreg/src/sysio.opreg.cpp @@ -191,6 +191,59 @@ bool is_fully_settled(const opreg::operator_entry& op) { return !has_active_locks(op.account); } +/// The `op_config` registration ceiling for `type`, or 0 when the type carries none. +/// +/// 0 is unambiguous as "uncapped": `setconfig` rejects any `max_available_*` that is +/// not positive, so a real ceiling is always >= 1. CHALLENGER has no `max_available_*` +/// field and is uncapped by construction — it is privileged-registered only. +uint32_t registration_ceiling_for_type(OperatorType type, const opreg::op_config& cfg) { + switch (type) { + case OperatorType::OPERATOR_TYPE_PRODUCER: return cfg.max_available_producers; + case OperatorType::OPERATOR_TYPE_BATCH: return cfg.max_available_batch_ops; + case OperatorType::OPERATOR_TYPE_UNDERWRITER: return cfg.max_available_underwriters; + default: return 0; + } +} + +/// Count NON-TERMINATED rows of `type`, stopping once `limit` is reached. +/// +/// The caller only needs to know whether the ceiling is already met, so the scan is +/// bounded by the ceiling rather than by the roster — registration stays O(ceiling) +/// however large the registry grows. +/// +/// TERMINATED rows are excluded so a terminated-and-settled operator frees its slot +/// when `prune` erases it; counting them would let settled history permanently exhaust +/// the registry. UNKNOWN, ACTIVE and SLASHED all count: UNKNOWN is what an attacker +/// accumulates for free, and ACTIVE + SLASHED is exactly the set the OPERATORS roster +/// ships. +/// Count operators currently carried by the OPERATORS roster — ACTIVE + SLASHED, the +/// exact set `sysio.epoch::advance` serializes. Bounded by `limit`: the caller only ever +/// asks "is the roster already at its ceiling", so walking past it buys nothing. +uint32_t count_roster_operators(name self, uint32_t limit) { + opreg::operators_t ops(self); + auto status_idx = ops.get_index<"bystatus"_n>(); + uint32_t count = 0; + for (const auto status : { OperatorStatus::OPERATOR_STATUS_ACTIVE, + OperatorStatus::OPERATOR_STATUS_SLASHED }) { + for (auto it = status_idx.lower_bound(magic_enum::enum_integer(status)); + it != status_idx.end() && it->status == status && count < limit; ++it) { + ++count; + } + } + return count; +} + +uint32_t count_registered_of_type(name self, OperatorType type, uint32_t limit) { + opreg::operators_t ops(self); + auto type_idx = ops.get_index<"bytype"_n>(); + uint32_t count = 0; + for (auto it = type_idx.lower_bound(magic_enum::enum_integer(type)); + it != type_idx.end() && it->type == type && count < limit; ++it) { + if (it->status != OperatorStatus::OPERATOR_STATUS_TERMINATED) ++count; + } + return count; +} + } // anonymous namespace // --------------------------------------------------------------------------- @@ -220,6 +273,36 @@ void opreg::setconfig(uint32_t max_available_producers, "terminate_max_pct_misses_24h must be in [1, 99]"); check(terminate_window_ms > 0, "terminate_window_ms must be positive"); + // The registry ceilings bound the OPERATORS attestation, so they cannot be raised + // past what an outbound envelope can carry. + // + // Every registered operator rides that attestation until it is TERMINATED, and it is + // the one attestation whose size grows with adoption — so a governance change that + // raised these three fields without this check could make `sysio.epoch::advance` + // produce a roster no envelope can hold. `buildenv` would then abort on its + // single-attestation guard, and because it is inline-sent from `advance` that stops + // epoch advancement CHAIN-WIDE, permanently, with no configuration left to change it + // through. `sysio.epoch` already applies this reasoning to the sibling + // BATCH_OPERATOR_GROUPS attestation via `MAX_SCHEDULED_BATCH_OPERATORS`. + // + // The sum is what matters: all three types share one roster. CHALLENGER carries no + // per-type ceiling, but a self-registered challenger can never reach the roster: ACTIVE + // at registration requires `is_bootstrapped` (privileged), and the eligibility path + // returns false for CHALLENGER, so it stays UNKNOWN and the ACTIVE+SLASHED filter + // excludes it. Bootstrapped operators DO reach the roster, so they are bounded by the + // global actual-roster check in `regoperator` rather than by this configured sum. + { + const uint64_t total_ceiling = static_cast(max_available_producers) + + max_available_batch_ops + + max_available_underwriters; + const uint32_t roster_ceiling = + sysio::epoch::max_roster_operators(sysio::chains::active_outpost_count()); + check(total_ceiling <= roster_ceiling, + "max_available_* sum exceeds the OPERATORS roster ceiling (" + + std::to_string(roster_ceiling) + "): the resulting roster could not fit an " + "outbound envelope, or could not be seated by an outpost"); + } + // SEC-28 residual: delivery records accrue only on duty epochs -- one per // `batch_op_groups`-epoch rotation for a resident operator -- so a rolling // window narrower than the full consecutive-miss run of duty epochs makes @@ -328,6 +411,46 @@ void opreg::regoperator(name account, ops.erase(op_pk); } + // Registration ceiling. `op_config.max_available_*` has been declared, validated in + // `setconfig` and persisted since the registry landed, but was read by NOTHING — so + // the registry grew without any count, rate or admission bound, and registration is + // self-authorizing with the row billed to the sysio RAM pool rather than to the + // registrant. Enforcing the ceiling here is what makes that bound real, and what + // stops the OPERATORS roster (sized against the outbound envelope) from growing + // without limit. + // + // Bootstrapped operators bypass it, consistently with their other privileged paths + // (`bootstrapped-operator-invariants.md`): they are the genesis seed the chain cannot + // start without, and they are privileged-registered. + if (!is_bootstrapped) { + opconfig_t ceiling_cfg_tbl(get_self()); + const auto ceiling_cfg = ceiling_cfg_tbl.get_or_default(op_config{}); + const auto ceiling = registration_ceiling_for_type(type, ceiling_cfg); + if (ceiling > 0) { + check(count_registered_of_type(get_self(), type, ceiling) < ceiling, + "operator registration ceiling reached for this operator type"); + } + } + + // Global actual-roster bound — applies to EVERY registration, bootstrapped included. + // + // The per-type ceilings above are a CONFIGURED sum that the bootstrap path bypasses, + // so on their own they are not a safety invariant: a privileged seed could push the + // LIVE roster past what half an envelope carries or what an outpost can seat, and a + // bootstrapped operator lands ACTIVE immediately, so it is in the roster at once. + // Bootstrap's exemptions from collateral and termination are ECONOMIC + // (`bootstrapped-operator-invariants.md`); they do not extend to a consensus-liveness + // capacity limit, because exceeding it does not harm the operator — it wedges the + // outpost for everyone. + { + const uint32_t roster_ceiling = + sysio::epoch::max_roster_operators(sysio::chains::active_outpost_count()); + check(count_roster_operators(get_self(), roster_ceiling) < roster_ceiling, + "OPERATORS roster ceiling reached (" + std::to_string(roster_ceiling) + + "): registering another operator would produce a roster that cannot fit an " + "outbound envelope or be seated by an outpost"); + } + // Verify authex links exist for all active outpost chains. // Skip when: bootstrapped OR privileged caller (sysio.opreg registering on behalf) // diff --git a/contracts/sysio.opreg/sysio.opreg.wasm b/contracts/sysio.opreg/sysio.opreg.wasm index 428cc39aba..771541971e 100755 Binary files a/contracts/sysio.opreg/sysio.opreg.wasm and b/contracts/sysio.opreg/sysio.opreg.wasm differ diff --git a/contracts/tests/sysio.dispatch_tests.cpp b/contracts/tests/sysio.dispatch_tests.cpp index 55ab29c60b..6b38ee1eca 100644 --- a/contracts/tests/sysio.dispatch_tests.cpp +++ b/contracts/tests/sysio.dispatch_tests.cpp @@ -476,13 +476,17 @@ class sysio_dispatch_tester : public tester { /// `prune_delay_ms` defaults to 10 minutes — far beyond any test's wall /// clock, so `prune` cases that want to exercise a gate OTHER than the delay /// lower it explicitly. + /// `max_available_underwriters` is a parameter because the registration ceiling is now + /// ENFORCED in `regoperator` (WIRE-342): a scenario that provisions more underwriters + /// than the ceiling must raise it explicitly rather than silently exceeding it. action_result opreg_setconfig_collat(const fc::variants& req_uw_collat, const fc::variants& req_prod_collat = fc::variants{}, - uint64_t prune_delay_ms = 600000) { + uint64_t prune_delay_ms = 600000, + uint32_t max_available_underwriters = 21) { return push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, "setconfig"_n, mvo() ("max_available_producers", 21) ("max_available_batch_ops", 63) - ("max_available_underwriters", 21) + ("max_available_underwriters", max_available_underwriters) ("terminate_prune_delay_ms", prune_delay_ms) ("terminate_max_consecutive_misses", 5) ("terminate_max_pct_misses_24h", 5) @@ -1021,6 +1025,24 @@ class sysio_dispatch_tester : public tester { return v["next"].as_uint64(); } + /// Raw bytes of the outbound envelope currently staged for `chain_code`. `sysio.msgch` + /// keeps `outenvelopes` one row deep per outpost and replaces it on each `buildenv`, so + /// this returns the most recent epoch's bytes -- the exact payload the outpost consumes. + /// Reading the ENVELOPE rather than the `attestations` rows is deliberate: `buildenv` + /// consumes the READY attestation rows as it bundles them, so they no longer exist by + /// the time an advance returns. + std::vector outbound_envelope_bytes(uint64_t chain_code, uint64_t scan_until = 32) { + for (uint64_t id = 0; id < scan_until; ++id) { + auto data = get_row_by_id(MSGCH_ACCOUNT, MSGCH_ACCOUNT, "outenvelopes"_n, id); + if (data.empty()) continue; + auto row = msgch_abi.binary_to_variant("outbound_envelope", data, + abi_serializer::create_yield_function(abi_serializer_max_time)); + if (row["chain_code"].as_uint64() != chain_code) continue; + return row["raw_envelope"].as>(); + } + return {}; + } + /// Read a collateral lock row by lock_id (uwrit `locks` KV table). lock_ids /// are allocated from 1 (uwcounters default), so the first swap's source + /// destination locks are ids 1 and 2. @@ -5322,6 +5344,15 @@ BOOST_FIXTURE_TEST_CASE(rcrdcommit_candidate_cap_bounds_row, // Provision 33 ACTIVE underwriters (register + meet the 1-unit ETH/ETH // minimum from bootstrap_for_dispatch's opreg config). UWRIT_OP stays out // of this roster. + // + // The registration ceiling is enforced as of WIRE-342, and the fixture's default + // `max_available_underwriters` is 21 — below the 32-candidate cap this test exercises. + // Raise it so the scenario is reachable; the ceiling itself is covered in + // `sysio.opreg_tests`. + BOOST_REQUIRE_EQUAL(success(), + opreg_setconfig_collat(fc::variants{chain_min_bond_mvo("ETH", "ETH", 1)}, + fc::variants{}, 600000, /*max_available_underwriters=*/40)); + std::vector uws; for (uint32_t i = 0; i < 33; ++i) { std::string s = "uwcap"; @@ -6260,4 +6291,193 @@ BOOST_FIXTURE_TEST_CASE(lock_hold_actions_require_chalg_auth, sysio_uwchal_teste .find("missing authority of sysio.chalg") != std::string::npos); } FC_LOG_AND_RETHROW() } +// --------------------------------------------------------------------------- +// WIRE-342 — the OPERATORS roster ships only when its content changed +// --------------------------------------------------------------------------- +// +// `next_att_id()` is the monotonic `attseq` counter every `queueout` mints from, so its +// delta across one `advance` counts what that advance queued — and unlike the +// `attestations` rows themselves it survives the inline `buildenv` drain. The assertions +// below are RELATIVE (static-epoch delta vs roster-changing-epoch delta) so they stay +// valid whatever else `advance` queues per epoch. + +/// Bring the fixture to a state where `advance` actually COMPLETES. +/// +/// `bootstrap_for_dispatch` alone is not enough: its own advance runs while emissions are +/// unconfigured, so `sysio.epoch::advance` gate-blocks and returns at its emissions check — +/// long before the roster/queueout block. Every delta below would then be 0, and a test +/// asserting "no roster was queued" would pass for entirely the wrong reason. +#define REQUIRE_ADVANCING_FIXTURE() \ + do { \ + bootstrap_for_dispatch(); \ + setup_wire_token_and_reserves(); \ + enable_epoch_advancement(); \ + BOOST_REQUIRE_EQUAL(success(), \ + push(MSGCH_ACCOUNT, msgch_abi, MSGCH_ACCOUNT, "bootstrap"_n, mvo())); \ + produce_blocks(); \ + BOOST_REQUIRE_EQUAL(current_epoch(), 1u); \ + } while (0) + +BOOST_FIXTURE_TEST_CASE(advance_queues_operators_only_when_the_roster_changes, + sysio_dispatch_tester) { try { + REQUIRE_ADVANCING_FIXTURE(); + create_accounts({"batchop.b"_n}); + produce_blocks(); + + // Establish the per-epoch baseline over an unchanged roster. + const uint32_t epoch_before_baseline = current_epoch(); + age_one_epoch(); + const uint64_t before_first = next_att_id(); + age_one_epoch(); + const uint64_t static_delta = next_att_id() - before_first; + + // Non-vacuity guards. Without these the whole test passes when `advance` silently does + // nothing — which is exactly how the first version of it "passed". + BOOST_REQUIRE_GT(current_epoch(), epoch_before_baseline); + BOOST_REQUIRE_GT(static_delta, 0u); + + // A second unchanged epoch must queue exactly the same set — the roster re-derives to + // byte-identical content, so no OPERATORS attestation is minted. Before this change the + // full roster was re-encoded and fanned to every outpost on EVERY advance regardless. + const uint64_t before_second = next_att_id(); + age_one_epoch(); + BOOST_REQUIRE_EQUAL(static_delta, next_att_id() - before_second); + + // A bootstrapped registration lands ACTIVE immediately, so the roster's content changes + // and the next advance queues exactly one MORE attestation: the roster itself. + BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, + "regoperator"_n, mvo() + ("account", std::string("batchop.b")) + ("type", OperatorType::OPERATOR_TYPE_BATCH) + ("is_bootstrapped", true))); + + const uint64_t before_change = next_att_id(); + age_one_epoch(); + BOOST_REQUIRE_EQUAL(static_delta + 1, next_att_id() - before_change); + + // And the epoch after the change is static again — one send per change, not a latch. + const uint64_t before_after = next_att_id(); + age_one_epoch(); + BOOST_REQUIRE_EQUAL(static_delta, next_att_id() - before_after); +} FC_LOG_AND_RETHROW() } + +BOOST_FIXTURE_TEST_CASE(advance_ignores_never_bonded_registrations, sysio_dispatch_tester) { try { + REQUIRE_ADVANCING_FIXTURE(); + create_accounts({"batchop.b"_n}); + produce_blocks(); + + age_one_epoch(); + const uint64_t before_first = next_att_id(); + age_one_epoch(); + const uint64_t static_delta = next_att_id() - before_first; + + // This assertion is what makes the test meaningful: it proves advance is really queueing + // per epoch, so the "no extra attestation" check below is a genuine observation rather + // than a comparison of two zeroes. + BOOST_REQUIRE_GT(static_delta, 0u); + + // A NON-bootstrapped registration lands OPERATOR_STATUS_UNKNOWN — registered, never + // bonded. It is excluded from the roster, so it changes no content, so it produces NO + // envelope traffic whatsoever. + // + // This is the whole WIRE-342 attack closure: accumulating never-bonded registrations + // was free (self-authorizing, row billed to the sysio RAM pool) and every one of them + // rode every envelope to every outpost. Now it costs the registrant a transaction and + // the chain nothing at all. + BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, + "regoperator"_n, mvo() + ("account", std::string("batchop.b")) + ("type", OperatorType::OPERATOR_TYPE_BATCH) + ("is_bootstrapped", false))); + + const uint64_t before_unknown = next_att_id(); + age_one_epoch(); + BOOST_REQUIRE_EQUAL(static_delta, next_att_id() - before_unknown); +} FC_LOG_AND_RETHROW() } + +BOOST_FIXTURE_TEST_CASE(advance_sends_the_roster_to_a_newly_activated_outpost_only, + sysio_dispatch_tester) { try { + REQUIRE_ADVANCING_FIXTURE(); + + age_one_epoch(); + const uint64_t before_first = next_att_id(); + age_one_epoch(); + const uint64_t static_delta = next_att_id() - before_first; // one outpost + BOOST_REQUIRE_GT(static_delta, 0u); + + // Register a SECOND outpost. The roster's content is unchanged — it lists operators, + // not chains — so the incumbent outpost's digest still matches and it gets nothing. + // The new outpost has no digest row at all, and that ABSENCE is what makes it receive + // the roster on its very first advance. + BOOST_REQUIRE_EQUAL(success(), push(CHAINS_ACCOUNT, chains_abi, CHAINS_ACCOUNT, + "regchain"_n, mvo() + ("kind", ChainKind::CHAIN_KIND_SVM) + ("code", codename_mvo("SOL")) + ("external_chain_id", 900) + ("name", std::string("outpost-two")) + ("description", std::string{}))); + + // `regchain` outside the bootstrap window inserts the row with `active = false`, and + // `is_active_outpost` skips it — so no fan-out reaches it until it is activated. That + // gate is exactly what makes "a newly ACTIVATED outpost" the event under test here. + BOOST_REQUIRE_EQUAL(success(), push(CHAINS_ACCOUNT, chains_abi, CHAINS_ACCOUNT, + "activchain"_n, mvo()("code", codename_mvo("SOL")))); + produce_blocks(); + + // +1 for the second outpost's own BATCH_OPERATOR_GROUPS (which ships every epoch to + // every outpost) and +1 for the roster going to the NEW outpost alone. If the digest + // gate were global rather than per-outpost this would be +1; if it re-sent to every + // outpost on any miss it would be +3. + const uint64_t before_new = next_att_id(); + age_one_epoch(); + BOOST_REQUIRE_EQUAL(static_delta + 2, next_att_id() - before_new); + + // Both outposts now hold the same roster digest, so the next epoch carries only the + // two per-epoch group attestations. + const uint64_t before_settled = next_att_id(); + age_one_epoch(); + BOOST_REQUIRE_EQUAL(static_delta + 1, next_att_id() - before_settled); +} FC_LOG_AND_RETHROW() } + +BOOST_FIXTURE_TEST_CASE(advance_roster_retains_slashed_operators, sysio_dispatch_tester) { try { + REQUIRE_ADVANCING_FIXTURE(); + create_accounts({"batchop.b"_n}); + produce_blocks(); + + // A bootstrapped registration lands ACTIVE immediately, so it joins the roster at once. + BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, + "regoperator"_n, mvo() + ("account", std::string("batchop.b")) + ("type", OperatorType::OPERATOR_TYPE_BATCH) + ("is_bootstrapped", true))); + + // Settle the roster so the operator's ACTIVE entry is what the recorded digest covers. + age_one_epoch(); + + // `sysio.opreg::slash` flips the row to SLASHED *before* emitting OPERATOR_ACTION(SLASH). + BOOST_REQUIRE_EQUAL(success(), slash_op("batchop.b"_n, "wire-342 roster retention")); + + const uint64_t before_slash_epoch = next_att_id(); + age_one_epoch(); + + // `status` is part of the encoded entry, so the flip is a content change and the digest + // gate must let this roster through rather than suppressing it as unchanged. Guarding on + // the mint counter first keeps the payload assertion below from passing against a stale + // envelope that some earlier epoch happened to leave staged. + BOOST_REQUIRE_GT(next_att_id(), before_slash_epoch); + + // Read the ENVELOPE, not the attestation rows: `buildenv` consumes those as it bundles. + const auto raw = outbound_envelope_bytes(fc::slug_name{"ETH"}.value); + BOOST_REQUIRE(!raw.empty()); + + // THE REGRESSION GUARD for the SLASHED carve-out. `build_operator_entry` writes + // `entry.account.name` as a plain string, so the account name appears verbatim in the + // serialized bytes. Narrowing the roster filter to ACTIVE-only would drop this entry -- + // and because the Solana slash handler resolves its target THROUGH the roster the + // outpost holds, that would silently turn every subsequent slash into a no-op on the + // outpost instead of failing loudly. + const std::string encoded(raw.begin(), raw.end()); + BOOST_REQUIRE(encoded.find("batchop.b") != std::string::npos); +} FC_LOG_AND_RETHROW() } + BOOST_AUTO_TEST_SUITE_END() diff --git a/contracts/tests/sysio.opreg_tests.cpp b/contracts/tests/sysio.opreg_tests.cpp index 7e22e3b613..bb76fd6141 100644 --- a/contracts/tests/sysio.opreg_tests.cpp +++ b/contracts/tests/sysio.opreg_tests.cpp @@ -1455,4 +1455,170 @@ BOOST_FIXTURE_TEST_CASE(flushwtdw_bounds_rows_per_epoch, sysio_opreg_tester) { t BOOST_REQUIRE_EQUAL(0u, count_pending()); } FC_LOG_AND_RETHROW() } +// --------------------------------------------------------------------------- +// WIRE-342 — registry ceilings bound the OPERATORS roster +// --------------------------------------------------------------------------- + +/// `epoch::max_roster_operators` mirrored for the host tests: the epoch contract header +/// is CDT-only and cannot be included here, so the derivation is restated with its inputs +/// rather than as a bare number. If `sysio.epoch.hpp` changes any of these, this mirror +/// diverges and the boundary cases below fail — which is the intent: the ceiling is +/// consensus-visible and a silent change to it should not pass unnoticed. +/// +/// Two independent bounds, whichever is smaller: +/// bytes — (32768 envelope - 512 baseline - 24 overhead) / 2 share / bytes-per-operator +/// entries — the strictest outpost's roster capacity (Solana's `MAX_OPERATORS`) +static constexpr uint32_t EXPECTED_MAX_OUTPOST_ROSTER_ENTRIES = 128; + +static constexpr uint32_t expected_max_roster_operators(uint32_t outposts) { + const uint32_t addresses = outposts == 0 ? 1 : (outposts > 8 ? 8 : outposts); + const uint32_t bytes_per = 17 + addresses * 39 + 2 + 3 + 2; + const uint32_t by_bytes = (32768 - 512 - 24) / 2 / bytes_per; + return by_bytes < EXPECTED_MAX_OUTPOST_ROSTER_ENTRIES + ? by_bytes : EXPECTED_MAX_OUTPOST_ROSTER_ENTRIES; +} + +/// This fixture registers no chains, so the ceiling is the outpost entry cap. +static constexpr uint32_t EXPECTED_ROSTER_CEILING = expected_max_roster_operators(0); + +BOOST_FIXTURE_TEST_CASE(setconfig_accepts_shipped_default_ceilings, sysio_opreg_tester) { try { + // The defaults must remain a legal configuration — a ceiling that rejected them would + // brick `setconfig` for every existing cluster. + BOOST_REQUIRE_EQUAL(success(), setconfig(21, 63, 21)); + BOOST_REQUIRE_LT(21u + 63u + 21u, EXPECTED_ROSTER_CEILING); + + // The launch outpost set is {ETH, SOL}. At that shape the OUTPOST entry cap binds + // before the envelope does — so the depot cannot accept a configuration Solana would + // refuse to seat. Adding outposts fattens every entry and hands the bound back to + // bytes, which is the case a fixed bytes-per-operator constant used to get wrong. + BOOST_REQUIRE_EQUAL(EXPECTED_MAX_OUTPOST_ROSTER_ENTRIES, expected_max_roster_operators(2)); + BOOST_REQUIRE_LT(expected_max_roster_operators(4), expected_max_roster_operators(2)); +} FC_LOG_AND_RETHROW() } + +BOOST_FIXTURE_TEST_CASE(setconfig_rejects_ceilings_exceeding_roster_capacity, sysio_opreg_tester) { try { + // Exactly at the ceiling is legal; one over is not. Pinning both sides is what makes + // this a boundary test rather than a smoke test. + const uint32_t at_ceiling = EXPECTED_ROSTER_CEILING; + BOOST_REQUIRE_EQUAL(success(), setconfig(at_ceiling - 2, 1, 1)); + + BOOST_REQUIRE_EQUAL( + error("assertion failure with message: max_available_* sum exceeds the OPERATORS " + "roster ceiling (" + std::to_string(EXPECTED_ROSTER_CEILING) + "): the " + "resulting roster could not fit an outbound envelope, or could not be seated " + "by an outpost"), + setconfig(at_ceiling - 1, 1, 1)); + + // A governance-scale raise is the case this guard exists for: without it the roster + // could grow past what an envelope carries, and `buildenv` aborts inline inside + // `sysio.epoch::advance` — halting epoch advancement chain-wide with no config left + // to change it through. + BOOST_REQUIRE_EQUAL( + error("assertion failure with message: max_available_* sum exceeds the OPERATORS " + "roster ceiling (" + std::to_string(EXPECTED_ROSTER_CEILING) + "): the " + "resulting roster could not fit an outbound envelope, or could not be seated " + "by an outpost"), + setconfig(500, 500, 500)); +} FC_LOG_AND_RETHROW() } + +BOOST_FIXTURE_TEST_CASE(regoperator_enforces_per_type_ceiling, sysio_opreg_tester) { try { + // One batch-operator slot. `max_available_*` was declared, validated and persisted + // since the registry landed but read by NOTHING, so registration had no count, rate + // or admission bound at all. + BOOST_REQUIRE_EQUAL(success(), setconfig(21, 1, 21)); + + BOOST_REQUIRE_EQUAL(success(), + regoperator("batchop.a"_n, OperatorType::OPERATOR_TYPE_BATCH, false)); + + BOOST_REQUIRE_EQUAL( + error("assertion failure with message: operator registration ceiling reached for " + "this operator type"), + regoperator("batchop.b"_n, OperatorType::OPERATOR_TYPE_BATCH, false)); + + // The ceiling is PER TYPE — a full batch-operator roster must not block underwriters. + BOOST_REQUIRE_EQUAL(success(), + regoperator("uwrit.a"_n, OperatorType::OPERATOR_TYPE_UNDERWRITER, false)); +} FC_LOG_AND_RETHROW() } + +BOOST_FIXTURE_TEST_CASE(regoperator_ceiling_bypassed_by_bootstrapped, sysio_opreg_tester) { try { + // Bootstrapped operators are the genesis seed the chain cannot start without, and are + // already exempt from the collateral and termination gates + // (`bootstrapped-operator-invariants.md`). The ceiling must not be the one gate that + // can refuse them, or a mis-set config could make the chain unstartable. + BOOST_REQUIRE_EQUAL(success(), setconfig(21, 1, 21)); + + BOOST_REQUIRE_EQUAL(success(), + regoperator("batchop.a"_n, OperatorType::OPERATOR_TYPE_BATCH, false)); + BOOST_REQUIRE_EQUAL(success(), + regoperator("batchop.b"_n, OperatorType::OPERATOR_TYPE_BATCH, true)); +} FC_LOG_AND_RETHROW() } + +BOOST_FIXTURE_TEST_CASE(regoperator_global_roster_bound_applies_to_bootstrapped, + sysio_opreg_tester) { try { + // The PER-TYPE ceilings are a configured sum the bootstrap path bypasses, so on their + // own they are not a safety invariant. The GLOBAL bound is, and it must hold for + // bootstrapped registrations too: a bootstrapped operator lands ACTIVE immediately and + // is therefore in the roster at once. Bootstrap's collateral and termination + // exemptions are economic; exceeding a transport/seating ceiling is not a cost to the + // operator, it wedges the outpost for everyone. + BOOST_REQUIRE_EQUAL(success(), setconfig(21, 63, 21)); + + std::vector ops; + ops.reserve(EXPECTED_ROSTER_CEILING + 1); + for (uint32_t i = 0; i <= EXPECTED_ROSTER_CEILING; ++i) { + std::string account = "rostr"; + account += static_cast('a' + i / 26); + account += static_cast('a' + i % 26); + ops.emplace_back(account); + } + // Chunked: creating this many accounts in one block trips `block_cpu_usage_exceeded` + // before the test reaches what it is actually asserting. + for (size_t start = 0; start < ops.size(); start += 8) { + const size_t stop = std::min(start + 8, ops.size()); + create_accounts(std::vector(ops.begin() + start, ops.begin() + stop)); + produce_blocks(); + } + + // Fill the roster to exactly the ceiling, all bootstrapped so each lands ACTIVE and + // enters the roster immediately. Blocks are produced along the way: this many + // registrations in one block would exhaust the per-block billable CPU. + // One block per registration. Each `regoperator` now also walks the roster (bounded by + // the ceiling) on top of the per-type count, so batching even a handful into one block + // trips `block_cpu_usage_exceeded` well before the ceiling is reached. + for (uint32_t i = 0; i < EXPECTED_ROSTER_CEILING; ++i) { + BOOST_REQUIRE_EQUAL(success(), + regoperator(ops[i], OperatorType::OPERATOR_TYPE_BATCH, true)); + produce_blocks(); + } + + // The bypass tested above covers the PER-TYPE ceiling only. It must NOT extend to the + // global bound — this is the assertion that makes the sum a real invariant. + BOOST_REQUIRE_EQUAL( + error("assertion failure with message: OPERATORS roster ceiling reached (" + + std::to_string(EXPECTED_ROSTER_CEILING) + "): registering another operator " + "would produce a roster that cannot fit an outbound envelope or be seated by " + "an outpost"), + regoperator(ops[EXPECTED_ROSTER_CEILING], OperatorType::OPERATOR_TYPE_BATCH, true)); +} FC_LOG_AND_RETHROW() } + +BOOST_FIXTURE_TEST_CASE(regoperator_ceiling_ignores_terminated_rows, sysio_opreg_tester) { try { + // The count deliberately excludes TERMINATED rows. Counting them would let settled + // history permanently exhaust the registry: terminated rows are erased only by the + // capped, permissionless `prune`, so a chain with churn would eventually refuse every + // registration. UNKNOWN, ACTIVE and SLASHED all count — UNKNOWN is what an attacker + // accumulates for free, and ACTIVE + SLASHED is exactly what the roster ships. + BOOST_REQUIRE_EQUAL(success(), setconfig(21, 1, 21)); + + BOOST_REQUIRE_EQUAL(success(), + regoperator("batchop.a"_n, OperatorType::OPERATOR_TYPE_BATCH, false)); + BOOST_REQUIRE_EQUAL( + error("assertion failure with message: operator registration ceiling reached for " + "this operator type"), + regoperator("batchop.b"_n, OperatorType::OPERATOR_TYPE_BATCH, false)); + + // Terminating the incumbent frees the slot even before `prune` erases the row. + BOOST_REQUIRE_EQUAL(success(), terminate("batchop.a"_n, "test")); + BOOST_REQUIRE_EQUAL(success(), + regoperator("batchop.b"_n, OperatorType::OPERATOR_TYPE_BATCH, false)); +} FC_LOG_AND_RETHROW() } + BOOST_AUTO_TEST_SUITE_END()