From ecc6ffde774f053379e8bc00aeb9df62deeaa647 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Mon, 28 Sep 2026 13:48:11 -0500 Subject: [PATCH 1/3] opp.common: share precision_from_decimals Moves sysio.swap's 10^decimals helper, unchanged, to sysio.opp.common/decimal.hpp so sysio.liq can size its desyndicate floor with the same definition. sysio.swap's wasm and abi are unchanged. --- .../include/sysio.opp.common/decimal.hpp | 24 +++++++++++++++++++ .../sysio.swap/include/sysio.swap/utils.hpp | 16 ++----------- 2 files changed, 26 insertions(+), 14 deletions(-) create mode 100644 contracts/sysio.opp.common/include/sysio.opp.common/decimal.hpp 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); From c239257105245a51e36af438bec0aa2a29bf6680 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Mon, 28 Sep 2026 13:48:11 -0500 Subject: [PATCH 2/3] msgch, epoch: bound the outbound queue and the roster that feeds it buildenv runs inline in sysio.epoch::advance. A READY attestation too large for any envelope made it abort, so one oversized row halted epoch advancement chain-wide, and its READY scan walked every outpost's rows each epoch with no bound, while sysio.liq::desyndicate lets any holder add rows. It now walks one outpost's queue through a (status, chain, lane) index and stops at the first row that does not fit, so a call reads about one envelope's worth of rows plus any it drops. The operator schedule (OPERATORS and BATCH_OPERATOR_GROUPS) packs ahead of other traffic, each lane oldest first: an outpost seats the next epoch's batch-operator group only from BATCH_OPERATOR_GROUPS, and in strict queue order a backlog queued before advance pushed it out of the envelope, so Solana refused the next epoch's deliverers. The schedule gets a fixed budget of 24 KiB, Solana's OPERATORS byte cap. A row larger than its lane can ever ship is dropped with a diagnostic: for the schedule, whatever an empty envelope cannot hold; for other traffic, whatever cannot fit beside a full schedule (7656 bytes). Without that bound a kept row could sit at the front of its lane forever and block every row behind it. An emptied queue still emits the epoch's envelope, since each outpost answers the depot's envelope with its own. The attestations table's unread bytype and byepoch indexes go. advance keeps the schedule within that budget. The OPERATORS roster listed every sysio.opreg row, and regoperator is permissionless, so unbonded registrations grew it without bound; it now lists ACTIVE and SLASHED operators only. Both outposts treat an omitted operator as not active, and SLASHED stays because Solana resolves a slash's target through its registry. A roster that would still take the schedule past the budget is withheld with a diagnostic, the way an empty batch window already is: only the queueout is skipped, advance continues, and the outposts keep their previous roster. --- OVERVIEW.md | 2 +- contracts/sysio.epoch/src/sysio.epoch.cpp | 172 +++++---- contracts/sysio.epoch/sysio.epoch.wasm | Bin 79839 -> 77995 bytes contracts/sysio.msgch/README.md | 37 +- .../include/sysio.msgch/sysio.msgch.hpp | 118 +++++- contracts/sysio.msgch/src/sysio.msgch.cpp | 215 +++++------ contracts/sysio.msgch/sysio.msgch.abi | 16 +- contracts/sysio.msgch/sysio.msgch.wasm | Bin 172091 -> 172265 bytes contracts/tests/sysio.msgch_chain_tests.cpp | 349 +++++++++++++++++- contracts/tests/sysio.msgch_tests.cpp | 249 +++++++++++++ 10 files changed, 947 insertions(+), 211 deletions(-) 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 3583ddc36b03401edcaecef5ff887efea340647a..fb356b928ec5701e4630638f7d662ef0ac849d38 100755 GIT binary patch delta 11103 zcmb7K349bq*6&w6nH-al1`%T^2MZC%L>0|2nvUo)l z?-YL#uZq{i>*5XZrr0Ik5^sxl#BTAfNZIwKYlr;Qd2vFa_<4A^Iw31fR9+R{ z-{Q{+^sVfjH5|W#bF;$FWUV2p3lH_)PU6$r@S8cMBrneLNpJ22a!FRUyd`%l9mV{7 z*)6XtdV_OIR~j_7u;T5DIwUo-pv|MY zNqx2MVu3EdyTqGfl2zjhmWoPUy8HPH4haxyT|w)(7WAc|f6^Vaih{mGbgM{pvt?Po zRw1VIoD81h3i_Hy=GKFjg+BA(ehc&$=s_zf=rb(M*HZ6elG)OFVUHFJ2+f~bDo(nc z>G-YGgZKq&<2I85kjV#O)$=%iYf~>3n&>0IpjZnXcEN|})VuxeDVL^cru$M~x~X64 zbMq9Pr@1Y<9Rul3)56R$u1Rw_R`KZ`Owg_0d4@6&4CdiI&$yTa_m(F4b!My^?L20N z5%e;-SgT0{zf0v${W6NwFtJ=q6EUrjd17Im+pk;N0JXH4$8?*i{nK1>!mt|+=vfpfL}oDN`$PT%hE7ni3;oRe&rT6JEYt# zdKVnMy9j42e=8dReOqbh6bQgz&{ANVA%NOhHkpKeB?;Zl>4U?T$ zpAnMtXn1`ZLtoacr1Vz^t;Ae33vG6qaXyStgNijhQL_kuP*6)C!{cV)&`a( zj?V^Cd=wd>1OXUVT0*{@c|7xId7YBjykA52J8LTu60ulHLoCUE2Xc&n z_zh9abQMIEk}k*@)Y5VV{VlB|_#{>HuoQdrpd(K}9LMnth@)b>FUvUz%7&>-g!!}k zvv++N%IUrgIkdYsU7Z9BM^?(Cmu7_0aWoQwoBQfB{Rw@|`RmhQ3+pI?Dq+1v(~OzU z9ylYgOGeO2>8WXs8qw|j`gp(F%wVUr9&foWhX!uzuFKP!HD=tbDaKxhbL*g`HRdcA zUX~q7GhGeE<2>{S;vozDv3PpW-zr|b)W+h$aOX0Xh5bv*W170h`(0*cgo}%nOl>S) zlU#gmKKYqViU*#FuwH`PIk>=RteC7HJX&5gpi5%ZV+#jl`>|)7yPDci?jA6pZ%pd+ z@&0Cz+EfP-UJ3fs&1M53diwvy=1&9Lxlt4lWOC-f8P~-)Dvw9)pq!o7*qZ;vO|V0Y zb9_B)P))@aLH1;Xyeub38vNTZV>Y7B!wE9E+puBsJbi$j)Q3a-&k-@Qd#k4Od~Ha!{Ql-Y*!=6UW=yrZEAsrGs#$Rp*2$tq7A6=rieYv4%PYj92|%Eo_gn0p$l-IQk{YQw!* z=NO%NCYqU?B#`7)l1Sw9<1!PYF84a=2SxcDgW?!W7!woErC1@3EphftD{lH(ZVodp zI_JiQ3+kBgj@ejD3GWn&#!MImHD&^dZj#1mm%MRkY0PEIhi3bQ*(%OuO)mX#XuD)2 zSx((%Yi0fGhk0C-n(mR~MwSgicFi!e267=#+h!Ouqva=r8pg2g=OhQV)uXkJqC{jc zh{FPI4#R@n*1BZLwS5@cO}^G2+PR1MJqPghyXw`8p!L9BrZFJ1_i9F^s>`*yPesNa z-KXm90>Cbmkt`PBFrZyar##hS6IS6l;GIK}{u)=QIPQY@hM9Vq5`QUJ(-dDTliDf3 zi1nG^1B}TKusOp&R%dra;u@FieqHWWT+91XcTvTq9xP0^F~!8XfT+?<@G91p0$l)j z`CT~(DxlGTSfK;6&@|JP6EtQ~v>fhIxG;bX?)BLKuCmAV9g-`2ihaFXj=w%TbT*Joy&)?H%N+m{HP4ZV zd5*ed?;Em5)VMf?6c*bJS4Lv2CSn4167O=v1*4UsiCF;X8)rK8&whYm25uj2`d||c zNn?}2Dw}U84$US;SOEciu5)O*U|uL0*=!d!1BEBlpxaOco)`5(V^Qke5XFxrY&FJO z4&Ha9bnqu+OYASNxp8Eu&TUl${cgP9F~Upi4@c>s09WF*2+_^bInF!c;y0WC4iaM% zyhpH1%8*z}CRp$hGqLXxZWE*XAH{q;W91plIm%;Ldd6+08*?b6a5B5#9fEi8QkzobSnS$&d1PO8B1l3MKU0J9hi&f``n z=x~8I0GpA=&tb#h#3Cf3YnnA=AkERmU3pQ$StuCU)=Ujr2GS?*eCV8=<*d=g&CgjV zUmJZ-J{*F#L=Gc=Hj+dXkw#^f*Omo}&ROYTL~QAeW$f63EoDt@Rae$2Jvzk!sF|{? z%=bsE8$h$@IywF3rdTfq{)jk9e$?cQ`av178J)*8#p5)p2arZmY^z_!G^MtJjSXN# zY(dSCELlD_vk5CRZvct2yuZC7S9U6I(Ztl^D+j~`eX6`e6ElvKcW|M~L_tC`Iq8

C(NgtF8|h*R7eRRhnSrahF^~8SGE1dw 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 RdWE#=&81XAE~A5{{SQUA75)GK delta 11236 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 #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 b09bf4cc693e825e7f3df3a544dd693714c3fd91..8e51fee14628d21545d9f76da5fb7d641b4bef92 100755 GIT binary patch delta 29766 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 delta 29490 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 I+t_CNUzJoHLI3~& 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). From 2da6b0a8b154d727bc6df5008b265e37d5fd901a Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Mon, 28 Sep 2026 13:48:12 -0500 Subject: [PATCH 3/3] liq: floor desyndicate at a per-symbol minimum desyndicate accepted a single subunit, so each call bought a sysio-billed outbound attestation and a relayer-paid payout on the outpost. Each shadow token now carries a minimum, by default one hundredth of a whole token and at least one subunit, which the contract's own authority can change with setmindesyn. --- contracts/sysio.liq/README.md | 12 +++- .../sysio.liq/include/sysio.liq/sysio.liq.hpp | 13 +++- contracts/sysio.liq/src/sysio.liq.cpp | 31 +++++++-- contracts/sysio.liq/sysio.liq.abi | 19 ++++++ contracts/sysio.liq/sysio.liq.wasm | Bin 62338 -> 65192 bytes contracts/tests/sysio.liq_tests.cpp | 61 ++++++++++++++++++ 6 files changed, 129 insertions(+), 7 deletions(-) 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 aa7bb79d50906bbd45853944aac2ed5f67958b58..a232f3d3f1f7cb26ba5613445f9060fec212d02f 100755 GIT binary patch delta 23812 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 delta 21245 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 MPwO0IeKhU=0kk~Gm;e9( 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 // ---------------------------------------------------------------------------