diff --git a/contracts/sysio.epoch/src/sysio.epoch.cpp b/contracts/sysio.epoch/src/sysio.epoch.cpp index ca1c2ed089..c3fe0e5deb 100644 --- a/contracts/sysio.epoch/src/sysio.epoch.cpp +++ b/contracts/sysio.epoch/src/sysio.epoch.cpp @@ -84,7 +84,9 @@ struct emissions_gate_result { EmissionsBlockReason reason = opp::types::EMISSIONS_BLOCK_REASON_UNSPECIFIED; }; -emissions_gate_result check_emissions_ready(uint32_t epoch_duration_sec, uint32_t target_epoch) { +emissions_gate_result check_emissions_ready(uint32_t epoch_duration_sec, + uint32_t operators_per_epoch, + uint32_t target_epoch) { emissions_gate_result r; sysiosystem::emissions::emitcfg_t emit_cfg_tbl(SYSTEM_ACCOUNT); @@ -124,12 +126,20 @@ emissions_gate_result check_emissions_ready(uint32_t epoch_duration_sec, uint32_ return r; } - // Decide whether this advance fires payepoch. Pay fires when the target - // epoch is `pay_cadence_epochs - 1` past `period_start_epoch`. Genesis - // case: t5s.period_start_epoch = 0, so the first period covers epochs - // 1..(pay_cadence - 1) (one short, since epoch 0 is genesis). Subsequent - // periods are exactly pay_cadence_epochs long. - r.is_pay_epoch = (target_epoch >= t5s.period_start_epoch + cfg.pay_cadence_epochs - 1); + // Decide whether this advance fires payepoch. New writes are bounded by + // setemitcfg, but pre-bound deployments can retain a larger stored cadence. + // Clamp it here so a legacy value cannot make rcrdbatch retain an unbounded + // roster history or defer the corrective payout forever. A zero value is + // likewise treated as the minimum safe cadence for old serialized state. + const uint16_t effective_pay_cadence_epochs = + sysiosystem::emissions::effective_pay_cadence_epochs( + cfg.pay_cadence_epochs, operators_per_epoch); + + // Pay fires when the target epoch is `effective_pay_cadence_epochs - 1` + // past `period_start_epoch`. Genesis case: t5s.period_start_epoch = 0, so + // the first period covers epochs 1..(pay_cadence - 1) (one short, since + // epoch 0 is genesis). Subsequent periods are exactly cadence epochs long. + r.is_pay_epoch = (target_epoch >= t5s.period_start_epoch + effective_pay_cadence_epochs - 1); // Pay-epoch only: check the period total (pending + this epoch's share) // against sysio's balance. Non-pay epochs do not transfer, so no balance @@ -272,11 +282,28 @@ void epoch::setconfig(uint32_t epoch_duration_sec, { sysiosystem::emissions::emitcfg_t emit_cfg_tbl(SYSTEM_ACCOUNT); if (emit_cfg_tbl.exists()) { + auto effective_emit_cfg = emit_cfg_tbl.get(); + const uint16_t stored_pay_cadence = effective_emit_cfg.pay_cadence_epochs; + const bool legacy_stored_cadence = + stored_pay_cadence < 1 + || stored_pay_cadence > sysiosystem::emissions::MAX_PAY_CADENCE_EPOCHS; + // Current configurations must satisfy the joint setter invariant: + // changing the roster cannot silently shorten a valid configured pay + // period. Only a pre-bound stored cadence outside today's accepted + // range uses the same runtime recovery clamp as advance(). + effective_emit_cfg.pay_cadence_epochs = legacy_stored_cadence + ? sysiosystem::emissions::effective_pay_cadence_epochs( + stored_pay_cadence, operators_per_epoch) + : stored_pay_cadence; + check(sysiosystem::emissions::batch_payout_work_fits( + effective_emit_cfg.pay_cadence_epochs, operators_per_epoch), + "stored sysio.system pay_cadence_epochs x operators_per_epoch " + "exceeds the batch payout credit safety cap (100)"); sysiosystem::emissions::t5state_t t5s_tbl(SYSTEM_ACCOUNT); const int64_t pending = t5s_tbl.exists() ? t5s_tbl.get().pending_emission_amount : 0; check(sysiosystem::emissions::period_accrual_fits_asset_range( - emit_cfg_tbl.get(), epoch_duration_sec, pending), + effective_emit_cfg, epoch_duration_sec, pending), "per-epoch emission ceiling x pay_cadence_epochs exceeds the asset range at this epoch_duration_sec"); } } @@ -402,7 +429,8 @@ void epoch::advance() { ).send(); } - const auto gate = check_emissions_ready(cfg.epoch_duration_sec, target_epoch); + const auto gate = check_emissions_ready( + cfg.epoch_duration_sec, cfg.operators_per_epoch, target_epoch); if (!gate.ready) { // OPP silent-return diagnostic: the epoch silently does NOT advance when the // emissions gate is not ready. Also recorded to blocklog, but a console @@ -851,15 +879,22 @@ void epoch::advance() { } } - // Emissions side. Two inline actions queued in FIFO order: + // Emissions side. Three inline actions queued in FIFO order: // 1. accrueepoch: always queued. Records this epoch's per-epoch share // onto t5state (pending_emission_amount + batch_group_epochs[group] // + last_epoch_emission for decay continuity). - // 2. payepoch: queued only on pay-epochs. Reads the now-updated t5state + // 2. rcrdbatch: always queued. Records the immutable roster that accrued + // this epoch after the schedule has slid for the next advance. + // 3. payepoch: queued only on pay-epochs. Reads the now-updated t5state // (which already includes this epoch's contribution from step 1), // distributes period_emission, and resets the accumulator. // Both run after advance() returns; their FIFO ordering guarantees - // payepoch sees the post-accrue state. + // payepoch sees the post-accrue roster history and state. + std::vector active_batch_op_members; + if (state.current_batch_op_group < state.batch_op_groups.size()) { + active_batch_op_members = state.batch_op_groups[state.current_batch_op_group]; + } + action( permission_level{get_self(), "owner"_n}, SYSTEM_ACCOUNT, @@ -871,6 +906,13 @@ void epoch::advance() { ) ).send(); + action( + permission_level{get_self(), "owner"_n}, + SYSTEM_ACCOUNT, + "rcrdbatch"_n, + std::make_tuple(state.current_epoch_index, active_batch_op_members) + ).send(); + if (gate.is_pay_epoch) { action( permission_level{get_self(), "owner"_n}, @@ -878,7 +920,7 @@ void epoch::advance() { "payepoch"_n, std::make_tuple( state.current_epoch_index, - state.batch_op_groups, + std::vector>{}, gate.period_emission ) ).send(); diff --git a/contracts/sysio.epoch/sysio.epoch.wasm b/contracts/sysio.epoch/sysio.epoch.wasm index b6a25dca52..7c6429cbf4 100755 Binary files a/contracts/sysio.epoch/sysio.epoch.wasm and b/contracts/sysio.epoch/sysio.epoch.wasm differ diff --git a/contracts/sysio.system/EMISSIONS.md b/contracts/sysio.system/EMISSIONS.md index 28c43d358e..0e955b71d9 100644 --- a/contracts/sysio.system/EMISSIONS.md +++ b/contracts/sysio.system/EMISSIONS.md @@ -11,9 +11,11 @@ through a claim action. All amounts are `WIRE` (9-decimal subunits). - Every epoch calls `accrueepoch`, which adds that epoch's curve share (`compute_epoch_emission`) to `t5state.pending_emission_amount`. +- It then calls `rcrdbatch`, which records the immutable batch-operator + roster that accrued that epoch. - On a pay-epoch boundary (`emitcfg.pay_cadence_epochs`) it then calls - `payepoch`, which distributes the accumulated `period_emission` by the - configured basis-point splits. + `payepoch`, which distributes the accumulated `period_emission`, consumes + the recorded rosters, and clears that period's history. The curve decays from `annual_initial_emission` toward `annual_min_emission` (clamped by `annual_max_emission`), stops at `t5_floor`, and auto-throttles as @@ -69,35 +71,74 @@ same exception `fundclaim` makes for `sysio.dclaim`. Producer pay is weight-scaled: active producers carry a flat weight and are additionally scaled by their eligible rounds over the pay period, while standbys carry a rank-decreasing weight and are not round-scaled. Batch-op pay is weighted -per group by that group's active-epoch count over the pay period, then split -evenly across all of the group's scheduled members (the per-member slice is the -group pool divided by the full group size). A credit is made only for members -that are opreg-ACTIVE, so the slices of skipped (inactive / slashed / terminated) -members stay in the treasury rather than being redistributed to the active -ones. Swap-fee rewards from `sysio.reserv`'s `rewards_bucket` are swept in -(`drainrewards`) and allocated **exclusively to the batch-operator +by the roster captured for each accrued epoch, then split evenly across all +members of each historical roster (the per-member slice is the group pool divided +by the full group size). Equal rosters are coalesced before their weighted slice +is calculated, preserving one group-level rounding step per roster. A credit is +made only for members that are opreg-ACTIVE, so the slices of skipped (inactive / +slashed / terminated) members stay in the treasury rather than being redistributed +to the active ones. When roster history is complete, swap-fee rewards from +`sysio.reserv`'s `rewards_bucket` are swept in (`drainrewards`) and allocated +**exclusively to the batch-operator distribution**, on top of their emission share and weighted by that same -per-group active-epoch count. Producers are not paid out of swap fees, so +historical active-epoch count. Producers are not paid out of swap fees, so `producer_bps` / `batch_op_bps` govern the emission split only. -Allocated is not the same as paid: as with emissions, only **eligible** shares -are actually credited. WIRE stays in the treasury when there are **no groups -at all**, when an **empty group owns positive active epochs** (its weighted slice -is skipped), when a **member is not opreg-ACTIVE**, or as the **remainder** of the -two integer divisions (per-group weighting, then the even per-member split). A -group active in **zero** epochs is *not* one of these cases — its weighted -allocation is already zero, and because the per-group counts sum to the **actual -accrued-epoch divisor** the remaining groups absorb the whole pool. +Allocated is not the same as paid: only **eligible** shares are actually +credited. Emission WIRE stays in the treasury when an **empty historical +roster** owns an accrued epoch, when a **member is not opreg-ACTIVE**, as the +**remainder** of the two integer divisions (per-roster weighting, then the even +per-member split), or when roster history is incomplete and the whole batch +emission slice takes the bounded recovery path. Incomplete history leaves swap +fees in `sysio.reserv` as described below. Every `batchepochs` row represents +one accrued epoch, so a zero-epoch historical roster cannot arise. -That divisor is the sum of the per-group counters, **not** the configured +That divisor is the sum of `t5state.batch_group_epochs`, **not** the configured `pay_cadence_epochs`. The two can differ — a mid-period `setemitcfg` cadence change, or the shortened genesis period — and normalizing by the configured value -is what caused a payout to be multiplied. Deriving it from the counters is what -makes the weights partition each pool by construction. - -`epochlog.fee_distributed` records what was actually paid, so it can be lower -than the swept amount — and is `0` when no eligible batch operator existed at all, -even though `drainrewards` swept the bucket to zero regardless. +is what caused a payout to be multiplied. The positional counters establish the +actual period length only; `batchepochs` supplies roster identity, so a schedule +rotation cannot reassign an earlier epoch to the current front group. + +### Roster-history bounds and recovery + +`setemitcfg` accepts a payment cadence only in `[1, 10]`, which bounds an +ordinary payment period to at most ten immutable roster snapshots. It also +validates `pay_cadence_epochs * operators_per_epoch <= 100`, preserving the +prior one-roster ceiling on expensive per-recipient credits; `sysio.epoch` +enforces the same constraint when its operator count changes. At the intended +production topology of 21 operators per epoch, the maximum accepted cadence is +4 (about 24 minutes at six-minute epochs); the conservative 100-credit ceiling +limits peak work in the mandatory `advance` transaction. When +`sysio.epoch` reads an older serialized configuration during an upgrade, it +effectively shortens the cadence to satisfy both bounds; the stored value is not +rewritten until a normal `setemitcfg` update. `payepoch` independently refuses +to issue more than 100 recipient credits summed across distinct historical +rosters and takes the non-halting recovery path if malformed legacy history +exceeds that limit. + +The `rcrdbatch` inline action also prunes the exact oldest retained row before +writing a new one. `payepoch` caps cleanup at 20 rows per payment, twice the +largest accepted period, so even a malformed gapped table cannot turn recovery +into an unbounded transaction; stale history drains monotonically. +If `payepoch` sees missing, stale, non-contiguous, or over-cap history, it does +not guess a roster or abort the enclosing `advance`: it retains that period's +batch-emission slice in the treasury, leaves the swap-fee bucket in +`sysio.reserv` for the next complete period, drains the unusable history within +the cleanup bound, and begins clean history after stale rows are gone. A complete +history remains required to credit batch-operator rewards. Every `epochlog` row +records whether history was complete and the exact batch-emission amount +retained, making recovery distinguishable from an ordinary zero-eligible payout. + +`epoch_log_retention_count` counts payment rows, not elapsed epoch indexes, so +cadence values greater than one retain the configured number of audit records. + +`epochlog.fee_distributed` records what was actually paid, while +`batch_fee_retained` records a complete-history sweep amount left in treasury +because an empty roster, inactive member, or division remainder prevented its +distribution. It is zero for incomplete history because those fees remain in +`sysio.reserv` for a later period. The analogous `batch_emission_retained` field +records undistributed batch emission. ## Retrieved via a claim action (pulled by recipient) @@ -173,6 +214,7 @@ period_emission | `claimnodedis` | node owner | Claim vested node-owner allocation (refused when it would spend pay reserved in `payclaimtot`) | | `claimpay` | the claiming account | Claim epoch pay credited by `payepoch` (producer / standby / batch-operator share) | | `accrueepoch` | `sysio.epoch` | Accrue this epoch's curve share | +| `rcrdbatch` | `sysio.epoch` | Record the batch-operator roster for this accrued epoch | | `payepoch` | `sysio.epoch` | Distribute the period's compute / capex / governance (credits `payclaims`; pushes only the category buckets) | | `fundclaim` | `sysio.dclaim` | Lazy capital drain into dclaim (never-throw) | | `viewnodedist` | read-only | Preview a node owner's claimable amount | @@ -190,11 +232,12 @@ period_emission | `payclaims` | Table | Per-account epoch pay credited by `payepoch` and not yet claimed | | `payclaimtot` | Singleton | Running total of outstanding `payclaims`, reserved by every gate that spends the treasury balance | | `t5state` | Singleton | Treasury state: pending emission, total distributed, decay continuity | +| `batchepochs` | Table | Up to ten immutable batch-operator rosters for unprocessed accrued epochs; cleared by `payepoch` | | `epochlog` | Table | Per-pay-epoch audit log (head-pruned to `epoch_log_retention_count`) | ## Dependencies -- Driven inline by `sysio.epoch::advance` (`accrueepoch` + `payepoch`). +- Driven inline by `sysio.epoch::advance` (`accrueepoch` + `rcrdbatch` + `payepoch`). - Reads producer eligibility and operator status from `sysio.opreg`. - Reads the canonical epoch duration from `sysio.epoch::epochcfg`. - Folds swap-fee rewards from `sysio.reserv` (`drainrewards`). diff --git a/contracts/sysio.system/include/sysio.system/emissions.hpp b/contracts/sysio.system/include/sysio.system/emissions.hpp index 2d25a76b67..814af124e2 100644 --- a/contracts/sysio.system/include/sysio.system/emissions.hpp +++ b/contracts/sysio.system/include/sysio.system/emissions.hpp @@ -34,6 +34,45 @@ inline constexpr uint32_t T1_MAX_NODE_OWNERS = 21; inline constexpr uint32_t T2_MAX_NODE_OWNERS = 84; inline constexpr uint32_t T3_MAX_NODE_OWNERS = 1000; +// Each recorded roster can contain at most sysio.epoch's 100 scheduled +// operators. Keeping at most ten epochs of history bounds roster retention and +// coalescing work. The joint cadence x roster-size guard below separately keeps +// the expensive per-recipient payout work at the prior one-roster ceiling. At +// the intended production topology of 21 operators per epoch, this permits a +// maximum cadence of 4 (about 24 minutes at six-minute epochs). Raising the +// credit cap requires separate evidence that a larger mandatory advance fits +// the transaction CPU/KV budget. +inline constexpr uint16_t MAX_PAY_CADENCE_EPOCHS = 10; +inline constexpr uint32_t MAX_BATCH_PAYOUT_CREDITS = 100; +// payepoch removes at most this many roster-history rows per payment. Normal +// operation adds no more than MAX_PAY_CADENCE_EPOCHS rows, so a malformed or +// legacy overlong table drains monotonically without an unbounded erase loop. +inline constexpr uint32_t MAX_BATCH_HISTORY_CLEANUP_ROWS = + MAX_PAY_CADENCE_EPOCHS * 2; + +inline uint16_t history_bounded_pay_cadence_epochs(uint16_t configured_cadence) { + return std::clamp(configured_cadence, 1, MAX_PAY_CADENCE_EPOCHS); +} + +inline bool batch_payout_work_fits(uint16_t pay_cadence_epochs, + uint32_t operators_per_epoch) { + return static_cast(pay_cadence_epochs) * operators_per_epoch + <= MAX_BATCH_PAYOUT_CREDITS; +} + +// Existing chains can carry a configuration written before the joint payout +// bound existed. Shorten that effective period at the advance gate rather than +// risking an over-budget mandatory inline payepoch. The runtime history check +// remains defense-in-depth for malformed rosters and legacy operator counts. +inline uint16_t effective_pay_cadence_epochs(uint16_t configured_cadence, + uint32_t operators_per_epoch) { + const uint16_t history_bounded = history_bounded_pay_cadence_epochs(configured_cadence); + const uint32_t safe_operators = std::max(operators_per_epoch, 1); + const uint32_t work_bounded = std::max( + MAX_BATCH_PAYOUT_CREDITS / safe_operators, 1); + return std::min(history_bounded, static_cast(work_bounded)); +} + // --------------------------------------------------------------------------- // Emission configuration (set via setemitcfg action) // --------------------------------------------------------------------------- @@ -81,17 +120,18 @@ struct [[sysio::table("emitcfg"), sysio::contract("sysio.system")]] emission_con uint32_t standby_end_rank; // last standby rank (default 28) // Audit-log retention. Caps the unbounded `epochlog` table at this many - // rows; payepoch prunes head-first after each insert. One row per epoch - // (~64 bytes), so 8640 ~= 30 days at 6-min epoch cadence. + // rows; payepoch prunes head-first after each insert. There is one row per + // payment period (one per epoch when pay_cadence_epochs == 1). uint32_t epoch_log_retention_count; // How many epochs accumulate before `payepoch` actually fires. 1 = pay - // every epoch (matches the original emissions behavior). Recommended - // production value: 100, which at a 6-min epoch is ~10h between pays - // and at a 1-min epoch is ~1h40m. Higher values reduce the per-tick - // inline-transfer count proportionally; the period-aggregate emission - // and per-recipient share-by-rounds math stay equivalent to summing - // the per-epoch results. Must be > 0; setemitcfg rejects zero. + // every epoch (matches the original emissions behavior). The roster history + // retained for a period is explicitly bounded at 10 epochs, while cadence x + // operators_per_epoch is bounded separately to at most 100 recipient credits + // per payout. At 21 operators this makes 4 the maximum production cadence. + // The period aggregate and per-recipient share-by-rounds math + // remain equivalent to summing per-epoch results. Must be in + // [1, MAX_PAY_CADENCE_EPOCHS]. uint16_t pay_cadence_epochs; SYSLIB_SERIALIZE(emission_config, @@ -277,11 +317,11 @@ struct [[sysio::table("t5state"), sysio::contract("sysio.system")]] t5_state { // legacy per-epoch behavior is unchanged. int64_t pending_emission_amount = 0; uint32_t period_start_epoch = 0; - // Per-batch-op-group active-epoch counter, indexed by group number. - // accrueepoch increments batch_group_epochs[current_batch_op_group] - // each non-pay epoch; payepoch divides batch_pool proportionally - // and clears the vector. Sized lazily to current_batch_op_group+1 - // on first use; pre-pay-cadence chains see length 0. + // Per-batch-op-schedule-position active-epoch counter. This remains the + // authoritative count of accrued epochs; immutable roster snapshots live + // in batchepochs_t so schedule rotation cannot reassign prior epochs. + // Sized lazily to current_batch_op_group+1 on first use; pre-pay-cadence + // chains see length 0. std::vector batch_group_epochs; // Cumulative shortfall (WIRE subunits) between requested and actually- @@ -299,6 +339,23 @@ struct [[sysio::table("t5state"), sysio::contract("sysio.system")]] t5_state { using t5state_t = sysio::kv::global<"t5state"_n, t5_state>; +// One immutable batch-operator roster for each accrued epoch in the pending +// payment period. payepoch consumes and clears this history atomically. +struct batch_epoch_key { + uint32_t sysio_epoch_index; + + SYSLIB_SERIALIZE(batch_epoch_key, (sysio_epoch_index)) +}; + +struct [[sysio::table("batchepochs"), sysio::contract("sysio.system")]] batch_epoch { + uint32_t sysio_epoch_index; + std::vector members; + + SYSLIB_SERIALIZE(batch_epoch, (sysio_epoch_index)(members)) +}; + +using batchepochs_t = sysio::kv::table<"batchepochs"_n, batch_epoch_key, batch_epoch>; + // --------------------------------------------------------------------------- // Per-epoch derivations from annual config + canonical epoch_duration_sec. // Both the gate (sysio.epoch::check_emissions_ready) and the success path @@ -436,10 +493,19 @@ struct [[sysio::table("epochlog"), sysio::contract("sysio.system")]] epoch_log { // fee, which accrues in sysio.reserv and is claimed there — it never reaches // this treasury and so never appears in this log. int64_t fee_distributed = 0; + // Durable attribution for WIRE left in the treasury by the batch payout. + // Retained emission includes incomplete-history recovery; retained swept + // fees arise only from empty rosters, inactive members, or integer-division + // remainders because incomplete-history fees remain in sysio.reserv. + // history_complete distinguishes recovery from eligibility shortfalls. + bool batch_history_complete = false; + int64_t batch_emission_retained = 0; + int64_t batch_fee_retained = 0; SYSLIB_SERIALIZE(epoch_log, (sysio_epoch_index)(epoch_count)(timestamp)(total_emission) - (compute_amount)(capex_amount)(governance_amount)(fee_distributed)) + (compute_amount)(capex_amount)(governance_amount)(fee_distributed) + (batch_history_complete)(batch_emission_retained)(batch_fee_retained)) }; using epochlog_t = sysio::kv::table<"epochlog"_n, epochlog_key, epoch_log>; diff --git a/contracts/sysio.system/include/sysio.system/sysio.system.hpp b/contracts/sysio.system/include/sysio.system/sysio.system.hpp index 9ec8020832..78a8677d6d 100644 --- a/contracts/sysio.system/include/sysio.system/sysio.system.hpp +++ b/contracts/sysio.system/include/sysio.system/sysio.system.hpp @@ -625,16 +625,15 @@ namespace sysiosystem { * distributes it across producer / batch / capital / capex / gov * pools as today, scaled to the period. * - * `batch_op_groups` is the full state.batch_op_groups vector from - * sysio.epoch; payepoch reads t5state.batch_group_epochs to weight - * the batch pool proportionally to each group's active-epoch count - * over the period, normalized by the ACTUAL accrued-epoch count (the - * sum of those counters) rather than the configured - * pay_cadence_epochs, which a mid-period setemitcfg change or the - * shortened genesis period can make disagree. Groups active in zero - * epochs are skipped, which happens whenever the accrued count is - * smaller than batch_op_groups.size(); skipping costs them nothing, - * since a zero count already weights their allocation to zero. + * `batch_op_groups` is retained as an ABI-compatible reserved field; + * payout uses only the immutable `batchepochs` roster history written + * for every accrued epoch. t5state.batch_group_epochs supplies the + * ACTUAL accrued-epoch count, rather than configured + * pay_cadence_epochs, because a mid-period config change or shortened + * genesis period can make those differ. Incomplete history retains the + * batch-emission slice in treasury, leaves swap fees in sysio.reserv + * for a later complete period, and is reset for the next period; it + * must never halt sysio.epoch::advance. * * Runtime conditions (config missing, treasury exhausted, balance * insufficient) are caught upstream by the gate, which records the @@ -655,7 +654,8 @@ namespace sysiosystem { * * Increments t5state.pending_emission_amount by `per_epoch_emission` * and bumps t5state.batch_group_epochs[batch_group_index] by 1, so - * the next payepoch sees the period total + per-group counts. + * the next payepoch sees the period total. `rcrdbatch` records the + * corresponding immutable roster separately. * * Because it also runs on the pay epoch, the counter sum that * `payepoch` normalizes by includes the epoch being paid. Reading this @@ -670,6 +670,15 @@ namespace sysiosystem { uint8_t batch_group_index, int64_t per_epoch_emission); + /** + * Record the immutable batch-operator roster for an accrued epoch. + * Empty rosters are retained so their emission share stays in the + * treasury. Called inline by sysio.epoch::advance immediately after + * accrueepoch. Auth: require_auth("sysio.epoch"). + */ + [[sysio::action]] + void rcrdbatch(uint32_t epoch_index, std::vector members); + /** * Fund a sysio.dclaim capital draw against the T5 drainable pool. * Called inline by sysio.dclaim::onreward as each STAKING_REWARD diff --git a/contracts/sysio.system/src/emissions.cpp b/contracts/sysio.system/src/emissions.cpp index 49449cdc29..674e28e5f9 100644 --- a/contracts/sysio.system/src/emissions.cpp +++ b/contracts/sysio.system/src/emissions.cpp @@ -323,17 +323,23 @@ void system_contract::setemitcfg(const emissions::emission_config& cfg) { "epoch_log_retention_count must be positive"); // Pay cadence (number of epochs accumulated per payepoch firing). Zero - // would divide-by-zero in the period share-by-rounds math; no upper - // bound is enforced (operator's call). + // would divide-by-zero in the period share-by-rounds math. This upper bound + // caps retained history; the joint check after epochcfg is loaded separately + // caps expensive per-recipient payout work. sysio::check(cfg.pay_cadence_epochs > 0, "pay_cadence_epochs must be positive"); + sysio::check(cfg.pay_cadence_epochs <= emissions::MAX_PAY_CADENCE_EPOCHS, + "pay_cadence_epochs exceeds batch roster history safety cap"); // Single read of sysio.epoch::epochcfg shared by the round-to-zero guards // (which need epoch_secs to scale annual values) and the post-init guard // (which compares per-epoch floor against remaining distributable). sysio::epoch::epochcfg_t epoch_cfg_tbl(epoch_refs::account); const bool epoch_configured = epoch_cfg_tbl.exists(); - const uint32_t epoch_secs = epoch_configured ? epoch_cfg_tbl.get().epoch_duration_sec : 0; + const auto epoch_cfg = epoch_configured + ? epoch_cfg_tbl.get() + : sysio::epoch::epoch_config{}; + const uint32_t epoch_secs = epoch_cfg.epoch_duration_sec; // Single read of t5_state shared by the period-accrual bound (which needs // the already-accrued pending amount) and the post-init brick guards below. @@ -348,6 +354,11 @@ void system_contract::setemitcfg(const emissions::emission_config& cfg) { // and emissions silently disable. Skipped pre-bootstrap (sysio.epoch not // yet configured); the same check fires on the next setemitcfg call. if (epoch_configured) { + sysio::check( + emissions::batch_payout_work_fits(cfg.pay_cadence_epochs, + epoch_cfg.operators_per_epoch), + "pay_cadence_epochs x operators_per_epoch exceeds the batch payout credit safety cap (100)"); + if (cfg.annual_initial_emission > 0) { sysio::check(emissions::scale_annual_to_epoch(cfg.annual_initial_emission, epoch_secs) > 0, "annual_initial_emission per-epoch share rounds to 0 at current epoch_duration_sec"); @@ -653,6 +664,44 @@ void system_contract::accrueepoch(uint32_t epoch_index, t5s.set(state, get_self()); } +// rcrdbatch - retain the exact roster that accrued this epoch. The schedule +// mutates before advance queues its inline actions, so a current position is +// not a stable identity for an earlier epoch. +void system_contract::rcrdbatch(uint32_t epoch_index, std::vector members) { + require_auth(epoch_refs::account); + + t5state_t t5s(get_self()); + sysio::check(t5s.exists(), "t5 state not initialized"); + const auto state = t5s.get(); + sysio::check(epoch_index == state.last_epoch_index, + "rcrdbatch must run after accrueepoch for the same epoch_index"); + + // The scheduler supplies canonical order today. Sorting here keeps the + // table identity stable even if a future scheduler changes that detail. + std::sort(members.begin(), members.end()); + + batchepochs_t history(get_self()); + const batch_epoch_key key{epoch_index}; + sysio::check(!history.contains(key), "batch roster already recorded for epoch"); + + // Never let an old stored cadence make this mandatory inline action throw. + // Normal advances pay at most every MAX_PAY_CADENCE_EPOCHS, but the exact + // oldest key probe also heals a pre-bound configuration without + // deserializing every historical roster on each advance. + if (epoch_index > emissions::MAX_PAY_CADENCE_EPOCHS) { + const batch_epoch_key oldest_retained{ + static_cast(epoch_index - emissions::MAX_PAY_CADENCE_EPOCHS)}; + if (history.contains(oldest_retained)) { + history.erase(oldest_retained); + } + } + + history.emplace(get_self(), key, batch_epoch{ + .sysio_epoch_index = epoch_index, + .members = members, + }); +} + // payepoch - pay the compute, capex, and governance shares of accumulated // emissions for the pay period ending at `epoch_index`. Called inline by // sysio.epoch::advance on a pay-epoch (period boundary defined by @@ -668,18 +717,18 @@ void system_contract::accrueepoch(uint32_t epoch_index, // dclaim has funds the moment a claim is credited rather than at the next // pay-epoch. // -// Swap-fee rewards: the batch-operator share of collected swap fees -// (sysio.reserv's rewards_bucket) is swept here via an inline drainrewards and -// allocated EXCLUSIVELY to the batch-operator distribution, on top of their -// emission share and weighted by the same per-group active-epoch count. +// Swap-fee rewards: when immutable roster history is complete, the batch-operator +// share of collected swap fees (sysio.reserv's rewards_bucket) is swept here via +// an inline drainrewards and allocated EXCLUSIVELY to the batch-operator +// distribution, on top of their emission share and weighted by the same +// historical-roster active-epoch count. Incomplete history leaves the bucket in +// sysio.reserv for a later complete period. // Producers are NOT paid out of swap fees, so producer_bps / batch_op_bps govern // the emission split only -- see the fold-in comment at the drain. Allocated is -// not paid: only ELIGIBLE shares go out, and whatever is skipped stays in this -// treasury, exactly as undistributed emission does. What is actually skipped is -// listed at the batch-op loop below -- note a zero-epoch group is NOT one of -// them, since its weighted allocation is zero to begin with. -// Fees are funded by the sweep -// (not the treasury) and so are excluded from total_distributed. +// not paid: only ELIGIBLE shares go out, and any skipped amount from a completed +// sweep stays in this treasury. The audit row records retained batch emission, +// retained swept fees, and whether roster history was complete. Fees are funded +// by the sweep (not the treasury) and so are excluded from total_distributed. // // Single-trx semantics guarantee gate conditions hold through this call; // payepoch trusts the gate-computed period_emission and does not recompute. @@ -689,7 +738,7 @@ void system_contract::accrueepoch(uint32_t epoch_index, // Slashed / terminated batch-op group members are skipped via opreg filter; // their slice remains in the treasury. void system_contract::payepoch(uint32_t epoch_index, - std::vector> batch_op_groups, + std::vector>, int64_t period_emission) { require_auth(epoch_refs::account); @@ -734,7 +783,7 @@ void system_contract::payepoch(uint32_t epoch_index, // normalizations correct whatever the config did mid-period. // // Sum in int64: each counter is a uint32 epoch tally and the vector is sized - // from batch_op_groups, so the total cannot approach the int64 range. Zero is + // from a scheduler-bounded group list, so the total cannot approach the int64 range. Zero is // impossible in practice (payepoch asserts accrueepoch ran for this same // epoch_index, and accrueepoch always increments a slot) but is guarded at // each use, because a zero divisor would abort the whole advance chain. @@ -743,6 +792,68 @@ void system_contract::payepoch(uint32_t epoch_index, accrued_epochs += group_epoch_count; } + // Preserve roster identity separately from the legacy positional counters. + // advance() slides its schedule before queueing this action, so a counter at + // position g cannot identify the roster that was active in a prior epoch. + struct recorded_batch_group { + std::vector members; + uint32_t active_epochs = 0; + }; + + batchepochs_t batch_history(get_self()); + std::vector recorded_batch_groups; + bool batch_history_complete = accrued_epochs > 0; + int64_t recorded_epochs = 0; + uint32_t batch_payout_credits = 0; + uint64_t expected_epoch_index = state.period_start_epoch; + + for (auto it = batch_history.begin(); it != batch_history.end(); ++it) { + // A stale/corrupted table must not make the mandatory payepoch inline + // action abort. Bound deserialization to the configured safety window, + // retain the batch slice, and clear the table below so the next period + // starts from a fresh immutable roster history. + if (recorded_epochs == emissions::MAX_PAY_CADENCE_EPOCHS) { + batch_history_complete = false; + break; + } + ++recorded_epochs; + + // A clean activation may initialize T5 after sysio.epoch has already + // advanced. In that first period, the earliest recorded roster defines + // the start rather than an obsolete literal epoch-one assumption. + if (expected_epoch_index == 0) { + expected_epoch_index = it->sysio_epoch_index; + } + if (static_cast(it->sysio_epoch_index) != expected_epoch_index) { + batch_history_complete = false; + } + ++expected_epoch_index; + + auto group_it = std::find_if( + recorded_batch_groups.begin(), recorded_batch_groups.end(), + [&](const auto& group) { return group.members == it->members; }); + if (group_it == recorded_batch_groups.end()) { + const uint64_t credits_with_group = + static_cast(batch_payout_credits) + it->members.size(); + if (credits_with_group > emissions::MAX_BATCH_PAYOUT_CREDITS) { + batch_history_complete = false; + } else { + batch_payout_credits = static_cast(credits_with_group); + recorded_batch_groups.push_back(recorded_batch_group{ + .members = it->members, + .active_epochs = 1, + }); + } + } else { + group_it->active_epochs += 1; + } + } + + batch_history_complete = + batch_history_complete + && recorded_epochs == accrued_epochs + && expected_epoch_index == static_cast(epoch_index) + 1; + // ----- Swap-fee rewards fold-in ----- // The BATCH-OPERATOR half of collected swap fees accrues in sysio.reserv's // rewards_bucket. The other half accrues per-underwriter in sysio.reserv and @@ -761,35 +872,37 @@ void system_contract::payepoch(uint32_t epoch_index, // `compute_amount` split only, and the entire drained fee pool goes to the // batch-op distribution below. // - // The fee WIRE lives in sysio.reserv's custody, so it must be swept here - // before the payouts below can spend it. drainrewards is queued FIRST (ahead - // of every payout transfer): inline actions execute depth-first, so the drain - // -- and the reserv->sysio transfer it queues -- run to completion before any - // sibling payout queued after it, landing the WIRE in this account's balance + // The fee WIRE lives in sysio.reserv's custody. Sweep it only when immutable + // roster history is complete; otherwise leave the bucket in reserv so a later + // complete period can distribute it. When swept, drainrewards is queued FIRST + // (ahead of every payout transfer): inline actions execute depth-first, so the + // drain -- and the reserv->sysio transfer it queues -- run to completion before + // any sibling payout queued after it, landing the WIRE in this account's balance // first. MUST remain ahead of the first send_wire_transfer below. // // Fees are funded by that transfer, NOT the T5 treasury, so fee payouts are // tracked in `fee_paid` and excluded from total_distributed (which governs - // the emission curve). Any fee not distributed stays in this treasury, exactly - // as undistributed emission does — see the batch-op loop for what is actually - // retained (an EMPTY group holding positive epochs, non-ACTIVE members, the - // two integer divisions' remainders, or no groups at all). A group active in - // zero epochs retains NOTHING: its weighted allocation is already zero. - const int64_t fee_total = get_reserv_rewards_balance(); - if (fee_total > 0) { - sysio::action( - {get_self(), "active"_n}, - RESERV_CONTRACT, - "drainrewards"_n, - std::make_tuple(fee_total) - ).send(); + // the emission curve). After a complete-history sweep, any amount skipped for + // an empty roster, non-ACTIVE members, or integer-division remainders stays in + // this treasury. Incomplete history leaves the entire bucket in reserv. + int64_t fee_batch_pool = 0; + if (batch_history_complete) { + fee_batch_pool = get_reserv_rewards_balance(); + if (fee_batch_pool > 0) { + sysio::action( + {get_self(), "active"_n}, + RESERV_CONTRACT, + "drainrewards"_n, + std::make_tuple(fee_batch_pool) + ).send(); + } } - const int64_t fee_batch_pool = fee_total; // "paid" here means DISTRIBUTED -- credited to `payclaims` for producers / standbys / // batch operators, transferred for the category buckets. Both leave the treasury's // spendable position, which is what these counters feed. int64_t actual_paid = 0; // emission actually distributed (counts toward total_distributed) + int64_t batch_emission_paid = 0; int64_t fee_paid = 0; // swap-fee rewards actually distributed (does NOT count toward treasury) // ======================================================================= @@ -924,60 +1037,67 @@ void system_contract::payepoch(uint32_t epoch_index, } // ======================================================================= - // Batch-op pay. With pay_cadence_epochs > 1 the active group can rotate - // multiple times across a period, so each group's slice is weighted by - // its active-epoch count (state.batch_group_epochs[g]) over the period. - // - // The divisor is the ACTUAL accrued epoch count -- the sum of those counters - // -- NOT cfg.pay_cadence_epochs. The two can disagree: accrueepoch increments - // one slot per epoch unconditionally, while setemitcfg may change - // pay_cadence_epochs at any time, taking effect on the next advance. Lowering - // cadence 3->1 after one accrual leaves the counters summing to 2 against a - // divisor of 1, which pays 2x batch_pool AND 2x fee_batch_pool -- the surplus - // fee drawn from this treasury even though only one fee pool was swept from - // sysio.reserv, and invisible to total_distributed because fee payouts are - // excluded from it. A shortened genesis period underpays by the inverse. - // Summing the counters makes the per-group weights partition the pool by - // construction, whatever the config did mid-period. - // - // A group active in zero epochs is skipped, but that retains NOTHING: its - // weighted allocation is `pool * 0 / accrued_epochs` == 0, and since the - // counters sum to that divisor the remaining groups already absorb the whole - // pool. What ACTUALLY leaves WIRE behind in the treasury is: - // * no groups at all (the enclosing `if` fails) — the entire pool; - // * an EMPTY group that owns POSITIVE epochs — skipped by the `group.empty()` - // test BEFORE the epoch check, so its weighted slice is never paid; - // * a member not registered ACTIVE in sysio.opreg (slashed / terminated / - // unknown) — that member's per-member slice; - // * the remainders of the two integer divisions below (per-group weighting - // and the even per-member split). + // Batch-op pay. Each historical roster receives a slice weighted by its + // actual active epochs over the period. The legacy counters still supply the + // actual period length, rather than cfg.pay_cadence_epochs: configuration can + // change between accruals. Complete immutable history is required for a + // batch payout. History is bounded by MAX_PAY_CADENCE_EPOCHS and recipient + // credits by MAX_BATCH_PAYOUT_CREDITS. Incomplete or over-budget history + // takes the non-halting retention path below, so it cannot abort advance. // ======================================================================= - if (accrued_epochs > 0 && !batch_op_groups.empty()) { - for (size_t g = 0; g < batch_op_groups.size(); ++g) { - const auto& group = batch_op_groups[g]; - if (group.empty()) continue; - const uint32_t group_epochs = - (g < state.batch_group_epochs.size()) ? state.batch_group_epochs[g] : 0; - if (group_epochs == 0) continue; - - // Period-weighted slices for this group, divided evenly among members. - // Emission and fee are weighted identically (by the group's active-epoch - // count over the period) so a member's fee tracks its emission reward. - const int64_t members = static_cast(group.size()); - const int64_t group_pool = static_cast( - static_cast<__int128>(batch_pool) * group_epochs / accrued_epochs); - const int64_t fee_group_pool = static_cast( - static_cast<__int128>(fee_batch_pool) * group_epochs / accrued_epochs); - const int64_t per_member = group_pool / members; - const int64_t fee_per_member = fee_group_pool / members; - - for (const auto& m : group) { - if (!is_op_active(m, OperatorType::OPERATOR_TYPE_BATCH)) continue; - // One credit carries both the emission and the fee share. - credit_pay(get_self(), m, per_member + fee_per_member, memo::batch_op_reward); - actual_paid += per_member; - fee_paid += fee_per_member; - } + auto pay_batch_group = [&](const std::vector& group, + uint32_t active_epochs) { + if (group.empty() || active_epochs == 0) return; + + // Period-weighted slices for this group, divided evenly among members. + // Emission and fee are weighted identically by active-epoch count. + const int64_t members = static_cast(group.size()); + const int64_t group_pool = static_cast( + static_cast<__int128>(batch_pool) * active_epochs / accrued_epochs); + const int64_t fee_group_pool = static_cast( + static_cast<__int128>(fee_batch_pool) * active_epochs / accrued_epochs); + const int64_t per_member = group_pool / members; + const int64_t fee_per_member = fee_group_pool / members; + + for (const auto& m : group) { + if (!is_op_active(m, OperatorType::OPERATOR_TYPE_BATCH)) continue; + // One credit carries both the emission and the fee share. + credit_pay(get_self(), m, per_member + fee_per_member, memo::batch_op_reward); + actual_paid += per_member; + batch_emission_paid += per_member; + fee_paid += fee_per_member; + } + }; + + if (batch_history_complete) { + for (const auto& group : recorded_batch_groups) { + pay_batch_group(group.members, group.active_epochs); + } + } else if (accrued_epochs > 0) { + // Do not guess a roster during a mixed-version upgrade or an incomplete + // first period: retain its batch emission in the treasury, leave swap fees + // in sysio.reserv, and let the next period establish complete history. + sysio::print("batch roster history incomplete; retaining batch emission and deferring swap fees\n"); + } + + const int64_t batch_emission_retained = batch_pool - batch_emission_paid; + const int64_t batch_fee_retained = fee_batch_pool - fee_paid; + + // A pay period is the history lifetime. Clear only after an actual accrued + // period: on the defensive zero-accrual path the history is preserved rather + // than silently discarding roster identity without a batch payout. Keep the + // cleanup bounded so an overlong legacy/corrupt table cannot exhaust the + // mandatory epoch-advance transaction. Since normal operation adds at most + // MAX_PAY_CADENCE_EPOCHS rows per period and this removes twice that many, + // stale history drains monotonically while rewards remain on the audited + // incomplete-history recovery path. + if (accrued_epochs > 0) { + uint32_t cleaned = 0; + for (auto it = batch_history.begin(); + it != batch_history.end() + && cleaned < emissions::MAX_BATCH_HISTORY_CLEANUP_ROWS; + ++cleaned) { + it = batch_history.erase(it); } } @@ -1046,11 +1166,15 @@ void system_contract::payepoch(uint32_t epoch_index, .capex_amount = capex_amount, .governance_amount = governance_amount, .fee_distributed = fee_paid, + .batch_history_complete = batch_history_complete, + .batch_emission_retained = batch_emission_retained, + .batch_fee_retained = batch_fee_retained, }); // Head-first prune of the audit log past its retention cap. Rows are added - // monotonically (one per successful payepoch) so live_count is computed in - // O(1) from id arithmetic. Drop up to two oldest rows per call: only one + // monotonically (one per successful payepoch), and epoch_count is the + // contiguous payment-row sequence even when pay cadence is greater than + // one. Drop up to two oldest rows per call: only one // is needed in steady state, but a recent retention-cap shrink (governance // lowering epoch_log_retention_count from N to a smaller M) leaves the // table over cap by N - M; pruning two per call drains it twice as fast @@ -1058,9 +1182,8 @@ void system_contract::payepoch(uint32_t epoch_index, for (int i = 0; i < 2; ++i) { auto first_it = epoch_table.begin(); if (first_it == epoch_table.end()) break; - const uint64_t oldest_index = first_it.key().sysio_epoch_index; const uint64_t live_count = - (static_cast(epoch_index) + 1) - oldest_index; + state.epoch_count - first_it->epoch_count + 1; if (live_count <= cfg.epoch_log_retention_count) break; epoch_table.erase(first_it); } diff --git a/contracts/sysio.system/sysio.system.abi b/contracts/sysio.system/sysio.system.abi index 7e1b73cc76..a6251a63a8 100644 --- a/contracts/sysio.system/sysio.system.abi +++ b/contracts/sysio.system/sysio.system.abi @@ -118,6 +118,30 @@ } ] }, + { + "name": "batch_epoch", + "base": "", + "fields": [ + { + "name": "sysio_epoch_index", + "type": "uint32" + }, + { + "name": "members", + "type": "name[]" + } + ] + }, + { + "name": "batch_epoch_key", + "base": "", + "fields": [ + { + "name": "sysio_epoch_index", + "type": "uint32" + } + ] + }, { "name": "block_header", "base": "", @@ -515,6 +539,18 @@ { "name": "fee_distributed", "type": "int64" + }, + { + "name": "batch_history_complete", + "type": "bool" + }, + { + "name": "batch_emission_retained", + "type": "int64" + }, + { + "name": "batch_fee_retained", + "type": "int64" } ] }, @@ -1040,6 +1076,20 @@ } ] }, + { + "name": "rcrdbatch", + "base": "", + "fields": [ + { + "name": "epoch_index", + "type": "uint32" + }, + { + "name": "members", + "type": "name[]" + } + ] + }, { "name": "regfinkey", "base": "", @@ -1903,6 +1953,11 @@ "type": "payepoch", "ricardian_contract": "" }, + { + "name": "rcrdbatch", + "type": "rcrdbatch", + "ricardian_contract": "" + }, { "name": "regfinkey", "type": "regfinkey", @@ -2078,6 +2133,14 @@ "key_types": ["uint64"], "table_id": 49446 }, + { + "name": "batchepochs", + "type": "batch_epoch", + "index_type": "i64", + "key_names": ["sysio_epoch_index"], + "key_types": ["uint32"], + "table_id": 22503 + }, { "name": "blockinfo", "type": "block_info_record", diff --git a/contracts/sysio.system/sysio.system.wasm b/contracts/sysio.system/sysio.system.wasm index a04e79d84a..1fcfd37adb 100755 Binary files a/contracts/sysio.system/sysio.system.wasm and b/contracts/sysio.system/sysio.system.wasm differ diff --git a/contracts/tests/emissions_tests.cpp b/contracts/tests/emissions_tests.cpp index 011e2d7117..1fdfb26517 100644 --- a/contracts/tests/emissions_tests.cpp +++ b/contracts/tests/emissions_tests.cpp @@ -507,6 +507,58 @@ class sysio_emissions_tester : public tester { produce_blocks(1); } + /// Current balance of sysio.reserv's batch-operator rewards bucket. + /// + /// Requires deploy_reserv(). A missing bucket is reported as zero. + int64_t reserv_reward_balance() { + const account_name RESERV = "sysio.reserv"_n; + auto data = get_row_by_account(RESERV, RESERV, "rewardbkt"_n, "rewardbkt"_n); + if (data.empty()) return 0; + + const auto* meta = control->find_account_metadata(RESERV); + BOOST_REQUIRE(meta != nullptr); + abi_def def; + BOOST_REQUIRE_EQUAL(abi_serializer::to_abi(meta->abi, def), true); + abi_serializer reserv_ser; + reserv_ser.set_abi(def, abi_serializer::create_yield_function(abi_serializer_max_time)); + auto bucket = reserv_ser.binary_to_variant( + "rewards_bucket", data, + abi_serializer::create_yield_function(abi_serializer_max_time)); + return static_cast(bucket["balance"].as_uint64()); + } + + /// Deploy sysio.reserv and seed its batch-operator rewards bucket with a + /// real bootstrap-window swap fee, returning the exact accrued balance. + int64_t seed_reserv_reward_bucket() { + const account_name RESERV = "sysio.reserv"_n; + const account_name UWRIT = "sysio.uwrit"_n; + deploy_reserv(); + + auto codename = [](std::string_view value) { + return mvo()("value", fc::slug_name{value}.value); + }; + BOOST_REQUIRE_EQUAL(success(), push_reserv_action(RESERV, "regreserve"_n, mvo() + ("chain_code", codename("ETH"))("token_code", codename("ETH"))("reserve_code", codename("PRIMARY")) + ("name", "eth")("description", "") + ("initial_chain_amount", 1'000'000'000'000ULL)("initial_wire_amount", 1'000'000'000'000ULL) + ("source_token_precision", 9u)("connector_weight_bps", 5000u)("is_private", false)("owner", name{}))); + BOOST_REQUIRE_EQUAL(success(), push_reserv_action(RESERV, "regreserve"_n, mvo() + ("chain_code", codename("SOLANA"))("token_code", codename("SOL"))("reserve_code", codename("PRIMARY")) + ("name", "sol")("description", "") + ("initial_chain_amount", 1'000'000'000'000ULL)("initial_wire_amount", 1'000'000'000'000ULL) + ("source_token_precision", 9u)("connector_weight_bps", 5000u)("is_private", false)("owner", name{}))); + BOOST_REQUIRE_EQUAL(success(), push_reserv_action(UWRIT, "applyswap"_n, mvo() + ("src_chain_code", codename("ETH"))("src_token_code", codename("ETH"))("src_reserve_code", codename("PRIMARY")) + ("src_amount", 1'000'000'000ULL) + ("dst_chain_code", codename("SOLANA"))("dst_token_code", codename("SOL")) + ("dst_reserve_code", codename("PRIMARY")) + ("dst_amount", 100'000'000ULL)("underwriter", name{}))); + + const int64_t balance = reserv_reward_balance(); + BOOST_REQUIRE_GT(balance, 0); + return balance; + } + /// `sysio.reserv::wireclaims` balance owed to `acc`, or 0 when there is no row. /// Requires deploy_reserv(). Credited by paywire / refundwire, drained by claimwire, and /// reclaimed to the treasury by the retention sweep sysio.epoch::advance inlines. @@ -546,7 +598,9 @@ class sysio_emissions_tester : public tester { /// The default emission config payload, split out so a caller that must push it through a /// different transport (see push_system_action_no_block) does not restate twenty fields. - fc::variant_object default_emit_cfg( uint16_t cadence ) { + fc::variant_object default_emit_cfg( uint16_t cadence, + int64_t annual_max_emission = ANNUAL_MAX_EMISSION, + uint32_t epoch_log_retention_count = 8640 ) { return mvo() ("t1_allocation", T1_ALLOCATION.get_amount()) ("t2_allocation", T2_ALLOCATION.get_amount()) @@ -559,7 +613,7 @@ class sysio_emissions_tester : public tester { ("t5_floor", 125'000'000'000'000'000LL) ("target_annual_decay_bps", TARGET_ANNUAL_DECAY_BPS) ("annual_initial_emission", ANNUAL_INITIAL_EMISSION) - ("annual_max_emission", ANNUAL_MAX_EMISSION) + ("annual_max_emission", annual_max_emission) ("annual_min_emission", ANNUAL_MIN_EMISSION) ("compute_bps", COMPUTE_BPS) ("capex_bps", CAPEX_BPS) @@ -567,10 +621,32 @@ class sysio_emissions_tester : public tester { ("producer_bps", PRODUCER_BPS) ("batch_op_bps", uint16_t(3000)) ("standby_end_rank", T_STANDBY_END_RANK) - ("epoch_log_retention_count", uint32_t(8640)) + ("epoch_log_retention_count", epoch_log_retention_count) ("pay_cadence_epochs", cadence); } + /// Simulate an emission config written before the cadence bounds existed. + /// This deliberately bypasses the current action validation and is used + /// only to exercise the mixed-version recovery paths in sysio.epoch. + void set_legacy_emitcfg_cadence_raw( uint16_t cadence ) { + const auto bytes = sysio_abi_ser.variant_to_binary( + "emission_config", default_emit_cfg(cadence), + abi_serializer::create_yield_function(abi_serializer_max_time)); + + char key_buf[chain::kv_pri_key_size]; + chain::kv_encode_be64(key_buf, "emitcfg"_n.to_uint64_t()); + auto& db = const_cast(control->db()); + const auto& kv_idx = db.get_index(); + auto it = kv_idx.find(boost::make_tuple( + config::system_account_name, + chain::compute_table_id("emitcfg"_n.to_uint64_t()), + std::string_view(key_buf, chain::kv_pri_key_size))); + BOOST_REQUIRE(it != kv_idx.end()); + db.modify(*it, [&](auto& row) { + row.value.assign(bytes.data(), bytes.size()); + }); + } + action_result setinittime( account_name signer, time_point_sec start ) { return push_system_action( signer, @@ -730,6 +806,28 @@ class sysio_emissions_tester : public tester { abi_serializer::create_yield_function(abi_serializer_max_time)); } + fc::variant get_batch_epoch( uint64_t sysio_epoch_index ) { + // batch_epoch_key stores uint32_t in order-preserving big-endian form; + // get_row_by_id is specialized for the common 8-byte integer key. + const uint32_t index = static_cast(sysio_epoch_index); + const char key_buf[4] = { + static_cast(index >> 24), + static_cast(index >> 16), + static_cast(index >> 8), + static_cast(index), + }; + const auto& kv_idx = control->db().get_index(); + auto it = kv_idx.find(boost::make_tuple( + config::system_account_name, + chain::compute_table_id("batchepochs"_n.to_uint64_t()), + std::string_view(key_buf, sizeof(key_buf)))); + if (it == kv_idx.end()) return fc::variant(); + vector data(it->value.begin(), it->value.end()); + if (data.empty()) return fc::variant(); + return sysio_abi_ser.binary_to_variant("batch_epoch", data, + abi_serializer::create_yield_function(abi_serializer_max_time)); + } + // ----------------------------- // Producer info reader // ----------------------------- @@ -2443,37 +2541,287 @@ BOOST_FIXTURE_TEST_CASE( accrueepoch_saturates_pending_accumulator, sysio_emissi get_t5_state()["pending_emission_amount"].as() ); } FC_LOG_AND_RETHROW() +BOOST_FIXTURE_TEST_CASE( payepoch_recovers_from_incomplete_batch_roster_history, sysio_emissions_tester ) try { + // A mixed contract version can reach payepoch without any immutable roster + // snapshots. That must retain and durably attribute the batch slice rather + // than aborting the inline sysio.epoch::advance path chain-wide. + constexpr int64_t period_emission = 10'000; + create_t5_holding_accounts(); + const int64_t fee_total = seed_reserv_reward_bucket(); + BOOST_REQUIRE_EQUAL(success(), setemitcfg_defaults(config::system_account_name)); + BOOST_REQUIRE_EQUAL(success(), initt5(config::system_account_name, tpsec(head_secs()))); + BOOST_REQUIRE_EQUAL(success(), + push_system_action(EPOCH, "accrueepoch"_n, mvo() + ("epoch_index", 1)("batch_group_index", 0)("per_epoch_emission", period_emission))); + + auto r = push_system_action(EPOCH, "payepoch"_n, mvo() + ("epoch_index", 1) + ("batch_op_groups", vector>{}) + ("period_emission", period_emission)); + BOOST_REQUIRE_EQUAL(success(), r); + + // The period completes and establishes a clean next-period boundary. + auto state = get_t5_state(); + BOOST_REQUIRE_EQUAL(int64_t(0), state["pending_emission_amount"].as()); + BOOST_REQUIRE_EQUAL(uint32_t(2), state["period_start_epoch"].as()); + + const int64_t compute = test_split_bps(period_emission, COMPUTE_BPS); + const int64_t producer_pool = test_split_bps(compute, PRODUCER_BPS); + auto log = get_epoch_log(1); + BOOST_REQUIRE(!log["batch_history_complete"].as_bool()); + BOOST_REQUIRE_EQUAL(compute - producer_pool, + log["batch_emission_retained"].as()); + BOOST_REQUIRE_EQUAL(int64_t(0), log["fee_distributed"].as()); + BOOST_REQUIRE_EQUAL(int64_t(0), log["batch_fee_retained"].as()); + BOOST_REQUIRE_EQUAL(fee_total, reserv_reward_balance()); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE( payepoch_seeds_initial_roster_history_at_activation_epoch, sysio_emissions_tester ) try { + // T5 may be initialized after sysio.epoch has already advanced. The first + // retained roster, not literal epoch one, defines that initial period. + create_t5_holding_accounts(); + BOOST_REQUIRE_EQUAL(success(), setemitcfg_defaults(config::system_account_name)); + BOOST_REQUIRE_EQUAL(success(), initt5(config::system_account_name, tpsec(head_secs()))); + BOOST_REQUIRE_EQUAL(success(), + push_system_action(EPOCH, "accrueepoch"_n, mvo() + ("epoch_index", 42)("batch_group_index", 0)("per_epoch_emission", int64_t(1)))); + BOOST_REQUIRE_EQUAL(success(), + push_system_action(EPOCH, "rcrdbatch"_n, mvo() + ("epoch_index", 42)("members", vector{}))); + + BOOST_REQUIRE_EQUAL(success(), push_system_action(EPOCH, "payepoch"_n, mvo() + ("epoch_index", 42) + ("batch_op_groups", vector>{}) + ("period_emission", int64_t(1)))); + + BOOST_REQUIRE_EQUAL(uint32_t(43), get_t5_state()["period_start_epoch"].as()); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE( payepoch_recovers_after_legacy_roster_history_exceeds_cap, sysio_emissions_tester ) try { + // A legacy cadence above the new cap can leave more accrued epochs than + // retained rosters. The eleventh rcrdbatch must prune its exact oldest row + // rather than halting advance; payepoch then takes the retention/recovery + // path and the following clean period pays normally. + constexpr uint32_t roster_history_cap = 10; + create_t5_holding_accounts(); + BOOST_REQUIRE_EQUAL(success(), setemitcfg_defaults(config::system_account_name)); + BOOST_REQUIRE_EQUAL(success(), initt5(config::system_account_name, tpsec(head_secs()))); + + for (uint32_t epoch_index = 1; epoch_index <= roster_history_cap + 1; ++epoch_index) { + BOOST_REQUIRE_EQUAL(success(), push_system_action(EPOCH, "accrueepoch"_n, mvo() + ("epoch_index", epoch_index)("batch_group_index", 0)("per_epoch_emission", int64_t(1)))); + BOOST_REQUIRE_EQUAL(success(), push_system_action(EPOCH, "rcrdbatch"_n, mvo() + ("epoch_index", epoch_index)("members", vector{}))); + } + + // Reaching this point proves the cap boundary did not reject the mandatory + // eleventh record. Its missing first snapshot takes the recovery path. + BOOST_REQUIRE_EQUAL(success(), push_system_action(EPOCH, "payepoch"_n, mvo() + ("epoch_index", roster_history_cap + 1) + ("batch_op_groups", vector>{}) + ("period_emission", int64_t(roster_history_cap + 1)))); + BOOST_REQUIRE_EQUAL(uint32_t(roster_history_cap + 2), + get_t5_state()["period_start_epoch"].as()); + + // The failed-completeness period cleared its history; the next complete + // period is processed normally rather than inheriting stale rows. + const uint32_t recovery_epoch = roster_history_cap + 2; + BOOST_REQUIRE_EQUAL(success(), push_system_action(EPOCH, "accrueepoch"_n, mvo() + ("epoch_index", recovery_epoch)("batch_group_index", 0)("per_epoch_emission", int64_t(1)))); + BOOST_REQUIRE_EQUAL(success(), push_system_action(EPOCH, "rcrdbatch"_n, mvo() + ("epoch_index", recovery_epoch)("members", vector{}))); + BOOST_REQUIRE_EQUAL(success(), push_system_action(EPOCH, "payepoch"_n, mvo() + ("epoch_index", recovery_epoch) + ("batch_op_groups", vector>{}) + ("period_emission", int64_t(1)))); + BOOST_REQUIRE_EQUAL(uint32_t(recovery_epoch + 1), + get_t5_state()["period_start_epoch"].as()); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE( payepoch_bounded_cleanup_drains_overlong_roster_history, + sysio_emissions_tester ) try { + // Gapped legacy/corrupt keys bypass rcrdbatch's exact oldest-key healing and + // can leave more than the normal ten rows. payepoch must bound each cleanup + // transaction, retain rewards while stale rows remain, and drain the table + // monotonically instead of attempting an unbounded erase loop. + constexpr uint32_t overlong_rows = 25; + constexpr uint32_t cleanup_rows = 20; + create_t5_holding_accounts(); + BOOST_REQUIRE_EQUAL(success(), setemitcfg_defaults(config::system_account_name)); + BOOST_REQUIRE_EQUAL(success(), initt5(config::system_account_name, tpsec(head_secs()))); + + for (uint32_t i = 1; i <= overlong_rows; ++i) { + const uint32_t epoch_index = i * 100; + BOOST_REQUIRE_EQUAL(success(), push_system_action(EPOCH, "accrueepoch"_n, mvo() + ("epoch_index", epoch_index)("batch_group_index", 0)("per_epoch_emission", int64_t(1)))); + BOOST_REQUIRE_EQUAL(success(), push_system_action(EPOCH, "rcrdbatch"_n, mvo() + ("epoch_index", epoch_index)("members", vector{}))); + } + + BOOST_REQUIRE_EQUAL(success(), push_system_action(EPOCH, "payepoch"_n, mvo() + ("epoch_index", overlong_rows * 100) + ("batch_op_groups", vector>{}) + ("period_emission", int64_t(overlong_rows)))); + + for (uint32_t i = 1; i <= cleanup_rows; ++i) { + BOOST_REQUIRE(get_batch_epoch(i * 100).is_null()); + } + for (uint32_t i = cleanup_rows + 1; i <= overlong_rows; ++i) { + BOOST_REQUIRE(!get_batch_epoch(i * 100).is_null()); + } + + // One more period removes the five stale rows plus its current row, still + // within the bound. The following contiguous period then has clean history. + const uint32_t recovery_epoch = overlong_rows * 100 + 1; + BOOST_REQUIRE_EQUAL(success(), push_system_action(EPOCH, "accrueepoch"_n, mvo() + ("epoch_index", recovery_epoch)("batch_group_index", 0)("per_epoch_emission", int64_t(1)))); + BOOST_REQUIRE_EQUAL(success(), push_system_action(EPOCH, "rcrdbatch"_n, mvo() + ("epoch_index", recovery_epoch)("members", vector{}))); + BOOST_REQUIRE_EQUAL(success(), push_system_action(EPOCH, "payepoch"_n, mvo() + ("epoch_index", recovery_epoch) + ("batch_op_groups", vector>{}) + ("period_emission", int64_t(1)))); + BOOST_REQUIRE(get_batch_epoch(overlong_rows * 100).is_null()); + BOOST_REQUIRE(get_batch_epoch(recovery_epoch).is_null()); + + const uint32_t clean_epoch = recovery_epoch + 1; + BOOST_REQUIRE_EQUAL(success(), push_system_action(EPOCH, "accrueepoch"_n, mvo() + ("epoch_index", clean_epoch)("batch_group_index", 0)("per_epoch_emission", int64_t(1)))); + BOOST_REQUIRE_EQUAL(success(), push_system_action(EPOCH, "rcrdbatch"_n, mvo() + ("epoch_index", clean_epoch)("members", vector{}))); + BOOST_REQUIRE_EQUAL(success(), push_system_action(EPOCH, "payepoch"_n, mvo() + ("epoch_index", clean_epoch) + ("batch_op_groups", vector>{}) + ("period_emission", int64_t(1)))); + BOOST_REQUIRE(get_epoch_log(clean_epoch)["batch_history_complete"].as_bool()); +} FC_LOG_AND_RETHROW() + BOOST_FIXTURE_TEST_CASE( setemitcfg_bounds_period_accrual_to_asset_range, sysio_emissions_tester ) try { // The pending accumulator saturates at asset::max_amount (previous test), and // a saturated accumulator leaves the pay-epoch readiness gate demanding a // balance no account can hold -- permanently blocking epoch advancement. So // setemitcfg must reject any config whose worst-case pay-period accumulation // (per-epoch emission ceiling * pay_cadence_epochs) could reach the clamp. - // - // At a 30-day epoch the fixture's ANNUAL_MAX_EMISSION scales to exactly - // 9.0e16 per epoch (3.0e15 per day * 30 days), and the asset ceiling - // (2^62 - 1 ~= 4.61e18) divides to 51.24 epochs of headroom: cadence 51 - // fits, cadence 52 must be rejected. + // A deliberately high annual ceiling scales to ~4.93e17 at a 30-day epoch: + // cadence 9 fits the asset range, whereas cadence 10 must be rejected. + constexpr int64_t high_annual_max = 6'000'000'000'000'000'000LL; BOOST_REQUIRE_EQUAL( success(), init_epoch_state(2'592'000) ); // 30-day epochs BOOST_REQUIRE_EQUAL( success(), - setemitcfg_with_cadence( config::system_account_name, uint16_t(51) ) ); + setemitcfg(config::system_account_name, default_emit_cfg(uint16_t(9), high_annual_max)) ); - auto r = setemitcfg_with_cadence( config::system_account_name, uint16_t(52) ); + auto r = setemitcfg(config::system_account_name, default_emit_cfg(uint16_t(10), high_annual_max)); BOOST_REQUIRE( r != success() ); require_substr( r, "per-epoch emission ceiling x pay_cadence_epochs exceeds the asset range" ); } FC_LOG_AND_RETHROW() +BOOST_FIXTURE_TEST_CASE( setemitcfg_caps_batch_roster_history, sysio_emissions_tester ) try { + // The independent history cap keeps payepoch at no more than ten immutable + // snapshots; the joint credit-work cap is covered separately below. + BOOST_REQUIRE_EQUAL( success(), + setemitcfg_with_cadence(config::system_account_name, uint16_t(10)) ); + + auto r = setemitcfg_with_cadence(config::system_account_name, uint16_t(11)); + BOOST_REQUIRE( r != success() ); + require_substr(r, "pay_cadence_epochs exceeds batch roster history safety cap"); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE( emission_config_boundaries_cap_batch_payout_credit_work, + sysio_emissions_tester ) try { + // setemitcfg owns cadence changes. With the epoch roster at its maximum of + // 100, cadence two would permit 200 distinct recipient credits and must be + // rejected at that boundary. + BOOST_REQUIRE_EQUAL(success(), setemitcfg_defaults(config::system_account_name)); + BOOST_REQUIRE_EQUAL(success(), init_epoch_state(60, /*operators_per_epoch*/100, + /*batch_op_groups_count*/3)); + auto r = setemitcfg_with_cadence(config::system_account_name, uint16_t(2)); + BOOST_REQUIRE(r != success()); + require_substr(r, "pay_cadence_epochs x operators_per_epoch exceeds the batch payout credit safety cap (100)"); + + // sysio.epoch::setconfig owns roster-size changes and enforces the same + // bound. Ten epochs at the normal seven-member roster fit; raising the + // roster to eleven would permit 110 credits and is rejected. + BOOST_REQUIRE_EQUAL(success(), init_epoch_state(60, /*operators_per_epoch*/7, + /*batch_op_groups_count*/3)); + BOOST_REQUIRE_EQUAL(success(), + setemitcfg_with_cadence(config::system_account_name, uint16_t(10))); + r = init_epoch_state(60, /*operators_per_epoch*/11, /*batch_op_groups_count*/3); + BOOST_REQUIRE(r != success()); + require_substr(r, "pay_cadence_epochs x operators_per_epoch exceeds the batch payout credit safety cap (100)"); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE( epoch_setconfig_names_prebootstrap_stored_cadence_in_work_bound_error, + sysio_emissions_tester ) try { + // setemitcfg cannot validate the joint bound before sysio.epoch has a + // configuration. The first epoch setconfig must reject an incompatible + // roster size and point operators back to the already-stored system cadence. + BOOST_REQUIRE_EQUAL(success(), + setemitcfg_with_cadence(config::system_account_name, uint16_t(10))); + + auto r = init_epoch_state(60, /*operators_per_epoch*/11, + /*batch_op_groups_count*/3); + BOOST_REQUIRE(r != success()); + require_substr( + r, + "stored sysio.system pay_cadence_epochs x operators_per_epoch exceeds the batch payout credit safety cap (100)"); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE( epoch_setconfig_recovers_legacy_cadence_with_runtime_work_bound, + sysio_emissions_tester ) try { + // A pre-bound cadence above today's history cap is shortened at runtime. + // setconfig must validate that same effective value, while the preceding + // boundary test continues to reject an unsafe roster change for a current, + // otherwise-valid stored cadence. + set_legacy_emitcfg_cadence_raw(uint16_t(1000)); + BOOST_REQUIRE_EQUAL(success(), init_epoch_state( + 60, /*operators_per_epoch*/11, /*batch_op_groups_count*/3)); +} FC_LOG_AND_RETHROW() + +BOOST_FIXTURE_TEST_CASE( payepoch_recovers_when_legacy_rosters_exceed_credit_budget, + sysio_emissions_tester ) try { + // Runtime defense-in-depth must keep malformed/legacy history from issuing + // more than 100 expensive credit_pay calls even if it bypassed both current + // configuration setters. + constexpr int64_t per_epoch_emission = 10'000; + vector first_roster; + vector second_roster; + for (uint64_t value = 1; value <= 51; ++value) { + first_roster.emplace_back(value); + second_roster.emplace_back(value + 100); + } + + create_t5_holding_accounts(); + BOOST_REQUIRE_EQUAL(success(), setemitcfg_defaults(config::system_account_name)); + BOOST_REQUIRE_EQUAL(success(), initt5(config::system_account_name, tpsec(head_secs()))); + for (uint32_t epoch_index = 1; epoch_index <= 2; ++epoch_index) { + BOOST_REQUIRE_EQUAL(success(), push_system_action(EPOCH, "accrueepoch"_n, mvo() + ("epoch_index", epoch_index) + ("batch_group_index", 0) + ("per_epoch_emission", per_epoch_emission))); + BOOST_REQUIRE_EQUAL(success(), push_system_action(EPOCH, "rcrdbatch"_n, mvo() + ("epoch_index", epoch_index) + ("members", epoch_index == 1 ? first_roster : second_roster))); + } + + BOOST_REQUIRE_EQUAL(success(), push_system_action(EPOCH, "payepoch"_n, mvo() + ("epoch_index", 2) + ("batch_op_groups", vector>{}) + ("period_emission", per_epoch_emission * 2))); + + auto log = get_epoch_log(2); + BOOST_REQUIRE(!log["batch_history_complete"].as_bool()); + BOOST_REQUIRE_GT(log["batch_emission_retained"].as(), 0); +} FC_LOG_AND_RETHROW() + BOOST_FIXTURE_TEST_CASE( epoch_setconfig_rejects_duration_breaking_accrual_bound, sysio_emissions_tester ) try { // Mirror of the previous test from the other config boundary: // scale_annual_to_epoch is linear in epoch_duration_sec, so a duration raise // can invalidate the period-accrual bound setemitcfg validated at the old - // duration. A maximal cadence accepted at 60s epochs (ceiling ~2.08e12 * - // 65535 ~= 1.4e17) must block a raise to 30-day epochs (9.0e16 * 65535 - // ~= 5.9e21, far past 2^62), while a modest raise to 600s epochs - // (2.08e13 * 65535 ~= 1.4e18) still fits. + // duration. With a high annual ceiling and maximum bounded cadence, 60s + // epochs fit, a raise to 30-day epochs must block, and 600s still fits. + constexpr int64_t high_annual_max = 6'000'000'000'000'000'000LL; BOOST_REQUIRE_EQUAL( success(), - setemitcfg_with_cadence( config::system_account_name, uint16_t(65535) ) ); + setemitcfg(config::system_account_name, default_emit_cfg(uint16_t(10), high_annual_max)) ); auto r = init_epoch_state(2'592'000); BOOST_REQUIRE( r != success() ); @@ -3572,56 +3920,8 @@ BOOST_FIXTURE_TEST_CASE( single_active_producer_full_active_share, sysio_emissio // more, the bucket is swept to 0 regardless, and the fee is NOT counted against // the emission treasury. BOOST_FIXTURE_TEST_CASE( payepoch_folds_swap_fee_rewards, sysio_emissions_tester ) try { - const account_name RESERV = "sysio.reserv"_n; - const account_name UWRIT = "sysio.uwrit"_n; - create_t5_holding_accounts(); - - // Deploy sysio.reserv (the single extra real contract this test stands up). - deploy_reserv(); - - // Local ABI serializer + slug_name helper for reserv reads/writes. - abi_serializer reserv_ser; - { - const auto* a = control->find_account_metadata( RESERV ); - BOOST_REQUIRE( a != nullptr ); - abi_def d; - BOOST_REQUIRE_EQUAL( abi_serializer::to_abi(a->abi, d), true ); - reserv_ser.set_abi( d, abi_serializer::create_yield_function(abi_serializer_max_time) ); - } - auto codename = [](std::string_view s) { return mvo()("value", fc::slug_name{s}.value); }; - auto reward_balance = [&]() -> int64_t { - auto data = get_row_by_account(RESERV, RESERV, "rewardbkt"_n, "rewardbkt"_n); - if (data.empty()) return 0; - auto v = reserv_ser.binary_to_variant("rewards_bucket", data, - abi_serializer::create_yield_function(abi_serializer_max_time)); - return static_cast(v["balance"].as_uint64()); - }; - - // --- Seed the rewards bucket via a real swap (still in the bootstrap window, - // current_epoch_index == 0, so regreserve is permitted) --- - BOOST_REQUIRE_EQUAL( success(), push_reserv_action(RESERV, "regreserve"_n, mvo() - ("chain_code", codename("ETH"))("token_code", codename("ETH"))("reserve_code", codename("PRIMARY")) - ("name", "eth")("description", "") - ("initial_chain_amount", 1'000'000'000'000ULL)("initial_wire_amount", 1'000'000'000'000ULL) - ("source_token_precision", 9u)("connector_weight_bps", 5000u)("is_private", false)("owner", name{}) ) ); - BOOST_REQUIRE_EQUAL( success(), push_reserv_action(RESERV, "regreserve"_n, mvo() - ("chain_code", codename("SOLANA"))("token_code", codename("SOL"))("reserve_code", codename("PRIMARY")) - ("name", "sol")("description", "") - ("initial_chain_amount", 1'000'000'000'000ULL)("initial_wire_amount", 1'000'000'000'000ULL) - ("source_token_precision", 9u)("connector_weight_bps", 5000u)("is_private", false)("owner", name{}) ) ); - // No winning underwriter on this settlement (`underwriter` unset): the whole - // fee falls through to the rewards bucket, which is the quantity payepoch - // drains. The underwriter half's own accrual + claim is covered by - // sysio.reserv_tests; this test is about the drain and who it reaches. - BOOST_REQUIRE_EQUAL( success(), push_reserv_action(UWRIT, "applyswap"_n, mvo() - ("src_chain_code", codename("ETH"))("src_token_code", codename("ETH"))("src_reserve_code", codename("PRIMARY")) - ("src_amount", 1'000'000'000ULL) - ("dst_chain_code", codename("SOLANA"))("dst_token_code", codename("SOL"))("dst_reserve_code", codename("PRIMARY")) - ("dst_amount", 100'000'000ULL)("underwriter", name{}) ) ); - - const int64_t fee_total = reward_balance(); - BOOST_REQUIRE_GT( fee_total, 0 ); + const int64_t fee_total = seed_reserv_reward_bucket(); // --- Single full-round producer; advance to the cadence-1 pay-epoch --- setup_producers(1); @@ -3645,6 +3945,7 @@ BOOST_FIXTURE_TEST_CASE( payepoch_folds_swap_fee_rewards, sysio_emissions_tester const int64_t capex = log["capex_amount"].as(); const int64_t gov = log["governance_amount"].as(); const int64_t producer_pool = test_split_bps(compute, PRODUCER_BPS); + const int64_t batch_pool = compute - producer_pool; // The producer received its emission share and NOTHING MORE. Swap fees pay // the parties that carry an individual swap — the winning underwriter and the @@ -3659,10 +3960,13 @@ BOOST_FIXTURE_TEST_CASE( payepoch_folds_swap_fee_rewards, sysio_emissions_tester // operator actually receiving the fee is // `payepoch_pays_swap_fee_to_active_batch_operator` below. BOOST_REQUIRE_EQUAL( log["fee_distributed"].as(), 0 ); + BOOST_REQUIRE(log["batch_history_complete"].as_bool()); + BOOST_REQUIRE_EQUAL(log["batch_emission_retained"].as(), batch_pool); + BOOST_REQUIRE_EQUAL(log["batch_fee_retained"].as(), fee_total); // The bucket was still swept to zero by the inline drain — the drain is // unconditional on there being a recipient, and it must not overdraw. - BOOST_REQUIRE_EQUAL( reward_balance(), 0 ); + BOOST_REQUIRE_EQUAL( reserv_reward_balance(), 0 ); // total_distributed counts emission only (producer_pool + capex + gov, with // the empty batch group's share staying in treasury) -- the fee is NOT @@ -3684,51 +3988,10 @@ BOOST_FIXTURE_TEST_CASE( payepoch_folds_swap_fee_rewards, sysio_emissions_tester // batch pool and the entire fee pool. Asserts the recipient's balance delta and // the exact positive `epochlog.fee_distributed`. BOOST_FIXTURE_TEST_CASE( payepoch_pays_swap_fee_to_active_batch_operator, sysio_emissions_tester ) try { - const account_name RESERV = "sysio.reserv"_n; - const account_name UWRIT = "sysio.uwrit"_n; const account_name BATCH_OP = "batchopa"_n; create_t5_holding_accounts(); - deploy_reserv(); - - abi_serializer reserv_ser; - { - const auto* a = control->find_account_metadata( RESERV ); - BOOST_REQUIRE( a != nullptr ); - abi_def d; - BOOST_REQUIRE_EQUAL( abi_serializer::to_abi(a->abi, d), true ); - reserv_ser.set_abi( d, abi_serializer::create_yield_function(abi_serializer_max_time) ); - } - auto codename = [](std::string_view s) { return mvo()("value", fc::slug_name{s}.value); }; - auto reward_balance = [&]() -> int64_t { - auto data = get_row_by_account(RESERV, RESERV, "rewardbkt"_n, "rewardbkt"_n); - if (data.empty()) return 0; - auto v = reserv_ser.binary_to_variant("rewards_bucket", data, - abi_serializer::create_yield_function(abi_serializer_max_time)); - return static_cast(v["balance"].as_uint64()); - }; - - // --- Seed the rewards bucket with a real swap fee (bootstrap window) --- - BOOST_REQUIRE_EQUAL( success(), push_reserv_action(RESERV, "regreserve"_n, mvo() - ("chain_code", codename("ETH"))("token_code", codename("ETH"))("reserve_code", codename("PRIMARY")) - ("name", "eth")("description", "") - ("initial_chain_amount", 1'000'000'000'000ULL)("initial_wire_amount", 1'000'000'000'000ULL) - ("source_token_precision", 9u)("connector_weight_bps", 5000u)("is_private", false)("owner", name{}) ) ); - BOOST_REQUIRE_EQUAL( success(), push_reserv_action(RESERV, "regreserve"_n, mvo() - ("chain_code", codename("SOLANA"))("token_code", codename("SOL"))("reserve_code", codename("PRIMARY")) - ("name", "sol")("description", "") - ("initial_chain_amount", 1'000'000'000'000ULL)("initial_wire_amount", 1'000'000'000'000ULL) - ("source_token_precision", 9u)("connector_weight_bps", 5000u)("is_private", false)("owner", name{}) ) ); - // No winning underwriter, so the whole network fee lands in the rewards bucket - // — the quantity payepoch drains and must hand to the batch operator. - BOOST_REQUIRE_EQUAL( success(), push_reserv_action(UWRIT, "applyswap"_n, mvo() - ("src_chain_code", codename("ETH"))("src_token_code", codename("ETH"))("src_reserve_code", codename("PRIMARY")) - ("src_amount", 1'000'000'000ULL) - ("dst_chain_code", codename("SOLANA"))("dst_token_code", codename("SOL"))("dst_reserve_code", codename("PRIMARY")) - ("dst_amount", 100'000'000ULL)("underwriter", name{}) ) ); - - const int64_t fee_total = reward_balance(); - BOOST_REQUIRE_GT( fee_total, 0 ); + const int64_t fee_total = seed_reserv_reward_bucket(); // --- A one-member rotation group, ACTIVE in opreg --- // Bootstrapped so the ACTIVE flip bypasses the collateral gate (see @@ -3775,10 +4038,13 @@ BOOST_FIXTURE_TEST_CASE( payepoch_pays_swap_fee_to_active_batch_operator, sysio_ // make. Exact value, not merely positive: a fee that leaked into the producer // pool or was double-counted would still be > 0 here. BOOST_REQUIRE_EQUAL( log["fee_distributed"].as(), fee_total ); + BOOST_REQUIRE(log["batch_history_complete"].as_bool()); + BOOST_REQUIRE_EQUAL(log["batch_emission_retained"].as(), int64_t(0)); + BOOST_REQUIRE_EQUAL(log["batch_fee_retained"].as(), int64_t(0)); // Bucket swept, and the fee is NOT charged against the emission curve — // total_distributed moves by the EMISSION only, excluding fee_total. - BOOST_REQUIRE_EQUAL( reward_balance(), 0 ); + BOOST_REQUIRE_EQUAL( reserv_reward_balance(), 0 ); const int64_t capex = log["capex_amount"].as(); const int64_t gov = log["governance_amount"].as(); const int64_t t5_after = get_t5_state()["total_distributed"].as(); @@ -3800,48 +4066,10 @@ BOOST_FIXTURE_TEST_CASE( payepoch_pays_swap_fee_to_active_batch_operator, sysio_ // zero fee makes the fee half of the bug invisible. BOOST_FIXTURE_TEST_CASE( cadence_drop_midperiod_does_not_multiply_batch_fee_payout, sysio_emissions_tester ) try { - const account_name RESERV = "sysio.reserv"_n; - const account_name UWRIT = "sysio.uwrit"_n; const account_name BATCH_OP = "batchopb"_n; create_t5_holding_accounts(); - deploy_reserv(); - - abi_serializer reserv_ser; - { - const auto* a = control->find_account_metadata( RESERV ); - BOOST_REQUIRE( a != nullptr ); - abi_def d; - BOOST_REQUIRE_EQUAL( abi_serializer::to_abi(a->abi, d), true ); - reserv_ser.set_abi( d, abi_serializer::create_yield_function(abi_serializer_max_time) ); - } - auto codename = [](std::string_view s) { return mvo()("value", fc::slug_name{s}.value); }; - auto reward_balance = [&]() -> int64_t { - auto data = get_row_by_account(RESERV, RESERV, "rewardbkt"_n, "rewardbkt"_n); - if (data.empty()) return 0; - auto v = reserv_ser.binary_to_variant("rewards_bucket", data, - abi_serializer::create_yield_function(abi_serializer_max_time)); - return static_cast(v["balance"].as_uint64()); - }; - - BOOST_REQUIRE_EQUAL( success(), push_reserv_action(RESERV, "regreserve"_n, mvo() - ("chain_code", codename("ETH"))("token_code", codename("ETH"))("reserve_code", codename("PRIMARY")) - ("name", "eth")("description", "") - ("initial_chain_amount", 1'000'000'000'000ULL)("initial_wire_amount", 1'000'000'000'000ULL) - ("source_token_precision", 9u)("connector_weight_bps", 5000u)("is_private", false)("owner", name{}) ) ); - BOOST_REQUIRE_EQUAL( success(), push_reserv_action(RESERV, "regreserve"_n, mvo() - ("chain_code", codename("SOLANA"))("token_code", codename("SOL"))("reserve_code", codename("PRIMARY")) - ("name", "sol")("description", "") - ("initial_chain_amount", 1'000'000'000'000ULL)("initial_wire_amount", 1'000'000'000'000ULL) - ("source_token_precision", 9u)("connector_weight_bps", 5000u)("is_private", false)("owner", name{}) ) ); - BOOST_REQUIRE_EQUAL( success(), push_reserv_action(UWRIT, "applyswap"_n, mvo() - ("src_chain_code", codename("ETH"))("src_token_code", codename("ETH"))("src_reserve_code", codename("PRIMARY")) - ("src_amount", 1'000'000'000ULL) - ("dst_chain_code", codename("SOLANA"))("dst_token_code", codename("SOL"))("dst_reserve_code", codename("PRIMARY")) - ("dst_amount", 100'000'000ULL)("underwriter", name{}) ) ); - - const int64_t fee_total = reward_balance(); - BOOST_REQUIRE_GT( fee_total, 0 ); + const int64_t fee_total = seed_reserv_reward_bucket(); create_accounts( { BATCH_OP }, false, false, false, true ); BOOST_REQUIRE_EQUAL( success(), @@ -3885,7 +4113,7 @@ BOOST_FIXTURE_TEST_CASE( cadence_drop_midperiod_does_not_multiply_batch_fee_payo // The load-bearing assertion: the fee is distributed ONCE. Under the old // divisor this was 2 * fee_total, with the surplus drawn from the treasury. BOOST_REQUIRE_EQUAL( log["fee_distributed"].as(), fee_total ); - BOOST_REQUIRE_EQUAL( reward_balance(), 0 ); + BOOST_REQUIRE_EQUAL( reserv_reward_balance(), 0 ); // And the emission side is not double-paid either. const int64_t capex = log["capex_amount"].as(); @@ -4656,6 +4884,31 @@ BOOST_FIXTURE_TEST_CASE( epochlog_prunes_past_retention_cap, sysio_emissions_tes BOOST_REQUIRE( !get_epoch_log(5).is_null() ); } FC_LOG_AND_RETHROW() +BOOST_FIXTURE_TEST_CASE( epochlog_retention_counts_payment_rows_not_epoch_distance, + sysio_emissions_tester ) try { + // With cadence > 1, pay-epoch indexes are spaced apart. Retention is a row + // count, so an index-distance calculation would prematurely prune rows. + create_t5_holding_accounts(); + auto cfg = default_emit_cfg(uint16_t(2), ANNUAL_MAX_EMISSION, uint32_t(3)); + BOOST_REQUIRE_EQUAL(success(), setemitcfg(config::system_account_name, cfg)); + BOOST_REQUIRE_EQUAL(success(), initt5(config::system_account_name, tpsec(head_secs()))); + + for (const uint32_t epoch_index : {1u, 3u, 5u, 7u, 9u}) { + BOOST_REQUIRE_EQUAL(success(), push_system_action(EPOCH, "accrueepoch"_n, mvo() + ("epoch_index", epoch_index)("batch_group_index", 0)("per_epoch_emission", int64_t(1)))); + BOOST_REQUIRE_EQUAL(success(), push_system_action(EPOCH, "payepoch"_n, mvo() + ("epoch_index", epoch_index) + ("batch_op_groups", vector>{}) + ("period_emission", int64_t(1)))); + } + + BOOST_REQUIRE(get_epoch_log(1).is_null()); + BOOST_REQUIRE(get_epoch_log(3).is_null()); + BOOST_REQUIRE(!get_epoch_log(5).is_null()); + BOOST_REQUIRE(!get_epoch_log(7).is_null()); + BOOST_REQUIRE(!get_epoch_log(9).is_null()); +} FC_LOG_AND_RETHROW() + // --------------------------------------------------------------------------- // pay_cadence_epochs > 1 (period-based pay) // --------------------------------------------------------------------------- @@ -4713,6 +4966,69 @@ BOOST_FIXTURE_TEST_CASE( pay_cadence_2_pays_every_other_epoch, sysio_emissions_t } } FC_LOG_AND_RETHROW() +// WNS-13 / WIRE-343: advance() slides the schedule before it queues accrual, +// so the position-based counter cannot identify the roster that accrued a past +// epoch. A cadence-two period must split the batch pool between its two +// historical rosters, and clearing that history must make the next period work +// independently as well. +BOOST_FIXTURE_TEST_CASE( pay_cadence_rotating_batch_rosters_receive_their_own_epochs, + sysio_emissions_tester ) try { + const account_name BATCH_OP_A = "batchopa"_n; + const account_name BATCH_OP_B = "batchopb"_n; + + create_t5_holding_accounts(); + create_accounts({ BATCH_OP_A, BATCH_OP_B }, false, false, false, true); + BOOST_REQUIRE_EQUAL( success(), + register_operator(BATCH_OP_A, OperatorType::OPERATOR_TYPE_BATCH, /*is_bootstrapped*/true) ); + BOOST_REQUIRE_EQUAL( success(), + register_operator(BATCH_OP_B, OperatorType::OPERATOR_TYPE_BATCH, /*is_bootstrapped*/true) ); + + // Two one-member groups rotate on every advance. The scheduler chooses the + // front group only after it has shifted the window, which is exactly the + // positional-identity loss this regression covers. + BOOST_REQUIRE_EQUAL( success(), init_epoch_state(60, /*operators_per_epoch*/1, + /*batch_op_groups_count*/2) ); + produce_blocks(1); + BOOST_REQUIRE_EQUAL( success(), push_epoch_action(EPOCH, "schbatchgps"_n, mvo()) ); + + BOOST_REQUIRE_EQUAL( success(), + setemitcfg_with_cadence(config::system_account_name, uint16_t(2)) ); + const uint32_t start = head_secs() - ONE_EPOCH - 1; + BOOST_REQUIRE_EQUAL( success(), initt5(config::system_account_name, tpsec(start)) ); + + // Epoch 1 is the shortened genesis pay period. Claim it before comparing + // the two full cadence-two periods below. + BOOST_REQUIRE_EQUAL( success(), advance_epoch_state() ); + const int64_t a_before = get_wire_balance_paid(BATCH_OP_A).get_amount(); + const int64_t b_before = get_wire_balance_paid(BATCH_OP_B).get_amount(); + + // Epochs 2 and 3 are the first full period; each roster is active once. + produce_blocks(130); + BOOST_REQUIRE_EQUAL( success(), advance_epoch_state() ); // epoch 2, non-pay + produce_blocks(130); + BOOST_REQUIRE_EQUAL( success(), advance_epoch_state() ); // epoch 3, pay + const int64_t a_first = get_wire_balance_paid(BATCH_OP_A).get_amount() - a_before; + const int64_t b_first = get_wire_balance_paid(BATCH_OP_B).get_amount() - b_before; + BOOST_REQUIRE_GT(a_first, 0); + BOOST_REQUIRE_GT(b_first, 0); + BOOST_REQUIRE_EQUAL(a_first, b_first); + + // A second period proves payepoch cleared the consumed history. If rows + // from epochs 2-3 survived, the current period's history is malformed and + // the compatibility fallback would again pay only the boundary roster. + const int64_t a_after_first = get_wire_balance(BATCH_OP_A).get_amount(); + const int64_t b_after_first = get_wire_balance(BATCH_OP_B).get_amount(); + produce_blocks(130); + BOOST_REQUIRE_EQUAL( success(), advance_epoch_state() ); // epoch 4, non-pay + produce_blocks(130); + BOOST_REQUIRE_EQUAL( success(), advance_epoch_state() ); // epoch 5, pay + const int64_t a_second = get_wire_balance_paid(BATCH_OP_A).get_amount() - a_after_first; + const int64_t b_second = get_wire_balance_paid(BATCH_OP_B).get_amount() - b_after_first; + BOOST_REQUIRE_GT(a_second, 0); + BOOST_REQUIRE_GT(b_second, 0); + BOOST_REQUIRE_EQUAL(a_second, b_second); +} FC_LOG_AND_RETHROW() + BOOST_FIXTURE_TEST_CASE( pay_cadence_pending_accumulates_then_drains, sysio_emissions_tester ) try { // Cadence=3 under period_start_epoch=0: pay-epoch condition is target >= 2, // so target=1 (genesis) is NON-pay, target=2 is pay, target=3..4 non-pay, diff --git a/docs/contract-upgrade-order.md b/docs/contract-upgrade-order.md index f5661dc7bf..458f14a7d9 100644 --- a/docs/contract-upgrade-order.md +++ b/docs/contract-upgrade-order.md @@ -126,7 +126,7 @@ Everything `sysio.epoch::advance` inlines, directly: | `sysio.opreg` | `recorddel`, `termcheck`, `flushwtdw` | | `sysio.chalg` | `slashop` | | `sysio.msgch` | `queueout`, `buildenv` | -| `sysio` | `accrueepoch`, `payepoch` | +| `sysio` | `accrueepoch`, `rcrdbatch`, `payepoch` | Those callees inline further (`drainfwq` → `sysio.reserv::refundwire`, `termcheck` → the `sysio.opreg` remit path, `payepoch` → `sysio.token::transfer`), @@ -137,6 +137,38 @@ Independently of inlines, the emissions readiness gate in `sysio.epoch` **reads* `sysio.system`'s `emitcfg`, `t5state` and `payclaimtot`, and `sysio.token`'s `accounts`. +### WIRE-343 pre-launch activation + +Activate WIRE-343 in one quiesced maintenance window, with no +`sysio.epoch::advance` between contract deployments. This remains the normal +pre-launch procedure because a complete immutable roster history is required +to credit the batch-operator payout: + +1. Set `pay_cadence_epochs` to a value in `[1, 10]` that also satisfies + `pay_cadence_epochs * operators_per_epoch <= 100` before the window. At the + intended 21-operator topology, the maximum is 4. An older stored value + outside the accepted bounds is read with an effective clamp until rewritten. +2. Prefer a completed payment period and verify that `t5state` has + `pending_emission_amount == 0`, all `batch_group_epochs` counters are zero, + and `batchepochs` is empty. +3. Quiesce epoch advancement and deploy the new `sysio.system` and + `sysio.epoch` contracts together before allowing the next `advance`. + +The deployed contracts also have a bounded recovery path for an accidental +mixed or mid-period deployment. `rcrdbatch` prunes the exact oldest retained +roster, and `payepoch` removes at most 20 history rows per payment, so even a +malformed gapped table cannot turn cleanup into an unbounded `advance`. +If `payepoch` sees missing, stale, non-contiguous, or over-cap history, it +retains that period's batch-emission slice in the treasury, leaves the swap-fee +bucket in `sysio.reserv` for the next complete period, records the retained +emission and incomplete-history status in `epochlog`, and drains stale history +monotonically before resuming batch payouts. Producer, capex, and governance +processing still completes. The runtime also shortens a legacy cadence when +necessary to keep batch credits at or below 100 per payout. This is a recovery +path, not a replacement for the normal quiesced deployment. Do not downgrade +while `batchepochs` is non-empty. T5 must also be initialized before its first +successful epoch advance. + ## The two rules for future changes 1. **A contract that gains an action `advance` inlines deploys BEFORE