diff --git a/OVERVIEW.md b/OVERVIEW.md index 88d9644cd9..191d911553 100644 --- a/OVERVIEW.md +++ b/OVERVIEW.md @@ -57,7 +57,7 @@ This allows a single unified message format across all supported chains. ### Outbound (Wire → External Chain) 1. System contracts queue attestations for a target outpost via `sysio.msgch::queueout()`. -2. When the epoch advances, `buildenv()` packs all `READY` attestations into a protobuf-encoded outbound envelope with a merkle root. +2. When the epoch advances, `buildenv()` packs each outpost's `READY` attestations, the operator schedule first and otherwise oldest first, into a protobuf-encoded outbound envelope of at most 32 KiB; the rest wait for the next epoch. 3. Batch operators read the outbound envelope and submit it to the target chain's Outpost contract. 4. The Outpost contract (`OPPInbound.sol` on Ethereum) verifies consensus, checks merkle proofs, and routes attestations to registered handler contracts. diff --git a/contracts/sysio.epoch/src/sysio.epoch.cpp b/contracts/sysio.epoch/src/sysio.epoch.cpp index e8711f0935..bbd8f4d529 100644 --- a/contracts/sysio.epoch/src/sysio.epoch.cpp +++ b/contracts/sysio.epoch/src/sysio.epoch.cpp @@ -19,6 +19,7 @@ #include #include #include +#include namespace sysio { @@ -57,6 +58,22 @@ constexpr name RECORD_DELIVERY = "recorddel"_n; constexpr name TERMINATION_CHECK = "termcheck"_n; } // namespace opreg_actions +/// Action identifiers owned by sysio.msgch and invoked while closing an epoch. +namespace msgch_actions { +constexpr name QUEUE_OUT = "queueout"_n; +} // namespace msgch_actions + +/// Operator statuses the OPERATORS roster lists; both outposts treat an operator missing from the +/// latest roster as not active. UNKNOWN (registered without the required bond) is left out because +/// registration is permissionless, so listing it would let anyone inflate the roster; the cost is +/// that such an operator's bond misses the Solana outpost's deposit priority lane. Leaving TERMINATED +/// out is safe: `releaselock` may still remit its locked collateral, and WITHDRAW_REMIT needs no +/// roster entry on either outpost. SLASHED stays because `opreg::slash` marks the row before it emits +/// the SLASH, and the Solana outpost resolves a slash's target, and keeps its tombstone, through the +/// roster. +constexpr std::array ROSTER_STATUSES = {OperatorStatus::OPERATOR_STATUS_ACTIVE, + OperatorStatus::OPERATOR_STATUS_SLASHED}; + /// Durable reason prefix for an epoch-delivery classification slash. constexpr const char* NON_CANONICAL_DELIVERY_REASON_PREFIX = "non-canonical OPP envelope delivery, epoch "; @@ -763,75 +780,65 @@ void epoch::advance() { std::make_tuple(state.current_epoch_index) ).send(); - // Queue OPERATORS attestation (full roster with authex chain addresses) for each outpost. - // IMPORTANT: Must come before BATCH_OPERATOR_GROUPS so that the ETH outpost's - // _handleOperators populates operatorEthAddress before _handleBatchOperatorGroups - // looks up those addresses. + // The operator schedule: the OPERATORS roster (with each operator's authex chain addresses) and the + // BATCH_OPERATOR_GROUPS window. Both are encoded before either is queued because together they must + // fit the schedule lane's share of the envelope (`msgch::SCHEDULE_LANE_BUDGET_BYTES`). + // + // The roster lists `ROSTER_STATUSES` only, walked through opreg's `bystatus` index. + std::vector encoded_operators; { opp::attestations::Operators ops_attest; opreg::operators_t opreg_ops(OPREG_ACCOUNT); + auto status_idx = opreg_ops.get_index<"bystatus"_n>(); 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 roster_status : ROSTER_STATUSES) { + for (auto it = status_idx.lower_bound(magic_enum::enum_integer(roster_status)); + it != status_idx.end() && it->status == roster_status; ++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, which are 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{}}; + auto out = zpp::bits::out{encoded_operators, 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(); - } } - // Queue BATCH_OPERATOR_GROUPS attestation for each outpost. + // Encode the BATCH_OPERATOR_GROUPS attestation, queued with the roster below. // // Ships ALL groups, and an active index that points ONE EPOCH AHEAD -- // at the group that will be on duty for `current_epoch_index + 1`, not the @@ -857,6 +864,8 @@ void epoch::advance() { // // `epoch_index` stays the epoch this envelope IS for; it identifies the // envelope, not the roster, and no outpost reads it. + std::vector encoded_groups; + bool have_next_group = false; { opp::attestations::BatchOperatorGroups attest; // The window SLIDES; it does not rotate. `advance` erases the front and @@ -919,7 +928,7 @@ void epoch::advance() { // Withheld by SKIPPING THE QUEUEOUT ONLY -- never by returning from // `advance`, which still has the epoch's remaining attestations and // actions to issue after this block. - const bool have_next_group = + have_next_group = next_group_index < group_count && !state.batch_op_groups[next_group_index].empty(); if (!have_next_group) { sysio::print("sysio.epoch::advance: no non-empty next group to publish at epoch ", @@ -947,27 +956,40 @@ void epoch::advance() { attest.groups.push_back(std::move(grp)); } - std::vector encoded; - auto out = zpp::bits::out{encoded, zpp::bits::no_size{}}; + auto out = zpp::bits::out{encoded_groups, zpp::bits::no_size{}}; (void)out(attest); + } - // `have_next_group` gates the QUEUEOUT, not `advance` -- see above. - if (have_next_group) { - 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_BATCH_OPERATOR_GROUPS, - encoded - ) - ).send(); - } + // A roster that would take the schedule past its budget is withheld the same way: its queueout is + // skipped, `advance` goes on, and the outposts keep the roster they hold. BATCH_OPERATOR_GROUPS + // counts toward the budget only when it ships. + const size_t schedule_bytes = + msgch::attestation_estimate_bytes(encoded_operators.size()) + + (have_next_group ? msgch::attestation_estimate_bytes(encoded_groups.size()) : 0); + const bool ship_operators = schedule_bytes <= msgch::SCHEDULE_LANE_BUDGET_BYTES; + if (!ship_operators) { + sysio::print("sysio.epoch::advance: operator schedule needs ", schedule_bytes, + " estimated bytes at epoch ", state.current_epoch_index, ", over its ", + msgch::SCHEDULE_LANE_BUDGET_BYTES, "-byte budget; withholding Operators (", + encoded_operators.size(), " bytes), outposts retain their previous roster\n"); + } + + // Every outpost's OPERATORS queueout goes out before any BATCH_OPERATOR_GROUPS one: the ETH + // outpost's `_handleOperators` populates `operatorEthAddress` before `_handleBatchOperatorGroups` + // looks those addresses up. `have_next_group` gates the queueout, not `advance`; see above. + const auto queue_to_active_outposts = [&](opp::types::AttestationType type, const std::vector& data) { + 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, msgch_actions::QUEUE_OUT, + std::make_tuple(it->code.value, type, data)).send(); } + }; + if (ship_operators) { + queue_to_active_outposts(opp::types::ATTESTATION_TYPE_OPERATORS, encoded_operators); + } + if (have_next_group) { + queue_to_active_outposts(opp::types::ATTESTATION_TYPE_BATCH_OPERATOR_GROUPS, encoded_groups); } // Drain the swap-from-WIRE queue: each row queued via diff --git a/contracts/sysio.epoch/sysio.epoch.wasm b/contracts/sysio.epoch/sysio.epoch.wasm index 3583ddc36b..fb356b928e 100755 Binary files a/contracts/sysio.epoch/sysio.epoch.wasm and b/contracts/sysio.epoch/sysio.epoch.wasm differ diff --git a/contracts/sysio.liq/README.md b/contracts/sysio.liq/README.md index ab400baab3..35ce0177f0 100644 --- a/contracts/sysio.liq/README.md +++ b/contracts/sysio.liq/README.md @@ -46,6 +46,13 @@ to be AuthX-linked for the token's chain, settles and burns the shadow, and queu `sysio.msgch::queueout`. Request ids start at 1. The burn is final: an outpost refusal is reconciled from its log by governance through `recredit`. +`quantity` must reach the symbol's `min_desyndicate`. Every de-syndication is a +sysio-billed outbound row and a payout the outpost relay pays for, so the floor keeps +dust from buying either. `create` sets it to one hundredth of a whole token (never +below one subunit), and `setmindesyn` retunes it per symbol. A holder can be left with +less than the floor, for example after a partial exit or a small transfer in; that +remainder can still be transferred, swapped, or topped up and then de-syndicated. + **Launch ingestion (epoch-0 bootstrap window, privileged caller).** `regliqpool` mints the LCO liq to `sysio`, deposits it with the T5 dex earmark WIRE into `sysio.swap`, creates the pair (`sysio` fee authority, the shadow as yield leg) @@ -57,7 +64,7 @@ import. | Table | Scope / key | Row | |---|---|---| -| `stat` | symbol code | `supply`, `chain_code`, `token_code`, `pair_symbol` (empty until `regliqpool`); index `bytoken` | +| `stat` | symbol code | `supply`, `chain_code`, `token_code`, `pair_symbol` (empty until `regliqpool`), `min_desyndicate`; index `bytoken` | | `accounts` | holder / symbol code | `balance`, `index_checkpoint` (uint128), `owed_wire` | | `yieldidx` | symbol code | `index` (uint128), `pot`, `carry` | | `parked` | symbol code, chain kind, pubkey | `chain_kind`, `pubkey`, `holding` (an account row) | @@ -73,6 +80,7 @@ import. | `create(sym, chain_code, token_code)` | self | Register a shadow for an active `TOKEN_KIND_LIQ` token bound to an active outpost | | `setkicker(bps)` | `sysio` | Kicker for the intakes from now on | | `recredit(holder, quantity)` | self | Mint back after an outpost refused a de-syndication | +| `setmindesyn(minimum)` | self | Smallest quantity `desyndicate` accepts for `minimum`'s symbol; positive | | `mintsynd(chain_code, sequence, account, token_code, amount)` | `sysio.msgch` | SYNDICATE_LIQ, linked user | | `park(chain_code, sequence, chain_kind, pubkey, token_code, amount)` | `sysio.msgch` | SYNDICATE_LIQ, unlinked user | | `mintyield(chain_code, sequence, epoch, token_code, amount)` | `sysio.msgch` | LIQ_YIELD into the pending balance | @@ -89,7 +97,7 @@ import. In order: `sysio.roa::setsyscode` for `sysio.liq`; the chain and its liq token registered and active in `sysio.chains` / `sysio.tokens`; `create` per shadow -symbol; `setkicker` if the default is not wanted; `regliqpool` per pool; +symbol; `setkicker` and `setmindesyn` if the defaults are not wanted; `regliqpool` per pool; `importsynd` batches; `importdone`. `sysio.swap` must be configured (`setconfig`) before `regliqpool`, and the batch-operator crank pushes `queueyield` per symbol and `sysio.swap::tickyield` per pair. diff --git a/contracts/sysio.liq/include/sysio.liq/sysio.liq.hpp b/contracts/sysio.liq/include/sysio.liq/sysio.liq.hpp index db49a7214e..8a1a67cc5f 100644 --- a/contracts/sysio.liq/include/sysio.liq/sysio.liq.hpp +++ b/contracts/sysio.liq/include/sysio.liq/sysio.liq.hpp @@ -67,6 +67,11 @@ namespace sysio { /// The kicker at launch: 2% of every yield intake, drawn from T5. static constexpr uint32_t DEFAULT_KICKER_BPS = 200; + /// `create` sets a new shadow's `min_desyndicate` to one whole token divided by this, and never + /// below one subunit. Every de-syndication is a sysio-billed outbound row and a relayer-paid + /// payout, so the floor keeps dust from buying either. + static constexpr int64_t DEFAULT_MIN_DESYNDICATE_DIVISOR = 100; + // ----------------------------------------------------------------------- // Deployment and governance // ----------------------------------------------------------------------- @@ -86,6 +91,10 @@ namespace sysio { /// contract's authority. [[sysio::action]] void recredit(name holder, asset quantity); + /// Set the smallest quantity `desyndicate` accepts for `minimum`'s symbol. Requires this + /// contract's authority; the minimum must be positive. + [[sysio::action]] void setmindesyn(asset minimum); + // ----------------------------------------------------------------------- // Inbound OPP effects (sysio.msgch dispatch; never throw) // ----------------------------------------------------------------------- @@ -153,6 +162,7 @@ namespace sysio { /// Burn `quantity` of `holder`'s shadow and queue DESYNDICATE_LIQ to the /// symbol's outpost, paying the pubkey `holder` has linked for that chain. + /// `quantity` must reach the symbol's `min_desyndicate`. /// The burn is final: an outpost refusal is reconciled by governance through /// `recredit`. Holder's authority. [[sysio::action]] void desyndicate(name holder, asset quantity); @@ -202,10 +212,11 @@ namespace sysio { sysio::slug_name chain_code; ///< the outpost whose liq this shadow mirrors sysio::slug_name token_code; ///< that outpost's liq token in sysio.tokens symbol_code pair_symbol; ///< the swap's pair token of this shadow's yield pool; empty until regliqpool + asset min_desyndicate; ///< smallest quantity `desyndicate` accepts; `setmindesyn` retunes it uint64_t by_token_code() const { return token_code.value; } - SYSLIB_SERIALIZE(currency_stats, (supply)(chain_code)(token_code)(pair_symbol)) + SYSLIB_SERIALIZE(currency_stats, (supply)(chain_code)(token_code)(pair_symbol)(min_desyndicate)) }; using stats = kv::table<"stat"_n, symbol_key, currency_stats, diff --git a/contracts/sysio.liq/src/sysio.liq.cpp b/contracts/sysio.liq/src/sysio.liq.cpp index cf7f96cf66..bcba0f8567 100644 --- a/contracts/sysio.liq/src/sysio.liq.cpp +++ b/contracts/sysio.liq/src/sysio.liq.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -77,6 +78,13 @@ void drop(const char* path, const char* reason) { sysio::print("sysio.liq::", path, ": DROP -- ", reason, "\n"); } +/// A new shadow's `min_desyndicate`: one whole token over `liq::DEFAULT_MIN_DESYNDICATE_DIVISOR`, at +/// least one subunit. +asset default_min_desyndicate(symbol sym) { + const int64_t one_token = opp::precision_from_decimals(sym.precision()); + return asset{ std::max(1, one_token / liq::DEFAULT_MIN_DESYNDICATE_DIVISOR), sym }; +} + } // namespace // --------------------------------------------------------------------------- @@ -104,10 +112,11 @@ void liq::create(symbol sym, sysio::slug_name chain_code, sysio::slug_name token stats statstable(get_self()); check(!stat_by_token(token_code).has_value(), "token_code already has a shadow symbol"); statstable.emplace(ram_payer, symbol_key{ sym.code().raw() }, currency_stats{ - .supply = asset{ 0, sym }, - .chain_code = chain_code, - .token_code = token_code, - .pair_symbol = symbol_code{}, + .supply = asset{ 0, sym }, + .chain_code = chain_code, + .token_code = token_code, + .pair_symbol = symbol_code{}, + .min_desyndicate = default_min_desyndicate(sym), }, "symbol already exists"); } @@ -130,6 +139,17 @@ void liq::recredit(name holder, asset quantity) { adjust_account(holder, quantity, ram_payer); } +void liq::setmindesyn(asset minimum) { + require_auth(get_self()); + check(minimum.is_valid() && minimum.amount > 0, "minimum must be positive"); + const currency_stats st = stat_of(minimum.symbol.code()); + check(minimum.symbol == st.supply.symbol, "symbol precision mismatch"); + stats statstable(get_self()); + statstable.modify(ram_payer, symbol_key{ minimum.symbol.code().raw() }, [&](currency_stats& s) { + s.min_desyndicate = minimum; + }); +} + // --------------------------------------------------------------------------- // Inbound OPP effects // --------------------------------------------------------------------------- @@ -346,6 +366,9 @@ void liq::desyndicate(name holder, asset quantity) { check(quantity.is_valid() && quantity.amount > 0, "quantity must be positive"); const currency_stats st = stat_of(quantity.symbol.code()); check(quantity.symbol == st.supply.symbol, "symbol precision mismatch"); + check(quantity >= st.min_desyndicate, [&] { + return "quantity is below the minimum de-syndication of " + st.min_desyndicate.to_string(); + }); const ChainKind kind = kind_of_chain(st.chain_code); const auto pubkey = linked_pubkey(holder, kind); diff --git a/contracts/sysio.liq/sysio.liq.abi b/contracts/sysio.liq/sysio.liq.abi index 00443bc1b3..df5371c4f3 100644 --- a/contracts/sysio.liq/sysio.liq.abi +++ b/contracts/sysio.liq/sysio.liq.abi @@ -122,6 +122,10 @@ { "name": "pair_symbol", "type": "symbol_code" + }, + { + "name": "min_desyndicate", + "type": "asset" } ] }, @@ -472,6 +476,16 @@ } ] }, + { + "name": "setmindesyn", + "base": "", + "fields": [ + { + "name": "minimum", + "type": "asset" + } + ] + }, { "name": "sweep", "base": "", @@ -623,6 +637,11 @@ "type": "setkicker", "ricardian_contract": "" }, + { + "name": "setmindesyn", + "type": "setmindesyn", + "ricardian_contract": "" + }, { "name": "sweep", "type": "sweep", diff --git a/contracts/sysio.liq/sysio.liq.wasm b/contracts/sysio.liq/sysio.liq.wasm index aa7bb79d50..a232f3d3f1 100755 Binary files a/contracts/sysio.liq/sysio.liq.wasm and b/contracts/sysio.liq/sysio.liq.wasm differ diff --git a/contracts/sysio.msgch/README.md b/contracts/sysio.msgch/README.md index 368ea8e699..f45029fe27 100644 --- a/contracts/sysio.msgch/README.md +++ b/contracts/sysio.msgch/README.md @@ -28,8 +28,41 @@ Inbound/outbound OPP message chain management and consensus tracking contract. | `deliver` | operator | Batch operator delivers a chain with hash + messages | | `evalcons` | `sysio.msgch` | Evaluate consensus on a chain request | | `processmsg` | `sysio.msgch` | Process a READY message (unpack, route attestations) | -| `queueout` | `sysio.epoch`, `sysio.opreg`, `sysio.uwrit`, `sysio.reserv`, `sysio.liq`, or `sysio.msgch` | Queue an outbound message to an outpost | -| `buildenv` | `sysio.msgch` | Build outbound envelope from queued messages | +| `queueout` | `sysio.epoch`, `sysio.opreg`, `sysio.uwrit`, `sysio.reserv`, `sysio.liq`, or `sysio.msgch` | Queue an outbound attestation for an outpost | +| `buildenv` | `sysio.epoch` | Pack an outpost's queued attestations into its outbound envelope (see below) | + +## Outbound queue + +`queueout` appends a READY row to `attestations`. Its `byqueue` index keys rows by +(status, chain_code, lane), so one outpost's queue sits together in packing order. The operator +schedule (`OPERATORS` and `BATCH_OPERATOR_GROUPS`) comes first, then everything else, each oldest +first. The schedule goes first because an outpost seats the next epoch's batch-operator group only +from `BATCH_OPERATOR_GROUPS`: other traffic queued ahead of it would leave that epoch's deliverers +unseated. + +`sysio.epoch::advance` sends `buildenv` inline for every active outpost once per epoch. `buildenv` +walks that outpost's READY rows in packing order and packs them into one envelope until the next +row would push it past 32 KiB; the rest wait for the next epoch. However deep the queue, the walk +reads the rows it packs (at most one envelope's worth), the rows it drops, and the one row it stops +on. + +The schedule may use 24 KiB of an envelope (`SCHEDULE_LANE_BUDGET_BYTES`), which equals the Solana +outpost's OPERATORS payload byte cap. That is the only Solana roster limit the budget covers: Solana +also skips a roster over its entry cap, one that maps two names to one key, and one that resolves to +no operators. `advance` withholds a roster that would take the schedule past the budget, and +`buildenv` prints a diagnostic when schedule rows queued some other way do. Each lane has a size +bound: + +| Lane | Largest `data` that ships | +|---|---| +| schedule | 32 232 bytes: fits an otherwise empty envelope | +| other | 7 656 bytes: fits beside a full schedule lane | + +A row over its lane's bound can never ship. `buildenv` erases it and prints `DROP attestation` +instead of aborting `advance`, and the rows behind it still ship. While the schedule stays within +its budget, every row within its bound ships once it reaches the front of its lane. If the drops +empty the queue, the envelope still goes out with no attestations, because each outpost answers the +depot's envelope with its own. ## Dependencies diff --git a/contracts/sysio.msgch/include/sysio.msgch/sysio.msgch.hpp b/contracts/sysio.msgch/include/sysio.msgch/sysio.msgch.hpp index dfdd17c2d1..6b5df9c311 100644 --- a/contracts/sysio.msgch/include/sysio.msgch/sysio.msgch.hpp +++ b/contracts/sysio.msgch/include/sysio.msgch/sysio.msgch.hpp @@ -10,6 +10,11 @@ #include #include +#include + +#include +#include + namespace sysio { @@ -74,9 +79,14 @@ namespace sysio { /// * `sysio.opreg::*` — `OPERATOR_ACTION` family (WITHDRAW_REMIT, /// SLASH) — once the reserve-flow work lands the same pattern /// reaches every depot-authorised outbound. + /// * `sysio.liq::desyndicate`: `DESYNDICATE_LIQ` to the shadow's outpost. + /// + /// Rows ship oldest first, except that the operator schedule (`queue_lane::schedule`) ships ahead + /// of other traffic. `data` over its lane's `max_attestation_data_bytes` can never ship; `buildenv` + /// drops it with a diagnostic. /// /// Gated to the depot's own system contracts (sysio.epoch / .opreg / - /// .uwrit / .reserv, plus msgch itself): each sends under its own + /// .uwrit / .reserv / .liq, plus msgch itself): each sends under its own /// {self, active} authority. The gate is required because a forged /// READY attestation rides out inside the next group-signed outbound /// envelope, which the outpost authenticates by the group signature — @@ -87,8 +97,13 @@ namespace sysio { opp::types::AttestationType attest_type, std::vector data); - /// Build outbound envelope from READY attestations for an outpost. - /// Collects attestations, packs into OPP Envelope, stores in outenvelopes. + /// Pack the outpost's READY attestations, in `queue_lane` order and oldest first within a lane, into + /// one outbound envelope of at most `MAX_ENVELOPE_BYTES` and store it in `outenvelopes`; the rest + /// stay READY for the next epoch. The walk stops at the first attestation that does not fit, so + /// however deep the queue it reads the rows it packs (at most one envelope's worth), the rows it + /// drops, and the one row it stops on. An attestation larger than its lane can ever ship + /// (`max_attestation_data_bytes`) is erased with a diagnostic instead of aborting + /// `sysio.epoch::advance`, which sends this inline. /// Stamps the envelope's route endpoints (start = WIRE, end = the /// destination's `sysio.chains` `{kind, external_chain_id}`) so the /// receiving outpost can reject an envelope a misconfigured relay @@ -96,6 +111,77 @@ namespace sysio { [[sysio::action]] void buildenv(uint64_t chain_code); + // ----------------------------------------------------------------------- + // Outbound queue + // ----------------------------------------------------------------------- + + /// Packing lane of an outbound attestation. `buildenv` packs every `schedule` row before any `other` + /// row, oldest first within each: an outpost seats the next epoch's batch-operator group only from + /// BATCH_OPERATOR_GROUPS, so other traffic queued ahead of it would leave the next epoch's + /// deliverers unseated and that epoch undeliverable. + enum class queue_lane : uint8_t { + schedule = 0, ///< the operator roster and group window `sysio.epoch::advance` queues each epoch + other = 1, ///< everything else + }; + + /// The lane `type` packs in. Within `schedule`, queue order keeps OPERATORS ahead of the + /// BATCH_OPERATOR_GROUPS that `advance` queues after it, which the Ethereum outpost relies on. + static queue_lane lane_of(opp::types::AttestationType type) { + switch (type) { + case opp::types::AttestationType::ATTESTATION_TYPE_OPERATORS: + case opp::types::AttestationType::ATTESTATION_TYPE_BATCH_OPERATOR_GROUPS: + return queue_lane::schedule; + default: + return queue_lane::other; + } + } + + /// Hard cap on an encoded envelope in both directions, the same as the Solana (`MAX_ENVELOPE_BYTES`) + /// and Ethereum (`OPP.MAX_ENVELOPE_BYTES`) outposts' caps. Ethereum binds it: a cold + /// `emitOutboundEnvelope` of a near-64-KiB envelope costs about 45 M gas, about 2.7x the EIP-7825 + /// per-transaction cap of 16 777 216. `deliver` rejects a larger inbound envelope before hashing it. + static constexpr size_t MAX_ENVELOPE_BYTES = 32'768; + + /// Conservative per-attestation framing the `buildenv` packing estimate charges on top of `data`: + /// protobuf tags, length prefixes and the type and data-size fields. It over-counts the encoding, so + /// the estimate errs towards a gap; the encoded-size check after serialisation is the backstop. + static constexpr size_t ATTESTATION_OVERHEAD_BYTES = 24; + + /// Conservative envelope and message header bytes the packing estimate starts from: the `Envelope` + /// header fields, the wrapping `Message`, its header and payload preamble, and a margin for length + /// prefixes. + static constexpr size_t ENVELOPE_BASELINE_BYTES = 512; + + /// Estimated envelope bytes of one attestation carrying `data_bytes` of `data`. + static constexpr size_t attestation_estimate_bytes(size_t data_bytes) { + return ATTESTATION_OVERHEAD_BYTES + data_bytes; + } + + /// Largest `data` that fits an otherwise empty envelope: the bound of a `schedule` row. + static constexpr size_t MAX_ATTESTATION_DATA_BYTES = + MAX_ENVELOPE_BYTES - ENVELOPE_BASELINE_BYTES - ATTESTATION_OVERHEAD_BYTES; + + /// Estimated bytes the operator schedule (the `schedule` lane, overhead included) may use in one + /// envelope. `sysio.epoch::advance` withholds a roster that would take the schedule past it, and + /// `buildenv` reports a schedule that does. Three quarters of the envelope, equal to the Solana + /// outpost's OPERATORS payload byte cap (`MAX_OPERATORS_PAYLOAD_BYTES`). Staying within it clears + /// only that size check: Solana also skips a roster over its `MAX_OPERATORS` entry cap, one that + /// maps two names to one key, and one that resolves to no operators. + static constexpr size_t SCHEDULE_LANE_BUDGET_BYTES = MAX_ENVELOPE_BYTES / 4 * 3; + + /// Largest `data` that fits beside a full schedule lane: the bound of an `other` row. While the + /// schedule stays within its budget, every `other` row `buildenv` keeps ships once it reaches the + /// front of its lane. + static constexpr size_t MAX_OTHER_ATTESTATION_DATA_BYTES = + MAX_ENVELOPE_BYTES - ENVELOPE_BASELINE_BYTES - SCHEDULE_LANE_BUDGET_BYTES - ATTESTATION_OVERHEAD_BYTES; + static_assert(MAX_OTHER_ATTESTATION_DATA_BYTES < MAX_ATTESTATION_DATA_BYTES, + "the schedule budget must leave the other lane a tighter bound than an empty envelope"); + + /// Largest `data` a row in `lane` can ever ship with; `buildenv` drops a larger one. + static constexpr size_t max_attestation_data_bytes(queue_lane lane) { + return lane == queue_lane::schedule ? MAX_ATTESTATION_DATA_BYTES : MAX_OTHER_ATTESTATION_DATA_BYTES; + } + // ----------------------------------------------------------------------- // Tables // ----------------------------------------------------------------------- @@ -183,22 +269,30 @@ namespace sysio { uint64_t ready_timestamp; uint64_t processed_timestamp; - uint64_t by_status() const { return static_cast(status); } - uint64_t by_type() const { return static_cast(type); } - uint64_t by_epoch() const { return epoch_index; } + /// Key of the `byqueue` index: `status`, `chain_code` and the packing lane, most significant first, + /// each field lossless. Entries sharing a key iterate in primary-key order, which is queue order, + /// so one outpost's rows in one status sit together in packing order: the schedule lane first, + /// each lane oldest first. + static uint128_t queue_key(opp::types::AttestationStatus status, uint64_t chain_code, queue_lane lane) { + constexpr uint32_t lane_bits = std::numeric_limits>::digits; + constexpr uint32_t chain_bits = std::numeric_limits::digits; + return (static_cast(magic_enum::enum_integer(status)) << (chain_bits + lane_bits)) | + (static_cast(chain_code) << lane_bits) | + static_cast(magic_enum::enum_integer(lane)); + } + + uint128_t by_queue() const { return queue_key(status, chain_code, lane_of(type)); } SYSLIB_SERIALIZE(attestation_entry, (id)(chain_code)(epoch_index)(type)(status)(data) (pending_timestamp)(ready_timestamp)(processed_timestamp)) }; + /// `byqueue` is the per-outpost outbound queue: `buildenv` packs its outpost's READY rows, and + /// clears the PROCESSED ones, each through one range of this index. using attestations_t = sysio::kv::table<"attestations"_n, id_key, attestation_entry, - sysio::kv::index<"bystatus"_n, - sysio::const_mem_fun>, - sysio::kv::index<"bytype"_n, - sysio::const_mem_fun>, - sysio::kv::index<"byepoch"_n, - sysio::const_mem_fun> + sysio::kv::index<"byqueue"_n, + sysio::const_mem_fun> >; /// Outbound envelope table. One-deep per outpost: `buildenv` erases every older row for the diff --git a/contracts/sysio.msgch/src/sysio.msgch.cpp b/contracts/sysio.msgch/src/sysio.msgch.cpp index efcfeeb01b..33a2b2880d 100644 --- a/contracts/sysio.msgch/src/sysio.msgch.cpp +++ b/contracts/sysio.msgch/src/sysio.msgch.cpp @@ -18,6 +18,7 @@ #include #include #include +#include namespace sysio { @@ -62,33 +63,10 @@ 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; +/// Why `buildenv` erased a READY attestation, for its diagnostic. +constexpr const char* UNDELIVERABLE_ALONE_REASON = "too large for any envelope"; +constexpr const char* UNDELIVERABLE_BESIDE_SCHEDULE_REASON = "too large to ship beside the operator schedule"; +constexpr const char* UNDELIVERABLE_ENCODED_REASON = "encodes past the envelope cap on its own"; /// 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. @@ -152,6 +130,26 @@ uint64_t mint_att_id(name self) { return out; } +/// Log a READY attestation that `buildenv` erases because it can never ship, and `reason` why. +void log_undeliverable_attestation(uint64_t id, AttestationType type, size_t data_bytes, uint64_t chain_code, + const char* reason) { + sysio::print("msgch::buildenv: DROP attestation ", id, " (type ", magic_enum::enum_integer(type), ", ", + data_bytes, " data bytes) for chain ", chain_code, ": ", reason, "\n"); +} + +/// Why a row of `lane` over its `msgch::max_attestation_data_bytes` can never ship. +const char* oversized_reason(msgch::queue_lane lane) { + return lane == msgch::queue_lane::schedule ? UNDELIVERABLE_ALONE_REASON : UNDELIVERABLE_BESIDE_SCHEDULE_REASON; +} + +/// The `byqueue` keys of `chain_code`'s rows in `status` in its first and last lane: the rows between +/// them, inclusive, are exactly that outpost's rows in that status, in packing order. +std::pair queue_span(AttestationStatus status, uint64_t chain_code) { + constexpr auto lanes = magic_enum::enum_values(); + return {msgch::attestation_entry::queue_key(status, chain_code, lanes.front()), + msgch::attestation_entry::queue_key(status, chain_code, lanes.back())}; +} + /// Size of the ACTIVE batch-operator group -- the set that can actually deliver for the current /// epoch. Every consensus threshold derives from THIS, never from the configured /// `operators_per_epoch`. @@ -1881,10 +1879,9 @@ void msgch::queueout(uint64_t chain_code, // sysio.chains, but a governance msig holding one of those authorities can // call queueout directly, and a typo'd code used to abort here. Without it // the row is created READY and is then unreachable forever: epoch::advance - // only fans buildenv to registered active outposts, the PROCESSED drain - // skips mismatched codes, and no prune action exists -- so it is permanent - // sysio-pool RAM, re-walked by the Phase-1 bystatus scan on every buildenv, - // for every outpost, every epoch. + // only fans buildenv to registered active outposts, each buildenv walks only + // its own outpost's rows, and no prune action exists, so the row is + // permanent sysio-pool RAM. // // The SVM terminal-account estimator that used to live beside this lookup // was deliberately deleted (it made the depot model another chain's packet @@ -1915,11 +1912,15 @@ void msgch::queueout(uint64_t chain_code, // --------------------------------------------------------------------------- // buildenv — build outbound envelope from READY attestations // -// Packs as many READY attestations as fit under MAX_ENVELOPE_BYTES into a -// single outbound envelope; any that don't fit stay in the table with -// status = READY and ride the next epoch's `buildenv` call. Mirrors the +// Packs this outpost's READY attestations into a single outbound envelope, +// the operator schedule first and otherwise oldest first (`msgch::queue_lane`), +// until the next one would overshoot MAX_ENVELOPE_BYTES; the rest stay READY +// and ride the next epoch's `buildenv` call. Mirrors the // Solana (`emit_outbound_inner`) and Ethereum (`emitOutboundEnvelope`) -// packing-loop pattern — never drop, never refuse, always emit what fits. +// packing-loop pattern. The rows it drops are ones their lane can never ship +// (`max_attestation_data_bytes`): `sysio.epoch::advance` sends this inline, +// so aborting would halt the epoch and keeping the row READY would hold up +// every row behind it. // // Each envelope chains from its predecessor on the same outpost stream: // `previous_envelope_hash` carries the prior emit's canonical epoch digest @@ -1947,29 +1948,66 @@ void msgch::buildenv(uint64_t chain_code) { const uint64_t now_ms = static_cast(current_time_point().time_since_epoch().count()) / 1000; - // ── Phase 1: collect candidate READY attestations for this outpost. - // Order is the secondary index's natural order, which is stable across - // epochs — preserves cross-epoch attestation ordering for the receiving - // chain. - std::vector candidate_entries; - std::vector candidate_ids; - - auto status_idx = atts.get_index<"bystatus"_n>(); - for (auto it = status_idx.lower_bound( - static_cast(AttestationStatus::ATTESTATION_STATUS_READY)); - it != status_idx.end() && - it->status == AttestationStatus::ATTESTATION_STATUS_READY; ++it) { - if (it->chain_code != chain_code) continue; - - opp::AttestationEntry entry; - entry.type = it->type; - entry.data_size = zpp::bits::vuint32_t{static_cast(it->data.size())}; - entry.data = it->data; - candidate_entries.push_back(std::move(entry)); - candidate_ids.push_back(it->id); + // Phase 1: collect this outpost's READY attestations in packing order (the schedule lane, then the + // rest, each oldest first) while they fit a conservative byte estimate; the trim loop below is the + // source of truth for the encoded size invariant. A row over its lane's bound can never ship and is + // dropped; every row within its bound fits beside a schedule within budget, so it ships once it + // reaches the front of its lane. + // + // The walk covers only this outpost's queue and stops at the first row that does not fit. However + // deep the queue, it reads the rows it packs (at most one envelope's worth), the rows it drops, each + // once since it is erased, and the one row it stops on: past the end of this outpost's span that is + // the next index entry, which belongs to another span. The rows behind the stop wait for the next + // epoch. + // + // This pass deliberately does NOT bound the envelope by any destination chain's transaction + // capacity. Solana dispatch is resumable: the outpost settles `[cursor, cursor + dispatch_limit)` + // per terminal call and the relay loops until the cursor drains, so an envelope that needs more + // effect accounts than one Solana transaction can carry is settled across several calls rather + // than refused here. Sizing a WIRE consensus envelope against another chain's packet limit put + // that chain's MTU inside depot consensus; the cursor removes the need. + std::vector entries; + std::vector included_ids; + size_t estimated_bytes = ENVELOPE_BASELINE_BYTES; + size_t schedule_bytes = 0; + bool dropped_any = false; + { + const auto [first, last] = queue_span(AttestationStatus::ATTESTATION_STATUS_READY, chain_code); + auto queue = atts.get_index<"byqueue"_n>(); + for (auto it = queue.lower_bound(first); it != queue.end() && it->by_queue() <= last;) { + const queue_lane lane = lane_of(it->type); + if (it->data.size() > max_attestation_data_bytes(lane)) { + log_undeliverable_attestation(it->id, it->type, it->data.size(), chain_code, oversized_reason(lane)); + it = queue.erase(std::move(it)); + dropped_any = true; + continue; + } + const size_t entry_bytes = attestation_estimate_bytes(it->data.size()); + if (estimated_bytes + entry_bytes > MAX_ENVELOPE_BYTES) break; + estimated_bytes += entry_bytes; + if (lane == queue_lane::schedule) schedule_bytes += entry_bytes; + + opp::AttestationEntry entry; + entry.type = it->type; + entry.data_size = zpp::bits::vuint32_t{static_cast(it->data.size())}; + entry.data = it->data; + entries.push_back(std::move(entry)); + included_ids.push_back(it->id); + ++it; + } } - if (candidate_entries.empty()) return; + // `sysio.epoch::advance` keeps its own schedule within budget, so this flags schedule rows queued + // some other way, which can make `other` rows wait. + if (schedule_bytes > SCHEDULE_LANE_BUDGET_BYTES) { + sysio::print("msgch::buildenv: operator schedule for chain ", chain_code, " takes ", schedule_bytes, + " estimated bytes, over its ", SCHEDULE_LANE_BUDGET_BYTES, "-byte budget\n"); + } + + // An empty queue emits nothing. One emptied by drops still emits, with no attestations: each + // outpost answers the depot's envelope for an epoch with its own, so skipping it would stall the + // epoch, and both outposts accept an envelope that carries none. + if (entries.empty() && !dropped_any) return; const auto op_row = [&]() { sysio::chains::chains_t chains_tbl(CHAINS_ACCOUNT); @@ -1989,41 +2027,6 @@ void msgch::buildenv(uint64_t chain_code) { route_endpoints.end.kind = op_row.kind; route_endpoints.end.id = op_row.external_chain_id; - // Phase 2: estimator-based initial pick. Walk candidates in order, accumulating a conservative byte - // estimate; stop once the next one would push the envelope over MAX_ENVELOPE_BYTES. The trim loop - // below is the source of truth for the encoded size invariant. - // - // This pass deliberately does NOT bound the envelope by any destination chain's transaction - // capacity. Solana dispatch is resumable: the outpost settles `[cursor, cursor + dispatch_limit)` - // per terminal call and the relay loops until the cursor drains, so an envelope that needs more - // effect accounts than one Solana transaction can carry is settled across several calls rather - // than refused here. Sizing a WIRE consensus envelope against another chain's packet limit put - // that chain's MTU inside depot consensus; the cursor removes the need. - size_t included_count = 0; - size_t estimated_bytes = ENVELOPE_BASELINE_BYTES; - for (const auto& entry : candidate_entries) { - const size_t entry_bytes = ATTESTATION_OVERHEAD_BYTES + entry.data.size(); - if (estimated_bytes + entry_bytes > MAX_ENVELOPE_BYTES) { - break; - } - estimated_bytes += entry_bytes; - ++included_count; - } - - // 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"); - - std::vector entries( - std::make_move_iterator(candidate_entries.begin()), - std::make_move_iterator(candidate_entries.begin() + included_count)); - std::vector included_ids( - candidate_ids.begin(), - candidate_ids.begin() + included_count); - // Chain links: the previous envelope emitted for this outpost. `outenvelopes` is one-deep per // outpost (see the cleanup below), so the single surviving row is the previous emit; its // `envelope_hash` is that envelope's epoch digest and its `last_message_id` is this outpost's @@ -2114,17 +2117,21 @@ void msgch::buildenv(uint64_t chain_code) { // stay READY and ride the next `buildenv` call. PROCESSED marking is deferred until after the loop // converges so a popped entry never needs a status revert. std::vector packed = build_packed(entries); - while (packed.size() > MAX_ENVELOPE_BYTES) { - check(entries.size() > 1, - "sysio.msgch::buildenv: a single READY attestation exceeds " - "MAX_ENVELOPE_BYTES -- cannot pack into an envelope"); + while (packed.size() > MAX_ENVELOPE_BYTES && !entries.empty()) { + // A lone entry that still overshoots can never ship: drop it as Phase 1 would, which leaves an + // envelope with no attestations, instead of aborting. + if (entries.size() == 1) { + log_undeliverable_attestation(included_ids.front(), entries.front().type, entries.front().data.size(), + chain_code, UNDELIVERABLE_ENCODED_REASON); + atts.erase(id_key{included_ids.front()}); + } entries.pop_back(); included_ids.pop_back(); packed = build_packed(entries); } - // Mark the surviving attestations as PROCESSED. Remaining candidates (popped by the trim loop or - // never picked by the estimator) stay READY for the next epoch's `buildenv` call. + // Mark the surviving attestations as PROCESSED. The rest (popped by the trim loop, or behind where + // Phase 1 stopped) stay READY for the next epoch's `buildenv` call. for (uint64_t aid : included_ids) { auto att_pk = id_key{aid}; if (atts.contains(att_pk)) { @@ -2174,15 +2181,11 @@ void msgch::buildenv(uint64_t chain_code) { it = by_outpost.erase(std::move(it)); } - // Drop the attestations we just consumed (status PROCESSED rows - // for this outpost). They've been bundled into `packed`; the - // bytes are dead weight on chain. - auto processed_idx = atts.get_index<"bystatus"_n>(); - for (auto it = processed_idx.lower_bound( - static_cast(AttestationStatus::ATTESTATION_STATUS_PROCESSED)); - it != processed_idx.end() && - it->status == AttestationStatus::ATTESTATION_STATUS_PROCESSED; ) { - if (it->chain_code != chain_code) { ++it; continue; } + // Drop this outpost's PROCESSED rows: the attestations just bundled into `packed`, whose bytes + // are dead weight on chain, and the inbound ones `apply_consensus` stored already dispatched. + const auto [first, last] = queue_span(AttestationStatus::ATTESTATION_STATUS_PROCESSED, chain_code); + auto processed_idx = atts.get_index<"byqueue"_n>(); + for (auto it = processed_idx.lower_bound(first); it != processed_idx.end() && it->by_queue() <= last;) { it = processed_idx.erase(std::move(it)); } } diff --git a/contracts/sysio.msgch/sysio.msgch.abi b/contracts/sysio.msgch/sysio.msgch.abi index d54a6586cf..4501cc2934 100644 --- a/contracts/sysio.msgch/sysio.msgch.abi +++ b/contracts/sysio.msgch/sysio.msgch.abi @@ -442,19 +442,9 @@ "table_id": 4978, "secondary_indexes": [ { - "name": "bystatus", - "key_type": "uint64", - "table_id": 15348 - }, - { - "name": "bytype", - "key_type": "uint64", - "table_id": 31638 - }, - { - "name": "byepoch", - "key_type": "uint64", - "table_id": 21249 + "name": "byqueue", + "key_type": "uint128", + "table_id": 11308 } ] }, diff --git a/contracts/sysio.msgch/sysio.msgch.wasm b/contracts/sysio.msgch/sysio.msgch.wasm index b09bf4cc69..8e51fee146 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/decimal.hpp b/contracts/sysio.opp.common/include/sysio.opp.common/decimal.hpp new file mode 100644 index 0000000000..877bad5def --- /dev/null +++ b/contracts/sysio.opp.common/include/sysio.opp.common/decimal.hpp @@ -0,0 +1,24 @@ +#pragma once +/** + * @file decimal.hpp + * @brief Decimal scale of an asset precision, for contracts that size amounts in whole tokens. + */ + +#include + +#include + +namespace sysio::opp { + +/// Largest decimal precision an amount may carry: 10^18 is the last power of ten inside int64. +inline constexpr uint8_t MAX_AMOUNT_PRECISION = 18; + +/// 10^decimals: the subunits in one whole token of that precision. +inline int64_t precision_from_decimals(uint8_t decimals) { + check(decimals <= MAX_AMOUNT_PRECISION, "precision should be <= 18"); + int64_t p10 = 1; + for (uint8_t i = 0; i < decimals; ++i) p10 *= 10; + return p10; +} + +} // namespace sysio::opp diff --git a/contracts/sysio.swap/include/sysio.swap/utils.hpp b/contracts/sysio.swap/include/sysio.swap/utils.hpp index 1789f48165..1334eabad0 100644 --- a/contracts/sysio.swap/include/sysio.swap/utils.hpp +++ b/contracts/sysio.swap/include/sysio.swap/utils.hpp @@ -4,6 +4,7 @@ #include #include +#include inline string_view trim(string_view sv) { sv.remove_prefix(std::min(sv.find_first_not_of(" "), sv.size())); // left trim @@ -29,10 +30,6 @@ inline bool starts_with(string_view sv, string_view s) { return sv.size() >= s.size() && sv.compare(0, s.size(), s) == 0; } -/// Largest decimal precision an amount string may carry: 10^18 is the last -/// power of ten inside int64. -inline constexpr uint8_t MAX_AMOUNT_PRECISION = 18; - /// Parse an unsigned decimal digit string into int64, aborting on any character /// that is not a digit and on overflow. Signs are not accepted: every amount /// read from a memo is a magnitude. An empty string is zero. @@ -46,15 +43,6 @@ inline int64_t to_int(string_view sv) { return res; } -/// 10^decimals, the scale of an amount with that many decimal places. -inline int64_t precision_from_decimals(uint8_t decimals) -{ - check(decimals <= MAX_AMOUNT_PRECISION, "precision should be <= 18"); - int64_t p10 = 1; - for (uint8_t i = 0; i < decimals; ++i) p10 *= 10; - return p10; -} - /// Parse " " as it appears in an exchange memo, e.g. /// "16.6570 VOICE": the symbol's precision is the number of decimals written. /// Every arithmetic step is overflow-checked, so an amount string too large for @@ -94,7 +82,7 @@ inline asset asset_from_string(string_view from) } int64_t amount = 0; - check( !__builtin_mul_overflow(int_part, precision_from_decimals(sym.precision()), &amount) + check( !__builtin_mul_overflow(int_part, sysio::opp::precision_from_decimals(sym.precision()), &amount) && !__builtin_add_overflow(amount, fract_part, &amount), "amount too large" ); return asset(amount, sym); diff --git a/contracts/tests/sysio.liq_tests.cpp b/contracts/tests/sysio.liq_tests.cpp index 0fa00acb23..fb99ad1665 100644 --- a/contracts/tests/sysio.liq_tests.cpp +++ b/contracts/tests/sysio.liq_tests.cpp @@ -250,6 +250,9 @@ class sysio_liq_tester : public tester { action_result recredit(name holder, int64_t amount, name signer = LIQ_ACCOUNT) { return push_liq(signer, "recredit"_n, mvo()("holder", holder)("quantity", asset(amount, LIQSOL_SYM))); } + action_result setmindesyn(const asset& minimum, name signer = LIQ_ACCOUNT) { + return push_liq(signer, "setmindesyn"_n, mvo()("minimum", minimum)); + } action_result regliqpool(std::string_view chain_code, std::string_view token_code, symbol pair_symbol, uint64_t initial_chain_amount, uint64_t initial_wire_amount, int32_t fee = 30, int64_t locked_shares = 0, uint32_t horizon_sec = 86400, uint32_t depth_cap_bps = 300, @@ -903,6 +906,64 @@ BOOST_FIXTURE_TEST_CASE(desyndicate_burns_and_queues_the_attestation, sysio_liq_ BOOST_REQUIRE_EQUAL(160 * UNIT, supply()); } FC_LOG_AND_RETHROW() +// Each de-syndication queues a sysio-billed outbound row and a relayer-paid payout, so the symbol's +// floor refuses dust: below it is rejected, at it is accepted, and only this contract moves it. +BOOST_FIXTURE_TEST_CASE(desyndicate_enforces_the_symbol_minimum, sysio_liq_tester) try { + BOOST_REQUIRE_EQUAL(success(), mintsynd(SOLANA, 1, "alice"_n, LIQSOL, 100 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), recordlink("alice"_n, ChainKind::CHAIN_KIND_SVM, ed_key())); + + // `create` starts the shadow at one hundredth of a whole token. + constexpr int64_t default_minimum = UNIT / 100; + BOOST_REQUIRE_EQUAL("0.010000000 LIQSOL", stat_row()["min_desyndicate"].as_string()); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("quantity is below the minimum de-syndication of 0.010000000 LIQSOL"), + desyndicate("alice"_n, default_minimum - 1)); + BOOST_REQUIRE_EQUAL(success(), desyndicate("alice"_n, default_minimum)); + const auto att = attestation_row(1); + BOOST_REQUIRE(!att.is_null()); + const auto data = att["data"].as>(); + sysio::opp::attestations::DesyndicateLIQ msg; + BOOST_REQUIRE(msg.ParseFromArray(data.data(), static_cast(data.size()))); + BOOST_REQUIRE_EQUAL(default_minimum, msg.amount().amount()); + + // Only this contract retunes it, never to zero, and only in the shadow's own precision. + BOOST_REQUIRE(mentions(setmindesyn(asset(UNIT, LIQSOL_SYM), "alice"_n), "missing authority of sysio.liq")); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("minimum must be positive"), setmindesyn(asset(0, LIQSOL_SYM))); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("minimum must be positive"), setmindesyn(asset(-1, LIQSOL_SYM))); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("symbol precision mismatch"), + setmindesyn(asset(1, symbol::from_string("6,LIQSOL")))); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("shadow symbol does not exist"), setmindesyn(asset(1, LIQETH_SYM))); + + // Raising the floor refuses what the default admitted; lowering it to one subunit admits that. + BOOST_REQUIRE_EQUAL(success(), setmindesyn(asset(UNIT, LIQSOL_SYM))); + BOOST_REQUIRE_EQUAL("1.000000000 LIQSOL", stat_row()["min_desyndicate"].as_string()); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("quantity is below the minimum de-syndication of 1.000000000 LIQSOL"), + desyndicate("alice"_n, UNIT - 1)); + BOOST_REQUIRE_EQUAL(success(), desyndicate("alice"_n, UNIT)); + BOOST_REQUIRE_EQUAL(success(), setmindesyn(asset(1, LIQSOL_SYM))); + BOOST_REQUIRE_EQUAL(success(), desyndicate("alice"_n, 1)); + + BOOST_REQUIRE_EQUAL(100 * UNIT - default_minimum - UNIT - 1, shadow_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(100 * UNIT - default_minimum - UNIT - 1, supply()); + BOOST_REQUIRE_EQUAL(4u, counters_row()["next_request_id"].as_uint64()); +} FC_LOG_AND_RETHROW() + +// The default floor is one hundredth of a whole token in the shadow's own precision, and never less +// than one subunit. +BOOST_FIXTURE_TEST_CASE(create_scales_the_default_minimum_to_the_precision, sysio_liq_tester) try { + const auto create_at = [&](std::string_view code, uint32_t precision) { + BOOST_REQUIRE_EQUAL(success(), regtoken(TokenKind::TOKEN_KIND_LIQ, code, precision, ChainKind::CHAIN_KIND_EVM, + ETH, std::vector(20, char(0x5a)))); + const symbol sym(static_cast(precision), code); + BOOST_REQUIRE_EQUAL(success(), create(sym, ETH, code)); + return stat_row(sym)["min_desyndicate"].as_string(); + }; + BOOST_REQUIRE_EQUAL("0.010000000 LIQSOL", stat_row()["min_desyndicate"].as_string()); + BOOST_REQUIRE_EQUAL("0.010000 LIQSIX", create_at("LIQSIX", 6)); + BOOST_REQUIRE_EQUAL("0.01 LIQTWO", create_at("LIQTWO", 2)); + BOOST_REQUIRE_EQUAL("0.1 LIQONE", create_at("LIQONE", 1)); + BOOST_REQUIRE_EQUAL("1 LIQZERO", create_at("LIQZERO", 0)); +} FC_LOG_AND_RETHROW() + // --------------------------------------------------------------------------- // Launch ingestion // --------------------------------------------------------------------------- diff --git a/contracts/tests/sysio.msgch_chain_tests.cpp b/contracts/tests/sysio.msgch_chain_tests.cpp index 6e06e2b82c..dc045eb2cd 100644 --- a/contracts/tests/sysio.msgch_chain_tests.cpp +++ b/contracts/tests/sysio.msgch_chain_tests.cpp @@ -40,6 +40,8 @@ #include "contracts.hpp" #include "contract_test_support.hpp" +#include + using namespace sysio::testing; using namespace sysio; using namespace sysio::chain; @@ -71,6 +73,30 @@ 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; +/// Mirrors of the packing limits 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; +constexpr size_t SCHEDULE_LANE_BUDGET_BYTES = MAX_ENVELOPE_BYTES / 4 * 3; +/// The largest `data` a schedule-lane row can ship: it fits an otherwise empty envelope. +constexpr size_t MAX_ATTESTATION_DATA_BYTES = + MAX_ENVELOPE_BYTES - ENVELOPE_BASELINE_BYTES - ATTESTATION_OVERHEAD_BYTES; +/// The largest `data` an other-lane row can ship: it fits beside a full schedule lane. +constexpr size_t MAX_OTHER_ATTESTATION_DATA_BYTES = + MAX_ENVELOPE_BYTES - ENVELOPE_BASELINE_BYTES - SCHEDULE_LANE_BUDGET_BYTES - ATTESTATION_OVERHEAD_BYTES; + +/// What `sysio.epoch::advance` prints when it withholds each operator schedule attestation. +constexpr std::string_view WITHHOLDING_OPERATORS = "withholding Operators"; +constexpr std::string_view WITHHOLDING_BATCH_OPERATOR_GROUPS = "withholding BatchOperatorGroups"; + +/// Challenger accounts the roster tests register: a prefix of `CHALLENGER_NAME_PREFIX`, then a suffix of +/// `CHALLENGER_SUFFIX_LENGTH` base-31 digits that keeps each unique, `CHALLENGERS_PER_BLOCK` to a block. +constexpr std::string_view CHALLENGER_NAME_PREFIX = "rosterop"; +constexpr std::string_view CHALLENGER_NAME_DIGITS = "abcdefghijklmnopqrstuvwxyz12345"; +constexpr size_t CHALLENGER_SUFFIX_LENGTH = 4; +constexpr size_t CHALLENGER_NAME_MAX_LENGTH = CHALLENGER_NAME_PREFIX.size() + CHALLENGER_SUFFIX_LENGTH; +constexpr uint32_t CHALLENGERS_PER_BLOCK = 50; + /// sysio.opreg action identifiers used by the WNS-16 fixture. namespace opreg_actions { constexpr name SET_CONFIG = "setconfig"_n; @@ -107,12 +133,14 @@ constexpr name CHECK_CONSENSUS = "chkcons"_n; /// sysio.msgch table identifiers used by the WNS-16 fixture. namespace msgch_tables { -constexpr name ENVELOPES = "envelopes"_n; +constexpr name ENVELOPES = "envelopes"_n; +constexpr name ATTESTATIONS = "attestations"_n; } // namespace msgch_tables -/// sysio.msgch secondary-index identifiers used by the envelopes retention tests. +/// sysio.msgch secondary-index identifiers used by the envelopes retention and outbound queue tests. namespace msgch_indexes { constexpr name BY_OUTPOST_EPOCH = "byoutepoch"_n; +constexpr name BY_QUEUE = "byqueue"_n; } // namespace msgch_indexes /// sysio.msgch ABI type identifiers used by the WNS-16 fixture. @@ -643,6 +671,20 @@ class sysio_msgch_chain_tester : public tester { return n; } + /// Stored `byqueue` entries of `attestations`; zero once every row is gone, unless an erase left an + /// orphan behind. + size_t attestation_queue_index_entries() { + const auto table_id = compute_sec_table_id(msgch_tables::ATTESTATIONS.to_uint64_t(), + msgch_indexes::BY_QUEUE.to_uint64_t()); + const auto& idx = control->db().get_index(); + size_t n = 0; + for (auto itr = idx.lower_bound(boost::make_tuple(MSGCH_ACCOUNT, table_id)); + itr != idx.end() && itr->code == MSGCH_ACCOUNT && itr->table_id == table_id; ++itr) { + ++n; + } + return n; + } + /// Chain tip of the inbound stream when a test feeds both outposts identical envelopes: the next /// delivery continues from the last accepted envelope's digest and message id (raw 32-byte /// strings, empty at stream genesis). @@ -805,6 +847,131 @@ class sysio_msgch_chain_tester : public tester { return env.messages(0).payload().attestations_size(); } + /// One letter per attestation of the most recent envelope for `chain_code`, in order: O for OPERATORS, + /// G for BATCH_OPERATOR_GROUPS, otherwise the first byte of the attestation's data (the tests tag each + /// row they queue with its own byte). + std::string shipped_letters(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); + std::string letters; + for (const auto& att : env.messages(0).payload().attestations()) { + if (att.type() == sysio::opp::types::ATTESTATION_TYPE_OPERATORS) { + letters.push_back('O'); + } else if (att.type() == sysio::opp::types::ATTESTATION_TYPE_BATCH_OPERATOR_GROUPS) { + letters.push_back('G'); + } else { + BOOST_REQUIRE(!att.data().empty()); + letters.push_back(att.data().front()); + } + } + return letters; + } + + /// The OPERATORS roster the most recent envelope for `chain_code` carries, as account name to status. + std::map shipped_roster(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); + std::map roster; + bool found = false; + for (const auto& att : env.messages(0).payload().attestations()) { + if (att.type() != sysio::opp::types::ATTESTATION_TYPE_OPERATORS) continue; + sysio::opp::attestations::Operators operators; + BOOST_REQUIRE(operators.ParseFromArray(att.data().data(), static_cast(att.data().size()))); + roster.clear(); + for (const auto& entry : operators.operators()) roster[entry.account().name()] = entry.status(); + found = true; + } + BOOST_REQUIRE_MESSAGE(found, "outbound envelope carries no OPERATORS attestation"); + return roster; + } + + /// The schedule estimate of the most recent envelope for `chain_code`, summed the way `advance` sums it: + /// the data of each OPERATORS and BATCH_OPERATOR_GROUPS attestation that shipped, plus its overhead. + size_t shipped_schedule_estimate_bytes(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); + size_t bytes = 0; + for (const auto& att : env.messages(0).payload().attestations()) { + if (att.type() == sysio::opp::types::ATTESTATION_TYPE_OPERATORS || + att.type() == sysio::opp::types::ATTESTATION_TYPE_BATCH_OPERATOR_GROUPS) { + bytes += ATTESTATION_OVERHEAD_BYTES + att.data().size(); + } + } + return bytes; + } + + /// Register `account` as an operator of `type` through opreg's own authority, which needs no account + /// or authex link. A bootstrapped registration is ACTIVE at once; any other stays UNKNOWN until bonded. + action_result register_operator(name account, opp::types::OperatorType type, bool bootstrapped) { + return push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, "regoperator"_n, mvo() + ("account", account.to_string()) + ("type", type) + ("is_bootstrapped", bootstrapped)); + } + + /// Challenger account `index`, `length` characters long: the start of `CHALLENGER_NAME_PREFIX`, then + /// `index` in `CHALLENGER_SUFFIX_LENGTH` base-31 digits, so each index names a different account. + static std::string challenger_name(uint32_t index, size_t length) { + BOOST_REQUIRE(length >= CHALLENGER_SUFFIX_LENGTH && length <= CHALLENGER_NAME_MAX_LENGTH); + std::string account(CHALLENGER_NAME_PREFIX.substr(0, length - CHALLENGER_SUFFIX_LENGTH)); + for (size_t digit = 0; digit < CHALLENGER_SUFFIX_LENGTH; ++digit, index /= CHALLENGER_NAME_DIGITS.size()) { + account.push_back(CHALLENGER_NAME_DIGITS[index % CHALLENGER_NAME_DIGITS.size()]); + } + return account; + } + + /// Bytes an ACTIVE challenger named `account` adds to an OPERATORS attestation: its entry and the + /// entry's own framing, encoded as the outposts decode it. + static size_t challenger_roster_bytes(const std::string& account) { + sysio::opp::attestations::Operators roster; + auto* entry = roster.add_operators(); + entry->mutable_account()->set_name(account); + entry->set_type(sysio::opp::types::OPERATOR_TYPE_CHALLENGER); + entry->set_status(sysio::opp::types::OPERATOR_STATUS_ACTIVE); + return roster.ByteSizeLong(); + } + + /// Register a bootstrapped, so ACTIVE, challenger with a fresh name `length` characters long. A + /// challenger adds roster bytes and leaves the batch schedule as it is. + name add_challenger(size_t length) { + const name account(challenger_name(challengers_registered, length)); + BOOST_REQUIRE_EQUAL(success(), + register_operator(account, opp::types::OperatorType::OPERATOR_TYPE_CHALLENGER, true)); + if (++challengers_registered % CHALLENGERS_PER_BLOCK == 0) produce_block(); + return account; + } + + /// Register challengers that add exactly `bytes` to the OPERATORS roster, as few as full-length names + /// allow, and return them. Each takes an even share of `bytes`, which sets its name's length: an entry + /// grows by one byte per name character. + std::vector grow_roster(size_t bytes) { + const size_t full_entry_bytes = challenger_roster_bytes(challenger_name(0, CHALLENGER_NAME_MAX_LENGTH)); + const size_t entries = (bytes + full_entry_bytes - 1) / full_entry_bytes; + std::vector added; + for (size_t i = 0; i < entries; ++i) { + const size_t share = bytes / entries + (i < bytes % entries ? 1 : 0); + added.push_back(add_challenger(CHALLENGER_NAME_MAX_LENGTH - (full_entry_bytes - share))); + BOOST_REQUIRE_EQUAL(share, challenger_roster_bytes(added.back().to_string())); + } + return added; + } + + /// `advance_one_epoch`, returning everything the advance and its inline actions printed. + std::string advance_one_epoch_console() { + elapse_epoch_boundary(); + const auto trace = base_tester::push_action(EPOCH_ACCOUNT, "advance"_n, MSGCH_ACCOUNT, mvo()); + produce_blocks(); + std::string console; + for (const auto& action_trace : trace->action_traces) console += action_trace.console; + return console; + } + // -- SEC-28 rotation-termination helpers (drive recorddel/termcheck through the REAL advance) -- /// The opreg operator row for `account` (status + status_reason), or null when absent. @@ -998,6 +1165,8 @@ class sysio_msgch_chain_tester : public tester { } abi_serializer sysio_abi, token_abi, epoch_abi, opreg_abi, msgch_abi, chalg_abi, chains_abi, uwrit_abi, roa_abi; + /// Challengers `add_challenger` has registered, which also numbers the next one's name. + uint32_t challengers_registered = 0; }; // --------------------------------------------------------------------------- @@ -2201,6 +2370,182 @@ 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() } +/// An attestation no envelope can carry must not halt `advance`, which sends `buildenv` inline. The +/// oversized roster is dropped, the epoch advances, and everything queued behind it ships in the same +/// envelope: the schedule the advance queues itself, then the other row. Nothing is left behind, row or +/// index entry. +BOOST_FIXTURE_TEST_CASE(advance_drops_an_oversized_attestation_and_ships_the_rest, sysio_msgch_chain_tester) { try { + bootstrap(); + const uint32_t epoch = current_epoch(); + + const std::string queued_behind = "queued behind the oversized row"; + BOOST_REQUIRE_EQUAL(success(), queueout(ETH_OUTPOST_ID, sysio::opp::types::ATTESTATION_TYPE_OPERATORS, + std::vector(MAX_ATTESTATION_DATA_BYTES + 1, 'x'))); + BOOST_REQUIRE_EQUAL(success(), queueout(ETH_OUTPOST_ID, sysio::opp::types::ATTESTATION_TYPE_SWAP_REMIT, + std::vector(queued_behind.begin(), queued_behind.end()))); + + BOOST_REQUIRE_EQUAL(epoch + 1, advance_one_epoch()); + + const auto row = find_outbound_envelope(ETH_OUTPOST_ID); + BOOST_REQUIRE(!row.is_null()); + const auto env = decode_envelope(row["raw_envelope"].as>()); + BOOST_REQUIRE_EQUAL(env.epoch_index(), epoch + 1); + BOOST_REQUIRE_EQUAL(env.messages_size(), 1); + const auto& shipped = env.messages(0).payload().attestations(); + BOOST_REQUIRE_EQUAL(shipped.size(), 3); + BOOST_REQUIRE(shipped[0].type() == sysio::opp::types::ATTESTATION_TYPE_OPERATORS); + BOOST_REQUIRE_LE(shipped[0].data().size(), MAX_ATTESTATION_DATA_BYTES); + BOOST_REQUIRE(shipped[1].type() == sysio::opp::types::ATTESTATION_TYPE_BATCH_OPERATOR_GROUPS); + BOOST_REQUIRE(shipped[2].type() == sysio::opp::types::ATTESTATION_TYPE_SWAP_REMIT); + BOOST_REQUIRE_EQUAL(shipped[2].data(), queued_behind); + + BOOST_REQUIRE_EQUAL(0u, attestation_count(ETH_OUTPOST_ID, epoch)); + BOOST_REQUIRE_EQUAL(0u, attestation_count(ETH_OUTPOST_ID, epoch + 1)); + BOOST_REQUIRE_EQUAL(0u, attestation_queue_index_entries()); +} FC_LOG_AND_RETHROW() } + +/// The operator schedule ships ahead of a backlog. In strict queue order, more than an envelope of other +/// rows queued before `advance` would push the BATCH_OPERATOR_GROUPS it queues out of the epoch's +/// envelope, and an outpost that seats groups only from it would refuse the next epoch's deliverers. +/// The schedule leads instead, OPERATORS first, and the backlog follows oldest first up to the budget; +/// the rest ships next epoch, again behind that epoch's schedule. +BOOST_FIXTURE_TEST_CASE(advance_ships_the_schedule_ahead_of_a_full_backlog, sysio_msgch_chain_tester) { try { + bootstrap(); + const uint32_t epoch = current_epoch(); + + // Four of these fit an envelope beside the schedule and a fifth does not, so the backlog alone + // overflows the envelope; each is within the other lane's bound. + constexpr size_t QUARTER_OF_AN_ENVELOPE = 7'000; + for (char tag : std::string("abcde")) { + BOOST_REQUIRE_EQUAL(success(), queueout(ETH_OUTPOST_ID, sysio::opp::types::ATTESTATION_TYPE_SWAP_REMIT, + std::vector(QUARTER_OF_AN_ENVELOPE, tag))); + } + + BOOST_REQUIRE_EQUAL(epoch + 1, advance_one_epoch()); + BOOST_REQUIRE_EQUAL("OGabcd", shipped_letters(ETH_OUTPOST_ID)); + BOOST_REQUIRE_EQUAL(1u, attestation_count(ETH_OUTPOST_ID, epoch)); + + BOOST_REQUIRE_EQUAL(epoch + 2, advance_one_epoch()); + BOOST_REQUIRE_EQUAL("OGe", shipped_letters(ETH_OUTPOST_ID)); + BOOST_REQUIRE_EQUAL(0u, attestation_count(ETH_OUTPOST_ID, epoch)); +} FC_LOG_AND_RETHROW() } + +/// Under the lane an other-lane row never meets an empty envelope, because the schedule `advance` queues +/// packs first every epoch. A row too large to fit beside a full schedule lane would therefore hold up +/// its lane for good, so it is dropped even when it would fit an empty envelope. A row at the bound, and +/// the small row queued behind the dropped ones, ship in that same envelope. +BOOST_FIXTURE_TEST_CASE(advance_drops_other_rows_that_cannot_fit_beside_the_schedule, sysio_msgch_chain_tester) { try { + bootstrap(); + const uint32_t epoch = current_epoch(); + + const auto queue = [&](char tag, size_t size) { + BOOST_REQUIRE_EQUAL(success(), queueout(ETH_OUTPOST_ID, sysio::opp::types::ATTESTATION_TYPE_SWAP_REMIT, + std::vector(size, tag))); + }; + queue('x', MAX_ATTESTATION_DATA_BYTES); // fits an empty envelope, never beside the schedule + queue('y', MAX_OTHER_ATTESTATION_DATA_BYTES + 1); // one byte over the other lane's bound + queue('b', MAX_OTHER_ATTESTATION_DATA_BYTES); // at the bound + queue('m', 1); + + BOOST_REQUIRE_EQUAL(epoch + 1, advance_one_epoch()); + BOOST_REQUIRE_EQUAL("OGbm", shipped_letters(ETH_OUTPOST_ID)); + BOOST_REQUIRE_EQUAL(0u, attestation_count(ETH_OUTPOST_ID, epoch)); +} FC_LOG_AND_RETHROW() } + +/// The OPERATORS roster lists ACTIVE and SLASHED operators only. A registration never bonded (UNKNOWN) and +/// a TERMINATED operator add nothing to it; a SLASHED one stays, because the Solana outpost resolves a +/// slash's target through the roster. +BOOST_FIXTURE_TEST_CASE(advance_roster_lists_only_active_and_slashed_operators, sysio_msgch_chain_tester) { try { + bootstrap(); + BOOST_REQUIRE_EQUAL(success(), register_operator(BATCHOP_B, opp::types::OperatorType::OPERATOR_TYPE_BATCH, true)); + BOOST_REQUIRE_EQUAL(success(), register_operator(BATCHOP_C, opp::types::OperatorType::OPERATOR_TYPE_BATCH, true)); + BOOST_REQUIRE_EQUAL(success(), register_operator(BATCHOP_D, opp::types::OperatorType::OPERATOR_TYPE_BATCH, false)); + BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, CHALG_ACCOUNT, "slash"_n, + mvo()("account", BATCHOP_B.to_string())("reason", std::string("roster test")))); + BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, "terminate"_n, + mvo()("account", BATCHOP_C.to_string())("reason", std::string("roster test")))); + BOOST_REQUIRE(opp::types::OperatorStatus::OPERATOR_STATUS_UNKNOWN == + get_operator(BATCHOP_D)[opreg_fields::STATUS].as()); + produce_blocks(); + + advance_one_epoch(); + const std::map expected{ + {BATCHOP.to_string(), opp::types::OperatorStatus::OPERATOR_STATUS_ACTIVE}, + {BATCHOP_B.to_string(), opp::types::OperatorStatus::OPERATOR_STATUS_SLASHED}, + }; + BOOST_REQUIRE(expected == shipped_roster(ETH_OUTPOST_ID)); + BOOST_REQUIRE(expected == shipped_roster(SOL_OUTPOST_ID)); +} FC_LOG_AND_RETHROW() } + +/// A roster that would take the schedule past its budget is withheld with a diagnostic, the way an empty +/// window is: `advance` still advances, BATCH_OPERATOR_GROUPS still ships, and so does the other lane. +BOOST_FIXTURE_TEST_CASE(advance_withholds_a_roster_over_the_schedule_budget, sysio_msgch_chain_tester) { try { + bootstrap(); + const uint32_t epoch = current_epoch(); + + // About 22 roster bytes each, so these alone take the roster past the 24 KiB budget. + constexpr uint32_t ROSTER_OPERATORS = 1'200; + for (uint32_t i = 0; i < ROSTER_OPERATORS; ++i) add_challenger(CHALLENGER_NAME_MAX_LENGTH); + BOOST_REQUIRE_EQUAL(success(), queueout(ETH_OUTPOST_ID, sysio::opp::types::ATTESTATION_TYPE_SWAP_REMIT, + std::vector{'m'})); + + const auto console = advance_one_epoch_console(); + BOOST_REQUIRE_MESSAGE(console.find(WITHHOLDING_OPERATORS) != std::string::npos, console); + BOOST_REQUIRE_EQUAL(epoch + 1, current_epoch()); + BOOST_REQUIRE_EQUAL("Gm", shipped_letters(ETH_OUTPOST_ID)); + BOOST_REQUIRE_EQUAL("G", shipped_letters(SOL_OUTPOST_ID)); +} FC_LOG_AND_RETHROW() } + +/// The roster is withheld only past the budget. A schedule whose estimate is exactly the budget ships whole; +/// one byte more, from a slash that widens one status, withholds OPERATORS with the diagnostic, and the epoch +/// still advances with BATCH_OPERATOR_GROUPS. +BOOST_FIXTURE_TEST_CASE(advance_withholds_the_roster_one_byte_past_the_budget, sysio_msgch_chain_tester) { try { + bootstrap(); + + const auto challengers = grow_roster(SCHEDULE_LANE_BUDGET_BYTES - shipped_schedule_estimate_bytes(ETH_OUTPOST_ID)); + const auto at_budget = advance_one_epoch_console(); + BOOST_REQUIRE_MESSAGE(at_budget.find(WITHHOLDING_OPERATORS) == std::string::npos, at_budget); + for (const uint64_t outpost : {ETH_OUTPOST_ID, SOL_OUTPOST_ID}) { + BOOST_REQUIRE_EQUAL("OG", shipped_letters(outpost)); + BOOST_REQUIRE_EQUAL(SCHEDULE_LANE_BUDGET_BYTES, shipped_schedule_estimate_bytes(outpost)); + } + + // SLASHED encodes one byte wider than ACTIVE, and a slashed operator stays on the roster. + BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, CHALG_ACCOUNT, "slash"_n, + mvo()("account", challengers.front().to_string()) + ("reason", std::string("one byte past the budget")))); + const uint32_t epoch = current_epoch(); + const auto past_budget = advance_one_epoch_console(); + const std::string one_byte_over = + "operator schedule needs " + std::to_string(SCHEDULE_LANE_BUDGET_BYTES + 1) + " estimated bytes"; + BOOST_REQUIRE_MESSAGE(past_budget.find(one_byte_over) != std::string::npos, past_budget); + BOOST_REQUIRE_MESSAGE(past_budget.find(WITHHOLDING_OPERATORS) != std::string::npos, past_budget); + BOOST_REQUIRE_EQUAL(epoch + 1, current_epoch()); + for (const uint64_t outpost : {ETH_OUTPOST_ID, SOL_OUTPOST_ID}) { + BOOST_REQUIRE_EQUAL("G", shipped_letters(outpost)); + } +} FC_LOG_AND_RETHROW() } + +/// BATCH_OPERATOR_GROUPS counts toward the budget only when it ships. Terminating the only batch operator +/// empties the next group, so the groups attestation is withheld, and a roster whose own estimate is the whole +/// budget ships although the groups attestation would have taken the schedule past it. +BOOST_FIXTURE_TEST_CASE(advance_counts_batch_operator_groups_toward_the_budget_only_when_they_ship, + sysio_msgch_chain_tester) { try { + bootstrap(); + BOOST_REQUIRE_EQUAL(success(), push(OPREG_ACCOUNT, opreg_abi, OPREG_ACCOUNT, "terminate"_n, + mvo()("account", BATCHOP.to_string())("reason", std::string("empty window")))); + // A terminated operator leaves the roster, so the challengers make up all of it. + grow_roster(SCHEDULE_LANE_BUDGET_BYTES - ATTESTATION_OVERHEAD_BYTES); + + const auto console = advance_one_epoch_console(); + BOOST_REQUIRE_MESSAGE(console.find(WITHHOLDING_BATCH_OPERATOR_GROUPS) != std::string::npos, console); + BOOST_REQUIRE_MESSAGE(console.find(WITHHOLDING_OPERATORS) == std::string::npos, console); + for (const uint64_t outpost : {ETH_OUTPOST_ID, SOL_OUTPOST_ID}) { + BOOST_REQUIRE_EQUAL("O", shipped_letters(outpost)); + BOOST_REQUIRE_EQUAL(SCHEDULE_LANE_BUDGET_BYTES, shipped_schedule_estimate_bytes(outpost)); + } +} FC_LOG_AND_RETHROW() } + // --------------------------------------------------------------------------- // Inbound `envelopes` retention: `deliver` prunes rows older than the // previous epoch, a bounded number per call. diff --git a/contracts/tests/sysio.msgch_tests.cpp b/contracts/tests/sysio.msgch_tests.cpp index 6b3c1c6d03..4bc8a27082 100644 --- a/contracts/tests/sysio.msgch_tests.cpp +++ b/contracts/tests/sysio.msgch_tests.cpp @@ -329,6 +329,61 @@ class sysio_msgch_envlog_tester : public tester { return fc::variant{}; } + /// Push `buildenv` for `chain_code` and return what it printed. + std::string buildenv_console(uint64_t chain_code) { + const auto trace = base_tester::push_action(MSGCH_ACCOUNT, "buildenv"_n, EPOCH_ACCOUNT, + mvo()("chain_code", chain_code)); + std::string console; + for (const auto& action_trace : trace->action_traces) console += action_trace.console; + return console; + } + + /// Whether attestation `id` is still stored. + bool attestation_stored(uint64_t id) { + return !get_row_by_id(MSGCH_ACCOUNT, MSGCH_ACCOUNT, "attestations"_n, id).empty(); + } + + /// The stored `outenvelopes` row for `chain_code` (the table keeps one per outpost), or null. + fc::variant outbound_envelope_row(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) return row; + } + return fc::variant{}; + } + + /// The raw envelope stored for `chain_code`; fails the test when there is none. + std::vector outbound_envelope_bytes(uint64_t chain_code) { + const auto row = outbound_envelope_row(chain_code); + BOOST_REQUIRE(!row.is_null()); + return row["raw_envelope"].as>(); + } + + /// The envelope stored for `chain_code`, decoded; `buildenv` always wraps one message. + opp::Envelope outbound_envelope_for(uint64_t chain_code) { + const auto raw = outbound_envelope_bytes(chain_code); + opp::Envelope env; + BOOST_REQUIRE(env.ParseFromArray(raw.data(), static_cast(raw.size()))); + BOOST_REQUIRE_EQUAL(env.messages_size(), 1); + return env; + } + + /// The first byte of every attestation the envelope stored for `chain_code` carries, in order. + /// The packing tests fill each payload with its own tag byte. + std::string shipped_tags(uint64_t chain_code) { + const auto env = outbound_envelope_for(chain_code); + std::string tags; + for (const auto& att : env.messages(0).payload().attestations()) { + BOOST_REQUIRE(!att.data().empty()); + tags.push_back(att.data().front()); + } + return tags; + } + /// Count populated `envlog` rows in the id range `[0, max_id_exclusive)`. /// Cheap enough for the test scales here (≤ a few thousand probes). uint32_t envlog_row_count_until(uint64_t max_id_exclusive) { @@ -359,6 +414,18 @@ constexpr uint64_t SOL_OUTPOST_ID = "SOL"_s.value; constexpr auto EVM_TEST_ATTESTATION_TYPE = opp::types::ATTESTATION_TYPE_OPERATORS; constexpr auto SWAP_REMIT_ATTESTATION_TYPE = opp::types::ATTESTATION_TYPE_SWAP_REMIT; +/// Mirrors of the packing limits 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; +constexpr size_t SCHEDULE_LANE_BUDGET_BYTES = MAX_ENVELOPE_BYTES / 4 * 3; +/// The largest schedule-lane `data` that can ship; one byte more fits no envelope and is dropped. +constexpr size_t MAX_ATTESTATION_DATA_BYTES = + MAX_ENVELOPE_BYTES - ENVELOPE_BASELINE_BYTES - ATTESTATION_OVERHEAD_BYTES; +/// The largest other-lane `data` that can ship; one byte more fits no envelope beside a full schedule lane. +constexpr size_t MAX_OTHER_ATTESTATION_DATA_BYTES = + MAX_ENVELOPE_BYTES - ENVELOPE_BASELINE_BYTES - SCHEDULE_LANE_BUDGET_BYTES - ATTESTATION_OVERHEAD_BYTES; + } // anonymous namespace /// Smoke: queueout + buildenv writes one row to `envlog` with the @@ -623,6 +690,188 @@ BOOST_FIXTURE_TEST_CASE(buildenv_packs_until_cap_then_leaves_remainder, BOOST_REQUIRE_EQUAL(still_ready_after_emit2, 0u); } FC_LOG_AND_RETHROW() } +/// `buildenv` walks only its own outpost's READY queue, oldest first, and stops at the first row that +/// does not fit. Rows behind that point are not reached, not even one its lane could never ship, and the +/// other outpost's rows, oversized or not, are neither packed nor dropped. +BOOST_FIXTURE_TEST_CASE(buildenv_packs_one_outpost_queue_in_order_up_to_the_budget, + sysio_msgch_envlog_tester) { try { + bootstrap_epoch_config(/*retention=*/200); + register_outpost(opp::types::CHAIN_KIND_EVM, 31337); + register_outpost(opp::types::CHAIN_KIND_SVM, 0); + produce_blocks(); + + // Four of these fit one envelope and a fifth does not; each is within the other lane's bound. + constexpr size_t QUARTER_OF_AN_ENVELOPE = 7'000; + const auto queue = [&](uint64_t chain_code, char tag, size_t size) { + BOOST_REQUIRE_EQUAL(success(), queueout_with_data(chain_code, SWAP_REMIT_ATTESTATION_TYPE, + std::vector(size, tag))); + }; + // Attestation ids run 1..8 in queue order, the two outposts interleaved. + queue(SOL_OUTPOST_ID, 'S', MAX_OTHER_ATTESTATION_DATA_BYTES + 1); // 1 + queue(ETH_OUTPOST_ID, 'a', QUARTER_OF_AN_ENVELOPE); // 2 + queue(SOL_OUTPOST_ID, 's', 1); // 3 + queue(ETH_OUTPOST_ID, 'b', QUARTER_OF_AN_ENVELOPE); // 4 + queue(ETH_OUTPOST_ID, 'c', QUARTER_OF_AN_ENVELOPE); // 5 + queue(ETH_OUTPOST_ID, 'd', QUARTER_OF_AN_ENVELOPE); // 6 + queue(ETH_OUTPOST_ID, 'e', QUARTER_OF_AN_ENVELOPE); // 7: does not fit behind a to d + queue(ETH_OUTPOST_ID, 'X', MAX_OTHER_ATTESTATION_DATA_BYTES + 1); // 8: behind the stop point + produce_blocks(); + + BOOST_REQUIRE_EQUAL(success(), buildenv(ETH_OUTPOST_ID)); + produce_blocks(); + BOOST_REQUIRE_EQUAL("abcd", shipped_tags(ETH_OUTPOST_ID)); + BOOST_REQUIRE(!attestation_stored(2) && !attestation_stored(4) && !attestation_stored(5) && !attestation_stored(6)); + BOOST_REQUIRE(attestation_stored(7)); + BOOST_REQUIRE_MESSAGE(attestation_stored(8), "the walk went past the first row that did not fit"); + BOOST_REQUIRE_MESSAGE(attestation_stored(1) && attestation_stored(3), "the walk touched another outpost's queue"); + BOOST_REQUIRE(outbound_envelope_row(SOL_OUTPOST_ID).is_null()); + + // The next envelope ships e, and the walk now reaches X and drops it. + BOOST_REQUIRE_EQUAL(success(), buildenv(ETH_OUTPOST_ID)); + produce_blocks(); + BOOST_REQUIRE_EQUAL("e", shipped_tags(ETH_OUTPOST_ID)); + BOOST_REQUIRE(!attestation_stored(7) && !attestation_stored(8)); + + // SOL's own walk drops its oversized head and ships the row behind it in the same envelope. + BOOST_REQUIRE_EQUAL(success(), buildenv(SOL_OUTPOST_ID)); + produce_blocks(); + BOOST_REQUIRE_EQUAL("s", shipped_tags(SOL_OUTPOST_ID)); + BOOST_REQUIRE(!attestation_stored(1) && !attestation_stored(3)); +} FC_LOG_AND_RETHROW() } + +/// Each lane's drop bound is exact. A schedule row that just fits an otherwise empty envelope ships alone +/// within the cap. An other-lane row at its bound, the most that fits beside a full schedule lane, ships; +/// one byte over is dropped with a diagnostic, and the row queued behind it ships in the same envelope. +BOOST_FIXTURE_TEST_CASE(buildenv_drops_only_what_its_lane_can_never_ship, sysio_msgch_envlog_tester) { try { + bootstrap_epoch_config(/*retention=*/200); + register_outpost(opp::types::CHAIN_KIND_EVM, 31337); + produce_blocks(); + + const auto queue = [&](opp::types::AttestationType type, char tag, size_t size) { + BOOST_REQUIRE_EQUAL(success(), queueout_with_data(ETH_OUTPOST_ID, type, std::vector(size, tag))); + }; + queue(EVM_TEST_ATTESTATION_TYPE, 'a', MAX_ATTESTATION_DATA_BYTES); // 1: schedule, fills it alone + queue(SWAP_REMIT_ATTESTATION_TYPE, 'b', MAX_OTHER_ATTESTATION_DATA_BYTES); // 2: other, at its bound + queue(SWAP_REMIT_ATTESTATION_TYPE, 'x', MAX_OTHER_ATTESTATION_DATA_BYTES + 1); // 3: other, one byte over + queue(SWAP_REMIT_ATTESTATION_TYPE, 'c', 1); // 4 + produce_blocks(); + + const auto first = buildenv_console(ETH_OUTPOST_ID); + produce_blocks(); + BOOST_REQUIRE_MESSAGE(first.find("DROP") == std::string::npos, first); + BOOST_REQUIRE_EQUAL("a", shipped_tags(ETH_OUTPOST_ID)); + BOOST_REQUIRE_LE(outbound_envelope_bytes(ETH_OUTPOST_ID).size(), MAX_ENVELOPE_BYTES); + BOOST_REQUIRE(attestation_stored(2) && attestation_stored(3) && attestation_stored(4)); + + const auto second = buildenv_console(ETH_OUTPOST_ID); + produce_blocks(); + BOOST_REQUIRE_MESSAGE(second.find("DROP attestation 3 (") != std::string::npos, second); + BOOST_REQUIRE_EQUAL("bc", shipped_tags(ETH_OUTPOST_ID)); + BOOST_REQUIRE(!attestation_stored(2) && !attestation_stored(3) && !attestation_stored(4)); +} FC_LOG_AND_RETHROW() } + +/// The lane bounds leave no gap. A schedule whose estimate is its whole budget and an other-lane row at its +/// bound together estimate exactly `MAX_ENVELOPE_BYTES`, and both ship in one envelope within the cap: nothing +/// is dropped, and the trim loop does not pop the other row. A single schedule row leaves the estimate the +/// least slack over the real encoding, and an earlier emit gives the envelope both chain links. +BOOST_FIXTURE_TEST_CASE(buildenv_ships_the_largest_other_row_beside_a_full_schedule, sysio_msgch_envlog_tester) { try { + bootstrap_epoch_config(/*retention=*/200); + register_outpost(opp::types::CHAIN_KIND_EVM, 31337); + produce_blocks(); + + const auto queue = [&](opp::types::AttestationType type, char tag, size_t size) { + BOOST_REQUIRE_EQUAL(success(), queueout_with_data(ETH_OUTPOST_ID, type, std::vector(size, tag))); + }; + queue(SWAP_REMIT_ATTESTATION_TYPE, 'p', 1); + produce_blocks(); + BOOST_REQUIRE_EQUAL(success(), buildenv(ETH_OUTPOST_ID)); + produce_blocks(); + + queue(EVM_TEST_ATTESTATION_TYPE, 'a', SCHEDULE_LANE_BUDGET_BYTES - ATTESTATION_OVERHEAD_BYTES); + queue(SWAP_REMIT_ATTESTATION_TYPE, 'b', MAX_OTHER_ATTESTATION_DATA_BYTES); + produce_blocks(); + + const auto console = buildenv_console(ETH_OUTPOST_ID); + produce_blocks(); + BOOST_REQUIRE_MESSAGE(console.find("DROP") == std::string::npos, console); + BOOST_REQUIRE_EQUAL("ab", shipped_tags(ETH_OUTPOST_ID)); + const auto env = outbound_envelope_for(ETH_OUTPOST_ID); + BOOST_REQUIRE(!env.previous_envelope_hash().empty() && !env.messages(0).header().previous_message_id().empty()); + const size_t envelope_bytes = outbound_envelope_bytes(ETH_OUTPOST_ID).size(); + BOOST_TEST_MESSAGE("a full schedule beside the largest other row encodes to " << envelope_bytes << " bytes"); + BOOST_REQUIRE_LE(envelope_bytes, MAX_ENVELOPE_BYTES); +} FC_LOG_AND_RETHROW() } + +/// A queue emptied by drops still emits the epoch's envelope, carrying no attestations, because each +/// outpost answers the depot's envelope with its own; an empty queue emits none. +BOOST_FIXTURE_TEST_CASE(buildenv_emits_an_empty_envelope_when_it_drops_the_whole_queue, + sysio_msgch_envlog_tester) { try { + bootstrap_epoch_config(/*retention=*/200); + register_outpost(opp::types::CHAIN_KIND_EVM, 31337); + produce_blocks(); + + BOOST_REQUIRE_EQUAL(success(), buildenv(ETH_OUTPOST_ID)); + produce_blocks(); + BOOST_REQUIRE(outbound_envelope_row(ETH_OUTPOST_ID).is_null()); + + BOOST_REQUIRE_EQUAL(success(), queueout_with_data(ETH_OUTPOST_ID, EVM_TEST_ATTESTATION_TYPE, + std::vector(MAX_ATTESTATION_DATA_BYTES + 1, 'x'))); + BOOST_REQUIRE_EQUAL(success(), buildenv(ETH_OUTPOST_ID)); + produce_blocks(); + BOOST_REQUIRE(!attestation_stored(1)); + BOOST_REQUIRE_EQUAL(0, outbound_envelope_for(ETH_OUTPOST_ID).messages(0).payload().attestations_size()); +} FC_LOG_AND_RETHROW() } + +/// The operator schedule packs ahead of other traffic whatever the queue order, and each lane keeps its own +/// queue order, so OPERATORS stays ahead of the BATCH_OPERATOR_GROUPS queued after it. +BOOST_FIXTURE_TEST_CASE(buildenv_packs_the_operator_schedule_first, sysio_msgch_envlog_tester) { try { + bootstrap_epoch_config(/*retention=*/200); + register_outpost(opp::types::CHAIN_KIND_EVM, 31337); + produce_blocks(); + + const auto queue = [&](opp::types::AttestationType type, char tag) { + BOOST_REQUIRE_EQUAL(success(), queueout_with_data(ETH_OUTPOST_ID, type, std::vector{tag})); + }; + queue(SWAP_REMIT_ATTESTATION_TYPE, 'a'); + queue(opp::types::ATTESTATION_TYPE_OPERATORS, 'o'); + queue(SWAP_REMIT_ATTESTATION_TYPE, 'b'); + queue(opp::types::ATTESTATION_TYPE_BATCH_OPERATOR_GROUPS, 'g'); + queue(opp::types::ATTESTATION_TYPE_OPERATORS, 'p'); + produce_blocks(); + + BOOST_REQUIRE_EQUAL(success(), buildenv(ETH_OUTPOST_ID)); + produce_blocks(); + BOOST_REQUIRE_EQUAL("ogpab", shipped_tags(ETH_OUTPOST_ID)); +} FC_LOG_AND_RETHROW() } + +/// A schedule over its budget still ships, since each row fits an envelope, but `buildenv` reports it: it can +/// hold other-lane rows back. A schedule exactly at the budget is not reported. +BOOST_FIXTURE_TEST_CASE(buildenv_reports_a_schedule_over_its_budget, sysio_msgch_envlog_tester) { try { + bootstrap_epoch_config(/*retention=*/200); + register_outpost(opp::types::CHAIN_KIND_EVM, 31337); + produce_blocks(); + + constexpr std::string_view over_budget = "over its 24576-byte budget"; + const auto queue = [&](char tag, size_t size) { + BOOST_REQUIRE_EQUAL(success(), queueout_with_data(ETH_OUTPOST_ID, EVM_TEST_ATTESTATION_TYPE, + std::vector(size, tag))); + }; + + queue('a', SCHEDULE_LANE_BUDGET_BYTES - ATTESTATION_OVERHEAD_BYTES); + produce_blocks(); + const auto at_budget = buildenv_console(ETH_OUTPOST_ID); + produce_blocks(); + BOOST_REQUIRE_MESSAGE(at_budget.find(over_budget) == std::string::npos, at_budget); + BOOST_REQUIRE_EQUAL("a", shipped_tags(ETH_OUTPOST_ID)); + + queue('b', SCHEDULE_LANE_BUDGET_BYTES - ATTESTATION_OVERHEAD_BYTES + 1); + produce_blocks(); + const auto past_budget = buildenv_console(ETH_OUTPOST_ID); + produce_blocks(); + BOOST_REQUIRE_MESSAGE(past_budget.find(over_budget) != std::string::npos, past_budget); + BOOST_REQUIRE_EQUAL("b", shipped_tags(ETH_OUTPOST_ID)); +} 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 SWAP_REMIT / WITHDRAW_REMIT / SLASH the outpost executes).